feat: add Go current-session HTTP adapter
This commit is contained in:
1 parent
c849077591
commit
772795e7eb
10 files changed
+1608
-7
No files matched your search
@@ -0,0 +1,437 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user