package httpapi_test import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/httpapi" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity" ) type passwordContract struct { Version int `json:"version"` Path string `json:"path"` Method string `json:"method"` LocalSessionTTLSeconds int64 `json:"localSessionTtlSeconds"` InputCases []struct { Name string `json:"name"` Body map[string]any `json:"body"` ExpectedRedirect string `json:"expectedRedirect"` } `json:"inputCases"` SafeNextCases []struct { Input *string `json:"input"` Expected string `json:"expected"` } `json:"safeNextCases"` Success struct { TopLevelKeys []string `json:"topLevelKeys"` PublicUserKeys []string `json:"publicUserKeys"` ForbiddenSerializedKeys []string `json:"forbiddenSerializedKeys"` } `json:"success"` Errors struct { InvalidInput fixtureError `json:"invalidInput"` InvalidCredentials fixtureError `json:"invalidCredentials"` DisabledAccount fixtureError `json:"disabledAccount"` DisabledOrganization fixtureError `json:"disabledOrganization"` LockedAccount fixtureError `json:"lockedAccount"` RateLimited fixtureError `json:"rateLimited"` Unconfigured fixtureError `json:"unconfigured"` } `json:"errors"` RateLimit struct { AttemptsPerIP int `json:"attemptsPerIp"` WindowSeconds int `json:"windowSeconds"` } `json:"rateLimit"` } type fixtureError struct { Status int `json:"status"` Body map[string]string `json:"body"` } type passwordIssuerStub struct { session identity.Session err error commands []identity.LoginCommand } func (stub *passwordIssuerStub) Login(_ context.Context, command identity.LoginCommand) (identity.Session, error) { stub.commands = append(stub.commands, command) return stub.session, stub.err } func TestAuthPasswordConsumesSharedInputAndRedirectContract(t *testing.T) { contract := loadPasswordContract(t) for _, test := range contract.InputCases { t.Run(test.Name, func(t *testing.T) { issuer := &passwordIssuerStub{session: fixtureLoginSession()} handler := newPasswordHandler(t, issuer) body, _ := json.Marshal(test.Body) response := servePassword(handler, contract.Path, body, "198.51.100.1") if response.Code != http.StatusOK { t.Fatalf("status = %d body=%q, want 200", response.Code, response.Body.String()) } if len(issuer.commands) != 1 || issuer.commands[0].Phone != "13800138000" || issuer.commands[0].Password != "TestPass123" { t.Fatalf("commands = %#v", issuer.commands) } var payload map[string]any if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { t.Fatal(err) } if payload["redirectTo"] != test.ExpectedRedirect || payload["authMode"] != string(identity.AuthModeUser) || payload["ok"] != true { t.Fatalf("payload = %#v", payload) } assertKeySet(t, payload, contract.Success.TopLevelKeys) assertKeySet(t, payload["user"].(map[string]any), contract.Success.PublicUserKeys) for _, forbidden := range contract.Success.ForbiddenSerializedKeys { if strings.Contains(response.Body.String(), `"`+forbidden+`"`) { t.Fatalf("response leaked forbidden key %q: %s", forbidden, response.Body.String()) } } }) } } func TestAuthPasswordConsumesSharedSafeNextContract(t *testing.T) { contract := loadPasswordContract(t) for _, test := range contract.SafeNextCases { name := "null" var next any if test.Input != nil { name, next = *test.Input, *test.Input } t.Run(name, func(t *testing.T) { issuer := &passwordIssuerStub{session: fixtureLoginSession()} handler := newPasswordHandler(t, issuer) body, _ := json.Marshal(map[string]any{"phone": "13800138000", "password": "TestPass123", "next": next}) response := servePassword(handler, contract.Path, body, "198.51.100.2") var payload struct { RedirectTo string `json:"redirectTo"` } if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { t.Fatal(err) } if response.Code != http.StatusOK || payload.RedirectTo != test.Expected { t.Fatalf("response = %d %q, redirect=%q want %q", response.Code, response.Body.String(), payload.RedirectTo, test.Expected) } }) } } func TestAuthPasswordConsumesSharedErrorContract(t *testing.T) { contract := loadPasswordContract(t) tests := []struct { name string reason identity.PasswordLoginFailure fixture fixtureError }{ {"invalid input", identity.LoginFailureInvalidInput, contract.Errors.InvalidInput}, {"credentials", identity.LoginFailureInvalidCredentials, contract.Errors.InvalidCredentials}, {"disabled", identity.LoginFailureAccountDisabled, contract.Errors.DisabledAccount}, {"invalid role", identity.LoginFailureInvalidRole, contract.Errors.DisabledOrganization}, {"organization required", identity.LoginFailureOrganizationRequired, contract.Errors.DisabledOrganization}, {"organization inactive", identity.LoginFailureOrganizationNotActive, contract.Errors.DisabledOrganization}, {"locked", identity.LoginFailureAccountLocked, contract.Errors.LockedAccount}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { issuer := &passwordIssuerStub{err: identity.NewPasswordLoginError(test.reason)} response := servePassword(newPasswordHandler(t, issuer), contract.Path, []byte(`{"phone":"13800138000","password":"bad"}`), "198.51.100.3") assertErrorFixture(t, response, test.fixture) }) } unconfiguredIssuer := &passwordIssuerStub{} response := servePassword(newPasswordHandlerWithConfig(t, httpapi.PasswordAuthConfig{}, unconfiguredIssuer), contract.Path, []byte(`{}`), "198.51.100.4") assertErrorFixture(t, response, contract.Errors.Unconfigured) if len(unconfiguredIssuer.commands) != 0 { t.Fatalf("unconfigured issuer calls = %d, want 0", len(unconfiguredIssuer.commands)) } } func TestAuthPasswordNullAndNonObjectJSONAreSafeInvalidInput(t *testing.T) { contract := loadPasswordContract(t) for _, body := range []string{"null", `[]`, `{not-json`} { issuer := &passwordIssuerStub{err: identity.NewPasswordLoginError(identity.LoginFailureInvalidInput)} response := servePassword(newPasswordHandler(t, issuer), contract.Path, []byte(body), "198.51.100.7") assertErrorFixture(t, response, contract.Errors.InvalidInput) } } func TestAuthPasswordRateLimitUsesForwardedIPAndClearsOnSuccess(t *testing.T) { contract := loadPasswordContract(t) issuer := &passwordIssuerStub{err: identity.NewPasswordLoginError(identity.LoginFailureInvalidCredentials)} handler := newPasswordHandler(t, issuer) requestBody := []byte(`{"phone":"13800138000","password":"bad"}`) for index := 0; index < contract.RateLimit.AttemptsPerIP; index++ { response := servePasswordWithHeaders(handler, contract.Path, requestBody, map[string]string{ "X-Forwarded-For": "203.0.113.9, 10.0.0.4", "X-Real-IP": "192.0.2.20", }) if response.Code != contract.Errors.InvalidCredentials.Status { t.Fatalf("attempt %d status = %d", index+1, response.Code) } } limited := servePasswordWithHeaders(handler, contract.Path, requestBody, map[string]string{"X-Forwarded-For": "203.0.113.9"}) assertErrorFixture(t, limited, contract.Errors.RateLimited) if len(issuer.commands) != contract.RateLimit.AttemptsPerIP { t.Fatalf("issuer calls = %d, want %d", len(issuer.commands), contract.RateLimit.AttemptsPerIP) } issuer.err = nil issuer.session = fixtureLoginSession() success := servePasswordWithHeaders(handler, contract.Path, []byte(`{"phone":"13800138000","password":"ok"}`), map[string]string{"X-Forwarded-For": "203.0.113.10"}) if success.Code != http.StatusOK { t.Fatalf("success status = %d", success.Code) } issuer.err = identity.NewPasswordLoginError(identity.LoginFailureInvalidCredentials) afterSuccess := servePasswordWithHeaders(handler, contract.Path, requestBody, map[string]string{"X-Forwarded-For": "203.0.113.10"}) if afterSuccess.Code != contract.Errors.InvalidCredentials.Status { t.Fatalf("after success status = %d", afterSuccess.Code) } } func TestAuthPasswordWritesTwentyFixtureCookiesAfterSuccessfulSign(t *testing.T) { contract := loadPasswordContract(t) cookie := loadPasswordSessionCookieContract(t) issuer := &passwordIssuerStub{session: fixtureLoginSession()} config := httpapi.PasswordAuthConfig{Configured: true, SessionSecret: cookie.Secret, PublicBaseURL: "https://app.example.test"} response := servePassword(newPasswordHandlerWithConfig(t, config, issuer), contract.Path, []byte(`{"phone":"13800138000","password":"ok"}`), "198.51.100.5") cookies := response.Result().Cookies() if response.Code != http.StatusOK || len(cookies) != cookie.Cookie.MaxChunks { t.Fatalf("status/cookies = %d/%d, want 200/%d", response.Code, len(cookies), cookie.Cookie.MaxChunks) } for index, got := range cookies { if got.Name != cookie.Cookie.ChunkNames[index] || got.Path != cookie.Cookie.Attributes.Path || got.HttpOnly != cookie.Cookie.Attributes.HTTPOnly || got.SameSite != http.SameSiteLaxMode || !got.Secure { t.Errorf("cookie %d = %#v", index, got) } if index == 0 { if got.Value == "" || got.Expires.Unix() != fixtureLoginSession().ExpiresAt { t.Errorf("session cookie = %#v", got) } } else if got.Value != cookie.Cookie.Clear.Value || got.MaxAge != -1 { t.Errorf("clear cookie %d = %#v", index, got) } } } func TestAuthPasswordInfrastructureFailureIsGenericAndWritesNoCookie(t *testing.T) { issuer := &passwordIssuerStub{err: errors.New("postgres password=secret")} response := servePassword(newPasswordHandler(t, issuer), "/api/auth/password", []byte(`{"phone":"13800138000","password":"ok"}`), "198.51.100.6") if response.Code != http.StatusInternalServerError || response.Body.String() != `{"error":"服务器内部错误。"}` || len(response.Result().Cookies()) != 0 { t.Fatalf("response = %d %q cookies=%d", response.Code, response.Body.String(), len(response.Result().Cookies())) } } func TestAuthPasswordMethodAndPathContract(t *testing.T) { contract := loadPasswordContract(t) handler := newPasswordHandler(t, &passwordIssuerStub{session: fixtureLoginSession()}) tests := []struct { method, path string status int }{ {http.MethodGet, contract.Path, http.StatusMethodNotAllowed}, {http.MethodPut, contract.Path, http.StatusMethodNotAllowed}, {http.MethodOptions, contract.Path, http.StatusNoContent}, {http.MethodPost, contract.Path + "/", http.StatusNotFound}, } for _, test := range tests { response := httptest.NewRecorder() handler.ServeHTTP(response, httptest.NewRequest(test.method, test.path, nil)) if response.Code != test.status || response.Body.Len() != 0 { t.Errorf("%s %s = %d %q", test.method, test.path, response.Code, response.Body.String()) } } } func newPasswordHandler(t *testing.T, issuer httpapi.PasswordSessionIssuer) http.Handler { t.Helper() cookie := loadPasswordSessionCookieContract(t) return newPasswordHandlerWithConfig(t, httpapi.PasswordAuthConfig{Configured: true, SessionSecret: cookie.Secret}, issuer) } func newPasswordHandlerWithConfig(t *testing.T, config httpapi.PasswordAuthConfig, issuer httpapi.PasswordSessionIssuer) http.Handler { t.Helper() handler, err := httpapi.NewAuthPasswordHandler(config, issuer) if err != nil { t.Fatalf("NewAuthPasswordHandler() error = %v", err) } return handler } func servePassword(handler http.Handler, path string, body []byte, ip string) *httptest.ResponseRecorder { return servePasswordWithHeaders(handler, path, body, map[string]string{"X-Real-IP": ip}) } func servePasswordWithHeaders(handler http.Handler, path string, body []byte, headers map[string]string) *httptest.ResponseRecorder { request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(string(body))) request.Header.Set("Content-Type", "application/json") for key, value := range headers { request.Header.Set(key, value) } response := httptest.NewRecorder() handler.ServeHTTP(response, request) return response } func fixtureLoginSession() identity.Session { version := 7 return identity.Session{Version: 1, AuthMode: identity.AuthModeUser, IssuedAt: 1_700_000_000, ExpiresAt: 1_700_086_400, SessionVersion: &version, User: identity.User{ ID: "account-1", Subject: "account-1", Username: "13800138000", Phone: "13800138000", DisplayName: "张三", ClientID: "platform", OrganizationID: "org-1", OrganizationName: "组织一", Role: "user", Status: "active", Authorities: []string{"ROLE_USER"}, Scope: []string{}, }} } func loadPasswordContract(t *testing.T) passwordContract { t.Helper() var fixture passwordContract loadHTTPFixture(t, "password-login-v1.json", &fixture) return fixture } type passwordSessionFixture struct { Secret string `json:"secret"` Cookie struct { MaxChunks int `json:"maxChunks"` ChunkNames []string `json:"chunkNames"` Attributes struct { HTTPOnly bool `json:"httpOnly"` SameSite string `json:"sameSite"` Path string `json:"path"` } `json:"attributes"` Clear struct { Value string `json:"value"` } `json:"clear"` } `json:"cookie"` } func loadPasswordSessionCookieContract(t *testing.T) passwordSessionFixture { t.Helper() var fixture passwordSessionFixture loadHTTPFixture(t, "session-cookie-v1.json", &fixture) return fixture } func loadHTTPFixture(t *testing.T, name string, target any) { t.Helper() data, err := os.ReadFile(filepath.Join("..", "..", "..", "contracts", "auth", name)) if err != nil { t.Fatalf("read %s: %v", name, err) } if err := json.Unmarshal(data, target); err != nil { t.Fatalf("decode %s: %v", name, err) } } func assertKeySet(t *testing.T, got map[string]any, want []string) { t.Helper() keys := make([]string, 0, len(got)) for key := range got { keys = append(keys, key) } if len(keys) != len(want) { t.Fatalf("keys = %v, want %v", keys, want) } for _, key := range want { if _, ok := got[key]; !ok { t.Fatalf("missing key %q in %v", key, keys) } } } func assertErrorFixture(t *testing.T, response *httptest.ResponseRecorder, fixture fixtureError) { t.Helper() want, _ := json.Marshal(fixture.Body) if response.Code != fixture.Status || strings.TrimSpace(response.Body.String()) != string(want) { t.Fatalf("response = %d %q, want %d %s", response.Code, response.Body.String(), fixture.Status, want) } }