package controllers import ( "encoding/json" "net/http" "net/http/cookiejar" "net/http/httptest" "net/url" "strings" "testing" "git.pcux.cn/xuans/Smail/db" "git.pcux.cn/xuans/Smail/dto/response" "git.pcux.cn/xuans/Smail/models" "git.pcux.cn/xuans/Smail/services/access" "git.pcux.cn/xuans/Smail/services/credentials" "git.pcux.cn/xuans/Smail/services/externalemail" "git.pcux.cn/xuans/Smail/services/verification" appcontext "git.pcux.cn/xuans/Smail/utils/context" ) // 真实控制器约束对照:输错后绑定仍待验证,重新绑定失败,但原挑战可继续验证。 func TestAuditPendingExternalBindingRequiresExistingChallenge(t *testing.T) { _, _, engine, user, _ := newPublicSecurityFixture(t) previousDB := db.Instance db.Instance = engine t.Cleanup(func() { db.Instance = previousDB }) if err := engine.Sync2(new(models.UserEmail)); err != nil { t.Fatal(err) } previous := securityRuntime t.Cleanup(func() { securityRuntime = previous }) vault := externalemail.NewVaultWithKeys([]byte("01234567890123456789012345678901"), []byte("abcdefabcdefabcdefabcdefabcdefab")) repo, err := externalemail.NewRepository(engine, vault) if err != nil { t.Fatal(err) } store, err := verification.NewStore(32) if err != nil { t.Fatal(err) } var issued verification.Challenge var deliveredCode string service, err := verification.NewService(store, func(challenge verification.Challenge, code string) error { issued, deliveredCode = challenge, code return nil }, nil) if err != nil { t.Fatal(err) } securityRuntime.external, securityRuntime.verification = repo, service ctx := &appcontext.Context{UserID: user.ID, UserAccount: user.Account, Role: access.RoleUser, RecentAuth: true, Lang: "zhCn"} invoke := func(handler func(*appcontext.Context, http.ResponseWriter, *http.Request), input any) response.Response { t.Helper() body, err := json.Marshal(input) if err != nil { t.Fatal(err) } recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "https://example.com/api/v1/security/external-email", strings.NewReader(string(body))) request.RemoteAddr = "192.0.2.10:12345" handler(ctx, recorder, request) var result response.Response if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil { t.Fatal(err) } return result } bind := map[string]any{"email": "binding@example.com", "type": "backup"} if result := invoke(BindExternalEmail, bind); result.ErrorNo != 0 || issued.ID == "" || deliveredCode == "" { t.Fatalf("首次绑定前置条件未成立: error=%d", result.ErrorNo) } check := map[string]any{"id": issued.ID, "binding_id": issued.ResourceID, "email": "binding@example.com", "code": "incorrect"} wrong := invoke(VerifyExternalEmail, check) if wrong.ErrorNo == 0 { t.Fatal("错误验证码意外通过") } var stored models.ExternalEmail if has, err := engine.ID(issued.ResourceID).Get(&stored); err != nil || !has || stored.Status != models.ExternalEmailStatusPending { t.Fatalf("错误验证码后绑定状态异常: found=%v err=%v status=%s", has, err, stored.Status) } retry := invoke(BindExternalEmail, bind) if retry.ErrorNo == 0 { t.Fatal("重复绑定应被真实仓储拒绝,前端必须恢复原挑战") } check["code"] = deliveredCode continued := invoke(VerifyExternalEmail, check) if continued.ErrorNo != 0 { t.Fatalf("原挑战正确验证码未通过: error=%d", continued.ErrorNo) } t.Logf("after_wrong_status=%s retry_bind_error=%d continued_verify_error=%d", stored.Status, retry.ErrorNo, continued.ErrorNo) } // 使用临时数据库和真实控制器,正向验证授权器允许的角色调整能够到达持久化。 func TestAuditAuthorizedRoleChangeMustSucceed(t *testing.T) { _, _, engine, user, super := newPublicSecurityFixture(t) previousDB, previousRuntime := db.Instance, securityRuntime db.Instance = engine t.Cleanup(func() { db.Instance = previousDB securityRuntime = previousRuntime }) credentialService, err := credentials.NewService(engine) if err != nil { t.Fatal(err) } securityRuntime.credentials = credentialService if _, err := credentialService.GenerateRecoveryCodes(user.ID, 5); err != nil { t.Fatal(err) } request := access.Request{ ActorRole: access.RoleSuperAdmin, Capability: access.CapabilityRoleManage, TargetRole: access.RoleUser, NewRole: access.RoleAdmin, RecentAuth: true, TargetRecoveryConfigured: true, } if err := access.Authorize(request); err != nil { t.Fatalf("角色变更授权前置条件未成立: %v", err) } ctx := &appcontext.Context{ UserID: super.ID, UserAccount: super.Account, Role: access.RoleSuperAdmin, RecentAuth: true, Lang: "zhCn", } result := editUserRequest(t, ctx, `{"id":`+itoa(user.ID)+`,"role":"admin","disabled":0}`) var stored models.User if found, err := engine.ID(user.ID).Get(&stored); err != nil || !found { t.Fatalf("读取目标角色失败: found=%v err=%v", found, err) } t.Logf("policy_allows=true controller_error=%d stored_role=%s", result, stored.EffectiveRole()) if result != 0 || stored.EffectiveRole() != access.RoleAdmin { t.Fatal("符合近期认证和目标恢复码条件的超级管理员仍无法修改用户角色") } if stored.SessionVersion <= user.SessionVersion || stored.SecurityVersion <= user.SecurityVersion { t.Fatal("纯角色变更必须同时提升会话版本和安全版本") } } // 使用真实 TLS HTTP 客户端及标准 CookieJar 检查浏览器同样遵循的 Path 匹配。 // 只核对 Cookie 是否随请求携带,不把隔离发送器当成真实邮件投递验收。 func TestAuditRecoveryCookieMustReachVersionedAPI(t *testing.T) { for _, prefix := range []string{"/api/security/recovery", "/api/v1/security/recovery"} { t.Run(prefix, func(t *testing.T) { enablePublicRecoveryForTest(t) recover, _, _, _, _ := newPublicSecurityFixture(t) previous := securityRuntime securityRuntime.recovery = recover t.Cleanup(func() { securityRuntime = previous }) server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, "/begin") { BeginPasswordRecovery(&appcontext.Context{}, w, r) return } _, ok := recoveryBrowserContext(r, w, false) _ = json.NewEncoder(w).Encode(map[string]bool{"context_received": ok}) })) defer server.Close() client := server.Client() jar, err := cookiejar.New(nil) if err != nil { t.Fatal(err) } client.Jar = jar begin, err := client.Post(server.URL+prefix+"/begin", "application/json", strings.NewReader(`{"identifier":"alice","email":"alice@example.com"}`)) if err != nil { t.Fatal(err) } var result response.Response err = json.NewDecoder(begin.Body).Decode(&result) _ = begin.Body.Close() if err != nil || result.ErrorNo != 0 { t.Fatalf("恢复申请前置条件失败: code=%d err=%v", result.ErrorNo, err) } cookiePath := "" for _, cookie := range begin.Cookies() { if cookie.Name == recoveryContextCookieName { cookiePath = cookie.Path } } if cookiePath == "" { t.Fatal("恢复申请没有签发浏览器上下文 Cookie") } target, err := url.Parse(server.URL + prefix + "/verify") if err != nil { t.Fatal(err) } verify, err := client.Get(target.String()) if err != nil { t.Fatal(err) } var received struct { ContextReceived bool `json:"context_received"` } err = json.NewDecoder(verify.Body).Decode(&received) _ = verify.Body.Close() if err != nil { t.Fatal(err) } t.Logf("request_prefix=%s cookie_path=%s sent_cookie_count=%d context_received=%v", prefix, cookiePath, len(jar.Cookies(target)), received.ContextReceived) if !received.ContextReceived { t.Fatal("恢复上下文 Cookie 的路径不覆盖前端使用的版本化接口,后续验证无法获取上下文") } }) } }