Compare commits

...

10 Commits

Author SHA1 Message Date
ld 5eeb37389b fix(config): harden default JWT secret
Replace known development JWT secret placeholders with an ephemeral runtime secret during config loading.

Return LoadInfo so startup can warn when token persistence depends on a stable configured secret.

Update config docs and tests for default, legacy placeholder, custom, env, and empty secret behavior.
2026-07-05 13:25:44 +08:00
ld 7b6587a951 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>
2026-07-04 17:31:01 +08:00
ld 3daad379e0 test(server): add admin route integration test 2026-07-04 17:23:35 +08:00
ld e04e39bea8 feat(svc): implement soft-delete in FileService
Delete now performs soft-delete (status='user_deleted') instead of physical deletion.

Removes storage content deletion — files are preserved on disk after soft-delete.

Second delete on already-deleted file returns nil (idempotent).

Add tests: SoftDelete, SoftDeleteKeepsStorage, SoftDeleteIdempotent, SoftDeleteForbidden.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-04 17:20:34 +08:00
ld ac98b5ddbd feat(middleware): add AdminRequired authorization middleware
AdminRequired gates admin endpoints by checking user IsAdmin flag.

Placed AFTER AuthRequired; fetches user from repository, returns 403 for non-admins.

Soft-deleted users are rejected with 401 since FindByID excludes them.

Tests: admin passes, non-admin forbidden, soft-deleted admin rejected, missing user ID.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-04 17:20:30 +08:00
ld 032f716e49 feat(repo): add soft-delete with status filter to file queries
File repository now filters by status=active in all query methods (FindByID, FindByUserID, FindByParentID, FindByNameAndParent).

Delete now performs soft-delete by setting status to 'user_deleted' instead of physical row deletion.

Add ListIncludeDeleted to UserRepository for admin views of all users.

Add tests: StatusFilter, SoftDelete, DeleteIdempotent, StatusFilterCount.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-04 17:20:22 +08:00
ld a8078f787c feat(model): add Status field and constants to User and File
Add Status field (gorm, indexed, default active) to both User and File models.

Add User status constants: StatusActive, StatusAdminDeleted.

Add File status constant: StatusUserDeleted.

