Files
ld 63ede5c237 refactor(api): enforce protocol-neutral service boundaries
- refactor: move HTTP status and DTO concerns out of model and service
  layers into API and handler code.
- feat: add admin service boundary and route auth through services
  instead of direct repository access.
- test: add architecture and error tests covering package boundaries,
  redaction, and service behavior.
- docs: record the protocol-neutral boundary decision and update
  architecture and roadmap notes.
2026-07-05 23:30:17 +08:00

332 lines
9.6 KiB
Go

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)
adminService := service.NewAdminService(userRepo)
adminHandler := NewAdminHandler(adminService)
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(authService))
admin.Use(middleware.AdminRequired())
{
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 testTokenPairResponse
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")
}
}