438 lines
17 KiB
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
|
|
}
|