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:
2026-07-16 12:24:36 +08:00
parent 6604ecb026
commit a18f96912d
30 changed files with 1259 additions and 394 deletions
+88 -18
View File
@@ -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)
}
}