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",
|
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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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, ¬Found) {
|
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
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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/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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,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"`
|
||||||
|
|||||||
@@ -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"`
|
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"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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) {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user