Files
NianAIGC/backend/internal/application/application_test.go

380 lines
13 KiB
Go

package application_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/application"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity"
)
func TestLocalApplicationServesFoundationHealthAndReadiness(t *testing.T) {
app, err := application.New(application.Options{
Getenv: func(name string) string {
if name == "ZHINIAN_DATA_BACKEND" {
return "local"
}
return ""
},
})
if err != nil {
t.Fatalf("New() error = %v", err)
}
t.Cleanup(app.Close)
for _, test := range []struct {
path string
wantCode int
}{
{path: "/api/health", wantCode: http.StatusOK},
{path: "/api/ready", wantCode: http.StatusOK},
} {
t.Run(test.path, func(t *testing.T) {
response := httptest.NewRecorder()
app.Handler().ServeHTTP(response, httptest.NewRequest(http.MethodGet, test.path, nil))
if response.Code != test.wantCode {
t.Fatalf("status = %d, want %d", response.Code, test.wantCode)
}
var payload struct {
OK bool `json:"ok"`
Database struct {
Backend string `json:"backend"`
Configured bool `json:"configured"`
} `json:"database"`
}
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if !payload.OK || payload.Database.Backend != "local" || !payload.Database.Configured {
t.Fatalf("payload = %+v", payload)
}
})
}
}
func TestApplicationRejectsInvalidProductionDatabaseConfiguration(t *testing.T) {
_, err := application.New(application.Options{
Getenv: func(name string) string {
if name == "NODE_ENV" {
return "production"
}
return ""
},
})
if err == nil {
t.Fatal("New() error = nil, want fail-closed database configuration error")
}
}
func TestApplicationServesAnonymousCurrentSessionWithAuthConfigurationState(t *testing.T) {
tests := []struct {
name string
environment map[string]string
wantRequired bool
wantConfigured bool
}{
{
name: "trusted local development",
environment: map[string]string{"ZHINIAN_DATA_BACKEND": "local"},
wantRequired: false,
wantConfigured: false,
},
{
name: "production missing session secret",
environment: map[string]string{
"ZHINIAN_DATA_BACKEND": "local",
"NODE_ENV": "production",
},
wantRequired: true,
wantConfigured: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
app, err := application.New(application.Options{Getenv: applicationEnv(test.environment)})
if err != nil {
t.Fatalf("New() error = %v", err)
}
t.Cleanup(app.Close)
response := httptest.NewRecorder()
app.Handler().ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/auth/me", nil))
if response.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", response.Code, http.StatusOK)
}
var payload map[string]any
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
want := map[string]any{
"authenticated": false,
"authRequired": test.wantRequired,
"authConfigured": test.wantConfigured,
"authMode": nil,
"user": nil,
}
if !reflect.DeepEqual(payload, want) {
t.Fatalf("payload = %#v, want %#v", payload, want)
}
})
}
}
func TestApplicationComposesSignedCookieResolverWithAuthorizationLoader(t *testing.T) {
secret := "application-current-session-secret-with-enough-entropy"
loader := &applicationAuthorizationLoader{snapshot: identity.AuthorizationSnapshot{
Account: identity.AccountSnapshot{
ID: "user-1", Phone: "13800138001", DisplayName: "Current User", Role: "user",
OrganizationID: "org-1", Status: "active", SessionVersion: 7,
},
Organization: &identity.OrganizationSnapshot{ID: "org-1", Name: "Primary Organization", Status: "active"},
}, found: true}
app, err := application.New(application.Options{
Getenv: applicationEnv(map[string]string{
"ZHINIAN_DATA_BACKEND": "local",
"ZHINIAN_AUTH_SESSION_SECRET": secret,
}),
AuthorizationLoader: loader,
})
if err != nil {
t.Fatalf("New() error = %v", err)
}
t.Cleanup(app.Close)
version := 7
session := identity.Session{
Version: 1, AuthMode: identity.AuthModeAdmin,
IssuedAt: time.Now().Add(-time.Minute).Unix(), ExpiresAt: time.Now().Add(time.Hour).Unix(),
SessionVersion: &version, AccessToken: "must-not-leak", TokenType: "bearer",
User: identity.User{
ID: "user-1", Subject: "forged", DisplayName: "Forged Admin", ClientID: "platform",
Role: "super_admin", Status: "active", Authorities: []string{"ROLE_SUPER_ADMIN"}, Scope: []string{"forged"},
},
}
raw, err := json.Marshal(session)
if err != nil {
t.Fatalf("marshal session: %v", err)
}
cookieValue, err := identity.Sign(raw, secret)
if err != nil {
t.Fatalf("sign session: %v", err)
}
request := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
request.AddCookie(&http.Cookie{Name: identity.SessionCookieName, Value: cookieValue})
response := httptest.NewRecorder()
app.Handler().ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", response.Code, http.StatusOK)
}
body := response.Body.Bytes()
var payload struct {
Authenticated bool `json:"authenticated"`
User struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
Authorities []string `json:"authorities"`
} `json:"user"`
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if !payload.Authenticated || payload.User.ID != "user-1" || payload.User.DisplayName != "Current User" || payload.User.Role != "user" {
t.Fatalf("payload = %+v", payload)
}
if !reflect.DeepEqual(payload.User.Authorities, []string{"ROLE_USER"}) {
t.Fatalf("authorities = %#v", payload.User.Authorities)
}
if len(loader.ids) != 1 || loader.ids[0] != "user-1" {
t.Fatalf("loader IDs = %#v", loader.ids)
}
for _, forbidden := range []string{"accessToken", "tokenType", "sessionVersion", "expiresAt", "issuedAt", "must-not-leak"} {
if strings.Contains(string(body), forbidden) {
t.Fatalf("response leaked %q: %s", forbidden, body)
}
}
for _, method := range []string{http.MethodHead, http.MethodOptions, http.MethodPost} {
methodRequest := httptest.NewRequest(method, "/api/auth/me", nil)
methodResponse := httptest.NewRecorder()
app.Handler().ServeHTTP(methodResponse, methodRequest)
wantStatus := http.StatusMethodNotAllowed
if method == http.MethodHead {
wantStatus = http.StatusOK
}
if method == http.MethodOptions {
wantStatus = http.StatusNoContent
}
if methodResponse.Code != wantStatus {
t.Fatalf("%s status = %d, want %d", method, methodResponse.Code, wantStatus)
}
}
}
func TestApplicationUsesDatabaseAuthorizationAdapterByDefault(t *testing.T) {
secret := "local-default-adapter-secret-with-enough-entropy"
app, err := application.New(application.Options{Getenv: applicationEnv(map[string]string{
"ZHINIAN_DATA_BACKEND": "local",
"ZHINIAN_AUTH_SESSION_SECRET": secret,
})})
if err != nil {
t.Fatalf("New() error = %v", err)
}
t.Cleanup(app.Close)
session := identity.Session{
Version: 1, AuthMode: identity.AuthModeUser,
IssuedAt: time.Now().Add(-time.Minute).Unix(), ExpiresAt: time.Now().Add(time.Hour).Unix(),
User: identity.User{
ID: "user-1", Subject: "user-1", DisplayName: "User", ClientID: "platform",
Authorities: []string{}, Scope: []string{},
},
}
raw, err := json.Marshal(session)
if err != nil {
t.Fatalf("marshal session: %v", err)
}
cookieValue, err := identity.Sign(raw, secret)
if err != nil {
t.Fatalf("sign session: %v", err)
}
request := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
request.AddCookie(&http.Cookie{Name: identity.SessionCookieName, Value: cookieValue})
response := httptest.NewRecorder()
app.Handler().ServeHTTP(response, request)
if response.Code != http.StatusInternalServerError || response.Body.Len() != 0 {
t.Fatalf("response = %d %q, want empty 500 from unavailable local authorization adapter", response.Code, response.Body.String())
}
}
func TestApplicationComposesPasswordLoginAndLogoutHandlers(t *testing.T) {
secret := "application-password-login-secret-with-enough-entropy"
authenticator := &applicationCredentialAuthenticator{account: identity.LoginAccount{
Account: identity.AccountSnapshot{
ID: "user-1", Phone: "13800138000", DisplayName: "Login User", Role: "user",
OrganizationID: "org-1", Status: "active", SessionVersion: 9,
},
Organization: &identity.OrganizationSnapshot{ID: "org-1", Name: "Primary Organization", Status: "active"},
}}
app, err := application.New(application.Options{
Getenv: applicationEnv(map[string]string{
"ZHINIAN_DATA_BACKEND": "local",
"ZHINIAN_AUTH_SESSION_SECRET": secret,
"NEXT_PUBLIC_APP_URL": "https://public.example.test",
}),
CredentialAuthenticator: authenticator,
})
if err != nil {
t.Fatalf("New() error = %v", err)
}
t.Cleanup(app.Close)
loginRequest := httptest.NewRequest(http.MethodPost, "/api/auth/password", strings.NewReader(`{
"phone":" (138) 0013-8000 ", "password":" password ", "next":"/create?source=login"
}`))
loginRequest.Header.Set("Content-Type", "application/json")
loginResponse := httptest.NewRecorder()
app.Handler().ServeHTTP(loginResponse, loginRequest)
if loginResponse.Code != http.StatusOK {
t.Fatalf("login response = %d %q", loginResponse.Code, loginResponse.Body.String())
}
if len(authenticator.phones) != 1 || authenticator.phones[0] != "13800138000" || authenticator.passwords[0] != "password" {
t.Fatalf("credential attempts = phones %#v passwords %#v", authenticator.phones, authenticator.passwords)
}
cookieValues := make(map[string]string)
for _, cookie := range loginResponse.Result().Cookies() {
if !cookie.Secure {
t.Fatalf("login cookie did not inherit HTTPS public base URL: %#v", cookie)
}
if cookie.Value != "" {
cookieValues[cookie.Name] = cookie.Value
}
}
signed, ok := identity.Reassemble(identity.SessionCookieName, func(name string) (string, bool) {
value, found := cookieValues[name]
return value, found
})
if !ok {
t.Fatalf("login response did not contain a session cookie: %#v", loginResponse.Header().Values("Set-Cookie"))
}
session, err := identity.Parse(signed, secret, time.Now())
if err != nil {
t.Fatalf("parse issued session: %v", err)
}
if session.User.ID != "user-1" || session.User.OrganizationName != "Primary Organization" || session.SessionVersion == nil || *session.SessionVersion != 9 {
t.Fatalf("session = %+v", session)
}
logoutResponse := httptest.NewRecorder()
app.Handler().ServeHTTP(logoutResponse, httptest.NewRequest(http.MethodPost, "http://app.test/api/auth/logout", nil))
if logoutResponse.Code != http.StatusTemporaryRedirect || logoutResponse.Header().Get("Location") != "http://app.test/auth/login?loggedOut=1" {
t.Fatalf("logout response = %d location=%q", logoutResponse.Code, logoutResponse.Header().Get("Location"))
}
if got := len(logoutResponse.Result().Cookies()); got != identity.CookieMaxChunks {
t.Fatalf("logout cookies = %d, want %d", got, identity.CookieMaxChunks)
}
}
func TestApplicationLeavesLogoutAvailableWhenPasswordAuthenticationIsUnconfigured(t *testing.T) {
app, err := application.New(application.Options{Getenv: applicationEnv(map[string]string{
"ZHINIAN_DATA_BACKEND": "local",
})})
if err != nil {
t.Fatalf("New() error = %v", err)
}
t.Cleanup(app.Close)
passwordResponse := httptest.NewRecorder()
app.Handler().ServeHTTP(passwordResponse, httptest.NewRequest(http.MethodPost, "/api/auth/password", strings.NewReader(`{"phone":"13800138000","password":"password"}`)))
if passwordResponse.Code != http.StatusServiceUnavailable {
t.Fatalf("password status = %d body=%q, want 503", passwordResponse.Code, passwordResponse.Body.String())
}
logoutResponse := httptest.NewRecorder()
app.Handler().ServeHTTP(logoutResponse, httptest.NewRequest(http.MethodGet, "http://app.test/api/auth/logout", nil))
if logoutResponse.Code != http.StatusTemporaryRedirect {
t.Fatalf("logout status = %d, want 307", logoutResponse.Code)
}
}
type applicationAuthorizationLoader struct {
snapshot identity.AuthorizationSnapshot
found bool
err error
ids []string
}
type applicationCredentialAuthenticator struct {
account identity.LoginAccount
err error
phones []string
passwords []string
}
func (authenticator *applicationCredentialAuthenticator) AttemptPasswordLogin(_ context.Context, phone, password string, _ time.Time) (identity.LoginAccount, error) {
authenticator.phones = append(authenticator.phones, phone)
authenticator.passwords = append(authenticator.passwords, password)
return authenticator.account, authenticator.err
}
func (loader *applicationAuthorizationLoader) FindAuthorizationSnapshot(_ context.Context, id string) (identity.AuthorizationSnapshot, bool, error) {
loader.ids = append(loader.ids, id)
return loader.snapshot, loader.found, loader.err
}
func applicationEnv(values map[string]string) func(string) string {
return func(name string) string { return values[name] }
}