Files
RenewCMS/test/api_keys_test.go
T

643 lines
23 KiB
Go

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)
created, err := uc.CreateAPIKey(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)
_, err := uc.CreateAPIKey(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)
if _, err := uc.CreateAPIKey(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)
if err := uc.RevokeAPIKey(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)
if err := uc.RevokeAPIKey(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)
if err := uc.RevokeAPIKey(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)
if err := uc.RevokeAPIKey(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)
if err := uc.RevokeAPIKey(2, 999); !errors.Is(err, apikey.ErrAPIKeyNotFound) {
t.Errorf("expected ErrAPIKeyNotFound, got %v", err)
}
if err := uc.RevokeAPIKey(99, stored.ID); !errors.Is(err, user.ErrUserNotFound) {
t.Errorf("expected ErrUserNotFound, got %v", err)
}
repo.fail.failNext("Revoke")
if err := uc.RevokeAPIKey(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)
if err := uc.ResetPassword("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)
if err := uc.ResetPassword(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)
if err := uc.ResetPassword("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)
if err := uc.ResetPassword("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)
err := uc.ResetPassword("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")
}
})
}