Files
NianAIGC/backend/internal/httpapi/auth_me_test.go

438 lines
17 KiB
Go

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
}