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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user