feat(repository): refactor query/mutation ports with capability-safe
interfaces - refactor: split UserRepository into AuthUserRepository and AdminUserRepository capabilities - refactor: split SessionRepository into AuthSessionRepository and repository-owned CLI/admin methods - refactor: replace generic Repository Update/Delete with operation-specific params and ownership predicates - refactor: replace CredentialRepository generic CRUD with passkey-specific methods - refactor: replace FileRepository generic Create/Update/Delete with UploadedFileParams, DirectoryParams, and owned soft-delete - refactor: remove repository fields from WebApp struct; repositories are now composition-time wiring only - feat: add domain error kinds ErrParentNotFound, ErrParentNotDir, ErrDirectoryNotEmpty, ErrInvalidMove - feat: add CredentialTypeAppPasskey constant - feat: add testutil.SetUserAdmin for test fixture setup that bypasses production service ports - test: add architecture test banning GORM Save in repository package - test: add capability interface contract tests ensuring each service receives the minimal interface - test: add blockingStorage helper for concurrent promotion tests - test: add preserved DSN parameter test for sqliteImmediateDSN - docs: update architecture decisions with repository write rules and capability separation - docs: update roadmap to clarify atomic single-use refresh sessions - docs: add -race test target to development docs
This commit is contained in:
@@ -20,6 +20,9 @@ Rules:
|
||||
- JSON response DTOs live in HTTP packages. Domain and service result structs are not the public REST schema.
|
||||
- Domain models may use `json:"-"` for defensive redaction of sensitive fields; this is not a REST response contract.
|
||||
- Domain errors carry a protocol-neutral kind, a safe message, and an optional cause. HTTP status mapping happens only at the API boundary.
|
||||
- Repository query ports remain entity-oriented, while mutation ports expose business commands with operation-specific parameter types. Services never pass a mutable domain entity to a generic update or delete method.
|
||||
- Service constructors using split repository capabilities accept the smallest capability needed by that service. Their composition-time unions do not cross into business services.
|
||||
- Repository mutations use explicit columns and lifecycle/ownership predicates. Full-entity `Save` is prohibited so a write cannot accidentally change protected fields or revive a soft-deleted record.
|
||||
- Repository abstracts the database; Storage abstracts where bytes live, including staged upload promotion.
|
||||
- `internal/server` is the composition root — wires all dependencies together.
|
||||
|
||||
@@ -42,6 +45,19 @@ Rules:
|
||||
| | `internal/storage` | Storage backend interface + local disk impl, with staging and promotion | 🛠 WIP |
|
||||
| **Util** | `internal/auth` | JWT sign/verify (HS256), token type discrimination (access/refresh), password hashing (bcrypt), app passkey tokens | ✅ |
|
||||
| | `internal/api` | Unified JSON error response helpers and REST error-kind mapping | ✅ |
|
||||
| | `internal/testutil` | Test-only fixture helpers for privileged setup that production service ports intentionally do not expose | ✅ |
|
||||
|
||||
## Persistence Write Boundaries
|
||||
|
||||
- User registration accepts `RegisteredUserParams`; the repository fixes new users to non-admin, active state. Administrative deletion is a separate capability.
|
||||
- File creation, directory creation, metadata changes, and soft deletion use distinct commands. Metadata updates can only write `name`, `parent_id`, and GORM-maintained `updated_at`.
|
||||
- Refresh sessions are created, atomically consumed, idempotently revoked, or removed by explicit maintenance operations. Refresh token rotation consumes a session at most once.
|
||||
- Passkey creation fixes the credential type to the app-passkey type. Usage recording and revocation include credential-type and ownership conditions where applicable.
|
||||
- `app.WebApp` contains runtime services, storage, database lifecycle, configuration, and metadata. Repository implementations remain local to bootstrap and are injected directly into the services that require them.
|
||||
|
||||
## File Hierarchy Transactions
|
||||
|
||||
File child creation, movement, and directory deletion share one parent-locking protocol. PostgreSQL locks active owned source and parent rows in sorted ID order with `SELECT ... FOR UPDATE`; directory deletion checks active children while holding the directory lock. SQLite connections add `_txlock=immediate` to the configured DSN so the transaction acquires its write reservation before reading hierarchy state. These rules prevent a concurrent mutation from creating an active child below a deleted directory.
|
||||
|
||||
## API Routes (v0)
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
**Consequences**:
|
||||
- Version is build metadata from `internal/app/version.go`, not a config-file field.
|
||||
- `app.WebApp` is the place to add future services, repositories, storage, and app metadata incrementally.
|
||||
- `app.WebApp` is the place to add future runtime services, storage, and app metadata incrementally; repository implementations stay inside composition setup.
|
||||
- Request ID middleware is not part of the current foundation; add it only with a logging/tracing/error-correlation design.
|
||||
|
||||
## 2026-04-29: Auth Refinements
|
||||
@@ -123,3 +123,26 @@
|
||||
- Authenticated file operations cannot use status codes or messages to determine whether another user's resource exists.
|
||||
- A successful first delete returns `204 No Content`; repeated deletes return `404 Not Found` while remaining idempotent in effect.
|
||||
- Unauthenticated requests remain `401 Unauthorized`, non-empty directory deletion remains `409 Conflict`, and permission-denied behavior outside the file boundary remains unchanged.
|
||||
|
||||
## 2026-07-16: Command-Scoped Persistence Mutations
|
||||
|
||||
**Context**: Generic `Repository.Update(*Entity)`, full-entity GORM `Save`, and broad delete methods exposed fields and lifecycle transitions that individual services were not authorized to perform. They also allowed a stale file entity to revive a soft-deleted row, left file parent checks vulnerable to concurrent deletion, and allowed two refresh requests to consume the same session.
|
||||
|
||||
**Decisions**:
|
||||
|
||||
| Decision | Guidance |
|
||||
|----------|----------|
|
||||
| Command-scoped writes | Every mutation has a use-case-specific method and parameter type. Mutable domain entities do not cross a service-to-repository write boundary. |
|
||||
| Capability interfaces | Auth and admin services accept only the query and command capabilities they require. Wider user and session interfaces exist only for construction and repository-focused consumers. |
|
||||
| Explicit mutation predicates | Updates name concrete columns and include ownership, credential type, and/or active-state predicates appropriate to the command. A zero-row guarded mutation maps to a domain not-found result. |
|
||||
| Transactional file hierarchy | Child creation, movement, and directory deletion revalidate hierarchy state in the same transaction. PostgreSQL locks relevant rows in sorted ID order; SQLite DSNs force `_txlock=immediate`. |
|
||||
| Atomic refresh consumption | Refresh rotates through one transaction that locks, returns, and deletes the session. A concurrent replay cannot issue a second token pair. Logout remains an idempotent revoke-by-hash command. |
|
||||
| Test-only privilege setup | Tests elevate users through `internal/testutil`; no production role mutation is added merely to prepare fixtures. |
|
||||
|
||||
**Consequences**:
|
||||
|
||||
- Protected user, file, session, and credential fields are absent from ordinary mutation inputs, reducing illegal write paths at compile time.
|
||||
- Deleted file rows cannot be revived by stale metadata updates, and successful hierarchy races cannot leave active orphan children.
|
||||
- PostgreSQL relies on row-level locking while SQLite serializes these hierarchy and session write transactions from their first database operation. This favors correctness over concurrent SQLite writers.
|
||||
- Adding a future profile, password, role, restore, or other mutation requires a named command and an explicit capability rather than extending a universal update method.
|
||||
- REST routes, payloads, status codes, database schema, and dependency set remain unchanged.
|
||||
|
||||
@@ -17,6 +17,7 @@ go build -o mygo .
|
||||
```bash
|
||||
go test ./...
|
||||
go test -v -run TestName ./internal/...
|
||||
go test -race ./internal/repository ./internal/service ./internal/server
|
||||
```
|
||||
|
||||
## Lint & Format
|
||||
@@ -52,6 +53,12 @@ go mod tidy # after adding/removing imports
|
||||
|
||||
Server config is loaded via viper from `config.yaml` (defaults in `internal/config/load.go`).
|
||||
|
||||
For SQLite, the repository preserves configured DSN parameters and adds
|
||||
`_txlock=immediate`. Write transactions therefore reserve the database before
|
||||
performing hierarchy or refresh-session reads, matching the locking assumptions
|
||||
used by the repository commands. Keep this behavior when supplying a SQLite URI
|
||||
such as `file:mygo.db?cache=shared`.
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
| Feature | Status | Notes |
|
||||
|---------|--------|-------|
|
||||
| CLI config management | ✅ | Viper YAML + env + flags, typed Duration config |
|
||||
| JWT authentication | ✅ | access + refresh tokens, refresh token in DB, app passkey support |
|
||||
| JWT authentication | ✅ | access + refresh tokens, atomic single-use refresh sessions, app passkey support |
|
||||
| Web API foundation | ✅ | WebApp composition, Gin router, graceful shutdown, `GET /api/v1/version` |
|
||||
| File upload/download/manage APIs | 🛠 WIP | REST API via Gin |
|
||||
| Admin endpoints | 🛠 WIP | user service boundary in place for superusers |
|
||||
@@ -21,13 +21,13 @@ Package-level implementation order (each task includes unit tests):
|
||||
4. `internal/api` — protocol-neutral error kind to REST response mapping ✅
|
||||
5. `internal/auth` — JWT utils ✅
|
||||
6. `internal/storage` — backend interface + local fs with staged upload promotion
|
||||
7. `internal/repository` — interfaces + GORM/SQLite impl ✅
|
||||
7. `internal/repository` — command-scoped mutation capabilities + GORM/SQLite transaction protocol ✅
|
||||
8. `internal/service` — auth, file, admin services ✅
|
||||
9. `internal/middleware` — logger, cors, auth ✅ (auth done; principal boundary enforced)
|
||||
10. `internal/handler` — auth, account, file, admin handlers 🛠 (HTTP DTO mapping in place)
|
||||
11. `internal/server` — Gin router, route registration, graceful shutdown ✅
|
||||
12. `cmd/serve.go`, `cmd/config.go`, `cmd/status.go` ✅ (serve done)
|
||||
13. Integration tests 🛠 (authenticated register → login → upload → list → download route flow covered with a small file; authentication bypass, invalid token, concealed cross-user resources, and strict delete boundaries covered)
|
||||
13. Integration tests 🛠 (authenticated register → login → upload → list → download route flow covered with a small file; authentication bypass, invalid token, concealed cross-user resources, strict delete, atomic refresh consumption, and file hierarchy race boundaries covered)
|
||||
14. Architecture boundary tests ✅
|
||||
|
||||
## Future
|
||||
|
||||
@@ -18,10 +18,6 @@ type WebApp struct {
|
||||
Version string
|
||||
|
||||
DB *gorm.DB
|
||||
UserRepo repository.UserRepository
|
||||
SessionRepo repository.SessionRepository
|
||||
FileRepo repository.FileRepository
|
||||
CredentialRepo repository.CredentialRepository
|
||||
AuthService *service.AuthService
|
||||
AdminService *service.AdminService
|
||||
FileService *service.FileService
|
||||
@@ -65,10 +61,6 @@ func Bootstrap(cfg *config.Config) (*WebApp, error) {
|
||||
Config: cfg,
|
||||
Version: AppVersion,
|
||||
DB: db,
|
||||
UserRepo: userRepo,
|
||||
SessionRepo: sessionRepo,
|
||||
FileRepo: fileRepo,
|
||||
CredentialRepo: credentialRepo,
|
||||
AuthService: authService,
|
||||
AdminService: adminService,
|
||||
FileService: fileService,
|
||||
@@ -78,10 +70,6 @@ func Bootstrap(cfg *config.Config) (*WebApp, error) {
|
||||
|
||||
// NewWebApp creates a WebApp with pre-built dependencies (useful for testing).
|
||||
func NewWebApp(cfg *config.Config, db *gorm.DB,
|
||||
userRepo repository.UserRepository,
|
||||
sessionRepo repository.SessionRepository,
|
||||
fileRepo repository.FileRepository,
|
||||
credentialRepo repository.CredentialRepository,
|
||||
authService *service.AuthService,
|
||||
adminService *service.AdminService,
|
||||
fileService *service.FileService,
|
||||
@@ -91,10 +79,6 @@ func NewWebApp(cfg *config.Config, db *gorm.DB,
|
||||
Config: cfg,
|
||||
Version: AppVersion,
|
||||
DB: db,
|
||||
UserRepo: userRepo,
|
||||
SessionRepo: sessionRepo,
|
||||
FileRepo: fileRepo,
|
||||
CredentialRepo: credentialRepo,
|
||||
AuthService: authService,
|
||||
AdminService: adminService,
|
||||
FileService: fileService,
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
func TestNewWebApp(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
|
||||
webApp := NewWebApp(cfg, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
webApp := NewWebApp(cfg, nil, nil, nil, nil, nil)
|
||||
|
||||
if webApp.Config != cfg {
|
||||
t.Fatal("Config was not assigned")
|
||||
@@ -20,7 +20,7 @@ func TestNewWebApp(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCloseNilDB(t *testing.T) {
|
||||
webApp := NewWebApp(&config.Config{}, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
webApp := NewWebApp(&config.Config{}, nil, nil, nil, nil, nil)
|
||||
if err := webApp.Close(); err != nil {
|
||||
t.Errorf("Close with nil DB should not error: %v", err)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,25 @@ func TestArchitecture_HTTPDoesNotImportRepositories(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchitecture_RepositoriesDoNotUseFullEntitySave(t *testing.T) {
|
||||
pkgs := parsePackage(t, "repository")
|
||||
for _, pkg := range pkgs {
|
||||
for filename, file := range pkg.Files {
|
||||
ast.Inspect(file, func(node ast.Node) bool {
|
||||
call, ok := node.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
selector, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if ok && selector.Sel.Name == "Save" {
|
||||
t.Fatalf("%s uses GORM Save; repository writes must use operation-specific columns", filename)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func packageImports(t *testing.T, dir string) map[string]bool {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -17,9 +17,10 @@ import (
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
"github.com/dhao2001/mygo/internal/repository"
|
||||
"github.com/dhao2001/mygo/internal/service"
|
||||
"github.com/dhao2001/mygo/internal/testutil"
|
||||
)
|
||||
|
||||
func setupAdminRouter(t *testing.T) (*gin.Engine, repository.UserRepository) {
|
||||
func setupAdminRouter(t *testing.T) (*gin.Engine, repository.UserRepository, *gorm.DB) {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
@@ -63,7 +64,7 @@ func setupAdminRouter(t *testing.T) (*gin.Engine, repository.UserRepository) {
|
||||
admin.DELETE("/users/:id", adminHandler.DeleteUser)
|
||||
}
|
||||
|
||||
return r, userRepo
|
||||
return r, userRepo, db
|
||||
}
|
||||
|
||||
// registerHelper creates a user via the HTTP endpoint and returns the user ID.
|
||||
@@ -113,24 +114,16 @@ func loginHelper(t *testing.T, r *gin.Engine, email, password string) string {
|
||||
}
|
||||
|
||||
// makeAdminHelper promotes a user to admin in the database.
|
||||
func makeAdminHelper(t *testing.T, userRepo repository.UserRepository, userID string) {
|
||||
func makeAdminHelper(t *testing.T, db *gorm.DB, 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)
|
||||
}
|
||||
testutil.SetUserAdmin(t, db, userID, true)
|
||||
}
|
||||
|
||||
func TestAdminDeleteUser(t *testing.T) {
|
||||
r, userRepo := setupAdminRouter(t)
|
||||
r, _, db := setupAdminRouter(t)
|
||||
|
||||
adminID := registerHelper(t, r, "admin", "admin@example.com", "adminpass123")
|
||||
makeAdminHelper(t, userRepo, adminID)
|
||||
makeAdminHelper(t, db, adminID)
|
||||
|
||||
userID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123")
|
||||
|
||||
@@ -159,10 +152,10 @@ func TestAdminDeleteUser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminDeleteUserForbidden(t *testing.T) {
|
||||
r, userRepo := setupAdminRouter(t)
|
||||
r, _, db := setupAdminRouter(t)
|
||||
|
||||
adminID := registerHelper(t, r, "admin", "admin@example.com", "adminpass123")
|
||||
makeAdminHelper(t, userRepo, adminID)
|
||||
makeAdminHelper(t, db, adminID)
|
||||
|
||||
userID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123")
|
||||
|
||||
@@ -181,7 +174,7 @@ func TestAdminDeleteUserForbidden(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminDeleteUserUnauthorized(t *testing.T) {
|
||||
r, _ := setupAdminRouter(t)
|
||||
r, _, _ := setupAdminRouter(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/users/some-id", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -193,10 +186,10 @@ func TestAdminDeleteUserUnauthorized(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminGetUser(t *testing.T) {
|
||||
r, userRepo := setupAdminRouter(t)
|
||||
r, _, db := setupAdminRouter(t)
|
||||
|
||||
adminID := registerHelper(t, r, "admin", "admin@example.com", "adminpass123")
|
||||
makeAdminHelper(t, userRepo, adminID)
|
||||
makeAdminHelper(t, db, adminID)
|
||||
|
||||
userID := registerHelper(t, r, "bob", "bob@example.com", "bobpass123")
|
||||
|
||||
@@ -231,15 +224,15 @@ func TestAdminGetUser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminGetDeletedUser(t *testing.T) {
|
||||
r, userRepo := setupAdminRouter(t)
|
||||
r, userRepo, db := setupAdminRouter(t)
|
||||
|
||||
adminID := registerHelper(t, r, "admin", "admin@example.com", "adminpass123")
|
||||
makeAdminHelper(t, userRepo, adminID)
|
||||
makeAdminHelper(t, db, 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 {
|
||||
if err := userRepo.MarkAdminDeleted(context.Background(), userID); err != nil {
|
||||
t.Fatalf("soft-delete user: %v", err)
|
||||
}
|
||||
|
||||
@@ -267,17 +260,17 @@ func TestAdminGetDeletedUser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminListIncludesDeleted(t *testing.T) {
|
||||
r, userRepo := setupAdminRouter(t)
|
||||
r, userRepo, db := setupAdminRouter(t)
|
||||
|
||||
adminID := registerHelper(t, r, "admin", "admin@example.com", "adminpass123")
|
||||
makeAdminHelper(t, userRepo, adminID)
|
||||
makeAdminHelper(t, db, 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 {
|
||||
if err := userRepo.MarkAdminDeleted(context.Background(), bobID); err != nil {
|
||||
t.Fatalf("soft-delete user: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// CredentialTypeAppPasskey identifies an application passkey credential.
|
||||
const CredentialTypeAppPasskey = "app_passkey"
|
||||
|
||||
// Credential represents an alternative authentication credential for a user.
|
||||
// The primary password is stored on the User model; additional credentials
|
||||
// (app passkeys, WebAuthn, OAuth) are stored here with a type discriminator.
|
||||
|
||||
@@ -11,6 +11,10 @@ var (
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
ErrUploadTooLarge = errors.New("upload exceeds maximum size")
|
||||
ErrParentNotFound = errors.New("parent resource not found")
|
||||
ErrParentNotDir = errors.New("parent resource is not a directory")
|
||||
ErrDirectoryNotEmpty = errors.New("directory is not empty")
|
||||
ErrInvalidMove = errors.New("invalid file move")
|
||||
)
|
||||
|
||||
// ErrorKind classifies domain errors without tying them to a transport.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRepositoryCapabilitiesExcludeUnneededOperations(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
capability reflect.Type
|
||||
forbidden []string
|
||||
}{
|
||||
{
|
||||
name: "auth user",
|
||||
capability: reflect.TypeFor[AuthUserRepository](),
|
||||
forbidden: []string{"MarkAdminDeleted", "ListIncludeDeleted"},
|
||||
},
|
||||
{
|
||||
name: "admin user",
|
||||
capability: reflect.TypeFor[AdminUserRepository](),
|
||||
forbidden: []string{"CreateRegisteredUser", "FindByEmail"},
|
||||
},
|
||||
{
|
||||
name: "auth session",
|
||||
capability: reflect.TypeFor[AuthSessionRepository](),
|
||||
forbidden: []string{"DeleteSessionsByUserID", "DeleteExpiredSessions"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
for _, method := range tt.forbidden {
|
||||
if _, ok := tt.capability.MethodByName(method); ok {
|
||||
t.Fatalf("capability unexpectedly exposes %s", method)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,15 +10,22 @@ import (
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
)
|
||||
|
||||
// PasskeyParams contains the fields accepted when creating an app passkey.
|
||||
type PasskeyParams struct {
|
||||
ID string
|
||||
UserID string
|
||||
Label string
|
||||
SecretHash string
|
||||
}
|
||||
|
||||
// CredentialRepository provides access to alternative credential records.
|
||||
type CredentialRepository interface {
|
||||
Create(ctx context.Context, cred *model.Credential) error
|
||||
FindByID(ctx context.Context, id string) (*model.Credential, error)
|
||||
FindByUserID(ctx context.Context, userID string) ([]model.Credential, error)
|
||||
FindByUserIDAndType(ctx context.Context, userID, credType string) ([]model.Credential, error)
|
||||
FindByHash(ctx context.Context, hash string) (*model.Credential, error)
|
||||
UpdateLastUsed(ctx context.Context, id string) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
CreatePasskey(ctx context.Context, params PasskeyParams) error
|
||||
FindPasskeyByID(ctx context.Context, id string) (*model.Credential, error)
|
||||
ListPasskeys(ctx context.Context, userID string) ([]model.Credential, error)
|
||||
FindPasskeyByHash(ctx context.Context, hash string) (*model.Credential, error)
|
||||
RecordPasskeyUsed(ctx context.Context, id string) error
|
||||
RevokeOwnedPasskey(ctx context.Context, userID, id string) error
|
||||
}
|
||||
|
||||
type credentialRepository struct {
|
||||
@@ -30,7 +37,14 @@ func NewCredentialRepository(db *gorm.DB) CredentialRepository {
|
||||
return &credentialRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *credentialRepository) Create(ctx context.Context, cred *model.Credential) error {
|
||||
func (r *credentialRepository) CreatePasskey(ctx context.Context, params PasskeyParams) error {
|
||||
cred := &model.Credential{
|
||||
ID: params.ID,
|
||||
UserID: params.UserID,
|
||||
Type: model.CredentialTypeAppPasskey,
|
||||
Label: params.Label,
|
||||
SecretHash: params.SecretHash,
|
||||
}
|
||||
result := r.db.WithContext(ctx).Create(cred)
|
||||
if result.Error != nil {
|
||||
if isDuplicateKeyError(result.Error) {
|
||||
@@ -41,9 +55,9 @@ func (r *credentialRepository) Create(ctx context.Context, cred *model.Credentia
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *credentialRepository) FindByID(ctx context.Context, id string) (*model.Credential, error) {
|
||||
func (r *credentialRepository) FindPasskeyByID(ctx context.Context, id string) (*model.Credential, error) {
|
||||
var cred model.Credential
|
||||
result := r.db.WithContext(ctx).First(&cred, "id = ?", id)
|
||||
result := r.db.WithContext(ctx).First(&cred, "id = ? AND type = ?", id, model.CredentialTypeAppPasskey)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
@@ -53,27 +67,18 @@ func (r *credentialRepository) FindByID(ctx context.Context, id string) (*model.
|
||||
return &cred, nil
|
||||
}
|
||||
|
||||
func (r *credentialRepository) FindByUserID(ctx context.Context, userID string) ([]model.Credential, error) {
|
||||
func (r *credentialRepository) ListPasskeys(ctx context.Context, userID string) ([]model.Credential, error) {
|
||||
var creds []model.Credential
|
||||
result := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&creds)
|
||||
result := r.db.WithContext(ctx).Where("user_id = ? AND type = ?", userID, model.CredentialTypeAppPasskey).Find(&creds)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
func (r *credentialRepository) FindByUserIDAndType(ctx context.Context, userID, credType string) ([]model.Credential, error) {
|
||||
var creds []model.Credential
|
||||
result := r.db.WithContext(ctx).Where("user_id = ? AND type = ?", userID, credType).Find(&creds)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
func (r *credentialRepository) FindByHash(ctx context.Context, hash string) (*model.Credential, error) {
|
||||
func (r *credentialRepository) FindPasskeyByHash(ctx context.Context, hash string) (*model.Credential, error) {
|
||||
var cred model.Credential
|
||||
result := r.db.WithContext(ctx).First(&cred, "secret_hash = ?", hash)
|
||||
result := r.db.WithContext(ctx).First(&cred, "secret_hash = ? AND type = ?", hash, model.CredentialTypeAppPasskey)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
@@ -83,19 +88,27 @@ func (r *credentialRepository) FindByHash(ctx context.Context, hash string) (*mo
|
||||
return &cred, nil
|
||||
}
|
||||
|
||||
func (r *credentialRepository) UpdateLastUsed(ctx context.Context, id string) error {
|
||||
func (r *credentialRepository) RecordPasskeyUsed(ctx context.Context, id string) error {
|
||||
now := time.Now()
|
||||
result := r.db.WithContext(ctx).Model(&model.Credential{}).Where("id = ?", id).Update("last_used_at", now)
|
||||
result := r.db.WithContext(ctx).Model(&model.Credential{}).
|
||||
Where("id = ? AND type = ?", id, model.CredentialTypeAppPasskey).
|
||||
Update("last_used_at", now)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *credentialRepository) Delete(ctx context.Context, id string) error {
|
||||
result := r.db.WithContext(ctx).Delete(&model.Credential{}, "id = ?", id)
|
||||
func (r *credentialRepository) RevokeOwnedPasskey(ctx context.Context, userID, id string) error {
|
||||
result := r.db.WithContext(ctx).Delete(&model.Credential{}, "id = ? AND user_id = ? AND type = ?", id, userID, model.CredentialTypeAppPasskey)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -24,6 +24,12 @@ func setupCredentialRepo(t *testing.T) CredentialRepository {
|
||||
return NewCredentialRepository(db)
|
||||
}
|
||||
|
||||
func createPasskeyRecord(ctx context.Context, repo CredentialRepository, cred *model.Credential) error {
|
||||
return repo.CreatePasskey(ctx, PasskeyParams{
|
||||
ID: cred.ID, UserID: cred.UserID, Label: cred.Label, SecretHash: cred.SecretHash,
|
||||
})
|
||||
}
|
||||
|
||||
func TestCredentialRepository_Create(t *testing.T) {
|
||||
repo := setupCredentialRepo(t)
|
||||
ctx := context.Background()
|
||||
@@ -36,7 +42,7 @@ func TestCredentialRepository_Create(t *testing.T) {
|
||||
SecretHash: "hash-abc",
|
||||
}
|
||||
|
||||
if err := repo.Create(ctx, cred); err != nil {
|
||||
if err := createPasskeyRecord(ctx, repo, cred); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -48,11 +54,11 @@ func TestCredentialRepository_CreateDuplicateHash(t *testing.T) {
|
||||
c1 := &model.Credential{ID: "cred-1", UserID: "user-1", Type: "app_passkey", Label: "A", SecretHash: "hash-abc"}
|
||||
c2 := &model.Credential{ID: "cred-2", UserID: "user-1", Type: "app_passkey", Label: "B", SecretHash: "hash-abc"}
|
||||
|
||||
if err := repo.Create(ctx, c1); err != nil {
|
||||
if err := createPasskeyRecord(ctx, repo, c1); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
err := repo.Create(ctx, c2)
|
||||
err := createPasskeyRecord(ctx, repo, c2)
|
||||
if err != model.ErrDuplicate {
|
||||
t.Fatalf("expected ErrDuplicate, got %v", err)
|
||||
}
|
||||
@@ -63,11 +69,11 @@ func TestCredentialRepository_FindByID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
cred := &model.Credential{ID: "cred-1", UserID: "user-1", Type: "app_passkey", Label: "Phone", SecretHash: "h1"}
|
||||
if err := repo.Create(ctx, cred); err != nil {
|
||||
if err := createPasskeyRecord(ctx, repo, cred); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
found, err := repo.FindByID(ctx, "cred-1")
|
||||
found, err := repo.FindPasskeyByID(ctx, "cred-1")
|
||||
if err != nil {
|
||||
t.Fatalf("FindByID = %v", err)
|
||||
}
|
||||
@@ -80,13 +86,13 @@ func TestCredentialRepository_FindByIDNotFound(t *testing.T) {
|
||||
repo := setupCredentialRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := repo.FindByID(ctx, "nonexistent")
|
||||
_, err := repo.FindPasskeyByID(ctx, "nonexistent")
|
||||
if err != model.ErrNotFound {
|
||||
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialRepository_FindByUserID(t *testing.T) {
|
||||
func TestCredentialRepository_ListPasskeys(t *testing.T) {
|
||||
repo := setupCredentialRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -95,12 +101,12 @@ func TestCredentialRepository_FindByUserID(t *testing.T) {
|
||||
c3 := &model.Credential{ID: "c-3", UserID: "user-2", Type: "app_passkey", Label: "C", SecretHash: "h3"}
|
||||
|
||||
for _, c := range []*model.Credential{c1, c2, c3} {
|
||||
if err := repo.Create(ctx, c); err != nil {
|
||||
if err := createPasskeyRecord(ctx, repo, c); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
creds, err := repo.FindByUserID(ctx, "user-1")
|
||||
creds, err := repo.ListPasskeys(ctx, "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("FindByUserID = %v", err)
|
||||
}
|
||||
@@ -109,28 +115,24 @@ func TestCredentialRepository_FindByUserID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialRepository_FindByUserIDAndType(t *testing.T) {
|
||||
func TestCredentialRepository_CreatePasskeyControlsType(t *testing.T) {
|
||||
repo := setupCredentialRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
c1 := &model.Credential{ID: "c-1", UserID: "user-1", Type: "app_passkey", Label: "A", SecretHash: "h1"}
|
||||
c2 := &model.Credential{ID: "c-2", UserID: "user-1", Type: "oauth", Label: "Github", SecretHash: "h2"}
|
||||
|
||||
for _, c := range []*model.Credential{c1, c2} {
|
||||
if err := repo.Create(ctx, c); err != nil {
|
||||
cred := &model.Credential{ID: "c-1", UserID: "user-1", Label: "A", SecretHash: "h1"}
|
||||
if err := createPasskeyRecord(ctx, repo, cred); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
passkeys, err := repo.FindByUserIDAndType(ctx, "user-1", "app_passkey")
|
||||
passkeys, err := repo.ListPasskeys(ctx, "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("FindByUserIDAndType = %v", err)
|
||||
t.Fatalf("ListPasskeys = %v", err)
|
||||
}
|
||||
if len(passkeys) != 1 {
|
||||
t.Errorf("len(passkeys) = %d, want 1", len(passkeys))
|
||||
}
|
||||
if passkeys[0].Type != "app_passkey" {
|
||||
t.Errorf("type = %q, want %q", passkeys[0].Type, "app_passkey")
|
||||
if passkeys[0].Type != model.CredentialTypeAppPasskey {
|
||||
t.Errorf("type = %q, want %q", passkeys[0].Type, model.CredentialTypeAppPasskey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,11 +141,11 @@ func TestCredentialRepository_FindByHash(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
cred := &model.Credential{ID: "c-1", UserID: "user-1", Type: "app_passkey", Label: "Phone", SecretHash: "hash-find"}
|
||||
if err := repo.Create(ctx, cred); err != nil {
|
||||
if err := createPasskeyRecord(ctx, repo, cred); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
found, err := repo.FindByHash(ctx, "hash-find")
|
||||
found, err := repo.FindPasskeyByHash(ctx, "hash-find")
|
||||
if err != nil {
|
||||
t.Fatalf("FindByHash = %v", err)
|
||||
}
|
||||
@@ -157,15 +159,15 @@ func TestCredentialRepository_UpdateLastUsed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
cred := &model.Credential{ID: "c-1", UserID: "user-1", Type: "app_passkey", Label: "Phone", SecretHash: "h1"}
|
||||
if err := repo.Create(ctx, cred); err != nil {
|
||||
if err := createPasskeyRecord(ctx, repo, cred); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
if err := repo.UpdateLastUsed(ctx, "c-1"); err != nil {
|
||||
if err := repo.RecordPasskeyUsed(ctx, "c-1"); err != nil {
|
||||
t.Fatalf("UpdateLastUsed = %v", err)
|
||||
}
|
||||
|
||||
found, err := repo.FindByID(ctx, "c-1")
|
||||
found, err := repo.FindPasskeyByID(ctx, "c-1")
|
||||
if err != nil {
|
||||
t.Fatalf("FindByID = %v", err)
|
||||
}
|
||||
@@ -179,16 +181,35 @@ func TestCredentialRepository_Delete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
cred := &model.Credential{ID: "c-1", UserID: "user-1", Type: "app_passkey", Label: "Phone", SecretHash: "h1"}
|
||||
if err := repo.Create(ctx, cred); err != nil {
|
||||
if err := createPasskeyRecord(ctx, repo, cred); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
if err := repo.Delete(ctx, "c-1"); err != nil {
|
||||
if err := repo.RevokeOwnedPasskey(ctx, "user-1", "c-1"); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
|
||||
_, err := repo.FindByID(ctx, "c-1")
|
||||
_, err := repo.FindPasskeyByID(ctx, "c-1")
|
||||
if err != model.ErrNotFound {
|
||||
t.Fatalf("expected ErrNotFound after delete, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialRepository_RevokeOwnedPasskeyEnforcesOwner(t *testing.T) {
|
||||
repo := setupCredentialRepo(t)
|
||||
ctx := context.Background()
|
||||
cred := &model.Credential{ID: "c-1", UserID: "user-1", Label: "Phone", SecretHash: "h1"}
|
||||
if err := createPasskeyRecord(ctx, repo, cred); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
if err := repo.RevokeOwnedPasskey(ctx, "user-2", cred.ID); err != model.ErrNotFound {
|
||||
t.Fatalf("cross-owner revoke = %v, want ErrNotFound", err)
|
||||
}
|
||||
if _, err := repo.FindPasskeyByID(ctx, cred.ID); err != nil {
|
||||
t.Fatalf("cross-owner revoke removed passkey: %v", err)
|
||||
}
|
||||
if err := repo.RevokeOwnedPasskey(ctx, cred.UserID, cred.ID); err != nil {
|
||||
t.Fatalf("owner revoke = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ package repository
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
@@ -23,7 +25,7 @@ func Open(cfg config.DatabaseConfig) (*gorm.DB, error) {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create db directory: %w", err)
|
||||
}
|
||||
dialector = sqlite.Open(cfg.SQLite.Path)
|
||||
dialector = sqlite.Open(sqliteImmediateDSN(cfg.SQLite.Path))
|
||||
case "postgres":
|
||||
dsn := fmt.Sprintf(
|
||||
"host=%s user=%s password=%s dbname=%s port=%d sslmode=%s",
|
||||
@@ -47,6 +49,20 @@ func Open(cfg config.DatabaseConfig) (*gorm.DB, error) {
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func sqliteImmediateDSN(path string) string {
|
||||
base, rawQuery, found := strings.Cut(path, "?")
|
||||
query, err := url.ParseQuery(rawQuery)
|
||||
if err != nil {
|
||||
separator := "?"
|
||||
if found {
|
||||
separator = "&"
|
||||
}
|
||||
return path + separator + "_txlock=immediate"
|
||||
}
|
||||
query.Set("_txlock", "immediate")
|
||||
return base + "?" + query.Encode()
|
||||
}
|
||||
|
||||
// AutoMigrate runs schema migration for all domain models.
|
||||
func AutoMigrate(db *gorm.DB) error {
|
||||
return db.AutoMigrate(
|
||||
|
||||
@@ -1,11 +1,31 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/config"
|
||||
)
|
||||
|
||||
func TestSQLiteImmediateDSNPreservesParameters(t *testing.T) {
|
||||
dsn := sqliteImmediateDSN("file:test.db?cache=shared&_busy_timeout=9000&_txlock=deferred")
|
||||
base, rawQuery, found := strings.Cut(dsn, "?")
|
||||
if !found || base != "file:test.db" {
|
||||
t.Fatalf("DSN base = %q, want file:test.db", base)
|
||||
}
|
||||
query, err := url.ParseQuery(rawQuery)
|
||||
if err != nil {
|
||||
t.Fatalf("parse DSN query: %v", err)
|
||||
}
|
||||
if query.Get("cache") != "shared" || query.Get("_busy_timeout") != "9000" {
|
||||
t.Fatalf("DSN did not preserve parameters: %q", dsn)
|
||||
}
|
||||
if query.Get("_txlock") != "immediate" {
|
||||
t.Fatalf("_txlock = %q, want immediate", query.Get("_txlock"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenSQLite(t *testing.T) {
|
||||
cfg := config.DatabaseConfig{
|
||||
Driver: "sqlite3",
|
||||
|
||||
+213
-14
@@ -3,22 +3,54 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
)
|
||||
|
||||
// UploadedFileParams contains persisted metadata for a completed upload.
|
||||
type UploadedFileParams struct {
|
||||
ID string
|
||||
UserID string
|
||||
ParentID *string
|
||||
Name string
|
||||
Size int64
|
||||
MimeType string
|
||||
StoragePath string
|
||||
Hash string
|
||||
}
|
||||
|
||||
// DirectoryParams contains the fields accepted when creating a directory.
|
||||
type DirectoryParams struct {
|
||||
ID string
|
||||
UserID string
|
||||
ParentID *string
|
||||
Name string
|
||||
}
|
||||
|
||||
// FileMetadataUpdate contains the user-editable metadata fields.
|
||||
// Nil fields are left unchanged.
|
||||
type FileMetadataUpdate struct {
|
||||
FileID string
|
||||
UserID string
|
||||
NewName *string
|
||||
NewParentID *string
|
||||
}
|
||||
|
||||
// FileRepository provides access to file records.
|
||||
type FileRepository interface {
|
||||
Create(ctx context.Context, file *model.File) error
|
||||
CreateUploadedFile(ctx context.Context, params UploadedFileParams) (*model.File, error)
|
||||
CreateDirectory(ctx context.Context, params DirectoryParams) (*model.File, error)
|
||||
FindByID(ctx context.Context, id string) (*model.File, error)
|
||||
FindByUserID(ctx context.Context, userID string, offset, limit int) ([]model.File, int64, error)
|
||||
FindByParentID(ctx context.Context, userID string, parentID *string) ([]model.File, error)
|
||||
FindByParentIDPaginated(ctx context.Context, userID string, parentID *string, offset, limit int) ([]model.File, int64, error)
|
||||
FindByNameAndParent(ctx context.Context, userID string, parentID *string, name string) (*model.File, error)
|
||||
Update(ctx context.Context, file *model.File) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
UpdateOwnedMetadata(ctx context.Context, params FileMetadataUpdate) (*model.File, error)
|
||||
SoftDeleteOwned(ctx context.Context, userID, fileID string) error
|
||||
}
|
||||
|
||||
type fileRepository struct {
|
||||
@@ -30,8 +62,57 @@ func NewFileRepository(db *gorm.DB) FileRepository {
|
||||
return &fileRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *fileRepository) Create(ctx context.Context, file *model.File) error {
|
||||
result := r.db.WithContext(ctx).Create(file)
|
||||
func (r *fileRepository) CreateUploadedFile(ctx context.Context, params UploadedFileParams) (*model.File, error) {
|
||||
file := &model.File{
|
||||
ID: params.ID,
|
||||
UserID: params.UserID,
|
||||
ParentID: params.ParentID,
|
||||
Name: params.Name,
|
||||
Size: params.Size,
|
||||
MimeType: params.MimeType,
|
||||
StoragePath: params.StoragePath,
|
||||
Hash: params.Hash,
|
||||
Status: model.StatusActive,
|
||||
IsDir: false,
|
||||
}
|
||||
if err := r.createEntry(ctx, file); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func (r *fileRepository) CreateDirectory(ctx context.Context, params DirectoryParams) (*model.File, error) {
|
||||
dir := &model.File{
|
||||
ID: params.ID,
|
||||
UserID: params.UserID,
|
||||
ParentID: params.ParentID,
|
||||
Name: params.Name,
|
||||
Status: model.StatusActive,
|
||||
IsDir: true,
|
||||
}
|
||||
if err := r.createEntry(ctx, dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func (r *fileRepository) createEntry(ctx context.Context, file *model.File) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if file.ParentID != nil {
|
||||
locked, err := lockActiveOwnedFiles(tx, file.UserID, []string{*file.ParentID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parent, ok := locked[*file.ParentID]
|
||||
if !ok {
|
||||
return model.ErrParentNotFound
|
||||
}
|
||||
if !parent.IsDir {
|
||||
return model.ErrParentNotDir
|
||||
}
|
||||
}
|
||||
|
||||
result := tx.Create(file)
|
||||
if result.Error != nil {
|
||||
if isDuplicateKeyError(result.Error) {
|
||||
return model.ErrDuplicate
|
||||
@@ -39,6 +120,7 @@ func (r *fileRepository) Create(ctx context.Context, file *model.File) error {
|
||||
return result.Error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *fileRepository) FindByID(ctx context.Context, id string) (*model.File, error) {
|
||||
@@ -106,18 +188,107 @@ func (r *fileRepository) FindByNameAndParent(ctx context.Context, userID string,
|
||||
return &file, nil
|
||||
}
|
||||
|
||||
func (r *fileRepository) Update(ctx context.Context, file *model.File) error {
|
||||
result := r.db.WithContext(ctx).Save(file)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
func (r *fileRepository) UpdateOwnedMetadata(ctx context.Context, params FileMetadataUpdate) (*model.File, error) {
|
||||
var updated model.File
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
ids := []string{params.FileID}
|
||||
if params.NewParentID != nil {
|
||||
if *params.NewParentID == params.FileID {
|
||||
return model.ErrInvalidMove
|
||||
}
|
||||
return nil
|
||||
ids = append(ids, *params.NewParentID)
|
||||
}
|
||||
|
||||
func (r *fileRepository) Delete(ctx context.Context, id string) error {
|
||||
result := r.db.WithContext(ctx).
|
||||
Model(&model.File{}).
|
||||
Where("id = ? AND status = ?", id, model.StatusActive).
|
||||
locked, err := lockActiveOwnedFiles(tx, params.UserID, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
source, ok := locked[params.FileID]
|
||||
if !ok {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
|
||||
updates := make(map[string]any, 2)
|
||||
finalName := source.Name
|
||||
finalParentID := source.ParentID
|
||||
if params.NewName != nil {
|
||||
updates["name"] = *params.NewName
|
||||
finalName = *params.NewName
|
||||
}
|
||||
if params.NewParentID != nil {
|
||||
parent, ok := locked[*params.NewParentID]
|
||||
if !ok {
|
||||
return model.ErrParentNotFound
|
||||
}
|
||||
if !parent.IsDir {
|
||||
return model.ErrParentNotDir
|
||||
}
|
||||
updates["parent_id"] = *params.NewParentID
|
||||
finalParentID = params.NewParentID
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
var conflictCount int64
|
||||
if err := tx.Model(&model.File{}).
|
||||
Where("user_id = ? AND parent_id IS ? AND name = ? AND status = ? AND id <> ?", params.UserID, finalParentID, finalName, model.StatusActive, params.FileID).
|
||||
Count(&conflictCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if conflictCount > 0 {
|
||||
return model.ErrDuplicate
|
||||
}
|
||||
|
||||
result := tx.Model(&model.File{}).
|
||||
Where("id = ? AND user_id = ? AND status = ?", params.FileID, params.UserID, model.StatusActive).
|
||||
Updates(updates)
|
||||
if result.Error != nil {
|
||||
if isDuplicateKeyError(result.Error) {
|
||||
return model.ErrDuplicate
|
||||
}
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
}
|
||||
|
||||
result := tx.First(&updated, "id = ? AND user_id = ? AND status = ?", source.ID, params.UserID, model.StatusActive)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
return result.Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &updated, nil
|
||||
}
|
||||
|
||||
func (r *fileRepository) SoftDeleteOwned(ctx context.Context, userID, fileID string) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
locked, err := lockActiveOwnedFiles(tx, userID, []string{fileID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file, ok := locked[fileID]
|
||||
if !ok {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
|
||||
if file.IsDir {
|
||||
var count int64
|
||||
if err := tx.Model(&model.File{}).
|
||||
Where("parent_id = ? AND status = ?", fileID, model.StatusActive).
|
||||
Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return model.ErrDirectoryNotEmpty
|
||||
}
|
||||
}
|
||||
|
||||
result := tx.Model(&model.File{}).
|
||||
Where("id = ? AND user_id = ? AND status = ?", fileID, userID, model.StatusActive).
|
||||
Update("status", model.StatusUserDeleted)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
@@ -126,4 +297,32 @@ func (r *fileRepository) Delete(ctx context.Context, id string) error {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func lockActiveOwnedFiles(tx *gorm.DB, userID string, ids []string) (map[string]model.File, error) {
|
||||
unique := make(map[string]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
unique[id] = struct{}{}
|
||||
}
|
||||
ordered := make([]string, 0, len(unique))
|
||||
for id := range unique {
|
||||
ordered = append(ordered, id)
|
||||
}
|
||||
sort.Strings(ordered)
|
||||
|
||||
var files []model.File
|
||||
result := tx.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
|
||||
Where("id IN ? AND user_id = ? AND status = ?", ordered, userID, model.StatusActive).
|
||||
Order("id ASC").
|
||||
Find(&files)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
|
||||
locked := make(map[string]model.File, len(files))
|
||||
for i := range files {
|
||||
locked[files[i].ID] = files[i]
|
||||
}
|
||||
return locked, nil
|
||||
}
|
||||
|
||||
@@ -3,11 +3,14 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/config"
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
)
|
||||
|
||||
@@ -25,6 +28,48 @@ func setupFileRepo(t *testing.T) FileRepository {
|
||||
return NewFileRepository(db)
|
||||
}
|
||||
|
||||
func createFileRecord(ctx context.Context, repo FileRepository, file *model.File) error {
|
||||
var err error
|
||||
if file.IsDir {
|
||||
_, err = repo.CreateDirectory(ctx, DirectoryParams{
|
||||
ID: file.ID, UserID: file.UserID, ParentID: file.ParentID, Name: file.Name,
|
||||
})
|
||||
} else {
|
||||
_, err = repo.CreateUploadedFile(ctx, UploadedFileParams{
|
||||
ID: file.ID, UserID: file.UserID, ParentID: file.ParentID, Name: file.Name,
|
||||
Size: file.Size, MimeType: file.MimeType, StoragePath: file.StoragePath, Hash: file.Hash,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if file.Status == model.StatusUserDeleted {
|
||||
return repo.SoftDeleteOwned(ctx, file.UserID, file.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupConcurrentFileRepo(t *testing.T) FileRepository {
|
||||
t.Helper()
|
||||
db, err := Open(config.DatabaseConfig{
|
||||
Driver: "sqlite3",
|
||||
SQLite: config.SQLiteConfig{Path: filepath.Join(t.TempDir(), "concurrency.db")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sqlDB, err := db.DB()
|
||||
if err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
return NewFileRepository(db)
|
||||
}
|
||||
|
||||
func TestFileRepository_Create(t *testing.T) {
|
||||
repo := setupFileRepo(t)
|
||||
ctx := context.Background()
|
||||
@@ -37,7 +82,7 @@ func TestFileRepository_Create(t *testing.T) {
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
|
||||
if err := repo.Create(ctx, file); err != nil {
|
||||
if err := createFileRecord(ctx, repo, file); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -52,7 +97,7 @@ func TestFileRepository_FindByID(t *testing.T) {
|
||||
Name: "test.txt",
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := repo.Create(ctx, file); err != nil {
|
||||
if err := createFileRecord(ctx, repo, file); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
@@ -85,7 +130,7 @@ func TestFileRepository_FindByUserID(t *testing.T) {
|
||||
{ID: "f-3", UserID: "user-2", Name: "c.txt", Status: model.StatusActive},
|
||||
}
|
||||
for _, f := range files {
|
||||
if err := repo.Create(ctx, f); err != nil {
|
||||
if err := createFileRecord(ctx, repo, f); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -107,13 +152,16 @@ func TestFileRepository_FindByParentID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
parentID := "dir-1"
|
||||
if err := createFileRecord(ctx, repo, &model.File{ID: parentID, UserID: "user-1", Name: "dir", Status: model.StatusActive, IsDir: true}); err != nil {
|
||||
t.Fatalf("Create parent = %v", err)
|
||||
}
|
||||
files := []*model.File{
|
||||
{ID: "f-1", UserID: "user-1", ParentID: &parentID, Name: "a.txt", Status: model.StatusActive},
|
||||
{ID: "f-2", UserID: "user-1", ParentID: &parentID, Name: "b.txt", Status: model.StatusActive},
|
||||
{ID: "f-3", UserID: "user-1", Name: "c.txt", Status: model.StatusActive},
|
||||
}
|
||||
for _, f := range files {
|
||||
if err := repo.Create(ctx, f); err != nil {
|
||||
if err := createFileRecord(ctx, repo, f); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -132,12 +180,15 @@ func TestFileRepository_FindByParentIDNull(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
parentID := "dir-1"
|
||||
if err := createFileRecord(ctx, repo, &model.File{ID: parentID, UserID: "user-1", Name: "dir", Status: model.StatusActive, IsDir: true}); err != nil {
|
||||
t.Fatalf("Create parent = %v", err)
|
||||
}
|
||||
files := []*model.File{
|
||||
{ID: "f-1", UserID: "user-1", ParentID: &parentID, Name: "a.txt", Status: model.StatusActive},
|
||||
{ID: "f-2", UserID: "user-1", Name: "root.txt", Status: model.StatusActive},
|
||||
}
|
||||
for _, f := range files {
|
||||
if err := repo.Create(ctx, f); err != nil {
|
||||
if err := createFileRecord(ctx, repo, f); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -146,8 +197,8 @@ func TestFileRepository_FindByParentIDNull(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("FindByParentID(nil) = %v", err)
|
||||
}
|
||||
if len(children) != 1 {
|
||||
t.Errorf("len(children) = %d, want 1", len(children))
|
||||
if len(children) != 2 {
|
||||
t.Errorf("len(children) = %d, want 2", len(children))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,14 +206,26 @@ func TestFileRepository_Update(t *testing.T) {
|
||||
repo := setupFileRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
file := &model.File{ID: "file-1", UserID: "user-1", Name: "original.txt", Status: model.StatusActive}
|
||||
if err := repo.Create(ctx, file); err != nil {
|
||||
file := &model.File{
|
||||
ID: "file-1",
|
||||
UserID: "user-1",
|
||||
Name: "original.txt",
|
||||
Size: 1024,
|
||||
MimeType: "text/plain",
|
||||
StoragePath: "data/user-1/file-1",
|
||||
Hash: "original-hash",
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := createFileRecord(ctx, repo, file); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
before, err := repo.FindByID(ctx, file.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("FindByID before update = %v", err)
|
||||
}
|
||||
|
||||
file.Name = "renamed.txt"
|
||||
file.Size = 2048
|
||||
if err := repo.Update(ctx, file); err != nil {
|
||||
newName := "renamed.txt"
|
||||
if _, err := repo.UpdateOwnedMetadata(ctx, FileMetadataUpdate{FileID: file.ID, UserID: file.UserID, NewName: &newName}); err != nil {
|
||||
t.Fatalf("Update = %v", err)
|
||||
}
|
||||
|
||||
@@ -173,8 +236,11 @@ func TestFileRepository_Update(t *testing.T) {
|
||||
if found.Name != "renamed.txt" {
|
||||
t.Errorf("name = %q, want %q", found.Name, "renamed.txt")
|
||||
}
|
||||
if found.Size != 2048 {
|
||||
t.Errorf("size = %d, want %d", found.Size, 2048)
|
||||
if found.UserID != before.UserID || found.Status != before.Status || found.IsDir != before.IsDir ||
|
||||
found.Size != before.Size || found.MimeType != before.MimeType ||
|
||||
found.StoragePath != before.StoragePath || found.Hash != before.Hash ||
|
||||
!found.CreatedAt.Equal(before.CreatedAt) {
|
||||
t.Fatalf("metadata update changed protected fields: before=%+v after=%+v", before, found)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,11 +249,11 @@ func TestFileRepository_Delete(t *testing.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 {
|
||||
if err := createFileRecord(ctx, repo, file); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
if err := repo.Delete(ctx, "file-1"); err != nil {
|
||||
if err := repo.SoftDeleteOwned(ctx, "user-1", "file-1"); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
|
||||
@@ -207,11 +273,11 @@ func TestFileRepository_SoftDelete(t *testing.T) {
|
||||
Name: "test.txt",
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := repo.Create(ctx, file); err != nil {
|
||||
if err := createFileRecord(ctx, repo, file); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
if err := repo.Delete(ctx, "file-1"); err != nil {
|
||||
if err := repo.SoftDeleteOwned(ctx, "user-1", "file-1"); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
|
||||
@@ -238,10 +304,10 @@ func TestFileRepository_StatusFilter(t *testing.T) {
|
||||
Status: model.StatusUserDeleted,
|
||||
}
|
||||
|
||||
if err := repo.Create(ctx, activeFile); err != nil {
|
||||
if err := createFileRecord(ctx, repo, activeFile); err != nil {
|
||||
t.Fatalf("Create active = %v", err)
|
||||
}
|
||||
if err := repo.Create(ctx, deletedFile); err != nil {
|
||||
if err := createFileRecord(ctx, repo, deletedFile); err != nil {
|
||||
t.Fatalf("Create deleted = %v", err)
|
||||
}
|
||||
|
||||
@@ -270,17 +336,17 @@ func TestFileRepository_DeleteReturnsNotFoundAfterDeletion(t *testing.T) {
|
||||
Name: "test.txt",
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := repo.Create(ctx, file); err != nil {
|
||||
if err := createFileRecord(ctx, repo, file); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
if err := repo.Delete(ctx, "file-1"); err != nil {
|
||||
if err := repo.SoftDeleteOwned(ctx, "user-1", "file-1"); err != nil {
|
||||
t.Fatalf("first Delete = %v", err)
|
||||
}
|
||||
if err := repo.Delete(ctx, "file-1"); !errors.Is(err, model.ErrNotFound) {
|
||||
if err := repo.SoftDeleteOwned(ctx, "user-1", "file-1"); !errors.Is(err, model.ErrNotFound) {
|
||||
t.Fatalf("second Delete = %v, want ErrNotFound", err)
|
||||
}
|
||||
if err := repo.Delete(ctx, "missing-file"); !errors.Is(err, model.ErrNotFound) {
|
||||
if err := repo.SoftDeleteOwned(ctx, "user-1", "missing-file"); !errors.Is(err, model.ErrNotFound) {
|
||||
t.Fatalf("missing Delete = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
@@ -302,10 +368,10 @@ func TestFileRepository_StatusFilterCount(t *testing.T) {
|
||||
Status: model.StatusUserDeleted,
|
||||
}
|
||||
|
||||
if err := repo.Create(ctx, activeFile); err != nil {
|
||||
if err := createFileRecord(ctx, repo, activeFile); err != nil {
|
||||
t.Fatalf("Create active = %v", err)
|
||||
}
|
||||
if err := repo.Create(ctx, deletedFile); err != nil {
|
||||
if err := createFileRecord(ctx, repo, deletedFile); err != nil {
|
||||
t.Fatalf("Create deleted = %v", err)
|
||||
}
|
||||
|
||||
@@ -317,3 +383,160 @@ func TestFileRepository_StatusFilterCount(t *testing.T) {
|
||||
t.Errorf("total = %d, want 1 (soft-deleted excluded from count)", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileRepository_UpdateCannotReviveDeletedFile(t *testing.T) {
|
||||
repo := setupFileRepo(t)
|
||||
ctx := context.Background()
|
||||
file := &model.File{ID: "file-1", UserID: "user-1", Name: "before.txt", Status: model.StatusActive}
|
||||
if err := createFileRecord(ctx, repo, file); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
if err := repo.SoftDeleteOwned(ctx, file.UserID, file.ID); err != nil {
|
||||
t.Fatalf("SoftDeleteOwned = %v", err)
|
||||
}
|
||||
|
||||
newName := "after.txt"
|
||||
_, err := repo.UpdateOwnedMetadata(ctx, FileMetadataUpdate{FileID: file.ID, UserID: file.UserID, NewName: &newName})
|
||||
if !errors.Is(err, model.ErrNotFound) {
|
||||
t.Fatalf("UpdateOwnedMetadata = %v, want ErrNotFound", err)
|
||||
}
|
||||
if _, err := repo.FindByID(ctx, file.ID); !errors.Is(err, model.ErrNotFound) {
|
||||
t.Fatalf("deleted file became active again: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileRepository_ConcurrentUpdateAndDeleteCannotReviveFile(t *testing.T) {
|
||||
repo := setupConcurrentFileRepo(t)
|
||||
ctx := context.Background()
|
||||
if _, err := repo.CreateUploadedFile(ctx, UploadedFileParams{
|
||||
ID: "file-1", UserID: "user-1", Name: "before.txt", StoragePath: "data/user-1/file-1",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateUploadedFile = %v", err)
|
||||
}
|
||||
|
||||
newName := "after.txt"
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
var updateErr, deleteErr error
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, updateErr = repo.UpdateOwnedMetadata(ctx, FileMetadataUpdate{
|
||||
FileID: "file-1", UserID: "user-1", NewName: &newName,
|
||||
})
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
deleteErr = repo.SoftDeleteOwned(ctx, "user-1", "file-1")
|
||||
}()
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
if deleteErr != nil {
|
||||
t.Fatalf("SoftDeleteOwned = %v, want success", deleteErr)
|
||||
}
|
||||
if updateErr != nil && !errors.Is(updateErr, model.ErrNotFound) {
|
||||
t.Fatalf("UpdateOwnedMetadata = %v, want success or ErrNotFound", updateErr)
|
||||
}
|
||||
if _, err := repo.FindByID(ctx, "file-1"); !errors.Is(err, model.ErrNotFound) {
|
||||
t.Fatalf("concurrent update revived deleted file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileRepository_CreateAndParentDeleteAreSerialized(t *testing.T) {
|
||||
repo := setupConcurrentFileRepo(t)
|
||||
ctx := context.Background()
|
||||
dir, err := repo.CreateDirectory(ctx, DirectoryParams{ID: "dir-1", UserID: "user-1", Name: "dir"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDirectory = %v", err)
|
||||
}
|
||||
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
var createErr, deleteErr error
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, createErr = repo.CreateUploadedFile(ctx, UploadedFileParams{
|
||||
ID: "child-1", UserID: "user-1", ParentID: &dir.ID, Name: "child.txt", StoragePath: "data/user-1/child-1",
|
||||
})
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
deleteErr = repo.SoftDeleteOwned(ctx, "user-1", dir.ID)
|
||||
}()
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
switch {
|
||||
case createErr == nil:
|
||||
if !errors.Is(deleteErr, model.ErrDirectoryNotEmpty) {
|
||||
t.Fatalf("create succeeded but delete error = %v, want ErrDirectoryNotEmpty", deleteErr)
|
||||
}
|
||||
if _, err := repo.FindByID(ctx, dir.ID); err != nil {
|
||||
t.Fatalf("parent missing after child creation: %v", err)
|
||||
}
|
||||
case deleteErr == nil:
|
||||
if !errors.Is(createErr, model.ErrParentNotFound) {
|
||||
t.Fatalf("delete succeeded but create error = %v, want ErrParentNotFound", createErr)
|
||||
}
|
||||
if _, err := repo.FindByID(ctx, "child-1"); !errors.Is(err, model.ErrNotFound) {
|
||||
t.Fatalf("active orphan child exists: %v", err)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("neither operation succeeded: create=%v delete=%v", createErr, deleteErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileRepository_MoveAndTargetDeleteAreSerialized(t *testing.T) {
|
||||
repo := setupConcurrentFileRepo(t)
|
||||
ctx := context.Background()
|
||||
target, err := repo.CreateDirectory(ctx, DirectoryParams{ID: "target-1", UserID: "user-1", Name: "target"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDirectory = %v", err)
|
||||
}
|
||||
if _, err := repo.CreateUploadedFile(ctx, UploadedFileParams{ID: "file-1", UserID: "user-1", Name: "file.txt", StoragePath: "data/user-1/file-1"}); err != nil {
|
||||
t.Fatalf("CreateUploadedFile = %v", err)
|
||||
}
|
||||
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
var moveErr, deleteErr error
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, moveErr = repo.UpdateOwnedMetadata(ctx, FileMetadataUpdate{FileID: "file-1", UserID: "user-1", NewParentID: &target.ID})
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
deleteErr = repo.SoftDeleteOwned(ctx, "user-1", target.ID)
|
||||
}()
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
switch {
|
||||
case moveErr == nil:
|
||||
if !errors.Is(deleteErr, model.ErrDirectoryNotEmpty) {
|
||||
t.Fatalf("move succeeded but delete error = %v, want ErrDirectoryNotEmpty", deleteErr)
|
||||
}
|
||||
case deleteErr == nil:
|
||||
if !errors.Is(moveErr, model.ErrParentNotFound) {
|
||||
t.Fatalf("delete succeeded but move error = %v, want ErrParentNotFound", moveErr)
|
||||
}
|
||||
file, err := repo.FindByID(ctx, "file-1")
|
||||
if err != nil {
|
||||
t.Fatalf("find source file: %v", err)
|
||||
}
|
||||
if file.ParentID != nil {
|
||||
t.Fatalf("failed move changed source parent to %v", *file.ParentID)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("neither operation succeeded: move=%v delete=%v", moveErr, deleteErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,18 +6,33 @@ import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
)
|
||||
|
||||
// SessionRepository provides access to refresh token sessions.
|
||||
// RefreshSessionParams contains the fields persisted for a refresh session.
|
||||
type RefreshSessionParams struct {
|
||||
ID string
|
||||
UserID string
|
||||
TokenHash string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// AuthSessionRepository exposes only session operations required by AuthService.
|
||||
type AuthSessionRepository interface {
|
||||
CreateRefreshSession(ctx context.Context, params RefreshSessionParams) error
|
||||
ConsumeRefreshSession(ctx context.Context, tokenHash string) (*model.Session, error)
|
||||
RevokeRefreshSession(ctx context.Context, tokenHash string) error
|
||||
}
|
||||
|
||||
// SessionRepository is the composition-time union of session capabilities.
|
||||
type SessionRepository interface {
|
||||
Create(ctx context.Context, session *model.Session) error
|
||||
AuthSessionRepository
|
||||
FindByID(ctx context.Context, id string) (*model.Session, error)
|
||||
FindByTokenHash(ctx context.Context, tokenHash string) (*model.Session, error)
|
||||
Delete(ctx context.Context, id string) error
|
||||
DeleteByUserID(ctx context.Context, userID string) error
|
||||
DeleteExpired(ctx context.Context) (int64, error)
|
||||
DeleteSessionsByUserID(ctx context.Context, userID string) error
|
||||
DeleteExpiredSessions(ctx context.Context) (int64, error)
|
||||
}
|
||||
|
||||
type sessionRepository struct {
|
||||
@@ -29,7 +44,13 @@ func NewSessionRepository(db *gorm.DB) SessionRepository {
|
||||
return &sessionRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *sessionRepository) Create(ctx context.Context, session *model.Session) error {
|
||||
func (r *sessionRepository) CreateRefreshSession(ctx context.Context, params RefreshSessionParams) error {
|
||||
session := &model.Session{
|
||||
ID: params.ID,
|
||||
UserID: params.UserID,
|
||||
TokenHash: params.TokenHash,
|
||||
ExpiresAt: params.ExpiresAt,
|
||||
}
|
||||
result := r.db.WithContext(ctx).Create(session)
|
||||
if result.Error != nil {
|
||||
if isDuplicateKeyError(result.Error) {
|
||||
@@ -64,15 +85,39 @@ func (r *sessionRepository) FindByTokenHash(ctx context.Context, tokenHash strin
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
func (r *sessionRepository) Delete(ctx context.Context, id string) error {
|
||||
result := r.db.WithContext(ctx).Delete(&model.Session{}, "id = ?", id)
|
||||
func (r *sessionRepository) ConsumeRefreshSession(ctx context.Context, tokenHash string) (*model.Session, error) {
|
||||
var consumed model.Session
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
|
||||
First(&consumed, "token_hash = ?", tokenHash)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
|
||||
result = tx.Delete(&model.Session{}, "id = ?", consumed.ID)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &consumed, nil
|
||||
}
|
||||
|
||||
func (r *sessionRepository) DeleteByUserID(ctx context.Context, userID string) error {
|
||||
func (r *sessionRepository) RevokeRefreshSession(ctx context.Context, tokenHash string) error {
|
||||
result := r.db.WithContext(ctx).Delete(&model.Session{}, "token_hash = ?", tokenHash)
|
||||
return result.Error
|
||||
}
|
||||
|
||||
func (r *sessionRepository) DeleteSessionsByUserID(ctx context.Context, userID string) error {
|
||||
result := r.db.WithContext(ctx).Delete(&model.Session{}, "user_id = ?", userID)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
@@ -80,7 +125,7 @@ func (r *sessionRepository) DeleteByUserID(ctx context.Context, userID string) e
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *sessionRepository) DeleteExpired(ctx context.Context) (int64, error) {
|
||||
func (r *sessionRepository) DeleteExpiredSessions(ctx context.Context) (int64, error) {
|
||||
result := r.db.WithContext(ctx).Delete(&model.Session{}, "expires_at < ?", time.Now())
|
||||
if result.Error != nil {
|
||||
return 0, result.Error
|
||||
|
||||
@@ -2,12 +2,16 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/config"
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
)
|
||||
|
||||
@@ -25,6 +29,12 @@ func setupSessionRepo(t *testing.T) SessionRepository {
|
||||
return NewSessionRepository(db)
|
||||
}
|
||||
|
||||
func createSessionRecord(ctx context.Context, repo SessionRepository, session *model.Session) error {
|
||||
return repo.CreateRefreshSession(ctx, RefreshSessionParams{
|
||||
ID: session.ID, UserID: session.UserID, TokenHash: session.TokenHash, ExpiresAt: session.ExpiresAt,
|
||||
})
|
||||
}
|
||||
|
||||
func TestSessionRepository_Create(t *testing.T) {
|
||||
repo := setupSessionRepo(t)
|
||||
ctx := context.Background()
|
||||
@@ -36,7 +46,7 @@ func TestSessionRepository_Create(t *testing.T) {
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
if err := repo.Create(ctx, session); err != nil {
|
||||
if err := createSessionRecord(ctx, repo, session); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -48,11 +58,11 @@ func TestSessionRepository_CreateDuplicateHash(t *testing.T) {
|
||||
s1 := &model.Session{ID: "session-1", UserID: "user-1", TokenHash: "hash-abc", ExpiresAt: time.Now().Add(24 * time.Hour)}
|
||||
s2 := &model.Session{ID: "session-2", UserID: "user-2", TokenHash: "hash-abc", ExpiresAt: time.Now().Add(24 * time.Hour)}
|
||||
|
||||
if err := repo.Create(ctx, s1); err != nil {
|
||||
if err := createSessionRecord(ctx, repo, s1); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
err := repo.Create(ctx, s2)
|
||||
err := createSessionRecord(ctx, repo, s2)
|
||||
if err != model.ErrDuplicate {
|
||||
t.Fatalf("expected ErrDuplicate, got %v", err)
|
||||
}
|
||||
@@ -63,7 +73,7 @@ func TestSessionRepository_FindByID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
session := &model.Session{ID: "session-1", UserID: "user-1", TokenHash: "hash-abc", ExpiresAt: time.Now().Add(24 * time.Hour)}
|
||||
if err := repo.Create(ctx, session); err != nil {
|
||||
if err := createSessionRecord(ctx, repo, session); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
@@ -81,7 +91,7 @@ func TestSessionRepository_FindByTokenHash(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
session := &model.Session{ID: "session-1", UserID: "user-1", TokenHash: "hash-abc", ExpiresAt: time.Now().Add(24 * time.Hour)}
|
||||
if err := repo.Create(ctx, session); err != nil {
|
||||
if err := createSessionRecord(ctx, repo, session); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
@@ -109,15 +119,19 @@ func TestSessionRepository_Delete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
session := &model.Session{ID: "session-1", UserID: "user-1", TokenHash: "hash-abc", ExpiresAt: time.Now().Add(24 * time.Hour)}
|
||||
if err := repo.Create(ctx, session); err != nil {
|
||||
if err := createSessionRecord(ctx, repo, session); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
if err := repo.Delete(ctx, "session-1"); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
consumed, err := repo.ConsumeRefreshSession(ctx, "hash-abc")
|
||||
if err != nil {
|
||||
t.Fatalf("ConsumeRefreshSession = %v", err)
|
||||
}
|
||||
if consumed.ID != "session-1" {
|
||||
t.Fatalf("consumed ID = %q, want session-1", consumed.ID)
|
||||
}
|
||||
|
||||
_, err := repo.FindByID(ctx, "session-1")
|
||||
_, err = repo.FindByID(ctx, "session-1")
|
||||
if err != model.ErrNotFound {
|
||||
t.Fatalf("expected ErrNotFound after delete, got %v", err)
|
||||
}
|
||||
@@ -132,12 +146,12 @@ func TestSessionRepository_DeleteByUserID(t *testing.T) {
|
||||
s3 := &model.Session{ID: "session-3", UserID: "user-2", TokenHash: "hash-3", ExpiresAt: time.Now().Add(24 * time.Hour)}
|
||||
|
||||
for _, s := range []*model.Session{s1, s2, s3} {
|
||||
if err := repo.Create(ctx, s); err != nil {
|
||||
if err := createSessionRecord(ctx, repo, s); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := repo.DeleteByUserID(ctx, "user-1"); err != nil {
|
||||
if err := repo.DeleteSessionsByUserID(ctx, "user-1"); err != nil {
|
||||
t.Fatalf("DeleteByUserID = %v", err)
|
||||
}
|
||||
|
||||
@@ -168,12 +182,12 @@ func TestSessionRepository_DeleteExpired(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, s := range []*model.Session{expired, valid} {
|
||||
if err := repo.Create(ctx, s); err != nil {
|
||||
if err := createSessionRecord(ctx, repo, s); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
count, err := repo.DeleteExpired(ctx)
|
||||
count, err := repo.DeleteExpiredSessions(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteExpired = %v", err)
|
||||
}
|
||||
@@ -188,3 +202,55 @@ func TestSessionRepository_DeleteExpired(t *testing.T) {
|
||||
t.Fatalf("valid session should still exist: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRepository_ConsumeRefreshSessionIsAtomic(t *testing.T) {
|
||||
db, err := Open(config.DatabaseConfig{
|
||||
Driver: "sqlite3",
|
||||
SQLite: config.SQLiteConfig{Path: filepath.Join(t.TempDir(), "sessions.db")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repo := NewSessionRepository(db)
|
||||
ctx := context.Background()
|
||||
if err := repo.CreateRefreshSession(ctx, RefreshSessionParams{
|
||||
ID: "session-1", UserID: "user-1", TokenHash: "single-use", ExpiresAt: time.Now().Add(time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateRefreshSession = %v", err)
|
||||
}
|
||||
|
||||
start := make(chan struct{})
|
||||
results := make(chan error, 2)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
for range 2 {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, err := repo.ConsumeRefreshSession(ctx, "single-use")
|
||||
results <- err
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
succeeded := 0
|
||||
notFound := 0
|
||||
for err := range results {
|
||||
switch {
|
||||
case err == nil:
|
||||
succeeded++
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
notFound++
|
||||
default:
|
||||
t.Fatalf("ConsumeRefreshSession unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
if succeeded != 1 || notFound != 1 {
|
||||
t.Fatalf("consume results: success=%d not_found=%d, want 1 each", succeeded, notFound)
|
||||
}
|
||||
}
|
||||
|
||||
+39
-24
@@ -15,19 +15,37 @@ func isDuplicateKeyError(err error) bool {
|
||||
return errors.Is(err, gorm.ErrDuplicatedKey) || strings.Contains(strings.ToLower(err.Error()), "unique constraint failed")
|
||||
}
|
||||
|
||||
// UserRepository provides access to user records.
|
||||
type UserRepository interface {
|
||||
Create(ctx context.Context, user *model.User) error
|
||||
// RegisteredUserParams contains the fields accepted when registering a user.
|
||||
// Administrative and lifecycle fields are intentionally excluded.
|
||||
type RegisteredUserParams struct {
|
||||
ID string
|
||||
Username string
|
||||
Email string
|
||||
PasswordHash string
|
||||
}
|
||||
|
||||
// AuthUserRepository exposes only user operations required by authentication.
|
||||
type AuthUserRepository interface {
|
||||
CreateRegisteredUser(ctx context.Context, params RegisteredUserParams) (*model.User, error)
|
||||
FindByID(ctx context.Context, id string) (*model.User, error)
|
||||
FindByIDIncludeDeleted(ctx context.Context, id string) (*model.User, error)
|
||||
FindByEmail(ctx context.Context, email string) (*model.User, error)
|
||||
FindByUsername(ctx context.Context, username string) (*model.User, error)
|
||||
Update(ctx context.Context, user *model.User) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
List(ctx context.Context, offset, limit int) ([]model.User, int64, error)
|
||||
}
|
||||
|
||||
// AdminUserRepository exposes only user operations required by administration.
|
||||
type AdminUserRepository interface {
|
||||
FindByIDIncludeDeleted(ctx context.Context, id string) (*model.User, error)
|
||||
MarkAdminDeleted(ctx context.Context, id string) error
|
||||
ListIncludeDeleted(ctx context.Context, offset, limit int) ([]model.User, int64, error)
|
||||
}
|
||||
|
||||
// UserRepository is the composition-time union of user capabilities.
|
||||
type UserRepository interface {
|
||||
AuthUserRepository
|
||||
AdminUserRepository
|
||||
FindByUsername(ctx context.Context, username string) (*model.User, error)
|
||||
List(ctx context.Context, offset, limit int) ([]model.User, int64, error)
|
||||
}
|
||||
|
||||
type userRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
@@ -37,15 +55,23 @@ func NewUserRepository(db *gorm.DB) UserRepository {
|
||||
return &userRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *userRepository) Create(ctx context.Context, user *model.User) error {
|
||||
func (r *userRepository) CreateRegisteredUser(ctx context.Context, params RegisteredUserParams) (*model.User, error) {
|
||||
user := &model.User{
|
||||
ID: params.ID,
|
||||
Username: params.Username,
|
||||
Email: params.Email,
|
||||
PasswordHash: params.PasswordHash,
|
||||
IsAdmin: false,
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
result := r.db.WithContext(ctx).Create(user)
|
||||
if result.Error != nil {
|
||||
if isDuplicateKeyError(result.Error) {
|
||||
return model.ErrDuplicate
|
||||
return nil, model.ErrDuplicate
|
||||
}
|
||||
return result.Error
|
||||
return nil, result.Error
|
||||
}
|
||||
return nil
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) FindByID(ctx context.Context, id string) (*model.User, error) {
|
||||
@@ -97,18 +123,7 @@ func (r *userRepository) FindByUsername(ctx context.Context, username string) (*
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) Update(ctx context.Context, user *model.User) error {
|
||||
result := r.db.WithContext(ctx).Save(user)
|
||||
if result.Error != nil {
|
||||
if isDuplicateKeyError(result.Error) {
|
||||
return model.ErrDuplicate
|
||||
}
|
||||
return result.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *userRepository) Delete(ctx context.Context, id string) error {
|
||||
func (r *userRepository) MarkAdminDeleted(ctx context.Context, id string) error {
|
||||
result := r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Update("status", model.StatusAdminDeleted)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
|
||||
@@ -24,6 +24,16 @@ func setupUserRepo(t *testing.T) UserRepository {
|
||||
return NewUserRepository(db)
|
||||
}
|
||||
|
||||
func createUserRecord(ctx context.Context, repo UserRepository, user *model.User) error {
|
||||
_, err := repo.CreateRegisteredUser(ctx, RegisteredUserParams{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
PasswordHash: user.PasswordHash,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func TestUserRepository_Create(t *testing.T) {
|
||||
repo := setupUserRepo(t)
|
||||
ctx := context.Background()
|
||||
@@ -36,7 +46,7 @@ func TestUserRepository_Create(t *testing.T) {
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
|
||||
if err := repo.Create(ctx, user); err != nil {
|
||||
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -48,11 +58,11 @@ func TestUserRepository_CreateDuplicateUsername(t *testing.T) {
|
||||
u1 := &model.User{ID: "user-1", Username: "alice", Email: "alice@example.com", PasswordHash: "hash", Status: model.StatusActive}
|
||||
u2 := &model.User{ID: "user-2", Username: "alice", Email: "alice2@example.com", PasswordHash: "hash", Status: model.StatusActive}
|
||||
|
||||
if err := repo.Create(ctx, u1); err != nil {
|
||||
if err := createUserRecord(ctx, repo, u1); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
err := repo.Create(ctx, u2)
|
||||
err := createUserRecord(ctx, repo, u2)
|
||||
if err != model.ErrDuplicate {
|
||||
t.Fatalf("expected ErrDuplicate, got %v", err)
|
||||
}
|
||||
@@ -63,7 +73,7 @@ func TestUserRepository_FindByID(t *testing.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 {
|
||||
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
@@ -91,7 +101,7 @@ func TestUserRepository_FindByEmail(t *testing.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 {
|
||||
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
@@ -109,7 +119,7 @@ func TestUserRepository_FindByUsername(t *testing.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 {
|
||||
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
@@ -122,26 +132,24 @@ func TestUserRepository_FindByUsername(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRepository_Update(t *testing.T) {
|
||||
func TestUserRepository_CreateRegisteredUserControlsPrivilegedFields(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 {
|
||||
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
user.Username = "alice2"
|
||||
if err := repo.Update(ctx, user); err != nil {
|
||||
t.Fatalf("Update = %v", err)
|
||||
}
|
||||
|
||||
found, err := repo.FindByID(ctx, "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("FindByID = %v", err)
|
||||
}
|
||||
if found.Username != "alice2" {
|
||||
t.Errorf("username = %q, want %q", found.Username, "alice2")
|
||||
if found.IsAdmin {
|
||||
t.Error("registered user must not be an administrator")
|
||||
}
|
||||
if found.Status != model.StatusActive {
|
||||
t.Errorf("status = %q, want %q", found.Status, model.StatusActive)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,11 +158,11 @@ func TestUserRepository_Delete(t *testing.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 {
|
||||
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
if err := repo.Delete(ctx, "user-1"); err != nil {
|
||||
if err := repo.MarkAdminDeleted(ctx, "user-1"); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
|
||||
@@ -176,7 +184,7 @@ func TestUserRepository_List(t *testing.T) {
|
||||
PasswordHash: "hash",
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := repo.Create(ctx, user); err != nil {
|
||||
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -204,11 +212,11 @@ func TestUserRepository_SoftDelete(t *testing.T) {
|
||||
PasswordHash: "hash",
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := repo.Create(ctx, user); err != nil {
|
||||
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
if err := repo.Delete(ctx, "user-1"); err != nil {
|
||||
if err := repo.MarkAdminDeleted(ctx, "user-1"); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
|
||||
@@ -231,11 +239,11 @@ func TestUserRepository_DisabledLogin(t *testing.T) {
|
||||
PasswordHash: "hash",
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := repo.Create(ctx, user); err != nil {
|
||||
if err := createUserRecord(ctx, repo, user); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
if err := repo.Delete(ctx, "user-1"); err != nil {
|
||||
if err := repo.MarkAdminDeleted(ctx, "user-1"); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
|
||||
@@ -258,7 +266,7 @@ func TestUserRepository_StatusFilterList(t *testing.T) {
|
||||
PasswordHash: "hash",
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := repo.Create(ctx, u1); err != nil {
|
||||
if err := createUserRecord(ctx, repo, u1); err != nil {
|
||||
t.Fatalf("Create u1 = %v", err)
|
||||
}
|
||||
|
||||
@@ -269,12 +277,12 @@ func TestUserRepository_StatusFilterList(t *testing.T) {
|
||||
PasswordHash: "hash",
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := repo.Create(ctx, u2); err != nil {
|
||||
if err := createUserRecord(ctx, repo, u2); err != nil {
|
||||
t.Fatalf("Create u2 = %v", err)
|
||||
}
|
||||
|
||||
// Soft-delete bob
|
||||
if err := repo.Delete(ctx, "user-2"); err != nil {
|
||||
if err := repo.MarkAdminDeleted(ctx, "user-2"); err != nil {
|
||||
t.Fatalf("Delete u2 = %v", err)
|
||||
}
|
||||
|
||||
@@ -305,15 +313,15 @@ func TestUserRepository_DeleteIdempotent(t *testing.T) {
|
||||
PasswordHash: "hash",
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := repo.Create(ctx, user); err != nil {
|
||||
if err := createUserRecord(ctx, repo, 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 {
|
||||
if err := repo.MarkAdminDeleted(ctx, "user-1"); err != nil {
|
||||
t.Fatalf("first Delete = %v", err)
|
||||
}
|
||||
if err := repo.Delete(ctx, "user-1"); err != nil {
|
||||
if err := repo.MarkAdminDeleted(ctx, "user-1"); err != nil {
|
||||
t.Fatalf("second Delete = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,10 +116,6 @@ func newFileIntegrationFixture(t *testing.T) *fileIntegrationFixture {
|
||||
webApp := app.NewWebApp(
|
||||
cfg,
|
||||
db,
|
||||
userRepo,
|
||||
sessionRepo,
|
||||
fileRepo,
|
||||
credentialRepo,
|
||||
authService,
|
||||
service.NewAdminService(userRepo),
|
||||
fileService,
|
||||
|
||||
@@ -2,7 +2,6 @@ package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -14,6 +13,7 @@ import (
|
||||
"github.com/dhao2001/mygo/internal/config"
|
||||
"github.com/dhao2001/mygo/internal/repository"
|
||||
"github.com/dhao2001/mygo/internal/service"
|
||||
"github.com/dhao2001/mygo/internal/testutil"
|
||||
)
|
||||
|
||||
func TestVersionRoute(t *testing.T) {
|
||||
@@ -25,7 +25,7 @@ func TestVersionRoute(t *testing.T) {
|
||||
},
|
||||
}
|
||||
authService := service.NewAuthService(nil, nil, nil, nil, 15*time.Minute, 7*24*time.Hour)
|
||||
webApp := app.NewWebApp(cfg, nil, nil, nil, nil, nil, authService, nil, nil, nil)
|
||||
webApp := app.NewWebApp(cfg, nil, authService, nil, nil, nil)
|
||||
router := NewRouter(webApp)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/version", nil)
|
||||
@@ -94,7 +94,7 @@ func TestAdminRoutes(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
webApp := app.NewWebApp(cfg, db, userRepo, sessionRepo, nil, credentialRepo, authService, adminService, nil, nil)
|
||||
webApp := app.NewWebApp(cfg, db, authService, adminService, nil, nil)
|
||||
router := NewRouter(webApp)
|
||||
|
||||
// Helper: register a user via POST /api/v1/auth/register and return the user ID.
|
||||
@@ -123,14 +123,7 @@ func TestAdminRoutes(t *testing.T) {
|
||||
|
||||
// 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)
|
||||
}
|
||||
testutil.SetUserAdmin(t, db, adminID, true)
|
||||
|
||||
// Generate admin JWT.
|
||||
adminToken, err := auth.GenerateAccessToken(adminID, jwtSecret, accessTTL)
|
||||
|
||||
@@ -10,11 +10,11 @@ import (
|
||||
|
||||
// AdminService handles administrator user-management operations.
|
||||
type AdminService struct {
|
||||
userRepo repository.UserRepository
|
||||
userRepo repository.AdminUserRepository
|
||||
}
|
||||
|
||||
// NewAdminService creates an AdminService.
|
||||
func NewAdminService(userRepo repository.UserRepository) *AdminService {
|
||||
func NewAdminService(userRepo repository.AdminUserRepository) *AdminService {
|
||||
return &AdminService{userRepo: userRepo}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func (s *AdminService) DeleteUser(ctx context.Context, id string) error {
|
||||
if _, err := s.GetUser(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.userRepo.Delete(ctx, id); err != nil {
|
||||
if err := s.userRepo.MarkAdminDeleted(ctx, id); err != nil {
|
||||
return model.NewInternalError("delete admin user", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -46,13 +46,13 @@ func TestAdminServiceListIncludesDeletedUsers(t *testing.T) {
|
||||
|
||||
active := &model.User{ID: "active-1", Username: "active", Email: "active@example.com", Status: model.StatusActive}
|
||||
deleted := &model.User{ID: "deleted-1", Username: "deleted", Email: "deleted@example.com", Status: model.StatusActive}
|
||||
if err := repo.Create(ctx, active); err != nil {
|
||||
if _, err := repo.CreateRegisteredUser(ctx, repository.RegisteredUserParams{ID: active.ID, Username: active.Username, Email: active.Email, PasswordHash: "hash"}); err != nil {
|
||||
t.Fatalf("create active: %v", err)
|
||||
}
|
||||
if err := repo.Create(ctx, deleted); err != nil {
|
||||
if _, err := repo.CreateRegisteredUser(ctx, repository.RegisteredUserParams{ID: deleted.ID, Username: deleted.Username, Email: deleted.Email, PasswordHash: "hash"}); err != nil {
|
||||
t.Fatalf("create deleted: %v", err)
|
||||
}
|
||||
if err := repo.Delete(ctx, deleted.ID); err != nil {
|
||||
if err := repo.MarkAdminDeleted(ctx, deleted.ID); err != nil {
|
||||
t.Fatalf("delete user: %v", err)
|
||||
}
|
||||
|
||||
|
||||
+30
-43
@@ -34,8 +34,8 @@ type Principal struct {
|
||||
|
||||
// AuthService handles user authentication and session management.
|
||||
type AuthService struct {
|
||||
userRepo repository.UserRepository
|
||||
sessionRepo repository.SessionRepository
|
||||
userRepo repository.AuthUserRepository
|
||||
sessionRepo repository.AuthSessionRepository
|
||||
credentialRepo repository.CredentialRepository
|
||||
jwtSecret []byte
|
||||
accessTTL time.Duration
|
||||
@@ -44,8 +44,8 @@ type AuthService struct {
|
||||
|
||||
// NewAuthService creates an AuthService.
|
||||
func NewAuthService(
|
||||
userRepo repository.UserRepository,
|
||||
sessionRepo repository.SessionRepository,
|
||||
userRepo repository.AuthUserRepository,
|
||||
sessionRepo repository.AuthSessionRepository,
|
||||
credentialRepo repository.CredentialRepository,
|
||||
jwtSecret []byte,
|
||||
accessTTL time.Duration,
|
||||
@@ -72,15 +72,13 @@ func (s *AuthService) Register(ctx context.Context, username, email, password st
|
||||
return nil, model.NewInternalError("hash password", err)
|
||||
}
|
||||
|
||||
user := &model.User{
|
||||
user, err := s.userRepo.CreateRegisteredUser(ctx, repository.RegisteredUserParams{
|
||||
ID: uuid.NewString(),
|
||||
Username: username,
|
||||
Email: email,
|
||||
PasswordHash: passwordHash,
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
|
||||
if err := s.userRepo.Create(ctx, user); err != nil {
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrDuplicate) {
|
||||
return nil, model.NewConflictError("username or email already exists")
|
||||
}
|
||||
@@ -124,7 +122,7 @@ func (s *AuthService) Refresh(ctx context.Context, refreshTokenStr string) (*Tok
|
||||
}
|
||||
|
||||
tokenHash := auth.HashToken(refreshTokenStr)
|
||||
session, err := s.sessionRepo.FindByTokenHash(ctx, tokenHash)
|
||||
session, err := s.sessionRepo.ConsumeRefreshSession(ctx, tokenHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, model.NewUnauthenticatedError("invalid token")
|
||||
@@ -144,25 +142,13 @@ func (s *AuthService) Refresh(ctx context.Context, refreshTokenStr string) (*Tok
|
||||
return nil, model.NewUnauthenticatedError("invalid token")
|
||||
}
|
||||
|
||||
if err := s.sessionRepo.Delete(ctx, session.ID); err != nil {
|
||||
return nil, model.NewInternalError("delete old session", err)
|
||||
}
|
||||
|
||||
return s.issueTokens(ctx, claims.UserID)
|
||||
}
|
||||
|
||||
// Logout invalidates a refresh token by deleting its session.
|
||||
func (s *AuthService) Logout(ctx context.Context, refreshTokenStr string) error {
|
||||
tokenHash := auth.HashToken(refreshTokenStr)
|
||||
session, err := s.sessionRepo.FindByTokenHash(ctx, tokenHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
return model.NewInternalError("find session", err)
|
||||
}
|
||||
|
||||
if err := s.sessionRepo.Delete(ctx, session.ID); err != nil {
|
||||
if err := s.sessionRepo.RevokeRefreshSession(ctx, tokenHash); err != nil {
|
||||
return model.NewInternalError("delete session", err)
|
||||
}
|
||||
return nil
|
||||
@@ -175,20 +161,18 @@ func (s *AuthService) CreatePasskey(ctx context.Context, userID, label string) (
|
||||
return nil, model.NewInternalError("generate token", err)
|
||||
}
|
||||
|
||||
cred := &model.Credential{
|
||||
ID: uuid.NewString(),
|
||||
credID := uuid.NewString()
|
||||
if err := s.credentialRepo.CreatePasskey(ctx, repository.PasskeyParams{
|
||||
ID: credID,
|
||||
UserID: userID,
|
||||
Type: "app_passkey",
|
||||
Label: label,
|
||||
SecretHash: hash,
|
||||
}
|
||||
|
||||
if err := s.credentialRepo.Create(ctx, cred); err != nil {
|
||||
}); err != nil {
|
||||
return nil, model.NewInternalError("create credential", err)
|
||||
}
|
||||
|
||||
return &CreatedPasskey{
|
||||
ID: cred.ID,
|
||||
ID: credID,
|
||||
Raw: raw,
|
||||
Label: label,
|
||||
}, nil
|
||||
@@ -201,7 +185,7 @@ func (s *AuthService) LoginWithPasskey(ctx context.Context, tokenStr string) (*T
|
||||
}
|
||||
|
||||
tokenHash := auth.HashToken(tokenStr)
|
||||
cred, err := s.credentialRepo.FindByHash(ctx, tokenHash)
|
||||
cred, err := s.credentialRepo.FindPasskeyByHash(ctx, tokenHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, model.NewUnauthenticatedError("invalid passkey")
|
||||
@@ -209,19 +193,21 @@ func (s *AuthService) LoginWithPasskey(ctx context.Context, tokenStr string) (*T
|
||||
return nil, model.NewInternalError("find credential", err)
|
||||
}
|
||||
|
||||
user, err := s.userRepo.FindByIDIncludeDeleted(ctx, cred.UserID)
|
||||
user, err := s.userRepo.FindByID(ctx, cred.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, model.NewUnauthenticatedError("invalid passkey")
|
||||
}
|
||||
return nil, model.NewInternalError("find user", err)
|
||||
}
|
||||
if user.Status != model.StatusActive {
|
||||
return nil, model.NewUnauthenticatedError("invalid passkey")
|
||||
}
|
||||
|
||||
if cred.Type != "app_passkey" {
|
||||
return nil, model.NewUnauthenticatedError("invalid credential type")
|
||||
if err := s.credentialRepo.RecordPasskeyUsed(ctx, cred.ID); err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, model.NewUnauthenticatedError("invalid passkey")
|
||||
}
|
||||
|
||||
if err := s.credentialRepo.UpdateLastUsed(ctx, cred.ID); err != nil {
|
||||
return nil, model.NewInternalError("update last used", err)
|
||||
}
|
||||
|
||||
@@ -230,7 +216,7 @@ func (s *AuthService) LoginWithPasskey(ctx context.Context, tokenStr string) (*T
|
||||
|
||||
// ListPasskeys returns all app passkeys for a user.
|
||||
func (s *AuthService) ListPasskeys(ctx context.Context, userID string) ([]model.Credential, error) {
|
||||
creds, err := s.credentialRepo.FindByUserIDAndType(ctx, userID, "app_passkey")
|
||||
creds, err := s.credentialRepo.ListPasskeys(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, model.NewInternalError("list passkeys", err)
|
||||
}
|
||||
@@ -239,7 +225,7 @@ func (s *AuthService) ListPasskeys(ctx context.Context, userID string) ([]model.
|
||||
|
||||
// RevokePasskey deletes an app passkey owned by the user.
|
||||
func (s *AuthService) RevokePasskey(ctx context.Context, userID, credID string) error {
|
||||
cred, err := s.credentialRepo.FindByID(ctx, credID)
|
||||
cred, err := s.credentialRepo.FindPasskeyByID(ctx, credID)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return model.NewNotFoundError("passkey not found", model.ErrNotFound)
|
||||
@@ -251,7 +237,10 @@ func (s *AuthService) RevokePasskey(ctx context.Context, userID, credID string)
|
||||
return model.NewPermissionDeniedError("access denied", model.ErrForbidden)
|
||||
}
|
||||
|
||||
if err := s.credentialRepo.Delete(ctx, credID); err != nil {
|
||||
if err := s.credentialRepo.RevokeOwnedPasskey(ctx, userID, credID); err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return model.NewNotFoundError("passkey not found", model.ErrNotFound)
|
||||
}
|
||||
return model.NewInternalError("delete credential", err)
|
||||
}
|
||||
return nil
|
||||
@@ -296,14 +285,12 @@ func (s *AuthService) issueTokens(ctx context.Context, userID string) (*TokenPai
|
||||
return nil, model.NewInternalError("generate refresh token", err)
|
||||
}
|
||||
|
||||
session := &model.Session{
|
||||
if err := s.sessionRepo.CreateRefreshSession(ctx, repository.RefreshSessionParams{
|
||||
ID: uuid.NewString(),
|
||||
UserID: userID,
|
||||
TokenHash: auth.HashToken(refreshToken),
|
||||
ExpiresAt: time.Now().Add(s.refreshTTL),
|
||||
}
|
||||
|
||||
if err := s.sessionRepo.Create(ctx, session); err != nil {
|
||||
}); err != nil {
|
||||
return nil, model.NewInternalError("create session", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -10,18 +12,20 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/auth"
|
||||
"github.com/dhao2001/mygo/internal/config"
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
"github.com/dhao2001/mygo/internal/repository"
|
||||
"github.com/dhao2001/mygo/internal/testutil"
|
||||
)
|
||||
|
||||
func setupAuthService(t *testing.T) *AuthService {
|
||||
svc, _ := setupAuthServiceWithRepos(t)
|
||||
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) {
|
||||
func setupAuthServiceWithRepos(t *testing.T) (*AuthService, repository.UserRepository, *gorm.DB) {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
@@ -42,7 +46,7 @@ func setupAuthServiceWithRepos(t *testing.T) (*AuthService, repository.UserRepos
|
||||
15*time.Minute,
|
||||
7*24*time.Hour,
|
||||
)
|
||||
return svc, userRepo
|
||||
return svc, userRepo, db
|
||||
}
|
||||
|
||||
func TestAuthService_Register(t *testing.T) {
|
||||
@@ -413,7 +417,7 @@ func TestAuthService_RefreshWithAccessToken(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthService_LoginDisabledUser(t *testing.T) {
|
||||
svc, userRepo := setupAuthServiceWithRepos(t)
|
||||
svc, userRepo, _ := setupAuthServiceWithRepos(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
||||
@@ -421,7 +425,7 @@ func TestAuthService_LoginDisabledUser(t *testing.T) {
|
||||
t.Fatalf("Register = %v", err)
|
||||
}
|
||||
|
||||
if err := userRepo.Delete(ctx, user.ID); err != nil {
|
||||
if err := userRepo.MarkAdminDeleted(ctx, user.ID); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
|
||||
@@ -442,7 +446,7 @@ func TestAuthService_LoginDisabledUser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthService_LoginDisabledUserSameMessage(t *testing.T) {
|
||||
svc, userRepo := setupAuthServiceWithRepos(t)
|
||||
svc, userRepo, _ := setupAuthServiceWithRepos(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
||||
@@ -454,7 +458,7 @@ func TestAuthService_LoginDisabledUserSameMessage(t *testing.T) {
|
||||
_, wrongPwErr := svc.Login(ctx, "alice@example.com", "wrongpassword")
|
||||
|
||||
// Soft-delete user, then try to login
|
||||
if err := userRepo.Delete(ctx, user.ID); err != nil {
|
||||
if err := userRepo.MarkAdminDeleted(ctx, user.ID); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
_, disabledErr := svc.Login(ctx, "alice@example.com", "password123")
|
||||
@@ -482,7 +486,7 @@ func TestAuthService_LoginDisabledUserSameMessage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthService_LoginWithPasskeyDisabledUser(t *testing.T) {
|
||||
svc, userRepo := setupAuthServiceWithRepos(t)
|
||||
svc, userRepo, _ := setupAuthServiceWithRepos(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
||||
@@ -506,7 +510,7 @@ func TestAuthService_LoginWithPasskeyDisabledUser(t *testing.T) {
|
||||
}
|
||||
|
||||
// Soft-delete the user
|
||||
if err := userRepo.Delete(ctx, user.ID); err != nil {
|
||||
if err := userRepo.MarkAdminDeleted(ctx, user.ID); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
|
||||
@@ -527,7 +531,7 @@ func TestAuthService_LoginWithPasskeyDisabledUser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthService_RefreshDisabledUser(t *testing.T) {
|
||||
svc, userRepo := setupAuthServiceWithRepos(t)
|
||||
svc, userRepo, _ := setupAuthServiceWithRepos(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
||||
@@ -541,7 +545,7 @@ func TestAuthService_RefreshDisabledUser(t *testing.T) {
|
||||
}
|
||||
|
||||
// Soft-delete the user
|
||||
if err := userRepo.Delete(ctx, user.ID); err != nil {
|
||||
if err := userRepo.MarkAdminDeleted(ctx, user.ID); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
|
||||
@@ -562,17 +566,14 @@ func TestAuthService_RefreshDisabledUser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthService_AuthenticateAccessToken(t *testing.T) {
|
||||
svc, userRepo := setupAuthServiceWithRepos(t)
|
||||
svc, _, db := setupAuthServiceWithRepos(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Register = %v", err)
|
||||
}
|
||||
user.IsAdmin = true
|
||||
if err := userRepo.Update(ctx, user); err != nil {
|
||||
t.Fatalf("promote user: %v", err)
|
||||
}
|
||||
testutil.SetUserAdmin(t, db, user.ID, true)
|
||||
|
||||
pair, err := svc.Login(ctx, "alice@example.com", "password123")
|
||||
if err != nil {
|
||||
@@ -592,7 +593,7 @@ func TestAuthService_AuthenticateAccessToken(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAuthService_AuthenticateAccessTokenRejectsDeletedUser(t *testing.T) {
|
||||
svc, userRepo := setupAuthServiceWithRepos(t)
|
||||
svc, userRepo, _ := setupAuthServiceWithRepos(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
||||
@@ -605,7 +606,7 @@ func TestAuthService_AuthenticateAccessTokenRejectsDeletedUser(t *testing.T) {
|
||||
t.Fatalf("Login = %v", err)
|
||||
}
|
||||
|
||||
if err := userRepo.Delete(ctx, user.ID); err != nil {
|
||||
if err := userRepo.MarkAdminDeleted(ctx, user.ID); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
|
||||
@@ -618,3 +619,72 @@ func TestAuthService_AuthenticateAccessTokenRejectsDeletedUser(t *testing.T) {
|
||||
t.Fatalf("kind = %q, want %q", ae.Kind, model.KindUnauthenticated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthService_RefreshTokenConcurrentUseAllowsOneSuccess(t *testing.T) {
|
||||
db, err := repository.Open(config.DatabaseConfig{
|
||||
Driver: "sqlite3",
|
||||
SQLite: config.SQLiteConfig{Path: filepath.Join(t.TempDir(), "auth.db")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := repository.AutoMigrate(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
svc := NewAuthService(
|
||||
repository.NewUserRepository(db),
|
||||
repository.NewSessionRepository(db),
|
||||
repository.NewCredentialRepository(db),
|
||||
[]byte("test-secret"),
|
||||
15*time.Minute,
|
||||
7*24*time.Hour,
|
||||
)
|
||||
ctx := context.Background()
|
||||
if _, err := svc.Register(ctx, "alice", "alice@example.com", "password123"); err != nil {
|
||||
t.Fatalf("Register = %v", err)
|
||||
}
|
||||
pair, err := svc.Login(ctx, "alice@example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Login = %v", err)
|
||||
}
|
||||
|
||||
type refreshResult struct {
|
||||
pair *TokenPair
|
||||
err error
|
||||
}
|
||||
start := make(chan struct{})
|
||||
results := make(chan refreshResult, 2)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
for range 2 {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
refreshed, err := svc.Refresh(ctx, pair.RefreshToken)
|
||||
results <- refreshResult{pair: refreshed, err: err}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
succeeded := 0
|
||||
rejected := 0
|
||||
for result := range results {
|
||||
if result.err == nil {
|
||||
succeeded++
|
||||
if result.pair == nil || result.pair.AccessToken == "" || result.pair.RefreshToken == "" {
|
||||
t.Fatal("successful refresh returned an empty token pair")
|
||||
}
|
||||
continue
|
||||
}
|
||||
var appErr *model.AppError
|
||||
if !errors.As(result.err, &appErr) || appErr.Kind != model.KindUnauthenticated {
|
||||
t.Fatalf("concurrent refresh error = %v, want unauthenticated", result.err)
|
||||
}
|
||||
rejected++
|
||||
}
|
||||
if succeeded != 1 || rejected != 1 {
|
||||
t.Fatalf("refresh results: success=%d rejected=%d, want 1 each", succeeded, rejected)
|
||||
}
|
||||
}
|
||||
|
||||
+43
-52
@@ -136,7 +136,7 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin
|
||||
return nil, model.NewInternalError("promote staged file", err)
|
||||
}
|
||||
|
||||
file := &model.File{
|
||||
file, err := s.fileRepo.CreateUploadedFile(ctx, repository.UploadedFileParams{
|
||||
ID: fileID,
|
||||
UserID: userID,
|
||||
ParentID: parentID,
|
||||
@@ -145,16 +145,19 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin
|
||||
MimeType: mimeType,
|
||||
StoragePath: storagePath,
|
||||
Hash: hex.EncodeToString(hasher.Sum(nil)),
|
||||
Status: model.StatusActive,
|
||||
IsDir: false,
|
||||
}
|
||||
|
||||
if err := s.fileRepo.Create(ctx, file); err != nil {
|
||||
})
|
||||
if err != nil {
|
||||
// Compensate: remove the promoted file.
|
||||
_ = s.storage.Delete(ctx, storagePath)
|
||||
if errors.Is(err, model.ErrDuplicate) {
|
||||
return nil, model.NewConflictError("a file with this name already exists in this directory")
|
||||
}
|
||||
if errors.Is(err, model.ErrParentNotFound) {
|
||||
return nil, model.NewNotFoundError("parent directory not found", model.ErrParentNotFound)
|
||||
}
|
||||
if errors.Is(err, model.ErrParentNotDir) {
|
||||
return nil, model.NewBadRequestError("parent is not a directory")
|
||||
}
|
||||
return nil, model.NewInternalError("create file record", err)
|
||||
}
|
||||
|
||||
@@ -256,44 +259,42 @@ func (s *FileService) List(ctx context.Context, userID string, parentID *string,
|
||||
|
||||
// Update renames or moves a file or directory.
|
||||
func (s *FileService) Update(ctx context.Context, userID, fileID string, newName string, newParentID *string) (*FileInfo, error) {
|
||||
file, err := s.getOwnedFile(ctx, userID, fileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate new name if provided.
|
||||
var newNamePtr *string
|
||||
if newName != "" {
|
||||
if err := validateFileName(newName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file.Name = newName
|
||||
newNamePtr = &newName
|
||||
}
|
||||
|
||||
// If parent is changing, verify the new parent.
|
||||
if newParentID != nil {
|
||||
if *newParentID == fileID {
|
||||
return nil, model.NewBadRequestError("cannot move a directory into itself")
|
||||
}
|
||||
if err := s.verifyParent(ctx, userID, *newParentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file.ParentID = newParentID
|
||||
}
|
||||
|
||||
// Check for name conflicts in the target directory.
|
||||
if newName != "" || newParentID != nil {
|
||||
conflict, err := s.fileRepo.FindByNameAndParent(ctx, userID, file.ParentID, file.Name)
|
||||
if err == nil && conflict.ID != fileID {
|
||||
return nil, model.NewConflictError("a file with this name already exists in the target directory")
|
||||
} else if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
return nil, model.NewInternalError("check name conflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.fileRepo.Update(ctx, file); err != nil {
|
||||
file, err := s.fileRepo.UpdateOwnedMetadata(ctx, repository.FileMetadataUpdate{
|
||||
FileID: fileID,
|
||||
UserID: userID,
|
||||
NewName: newNamePtr,
|
||||
NewParentID: newParentID,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrDuplicate) {
|
||||
return nil, model.NewConflictError("a file with this name already exists in the target directory")
|
||||
}
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, model.NewNotFoundError("file not found", model.ErrNotFound)
|
||||
}
|
||||
if errors.Is(err, model.ErrParentNotFound) {
|
||||
return nil, model.NewNotFoundError("parent directory not found", model.ErrParentNotFound)
|
||||
}
|
||||
if errors.Is(err, model.ErrParentNotDir) {
|
||||
return nil, model.NewBadRequestError("parent is not a directory")
|
||||
}
|
||||
if errors.Is(err, model.ErrInvalidMove) {
|
||||
return nil, model.NewBadRequestError("cannot move a directory into itself")
|
||||
}
|
||||
return nil, model.NewInternalError("update file", err)
|
||||
}
|
||||
|
||||
@@ -302,26 +303,13 @@ func (s *FileService) Update(ctx context.Context, userID, fileID string, newName
|
||||
|
||||
// Delete soft-deletes a file or (empty) directory. Storage content is preserved.
|
||||
func (s *FileService) Delete(ctx context.Context, userID, fileID string) error {
|
||||
file, err := s.getOwnedFile(ctx, userID, fileID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Directories must be empty before deletion.
|
||||
if file.IsDir {
|
||||
_, total, err := s.fileRepo.FindByParentIDPaginated(ctx, userID, &fileID, 0, 1)
|
||||
if err != nil {
|
||||
return model.NewInternalError("check directory contents", err)
|
||||
}
|
||||
if total > 0 {
|
||||
return model.NewConflictError("directory is not empty")
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.fileRepo.Delete(ctx, fileID); err != nil {
|
||||
if err := s.fileRepo.SoftDeleteOwned(ctx, userID, fileID); err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return model.NewNotFoundError("file not found", model.ErrNotFound)
|
||||
}
|
||||
if errors.Is(err, model.ErrDirectoryNotEmpty) {
|
||||
return model.NewConflictError("directory is not empty")
|
||||
}
|
||||
return model.NewInternalError("delete file record", err)
|
||||
}
|
||||
|
||||
@@ -347,19 +335,22 @@ func (s *FileService) CreateDir(ctx context.Context, userID string, parentID *st
|
||||
return nil, model.NewInternalError("check name conflict", err)
|
||||
}
|
||||
|
||||
dir := &model.File{
|
||||
dir, err := s.fileRepo.CreateDirectory(ctx, repository.DirectoryParams{
|
||||
ID: uuid.NewString(),
|
||||
UserID: userID,
|
||||
ParentID: parentID,
|
||||
Name: dirName,
|
||||
Status: model.StatusActive,
|
||||
IsDir: true,
|
||||
}
|
||||
|
||||
if err := s.fileRepo.Create(ctx, dir); err != nil {
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrDuplicate) {
|
||||
return nil, model.NewConflictError("a file with this name already exists in this directory")
|
||||
}
|
||||
if errors.Is(err, model.ErrParentNotFound) {
|
||||
return nil, model.NewNotFoundError("parent directory not found", model.ErrParentNotFound)
|
||||
}
|
||||
if errors.Is(err, model.ErrParentNotDir) {
|
||||
return nil, model.NewBadRequestError("parent is not a directory")
|
||||
}
|
||||
return nil, model.NewInternalError("create directory record", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,33 @@ type memStorage struct {
|
||||
files map[string][]byte
|
||||
}
|
||||
|
||||
type blockingPromoteStorage struct {
|
||||
*memStorage
|
||||
promoted chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func newBlockingPromoteStorage() *blockingPromoteStorage {
|
||||
return &blockingPromoteStorage{
|
||||
memStorage: newMemStorage(),
|
||||
promoted: make(chan struct{}),
|
||||
release: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *blockingPromoteStorage) PromoteStaged(ctx context.Context, stagedPath, finalPath string) error {
|
||||
if err := s.memStorage.PromoteStaged(ctx, stagedPath, finalPath); err != nil {
|
||||
return err
|
||||
}
|
||||
close(s.promoted)
|
||||
select {
|
||||
case <-s.release:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func newMemStorage() *memStorage {
|
||||
return &memStorage{files: make(map[string][]byte)}
|
||||
}
|
||||
@@ -407,6 +434,30 @@ func TestFileService_Update(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileService_UpdateRejectsDuplicateNameInRoot(t *testing.T) {
|
||||
svc := setupFileService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := svc.Upload(ctx, "user1", nil, "existing.txt", strings.NewReader("first")); err != nil {
|
||||
t.Fatalf("Upload existing = %v", err)
|
||||
}
|
||||
file, err := svc.Upload(ctx, "user1", nil, "rename-me.txt", strings.NewReader("second"))
|
||||
if err != nil {
|
||||
t.Fatalf("Upload source = %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.Update(ctx, "user1", file.ID, "existing.txt", nil)
|
||||
assertAppError(t, err, model.KindConflict)
|
||||
|
||||
unchanged, err := svc.Get(ctx, "user1", file.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get source after rejected rename = %v", err)
|
||||
}
|
||||
if unchanged.Name != "rename-me.txt" {
|
||||
t.Fatalf("source name = %q, want rename-me.txt", unchanged.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileService_UpdateReturnsNotFoundForOtherUser(t *testing.T) {
|
||||
svc := setupFileService(t)
|
||||
ctx := context.Background()
|
||||
@@ -663,3 +714,42 @@ func TestFileService_SoftDeleteReturnsNotFoundForOtherUser(t *testing.T) {
|
||||
err = svc.Delete(ctx, "user2", info.ID)
|
||||
assertAppErrorMessage(t, err, model.KindNotFound, "file not found")
|
||||
}
|
||||
|
||||
func TestFileService_UploadRechecksParentAfterStoragePromotion(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.File{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
store := newBlockingPromoteStorage()
|
||||
svc := NewFileService(repository.NewFileRepository(db), store, 0, nil)
|
||||
ctx := context.Background()
|
||||
dir, err := svc.CreateDir(ctx, "user1", nil, "parent")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDir = %v", err)
|
||||
}
|
||||
|
||||
uploadResult := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := svc.Upload(ctx, "user1", &dir.ID, "child.txt", strings.NewReader("content"))
|
||||
uploadResult <- err
|
||||
}()
|
||||
|
||||
<-store.promoted
|
||||
if err := svc.Delete(ctx, "user1", dir.ID); err != nil {
|
||||
t.Fatalf("Delete parent while upload is paused = %v", err)
|
||||
}
|
||||
close(store.release)
|
||||
|
||||
err = <-uploadResult
|
||||
assertAppErrorMessage(t, err, model.KindNotFound, "parent directory not found")
|
||||
store.mu.RLock()
|
||||
remainingObjects := len(store.files)
|
||||
store.mu.RUnlock()
|
||||
if remainingObjects != 0 {
|
||||
t.Fatalf("storage objects after rejected upload = %d, want 0", remainingObjects)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Package testutil contains fixtures that intentionally bypass production
|
||||
// service ports. Production packages must not import it.
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
)
|
||||
|
||||
// SetUserAdmin changes a user's administrative flag for test fixture setup.
|
||||
func SetUserAdmin(t testing.TB, db *gorm.DB, userID string, isAdmin bool) {
|
||||
t.Helper()
|
||||
result := db.Model(&model.User{}).Where("id = ?", userID).Update("is_admin", isAdmin)
|
||||
if result.Error != nil {
|
||||
t.Fatalf("set test user admin flag: %v", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
t.Fatalf("set test user admin flag: user %q not found", userID)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user