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) }