355 lines
14 KiB
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)
|
|
}
|
|
}
|