Files
Agent-System/internal/server/auth_handlers_test.go
T
weichen f3f23a3018 feat(errcode): 用稳定错误码记录 HTTP 与异步失败
失败响应此前不写日志,request_id 无法对照原因。注册表不覆盖处理器已经给出的 HTTP 状态。
2026-09-28 02:27:16 +00:00

442 lines
15 KiB
Go

package server_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"chat-agent-system/internal/auth"
"chat-agent-system/internal/config"
"chat-agent-system/internal/server"
)
func setupTestServer(t *testing.T) (*httptest.Server, *auth.Service) {
t.Helper()
cfg := &config.Config{
Env: "dev",
DataDir: t.TempDir(),
AgentBackend: "stub",
AllowInMemory: true,
AdminUsername: "admin",
AdminPassword: "admin123",
AuthRegistrationEnabled: true,
AuthRequireApproval: false,
}
srv := server.NewServer(cfg, nil)
cfg.AllowInMemory = false
ts := httptest.NewServer(srv.Routes())
t.Cleanup(ts.Close)
return ts, srv.AuthService()
}
func TestAuthHandlers_PublicConfigUnauthenticated(t *testing.T) {
ts, authSvc := setupTestServer(t)
_ = authSvc.UpdateConfig(t.Context(), auth.SystemAuthConfig{
AllowRegistration: true,
RequireApproval: true,
})
resp, err := http.Get(ts.URL + "/api/v1/auth/public-config")
if err != nil {
t.Fatalf("public-config request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("Expected 200 for unauthenticated /auth/public-config, got %d", resp.StatusCode)
}
var cfg auth.SystemAuthConfig
if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil {
t.Fatalf("decode public-config: %v", err)
}
if !cfg.AllowRegistration || !cfg.RequireApproval {
t.Fatalf("unexpected public-config: %+v", cfg)
}
adminResp, err := http.Get(ts.URL + "/api/v1/admin/config")
if err != nil {
t.Fatalf("admin config request failed: %v", err)
}
defer adminResp.Body.Close()
if adminResp.StatusCode != http.StatusUnauthorized {
t.Fatalf("Expected 401 for unauthenticated /admin/config, got %d", adminResp.StatusCode)
}
}
func TestAuthHandlers_UnauthenticatedMeReturns401(t *testing.T) {
ts, _ := setupTestServer(t)
resp, err := http.Get(ts.URL + "/api/v1/auth/me")
if err != nil {
t.Fatalf("Me request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("Expected 401 for unauthenticated /auth/me, got %d", resp.StatusCode)
}
}
func TestAuthHandlers_LoginAndMe(t *testing.T) {
ts, _ := setupTestServer(t)
// 1. Admin login with .env credentials
loginReq := map[string]string{
"username": "admin",
"password": "admin123",
}
body, _ := json.Marshal(loginReq)
resp, err := http.Post(ts.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("Login request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("Expected 200 OK, got %d", resp.StatusCode)
}
var session auth.SessionInfo
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
t.Fatalf("Failed to decode session: %v", err)
}
if session.User == nil || session.User.RoleID != "role-admin" {
t.Errorf("Expected role-admin, got %+v", session.User)
}
// 2. Fetch /api/v1/auth/me with Authorization header
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+session.Token)
client := &http.Client{}
meResp, err := client.Do(req)
if err != nil {
t.Fatalf("Me request failed: %v", err)
}
defer meResp.Body.Close()
if meResp.StatusCode != http.StatusOK {
t.Fatalf("Expected 200 OK for /auth/me, got %d", meResp.StatusCode)
}
var meBody map[string]any
if err := json.NewDecoder(meResp.Body).Decode(&meBody); err != nil {
t.Fatalf("decode /auth/me: %v", err)
}
caps, ok := meBody["capabilities"].([]any)
if !ok {
t.Fatalf("expected capabilities array in /auth/me, got %T", meBody["capabilities"])
}
_ = caps // memory mode: empty slice is valid
}
func TestAuthMe_LogoutNextRequestIs401(t *testing.T) {
ts, _ := setupTestServer(t)
body, _ := json.Marshal(map[string]string{"username": "admin", "password": "admin123"})
loginResp, err := http.Post(ts.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer loginResp.Body.Close()
if loginResp.StatusCode != http.StatusOK {
t.Fatalf("login: %d", loginResp.StatusCode)
}
var session auth.SessionInfo
if err := json.NewDecoder(loginResp.Body).Decode(&session); err != nil {
t.Fatal(err)
}
me := func() int {
t.Helper()
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+session.Token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
return resp.StatusCode
}
if code := me(); code != http.StatusOK {
t.Fatalf("me before logout: %d", code)
}
logoutReq, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/auth/logout", nil)
logoutReq.Header.Set("Authorization", "Bearer "+session.Token)
logoutResp, err := http.DefaultClient.Do(logoutReq)
if err != nil {
t.Fatal(err)
}
defer logoutResp.Body.Close()
if logoutResp.StatusCode != http.StatusOK {
t.Fatalf("logout: %d", logoutResp.StatusCode)
}
if code := me(); code != http.StatusUnauthorized {
t.Fatalf("me after logout must be 401, got %d", code)
}
}
func TestAuthHandlers_RegisterAndApproval(t *testing.T) {
ts, authSvc := setupTestServer(t)
// 1. Turn on RequireApproval
_ = authSvc.UpdateConfig(t.Context(), auth.SystemAuthConfig{
AllowRegistration: true,
RequireApproval: true,
})
// 2. Register user
regReq := map[string]string{
"username": "new_doctor",
"password": "DoctorPass123",
"name": "王医生",
}
body, _ := json.Marshal(regReq)
resp, err := http.Post(ts.URL+"/api/v1/auth/register", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("Register request failed: %v", err)
}
defer resp.Body.Close()
var regResp map[string]any
_ = json.NewDecoder(resp.Body).Decode(&regResp)
if regResp["status"] != "pending_approval" {
t.Errorf("Expected pending_approval, got %v", regResp["status"])
}
// 3. Trying to login should fail because of pending approval
loginBody, _ := json.Marshal(map[string]string{
"username": "new_doctor",
"password": "DoctorPass123",
})
loginResp, _ := http.Post(ts.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(loginBody))
if loginResp.StatusCode != http.StatusForbidden {
t.Errorf("Expected 403 Forbidden for pending user, got %d", loginResp.StatusCode)
}
}
func TestAuthHandlers_RegisterDuplicateUsername(t *testing.T) {
ts, _ := setupTestServer(t)
body, _ := json.Marshal(map[string]string{
"username": "dup_user",
"password": "DupPass123",
"name": "重复用户",
})
first, err := http.Post(ts.URL+"/api/v1/auth/register", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("first register: %v", err)
}
defer first.Body.Close()
if first.StatusCode != http.StatusOK {
t.Fatalf("first register status %d", first.StatusCode)
}
secondBody, _ := json.Marshal(map[string]string{
"username": "dup_user",
"password": "DupPass123",
"name": "重复用户",
})
second, err := http.Post(ts.URL+"/api/v1/auth/register", "application/json", bytes.NewReader(secondBody))
if err != nil {
t.Fatalf("second register: %v", err)
}
defer second.Body.Close()
if second.StatusCode != http.StatusBadRequest {
t.Fatalf("duplicate username must be 400, got %d", second.StatusCode)
}
var env map[string]any
if err := json.NewDecoder(second.Body).Decode(&env); err != nil {
t.Fatalf("decode: %v", err)
}
errText, _ := env["error"].(string)
if !bytes.Contains([]byte(errText), []byte("已存在")) {
t.Fatalf("expected error to mention 已存在, got %#v", env)
}
if env["code"] != "AUTH_USERNAME_EXISTS" {
t.Fatalf("expected AUTH_USERNAME_EXISTS, got %#v", env["code"])
}
}
func TestAuthHandlers_AdminCreateUser_MustChangePassword(t *testing.T) {
ts, _ := setupTestServer(t)
// 1. Admin login to obtain token
loginBody, _ := json.Marshal(map[string]string{
"username": "admin",
"password": "admin123",
})
resp, _ := http.Post(ts.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(loginBody))
var adminSession auth.SessionInfo
_ = json.NewDecoder(resp.Body).Decode(&adminSession)
// 2. Admin creates a new user
createUserReq := map[string]string{
"username": "qa_engineer",
"name": "周质量",
"initial_password": "TempPassword123",
"role_id": "role-director",
"position": "质量总监",
}
reqBody, _ := json.Marshal(createUserReq)
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/admin/users", bytes.NewReader(reqBody))
req.Header.Set("Authorization", "Bearer "+adminSession.Token)
client := &http.Client{}
createResp, err := client.Do(req)
if err != nil {
t.Fatalf("Admin create user request failed: %v", err)
}
defer createResp.Body.Close()
if createResp.StatusCode != http.StatusOK {
t.Fatalf("Expected 200 OK, got %d", createResp.StatusCode)
}
// 3. New user logs in with initial password -> must_change_password must be true
userLoginBody, _ := json.Marshal(map[string]string{
"username": "qa_engineer",
"password": "TempPassword123",
})
uResp, _ := http.Post(ts.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(userLoginBody))
var uSession auth.SessionInfo
_ = json.NewDecoder(uResp.Body).Decode(&uSession)
if !uSession.MustChangePassword {
t.Errorf("Expected MustChangePassword to be true for admin created user")
}
// 4. Change password
changeBody, _ := json.Marshal(map[string]string{
"old_password": "TempPassword123",
"new_password": "NewPermanentPass888",
})
chReq, _ := http.NewRequest("POST", ts.URL+"/api/v1/auth/change-password", bytes.NewReader(changeBody))
chReq.Header.Set("Authorization", "Bearer "+uSession.Token)
chResp, err := client.Do(chReq)
if err != nil || chResp.StatusCode != http.StatusOK {
t.Fatalf("Change password failed: %v", err)
}
// 5. Login again with new password -> must_change_password must be false
newLoginBody, _ := json.Marshal(map[string]string{
"username": "qa_engineer",
"password": "NewPermanentPass888",
})
nResp, _ := http.Post(ts.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(newLoginBody))
var nSession auth.SessionInfo
_ = json.NewDecoder(nResp.Body).Decode(&nSession)
if nSession.MustChangePassword {
t.Errorf("Expected MustChangePassword to be false after changing password")
}
}
func TestAuthHandlers_AdminResetPassword(t *testing.T) {
ts, authSvc := setupTestServer(t)
client := &http.Client{}
adminLoginBody, _ := json.Marshal(map[string]string{"username": "admin", "password": "admin123"})
adminResp, err := http.Post(ts.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(adminLoginBody))
if err != nil {
t.Fatalf("admin login: %v", err)
}
var adminSession auth.SessionInfo
_ = json.NewDecoder(adminResp.Body).Decode(&adminSession)
adminResp.Body.Close()
if adminSession.Token == "" {
t.Fatal("admin token empty")
}
created, err := authSvc.CreateUserByAdmin(t.Context(), auth.CreateUserRequest{
Username: "reset_target",
Name: "重置目标",
InitialPassword: "OldPassword111",
RoleID: "role-engineer",
Position: "方案工程师",
})
if err != nil {
t.Fatalf("create target: %v", err)
}
oldLoginBody, _ := json.Marshal(map[string]string{"username": "reset_target", "password": "OldPassword111"})
oldLoginResp, _ := http.Post(ts.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(oldLoginBody))
var oldSession auth.SessionInfo
_ = json.NewDecoder(oldLoginResp.Body).Decode(&oldSession)
oldLoginResp.Body.Close()
if oldSession.Token == "" {
t.Fatal("target old session empty")
}
resetReq, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/admin/users/"+itoa64(created.ID)+"/reset-password", bytes.NewReader([]byte("{}")))
resetReq.Header.Set("Authorization", "Bearer "+adminSession.Token)
resetReq.Header.Set("Content-Type", "application/json")
resetResp, err := client.Do(resetReq)
if err != nil {
t.Fatalf("reset request: %v", err)
}
defer resetResp.Body.Close()
if resetResp.StatusCode != http.StatusOK {
t.Fatalf("reset status=%d", resetResp.StatusCode)
}
var resetBody auth.AdminResetPasswordResult
if err := json.NewDecoder(resetResp.Body).Decode(&resetBody); err != nil {
t.Fatalf("decode reset: %v", err)
}
if resetBody.TempPassword == "" || !resetBody.MustChangePassword || resetBody.UserID != created.ID {
t.Fatalf("unexpected reset body: %+v", resetBody)
}
// Old password no longer works.
badResp, _ := http.Post(ts.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(oldLoginBody))
if badResp.StatusCode != http.StatusUnauthorized {
t.Fatalf("old password expected 401, got %d", badResp.StatusCode)
}
badResp.Body.Close()
// Old session revoked.
meReq, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/auth/me", nil)
meReq.Header.Set("Authorization", "Bearer "+oldSession.Token)
meResp, _ := client.Do(meReq)
if meResp.StatusCode != http.StatusUnauthorized {
t.Fatalf("old session expected 401, got %d", meResp.StatusCode)
}
meResp.Body.Close()
// Temp password works and forces change.
tempLoginBody, _ := json.Marshal(map[string]string{"username": "reset_target", "password": resetBody.TempPassword})
tempResp, _ := http.Post(ts.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(tempLoginBody))
var tempSession auth.SessionInfo
_ = json.NewDecoder(tempResp.Body).Decode(&tempSession)
tempResp.Body.Close()
if tempResp.StatusCode != http.StatusOK || !tempSession.MustChangePassword {
t.Fatalf("temp login status=%d must_change=%v", tempResp.StatusCode, tempSession.MustChangePassword)
}
// Non-admin forbidden.
memberLoginBody, _ := json.Marshal(map[string]string{"username": "zhanggong", "password": "123456"})
memberResp, _ := http.Post(ts.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(memberLoginBody))
var memberSession auth.SessionInfo
_ = json.NewDecoder(memberResp.Body).Decode(&memberSession)
memberResp.Body.Close()
denyReq, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/admin/users/"+itoa64(created.ID)+"/reset-password", bytes.NewReader([]byte("{}")))
denyReq.Header.Set("Authorization", "Bearer "+memberSession.Token)
denyReq.Header.Set("Content-Type", "application/json")
denyResp, _ := client.Do(denyReq)
if denyResp.StatusCode != http.StatusForbidden {
t.Fatalf("non-admin expected 403, got %d", denyResp.StatusCode)
}
denyResp.Body.Close()
// Missing user.
missingReq, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/admin/users/999999/reset-password", bytes.NewReader([]byte("{}")))
missingReq.Header.Set("Authorization", "Bearer "+adminSession.Token)
missingReq.Header.Set("Content-Type", "application/json")
missingResp, _ := client.Do(missingReq)
if missingResp.StatusCode != http.StatusNotFound && missingResp.StatusCode != http.StatusBadRequest {
t.Fatalf("missing user expected 404/400, got %d", missingResp.StatusCode)
}
missingResp.Body.Close()
}
func itoa64(v int64) string {
return strconv.FormatInt(v, 10)
}