package httpapi_test import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "os" "path/filepath" "runtime" "strings" "testing" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/httpapi" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity" ) func TestAuthMeReturnsAnonymousPublicProjectionWithoutCookie(t *testing.T) { contract := loadCurrentSessionContract(t) resolver := &sessionResolverStub{} handler := newAuthMeHandler(t, authStateFromFixture(t, contract.Responses.Anonymous), resolver) recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, contract.Path, nil)) assertFixtureJSONResponse(t, recorder, contract.Methods.GET.Status, contract.Methods.GET.ContentType, contract.Responses.Anonymous) if resolver.calls != 0 { t.Fatalf("Resolve calls = %d, want 0", resolver.calls) } } func TestAuthMeReturnsFixtureAuthenticatedPublicProjections(t *testing.T) { contract := loadCurrentSessionContract(t) tests := []struct { name string response json.RawMessage }{ {name: "authenticated user", response: contract.Responses.AuthenticatedUser}, {name: "unbound super administrator", response: contract.Responses.UnboundSuperAdministrator}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { fixture := decodeFixtureResponse(t, test.response) resolver := &sessionResolverStub{session: sessionFromFixture(t, fixture, contract.ForbiddenSessionKeys)} handler := newAuthMeHandler(t, httpapi.AuthState{Required: fixture.AuthRequired, Configured: fixture.AuthConfigured}, resolver) request := httptest.NewRequest(http.MethodGet, contract.Path, nil) request.AddCookie(&http.Cookie{Name: identity.SessionCookieName, Value: "part-0"}) request.AddCookie(&http.Cookie{Name: identity.SessionCookieName + ".1", Value: "part-1"}) recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, request) assertFixtureJSONResponse(t, recorder, contract.Methods.GET.Status, contract.Methods.GET.ContentType, test.response) assertForbiddenSessionKeysAbsent(t, recorder.Body.Bytes(), contract.ForbiddenSessionKeys) if resolver.calls != 1 || resolver.value != "part-0part-1" { t.Fatalf("Resolve calls/value = %d/%q, want 1/%q", resolver.calls, resolver.value, "part-0part-1") } }) } } func TestAuthMeAnonymousCasesDoNotLeakResolverErrors(t *testing.T) { tests := []struct { name string state httpapi.AuthState cookies []*http.Cookie resolverErr error }{ {name: "unconfigured", state: httpapi.AuthState{Required: true}, cookies: []*http.Cookie{{Name: identity.SessionCookieName, Value: "cookie"}}}, {name: "unauthenticated", state: httpapi.AuthState{Required: true, Configured: true}, cookies: []*http.Cookie{{Name: identity.SessionCookieName, Value: "cookie"}}, resolverErr: identity.ErrUnauthenticated}, {name: "over reader ceiling", state: httpapi.AuthState{Configured: true}, cookies: []*http.Cookie{{Name: identity.SessionCookieName, Value: strings.Repeat("x", identity.CookieMaxValueLength+1)}}}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { resolver := &sessionResolverStub{err: test.resolverErr} handler := newAuthMeHandler(t, test.state, resolver) request := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) for _, cookie := range test.cookies { request.AddCookie(cookie) } recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, request) assertFixtureJSONResponse(t, recorder, http.StatusOK, "application/json", json.RawMessage(`{"authenticated":false,"authRequired":`+boolJSON(test.state.Required)+`,"authConfigured":`+boolJSON(test.state.Configured)+`,"authMode":null,"user":null}`)) wantCalls := 0 if test.resolverErr != nil { wantCalls = 1 } if resolver.calls != wantCalls { t.Fatalf("Resolve calls = %d, want %d", resolver.calls, wantCalls) } }) } } func TestAuthMeDuplicateProtectedCookieUsesLastValueLikeNext(t *testing.T) { resolver := &sessionResolverStub{err: identity.ErrUnauthenticated} handler := newAuthMeHandler(t, httpapi.AuthState{Configured: true}, resolver) request := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) request.AddCookie(&http.Cookie{Name: identity.SessionCookieName, Value: "first"}) request.AddCookie(&http.Cookie{Name: identity.SessionCookieName, Value: "second"}) handler.ServeHTTP(httptest.NewRecorder(), request) if resolver.calls != 1 || resolver.value != "second" { t.Fatalf("Resolve calls/value = %d/%q, want 1/%q", resolver.calls, resolver.value, "second") } } func TestAuthMeCookieReassemblyStopsAtGapAndIgnoresChunkTwenty(t *testing.T) { resolver := &sessionResolverStub{err: identity.ErrUnauthenticated} handler := newAuthMeHandler(t, httpapi.AuthState{Configured: true}, resolver) request := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) for _, cookie := range []*http.Cookie{ {Name: identity.SessionCookieName, Value: "zero"}, {Name: identity.SessionCookieName + ".1", Value: "one"}, {Name: identity.SessionCookieName + ".3", Value: "three"}, {Name: identity.SessionCookieName + ".20", Value: "twenty"}, } { request.AddCookie(cookie) } handler.ServeHTTP(httptest.NewRecorder(), request) if resolver.value != "zeroone" { t.Fatalf("Resolve value = %q, want %q", resolver.value, "zeroone") } } func TestAuthMeAcceptsExactReaderCeilingAcrossTwentyChunks(t *testing.T) { resolver := &sessionResolverStub{err: identity.ErrUnauthenticated} handler := newAuthMeHandler(t, httpapi.AuthState{Configured: true}, resolver) request := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) for index, name := range identity.CookieNames() { request.AddCookie(&http.Cookie{Name: name, Value: strings.Repeat(string(rune('a'+index%26)), identity.CookieChunkSize)}) } recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK || resolver.calls != 1 || len(resolver.value) != identity.CookieMaxValueLength { t.Fatalf("response/calls/value length = %d/%d/%d, want 200/1/%d", recorder.Code, resolver.calls, len(resolver.value), identity.CookieMaxValueLength) } } func TestAuthMeReturnsGenericEmptyServerFailure(t *testing.T) { contract := loadCurrentSessionContract(t) resolver := &sessionResolverStub{err: errors.New("database DSN secret leaked")} handler := newAuthMeHandler(t, httpapi.AuthState{Configured: true}, resolver) request := httptest.NewRequest(http.MethodGet, contract.Path, nil) request.AddCookie(&http.Cookie{Name: identity.SessionCookieName, Value: "cookie"}) recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, request) if recorder.Code != contract.InfrastructureError.TransportStatus || recorder.Body.Len() != 0 { t.Fatalf("response = %d %q, want empty 500", recorder.Code, recorder.Body.String()) } if !contract.InfrastructureError.MustNotReturnAnonymous || contract.InfrastructureError.DirectGet != "rejects" { t.Fatalf("invalid infrastructure error fixture: %+v", contract.InfrastructureError) } if got := recorder.Header().Get("Content-Type"); got != "" { t.Fatalf("Content-Type = %q, want empty", got) } } func TestAuthMeHeadRunsSessionResolutionWithoutWritingBody(t *testing.T) { resolver := &sessionResolverStub{session: identity.Session{ AuthMode: identity.AuthModeUser, User: identity.User{ ID: "u", Subject: "u", DisplayName: "Ada", ClientID: "platform", }, }} handler := newAuthMeHandler(t, httpapi.AuthState{Configured: true}, resolver) request := httptest.NewRequest(http.MethodHead, "/api/auth/me", nil) request.AddCookie(&http.Cookie{Name: identity.SessionCookieName, Value: "cookie"}) recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK || recorder.Body.Len() != 0 || resolver.calls != 1 { t.Fatalf("response/resolver = %d %q / %d calls, want 200 empty / 1 call", recorder.Code, recorder.Body.String(), resolver.calls) } if got := recorder.Header().Get("Content-Type"); got != "application/json" { t.Fatalf("Content-Type = %q, want application/json", got) } } func TestAuthMeMethodContract(t *testing.T) { contract := loadCurrentSessionContract(t) if contract.Version != 1 { t.Fatalf("contract version = %d, want 1", contract.Version) } if contract.Methods.GET.Body != "json" || contract.Methods.HEAD.Body != "empty" || !contract.Methods.HEAD.ExecutesGet || contract.Methods.OPTIONS.Body != "empty" || contract.Methods.Unsupported.Body != "empty" { t.Fatalf("invalid method body semantics in fixture: %+v", contract.Methods) } handler := newAuthMeHandler(t, httpapi.AuthState{}, &sessionResolverStub{}) tests := []struct { method, path string status int allow string contentType string }{ {method: http.MethodHead, path: contract.Path, status: contract.Methods.HEAD.Status, contentType: contract.Methods.HEAD.ContentType}, {method: http.MethodOptions, path: contract.Path, status: contract.Methods.OPTIONS.Status, allow: contract.Methods.OPTIONS.Allow}, } for _, method := range contract.Methods.Unsupported.Methods { tests = append(tests, struct { method, path string status int allow string contentType string }{method: method, path: contract.Path, status: contract.Methods.Unsupported.Status}) } tests = append(tests, struct { method, path string status int allow string contentType string }{method: http.MethodGet, path: contract.Path + "/", status: http.StatusNotFound}) for _, test := range tests { t.Run(test.method+" "+test.path, func(t *testing.T) { recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, httptest.NewRequest(test.method, test.path, nil)) if recorder.Code != test.status || recorder.Body.Len() != 0 { t.Fatalf("response = %d %q, want %d with empty body", recorder.Code, recorder.Body.String(), test.status) } if got := recorder.Header().Get("Allow"); got != test.allow { t.Fatalf("Allow = %q, want %q", got, test.allow) } if got := recorder.Header().Get("Content-Type"); got != test.contentType { t.Fatalf("Content-Type = %q, want %q", got, test.contentType) } }) } } func TestAuthMeRequiresResolverWhenAuthenticationIsConfigured(t *testing.T) { handler, err := httpapi.NewAuthMeHandler(httpapi.AuthState{Configured: true}, nil) if err == nil || handler != nil { t.Fatalf("NewAuthMeHandler() = %#v, %v; want nil handler and error", handler, err) } } func newAuthMeHandler(t *testing.T, state httpapi.AuthState, resolver httpapi.SessionResolver) http.Handler { t.Helper() handler, err := httpapi.NewAuthMeHandler(state, resolver) if err != nil { t.Fatalf("NewAuthMeHandler: %v", err) } return handler } func assertFixtureJSONResponse(t *testing.T, recorder *httptest.ResponseRecorder, status int, contentType string, wantJSON json.RawMessage) { t.Helper() if recorder.Code != status { t.Fatalf("status = %d, want %d", recorder.Code, status) } if got := recorder.Header().Get("Content-Type"); got != contentType { t.Fatalf("Content-Type = %q, want %q", got, contentType) } var got, want any if err := json.Unmarshal(recorder.Body.Bytes(), &got); err != nil { t.Fatalf("decode response: %v", err) } if err := json.Unmarshal(wantJSON, &want); err != nil { t.Fatalf("decode expected JSON: %v", err) } gotJSON, err := json.Marshal(got) if err != nil { t.Fatalf("encode response: %v", err) } wantJSON, err = json.Marshal(want) if err != nil { t.Fatalf("encode expected response: %v", err) } if string(gotJSON) != string(wantJSON) { t.Fatalf("response = %#v, want %#v", got, want) } } func loadCurrentSessionContract(t *testing.T) currentSessionContract { t.Helper() _, filename, _, ok := runtime.Caller(0) if !ok { t.Fatal("locate auth_me_test.go") } path := filepath.Join(filepath.Dir(filename), "..", "..", "..", "contracts", "auth", "current-session-v1.json") raw, err := os.ReadFile(path) if err != nil { t.Fatalf("read current-session fixture: %v", err) } var contract currentSessionContract if err := json.Unmarshal(raw, &contract); err != nil { t.Fatalf("decode current-session fixture: %v", err) } if contract.Path == "" || len(contract.Responses.Anonymous) == 0 || len(contract.Responses.AuthenticatedUser) == 0 || len(contract.Responses.UnboundSuperAdministrator) == 0 { t.Fatal("current-session fixture is missing required path or responses") } return contract } func decodeFixtureResponse(t *testing.T, raw json.RawMessage) fixtureResponse { t.Helper() var response fixtureResponse if err := json.Unmarshal(raw, &response); err != nil { t.Fatalf("decode fixture response: %v", err) } return response } func authStateFromFixture(t *testing.T, raw json.RawMessage) httpapi.AuthState { t.Helper() response := decodeFixtureResponse(t, raw) return httpapi.AuthState{Required: response.AuthRequired, Configured: response.AuthConfigured} } func sessionFromFixture(t *testing.T, response fixtureResponse, forbiddenKeys []string) identity.Session { t.Helper() if !response.Authenticated || response.User == nil { t.Fatal("authenticated fixture response must include a user") } mode := identity.AuthMode(response.AuthMode) session := identity.Session{ Version: 1, AuthMode: mode, IssuedAt: 123, ExpiresAt: 456, AccessToken: "must-not-leak", TokenType: "Bearer", User: identity.User{ ID: response.User.ID, Subject: response.User.Subject, Username: response.User.Username, Phone: response.User.Phone, DisplayName: response.User.DisplayName, ClientID: response.User.ClientID, TenantID: response.User.TenantID, OrganizationID: response.User.OrganizationID, OrganizationName: response.User.OrganizationName, Role: response.User.Role, Status: response.User.Status, Authorities: response.User.Authorities, Scope: response.User.Scope, }, } version := 9 session.SessionVersion = &version if len(forbiddenKeys) == 0 { t.Fatal("fixture must identify forbidden session keys") } return session } func assertForbiddenSessionKeysAbsent(t *testing.T, raw []byte, forbiddenKeys []string) { t.Helper() var response map[string]json.RawMessage if err := json.Unmarshal(raw, &response); err != nil { t.Fatalf("decode response keys: %v", err) } for _, key := range forbiddenKeys { if _, found := response[key]; found { t.Errorf("forbidden session key %q leaked in response", key) } } } func boolJSON(value bool) string { if value { return "true" } return "false" } type sessionResolverStub struct { session identity.Session err error calls int value string } type currentSessionContract struct { Version int `json:"version"` Path string `json:"path"` Methods struct { GET fixtureMethod `json:"GET"` HEAD fixtureMethod `json:"HEAD"` OPTIONS fixtureMethod `json:"OPTIONS"` Unsupported struct { Methods []string `json:"methods"` Status int `json:"status"` Body string `json:"body"` ContentType *string `json:"contentType"` Allow *string `json:"allow"` } `json:"unsupported"` } `json:"methods"` Responses struct { Anonymous json.RawMessage `json:"anonymous"` AuthenticatedUser json.RawMessage `json:"authenticatedUser"` UnboundSuperAdministrator json.RawMessage `json:"unboundSuperAdministrator"` } `json:"responses"` ForbiddenSessionKeys []string `json:"forbiddenSessionKeys"` InfrastructureError struct { DirectGet string `json:"directGet"` TransportStatus int `json:"transportStatus"` MustNotReturnAnonymous bool `json:"mustNotReturnAnonymous"` } `json:"infrastructureError"` } type fixtureMethod struct { Status int `json:"status"` Body string `json:"body"` ContentType string `json:"contentType"` Allow string `json:"allow"` ExecutesGet bool `json:"executesGet"` } type fixtureResponse struct { Authenticated bool `json:"authenticated"` AuthRequired bool `json:"authRequired"` AuthConfigured bool `json:"authConfigured"` AuthMode string `json:"authMode"` User *fixtureUser `json:"user"` } type fixtureUser struct { ID string `json:"id"` Subject string `json:"subject"` Username string `json:"username"` Phone string `json:"phone"` DisplayName string `json:"displayName"` ClientID string `json:"clientId"` TenantID string `json:"tenantId"` OrganizationID string `json:"organizationId"` OrganizationName string `json:"organizationName"` Role string `json:"role"` Status string `json:"status"` Authorities []string `json:"authorities"` Scope []string `json:"scope"` } func (stub *sessionResolverStub) Resolve(_ context.Context, value string) (identity.Session, error) { stub.calls++ stub.value = value return stub.session, stub.err }