Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5eeb37389b | |||
| 7b6587a951 | |||
| 3daad379e0 | |||
| e04e39bea8 | |||
| ac98b5ddbd | |||
| 032f716e49 | |||
| a8078f787c | |||
| 170e54495d | |||
| 118b7e4d2a | |||
| bff131ba42 |
+6
-1
@@ -23,7 +23,7 @@ var serveCmd = &cobra.Command{
|
||||
Short: "Start the MyGO HTTP server",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
v := config.New()
|
||||
cfg, err := config.Load(v, serveConfigFile)
|
||||
cfg, loadInfo, err := config.Load(v, serveConfigFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
@@ -32,6 +32,11 @@ var serveCmd = &cobra.Command{
|
||||
appLogger := mygolog.NewLogger(cfg.Log)
|
||||
slog.SetDefault(appLogger)
|
||||
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)
|
||||
defer stop()
|
||||
|
||||
+3
-1
@@ -28,6 +28,8 @@ log:
|
||||
file_level: debug # file: debug, info, warn, error
|
||||
|
||||
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
|
||||
refresh_ttl: 168h
|
||||
|
||||
@@ -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. |
|
||||
| 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()`. |
|
||||
|
||||
**Consequences**:
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
+6
-1
@@ -52,9 +52,14 @@ storage:
|
||||
path: data/files
|
||||
|
||||
jwt:
|
||||
secret: changeme-in-production
|
||||
secret: dev-secret-do-not-use-in-production
|
||||
access_ttl: 15m
|
||||
refresh_ttl: 168h
|
||||
```
|
||||
|
||||
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
@@ -1,6 +1,8 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -8,6 +10,17 @@ import (
|
||||
"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) {
|
||||
v.SetDefault("server.host", "0.0.0.0")
|
||||
v.SetDefault("server.port", 10086)
|
||||
@@ -24,7 +37,7 @@ func defaults(v *viper.Viper) {
|
||||
v.SetDefault("storage.driver", "local")
|
||||
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.refresh_ttl", "168h")
|
||||
|
||||
@@ -42,7 +55,7 @@ func New() *viper.Viper {
|
||||
return v
|
||||
}
|
||||
|
||||
func Load(v *viper.Viper, cfgFile string) (*Config, error) {
|
||||
func Load(v *viper.Viper, cfgFile string) (*Config, LoadInfo, error) {
|
||||
if cfgFile != "" {
|
||||
v.SetConfigFile(cfgFile)
|
||||
} else {
|
||||
@@ -54,18 +67,54 @@ func Load(v *viper.Viper, cfgFile string) (*Config, error) {
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
var notFound viper.ConfigFileNotFoundError
|
||||
if !errors.As(err, ¬Found) {
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
return nil, LoadInfo{}, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
func TestDefaults(t *testing.T) {
|
||||
v := New()
|
||||
cfg, err := Load(v, "")
|
||||
cfg, info, err := Load(v, "")
|
||||
if err != nil {
|
||||
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) {
|
||||
@@ -67,10 +77,13 @@ jwt:
|
||||
}
|
||||
|
||||
v := New()
|
||||
cfg, err := Load(v, path)
|
||||
cfg, info, err := Load(v, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.EphemeralJWTSecret {
|
||||
t.Fatal("custom jwt.secret should not be replaced")
|
||||
}
|
||||
|
||||
if 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")
|
||||
|
||||
v := New()
|
||||
cfg, err := Load(v, "")
|
||||
cfg, info, err := Load(v, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.EphemeralJWTSecret {
|
||||
t.Fatal("env jwt.secret should not be replaced")
|
||||
}
|
||||
|
||||
if 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")
|
||||
|
||||
v := New()
|
||||
cfg, err := Load(v, path)
|
||||
cfg, _, err := Load(v, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -164,7 +180,7 @@ server:
|
||||
}
|
||||
|
||||
v := New()
|
||||
_, err := Load(v, path)
|
||||
_, _, err := Load(v, path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for port 0, got nil")
|
||||
}
|
||||
@@ -183,7 +199,7 @@ jwt:
|
||||
}
|
||||
|
||||
v := New()
|
||||
_, err := Load(v, path)
|
||||
_, _, err := Load(v, path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty jwt.secret, got nil")
|
||||
}
|
||||
@@ -191,12 +207,48 @@ jwt:
|
||||
|
||||
func TestExplicitConfigFileNotFound(t *testing.T) {
|
||||
v := New()
|
||||
_, err := Load(v, "/nonexistent/path/config.yaml")
|
||||
_, _, err := Load(v, "/nonexistent/path/config.yaml")
|
||||
if err == nil {
|
||||
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) {
|
||||
j := JWTConfig{AccessTTL: 15 * time.Minute}
|
||||
if j.AccessTTL != 15*time.Minute {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/dhao2001/mygo/internal/api"
|
||||
"github.com/dhao2001/mygo/internal/auth"
|
||||
"github.com/dhao2001/mygo/internal/repository"
|
||||
)
|
||||
|
||||
const userIDKey = "user_id"
|
||||
@@ -68,3 +69,33 @@ func MustGetUserID(c *gin.Context) string {
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -8,8 +10,12 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/auth"
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
"github.com/dhao2001/mygo/internal/repository"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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,6 +4,9 @@ import (
|
||||
"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.
|
||||
type File struct {
|
||||
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"`
|
||||
StoragePath string `gorm:"type:varchar(512)" json:"storage_path"`
|
||||
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"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,13 @@ type User struct {
|
||||
Email string `gorm:"uniqueIndex;type:varchar(255);not null" json:"email"`
|
||||
PasswordHash string `gorm:"type:varchar(255);not null" json:"-"`
|
||||
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"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// User status constants.
|
||||
const (
|
||||
StatusActive = "active"
|
||||
StatusAdminDeleted = "admin_deleted"
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
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) {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
@@ -57,11 +57,11 @@ func (r *fileRepository) FindByUserID(ctx context.Context, userID string, offset
|
||||
var files []model.File
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
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) {
|
||||
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 {
|
||||
return nil, result.Error
|
||||
}
|
||||
@@ -82,11 +82,11 @@ func (r *fileRepository) FindByParentIDPaginated(ctx context.Context, userID str
|
||||
var files []model.File
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
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) {
|
||||
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) {
|
||||
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 {
|
||||
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 {
|
||||
return result.Error
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ func TestFileRepository_Create(t *testing.T) {
|
||||
UserID: "user-1",
|
||||
Name: "test.txt",
|
||||
Size: 1024,
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
|
||||
if err := repo.Create(ctx, file); err != nil {
|
||||
@@ -48,6 +49,7 @@ func TestFileRepository_FindByID(t *testing.T) {
|
||||
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)
|
||||
@@ -77,9 +79,9 @@ func TestFileRepository_FindByUserID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
files := []*model.File{
|
||||
{ID: "f-1", UserID: "user-1", Name: "a.txt"},
|
||||
{ID: "f-2", UserID: "user-1", Name: "b.txt"},
|
||||
{ID: "f-3", UserID: "user-2", Name: "c.txt"},
|
||||
{ID: "f-1", UserID: "user-1", Name: "a.txt", Status: model.StatusActive},
|
||||
{ID: "f-2", UserID: "user-1", Name: "b.txt", Status: model.StatusActive},
|
||||
{ID: "f-3", UserID: "user-2", Name: "c.txt", Status: model.StatusActive},
|
||||
}
|
||||
for _, f := range files {
|
||||
if err := repo.Create(ctx, f); err != nil {
|
||||
@@ -105,9 +107,9 @@ func TestFileRepository_FindByParentID(t *testing.T) {
|
||||
|
||||
parentID := "dir-1"
|
||||
files := []*model.File{
|
||||
{ID: "f-1", UserID: "user-1", ParentID: &parentID, Name: "a.txt"},
|
||||
{ID: "f-2", UserID: "user-1", ParentID: &parentID, Name: "b.txt"},
|
||||
{ID: "f-3", UserID: "user-1", Name: "c.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", Status: model.StatusActive},
|
||||
{ID: "f-3", UserID: "user-1", Name: "c.txt", Status: model.StatusActive},
|
||||
}
|
||||
for _, f := range files {
|
||||
if err := repo.Create(ctx, f); err != nil {
|
||||
@@ -130,8 +132,8 @@ func TestFileRepository_FindByParentIDNull(t *testing.T) {
|
||||
|
||||
parentID := "dir-1"
|
||||
files := []*model.File{
|
||||
{ID: "f-1", UserID: "user-1", ParentID: &parentID, Name: "a.txt"},
|
||||
{ID: "f-2", UserID: "user-1", Name: "root.txt"},
|
||||
{ID: "f-1", UserID: "user-1", ParentID: &parentID, Name: "a.txt", Status: model.StatusActive},
|
||||
{ID: "f-2", UserID: "user-1", Name: "root.txt", Status: model.StatusActive},
|
||||
}
|
||||
for _, f := range files {
|
||||
if err := repo.Create(ctx, f); err != nil {
|
||||
@@ -152,7 +154,7 @@ func TestFileRepository_Update(t *testing.T) {
|
||||
repo := setupFileRepo(t)
|
||||
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 {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
@@ -179,7 +181,7 @@ func TestFileRepository_Delete(t *testing.T) {
|
||||
repo := setupFileRepo(t)
|
||||
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 {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
@@ -193,3 +195,121 @@ func TestFileRepository_Delete(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,11 +19,13 @@ func isDuplicateKeyError(err error) bool {
|
||||
type UserRepository interface {
|
||||
Create(ctx context.Context, user *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)
|
||||
FindByUsername(ctx context.Context, username string) (*model.User, error)
|
||||
Update(ctx context.Context, user *model.User) error
|
||||
Delete(ctx context.Context, id string) 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 {
|
||||
@@ -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) {
|
||||
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
|
||||
result := r.db.WithContext(ctx).First(&user, "id = ?", id)
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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 {
|
||||
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 {
|
||||
return result.Error
|
||||
}
|
||||
@@ -105,6 +120,23 @@ func (r *userRepository) List(ctx context.Context, offset, limit int) ([]model.U
|
||||
var users []model.User
|
||||
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 {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ func TestUserRepository_Create(t *testing.T) {
|
||||
Username: "alice",
|
||||
Email: "alice@example.com",
|
||||
PasswordHash: "hash",
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
|
||||
if err := repo.Create(ctx, user); err != nil {
|
||||
@@ -44,8 +45,8 @@ func TestUserRepository_CreateDuplicateUsername(t *testing.T) {
|
||||
repo := setupUserRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
u1 := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash"}
|
||||
u2 := &model.User{ID: "user-2", Username: "alice", Email: "alice2@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", Status: model.StatusActive}
|
||||
|
||||
if err := repo.Create(ctx, u1); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
@@ -61,7 +62,7 @@ func TestUserRepository_FindByID(t *testing.T) {
|
||||
repo := setupUserRepo(t)
|
||||
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 {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
@@ -89,7 +90,7 @@ func TestUserRepository_FindByEmail(t *testing.T) {
|
||||
repo := setupUserRepo(t)
|
||||
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 {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
@@ -107,7 +108,7 @@ func TestUserRepository_FindByUsername(t *testing.T) {
|
||||
repo := setupUserRepo(t)
|
||||
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 {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
@@ -125,7 +126,7 @@ func TestUserRepository_Update(t *testing.T) {
|
||||
repo := setupUserRepo(t)
|
||||
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 {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
@@ -148,7 +149,7 @@ func TestUserRepository_Delete(t *testing.T) {
|
||||
repo := setupUserRepo(t)
|
||||
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 {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
@@ -173,6 +174,7 @@ func TestUserRepository_List(t *testing.T) {
|
||||
Username: "user" + string(rune('0'+i)),
|
||||
Email: "user" + string(rune('0'+i)) + "@example.com",
|
||||
PasswordHash: "hash",
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := repo.Create(ctx, user); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
@@ -190,3 +192,128 @@ func TestUserRepository_List(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ func setupProtectedRoutes(rg *gin.RouterGroup, webApp *app.WebApp) {
|
||||
jwtSecret := []byte(webApp.Config.JWT.Secret)
|
||||
accountHandler := handler.NewAccountHandler(webApp.AuthService)
|
||||
fileHandler := handler.NewFileHandler(webApp.FileService)
|
||||
adminHandler := handler.NewAdminHandler(webApp.UserRepo)
|
||||
|
||||
rg.Use(middleware.AuthRequired(jwtSecret))
|
||||
|
||||
@@ -36,4 +37,12 @@ func setupProtectedRoutes(rg *gin.RouterGroup, webApp *app.WebApp) {
|
||||
files.PUT("/:id", fileHandler.Update)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -8,7 +10,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/app"
|
||||
"github.com/dhao2001/mygo/internal/auth"
|
||||
"github.com/dhao2001/mygo/internal/config"
|
||||
"github.com/dhao2001/mygo/internal/repository"
|
||||
"github.com/dhao2001/mygo/internal/service"
|
||||
)
|
||||
|
||||
@@ -56,3 +60,123 @@ func TestAddress(t *testing.T) {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ func (s *AuthService) Register(ctx context.Context, username, email, password st
|
||||
Username: username,
|
||||
Email: email,
|
||||
PasswordHash: passwordHash,
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
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"}
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
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" {
|
||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid credential type"}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,13 @@ import (
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
@@ -30,12 +37,13 @@ func setupAuthService(t *testing.T) *AuthService {
|
||||
sessionRepo := repository.NewSessionRepository(db)
|
||||
credentialRepo := repository.NewCredentialRepository(db)
|
||||
|
||||
return NewAuthService(
|
||||
svc := NewAuthService(
|
||||
userRepo, sessionRepo, credentialRepo,
|
||||
[]byte("test-secret"),
|
||||
15*time.Minute,
|
||||
7*24*time.Hour,
|
||||
)
|
||||
return svc, userRepo
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,7 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin
|
||||
MimeType: mimeType,
|
||||
StoragePath: storagePath,
|
||||
Hash: hex.EncodeToString(hasher.Sum(nil)),
|
||||
Status: model.StatusActive,
|
||||
IsDir: false,
|
||||
}
|
||||
|
||||
@@ -240,10 +241,14 @@ func (s *FileService) Update(ctx context.Context, userID, fileID string, newName
|
||||
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 {
|
||||
file, err := s.getOwnedFile(ctx, userID, fileID)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -262,14 +267,6 @@ func (s *FileService) Delete(ctx context.Context, userID, fileID string) error {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -297,6 +294,7 @@ func (s *FileService) CreateDir(ctx context.Context, userID string, parentID *st
|
||||
UserID: userID,
|
||||
ParentID: parentID,
|
||||
Name: dirName,
|
||||
Status: model.StatusActive,
|
||||
IsDir: true,
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ func (s *memStorage) Delete(_ context.Context, path string) error {
|
||||
|
||||
var _ storage.StorageBackend = (*memStorage)(nil)
|
||||
|
||||
func setupFileService(t *testing.T) *FileService {
|
||||
func setupFileServiceWithStorage(t *testing.T) (*FileService, *memStorage) {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
@@ -87,7 +87,12 @@ func setupFileService(t *testing.T) *FileService {
|
||||
fileRepo := repository.NewFileRepository(db)
|
||||
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) {
|
||||
@@ -461,3 +466,80 @@ func TestFileService_VerifyParentForbidden(t *testing.T) {
|
||||
_, err = svc.Upload(ctx, "user2", &dir.ID, "stolen.txt", strings.NewReader("data"))
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user