Scert/backend/internal/httpapi/auth_api_test.go

286 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/database"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
)
func TestAuthLoginSupportsUserNameEmailAndDisabledAccounts(t *testing.T) {
directory := t.TempDir()
cfg := config.Default(directory)
cfg.SetupReady = true
cfg.Admin.Username = "admin"
cfg.Admin.Email = "admin@example.test"
cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123")
cfg.Database.DSN = filepath.Join(directory, "data", "auth-api.db")
db, err := database.Open(t.Context(), cfg.Database)
if err != nil {
t.Fatalf("打开认证测试数据库失败:%v", err)
}
defer db.Close()
if err := database.Migrate(t.Context(), db.DB, "sqlite"); err != nil {
t.Fatalf("迁移认证测试数据库失败:%v", err)
}
users := repository.NewSQLUserRepository(db.DB, "sqlite")
if err := users.EnsureSuperAdmin(t.Context(), repository.User{
ID: "primary-super-admin", Username: cfg.Admin.Username, Email: cfg.Admin.Email, PasswordHash: cfg.Admin.PasswordHash,
}); err != nil {
t.Fatalf("保存测试超级管理员失败:%v", err)
}
if err := users.CreateUser(t.Context(), repository.User{
ID: "operator-1", Username: "operator", Email: "operator@example.test",
PasswordHash: httpUserPasswordHash(t, "Operator123"), Role: repository.UserRoleAdmin, Status: repository.UserStatusActive,
}); err != nil {
t.Fatalf("保存测试管理员失败:%v", err)
}
if err := users.CreateUser(t.Context(), repository.User{
ID: "disabled-1", Username: "disabled", Email: "disabled@example.test",
PasswordHash: httpUserPasswordHash(t, "Disabled123"), Role: repository.UserRoleUser, Status: repository.UserStatusDisabled,
}); err != nil {
t.Fatalf("保存测试禁用用户失败:%v", err)
}
server := New(Dependencies{Config: cfg, Repository: &stubDomainRepository{}, Users: users})
for _, identifier := range []string{"operator", "OPERATOR@EXAMPLE.TEST"} {
response := loginAuthTest(t, server, identifier, "Operator123", false)
var payload struct {
Account string `json:"account"`
Email string `json:"email"`
Role string `json:"role"`
IsAdmin bool `json:"is_admin"`
}
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatalf("解析用户登录响应失败:%v", err)
}
if payload.Account != "operator" || payload.Email != "operator@example.test" || payload.Role != repository.UserRoleAdmin || !payload.IsAdmin {
t.Fatalf("用户登录响应内容错误:%+v", payload)
}
}
disabled := loginAuthTest(t, server, "disabled", "Disabled123", false)
if disabled.Code != http.StatusUnauthorized {
t.Fatalf("禁用用户登录应返回 401,实际为 %d:%s", disabled.Code, disabled.Body.String())
}
}
func TestAuthSessionSlidesAndHonorsAbsoluteRememberExpiration(t *testing.T) {
directory := t.TempDir()
cfg := config.Default(directory)
cfg.SetupReady = true
cfg.Admin.Username = "admin"
cfg.Admin.Email = "admin@example.test"
cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123")
server := New(Dependencies{Config: cfg, Repository: &stubDomainRepository{}})
current := time.Date(2026, 8, 30, 10, 0, 0, 0, time.UTC)
server.now = func() time.Time { return current }
login := loginAuthTest(t, server, "admin", "AdminPass123", true)
cookies := login.Result().Cookies()
if len(cookies) == 0 {
t.Fatal("记住登录响应未返回会话 Cookie")
}
token := cookies[0].Value
server.authMu.RLock()
initial := server.authSessions[token]
server.authMu.RUnlock()
if !initial.AbsoluteExpiresAt.Equal(current.Add(rememberSessionLife)) || !initial.ExpiresAt.Equal(current.Add(authSessionLifetime)) {
t.Fatalf("记住登录会话期限错误:%+v", initial)
}
current = current.Add(20 * time.Minute)
sessionResponse := httptest.NewRecorder()
sessionRequest := httptest.NewRequest(http.MethodGet, "/api/v1/auth/session", nil)
sessionRequest.AddCookie(cookies[0])
server.Handler().ServeHTTP(sessionResponse, sessionRequest)
if sessionResponse.Code != http.StatusOK {
t.Fatalf("有效会话检查失败:%d %s", sessionResponse.Code, sessionResponse.Body.String())
}
server.authMu.RLock()
sliding := server.authSessions[token]
server.authMu.RUnlock()
if !sliding.ExpiresAt.Equal(current.Add(authSessionLifetime)) {
t.Fatalf("会话未按访问时间滑动续期:%+v", sliding)
}
current = current.Add(rememberSessionLife)
expired := httptest.NewRecorder()
expiredRequest := httptest.NewRequest(http.MethodGet, "/api/v1/auth/session", nil)
expiredRequest.AddCookie(cookies[0])
server.Handler().ServeHTTP(expired, expiredRequest)
if expired.Code != http.StatusUnauthorized {
t.Fatalf("超过绝对期限的会话应返回 401,实际为 %d:%s", expired.Code, expired.Body.String())
}
}
func TestAuthSessionIsInvalidatedWhenUserChanges(t *testing.T) {
directory := t.TempDir()
cfg := config.Default(directory)
cfg.SetupReady = true
cfg.Admin.Username = "admin"
cfg.Admin.Email = "admin@example.test"
cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123")
cfg.Database.DSN = filepath.Join(directory, "data", "auth-invalidation.db")
db, err := database.Open(t.Context(), cfg.Database)
if err != nil {
t.Fatalf("打开会话失效测试数据库失败:%v", err)
}
defer db.Close()
if err := database.Migrate(t.Context(), db.DB, "sqlite"); err != nil {
t.Fatalf("迁移会话失效测试数据库失败:%v", err)
}
users := repository.NewSQLUserRepository(db.DB, "sqlite")
if err := users.EnsureSuperAdmin(t.Context(), repository.User{
ID: "primary-super-admin", Username: cfg.Admin.Username, Email: cfg.Admin.Email, PasswordHash: cfg.Admin.PasswordHash,
}); err != nil {
t.Fatalf("保存会话失效测试超级管理员失败:%v", err)
}
if err := users.CreateUser(t.Context(), repository.User{
ID: "operator-1", Username: "operator", Email: "operator@example.test",
PasswordHash: httpUserPasswordHash(t, "Operator123"), Role: repository.UserRoleAdmin, Status: repository.UserStatusActive,
}); err != nil {
t.Fatalf("保存会话失效测试用户失败:%v", err)
}
server := New(Dependencies{Config: cfg, Repository: &stubDomainRepository{}, Users: users})
login := loginAuthTest(t, server, "operator", "Operator123", false)
cookies := login.Result().Cookies()
if len(cookies) == 0 {
t.Fatal("会话失效测试未返回 Cookie")
}
if err := users.UpdateUser(t.Context(), repository.User{
ID: "operator-1", Username: "operator", Email: "operator@example.test",
PasswordHash: httpUserPasswordHash(t, "Operator123"), Role: repository.UserRoleAdmin, Status: repository.UserStatusDisabled,
}); err != nil {
t.Fatalf("禁用会话失效测试用户失败:%v", err)
}
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/session", nil)
request.AddCookie(cookies[0])
server.Handler().ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("禁用用户的旧会话应返回 401,实际为 %d:%s", response.Code, response.Body.String())
}
server.authMu.RLock()
_, exists := server.authSessions[cookies[0].Value]
server.authMu.RUnlock()
if exists {
t.Fatal("禁用用户的旧会话未从内存中清理")
}
}
func loginAuthTest(t *testing.T, server *Server, identifier, password string, remember bool) *httptest.ResponseRecorder {
t.Helper()
body := `{"identifier":"` + identifier + `","password":"` + password + `","remember":` + boolString(remember) + `}`
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
if remember && response.Code != http.StatusOK {
t.Fatalf("记住登录失败:%d %s", response.Code, response.Body.String())
}
if !remember && identifier != "disabled" && response.Code != http.StatusOK {
t.Fatalf("登录失败:%s,状态码=%d,响应=%s", identifier, response.Code, response.Body.String())
}
return response
}
func boolString(value bool) string {
if value {
return "true"
}
return "false"
}
func TestAuthSessionIsInvalidatedWhenUserPasswordChangesOrIsDeleted(t *testing.T) {
directory := t.TempDir()
cfg := config.Default(directory)
cfg.SetupReady = true
cfg.Admin.Username = "admin"
cfg.Admin.Email = "admin@example.test"
cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123")
cfg.Database.DSN = filepath.Join(directory, "data", "auth-invalidation-actions.db")
db, err := database.Open(t.Context(), cfg.Database)
if err != nil {
t.Fatalf("打开用户会话操作测试数据库失败:%v", err)
}
defer db.Close()
if err := database.Migrate(t.Context(), db.DB, "sqlite"); err != nil {
t.Fatalf("迁移用户会话操作测试数据库失败:%v", err)
}
users := repository.NewSQLUserRepository(db.DB, "sqlite")
if err := users.EnsureSuperAdmin(t.Context(), repository.User{
ID: "primary-super-admin", Username: cfg.Admin.Username, Email: cfg.Admin.Email, PasswordHash: cfg.Admin.PasswordHash,
}); err != nil {
t.Fatalf("保存用户会话操作测试超级管理员失败:%v", err)
}
if err := users.CreateUser(t.Context(), repository.User{
ID: "operator-1", Username: "operator", Email: "operator@example.test",
PasswordHash: httpUserPasswordHash(t, "Operator123"), Role: repository.UserRoleAdmin, Status: repository.UserStatusActive,
}); err != nil {
t.Fatalf("保存用户会话操作测试用户失败:%v", err)
}
server := New(Dependencies{Config: cfg, Repository: &stubDomainRepository{}, Users: users})
passwordLogin := loginAuthTest(t, server, "operator", "Operator123", false)
passwordCookie := passwordLogin.Result().Cookies()[0]
if err := users.UpdateUser(t.Context(), repository.User{
ID: "operator-1", Username: "operator", Email: "operator@example.test",
PasswordHash: httpUserPasswordHash(t, "Changed123"), Role: repository.UserRoleAdmin, Status: repository.UserStatusActive,
}); err != nil {
t.Fatalf("修改用户密码失败:%v", err)
}
passwordResponse := authSessionRequest(server, passwordCookie)
if passwordResponse.Code != http.StatusUnauthorized {
t.Fatalf("用户改密后旧会话应返回 401,实际为 %d:%s", passwordResponse.Code, passwordResponse.Body.String())
}
deleteLogin := loginAuthTest(t, server, "operator", "Changed123", false)
deleteCookie := deleteLogin.Result().Cookies()[0]
if err := users.DeleteUser(t.Context(), "operator-1"); err != nil {
t.Fatalf("删除用户失败:%v", err)
}
deleteResponse := authSessionRequest(server, deleteCookie)
if deleteResponse.Code != http.StatusUnauthorized {
t.Fatalf("删除用户后旧会话应返回 401,实际为 %d:%s", deleteResponse.Code, deleteResponse.Body.String())
}
}
func TestPrimarySuperAdminSessionIsInvalidatedWhenConfigurationPasswordChanges(t *testing.T) {
directory := t.TempDir()
cfg := config.Default(directory)
cfg.SetupReady = true
cfg.Admin.Username = "admin"
cfg.Admin.Email = "admin@example.test"
cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123")
server := New(Dependencies{Config: cfg, Repository: &stubDomainRepository{}})
login := loginAuthTest(t, server, "admin", "AdminPass123", false)
cookie := login.Result().Cookies()[0]
server.configMu.Lock()
server.config.Admin.PasswordHash = httpUserPasswordHash(t, "Changed123")
server.configMu.Unlock()
response := authSessionRequest(server, cookie)
if response.Code != http.StatusUnauthorized {
t.Fatalf("主超级管理员改密后旧会话应返回 401,实际为 %d:%s", response.Code, response.Body.String())
}
}
func authSessionRequest(server *Server, cookie *http.Cookie) *httptest.ResponseRecorder {
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/session", nil)
request.AddCookie(cookie)
server.Handler().ServeHTTP(response, request)
return response
}