442 lines
15 KiB
Go
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(®Resp)
|
|
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)
|
|
}
|