643 lines
23 KiB
Go
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, 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")
|
|
}
|
|
})
|
|
}
|