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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user