package test import ( "RenewCMS/internal/domain/apikey" "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/hashing" "RenewCMS/internal/infrastructure/useCases" "crypto/sha256" "encoding/hex" "errors" "regexp" "sort" "testing" "time" ) type apiKeyRevocation struct { userId uint32 at time.Time } type mockAPIKeyRepo struct { keys map[uint32]apikey.APIKey nextID uint32 fail failSwitch createCalls []apikey.APIKey touchCalls []time.Time revokeCalls int revokeAllCalls []apiKeyRevocation getByHashedCalls int } func newMockAPIKeyRepo() *mockAPIKeyRepo { return &mockAPIKeyRepo{keys: make(map[uint32]apikey.APIKey)} } func (m *mockAPIKeyRepo) sorted() []apikey.APIKey { result := make([]apikey.APIKey, 0, len(m.keys)) for _, key := range m.keys { result = append(result, key) } sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID }) return result } func (m *mockAPIKeyRepo) Create(key apikey.APIKey) (apikey.APIKey, error) { if err := m.fail.trigger("Create"); err != nil { return apikey.APIKey{}, err } m.createCalls = append(m.createCalls, key) m.nextID++ key.ID = m.nextID m.keys[key.ID] = key return key, nil } func (m *mockAPIKeyRepo) Get(id uint32) (apikey.APIKey, error) { if err := m.fail.trigger("Get"); err != nil { return apikey.APIKey{}, err } key, ok := m.keys[id] if !ok { return apikey.APIKey{}, apikey.ErrAPIKeyNotFound } return key, nil } func (m *mockAPIKeyRepo) GetByHashedKey(hashedKey string) (apikey.APIKey, error) { m.getByHashedCalls++ if err := m.fail.trigger("GetByHashedKey"); err != nil { return apikey.APIKey{}, err } for _, key := range m.sorted() { if key.HashedKey == hashedKey { return key, nil } } return apikey.APIKey{}, apikey.ErrAPIKeyNotFound } func (m *mockAPIKeyRepo) ListByUser(userId uint32) ([]apikey.APIKey, error) { if err := m.fail.trigger("ListByUser"); err != nil { return nil, err } result := make([]apikey.APIKey, 0) for _, key := range m.sorted() { if key.UserID == userId { result = append(result, key) } } return result, nil } func (m *mockAPIKeyRepo) ListAll() ([]apikey.APIKey, error) { if err := m.fail.trigger("ListAll"); err != nil { return nil, err } return m.sorted(), nil } func (m *mockAPIKeyRepo) Revoke(id uint32, at time.Time) error { m.revokeCalls++ if err := m.fail.trigger("Revoke"); err != nil { return err } key, ok := m.keys[id] if !ok || key.RevokedAt != nil { return nil } key.RevokedAt = &at m.keys[id] = key return nil } func (m *mockAPIKeyRepo) RevokeAllByUser(userId uint32, at time.Time) error { m.revokeAllCalls = append(m.revokeAllCalls, apiKeyRevocation{userId, at}) if err := m.fail.trigger("RevokeAllByUser"); err != nil { return err } for id, key := range m.keys { if key.UserID == userId && key.RevokedAt == nil { revokedAt := at key.RevokedAt = &revokedAt m.keys[id] = key } } return nil } func (m *mockAPIKeyRepo) TouchLastUsed(id uint32, at time.Time) error { m.touchCalls = append(m.touchCalls, at) if err := m.fail.trigger("TouchLastUsed"); err != nil { return err } key, ok := m.keys[id] if !ok { return apikey.ErrAPIKeyNotFound } key.LastUsedAt = &at m.keys[id] = key return nil } var _ apikey.Repository = &mockAPIKeyRepo{} type stubKeyGenerator struct { raw string prefix string } func (s *stubKeyGenerator) Generate() (string, string, string) { return s.raw, s.prefix, s.Hash(s.raw) } func (s *stubKeyGenerator) Hash(rawKey string) string { return "hash:" + rawKey } var _ apikey.KeyGenerator = &stubKeyGenerator{} var apiKeyUnitNow = time.Date(2026, time.October, 4, 12, 0, 0, 0, time.UTC) func apiKeyUnitClock() time.Time { return apiKeyUnitNow } func seedStubKey(repo *mockAPIKeyRepo, raw string, mutate func(*apikey.APIKey)) apikey.APIKey { key := apikey.APIKey{UserID: 2, Name: "k", Prefix: raw, HashedKey: "hash:" + raw, CreatedAt: apiKeyUnitNow.Add(-time.Hour)} if mutate != nil { mutate(&key) } stored, _ := repo.Create(key) return stored } func apiKeyUnitUsers() *mockUserRepo { users := newMockUserRepo() users.users[1] = user.User{ID: 1, Username: "marta", IsVerified: true, Role: user.RoleOwner} users.users[2] = user.User{ID: 2, Username: "dev", IsVerified: true, Role: user.RoleAdmin, TokenVersion: 3} users.users[3] = user.User{ID: 3, Username: "pat", IsVerified: true, Role: user.RoleAdmin} users.users[4] = user.User{ID: 4, Username: "quinn", IsVerified: false, Role: user.RoleAdmin} return users } func TestSHA256KeyGenerator(t *testing.T) { generator := hashing.NewSHA256KeyGenerator() rawKeyPattern := regexp.MustCompile(`^rcms_[A-Za-z0-9_-]{43}$`) t.Run("A generated key has the rcms_ format, a 12 character prefix and its SHA-256 hash", func(t *testing.T) { raw, prefix, hashed := generator.Generate() if !rawKeyPattern.MatchString(raw) { t.Fatalf("unexpected raw key %q", raw) } if prefix != raw[:12] { t.Errorf("expected prefix %q, got %q", raw[:12], prefix) } sum := sha256.Sum256([]byte(raw)) if hashed != hex.EncodeToString(sum[:]) { t.Errorf("expected the SHA-256 of the raw key, got %q", hashed) } }) t.Run("Hash is deterministic and never the raw key", func(t *testing.T) { if generator.Hash("rcms_abc") != generator.Hash("rcms_abc") { t.Errorf("expected a deterministic hash") } if generator.Hash("rcms_abc") == generator.Hash("rcms_abd") { t.Errorf("expected different hashes for different keys") } }) t.Run("Two generated keys differ", func(t *testing.T) { first, _, _ := generator.Generate() second, _, _ := generator.Generate() if first == second { t.Errorf("expected two different keys") } }) } func TestCreateAPIKeyUseCase(t *testing.T) { t.Run("Use case: creating a key hashes it and stores no plaintext", func(t *testing.T) { repo := newMockAPIKeyRepo() uc := useCases.NewCreateAPIKeyUseCase(repo, &stubKeyGenerator{raw: "rcms_fake", prefix: "rcms_fake"}, apiKeyUnitClock, discardRecorder) created, err := uc.CreateAPIKey(testCtx, useCases.CreateAPIKeyCommand{UserID: 2, Name: "ci"}) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(repo.createCalls) != 1 { t.Fatalf("expected one Create call, got %d", len(repo.createCalls)) } stored := repo.createCalls[0] if stored.HashedKey != "hash:rcms_fake" || stored.Prefix != "rcms_fake" || stored.UserID != 2 || stored.Name != "ci" { t.Errorf("unexpected stored key: %+v", stored) } if !stored.CreatedAt.Equal(apiKeyUnitNow) { t.Errorf("expected CreatedAt from the clock, got %v", stored.CreatedAt) } if created.RawKey != "rcms_fake" { t.Errorf("expected the raw key in the result, got %q", created.RawKey) } if stored.HashedKey == created.RawKey || stored.Prefix == "" { t.Errorf("the raw key must not be stored") } }) t.Run("An expiry that is not in the future is refused and nothing is stored", func(t *testing.T) { for name, expiry := range map[string]time.Time{"in the past": apiKeyUnitNow.Add(-time.Hour), "exactly now": apiKeyUnitNow} { repo := newMockAPIKeyRepo() uc := useCases.NewCreateAPIKeyUseCase(repo, &stubKeyGenerator{raw: "rcms_fake", prefix: "rcms_fake"}, apiKeyUnitClock, discardRecorder) _, err := uc.CreateAPIKey(testCtx, useCases.CreateAPIKeyCommand{UserID: 2, Name: "ci", ExpiresAt: &expiry}) if !errors.Is(err, apikey.ErrExpiryNotInFuture) { t.Errorf("%s: expected ErrExpiryNotInFuture, got %v", name, err) } if len(repo.createCalls) != 0 { t.Errorf("%s: expected nothing stored", name) } } }) t.Run("A repository failure is returned", func(t *testing.T) { repo := newMockAPIKeyRepo() repo.fail.failNext("Create") uc := useCases.NewCreateAPIKeyUseCase(repo, &stubKeyGenerator{raw: "rcms_fake", prefix: "rcms_fake"}, apiKeyUnitClock, discardRecorder) if _, err := uc.CreateAPIKey(testCtx, useCases.CreateAPIKeyCommand{UserID: 2, Name: "ci"}); !errors.Is(err, errRepositoryFailure) { t.Errorf("expected the repository failure, got %v", err) } }) } func TestAuthenticateAPIKeyUseCase(t *testing.T) { newUseCase := func(repo *mockAPIKeyRepo, users *mockUserRepo) *useCases.AuthenticateAPIKeyUseCase { return useCases.NewAuthenticateAPIKeyUseCase(repo, &stubKeyGenerator{}, users, apiKeyUnitClock) } t.Run("Use case: authenticating returns the user and rejects every invalid case with one sentinel", func(t *testing.T) { repo := newMockAPIKeyRepo() expiredAt := apiKeyUnitNow.Add(-time.Second) exactlyNow := apiKeyUnitNow revokedAt := apiKeyUnitNow.Add(-time.Minute) seedStubKey(repo, "rcms_expired", func(k *apikey.APIKey) { k.ExpiresAt = &expiredAt }) seedStubKey(repo, "rcms_exactlynow", func(k *apikey.APIKey) { k.ExpiresAt = &exactlyNow }) seedStubKey(repo, "rcms_revoked", func(k *apikey.APIKey) { k.RevokedAt = &revokedAt }) seedStubKey(repo, "rcms_missinguser", func(k *apikey.APIKey) { k.UserID = 99 }) seedStubKey(repo, "rcms_unverified", func(k *apikey.APIKey) { k.UserID = 4 }) uc := newUseCase(repo, apiKeyUnitUsers()) for _, raw := range []string{"rcms_unknown", "rcms_", "", "nope", "rcms_expired", "rcms_exactlynow", "rcms_revoked", "rcms_missinguser", "rcms_unverified"} { if _, err := uc.AuthenticateAPIKey(raw); !errors.Is(err, apikey.ErrAPIKeyInvalid) { t.Errorf("%q: expected ErrAPIKeyInvalid, got %v", raw, err) } } if len(repo.touchCalls) != 0 { t.Errorf("a rejected key must never record last_used_at") } }) t.Run("A valid key returns the user from the user repository", func(t *testing.T) { repo := newMockAPIKeyRepo() stored := seedStubKey(repo, "rcms_valid", nil) uc := newUseCase(repo, apiKeyUnitUsers()) authenticated, err := uc.AuthenticateAPIKey("rcms_valid") if err != nil { t.Fatalf("unexpected error: %v", err) } if authenticated.User.ID != 2 || authenticated.User.TokenVersion != 3 || authenticated.Key.ID != stored.ID { t.Errorf("unexpected result: %+v", authenticated) } }) t.Run("A key expiring in the future is valid", func(t *testing.T) { repo := newMockAPIKeyRepo() later := apiKeyUnitNow.Add(time.Second) seedStubKey(repo, "rcms_later", func(k *apikey.APIKey) { k.ExpiresAt = &later }) if _, err := newUseCase(repo, apiKeyUnitUsers()).AuthenticateAPIKey("rcms_later"); err != nil { t.Errorf("unexpected error: %v", err) } }) t.Run("Use case: a failing repository is not reported as an invalid key", func(t *testing.T) { repo := newMockAPIKeyRepo() seedStubKey(repo, "rcms_valid", nil) repo.fail.failNext("GetByHashedKey") _, err := newUseCase(repo, apiKeyUnitUsers()).AuthenticateAPIKey("rcms_valid") if !errors.Is(err, errRepositoryFailure) || errors.Is(err, apikey.ErrAPIKeyInvalid) { t.Errorf("expected the raw repository failure, got %v", err) } }) t.Run("A failing user repository is not reported as an invalid key", func(t *testing.T) { repo := newMockAPIKeyRepo() seedStubKey(repo, "rcms_valid", nil) users := apiKeyUnitUsers() users.fail.failNext("Get") _, err := newUseCase(repo, users).AuthenticateAPIKey("rcms_valid") if !errors.Is(err, errRepositoryFailure) || errors.Is(err, apikey.ErrAPIKeyInvalid) { t.Errorf("expected the raw repository failure, got %v", err) } }) t.Run("Using a key records last_used_at at most once per minute", func(t *testing.T) { repo := newMockAPIKeyRepo() stored := seedStubKey(repo, "rcms_valid", nil) now := apiKeyUnitNow uc := useCases.NewAuthenticateAPIKeyUseCase(repo, &stubKeyGenerator{}, apiKeyUnitUsers(), func() time.Time { return now }) steps := []struct { offset time.Duration writes int lastUsed time.Duration }{ {0, 1, 0}, {30 * time.Second, 1, 0}, {60 * time.Second, 2, 60 * time.Second}, {61*time.Second + 30*time.Second, 2, 60 * time.Second}, {130 * time.Second, 3, 130 * time.Second}, } for _, step := range steps { now = apiKeyUnitNow.Add(step.offset) if _, err := uc.AuthenticateAPIKey("rcms_valid"); err != nil { t.Fatalf("unexpected error at +%v: %v", step.offset, err) } if len(repo.touchCalls) != step.writes { t.Errorf("at +%v: expected %d writes, got %d", step.offset, step.writes, len(repo.touchCalls)) } if got := repo.keys[stored.ID].LastUsedAt; got == nil || !got.Equal(apiKeyUnitNow.Add(step.lastUsed)) { t.Errorf("at +%v: expected last_used_at +%v, got %v", step.offset, step.lastUsed, got) } } }) t.Run("A failure to record last_used_at doesn't fail the authentication", func(t *testing.T) { repo := newMockAPIKeyRepo() seedStubKey(repo, "rcms_valid", nil) repo.fail.failNext("TouchLastUsed") if _, err := newUseCase(repo, apiKeyUnitUsers()).AuthenticateAPIKey("rcms_valid"); err != nil { t.Errorf("unexpected error: %v", err) } }) } func TestListAPIKeysUseCase(t *testing.T) { seed := func(repo *mockAPIKeyRepo) { seedStubKey(repo, "rcms_a", func(k *apikey.APIKey) { k.UserID = 2; k.CreatedAt = apiKeyUnitNow.Add(-3 * time.Hour) }) seedStubKey(repo, "rcms_b", func(k *apikey.APIKey) { k.UserID = 2; k.CreatedAt = apiKeyUnitNow.Add(-2 * time.Hour) }) seedStubKey(repo, "rcms_c", func(k *apikey.APIKey) { k.UserID = 3; k.CreatedAt = apiKeyUnitNow.Add(-90 * time.Minute) }) seedStubKey(repo, "rcms_d", func(k *apikey.APIKey) { k.UserID = 1; k.CreatedAt = apiKeyUnitNow.Add(-time.Hour) }) } prefixes := func(entries []useCases.APIKeyEntry) []string { result := make([]string, 0, len(entries)) for _, entry := range entries { result = append(result, entry.Key.Prefix) } return result } t.Run("An admin lists only their own keys, newest first", func(t *testing.T) { repo := newMockAPIKeyRepo() seed(repo) entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(2) if err != nil { t.Fatalf("unexpected error: %v", err) } if got := prefixes(entries); len(got) != 2 || got[0] != "rcms_b" || got[1] != "rcms_a" { t.Errorf("unexpected listing %v", got) } }) t.Run("The owner lists every key, newest first", func(t *testing.T) { repo := newMockAPIKeyRepo() seed(repo) entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(1) if err != nil { t.Fatalf("unexpected error: %v", err) } got := prefixes(entries) want := []string{"rcms_d", "rcms_c", "rcms_b", "rcms_a"} for i := range want { if len(got) != len(want) || got[i] != want[i] { t.Fatalf("expected %v, got %v", want, got) } } }) t.Run("Keys created at the same instant are listed by descending id", func(t *testing.T) { repo := newMockAPIKeyRepo() same := apiKeyUnitNow.Add(-time.Hour) for _, raw := range []string{"rcms_first", "rcms_second", "rcms_third"} { seedStubKey(repo, raw, func(k *apikey.APIKey) { k.CreatedAt = same }) } entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(2) if err != nil { t.Fatalf("unexpected error: %v", err) } got := prefixes(entries) want := []string{"rcms_third", "rcms_second", "rcms_first"} if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] || got[2] != want[2] { t.Errorf("expected %v, got %v", want, got) } }) t.Run("Each entry carries its status at the clock's now", func(t *testing.T) { repo := newMockAPIKeyRepo() expired := apiKeyUnitNow.Add(-time.Hour) revoked := apiKeyUnitNow.Add(-time.Minute) seedStubKey(repo, "rcms_active", nil) seedStubKey(repo, "rcms_expired", func(k *apikey.APIKey) { k.ExpiresAt = &expired }) seedStubKey(repo, "rcms_both", func(k *apikey.APIKey) { k.ExpiresAt = &expired; k.RevokedAt = &revoked }) entries, _ := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(2) statuses := map[string]apikey.Status{} for _, entry := range entries { statuses[entry.Key.Prefix] = entry.Status } if statuses["rcms_active"] != apikey.StatusActive || statuses["rcms_expired"] != apikey.StatusExpired || statuses["rcms_both"] != apikey.StatusRevoked { t.Errorf("unexpected statuses %v", statuses) } }) t.Run("An unknown requester and repository failures are returned", func(t *testing.T) { repo := newMockAPIKeyRepo() uc := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock) if _, err := uc.ListAPIKeys(99); !errors.Is(err, user.ErrUserNotFound) { t.Errorf("expected ErrUserNotFound, got %v", err) } repo.fail.failNext("ListByUser") if _, err := uc.ListAPIKeys(2); !errors.Is(err, errRepositoryFailure) { t.Errorf("expected the repository failure, got %v", err) } repo.fail.failNext("ListAll") if _, err := uc.ListAPIKeys(1); !errors.Is(err, errRepositoryFailure) { t.Errorf("expected the repository failure, got %v", err) } }) } func TestRevokeAPIKeyUseCase(t *testing.T) { t.Run("A creator revokes their own key at the clock's now", func(t *testing.T) { repo := newMockAPIKeyRepo() stored := seedStubKey(repo, "rcms_a", nil) uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock, discardRecorder) if err := uc.RevokeAPIKey(testCtx, 2, stored.ID); err != nil { t.Fatalf("unexpected error: %v", err) } if got := repo.keys[stored.ID].RevokedAt; got == nil || !got.Equal(apiKeyUnitNow) { t.Errorf("expected revoked_at %v, got %v", apiKeyUnitNow, got) } }) t.Run("Another admin gets not found and the key stays active", func(t *testing.T) { repo := newMockAPIKeyRepo() stored := seedStubKey(repo, "rcms_a", nil) uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock, discardRecorder) if err := uc.RevokeAPIKey(testCtx, 3, stored.ID); !errors.Is(err, apikey.ErrAPIKeyNotFound) { t.Errorf("expected ErrAPIKeyNotFound, got %v", err) } if repo.keys[stored.ID].RevokedAt != nil || repo.revokeCalls != 0 { t.Errorf("expected the key untouched") } }) t.Run("The owner revokes any key", func(t *testing.T) { repo := newMockAPIKeyRepo() stored := seedStubKey(repo, "rcms_a", func(k *apikey.APIKey) { k.UserID = 3 }) uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock, discardRecorder) if err := uc.RevokeAPIKey(testCtx, 1, stored.ID); err != nil { t.Fatalf("unexpected error: %v", err) } if repo.keys[stored.ID].RevokedAt == nil { t.Errorf("expected the key revoked") } }) t.Run("Revoking an already revoked key keeps its original revoked_at", func(t *testing.T) { repo := newMockAPIKeyRepo() original := apiKeyUnitNow.Add(-time.Hour) stored := seedStubKey(repo, "rcms_a", func(k *apikey.APIKey) { k.RevokedAt = &original }) uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock, discardRecorder) if err := uc.RevokeAPIKey(testCtx, 2, stored.ID); err != nil { t.Fatalf("unexpected error: %v", err) } if got := repo.keys[stored.ID].RevokedAt; got == nil || !got.Equal(original) { t.Errorf("expected revoked_at %v, got %v", original, got) } }) t.Run("An unknown key, an unknown requester and repository failures are returned", func(t *testing.T) { repo := newMockAPIKeyRepo() stored := seedStubKey(repo, "rcms_a", nil) uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock, discardRecorder) if err := uc.RevokeAPIKey(testCtx, 2, 999); !errors.Is(err, apikey.ErrAPIKeyNotFound) { t.Errorf("expected ErrAPIKeyNotFound, got %v", err) } if err := uc.RevokeAPIKey(testCtx, 99, stored.ID); !errors.Is(err, user.ErrUserNotFound) { t.Errorf("expected ErrUserNotFound, got %v", err) } repo.fail.failNext("Revoke") if err := uc.RevokeAPIKey(testCtx, 2, stored.ID); !errors.Is(err, errRepositoryFailure) { t.Errorf("expected the repository failure, got %v", err) } }) } func TestResetPasswordRevokesAPIKeys(t *testing.T) { resetUser := func(expiration time.Time) *mockUserRepo { repo := newMockUserRepo() repo.users[2] = user.User{ID: 2, Email: "dev@example.com", Password: "hashed:old", PasswordResetCode: "hashed:reset-code-1", PasswordResetExpiration: expiration} repo.users[3] = user.User{ID: 3, Email: "pat@example.com"} return repo } t.Run("Use case: ResetPassword revokes the user's keys through the domain repository", func(t *testing.T) { keys := newMockAPIKeyRepo() seedStubKey(keys, "rcms_a", func(k *apikey.APIKey) { k.UserID = 2 }) other := seedStubKey(keys, "rcms_c", func(k *apikey.APIKey) { k.UserID = 3 }) uc := useCases.NewResetPasswordUseCase(resetUser(apiKeyUnitNow.Add(time.Hour)), &stubHasher{}, keys, apiKeyUnitClock, discardRecorder) if err := uc.ResetPassword(testCtx, "dev@example.com", "reset-code-1", "newpassword1"); err != nil { t.Fatalf("unexpected error: %v", err) } if len(keys.revokeAllCalls) != 1 || keys.revokeAllCalls[0].userId != 2 || !keys.revokeAllCalls[0].at.Equal(apiKeyUnitNow) { t.Errorf("expected exactly one RevokeAllByUser(2, T), got %+v", keys.revokeAllCalls) } if keys.keys[other.ID].RevokedAt != nil { t.Errorf("expected user 3's key untouched") } }) t.Run("Use case: ResetPassword does not call RevokeAllByUser when the reset fails", func(t *testing.T) { cases := []struct { name string expiration time.Time code string email string want error }{ {"wrong code", apiKeyUnitNow.Add(time.Hour), "wrong", "dev@example.com", user.ErrInvalidPasswordResetCode}, {"expired code", apiKeyUnitNow.Add(-time.Second), "reset-code-1", "dev@example.com", user.ErrPasswordResetCodeExpired}, {"unknown email", apiKeyUnitNow.Add(time.Hour), "reset-code-1", "nobody@example.com", user.ErrInvalidPasswordResetCode}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { keys := newMockAPIKeyRepo() uc := useCases.NewResetPasswordUseCase(resetUser(tc.expiration), &stubHasher{}, keys, apiKeyUnitClock, discardRecorder) if err := uc.ResetPassword(testCtx, tc.email, tc.code, "newpassword1"); !errors.Is(err, tc.want) { t.Errorf("expected %v, got %v", tc.want, err) } if len(keys.revokeAllCalls) != 0 { t.Errorf("expected RevokeAllByUser never called") } }) } }) t.Run("A failed password change and a failed hash revoke nothing", func(t *testing.T) { keys := newMockAPIKeyRepo() users := resetUser(apiKeyUnitNow.Add(time.Hour)) users.fail.failNext("ResetPassword") uc := useCases.NewResetPasswordUseCase(users, &stubHasher{}, keys, apiKeyUnitClock, discardRecorder) if err := uc.ResetPassword(testCtx, "dev@example.com", "reset-code-1", "newpassword1"); !errors.Is(err, errRepositoryFailure) { t.Errorf("expected the repository failure, got %v", err) } hashErr := errors.New("hash failed") uc = useCases.NewResetPasswordUseCase(resetUser(apiKeyUnitNow.Add(time.Hour)), &stubHasher{hashErr: hashErr}, keys, apiKeyUnitClock, discardRecorder) if err := uc.ResetPassword(testCtx, "dev@example.com", "reset-code-1", "newpassword1"); !errors.Is(err, hashErr) { t.Errorf("expected the hash error, got %v", err) } if len(keys.revokeAllCalls) != 0 { t.Errorf("expected RevokeAllByUser never called") } }) t.Run("Use case: a failure to revoke keys after a reset is reported", func(t *testing.T) { keys := newMockAPIKeyRepo() keys.fail.failNext("RevokeAllByUser") users := resetUser(apiKeyUnitNow.Add(time.Hour)) uc := useCases.NewResetPasswordUseCase(users, &stubHasher{}, keys, apiKeyUnitClock, discardRecorder) err := uc.ResetPassword(testCtx, "dev@example.com", "reset-code-1", "newpassword1") if !errors.Is(err, errRepositoryFailure) { t.Errorf("expected the revoke failure, got %v", err) } if users.users[2].Password != "hashed:newpassword1" { t.Errorf("expected the password already changed") } }) }