Add tests verifying Status field is excluded from JSON serialization.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-04 17:20:14 +08:00
ld 170e54495d feat(handler): add AdminHandler with admin routes 2026-07-04 17:18:23 +08:00
ld 118b7e4d2a feat(svc): reject disabled users in Login, LoginWithPasskey, and Refresh 2026-07-04 17:13:02 +08:00
ld bff131ba42 feat(repo): soft-delete User with status filter on queries 2026-07-04 17:08:10 +08:00
24 changed files with 1567 additions and 56 deletions
+6 -1
View File
@@ -23,7 +23,7 @@ var serveCmd = &cobra.Command{
Short: "Start the MyGO HTTP server", Short: "Start the MyGO HTTP server",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
v := config.New() v := config.New()
cfg, err := config.Load(v, serveConfigFile) cfg, loadInfo, err := config.Load(v, serveConfigFile)
if err != nil { if err != nil {
return fmt.Errorf("load config: %w", err) return fmt.Errorf("load config: %w", err)
} }
@@ -32,6 +32,11 @@ var serveCmd = &cobra.Command{
appLogger := mygolog.NewLogger(cfg.Log) appLogger := mygolog.NewLogger(cfg.Log)
slog.SetDefault(appLogger) slog.SetDefault(appLogger)
slog.Info("mygo server starting") slog.Info("mygo server starting")
if loadInfo.EphemeralJWTSecret {
slog.Warn("jwt.secret is a development placeholder; using an ephemeral runtime secret. " +
"Set a stable jwt.secret or MYGO_JWT_SECRET for production, multi-instance deployments, " +
"and token persistence across restarts")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() defer stop()
+3 -1
View File
@@ -28,6 +28,8 @@ log:
file_level: debug # file: debug, info, warn, error file_level: debug # file: debug, info, warn, error
jwt: jwt:
secret: change-me-in-production # Development placeholder. MyGO replaces this with an ephemeral runtime
# secret at startup; set a stable value for production.
secret: dev-secret-do-not-use-in-production
access_ttl: 15m access_ttl: 15m
refresh_ttl: 168h refresh_ttl: 168h
+2
View File
@@ -59,9 +59,11 @@
|----------|----------| |----------|----------|
| One handler per route group | `AuthHandler` owns `/auth/*` (public); `AccountHandler` owns `/account/*` (protected). A route group maps 1:1 to a handler type. | | One handler per route group | `AuthHandler` owns `/auth/*` (public); `AccountHandler` owns `/account/*` (protected). A route group maps 1:1 to a handler type. |
| JWT `type` claim | `Claims.Type` distinguishes access from refresh tokens. Middleware and service enforce the correct type at their respective boundaries. `ParseToken` does no type check — it verifies cryptographic validity only. | | JWT `type` claim | `Claims.Type` distinguishes access from refresh tokens. Middleware and service enforce the correct type at their respective boundaries. `ParseToken` does no type check — it verifies cryptographic validity only. |
| Default JWT secret hardening | The development placeholder `jwt.secret` is replaced with an ephemeral runtime secret during config loading. Production and multi-instance deployments must set a stable secret. |
| `time.Duration` in config structs | Config fields representing durations use `time.Duration` directly. Viper's built-in `StringToTimeDurationHookFunc` handles string→Duration conversion at unmarshal time. No accessor methods, no runtime parsing. Invalid values fail at startup via `Load()`. | | `time.Duration` in config structs | Config fields representing durations use `time.Duration` directly. Viper's built-in `StringToTimeDurationHookFunc` handles string→Duration conversion at unmarshal time. No accessor methods, no runtime parsing. Invalid values fail at startup via `Load()`. |
**Consequences**: **Consequences**:
- Handlers are independently extensible (caching, rate limiting scoped per handler). - Handlers are independently extensible (caching, rate limiting scoped per handler).
- Refresh tokens cannot authenticate API requests; access tokens cannot be used to issue new token pairs. - Refresh tokens cannot authenticate API requests; access tokens cannot be used to issue new token pairs.
- The placeholder JWT secret is safe for local startup, but tokens signed with it are invalidated on restart.
- New duration config fields require zero boilerplate — declare as `time.Duration` in the struct. - New duration config fields require zero boilerplate — declare as `time.Duration` in the struct.
+6 -1
View File
@@ -52,9 +52,14 @@ storage:
path: data/files path: data/files
jwt: jwt:
secret: changeme-in-production secret: dev-secret-do-not-use-in-production
access_ttl: 15m access_ttl: 15m
refresh_ttl: 168h refresh_ttl: 168h
``` ```
Environment variables use `MYGO_` prefix with underscore separators: `MYGO_SERVER_PORT=8080`, `MYGO_JWT_SECRET=...` Environment variables use `MYGO_` prefix with underscore separators: `MYGO_SERVER_PORT=8080`, `MYGO_JWT_SECRET=...`
The default JWT secret is a development placeholder. At startup, MyGO replaces
it with an ephemeral runtime secret; set a stable `jwt.secret` or
`MYGO_JWT_SECRET` when tokens must survive restarts or when running multiple
instances.
+55 -6
View File
@@ -1,6 +1,8 @@
package config package config
import ( import (
"crypto/rand"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
@@ -8,6 +10,17 @@ import (
"github.com/spf13/viper" "github.com/spf13/viper"
) )
// DefaultJWTSecret is a development-only placeholder. Load replaces it with
// an ephemeral secret before returning a usable Config.
const DefaultJWTSecret = "dev-secret-do-not-use-in-production"
// LoadInfo describes runtime decisions made while loading configuration.
type LoadInfo struct {
// EphemeralJWTSecret is true when a placeholder jwt.secret was replaced
// with a random in-memory value for the current process.
EphemeralJWTSecret bool
}
func defaults(v *viper.Viper) { func defaults(v *viper.Viper) {
v.SetDefault("server.host", "0.0.0.0") v.SetDefault("server.host", "0.0.0.0")
v.SetDefault("server.port", 10086) v.SetDefault("server.port", 10086)
@@ -24,7 +37,7 @@ func defaults(v *viper.Viper) {
v.SetDefault("storage.driver", "local") v.SetDefault("storage.driver", "local")
v.SetDefault("storage.local.path", "data/files") v.SetDefault("storage.local.path", "data/files")
v.SetDefault("jwt.secret", "dev-secret-do-not-use-in-production") v.SetDefault("jwt.secret", DefaultJWTSecret)
v.SetDefault("jwt.access_ttl", "15m") v.SetDefault("jwt.access_ttl", "15m")
v.SetDefault("jwt.refresh_ttl", "168h") v.SetDefault("jwt.refresh_ttl", "168h")
@@ -42,7 +55,7 @@ func New() *viper.Viper {
return v return v
} }
func Load(v *viper.Viper, cfgFile string) (*Config, error) { func Load(v *viper.Viper, cfgFile string) (*Config, LoadInfo, error) {
if cfgFile != "" { if cfgFile != "" {
v.SetConfigFile(cfgFile) v.SetConfigFile(cfgFile)
} else { } else {
@@ -54,18 +67,54 @@ func Load(v *viper.Viper, cfgFile string) (*Config, error) {
if err := v.ReadInConfig(); err != nil { if err := v.ReadInConfig(); err != nil {
var notFound viper.ConfigFileNotFoundError var notFound viper.ConfigFileNotFoundError
if !errors.As(err, &notFound) { if !errors.As(err, &notFound) {
return nil, fmt.Errorf("read config: %w", err) return nil, LoadInfo{}, fmt.Errorf("read config: %w", err)
} }
} }
var cfg Config var cfg Config
if err := v.Unmarshal(&cfg); err != nil { if err := v.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("unmarshal config: %w", err) return nil, LoadInfo{}, fmt.Errorf("unmarshal config: %w", err)
}
info, err := hardenRuntimeSecrets(&cfg)
if err != nil {
return nil, LoadInfo{}, err
} }
if err := cfg.Validate(); err != nil { if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("validate config: %w", err) return nil, LoadInfo{}, fmt.Errorf("validate config: %w", err)
} }
return &cfg, nil return &cfg, info, nil
}
func hardenRuntimeSecrets(cfg *Config) (LoadInfo, error) {
if !isWeakJWTSecret(cfg.JWT.Secret) {
return LoadInfo{}, nil
}
secret, err := generateEphemeralJWTSecret()
if err != nil {
return LoadInfo{}, fmt.Errorf("generate ephemeral jwt secret: %w", err)
}
cfg.JWT.Secret = secret
return LoadInfo{EphemeralJWTSecret: true}, nil
}
func isWeakJWTSecret(secret string) bool {
switch secret {
case DefaultJWTSecret, "change-me-in-production", "changeme-in-production":
return true
default:
return false
}
}
func generateEphemeralJWTSecret() (string, error) {
var secret [32]byte
if _, err := rand.Read(secret[:]); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(secret[:]), nil
} }
+59 -7
View File
@@ -9,7 +9,7 @@ import (
func TestDefaults(t *testing.T) { func TestDefaults(t *testing.T) {
v := New() v := New()
cfg, err := Load(v, "") cfg, info, err := Load(v, "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -36,6 +36,16 @@ func TestDefaults(t *testing.T) {
} }
}) })
} }
if !info.EphemeralJWTSecret {
t.Fatal("expected default jwt.secret to be replaced with an ephemeral secret")
}
if cfg.JWT.Secret == DefaultJWTSecret {
t.Fatal("default jwt.secret was not replaced")
}
if cfg.JWT.Secret == "" {
t.Fatal("ephemeral jwt.secret is empty")
}
} }
func TestFromYAML(t *testing.T) { func TestFromYAML(t *testing.T) {
@@ -67,10 +77,13 @@ jwt:
} }
v := New() v := New()
cfg, err := Load(v, path) cfg, info, err := Load(v, path)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if info.EphemeralJWTSecret {
t.Fatal("custom jwt.secret should not be replaced")
}
if cfg.Server.Host != "127.0.0.1" { if cfg.Server.Host != "127.0.0.1" {
t.Errorf("server.host = %q, want %q", cfg.Server.Host, "127.0.0.1") t.Errorf("server.host = %q, want %q", cfg.Server.Host, "127.0.0.1")
@@ -102,10 +115,13 @@ func TestEnvOverride(t *testing.T) {
t.Setenv("MYGO_DATABASE_SQLITE_PATH", "/env/path/db.sqlite") t.Setenv("MYGO_DATABASE_SQLITE_PATH", "/env/path/db.sqlite")
v := New() v := New()
cfg, err := Load(v, "") cfg, info, err := Load(v, "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if info.EphemeralJWTSecret {
t.Fatal("env jwt.secret should not be replaced")
}
if cfg.Server.Port != 8080 { if cfg.Server.Port != 8080 {
t.Errorf("server.port = %d, want %d", cfg.Server.Port, 8080) t.Errorf("server.port = %d, want %d", cfg.Server.Port, 8080)
@@ -137,7 +153,7 @@ server:
t.Setenv("MYGO_SERVER_PORT", "9999") t.Setenv("MYGO_SERVER_PORT", "9999")
v := New() v := New()
cfg, err := Load(v, path) cfg, _, err := Load(v, path)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -164,7 +180,7 @@ server:
} }
v := New() v := New()
_, err := Load(v, path) _, _, err := Load(v, path)
if err == nil { if err == nil {
t.Fatal("expected error for port 0, got nil") t.Fatal("expected error for port 0, got nil")
} }
@@ -183,7 +199,7 @@ jwt:
} }
v := New() v := New()
_, err := Load(v, path) _, _, err := Load(v, path)
if err == nil { if err == nil {
t.Fatal("expected error for empty jwt.secret, got nil") t.Fatal("expected error for empty jwt.secret, got nil")
} }
@@ -191,12 +207,48 @@ jwt:
func TestExplicitConfigFileNotFound(t *testing.T) { func TestExplicitConfigFileNotFound(t *testing.T) {
v := New() v := New()
_, err := Load(v, "/nonexistent/path/config.yaml") _, _, err := Load(v, "/nonexistent/path/config.yaml")
if err == nil { if err == nil {
t.Fatal("expected error when explicitly specifying a nonexistent config file") t.Fatal("expected error when explicitly specifying a nonexistent config file")
} }
} }
func TestWeakJWTSecretsAreReplaced(t *testing.T) {
tests := []string{
DefaultJWTSecret,
"change-me-in-production",
"changeme-in-production",
}
for _, secret := range tests {
t.Run(secret, func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
yaml := "jwt:\n secret: " + secret + "\n"
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
t.Fatal(err)
}
v := New()
cfg, info, err := Load(v, path)
if err != nil {
t.Fatal(err)
}
if !info.EphemeralJWTSecret {
t.Fatal("expected weak jwt.secret to be replaced")
}
if cfg.JWT.Secret == secret {
t.Fatal("weak jwt.secret was not replaced")
}
if cfg.JWT.Secret == "" {
t.Fatal("ephemeral jwt.secret is empty")
}
})
}
}
func TestJWTConfigAccessDuration(t *testing.T) { func TestJWTConfigAccessDuration(t *testing.T) {
j := JWTConfig{AccessTTL: 15 * time.Minute} j := JWTConfig{AccessTTL: 15 * time.Minute}
if j.AccessTTL != 15*time.Minute { if j.AccessTTL != 15*time.Minute {
+111
View File
@@ -0,0 +1,111 @@
package handler
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/dhao2001/mygo/internal/api"
"github.com/dhao2001/mygo/internal/model"
"github.com/dhao2001/mygo/internal/repository"
)
// AdminHandler handles admin-only endpoints for user management.
type AdminHandler struct {
userRepo repository.UserRepository
}
// NewAdminHandler creates an AdminHandler.
func NewAdminHandler(userRepo repository.UserRepository) *AdminHandler {
return &AdminHandler{userRepo: userRepo}
}
// adminUserResponse exposes all user fields, including status, for admin views.
type adminUserResponse struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
IsAdmin bool `json:"is_admin"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// adminUserListResponse wraps a paginated list of admin user views.
type adminUserListResponse struct {
Users []adminUserResponse `json:"users"`
Total int64 `json:"total"`
}
func toAdminResponse(u *model.User) adminUserResponse {
return adminUserResponse{
ID: u.ID,
Username: u.Username,
Email: u.Email,
IsAdmin: u.IsAdmin,
Status: u.Status,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
}
// DeleteUser handles DELETE /api/v1/admin/users/:id — soft-deletes a user.
func (h *AdminHandler) DeleteUser(c *gin.Context) {
id := c.Param("id")
if err := h.userRepo.Delete(c.Request.Context(), id); err != nil {
api.RespondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// GetUser handles GET /api/v1/admin/users/:id — returns a user including deleted.
func (h *AdminHandler) GetUser(c *gin.Context) {
id := c.Param("id")
user, err := h.userRepo.FindByIDIncludeDeleted(c.Request.Context(), id)
if err != nil {
api.RespondError(c, err)
return
}
c.JSON(http.StatusOK, toAdminResponse(user))
}
// ListUsers handles GET /api/v1/admin/users — returns a paginated list of all users.
func (h *AdminHandler) ListUsers(c *gin.Context) {
offset, err := strconv.Atoi(c.DefaultQuery("offset", "0"))
if err != nil || offset < 0 {
api.Error(c, http.StatusBadRequest, "invalid offset")
return
}
limit, err := strconv.Atoi(c.DefaultQuery("limit", "50"))
if err != nil || limit < 1 {
api.Error(c, http.StatusBadRequest, "invalid limit")
return
}
if limit > 200 {
limit = 200
}
users, total, err := h.userRepo.ListIncludeDeleted(c.Request.Context(), offset, limit)
if err != nil {
api.RespondError(c, err)
return
}
items := make([]adminUserResponse, 0, len(users))
for i := range users {
items = append(items, toAdminResponse(&users[i]))
}
c.JSON(http.StatusOK, adminUserListResponse{
Users: items,
Total: total,
})
}
+330
View File
@@ -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")
}
}
+31
View File
@@ -8,6 +8,7 @@ import (
"github.com/dhao2001/mygo/internal/api" "github.com/dhao2001/mygo/internal/api"
"github.com/dhao2001/mygo/internal/auth" "github.com/dhao2001/mygo/internal/auth"
"github.com/dhao2001/mygo/internal/repository"
) )
const userIDKey = "user_id" const userIDKey = "user_id"
@@ -68,3 +69,33 @@ func MustGetUserID(c *gin.Context) string {
} }
return userID return userID
} }
// AdminRequired returns a Gin middleware that gates access to admin-only
// endpoints. It must be applied AFTER AuthRequired. It fetches the user from
// the repository, and returns 403 if the user is not an admin. Soft-deleted
// users are not found by FindByID and will receive a 401 response.
func AdminRequired(userRepo repository.UserRepository) gin.HandlerFunc {
return func(c *gin.Context) {
userID := GetUserID(c)
if userID == "" {
api.Error(c, http.StatusUnauthorized, "missing user context")
c.Abort()
return
}
user, err := userRepo.FindByID(c.Request.Context(), userID)
if err != nil {
api.Error(c, http.StatusUnauthorized, "user not found")
c.Abort()
return
}
if !user.IsAdmin {
api.Error(c, http.StatusForbidden, "admin access required")
c.Abort()
return
}
c.Next()
}
}
+165
View File
@@ -1,6 +1,8 @@
package middleware package middleware
import ( import (
"context"
"encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -8,8 +10,12 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/dhao2001/mygo/internal/auth" "github.com/dhao2001/mygo/internal/auth"
"github.com/dhao2001/mygo/internal/model"
"github.com/dhao2001/mygo/internal/repository"
) )
func setupTestRouter(secret []byte) *gin.Engine { func setupTestRouter(secret []byte) *gin.Engine {
@@ -196,3 +202,162 @@ func TestAuthRequiredWrongSecret(t *testing.T) {
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized) t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
} }
} }
// --- AdminRequired tests ---
// errorBody extracts the error.message field from a JSON response body.
type errorBody struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
}
func setupAdminTestDB(t *testing.T) 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{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return repository.NewUserRepository(db)
}
// injectUserIDMiddleware simulates AuthRequired by injecting a user_id into the context.
func injectUserIDMiddleware(userID string) gin.HandlerFunc {
return func(c *gin.Context) {
c.Set(userIDKey, userID)
c.Next()
}
}
func TestAdminRequired_AdminPasses(t *testing.T) {
repo := setupAdminTestDB(t)
ctx := context.Background()
user := &model.User{
ID: "admin-1",
Username: "admin",
Email: "admin@example.com",
IsAdmin: true,
Status: model.StatusActive,
}
if err := repo.Create(ctx, user); err != nil {
t.Fatalf("Create = %v", err)
}
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(injectUserIDMiddleware("admin-1"))
r.Use(AdminRequired(repo))
r.GET("/admin", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
req := httptest.NewRequest(http.MethodGet, "/admin", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
}
}
func TestAdminRequired_NonAdminForbidden(t *testing.T) {
repo := setupAdminTestDB(t)
ctx := context.Background()
user := &model.User{
ID: "user-1",
Username: "regular",
Email: "regular@example.com",
IsAdmin: false,
Status: model.StatusActive,
}
if err := repo.Create(ctx, user); err != nil {
t.Fatalf("Create = %v", err)
}
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(injectUserIDMiddleware("user-1"))
r.Use(AdminRequired(repo))
r.GET("/admin", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
req := httptest.NewRequest(http.MethodGet, "/admin", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("status = %d, want %d", rec.Code, http.StatusForbidden)
}
var body errorBody
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if body.Error.Message != "admin access required" {
t.Errorf("message = %q, want %q", body.Error.Message, "admin access required")
}
}
func TestAdminRequired_SoftDeletedAdmin(t *testing.T) {
repo := setupAdminTestDB(t)
ctx := context.Background()
user := &model.User{
ID: "admin-2",
Username: "deleted_admin",
Email: "deleted_admin@example.com",
IsAdmin: true,
Status: model.StatusActive,
}
if err := repo.Create(ctx, user); err != nil {
t.Fatalf("Create = %v", err)
}
// Soft-delete the admin user
if err := repo.Delete(ctx, "admin-2"); err != nil {
t.Fatalf("Delete = %v", err)
}
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(injectUserIDMiddleware("admin-2"))
r.Use(AdminRequired(repo))
r.GET("/admin", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
req := httptest.NewRequest(http.MethodGet, "/admin", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
}
}
func TestAdminRequired_NoUserID(t *testing.T) {
repo := setupAdminTestDB(t)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(AdminRequired(repo))
r.GET("/admin", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
req := httptest.NewRequest(http.MethodGet, "/admin", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
}
}
+4
View File
@@ -4,6 +4,9 @@ import (
"time" "time"
) )
// StatusUserDeleted marks a file that has been deleted by the user (soft delete).
const StatusUserDeleted = "user_deleted"
// File represents a file or directory entry in the virtual filesystem. // File represents a file or directory entry in the virtual filesystem.
type File struct { type File struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"` ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
@@ -14,6 +17,7 @@ type File struct {
MimeType string `gorm:"type:varchar(127)" json:"mime_type"` MimeType string `gorm:"type:varchar(127)" json:"mime_type"`
StoragePath string `gorm:"type:varchar(512)" json:"storage_path"` StoragePath string `gorm:"type:varchar(512)" json:"storage_path"`
Hash string `gorm:"type:varchar(64)" json:"-"` Hash string `gorm:"type:varchar(64)" json:"-"`
Status string `gorm:"type:varchar(32);not null;default:active;index" json:"-"`
IsDir bool `gorm:"default:false" json:"is_dir"` IsDir bool `gorm:"default:false" json:"is_dir"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
+49
View File
@@ -0,0 +1,49 @@
package model
import (
"encoding/json"
"testing"
"time"
)
func TestFile_StatusExcludedFromJSON(t *testing.T) {
f := &File{
ID: "1",
UserID: "u1",
ParentID: nil,
Name: "test.txt",
Size: 100,
MimeType: "text/plain",
Status: StatusActive,
IsDir: false,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
data, err := json.Marshal(f)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
var result map[string]any
if err := json.Unmarshal(data, &result); err != nil {
t.Fatalf("json.Unmarshal: %v", err)
}
if _, ok := result["status"]; ok {
t.Errorf("Status field should be excluded from JSON (json:\"-\"), but it appeared in output")
}
if _, ok := result["hash"]; ok {
t.Errorf("Hash field should be excluded from JSON (json:\"-\"), but it appeared in output")
}
}
func TestStatusConstants(t *testing.T) {
if StatusActive != "active" {
t.Errorf("StatusActive = %q, want \"active\"", StatusActive)
}
if StatusUserDeleted != "user_deleted" {
t.Errorf("StatusUserDeleted = %q, want \"user_deleted\"", StatusUserDeleted)
}
}
+7
View File
@@ -11,6 +11,13 @@ type User struct {
Email string `gorm:"uniqueIndex;type:varchar(255);not null" json:"email"` Email string `gorm:"uniqueIndex;type:varchar(255);not null" json:"email"`
PasswordHash string `gorm:"type:varchar(255);not null" json:"-"` PasswordHash string `gorm:"type:varchar(255);not null" json:"-"`
IsAdmin bool `gorm:"default:false" json:"is_admin"` IsAdmin bool `gorm:"default:false" json:"is_admin"`
Status string `gorm:"type:varchar(32);not null;default:active;index" json:"-"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
// User status constants.
const (
StatusActive = "active"
StatusAdminDeleted = "admin_deleted"
)
+29
View File
@@ -0,0 +1,29 @@
package model
import (
"encoding/json"
"testing"
)
func TestUserJSONOmitsStatusField(t *testing.T) {
u := User{
ID: "user-1",
Username: "alice",
Email: "alice@example.com",
Status: StatusActive,
}
raw, err := json.Marshal(u)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
var m map[string]interface{}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("json.Unmarshal: %v", err)
}
if _, ok := m["status"]; ok {
t.Error("Status field should not appear in JSON output")
}
}
+8 -8
View File
@@ -43,7 +43,7 @@ func (r *fileRepository) Create(ctx context.Context, file *model.File) error {
func (r *fileRepository) FindByID(ctx context.Context, id string) (*model.File, error) { func (r *fileRepository) FindByID(ctx context.Context, id string) (*model.File, error) {
var file model.File var file model.File
result := r.db.WithContext(ctx).First(&file, "id = ?", id) result := r.db.WithContext(ctx).First(&file, "id = ? AND status = ?", id, model.StatusActive)
if errors.Is(result.Error, gorm.ErrRecordNotFound) { if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, model.ErrNotFound return nil, model.ErrNotFound
} }
@@ -57,11 +57,11 @@ func (r *fileRepository) FindByUserID(ctx context.Context, userID string, offset
var files []model.File var files []model.File
var total int64 var total int64
if err := r.db.WithContext(ctx).Model(&model.File{}).Where("user_id = ?", userID).Count(&total).Error; err != nil { if err := r.db.WithContext(ctx).Model(&model.File{}).Where("user_id = ? AND status = ?", userID, model.StatusActive).Count(&total).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
result := r.db.WithContext(ctx).Where("user_id = ?", userID).Offset(offset).Limit(limit).Find(&files) result := r.db.WithContext(ctx).Where("user_id = ? AND status = ?", userID, model.StatusActive).Offset(offset).Limit(limit).Find(&files)
if result.Error != nil { if result.Error != nil {
return nil, 0, result.Error return nil, 0, result.Error
} }
@@ -71,7 +71,7 @@ func (r *fileRepository) FindByUserID(ctx context.Context, userID string, offset
func (r *fileRepository) FindByParentID(ctx context.Context, userID string, parentID *string) ([]model.File, error) { func (r *fileRepository) FindByParentID(ctx context.Context, userID string, parentID *string) ([]model.File, error) {
var files []model.File var files []model.File
result := r.db.WithContext(ctx).Where("user_id = ? AND parent_id IS ?", userID, parentID).Find(&files) result := r.db.WithContext(ctx).Where("user_id = ? AND parent_id IS ? AND status = ?", userID, parentID, model.StatusActive).Find(&files)
if result.Error != nil { if result.Error != nil {
return nil, result.Error return nil, result.Error
} }
@@ -82,11 +82,11 @@ func (r *fileRepository) FindByParentIDPaginated(ctx context.Context, userID str
var files []model.File var files []model.File
var total int64 var total int64
if err := r.db.WithContext(ctx).Model(&model.File{}).Where("user_id = ? AND parent_id IS ?", userID, parentID).Count(&total).Error; err != nil { if err := r.db.WithContext(ctx).Model(&model.File{}).Where("user_id = ? AND parent_id IS ? AND status = ?", userID, parentID, model.StatusActive).Count(&total).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
result := r.db.WithContext(ctx).Where("user_id = ? AND parent_id IS ?", userID, parentID).Offset(offset).Limit(limit).Order("is_dir DESC, name ASC").Find(&files) result := r.db.WithContext(ctx).Where("user_id = ? AND parent_id IS ? AND status = ?", userID, parentID, model.StatusActive).Offset(offset).Limit(limit).Order("is_dir DESC, name ASC").Find(&files)
if result.Error != nil { if result.Error != nil {
return nil, 0, result.Error return nil, 0, result.Error
} }
@@ -96,7 +96,7 @@ func (r *fileRepository) FindByParentIDPaginated(ctx context.Context, userID str
func (r *fileRepository) FindByNameAndParent(ctx context.Context, userID string, parentID *string, name string) (*model.File, error) { func (r *fileRepository) FindByNameAndParent(ctx context.Context, userID string, parentID *string, name string) (*model.File, error) {
var file model.File var file model.File
result := r.db.WithContext(ctx).Where("user_id = ? AND parent_id IS ? AND name = ?", userID, parentID, name).First(&file) result := r.db.WithContext(ctx).Where("user_id = ? AND parent_id IS ? AND name = ? AND status = ?", userID, parentID, name, model.StatusActive).First(&file)
if errors.Is(result.Error, gorm.ErrRecordNotFound) { if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, model.ErrNotFound return nil, model.ErrNotFound
} }
@@ -115,7 +115,7 @@ func (r *fileRepository) Update(ctx context.Context, file *model.File) error {
} }
func (r *fileRepository) Delete(ctx context.Context, id string) error { func (r *fileRepository) Delete(ctx context.Context, id string) error {
result := r.db.WithContext(ctx).Delete(&model.File{}, "id = ?", id) result := r.db.WithContext(ctx).Model(&model.File{}).Where("id = ?", id).Update("status", model.StatusUserDeleted)
if result.Error != nil { if result.Error != nil {
return result.Error return result.Error
} }
+130 -10
View File
@@ -33,6 +33,7 @@ func TestFileRepository_Create(t *testing.T) {
UserID: "user-1", UserID: "user-1",
Name: "test.txt", Name: "test.txt",
Size: 1024, Size: 1024,
Status: model.StatusActive,
} }
if err := repo.Create(ctx, file); err != nil { if err := repo.Create(ctx, file); err != nil {
@@ -48,6 +49,7 @@ func TestFileRepository_FindByID(t *testing.T) {
ID: "file-1", ID: "file-1",
UserID: "user-1", UserID: "user-1",
Name: "test.txt", Name: "test.txt",
Status: model.StatusActive,
} }
if err := repo.Create(ctx, file); err != nil { if err := repo.Create(ctx, file); err != nil {
t.Fatalf("Create = %v", err) t.Fatalf("Create = %v", err)
@@ -77,9 +79,9 @@ func TestFileRepository_FindByUserID(t *testing.T) {
ctx := context.Background() ctx := context.Background()
files := []*model.File{ files := []*model.File{
{ID: "f-1", UserID: "user-1", Name: "a.txt"}, {ID: "f-1", UserID: "user-1", Name: "a.txt", Status: model.StatusActive},
{ID: "f-2", UserID: "user-1", Name: "b.txt"}, {ID: "f-2", UserID: "user-1", Name: "b.txt", Status: model.StatusActive},
{ID: "f-3", UserID: "user-2", Name: "c.txt"}, {ID: "f-3", UserID: "user-2", Name: "c.txt", Status: model.StatusActive},
} }
for _, f := range files { for _, f := range files {
if err := repo.Create(ctx, f); err != nil { if err := repo.Create(ctx, f); err != nil {
@@ -105,9 +107,9 @@ func TestFileRepository_FindByParentID(t *testing.T) {
parentID := "dir-1" parentID := "dir-1"
files := []*model.File{ files := []*model.File{
{ID: "f-1", UserID: "user-1", ParentID: &parentID, Name: "a.txt"}, {ID: "f-1", UserID: "user-1", ParentID: &parentID, Name: "a.txt", Status: model.StatusActive},
{ID: "f-2", UserID: "user-1", ParentID: &parentID, Name: "b.txt"}, {ID: "f-2", UserID: "user-1", ParentID: &parentID, Name: "b.txt", Status: model.StatusActive},
{ID: "f-3", UserID: "user-1", Name: "c.txt"}, {ID: "f-3", UserID: "user-1", Name: "c.txt", Status: model.StatusActive},
} }
for _, f := range files { for _, f := range files {
if err := repo.Create(ctx, f); err != nil { if err := repo.Create(ctx, f); err != nil {
@@ -130,8 +132,8 @@ func TestFileRepository_FindByParentIDNull(t *testing.T) {
parentID := "dir-1" parentID := "dir-1"
files := []*model.File{ files := []*model.File{
{ID: "f-1", UserID: "user-1", ParentID: &parentID, Name: "a.txt"}, {ID: "f-1", UserID: "user-1", ParentID: &parentID, Name: "a.txt", Status: model.StatusActive},
{ID: "f-2", UserID: "user-1", Name: "root.txt"}, {ID: "f-2", UserID: "user-1", Name: "root.txt", Status: model.StatusActive},
} }
for _, f := range files { for _, f := range files {
if err := repo.Create(ctx, f); err != nil { if err := repo.Create(ctx, f); err != nil {
@@ -152,7 +154,7 @@ func TestFileRepository_Update(t *testing.T) {
repo := setupFileRepo(t) repo := setupFileRepo(t)
ctx := context.Background() ctx := context.Background()
file := &model.File{ID: "file-1", UserID: "user-1", Name: "original.txt"} file := &model.File{ID: "file-1", UserID: "user-1", Name: "original.txt", Status: model.StatusActive}
if err := repo.Create(ctx, file); err != nil { if err := repo.Create(ctx, file); err != nil {
t.Fatalf("Create = %v", err) t.Fatalf("Create = %v", err)
} }
@@ -179,7 +181,7 @@ func TestFileRepository_Delete(t *testing.T) {
repo := setupFileRepo(t) repo := setupFileRepo(t)
ctx := context.Background() ctx := context.Background()
file := &model.File{ID: "file-1", UserID: "user-1", Name: "test.txt"} file := &model.File{ID: "file-1", UserID: "user-1", Name: "test.txt", Status: model.StatusActive}
if err := repo.Create(ctx, file); err != nil { if err := repo.Create(ctx, file); err != nil {
t.Fatalf("Create = %v", err) t.Fatalf("Create = %v", err)
} }
@@ -193,3 +195,121 @@ func TestFileRepository_Delete(t *testing.T) {
t.Fatalf("expected ErrNotFound after delete, got %v", err) t.Fatalf("expected ErrNotFound after delete, got %v", err)
} }
} }
func TestFileRepository_SoftDelete(t *testing.T) {
repo := setupFileRepo(t)
ctx := context.Background()
file := &model.File{
ID: "file-1",
UserID: "user-1",
Name: "test.txt",
Status: model.StatusActive,
}
if err := repo.Create(ctx, file); err != nil {
t.Fatalf("Create = %v", err)
}
if err := repo.Delete(ctx, "file-1"); err != nil {
t.Fatalf("Delete = %v", err)
}
_, err := repo.FindByID(ctx, "file-1")
if err != model.ErrNotFound {
t.Fatalf("expected ErrNotFound after soft-delete, got %v", err)
}
}
func TestFileRepository_StatusFilter(t *testing.T) {
repo := setupFileRepo(t)
ctx := context.Background()
activeFile := &model.File{
ID: "f-1",
UserID: "user-1",
Name: "active.txt",
Status: model.StatusActive,
}
deletedFile := &model.File{
ID: "f-2",
UserID: "user-1",
Name: "deleted.txt",
Status: model.StatusUserDeleted,
}
if err := repo.Create(ctx, activeFile); err != nil {
t.Fatalf("Create active = %v", err)
}
if err := repo.Create(ctx, deletedFile); err != nil {
t.Fatalf("Create deleted = %v", err)
}
result, total, err := repo.FindByUserID(ctx, "user-1", 0, 10)
if err != nil {
t.Fatalf("FindByUserID = %v", err)
}
if len(result) != 1 {
t.Errorf("len(result) = %d, want 1", len(result))
}
if total != 1 {
t.Errorf("total = %d, want 1", total)
}
if result[0].ID != "f-1" {
t.Errorf("result[0].ID = %q, want %q", result[0].ID, "f-1")
}
}
func TestFileRepository_DeleteIdempotent(t *testing.T) {
repo := setupFileRepo(t)
ctx := context.Background()
file := &model.File{
ID: "file-1",
UserID: "user-1",
Name: "test.txt",
Status: model.StatusActive,
}
if err := repo.Create(ctx, file); err != nil {
t.Fatalf("Create = %v", err)
}
if err := repo.Delete(ctx, "file-1"); err != nil {
t.Fatalf("first Delete = %v", err)
}
if err := repo.Delete(ctx, "file-1"); err != nil {
t.Fatalf("second Delete = %v", err)
}
}
func TestFileRepository_StatusFilterCount(t *testing.T) {
repo := setupFileRepo(t)
ctx := context.Background()
activeFile := &model.File{
ID: "f-1",
UserID: "user-1",
Name: "active.txt",
Status: model.StatusActive,
}
deletedFile := &model.File{
ID: "f-2",
UserID: "user-1",
Name: "deleted.txt",
Status: model.StatusUserDeleted,
}
if err := repo.Create(ctx, activeFile); err != nil {
t.Fatalf("Create active = %v", err)
}
if err := repo.Create(ctx, deletedFile); err != nil {
t.Fatalf("Create deleted = %v", err)
}
_, total, err := repo.FindByUserID(ctx, "user-1", 0, 10)
if err != nil {
t.Fatalf("FindByUserID = %v", err)
}
if total != 1 {
t.Errorf("total = %d, want 1 (soft-deleted excluded from count)", total)
}
}
+35 -3
View File
@@ -19,11 +19,13 @@ func isDuplicateKeyError(err error) bool {
type UserRepository interface { type UserRepository interface {
Create(ctx context.Context, user *model.User) error Create(ctx context.Context, user *model.User) error
FindByID(ctx context.Context, id string) (*model.User, error) FindByID(ctx context.Context, id string) (*model.User, error)
FindByIDIncludeDeleted(ctx context.Context, id string) (*model.User, error)
FindByEmail(ctx context.Context, email string) (*model.User, error) FindByEmail(ctx context.Context, email string) (*model.User, error)
FindByUsername(ctx context.Context, username string) (*model.User, error) FindByUsername(ctx context.Context, username string) (*model.User, error)
Update(ctx context.Context, user *model.User) error Update(ctx context.Context, user *model.User) error
Delete(ctx context.Context, id string) error Delete(ctx context.Context, id string) error
List(ctx context.Context, offset, limit int) ([]model.User, int64, error) List(ctx context.Context, offset, limit int) ([]model.User, int64, error)
ListIncludeDeleted(ctx context.Context, offset, limit int) ([]model.User, int64, error)
} }
type userRepository struct { type userRepository struct {
@@ -47,6 +49,19 @@ func (r *userRepository) Create(ctx context.Context, user *model.User) error {
} }
func (r *userRepository) FindByID(ctx context.Context, id string) (*model.User, error) { func (r *userRepository) FindByID(ctx context.Context, id string) (*model.User, error) {
var user model.User
result := r.db.WithContext(ctx).First(&user, "id = ? AND status = ?", id, model.StatusActive)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, model.ErrNotFound
}
if result.Error != nil {
return nil, result.Error
}
return &user, nil
}
// FindByIDIncludeDeleted finds a user by ID regardless of status.
func (r *userRepository) FindByIDIncludeDeleted(ctx context.Context, id string) (*model.User, error) {
var user model.User var user model.User
result := r.db.WithContext(ctx).First(&user, "id = ?", id) result := r.db.WithContext(ctx).First(&user, "id = ?", id)
if errors.Is(result.Error, gorm.ErrRecordNotFound) { if errors.Is(result.Error, gorm.ErrRecordNotFound) {
@@ -60,7 +75,7 @@ func (r *userRepository) FindByID(ctx context.Context, id string) (*model.User,
func (r *userRepository) FindByEmail(ctx context.Context, email string) (*model.User, error) { func (r *userRepository) FindByEmail(ctx context.Context, email string) (*model.User, error) {
var user model.User var user model.User
result := r.db.WithContext(ctx).First(&user, "email = ?", email) result := r.db.WithContext(ctx).First(&user, "email = ? AND status = ?", email, model.StatusActive)
if errors.Is(result.Error, gorm.ErrRecordNotFound) { if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, model.ErrNotFound return nil, model.ErrNotFound
} }
@@ -72,7 +87,7 @@ func (r *userRepository) FindByEmail(ctx context.Context, email string) (*model.
func (r *userRepository) FindByUsername(ctx context.Context, username string) (*model.User, error) { func (r *userRepository) FindByUsername(ctx context.Context, username string) (*model.User, error) {
var user model.User var user model.User
result := r.db.WithContext(ctx).First(&user, "username = ?", username) result := r.db.WithContext(ctx).First(&user, "username = ? AND status = ?", username, model.StatusActive)
if errors.Is(result.Error, gorm.ErrRecordNotFound) { if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, model.ErrNotFound return nil, model.ErrNotFound
} }
@@ -94,7 +109,7 @@ func (r *userRepository) Update(ctx context.Context, user *model.User) error {
} }
func (r *userRepository) Delete(ctx context.Context, id string) error { func (r *userRepository) Delete(ctx context.Context, id string) error {
result := r.db.WithContext(ctx).Delete(&model.User{}, "id = ?", id) result := r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Update("status", model.StatusAdminDeleted)
if result.Error != nil { if result.Error != nil {
return result.Error return result.Error
} }
@@ -105,6 +120,23 @@ func (r *userRepository) List(ctx context.Context, offset, limit int) ([]model.U
var users []model.User var users []model.User
var total int64 var total int64
if err := r.db.WithContext(ctx).Model(&model.User{}).Where("status = ?", model.StatusActive).Count(&total).Error; err != nil {
return nil, 0, err
}
result := r.db.WithContext(ctx).Where("status = ?", model.StatusActive).Offset(offset).Limit(limit).Find(&users)
if result.Error != nil {
return nil, 0, result.Error
}
return users, total, nil
}
// ListIncludeDeleted returns all users (including soft-deleted), paginated.
func (r *userRepository) ListIncludeDeleted(ctx context.Context, offset, limit int) ([]model.User, int64, error) {
var users []model.User
var total int64
if err := r.db.WithContext(ctx).Model(&model.User{}).Count(&total).Error; err != nil { if err := r.db.WithContext(ctx).Model(&model.User{}).Count(&total).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
+134 -7
View File
@@ -33,6 +33,7 @@ func TestUserRepository_Create(t *testing.T) {
Username: "alice", Username: "alice",
Email: "alice@example.com", Email: "alice@example.com",
PasswordHash: "hash", PasswordHash: "hash",
Status: model.StatusActive,
} }
if err := repo.Create(ctx, user); err != nil { if err := repo.Create(ctx, user); err != nil {
@@ -44,8 +45,8 @@ func TestUserRepository_CreateDuplicateUsername(t *testing.T) {
repo := setupUserRepo(t) repo := setupUserRepo(t)
ctx := context.Background() ctx := context.Background()
u1 := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash"} u1 := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash", Status: model.StatusActive}
u2 := &model.User{ID: "user-2", Username: "alice", Email: "alice2@example.com", PasswordHash: "hash"} u2 := &model.User{ID: "user-2", Username: "alice", Email: "alice2@example.com", PasswordHash: "hash", Status: model.StatusActive}
if err := repo.Create(ctx, u1); err != nil { if err := repo.Create(ctx, u1); err != nil {
t.Fatalf("Create = %v", err) t.Fatalf("Create = %v", err)
@@ -61,7 +62,7 @@ func TestUserRepository_FindByID(t *testing.T) {
repo := setupUserRepo(t) repo := setupUserRepo(t)
ctx := context.Background() ctx := context.Background()
user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash"} user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash", Status: model.StatusActive}
if err := repo.Create(ctx, user); err != nil { if err := repo.Create(ctx, user); err != nil {
t.Fatalf("Create = %v", err) t.Fatalf("Create = %v", err)
} }
@@ -89,7 +90,7 @@ func TestUserRepository_FindByEmail(t *testing.T) {
repo := setupUserRepo(t) repo := setupUserRepo(t)
ctx := context.Background() ctx := context.Background()
user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash"} user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash", Status: model.StatusActive}
if err := repo.Create(ctx, user); err != nil { if err := repo.Create(ctx, user); err != nil {
t.Fatalf("Create = %v", err) t.Fatalf("Create = %v", err)
} }
@@ -107,7 +108,7 @@ func TestUserRepository_FindByUsername(t *testing.T) {
repo := setupUserRepo(t) repo := setupUserRepo(t)
ctx := context.Background() ctx := context.Background()
user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash"} user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash", Status: model.StatusActive}
if err := repo.Create(ctx, user); err != nil { if err := repo.Create(ctx, user); err != nil {
t.Fatalf("Create = %v", err) t.Fatalf("Create = %v", err)
} }
@@ -125,7 +126,7 @@ func TestUserRepository_Update(t *testing.T) {
repo := setupUserRepo(t) repo := setupUserRepo(t)
ctx := context.Background() ctx := context.Background()
user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash"} user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash", Status: model.StatusActive}
if err := repo.Create(ctx, user); err != nil { if err := repo.Create(ctx, user); err != nil {
t.Fatalf("Create = %v", err) t.Fatalf("Create = %v", err)
} }
@@ -148,7 +149,7 @@ func TestUserRepository_Delete(t *testing.T) {
repo := setupUserRepo(t) repo := setupUserRepo(t)
ctx := context.Background() ctx := context.Background()
user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash"} user := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash", Status: model.StatusActive}
if err := repo.Create(ctx, user); err != nil { if err := repo.Create(ctx, user); err != nil {
t.Fatalf("Create = %v", err) t.Fatalf("Create = %v", err)
} }
@@ -173,6 +174,7 @@ func TestUserRepository_List(t *testing.T) {
Username: "user" + string(rune('0'+i)), Username: "user" + string(rune('0'+i)),
Email: "user" + string(rune('0'+i)) + "@example.com", Email: "user" + string(rune('0'+i)) + "@example.com",
PasswordHash: "hash", PasswordHash: "hash",
Status: model.StatusActive,
} }
if err := repo.Create(ctx, user); err != nil { if err := repo.Create(ctx, user); err != nil {
t.Fatalf("Create = %v", err) t.Fatalf("Create = %v", err)
@@ -190,3 +192,128 @@ func TestUserRepository_List(t *testing.T) {
t.Errorf("total = %d, want %d", total, 5) t.Errorf("total = %d, want %d", total, 5)
} }
} }
func TestUserRepository_SoftDelete(t *testing.T) {
repo := setupUserRepo(t)
ctx := context.Background()
user := &model.User{
ID: "user-1",
Username: "alice",
Email: "alice@example.com",
PasswordHash: "hash",
Status: model.StatusActive,
}
if err := repo.Create(ctx, user); err != nil {
t.Fatalf("Create = %v", err)
}
if err := repo.Delete(ctx, "user-1"); err != nil {
t.Fatalf("Delete = %v", err)
}
// After soft-delete, FindByID should return ErrNotFound
// because the status filter excludes soft-deleted users.
_, err := repo.FindByID(ctx, "user-1")
if err != model.ErrNotFound {
t.Fatalf("expected ErrNotFound after soft-delete, got %v", err)
}
}
func TestUserRepository_DisabledLogin(t *testing.T) {
repo := setupUserRepo(t)
ctx := context.Background()
user := &model.User{
ID: "user-1",
Username: "alice",
Email: "alice@example.com",
PasswordHash: "hash",
Status: model.StatusActive,
}
if err := repo.Create(ctx, user); err != nil {
t.Fatalf("Create = %v", err)
}
if err := repo.Delete(ctx, "user-1"); err != nil {
t.Fatalf("Delete = %v", err)
}
// Soft-deleted users should not be findable by email,
// preventing login for disabled accounts.
_, err := repo.FindByEmail(ctx, "alice@example.com")
if err != model.ErrNotFound {
t.Fatalf("expected ErrNotFound for soft-deleted user login, got %v", err)
}
}
func TestUserRepository_StatusFilterList(t *testing.T) {
repo := setupUserRepo(t)
ctx := context.Background()
u1 := &model.User{
ID: "user-1",
Username: "alice",
Email: "alice@example.com",
PasswordHash: "hash",
Status: model.StatusActive,
}
if err := repo.Create(ctx, u1); err != nil {
t.Fatalf("Create u1 = %v", err)
}
u2 := &model.User{
ID: "user-2",
Username: "bob",
Email: "bob@example.com",
PasswordHash: "hash",
Status: model.StatusActive,
}
if err := repo.Create(ctx, u2); err != nil {
t.Fatalf("Create u2 = %v", err)
}
// Soft-delete bob
if err := repo.Delete(ctx, "user-2"); err != nil {
t.Fatalf("Delete u2 = %v", err)
}
// List with status filter should only return active users.
users, total, err := repo.List(ctx, 0, 10)
if err != nil {
t.Fatalf("List = %v", err)
}
if len(users) != 1 {
t.Fatalf("expected 1 active user, got %d", len(users))
}
if total != 1 {
t.Fatalf("expected total 1, got %d", total)
}
if users[0].ID != "user-1" {
t.Errorf("expected active user user-1, got %s", users[0].ID)
}
}
func TestUserRepository_DeleteIdempotent(t *testing.T) {
repo := setupUserRepo(t)
ctx := context.Background()
user := &model.User{
ID: "user-1",
Username: "alice",
Email: "alice@example.com",
PasswordHash: "hash",
Status: model.StatusActive,
}
if err := repo.Create(ctx, user); err != nil {
t.Fatalf("Create = %v", err)
}
// Soft-delete the same user twice should not error.
if err := repo.Delete(ctx, "user-1"); err != nil {
t.Fatalf("first Delete = %v", err)
}
if err := repo.Delete(ctx, "user-1"); err != nil {
t.Fatalf("second Delete = %v", err)
}
}
+9
View File
@@ -12,6 +12,7 @@ func setupProtectedRoutes(rg *gin.RouterGroup, webApp *app.WebApp) {
jwtSecret := []byte(webApp.Config.JWT.Secret) jwtSecret := []byte(webApp.Config.JWT.Secret)
accountHandler := handler.NewAccountHandler(webApp.AuthService) accountHandler := handler.NewAccountHandler(webApp.AuthService)
fileHandler := handler.NewFileHandler(webApp.FileService) fileHandler := handler.NewFileHandler(webApp.FileService)
adminHandler := handler.NewAdminHandler(webApp.UserRepo)
rg.Use(middleware.AuthRequired(jwtSecret)) rg.Use(middleware.AuthRequired(jwtSecret))
@@ -36,4 +37,12 @@ func setupProtectedRoutes(rg *gin.RouterGroup, webApp *app.WebApp) {
files.PUT("/:id", fileHandler.Update) files.PUT("/:id", fileHandler.Update)
files.DELETE("/:id", fileHandler.Delete) files.DELETE("/:id", fileHandler.Delete)
} }
admin := rg.Group("/admin")
admin.Use(middleware.AdminRequired(webApp.UserRepo))
{
admin.GET("/users", adminHandler.ListUsers)
admin.GET("/users/:id", adminHandler.GetUser)
admin.DELETE("/users/:id", adminHandler.DeleteUser)
}
} }
+124
View File
@@ -1,6 +1,8 @@
package server package server
import ( import (
"bytes"
"context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -8,7 +10,9 @@ import (
"time" "time"
"github.com/dhao2001/mygo/internal/app" "github.com/dhao2001/mygo/internal/app"
"github.com/dhao2001/mygo/internal/auth"
"github.com/dhao2001/mygo/internal/config" "github.com/dhao2001/mygo/internal/config"
"github.com/dhao2001/mygo/internal/repository"
"github.com/dhao2001/mygo/internal/service" "github.com/dhao2001/mygo/internal/service"
) )
@@ -56,3 +60,123 @@ func TestAddress(t *testing.T) {
t.Errorf("Address() = %q, want %q", got, want) t.Errorf("Address() = %q, want %q", got, want)
} }
} }
func TestAdminRoutes(t *testing.T) {
// Setup SQLite :memory:
dbCfg := config.DatabaseConfig{
Driver: "sqlite3",
SQLite: config.SQLiteConfig{Path: ":memory:"},
}
db, err := repository.Open(dbCfg)
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := repository.AutoMigrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
userRepo := repository.NewUserRepository(db)
sessionRepo := repository.NewSessionRepository(db)
credentialRepo := repository.NewCredentialRepository(db)
jwtSecret := []byte("test-secret")
accessTTL := 15 * time.Minute
refreshTTL := 168 * time.Hour
authService := service.NewAuthService(userRepo, sessionRepo, credentialRepo, jwtSecret, accessTTL, refreshTTL)
cfg := &config.Config{
JWT: config.JWTConfig{
Secret: "test-secret",
AccessTTL: accessTTL,
RefreshTTL: refreshTTL,
},
}
webApp := app.NewWebApp(cfg, db, userRepo, sessionRepo, nil, credentialRepo, authService, nil, nil)
router := NewRouter(webApp)
// Helper: register a user via POST /api/v1/auth/register and return the user ID.
register := func(username, email, password string) string {
body, _ := json.Marshal(map[string]string{
"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()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("register %s: status=%d, body=%s", username, rec.Code, rec.Body.String())
}
var user struct {
ID string `json:"id"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &user); err != nil {
t.Fatalf("unmarshal register response: %v", err)
}
return user.ID
}
// Register admin user and promote to admin.
adminID := register("admin", "admin@test.local", "adminpass1")
adminUser, err := userRepo.FindByID(context.Background(), adminID)
if err != nil {
t.Fatalf("find admin user: %v", err)
}
adminUser.IsAdmin = true
if err := userRepo.Update(context.Background(), adminUser); err != nil {
t.Fatalf("promote to admin: %v", err)
}
// Generate admin JWT.
adminToken, err := auth.GenerateAccessToken(adminID, jwtSecret, accessTTL)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
// Register a regular (non-admin) user.
regularID := register("regular", "regular@test.local", "regularpass1")
regularToken, err := auth.GenerateAccessToken(regularID, jwtSecret, accessTTL)
if err != nil {
t.Fatalf("generate regular token: %v", err)
}
// Register a victim user to delete.
victimID := register("victim", "victim@test.local", "victimpass1")
// Helper: make a DELETE request with optional auth header.
deleteUser := func(userID, token string) *httptest.ResponseRecorder {
url := "/api/v1/admin/users/" + userID
req := httptest.NewRequest(http.MethodDelete, url, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
// Test 1: Admin can delete a user → 204.
rec := deleteUser(victimID, adminToken)
if rec.Code != http.StatusNoContent {
t.Errorf("admin delete: status=%d, want %d, body=%s", rec.Code, http.StatusNoContent, rec.Body.String())
}
// Register a second victim for the remaining tests (first victim is soft-deleted).
victim2ID := register("victim2", "victim2@test.local", "victim2pass1")
// Test 2: Non-admin JWT → 403.
rec = deleteUser(victim2ID, regularToken)
if rec.Code != http.StatusForbidden {
t.Errorf("non-admin delete: status=%d, want %d, body=%s", rec.Code, http.StatusForbidden, rec.Body.String())
}
// Test 3: No auth → 401.
rec = deleteUser(victim2ID, "")
if rec.Code != http.StatusUnauthorized {
t.Errorf("no-auth delete: status=%d, want %d, body=%s", rec.Code, http.StatusUnauthorized, rec.Body.String())
}
}
+21
View File
@@ -72,6 +72,7 @@ func (s *AuthService) Register(ctx context.Context, username, email, password st
Username: username, Username: username,
Email: email, Email: email,
PasswordHash: passwordHash, PasswordHash: passwordHash,
Status: model.StatusActive,
} }
if err := s.userRepo.Create(ctx, user); err != nil { if err := s.userRepo.Create(ctx, user); err != nil {
@@ -94,6 +95,10 @@ func (s *AuthService) Login(ctx context.Context, email, password string) (*Token
return nil, model.NewInternalError("find user", err) return nil, model.NewInternalError("find user", err)
} }
if user.Status != model.StatusActive {
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid email or password"}
}
if err := auth.VerifyPassword(user.PasswordHash, password); err != nil { if err := auth.VerifyPassword(user.PasswordHash, password); err != nil {
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid email or password"} return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid email or password"}
} }
@@ -126,6 +131,14 @@ func (s *AuthService) Refresh(ctx context.Context, refreshTokenStr string) (*Tok
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"} return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"}
} }
user, err := s.userRepo.FindByID(ctx, claims.UserID)
if err != nil {
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"}
}
if user.Status != model.StatusActive {
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"}
}
if err := s.sessionRepo.Delete(ctx, session.ID); err != nil { if err := s.sessionRepo.Delete(ctx, session.ID); err != nil {
return nil, model.NewInternalError("delete old session", err) return nil, model.NewInternalError("delete old session", err)
} }
@@ -188,6 +201,14 @@ func (s *AuthService) LoginWithPasskey(ctx context.Context, tokenStr string) (*T
return nil, model.NewInternalError("find credential", err) return nil, model.NewInternalError("find credential", err)
} }
user, err := s.userRepo.FindByIDIncludeDeleted(ctx, cred.UserID)
if err != nil {
return nil, model.NewInternalError("find user", err)
}
if user.Status != model.StatusActive {
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid passkey"}
}
if cred.Type != "app_passkey" { if cred.Type != "app_passkey" {
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid credential type"} return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid credential type"}
} }
+158 -1
View File
@@ -16,6 +16,13 @@ import (
) )
func setupAuthService(t *testing.T) *AuthService { func setupAuthService(t *testing.T) *AuthService {
svc, _ := setupAuthServiceWithRepos(t)
return svc
}
// setupAuthServiceWithRepos creates an AuthService and returns the UserRepository
// for tests that need direct repo access (e.g., soft-deleting users).
func setupAuthServiceWithRepos(t *testing.T) (*AuthService, repository.UserRepository) {
t.Helper() t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
@@ -30,12 +37,13 @@ func setupAuthService(t *testing.T) *AuthService {
sessionRepo := repository.NewSessionRepository(db) sessionRepo := repository.NewSessionRepository(db)
credentialRepo := repository.NewCredentialRepository(db) credentialRepo := repository.NewCredentialRepository(db)
return NewAuthService( svc := NewAuthService(
userRepo, sessionRepo, credentialRepo, userRepo, sessionRepo, credentialRepo,
[]byte("test-secret"), []byte("test-secret"),
15*time.Minute, 15*time.Minute,
7*24*time.Hour, 7*24*time.Hour,
) )
return svc, userRepo
} }
func TestAuthService_Register(t *testing.T) { func TestAuthService_Register(t *testing.T) {
@@ -404,3 +412,152 @@ func TestAuthService_RefreshWithAccessToken(t *testing.T) {
t.Fatal("expected error when using access token for refresh, got nil") t.Fatal("expected error when using access token for refresh, got nil")
} }
} }
func TestAuthService_LoginDisabledUser(t *testing.T) {
svc, userRepo := setupAuthServiceWithRepos(t)
ctx := context.Background()
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
if err != nil {
t.Fatalf("Register = %v", err)
}
if err := userRepo.Delete(ctx, user.ID); err != nil {
t.Fatalf("Delete = %v", err)
}
_, err = svc.Login(ctx, "alice@example.com", "password123")
if err == nil {
t.Fatal("expected error for disabled user login, got nil")
}
var ae *model.AppError
if !errors.As(err, &ae) {
t.Fatalf("expected AppError, got %T: %v", err, err)
}
if ae.Status != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", ae.Status, http.StatusUnauthorized)
}
if ae.Message != "invalid email or password" {
t.Errorf("message = %q, want %q", ae.Message, "invalid email or password")
}
}
func TestAuthService_LoginDisabledUserSameMessage(t *testing.T) {
svc, userRepo := setupAuthServiceWithRepos(t)
ctx := context.Background()
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
if err != nil {
t.Fatalf("Register = %v", err)
}
// Get error for wrong password (active user)
_, wrongPwErr := svc.Login(ctx, "alice@example.com", "wrongpassword")
// Soft-delete user, then try to login
if err := userRepo.Delete(ctx, user.ID); err != nil {
t.Fatalf("Delete = %v", err)
}
_, disabledErr := svc.Login(ctx, "alice@example.com", "password123")
// Both errors should have the same message (no user enumeration)
if disabledErr == nil {
t.Fatal("expected error for disabled user login, got nil")
}
var aeDisabled *model.AppError
if !errors.As(disabledErr, &aeDisabled) {
t.Fatalf("expected AppError, got %T: %v", disabledErr, disabledErr)
}
var aeWrongPw *model.AppError
if !errors.As(wrongPwErr, &aeWrongPw) {
t.Fatalf("expected AppError for wrong password, got %T: %v", wrongPwErr, wrongPwErr)
}
if aeDisabled.Message != aeWrongPw.Message {
t.Errorf("disabled message = %q, want %q (must match wrong-password message to prevent user enumeration)", aeDisabled.Message, aeWrongPw.Message)
}
if aeDisabled.Message != "invalid email or password" {
t.Errorf("disabled message = %q, want %q", aeDisabled.Message, "invalid email or password")
}
}
func TestAuthService_LoginWithPasskeyDisabledUser(t *testing.T) {
svc, userRepo := setupAuthServiceWithRepos(t)
ctx := context.Background()
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
if err != nil {
t.Fatalf("Register = %v", err)
}
pair, err := svc.Login(ctx, "alice@example.com", "password123")
if err != nil {
t.Fatalf("Login = %v", err)
}
claims, err := auth.ParseToken(pair.AccessToken, []byte("test-secret"))
if err != nil {
t.Fatalf("ParseToken = %v", err)
}
pk, err := svc.CreatePasskey(ctx, claims.UserID, "My Phone")
if err != nil {
t.Fatalf("CreatePasskey = %v", err)
}
// Soft-delete the user
if err := userRepo.Delete(ctx, user.ID); err != nil {
t.Fatalf("Delete = %v", err)
}
_, err = svc.LoginWithPasskey(ctx, pk.Raw)
if err == nil {
t.Fatal("expected error for disabled user passkey login, got nil")
}
var ae *model.AppError
if !errors.As(err, &ae) {
t.Fatalf("expected AppError, got %T: %v", err, err)
}
if ae.Status != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", ae.Status, http.StatusUnauthorized)
}
if ae.Message != "invalid passkey" {
t.Errorf("message = %q, want %q", ae.Message, "invalid passkey")
}
}
func TestAuthService_RefreshDisabledUser(t *testing.T) {
svc, userRepo := setupAuthServiceWithRepos(t)
ctx := context.Background()
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
if err != nil {
t.Fatalf("Register = %v", err)
}
pair, err := svc.Login(ctx, "alice@example.com", "password123")
if err != nil {
t.Fatalf("Login = %v", err)
}
// Soft-delete the user
if err := userRepo.Delete(ctx, user.ID); err != nil {
t.Fatalf("Delete = %v", err)
}
_, err = svc.Refresh(ctx, pair.RefreshToken)
if err == nil {
t.Fatal("expected error for disabled user refresh, got nil")
}
var ae *model.AppError
if !errors.As(err, &ae) {
t.Fatalf("expected AppError, got %T: %v", err, err)
}
if ae.Status != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", ae.Status, http.StatusUnauthorized)
}
if ae.Message != "invalid token" {
t.Errorf("message = %q, want %q", ae.Message, "invalid token")
}
}
+7 -9
View File
@@ -129,6 +129,7 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin
MimeType: mimeType, MimeType: mimeType,
StoragePath: storagePath, StoragePath: storagePath,
Hash: hex.EncodeToString(hasher.Sum(nil)), Hash: hex.EncodeToString(hasher.Sum(nil)),
Status: model.StatusActive,
IsDir: false, IsDir: false,
} }
@@ -240,10 +241,14 @@ func (s *FileService) Update(ctx context.Context, userID, fileID string, newName
return modelToFileInfo(file), nil return modelToFileInfo(file), nil
} }
// Delete removes a file or (empty) directory. // Delete soft-deletes a file or (empty) directory. Storage content is preserved.
func (s *FileService) Delete(ctx context.Context, userID, fileID string) error { func (s *FileService) Delete(ctx context.Context, userID, fileID string) error {
file, err := s.getOwnedFile(ctx, userID, fileID) file, err := s.getOwnedFile(ctx, userID, fileID)
if err != nil { if err != nil {
var ae *model.AppError
if errors.As(err, &ae) && errors.Is(ae.Err, model.ErrNotFound) {
return nil // idempotent: already deleted
}
return err return err
} }
@@ -262,14 +267,6 @@ func (s *FileService) Delete(ctx context.Context, userID, fileID string) error {
return model.NewInternalError("delete file record", err) return model.NewInternalError("delete file record", err)
} }
// Remove content from storage (directories have no stored content).
if !file.IsDir {
if err := s.storage.Delete(ctx, file.StoragePath); err != nil {
s.logger.WarnContext(ctx, "failed to delete file from storage",
"path", file.StoragePath, "error", err)
}
}
return nil return nil
} }
@@ -297,6 +294,7 @@ func (s *FileService) CreateDir(ctx context.Context, userID string, parentID *st
UserID: userID, UserID: userID,
ParentID: parentID, ParentID: parentID,
Name: dirName, Name: dirName,
Status: model.StatusActive,
IsDir: true, IsDir: true,
} }
+84 -2
View File
@@ -73,7 +73,7 @@ func (s *memStorage) Delete(_ context.Context, path string) error {
var _ storage.StorageBackend = (*memStorage)(nil) var _ storage.StorageBackend = (*memStorage)(nil)
func setupFileService(t *testing.T) *FileService { func setupFileServiceWithStorage(t *testing.T) (*FileService, *memStorage) {
t.Helper() t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
@@ -87,7 +87,12 @@ func setupFileService(t *testing.T) *FileService {
fileRepo := repository.NewFileRepository(db) fileRepo := repository.NewFileRepository(db)
store := newMemStorage() store := newMemStorage()
return NewFileService(fileRepo, store, 0, nil) // 0 = unlimited, nil logger defaults to slog.Default() return NewFileService(fileRepo, store, 0, nil), store // 0 = unlimited, nil logger defaults to slog.Default()
}
func setupFileService(t *testing.T) *FileService {
svc, _ := setupFileServiceWithStorage(t)
return svc
} }
func TestFileService_Upload(t *testing.T) { func TestFileService_Upload(t *testing.T) {
@@ -461,3 +466,80 @@ func TestFileService_VerifyParentForbidden(t *testing.T) {
_, err = svc.Upload(ctx, "user2", &dir.ID, "stolen.txt", strings.NewReader("data")) _, err = svc.Upload(ctx, "user2", &dir.ID, "stolen.txt", strings.NewReader("data"))
assertAppError(t, err, http.StatusForbidden) assertAppError(t, err, http.StatusForbidden)
} }
func TestFileService_SoftDelete(t *testing.T) {
svc := setupFileService(t)
ctx := context.Background()
info, err := svc.Upload(ctx, "user1", nil, "soft-delete.txt", strings.NewReader("data"))
if err != nil {
t.Fatalf("Upload = %v", err)
}
if err := svc.Delete(ctx, "user1", info.ID); err != nil {
t.Fatalf("Delete = %v", err)
}
_, err = svc.Get(ctx, "user1", info.ID)
assertAppError(t, err, http.StatusNotFound)
}
func TestFileService_SoftDeleteKeepsStorage(t *testing.T) {
svc, store := setupFileServiceWithStorage(t)
ctx := context.Background()
content := "keep me after soft-delete"
info, err := svc.Upload(ctx, "user1", nil, "keep.txt", strings.NewReader(content))
if err != nil {
t.Fatalf("Upload = %v", err)
}
if err := svc.Delete(ctx, "user1", info.ID); err != nil {
t.Fatalf("Delete = %v", err)
}
storagePath := "user1/" + info.ID
reader, err := store.Open(ctx, storagePath)
if err != nil {
t.Fatalf("storage should retain content after soft-delete, got: %v", err)
}
defer reader.Close()
data, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("read storage: %v", err)
}
if string(data) != content {
t.Errorf("storage content = %q, want %q", string(data), content)
}
}
func TestFileService_SoftDeleteIdempotent(t *testing.T) {
svc := setupFileService(t)
ctx := context.Background()
info, err := svc.Upload(ctx, "user1", nil, "idem.txt", strings.NewReader("data"))
if err != nil {
t.Fatalf("Upload = %v", err)
}
if err := svc.Delete(ctx, "user1", info.ID); err != nil {
t.Fatalf("first Delete = %v", err)
}
if err := svc.Delete(ctx, "user1", info.ID); err != nil {
t.Fatalf("second Delete should be idempotent, got: %v", err)
}
}
func TestFileService_SoftDeleteForbidden(t *testing.T) {
svc := setupFileService(t)
ctx := context.Background()
info, err := svc.Upload(ctx, "user1", nil, "forbidden.txt", strings.NewReader("data"))
if err != nil {
t.Fatalf("Upload = %v", err)
}
err = svc.Delete(ctx, "user2", info.ID)
assertAppError(t, err, http.StatusForbidden)
}