From 7b6587a951e8a3e3511617239c7b5f15a0dbf28c Mon Sep 17 00:00:00 2001 From: Huxley Date: Sat, 4 Jul 2026 17:31:01 +0800 Subject: [PATCH] test(handler): TDD tests for AdminHandler user CRUD and visibility Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- internal/handler/admin_test.go | 330 +++++++++++++++++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 internal/handler/admin_test.go diff --git a/internal/handler/admin_test.go b/internal/handler/admin_test.go new file mode 100644 index 0000000..76c50fb --- /dev/null +++ b/internal/handler/admin_test.go @@ -0,0 +1,330 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/dhao2001/mygo/internal/middleware" + "github.com/dhao2001/mygo/internal/model" + "github.com/dhao2001/mygo/internal/repository" + "github.com/dhao2001/mygo/internal/service" +) + +func setupAdminRouter(t *testing.T) (*gin.Engine, repository.UserRepository) { + t.Helper() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.AutoMigrate(&model.User{}, &model.Session{}, &model.Credential{}); err != nil { + t.Fatalf("migrate: %v", err) + } + + secret := []byte("test-secret") + userRepo := repository.NewUserRepository(db) + authService := service.NewAuthService( + userRepo, + repository.NewSessionRepository(db), + repository.NewCredentialRepository(db), + secret, + 15*time.Minute, + 7*24*time.Hour, + ) + + authHandler := NewAuthHandler(authService) + adminHandler := NewAdminHandler(userRepo) + + gin.SetMode(gin.TestMode) + r := gin.New() + + auth := r.Group("/api/v1/auth") + { + auth.POST("/register", authHandler.Register) + auth.POST("/login", authHandler.Login) + } + + admin := r.Group("/api/v1/admin") + admin.Use(middleware.AuthRequired(secret)) + admin.Use(middleware.AdminRequired(userRepo)) + { + admin.GET("/users", adminHandler.ListUsers) + admin.GET("/users/:id", adminHandler.GetUser) + admin.DELETE("/users/:id", adminHandler.DeleteUser) + } + + return r, userRepo +} + +// registerHelper creates a user via the HTTP endpoint and returns the user ID. +func registerHelper(t *testing.T, r *gin.Engine, username, email, password string) string { + t.Helper() + + body, _ := json.Marshal(gin.H{ + "username": username, + "email": email, + "password": password, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("register %s: status = %d, body = %s", username, rec.Code, rec.Body.String()) + } + + var user model.User + if err := json.Unmarshal(rec.Body.Bytes(), &user); err != nil { + t.Fatalf("unmarshal register response: %v", err) + } + return user.ID +} + +// loginHelper logs in a user and returns the access token. +func loginHelper(t *testing.T, r *gin.Engine, email, password string) string { + t.Helper() + + body, _ := json.Marshal(gin.H{"email": email, "password": password}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("login %s: status = %d, body = %s", email, rec.Code, rec.Body.String()) + } + + var pair service.TokenPair + if err := json.Unmarshal(rec.Body.Bytes(), &pair); err != nil { + t.Fatalf("unmarshal login response: %v", err) + } + return pair.AccessToken +} + +// makeAdminHelper promotes a user to admin in the database. +func makeAdminHelper(t *testing.T, userRepo repository.UserRepository, userID string) { + t.Helper() + + user, err := userRepo.FindByID(context.Background(), userID) + if err != nil { + t.Fatalf("find user %s: %v", userID, err) + } + user.IsAdmin = true + if err := userRepo.Update(context.Background(), user); err != nil { + t.Fatalf("update user: %v", err) + } +} + +func TestAdminDeleteUser(t *testing.T) { + r, userRepo := setupAdminRouter(t) + + adminID := registerHelper(t, r, "admin", "admin@example.com", "adminpass123") + makeAdminHelper(t, userRepo, adminID) + + userID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123") + + adminToken := loginHelper(t, r, "admin@example.com", "adminpass123") + + // Delete regular user as admin + req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/users/"+userID, nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Errorf("delete: status = %d, want %d; body = %s", rec.Code, http.StatusNoContent, rec.Body.String()) + } + + // Verify regular user cannot login (soft-deleted) + loginBody, _ := json.Marshal(gin.H{"email": "bob@example.com", "password": "bobpass123"}) + req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("login after soft-delete: status = %d, want %d; body = %s", rec.Code, http.StatusUnauthorized, rec.Body.String()) + } +} + +func TestAdminDeleteUserForbidden(t *testing.T) { + r, userRepo := setupAdminRouter(t) + + adminID := registerHelper(t, r, "admin", "admin@example.com", "adminpass123") + makeAdminHelper(t, userRepo, adminID) + + userID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123") + + // Login as regular user (non-admin) + userToken := loginHelper(t, r, "bob@example.com", "bobpass123") + + // Attempt to delete as non-admin + req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/users/"+userID, nil) + req.Header.Set("Authorization", "Bearer "+userToken) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusForbidden, rec.Body.String()) + } +} + +func TestAdminDeleteUserUnauthorized(t *testing.T) { + r, _ := setupAdminRouter(t) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/users/some-id", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusUnauthorized, rec.Body.String()) + } +} + +func TestAdminGetUser(t *testing.T) { + r, userRepo := setupAdminRouter(t) + + adminID := registerHelper(t, r, "admin", "admin@example.com", "adminpass123") + makeAdminHelper(t, userRepo, adminID) + + userID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123") + + adminToken := loginHelper(t, r, "admin@example.com", "adminpass123") + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/users/"+userID, nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("get user: status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + IsAdmin bool `json:"is_admin"` + Status string `json:"status"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if resp.Username != "bob" { + t.Errorf("username = %q, want %q", resp.Username, "bob") + } + if resp.Status != model.StatusActive { + t.Errorf("status = %q, want %q", resp.Status, model.StatusActive) + } +} + +func TestAdminGetDeletedUser(t *testing.T) { + r, userRepo := setupAdminRouter(t) + + adminID := registerHelper(t, r, "admin", "admin@example.com", "adminpass123") + makeAdminHelper(t, userRepo, adminID) + + userID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123") + + // Soft-delete regular user directly via repo + if err := userRepo.Delete(context.Background(), userID); err != nil { + t.Fatalf("soft-delete user: %v", err) + } + + adminToken := loginHelper(t, r, "admin@example.com", "adminpass123") + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/users/"+userID, nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("get deleted user: status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Status string `json:"status"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if resp.Status != model.StatusAdminDeleted { + t.Errorf("status = %q, want %q", resp.Status, model.StatusAdminDeleted) + } +} + +func TestAdminListIncludesDeleted(t *testing.T) { + r, userRepo := setupAdminRouter(t) + + adminID := registerHelper(t, r, "admin", "admin@example.com", "adminpass123") + makeAdminHelper(t, userRepo, adminID) + + // Create 2 regular users + _ = registerHelper(t, r, "alice", "alice@example.com", "alicepass123") + bobID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123") + + // Soft-delete bob + if err := userRepo.Delete(context.Background(), bobID); err != nil { + t.Fatalf("soft-delete user: %v", err) + } + + adminToken := loginHelper(t, r, "admin@example.com", "adminpass123") + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/users", nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("list users: status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Users []struct { + ID string `json:"id"` + Status string `json:"status"` + } `json:"users"` + Total int64 `json:"total"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + // Total should be 3: admin + alice (active) + bob (soft-deleted) + if resp.Total != 3 { + t.Errorf("total = %d, want %d", resp.Total, 3) + } + + // Verify both active and soft-deleted users appear + foundAlice := false + foundBob := false + for _, u := range resp.Users { + switch u.Status { + case model.StatusActive: + // alice or admin + foundAlice = true + case model.StatusAdminDeleted: + if u.ID == bobID { + foundBob = true + } + } + } + if !foundAlice { + t.Error("active user not found in list") + } + if !foundBob { + t.Error("soft-deleted user not found in list") + } +}