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

355 lines
14 KiB
Go

package httpapi_test
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/httpapi"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity"
)
type passwordContract struct {
Version int `json:"version"`
Path string `json:"path"`
Method string `json:"method"`
LocalSessionTTLSeconds int64 `json:"localSessionTtlSeconds"`
InputCases []struct {
Name string `json:"name"`
Body map[string]any `json:"body"`
ExpectedRedirect string `json:"expectedRedirect"`
} `json:"inputCases"`
SafeNextCases []struct {
Input *string `json:"input"`
Expected string `json:"expected"`
} `json:"safeNextCases"`
Success struct {
TopLevelKeys []string `json:"topLevelKeys"`
PublicUserKeys []string `json:"publicUserKeys"`
ForbiddenSerializedKeys []string `json:"forbiddenSerializedKeys"`
} `json:"success"`
Errors struct {
InvalidInput fixtureError `json:"invalidInput"`
InvalidCredentials fixtureError `json:"invalidCredentials"`
DisabledAccount fixtureError `json:"disabledAccount"`
DisabledOrganization fixtureError `json:"disabledOrganization"`
LockedAccount fixtureError `json:"lockedAccount"`
RateLimited fixtureError `json:"rateLimited"`
Unconfigured fixtureError `json:"unconfigured"`
} `json:"errors"`
RateLimit struct {
AttemptsPerIP int `json:"attemptsPerIp"`
WindowSeconds int `json:"windowSeconds"`
} `json:"rateLimit"`
}
type fixtureError struct {
Status int `json:"status"`
Body map[string]string `json:"body"`
}
type passwordIssuerStub struct {
session identity.Session
err error
commands []identity.LoginCommand
}
func (stub *passwordIssuerStub) Login(_ context.Context, command identity.LoginCommand) (identity.Session, error) {
stub.commands = append(stub.commands, command)
return stub.session, stub.err
}
func TestAuthPasswordConsumesSharedInputAndRedirectContract(t *testing.T) {
contract := loadPasswordContract(t)
for _, test := range contract.InputCases {
t.Run(test.Name, func(t *testing.T) {
issuer := &passwordIssuerStub{session: fixtureLoginSession()}
handler := newPasswordHandler(t, issuer)
body, _ := json.Marshal(test.Body)
response := servePassword(handler, contract.Path, body, "198.51.100.1")
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%q, want 200", response.Code, response.Body.String())
}
if len(issuer.commands) != 1 || issuer.commands[0].Phone != "13800138000" || issuer.commands[0].Password != "TestPass123" {
t.Fatalf("commands = %#v", issuer.commands)
}
var payload map[string]any
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["redirectTo"] != test.ExpectedRedirect || payload["authMode"] != string(identity.AuthModeUser) || payload["ok"] != true {
t.Fatalf("payload = %#v", payload)
}
assertKeySet(t, payload, contract.Success.TopLevelKeys)
assertKeySet(t, payload["user"].(map[string]any), contract.Success.PublicUserKeys)
for _, forbidden := range contract.Success.ForbiddenSerializedKeys {
if strings.Contains(response.Body.String(), `"`+forbidden+`"`) {
t.Fatalf("response leaked forbidden key %q: %s", forbidden, response.Body.String())
}
}
})
}
}
func TestAuthPasswordConsumesSharedSafeNextContract(t *testing.T) {
contract := loadPasswordContract(t)
for _, test := range contract.SafeNextCases {
name := "null"
var next any
if test.Input != nil {
name, next = *test.Input, *test.Input
}
t.Run(name, func(t *testing.T) {
issuer := &passwordIssuerStub{session: fixtureLoginSession()}
handler := newPasswordHandler(t, issuer)
body, _ := json.Marshal(map[string]any{"phone": "13800138000", "password": "TestPass123", "next": next})
response := servePassword(handler, contract.Path, body, "198.51.100.2")
var payload struct {
RedirectTo string `json:"redirectTo"`
}
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if response.Code != http.StatusOK || payload.RedirectTo != test.Expected {
t.Fatalf("response = %d %q, redirect=%q want %q", response.Code, response.Body.String(), payload.RedirectTo, test.Expected)
}
})
}
}
func TestAuthPasswordConsumesSharedErrorContract(t *testing.T) {
contract := loadPasswordContract(t)
tests := []struct {
name string
reason identity.PasswordLoginFailure
fixture fixtureError
}{
{"invalid input", identity.LoginFailureInvalidInput, contract.Errors.InvalidInput},
{"credentials", identity.LoginFailureInvalidCredentials, contract.Errors.InvalidCredentials},
{"disabled", identity.LoginFailureAccountDisabled, contract.Errors.DisabledAccount},
{"invalid role", identity.LoginFailureInvalidRole, contract.Errors.DisabledOrganization},
{"organization required", identity.LoginFailureOrganizationRequired, contract.Errors.DisabledOrganization},
{"organization inactive", identity.LoginFailureOrganizationNotActive, contract.Errors.DisabledOrganization},
{"locked", identity.LoginFailureAccountLocked, contract.Errors.LockedAccount},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
issuer := &passwordIssuerStub{err: identity.NewPasswordLoginError(test.reason)}
response := servePassword(newPasswordHandler(t, issuer), contract.Path, []byte(`{"phone":"13800138000","password":"bad"}`), "198.51.100.3")
assertErrorFixture(t, response, test.fixture)
})
}
unconfiguredIssuer := &passwordIssuerStub{}
response := servePassword(newPasswordHandlerWithConfig(t, httpapi.PasswordAuthConfig{}, unconfiguredIssuer), contract.Path, []byte(`{}`), "198.51.100.4")
assertErrorFixture(t, response, contract.Errors.Unconfigured)
if len(unconfiguredIssuer.commands) != 0 {
t.Fatalf("unconfigured issuer calls = %d, want 0", len(unconfiguredIssuer.commands))
}
}
func TestAuthPasswordNullAndNonObjectJSONAreSafeInvalidInput(t *testing.T) {
contract := loadPasswordContract(t)
for _, body := range []string{"null", `[]`, `{not-json`} {
issuer := &passwordIssuerStub{err: identity.NewPasswordLoginError(identity.LoginFailureInvalidInput)}
response := servePassword(newPasswordHandler(t, issuer), contract.Path, []byte(body), "198.51.100.7")
assertErrorFixture(t, response, contract.Errors.InvalidInput)
}
}
func TestAuthPasswordRateLimitUsesForwardedIPAndClearsOnSuccess(t *testing.T) {
contract := loadPasswordContract(t)
issuer := &passwordIssuerStub{err: identity.NewPasswordLoginError(identity.LoginFailureInvalidCredentials)}
handler := newPasswordHandler(t, issuer)
requestBody := []byte(`{"phone":"13800138000","password":"bad"}`)
for index := 0; index < contract.RateLimit.AttemptsPerIP; index++ {
response := servePasswordWithHeaders(handler, contract.Path, requestBody, map[string]string{
"X-Forwarded-For": "203.0.113.9, 10.0.0.4",
"X-Real-IP": "192.0.2.20",
})
if response.Code != contract.Errors.InvalidCredentials.Status {
t.Fatalf("attempt %d status = %d", index+1, response.Code)
}
}
limited := servePasswordWithHeaders(handler, contract.Path, requestBody, map[string]string{"X-Forwarded-For": "203.0.113.9"})
assertErrorFixture(t, limited, contract.Errors.RateLimited)
if len(issuer.commands) != contract.RateLimit.AttemptsPerIP {
t.Fatalf("issuer calls = %d, want %d", len(issuer.commands), contract.RateLimit.AttemptsPerIP)
}
issuer.err = nil
issuer.session = fixtureLoginSession()
success := servePasswordWithHeaders(handler, contract.Path, []byte(`{"phone":"13800138000","password":"ok"}`), map[string]string{"X-Forwarded-For": "203.0.113.10"})
if success.Code != http.StatusOK {
t.Fatalf("success status = %d", success.Code)
}
issuer.err = identity.NewPasswordLoginError(identity.LoginFailureInvalidCredentials)
afterSuccess := servePasswordWithHeaders(handler, contract.Path, requestBody, map[string]string{"X-Forwarded-For": "203.0.113.10"})
if afterSuccess.Code != contract.Errors.InvalidCredentials.Status {
t.Fatalf("after success status = %d", afterSuccess.Code)
}
}
func TestAuthPasswordWritesTwentyFixtureCookiesAfterSuccessfulSign(t *testing.T) {
contract := loadPasswordContract(t)
cookie := loadPasswordSessionCookieContract(t)
issuer := &passwordIssuerStub{session: fixtureLoginSession()}
config := httpapi.PasswordAuthConfig{Configured: true, SessionSecret: cookie.Secret, PublicBaseURL: "https://app.example.test"}
response := servePassword(newPasswordHandlerWithConfig(t, config, issuer), contract.Path, []byte(`{"phone":"13800138000","password":"ok"}`), "198.51.100.5")
cookies := response.Result().Cookies()
if response.Code != http.StatusOK || len(cookies) != cookie.Cookie.MaxChunks {
t.Fatalf("status/cookies = %d/%d, want 200/%d", response.Code, len(cookies), cookie.Cookie.MaxChunks)
}
for index, got := range cookies {
if got.Name != cookie.Cookie.ChunkNames[index] || got.Path != cookie.Cookie.Attributes.Path || got.HttpOnly != cookie.Cookie.Attributes.HTTPOnly || got.SameSite != http.SameSiteLaxMode || !got.Secure {
t.Errorf("cookie %d = %#v", index, got)
}
if index == 0 {
if got.Value == "" || got.Expires.Unix() != fixtureLoginSession().ExpiresAt {
t.Errorf("session cookie = %#v", got)
}
} else if got.Value != cookie.Cookie.Clear.Value || got.MaxAge != -1 {
t.Errorf("clear cookie %d = %#v", index, got)
}
}
}
func TestAuthPasswordInfrastructureFailureIsGenericAndWritesNoCookie(t *testing.T) {
issuer := &passwordIssuerStub{err: errors.New("postgres password=secret")}
response := servePassword(newPasswordHandler(t, issuer), "/api/auth/password", []byte(`{"phone":"13800138000","password":"ok"}`), "198.51.100.6")
if response.Code != http.StatusInternalServerError || response.Body.String() != `{"error":"服务器内部错误。"}` || len(response.Result().Cookies()) != 0 {
t.Fatalf("response = %d %q cookies=%d", response.Code, response.Body.String(), len(response.Result().Cookies()))
}
}
func TestAuthPasswordMethodAndPathContract(t *testing.T) {
contract := loadPasswordContract(t)
handler := newPasswordHandler(t, &passwordIssuerStub{session: fixtureLoginSession()})
tests := []struct {
method, path string
status int
}{
{http.MethodGet, contract.Path, http.StatusMethodNotAllowed},
{http.MethodPut, contract.Path, http.StatusMethodNotAllowed},
{http.MethodOptions, contract.Path, http.StatusNoContent},
{http.MethodPost, contract.Path + "/", http.StatusNotFound},
}
for _, test := range tests {
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(test.method, test.path, nil))
if response.Code != test.status || response.Body.Len() != 0 {
t.Errorf("%s %s = %d %q", test.method, test.path, response.Code, response.Body.String())
}
}
}
func newPasswordHandler(t *testing.T, issuer httpapi.PasswordSessionIssuer) http.Handler {
t.Helper()
cookie := loadPasswordSessionCookieContract(t)
return newPasswordHandlerWithConfig(t, httpapi.PasswordAuthConfig{Configured: true, SessionSecret: cookie.Secret}, issuer)
}
func newPasswordHandlerWithConfig(t *testing.T, config httpapi.PasswordAuthConfig, issuer httpapi.PasswordSessionIssuer) http.Handler {
t.Helper()
handler, err := httpapi.NewAuthPasswordHandler(config, issuer)
if err != nil {
t.Fatalf("NewAuthPasswordHandler() error = %v", err)
}
return handler
}
func servePassword(handler http.Handler, path string, body []byte, ip string) *httptest.ResponseRecorder {
return servePasswordWithHeaders(handler, path, body, map[string]string{"X-Real-IP": ip})
}
func servePasswordWithHeaders(handler http.Handler, path string, body []byte, headers map[string]string) *httptest.ResponseRecorder {
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(string(body)))
request.Header.Set("Content-Type", "application/json")
for key, value := range headers {
request.Header.Set(key, value)
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
return response
}
func fixtureLoginSession() identity.Session {
version := 7
return identity.Session{Version: 1, AuthMode: identity.AuthModeUser, IssuedAt: 1_700_000_000, ExpiresAt: 1_700_086_400, SessionVersion: &version, User: identity.User{
ID: "account-1", Subject: "account-1", Username: "13800138000", Phone: "13800138000", DisplayName: "张三", ClientID: "platform", OrganizationID: "org-1", OrganizationName: "组织一", Role: "user", Status: "active", Authorities: []string{"ROLE_USER"}, Scope: []string{},
}}
}
func loadPasswordContract(t *testing.T) passwordContract {
t.Helper()
var fixture passwordContract
loadHTTPFixture(t, "password-login-v1.json", &fixture)
return fixture
}
type passwordSessionFixture struct {
Secret string `json:"secret"`
Cookie struct {
MaxChunks int `json:"maxChunks"`
ChunkNames []string `json:"chunkNames"`
Attributes struct {
HTTPOnly bool `json:"httpOnly"`
SameSite string `json:"sameSite"`
Path string `json:"path"`
} `json:"attributes"`
Clear struct {
Value string `json:"value"`
} `json:"clear"`
} `json:"cookie"`
}
func loadPasswordSessionCookieContract(t *testing.T) passwordSessionFixture {
t.Helper()
var fixture passwordSessionFixture
loadHTTPFixture(t, "session-cookie-v1.json", &fixture)
return fixture
}
func loadHTTPFixture(t *testing.T, name string, target any) {
t.Helper()
data, err := os.ReadFile(filepath.Join("..", "..", "..", "contracts", "auth", name))
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
if err := json.Unmarshal(data, target); err != nil {
t.Fatalf("decode %s: %v", name, err)
}
}
func assertKeySet(t *testing.T, got map[string]any, want []string) {
t.Helper()
keys := make([]string, 0, len(got))
for key := range got {
keys = append(keys, key)
}
if len(keys) != len(want) {
t.Fatalf("keys = %v, want %v", keys, want)
}
for _, key := range want {
if _, ok := got[key]; !ok {
t.Fatalf("missing key %q in %v", key, keys)
}
}
}
func assertErrorFixture(t *testing.T, response *httptest.ResponseRecorder, fixture fixtureError) {
t.Helper()
want, _ := json.Marshal(fixture.Body)
if response.Code != fixture.Status || strings.TrimSpace(response.Body.String()) != string(want) {
t.Fatalf("response = %d %q, want %d %s", response.Code, response.Body.String(), fixture.Status, want)
}
}