auth_cache_invalidation_outbox_repo_test.go 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
  1. package repository
  2. import (
  3. "context"
  4. "crypto/sha256"
  5. "encoding/hex"
  6. "strings"
  7. "testing"
  8. "time"
  9. sqlmock "github.com/DATA-DOG/go-sqlmock"
  10. "github.com/Wei-Shaw/sub2api/migrations"
  11. "github.com/stretchr/testify/require"
  12. )
  13. func TestAuthCacheInvalidationOutboxRepository_ClaimUsesLeaseAndSkipLocked(t *testing.T) {
  14. db, mock, err := sqlmock.New()
  15. require.NoError(t, err)
  16. defer func() { _ = db.Close() }()
  17. created := time.Now().UTC()
  18. mock.ExpectQuery("(?s)claimed_at < NOW\\(\\) - .*FOR UPDATE SKIP LOCKED.*RETURNING").
  19. WithArgs("worker-a", 100, int64(30)).
  20. WillReturnRows(sqlmock.NewRows([]string{"id", "cache_key", "attempts", "delivery_stage", "created_at"}).
  21. AddRow(int64(4), strings.Repeat("a", 64), 2, 1, created))
  22. repo := NewAuthCacheInvalidationOutboxRepository(db)
  23. events, err := repo.Claim(context.Background(), "worker-a", 100, 30*time.Second)
  24. require.NoError(t, err)
  25. require.Len(t, events, 1)
  26. require.Equal(t, int64(4), events[0].ID)
  27. require.Equal(t, 1, events[0].Stage)
  28. require.NoError(t, mock.ExpectationsWereMet())
  29. }
  30. func TestAuthCacheInvalidationOutboxRepository_ClaimIsBoundedByDefault(t *testing.T) {
  31. db, mock, err := sqlmock.New()
  32. require.NoError(t, err)
  33. defer func() { _ = db.Close() }()
  34. mock.ExpectQuery("(?s)FROM auth_cache_invalidation_outbox.*LIMIT \\$2.*SKIP LOCKED").
  35. WithArgs("worker", 100, int64(30)).
  36. WillReturnRows(sqlmock.NewRows([]string{"id", "cache_key", "attempts", "delivery_stage", "created_at"}))
  37. repo := NewAuthCacheInvalidationOutboxRepository(db)
  38. _, err = repo.Claim(context.Background(), "worker", 0, 0)
  39. require.NoError(t, err)
  40. require.NoError(t, mock.ExpectationsWereMet())
  41. }
  42. func TestAuthCacheInvalidationOutboxRepository_ClaimOwnershipTransitions(t *testing.T) {
  43. db, mock, err := sqlmock.New()
  44. require.NoError(t, err)
  45. defer func() { _ = db.Close() }()
  46. repo := NewAuthCacheInvalidationOutboxRepository(db)
  47. next := time.Now().UTC().Add(time.Minute)
  48. mock.ExpectExec("UPDATE auth_cache_invalidation_outbox").
  49. WithArgs(int64(1), "worker", next).
  50. WillReturnResult(sqlmock.NewResult(0, 1))
  51. require.NoError(t, repo.ScheduleSecondPass(context.Background(), 1, "worker", next))
  52. retryAt := next.Add(time.Minute)
  53. mock.ExpectExec("UPDATE auth_cache_invalidation_outbox").
  54. WithArgs(int64(2), "worker", retryAt, "publish failed").
  55. WillReturnResult(sqlmock.NewResult(0, 1))
  56. require.NoError(t, repo.RetryClaimed(context.Background(), 2, "worker", retryAt, "publish failed"))
  57. mock.ExpectExec("DELETE FROM auth_cache_invalidation_outbox").
  58. WithArgs(int64(3), "worker").
  59. WillReturnResult(sqlmock.NewResult(0, 1))
  60. require.NoError(t, repo.DeleteClaimed(context.Background(), 3, "worker"))
  61. require.NoError(t, mock.ExpectationsWereMet())
  62. }
  63. func TestAuthCacheInvalidationOutboxRepository_RejectsLostClaim(t *testing.T) {
  64. db, mock, err := sqlmock.New()
  65. require.NoError(t, err)
  66. defer func() { _ = db.Close() }()
  67. mock.ExpectExec("DELETE FROM auth_cache_invalidation_outbox").
  68. WithArgs(int64(3), "old-worker").
  69. WillReturnResult(sqlmock.NewResult(0, 0))
  70. repo := NewAuthCacheInvalidationOutboxRepository(db)
  71. err = repo.DeleteClaimed(context.Background(), 3, "old-worker")
  72. require.ErrorContains(t, err, "no longer owned")
  73. }
  74. func TestAuthCacheInvalidationOutboxRepository_StatsExposeDurableLagAndFailures(t *testing.T) {
  75. db, mock, err := sqlmock.New()
  76. require.NoError(t, err)
  77. defer func() { _ = db.Close() }()
  78. oldest := time.Now().UTC().Add(-time.Minute)
  79. mock.ExpectQuery("(?s)SELECT COUNT\\(\\*\\), MIN\\(created_at\\), COALESCE\\(MAX\\(attempts\\), 0\\)").
  80. WillReturnRows(sqlmock.NewRows([]string{"count", "min", "max", "last_error"}).AddRow(5, oldest, 7, "redis down"))
  81. repo := NewAuthCacheInvalidationOutboxRepository(db)
  82. stats, err := repo.Stats(context.Background())
  83. require.NoError(t, err)
  84. require.Equal(t, int64(5), stats.Pending)
  85. require.Equal(t, 7, stats.MaxAttempts)
  86. require.Equal(t, "redis down", stats.LastError)
  87. require.NotNil(t, stats.OldestCreatedAt)
  88. }
  89. func TestAuthCacheInvalidationMigration_SecurityCoverageAndNoPlaintextPayload(t *testing.T) {
  90. content, err := migrations.FS.ReadFile("184_auth_cache_invalidation_outbox.sql")
  91. require.NoError(t, err)
  92. sqlText := string(content)
  93. for _, required := range []string{
  94. "encode(sha256(convert_to(raw_key, 'UTF8')), 'hex')",
  95. "OLD.key", "OLD.status", "OLD.deleted_at", "OLD.user_id", "OLD.group_id",
  96. "OLD.ip_whitelist", "OLD.ip_blacklist", "OLD.expires_at",
  97. "trg_users_auth_cache_invalidation", "trg_groups_auth_cache_invalidation",
  98. "trg_user_allowed_groups_auth_cache_invalidation", "FOR EACH ROW",
  99. "delivery_stage", "claimed_at", "available_at",
  100. } {
  101. require.Contains(t, sqlText, required)
  102. }
  103. require.NotContains(t, sqlText, "quota_used IS DISTINCT")
  104. require.NotContains(t, sqlText, "last_used_at IS DISTINCT")
  105. plaintext := "sk-plaintext-must-not-be-stored"
  106. sum := sha256.Sum256([]byte(plaintext))
  107. require.Len(t, hex.EncodeToString(sum[:]), 64)
  108. require.NotContains(t, sqlText, plaintext)
  109. }