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:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+31
-44
@@ -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.UpdateLastUsed(ctx, cred.ID); err != nil {
|
||||
if err := s.credentialRepo.RecordPasskeyUsed(ctx, cred.ID); err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, model.NewUnauthenticatedError("invalid passkey")
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user