prompt_repository_integration_test.go 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. package securityaudit
  2. import (
  3. "context"
  4. "database/sql"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "os"
  9. "path/filepath"
  10. "sort"
  11. "strings"
  12. "sync"
  13. "testing"
  14. "time"
  15. _ "github.com/lib/pq"
  16. "github.com/stretchr/testify/require"
  17. )
  18. const promptAuditPostgresTestEnv = "PROMPT_AUDIT_TEST_POSTGRES_DSN"
  19. func openPromptAuditIntegrationDB(t *testing.T) *sql.DB {
  20. t.Helper()
  21. dsn := strings.TrimSpace(os.Getenv(promptAuditPostgresTestEnv))
  22. if dsn == "" {
  23. t.Skip(promptAuditPostgresTestEnv + " is not set")
  24. }
  25. db, err := sql.Open("postgres", dsn)
  26. require.NoError(t, err)
  27. db.SetMaxOpenConns(16)
  28. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
  29. defer cancel()
  30. require.NoError(t, db.PingContext(ctx))
  31. _, err = db.ExecContext(ctx, `
  32. CREATE TABLE IF NOT EXISTS users (id BIGSERIAL PRIMARY KEY);
  33. CREATE TABLE IF NOT EXISTS groups (id BIGSERIAL PRIMARY KEY);
  34. CREATE TABLE IF NOT EXISTS api_keys (id BIGSERIAL PRIMARY KEY);
  35. CREATE TABLE IF NOT EXISTS settings (
  36. key VARCHAR(255) PRIMARY KEY,
  37. value TEXT NOT NULL DEFAULT '',
  38. updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
  39. );
  40. `)
  41. require.NoError(t, err)
  42. for _, name := range []string{"181_prompt_audit.sql", "182_prompt_audit_full_prompt.sql"} {
  43. migration, err := os.ReadFile(filepath.Join("..", "..", "migrations", name))
  44. require.NoError(t, err)
  45. // The migration runner can retry an interrupted deployment; the migration
  46. // must therefore be safe to execute more than once.
  47. _, err = db.ExecContext(ctx, string(migration))
  48. require.NoError(t, err)
  49. _, err = db.ExecContext(ctx, string(migration))
  50. require.NoError(t, err)
  51. }
  52. t.Cleanup(func() { require.NoError(t, db.Close()) })
  53. resetPromptAuditIntegrationDB(t, db)
  54. return db
  55. }
  56. func resetPromptAuditIntegrationDB(t *testing.T, db *sql.DB) {
  57. t.Helper()
  58. _, err := db.Exec(`TRUNCATE TABLE prompt_audit_events, prompt_audit_jobs, api_keys, users, groups, settings RESTART IDENTITY CASCADE`)
  59. require.NoError(t, err)
  60. }
  61. func insertIdentity(t *testing.T, db *sql.DB, table string) int64 {
  62. t.Helper()
  63. var id int64
  64. require.NoError(t, db.QueryRow(`INSERT INTO `+table+` DEFAULT VALUES RETURNING id`).Scan(&id))
  65. return id
  66. }
  67. func integrationSnapshot(seed string) PromptSnapshot {
  68. return PromptSnapshot{
  69. RequestID: "request-" + seed, UsernameSnapshot: "user-" + seed,
  70. UserEmailSnapshot: "user-" + seed + "@example.test", APIKeyNameSnapshot: "key-" + seed,
  71. GroupName: "group-" + seed, Provider: "openai", Endpoint: "/v1/chat/completions",
  72. Protocol: "openai_chat", Model: "gpt-test", PromptHash: strings.Repeat(seed[:1], 64),
  73. RedactedPreview: "redacted-" + seed, PromptLength: len([]rune(seed)), MessageCount: 1,
  74. }
  75. }
  76. func integrationResult(decision EventDecision) *NormalizedResult {
  77. result := &NormalizedResult{
  78. Decision: decision, RiskLevel: RiskLow, Action: ActionAllow, Safety: "Safe",
  79. Categories: []string{}, MatchedScanners: []string{}, ScannerScores: map[string]float64{},
  80. ScannerEvidence: map[string]string{}, ScannerBackend: "qwen3guard-openai",
  81. ScannerVersion: "test", GuardEndpointID: "guard-1", PolicyID: "priority",
  82. PolicyVersion: 1, ChunkTotal: 1, LatencyMS: 2,
  83. }
  84. if decision != EventPass {
  85. result.RiskLevel = RiskCritical
  86. result.Action = ActionBlock
  87. result.Safety = "Unsafe"
  88. result.Categories = []string{"pii"}
  89. result.MatchedScanners = []string{"pii"}
  90. result.ScannerScores["pii"] = 1
  91. result.ScannerEvidence["pii"] = "redacted evidence"
  92. }
  93. return result
  94. }
  95. func TestPromptAuditMigrationSchemaAndLeakageGate(t *testing.T) {
  96. db := openPromptAuditIntegrationDB(t)
  97. ctx := context.Background()
  98. rows, err := db.QueryContext(ctx, `SELECT table_name, column_name FROM information_schema.columns
  99. WHERE table_schema='public' AND table_name IN ('prompt_audit_jobs','prompt_audit_events')`)
  100. require.NoError(t, err)
  101. defer func() { _ = rows.Close() }()
  102. forbidden := []string{"raw_prompt", "raw_request", "payload", "token", "authorization", "credential", "ciphertext"}
  103. for rows.Next() {
  104. var tableName, columnName string
  105. require.NoError(t, rows.Scan(&tableName, &columnName))
  106. lower := strings.ToLower(columnName)
  107. for _, word := range forbidden {
  108. require.NotContainsf(t, lower, word, "%s.%s is a forbidden raw/credential column", tableName, columnName)
  109. }
  110. }
  111. require.NoError(t, rows.Err())
  112. indexRows, err := db.QueryContext(ctx, `SELECT indexname FROM pg_indexes
  113. WHERE schemaname='public' AND tablename IN ('prompt_audit_jobs','prompt_audit_events')`)
  114. require.NoError(t, err)
  115. defer func() { _ = indexRows.Close() }()
  116. indexes := map[string]bool{}
  117. for indexRows.Next() {
  118. var name string
  119. require.NoError(t, indexRows.Scan(&name))
  120. indexes[name] = true
  121. }
  122. for _, name := range []string{
  123. "idx_prompt_audit_jobs_schedule", "idx_prompt_audit_jobs_request", "idx_prompt_audit_jobs_user_created",
  124. "idx_prompt_audit_jobs_api_key_created", "idx_prompt_audit_jobs_group_created", "idx_prompt_audit_jobs_prompt_hash",
  125. "idx_prompt_audit_jobs_created", "idx_prompt_audit_events_job", "idx_prompt_audit_events_request",
  126. "idx_prompt_audit_events_decision_created", "idx_prompt_audit_events_risk_created",
  127. "idx_prompt_audit_events_user_created", "idx_prompt_audit_events_api_key_created",
  128. "idx_prompt_audit_events_group_created", "idx_prompt_audit_events_prompt_hash", "idx_prompt_audit_events_created",
  129. } {
  130. require.Truef(t, indexes[name], "missing index %s", name)
  131. }
  132. _, err = db.ExecContext(ctx, `INSERT INTO prompt_audit_jobs(status) VALUES ('unknown')`)
  133. require.Error(t, err)
  134. _, err = db.ExecContext(ctx, `INSERT INTO prompt_audit_jobs(prompt_length) VALUES (-1)`)
  135. require.Error(t, err)
  136. var jobID int64
  137. require.NoError(t, db.QueryRowContext(ctx, `INSERT INTO prompt_audit_jobs DEFAULT VALUES RETURNING id`).Scan(&jobID))
  138. _, err = db.ExecContext(ctx, `INSERT INTO prompt_audit_events(job_id,chunk_total) VALUES ($1,-1)`, jobID)
  139. require.Error(t, err)
  140. }
  141. func TestPromptAuditDatabasePersistsFullPromptOnEventsOnly(t *testing.T) {
  142. db := openPromptAuditIntegrationDB(t)
  143. repo := NewPostgreSQLRepository(db)
  144. ctx := context.Background()
  145. const promptCanary = "PROMPT_AUDIT_CANARY_SECRET_DO_NOT_PERSIST"
  146. request := Request{
  147. RequestID: "canary-request", Provider: "openai",
  148. Endpoint: "/v1/chat/completions", Protocol: "openai_chat", Model: "gpt-test", Stage: "http",
  149. Body: []byte(`{"messages":[{"role":"user","content":"` + promptCanary + `"}]}`),
  150. }
  151. snapshot, err := ExtractPromptSnapshot(request)
  152. require.NoError(t, err)
  153. require.NotContains(t, snapshot.RedactedPreview, promptCanary)
  154. require.Contains(t, snapshot.FullPrompt, promptCanary)
  155. event, err := repo.RecordBlocking(ctx, snapshot.Redacted(), 1, integrationResult(EventCritical), true)
  156. require.NoError(t, err)
  157. // The event intentionally retains the full prompt for admin review; the
  158. // redacted preview and transient job row still never contain it.
  159. adminJSON, err := json.Marshal(event)
  160. require.NoError(t, err)
  161. require.Contains(t, string(adminJSON), promptCanary)
  162. require.NotContains(t, event.Snapshot.RedactedPreview, promptCanary)
  163. var storedFullPrompt string
  164. require.NoError(t, db.QueryRow(`SELECT full_prompt FROM prompt_audit_events WHERE id=$1`, event.ID).Scan(&storedFullPrompt))
  165. require.Contains(t, storedFullPrompt, promptCanary)
  166. detail, err := repo.GetEvent(ctx, event.ID)
  167. require.NoError(t, err)
  168. require.Contains(t, detail.Snapshot.FullPrompt, promptCanary)
  169. var jobJSON string
  170. require.NoError(t, db.QueryRow(`SELECT row_to_json(j)::text FROM prompt_audit_jobs j WHERE id=$1`, event.JobID).Scan(&jobJSON))
  171. require.NotContains(t, jobJSON, promptCanary)
  172. failedJob, err := repo.CreateStagingWithCapacity(ctx, integrationSnapshot("error"), 1, 3, 10)
  173. require.NoError(t, err)
  174. const errorCanary = "GUARD_RAW_RESPONSE_CANARY_SECRET"
  175. require.NoError(t, repo.MarkStagingFailed(ctx, failedJob.ID, "payload_store_failed", "raw guard body: "+errorCanary))
  176. var code, message string
  177. require.NoError(t, db.QueryRow(`SELECT last_error_code,last_error_message FROM prompt_audit_jobs WHERE id=$1`, failedJob.ID).Scan(&code, &message))
  178. require.Equal(t, "payload_store_failed", code)
  179. require.Equal(t, stableErrorMessage(code), message)
  180. require.NotContains(t, message, errorCanary)
  181. require.LessOrEqual(t, len([]rune(message)), 160)
  182. }
  183. func TestPromptAuditRepositoryAdmissionClaimFencingAndEventTransaction(t *testing.T) {
  184. db := openPromptAuditIntegrationDB(t)
  185. repo := NewPostgreSQLRepository(db)
  186. ctx := context.Background()
  187. start := make(chan struct{})
  188. type admissionResult struct {
  189. job *Job
  190. err error
  191. }
  192. results := make(chan admissionResult, 2)
  193. var wg sync.WaitGroup
  194. for i := 0; i < 2; i++ {
  195. wg.Add(1)
  196. go func(index int) {
  197. defer wg.Done()
  198. <-start
  199. job, err := repo.CreateStagingWithCapacity(ctx, integrationSnapshot(string(rune('a'+index))), 1, 3, 1)
  200. results <- admissionResult{job: job, err: err}
  201. }(i)
  202. }
  203. close(start)
  204. wg.Wait()
  205. close(results)
  206. var accepted *Job
  207. rejected := 0
  208. for result := range results {
  209. if result.err == nil {
  210. require.Nil(t, accepted)
  211. accepted = result.job
  212. continue
  213. }
  214. require.True(t, errors.Is(result.err, ErrQueueFull) || errors.Is(result.err, ErrQueueAdmissionBusy))
  215. rejected++
  216. }
  217. require.NotNil(t, accepted)
  218. require.Equal(t, 1, rejected)
  219. stats, err := repo.QueueStats(ctx)
  220. require.NoError(t, err)
  221. require.Equal(t, int64(1), stats.Active)
  222. require.NoError(t, repo.PublishQueued(ctx, accepted.ID))
  223. claimStart := make(chan struct{})
  224. claims := make(chan *Job, 2)
  225. for i := 0; i < 2; i++ {
  226. wg.Add(1)
  227. go func() {
  228. defer wg.Done()
  229. <-claimStart
  230. job, claimed, claimErr := repo.ClaimNextJob(ctx, time.Now().Add(time.Second))
  231. require.NoError(t, claimErr)
  232. if claimed {
  233. claims <- job
  234. }
  235. }()
  236. }
  237. close(claimStart)
  238. wg.Wait()
  239. close(claims)
  240. claimedJobs := make([]*Job, 0, 1)
  241. for job := range claims {
  242. claimedJobs = append(claimedJobs, job)
  243. }
  244. require.Len(t, claimedJobs, 1)
  245. firstClaim := claimedJobs[0]
  246. require.Equal(t, int64(1), firstClaim.ClaimVersion)
  247. reclaimed, err := repo.ReclaimStale(ctx, time.Now().Add(time.Hour), time.Now().Add(time.Hour), 10)
  248. require.NoError(t, err)
  249. require.Equal(t, int64(1), reclaimed)
  250. secondClaim, claimed, err := repo.ClaimNextJob(ctx, time.Now().Add(time.Second))
  251. require.NoError(t, err)
  252. require.True(t, claimed)
  253. require.Greater(t, secondClaim.ClaimVersion, firstClaim.ClaimVersion)
  254. require.ErrorIs(t, repo.RefreshLease(ctx, firstClaim.ID, firstClaim.ClaimVersion, time.Now()), ErrLeaseLost)
  255. _, err = repo.Complete(ctx, firstClaim, integrationResult(EventCritical), true)
  256. require.ErrorIs(t, err, ErrLeaseLost)
  257. event, err := repo.Complete(ctx, secondClaim, integrationResult(EventCritical), true)
  258. require.NoError(t, err)
  259. require.NotNil(t, event)
  260. var status string
  261. var eventCount int
  262. require.NoError(t, db.QueryRow(`SELECT status FROM prompt_audit_jobs WHERE id=$1`, secondClaim.ID).Scan(&status))
  263. require.Equal(t, "done", status)
  264. require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM prompt_audit_events WHERE job_id=$1`, secondClaim.ID).Scan(&eventCount))
  265. require.Equal(t, 1, eventCount)
  266. staging, err := repo.CreateStagingWithCapacity(ctx, integrationSnapshot("stale"), 1, 3, 10)
  267. require.NoError(t, err)
  268. reclaimed, err = repo.ReclaimStale(ctx, time.Now().Add(time.Hour), time.Now().Add(time.Hour), 10)
  269. require.NoError(t, err)
  270. require.Equal(t, int64(1), reclaimed)
  271. require.NoError(t, db.QueryRow(`SELECT status FROM prompt_audit_jobs WHERE id=$1`, staging.ID).Scan(&status))
  272. require.Equal(t, "failed", status)
  273. }
  274. func TestPromptAuditRepositoryForeignKeysFiltersAndStableIdentitySnapshots(t *testing.T) {
  275. db := openPromptAuditIntegrationDB(t)
  276. repo := NewPostgreSQLRepository(db)
  277. ctx := context.Background()
  278. userID := insertIdentity(t, db, "users")
  279. apiKeyID := insertIdentity(t, db, "api_keys")
  280. groupID := insertIdentity(t, db, "groups")
  281. snapshot := integrationSnapshot("identity")
  282. snapshot.UserID, snapshot.APIKeyID, snapshot.GroupID = userID, apiKeyID, &groupID
  283. event, err := repo.RecordBlocking(ctx, snapshot, 7, integrationResult(EventCritical), true)
  284. require.NoError(t, err)
  285. require.NotNil(t, event)
  286. start, end := time.Now().Add(-time.Hour), time.Now().Add(time.Hour)
  287. page, err := repo.ListEvents(ctx, EventFilter{
  288. Decision: string(EventCritical), RiskLevel: string(RiskCritical), Endpoint: snapshot.Endpoint,
  289. GroupID: &groupID, UserID: &userID, APIKeyID: &apiKeyID, RequestID: snapshot.RequestID,
  290. PromptHash: snapshot.PromptHash, Keyword: snapshot.UsernameSnapshot, StartAt: &start, EndAt: &end,
  291. }, 1, 10)
  292. require.NoError(t, err)
  293. require.Equal(t, int64(1), page.Total)
  294. require.Len(t, page.Items, 1)
  295. require.NotEmpty(t, page.Items[0].IssueSummaries)
  296. require.Equal(t, snapshot.UsernameSnapshot, page.Items[0].Snapshot.UsernameSnapshot)
  297. require.Equal(t, snapshot.UserEmailSnapshot, page.Items[0].Snapshot.UserEmailSnapshot)
  298. require.Equal(t, snapshot.APIKeyNameSnapshot, page.Items[0].Snapshot.APIKeyNameSnapshot)
  299. _, err = db.Exec(`DELETE FROM users WHERE id=$1`, userID)
  300. require.NoError(t, err)
  301. _, err = db.Exec(`DELETE FROM api_keys WHERE id=$1`, apiKeyID)
  302. require.NoError(t, err)
  303. _, err = db.Exec(`DELETE FROM groups WHERE id=$1`, groupID)
  304. require.NoError(t, err)
  305. stored, err := repo.GetEvent(ctx, event.ID)
  306. require.NoError(t, err)
  307. require.Zero(t, stored.Snapshot.UserID)
  308. require.Zero(t, stored.Snapshot.APIKeyID)
  309. require.Nil(t, stored.Snapshot.GroupID)
  310. require.Equal(t, snapshot.UsernameSnapshot, stored.Snapshot.UsernameSnapshot)
  311. require.Equal(t, snapshot.UserEmailSnapshot, stored.Snapshot.UserEmailSnapshot)
  312. require.Equal(t, snapshot.APIKeyNameSnapshot, stored.Snapshot.APIKeyNameSnapshot)
  313. _, err = db.Exec(`DELETE FROM prompt_audit_jobs WHERE id=$1`, event.JobID)
  314. require.NoError(t, err)
  315. _, err = repo.GetEvent(ctx, event.ID)
  316. require.ErrorIs(t, err, ErrEventNotFound)
  317. }
  318. func TestPromptAuditRepositoryHighWaterAndSafeDeletion(t *testing.T) {
  319. db := openPromptAuditIntegrationDB(t)
  320. repo := NewPostgreSQLRepository(db)
  321. ctx := context.Background()
  322. first, err := repo.RecordBlocking(ctx, integrationSnapshot("first"), 1, integrationResult(EventCritical), true)
  323. require.NoError(t, err)
  324. second, err := repo.RecordBlocking(ctx, integrationSnapshot("second"), 1, integrationResult(EventCritical), true)
  325. require.NoError(t, err)
  326. start, end := time.Now().Add(-time.Hour), time.Now().Add(time.Hour)
  327. filter := EventFilter{Decision: string(EventCritical), StartAt: &start, EndAt: &end}
  328. preview, err := repo.PreviewDelete(ctx, filter)
  329. require.NoError(t, err)
  330. require.Equal(t, int64(2), preview.MatchedCount)
  331. require.Equal(t, second.ID, preview.SnapshotMaxID)
  332. require.Equal(t, FilterHash(preview.FilterSummary, preview.SnapshotMaxID), preview.FilterHash)
  333. newer, err := repo.RecordBlocking(ctx, integrationSnapshot("newer"), 1, integrationResult(EventCritical), true)
  334. require.NoError(t, err)
  335. result, err := repo.DeleteEventsByFilter(ctx, filter, preview.SnapshotMaxID, 1)
  336. require.NoError(t, err)
  337. require.Equal(t, int64(2), result.DeletedEvents)
  338. require.Equal(t, int64(2), result.DeletedJobs)
  339. _, err = repo.GetEvent(ctx, first.ID)
  340. require.ErrorIs(t, err, ErrEventNotFound)
  341. _, err = repo.GetEvent(ctx, second.ID)
  342. require.ErrorIs(t, err, ErrEventNotFound)
  343. _, err = repo.GetEvent(ctx, newer.ID)
  344. require.NoError(t, err, "an event created after preview must survive high-water deletion")
  345. processingEvent, err := repo.RecordBlocking(ctx, integrationSnapshot("processing"), 1, integrationResult(EventCritical), true)
  346. require.NoError(t, err)
  347. _, err = db.Exec(`UPDATE prompt_audit_jobs SET status='processing' WHERE id=$1`, processingEvent.JobID)
  348. require.NoError(t, err)
  349. deleteResult, err := repo.DeleteEvent(ctx, processingEvent.ID)
  350. require.NoError(t, err)
  351. require.Equal(t, int64(1), deleteResult.DeletedEvents)
  352. require.Zero(t, deleteResult.DeletedJobs)
  353. var remaining int
  354. require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM prompt_audit_jobs WHERE id=$1`, processingEvent.JobID).Scan(&remaining))
  355. require.Equal(t, 1, remaining, "processing jobs must not be deleted as orphans")
  356. batchOne, err := repo.RecordBlocking(ctx, integrationSnapshot("batch-one"), 1, integrationResult(EventCritical), true)
  357. require.NoError(t, err)
  358. batchTwo, err := repo.RecordBlocking(ctx, integrationSnapshot("batch-two"), 1, integrationResult(EventCritical), true)
  359. require.NoError(t, err)
  360. ids := []int64{batchTwo.ID, batchOne.ID, batchOne.ID}
  361. sort.Slice(ids, func(i, j int) bool { return ids[i] > ids[j] })
  362. batchResult, err := repo.DeleteEventsByIDs(ctx, ids)
  363. require.NoError(t, err)
  364. require.Equal(t, int64(2), batchResult.DeletedEvents)
  365. }
  366. func TestPromptAuditServiceConfirmationKeepsPostPreviewEventsAndConcurrentDeletesAreSafe(t *testing.T) {
  367. db := openPromptAuditIntegrationDB(t)
  368. repo := NewPostgreSQLRepository(db)
  369. ctx := context.Background()
  370. now := time.Now().UTC()
  371. start, end := now.Add(-time.Hour), now.Add(time.Hour)
  372. filter := EventFilter{Decision: string(EventCritical), StartAt: &start, EndAt: &end}
  373. for i := 0; i < 12; i++ {
  374. _, err := repo.RecordBlocking(ctx, integrationSnapshot(fmt.Sprintf("event-%02d", i)), 1, integrationResult(EventCritical), true)
  375. require.NoError(t, err)
  376. }
  377. service := &PromptService{
  378. config: &fakeConfigStore{}, repo: repo, payload: NewRedisPayloadStore(nil), clock: fixedClock{now: now},
  379. }
  380. preview, err := service.PreviewDelete(ctx, filter, 77)
  381. require.NoError(t, err)
  382. require.Equal(t, int64(12), preview.MatchedCount)
  383. newer, err := repo.RecordBlocking(ctx, integrationSnapshot("post-preview"), 1, integrationResult(EventCritical), true)
  384. require.NoError(t, err)
  385. result, err := service.DeleteByFilter(ctx, DeleteByFilterRequest{
  386. Filter: filter, SnapshotMaxID: preview.SnapshotMaxID, FilterHash: preview.FilterHash,
  387. ConfirmationToken: preview.ConfirmationToken, Confirm: true,
  388. }, 77)
  389. require.NoError(t, err)
  390. require.Equal(t, int64(12), result.DeletedEvents)
  391. _, err = repo.GetEvent(ctx, newer.ID)
  392. require.NoError(t, err, "events created after delete-preview must survive")
  393. resetPromptAuditIntegrationDB(t, db)
  394. for i := 0; i < 24; i++ {
  395. _, err := repo.RecordBlocking(ctx, integrationSnapshot(fmt.Sprintf("race-%02d", i)), 1, integrationResult(EventCritical), true)
  396. require.NoError(t, err)
  397. }
  398. preview, err = repo.PreviewDelete(ctx, filter)
  399. require.NoError(t, err)
  400. type deleteOutcome struct {
  401. result *DeleteResult
  402. err error
  403. }
  404. outcomes := make(chan deleteOutcome, 2)
  405. var wg sync.WaitGroup
  406. for i := 0; i < 2; i++ {
  407. wg.Add(1)
  408. go func() {
  409. defer wg.Done()
  410. deleted, deleteErr := repo.DeleteEventsByFilter(ctx, filter, preview.SnapshotMaxID, 1)
  411. outcomes <- deleteOutcome{result: deleted, err: deleteErr}
  412. }()
  413. }
  414. wg.Wait()
  415. close(outcomes)
  416. var deletedTotal int64
  417. for outcome := range outcomes {
  418. require.NoError(t, outcome.err)
  419. require.NotNil(t, outcome.result)
  420. deletedTotal += outcome.result.DeletedEvents
  421. }
  422. require.Equal(t, int64(24), deletedTotal, "concurrent deleters must neither double-count nor strand matching events")
  423. remaining, err := repo.ListEvents(ctx, filter, 1, 100)
  424. require.NoError(t, err)
  425. require.Zero(t, remaining.Total)
  426. }