Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
463 lines
19 KiB
Go
463 lines
19 KiB
Go
package securityaudit
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
_ "github.com/lib/pq"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
const promptAuditPostgresTestEnv = "PROMPT_AUDIT_TEST_POSTGRES_DSN"
|
|
|
|
func openPromptAuditIntegrationDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
dsn := strings.TrimSpace(os.Getenv(promptAuditPostgresTestEnv))
|
|
if dsn == "" {
|
|
t.Skip(promptAuditPostgresTestEnv + " is not set")
|
|
}
|
|
db, err := sql.Open("postgres", dsn)
|
|
require.NoError(t, err)
|
|
db.SetMaxOpenConns(16)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
require.NoError(t, db.PingContext(ctx))
|
|
_, err = db.ExecContext(ctx, `
|
|
CREATE TABLE IF NOT EXISTS users (id BIGSERIAL PRIMARY KEY);
|
|
CREATE TABLE IF NOT EXISTS groups (id BIGSERIAL PRIMARY KEY);
|
|
CREATE TABLE IF NOT EXISTS api_keys (id BIGSERIAL PRIMARY KEY);
|
|
CREATE TABLE IF NOT EXISTS settings (
|
|
key VARCHAR(255) PRIMARY KEY,
|
|
value TEXT NOT NULL DEFAULT '',
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
`)
|
|
require.NoError(t, err)
|
|
for _, name := range []string{"181_prompt_audit.sql", "182_prompt_audit_full_prompt.sql"} {
|
|
migration, err := os.ReadFile(filepath.Join("..", "..", "migrations", name))
|
|
require.NoError(t, err)
|
|
// The migration runner can retry an interrupted deployment; the migration
|
|
// must therefore be safe to execute more than once.
|
|
_, err = db.ExecContext(ctx, string(migration))
|
|
require.NoError(t, err)
|
|
_, err = db.ExecContext(ctx, string(migration))
|
|
require.NoError(t, err)
|
|
}
|
|
t.Cleanup(func() { require.NoError(t, db.Close()) })
|
|
resetPromptAuditIntegrationDB(t, db)
|
|
return db
|
|
}
|
|
|
|
func resetPromptAuditIntegrationDB(t *testing.T, db *sql.DB) {
|
|
t.Helper()
|
|
_, err := db.Exec(`TRUNCATE TABLE prompt_audit_events, prompt_audit_jobs, api_keys, users, groups, settings RESTART IDENTITY CASCADE`)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func insertIdentity(t *testing.T, db *sql.DB, table string) int64 {
|
|
t.Helper()
|
|
var id int64
|
|
require.NoError(t, db.QueryRow(`INSERT INTO `+table+` DEFAULT VALUES RETURNING id`).Scan(&id))
|
|
return id
|
|
}
|
|
|
|
func integrationSnapshot(seed string) PromptSnapshot {
|
|
return PromptSnapshot{
|
|
RequestID: "request-" + seed, UsernameSnapshot: "user-" + seed,
|
|
UserEmailSnapshot: "user-" + seed + "@example.test", APIKeyNameSnapshot: "key-" + seed,
|
|
GroupName: "group-" + seed, Provider: "openai", Endpoint: "/v1/chat/completions",
|
|
Protocol: "openai_chat", Model: "gpt-test", PromptHash: strings.Repeat(seed[:1], 64),
|
|
RedactedPreview: "redacted-" + seed, PromptLength: len([]rune(seed)), MessageCount: 1,
|
|
}
|
|
}
|
|
|
|
func integrationResult(decision EventDecision) *NormalizedResult {
|
|
result := &NormalizedResult{
|
|
Decision: decision, RiskLevel: RiskLow, Action: ActionAllow, Safety: "Safe",
|
|
Categories: []string{}, MatchedScanners: []string{}, ScannerScores: map[string]float64{},
|
|
ScannerEvidence: map[string]string{}, ScannerBackend: "qwen3guard-openai",
|
|
ScannerVersion: "test", GuardEndpointID: "guard-1", PolicyID: "priority",
|
|
PolicyVersion: 1, ChunkTotal: 1, LatencyMS: 2,
|
|
}
|
|
if decision != EventPass {
|
|
result.RiskLevel = RiskCritical
|
|
result.Action = ActionBlock
|
|
result.Safety = "Unsafe"
|
|
result.Categories = []string{"pii"}
|
|
result.MatchedScanners = []string{"pii"}
|
|
result.ScannerScores["pii"] = 1
|
|
result.ScannerEvidence["pii"] = "redacted evidence"
|
|
}
|
|
return result
|
|
}
|
|
|
|
func TestPromptAuditMigrationSchemaAndLeakageGate(t *testing.T) {
|
|
db := openPromptAuditIntegrationDB(t)
|
|
ctx := context.Background()
|
|
|
|
rows, err := db.QueryContext(ctx, `SELECT table_name, column_name FROM information_schema.columns
|
|
WHERE table_schema='public' AND table_name IN ('prompt_audit_jobs','prompt_audit_events')`)
|
|
require.NoError(t, err)
|
|
defer func() { _ = rows.Close() }()
|
|
forbidden := []string{"raw_prompt", "raw_request", "payload", "token", "authorization", "credential", "ciphertext"}
|
|
for rows.Next() {
|
|
var tableName, columnName string
|
|
require.NoError(t, rows.Scan(&tableName, &columnName))
|
|
lower := strings.ToLower(columnName)
|
|
for _, word := range forbidden {
|
|
require.NotContainsf(t, lower, word, "%s.%s is a forbidden raw/credential column", tableName, columnName)
|
|
}
|
|
}
|
|
require.NoError(t, rows.Err())
|
|
|
|
indexRows, err := db.QueryContext(ctx, `SELECT indexname FROM pg_indexes
|
|
WHERE schemaname='public' AND tablename IN ('prompt_audit_jobs','prompt_audit_events')`)
|
|
require.NoError(t, err)
|
|
defer func() { _ = indexRows.Close() }()
|
|
indexes := map[string]bool{}
|
|
for indexRows.Next() {
|
|
var name string
|
|
require.NoError(t, indexRows.Scan(&name))
|
|
indexes[name] = true
|
|
}
|
|
for _, name := range []string{
|
|
"idx_prompt_audit_jobs_schedule", "idx_prompt_audit_jobs_request", "idx_prompt_audit_jobs_user_created",
|
|
"idx_prompt_audit_jobs_api_key_created", "idx_prompt_audit_jobs_group_created", "idx_prompt_audit_jobs_prompt_hash",
|
|
"idx_prompt_audit_jobs_created", "idx_prompt_audit_events_job", "idx_prompt_audit_events_request",
|
|
"idx_prompt_audit_events_decision_created", "idx_prompt_audit_events_risk_created",
|
|
"idx_prompt_audit_events_user_created", "idx_prompt_audit_events_api_key_created",
|
|
"idx_prompt_audit_events_group_created", "idx_prompt_audit_events_prompt_hash", "idx_prompt_audit_events_created",
|
|
} {
|
|
require.Truef(t, indexes[name], "missing index %s", name)
|
|
}
|
|
|
|
_, err = db.ExecContext(ctx, `INSERT INTO prompt_audit_jobs(status) VALUES ('unknown')`)
|
|
require.Error(t, err)
|
|
_, err = db.ExecContext(ctx, `INSERT INTO prompt_audit_jobs(prompt_length) VALUES (-1)`)
|
|
require.Error(t, err)
|
|
var jobID int64
|
|
require.NoError(t, db.QueryRowContext(ctx, `INSERT INTO prompt_audit_jobs DEFAULT VALUES RETURNING id`).Scan(&jobID))
|
|
_, err = db.ExecContext(ctx, `INSERT INTO prompt_audit_events(job_id,chunk_total) VALUES ($1,-1)`, jobID)
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestPromptAuditDatabasePersistsFullPromptOnEventsOnly(t *testing.T) {
|
|
db := openPromptAuditIntegrationDB(t)
|
|
repo := NewPostgreSQLRepository(db)
|
|
ctx := context.Background()
|
|
const promptCanary = "PROMPT_AUDIT_CANARY_SECRET_DO_NOT_PERSIST"
|
|
request := Request{
|
|
RequestID: "canary-request", Provider: "openai",
|
|
Endpoint: "/v1/chat/completions", Protocol: "openai_chat", Model: "gpt-test", Stage: "http",
|
|
Body: []byte(`{"messages":[{"role":"user","content":"` + promptCanary + `"}]}`),
|
|
}
|
|
snapshot, err := ExtractPromptSnapshot(request)
|
|
require.NoError(t, err)
|
|
require.NotContains(t, snapshot.RedactedPreview, promptCanary)
|
|
require.Contains(t, snapshot.FullPrompt, promptCanary)
|
|
event, err := repo.RecordBlocking(ctx, snapshot.Redacted(), 1, integrationResult(EventCritical), true)
|
|
require.NoError(t, err)
|
|
// The event intentionally retains the full prompt for admin review; the
|
|
// redacted preview and transient job row still never contain it.
|
|
adminJSON, err := json.Marshal(event)
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(adminJSON), promptCanary)
|
|
require.NotContains(t, event.Snapshot.RedactedPreview, promptCanary)
|
|
|
|
var storedFullPrompt string
|
|
require.NoError(t, db.QueryRow(`SELECT full_prompt FROM prompt_audit_events WHERE id=$1`, event.ID).Scan(&storedFullPrompt))
|
|
require.Contains(t, storedFullPrompt, promptCanary)
|
|
|
|
detail, err := repo.GetEvent(ctx, event.ID)
|
|
require.NoError(t, err)
|
|
require.Contains(t, detail.Snapshot.FullPrompt, promptCanary)
|
|
|
|
var jobJSON string
|
|
require.NoError(t, db.QueryRow(`SELECT row_to_json(j)::text FROM prompt_audit_jobs j WHERE id=$1`, event.JobID).Scan(&jobJSON))
|
|
require.NotContains(t, jobJSON, promptCanary)
|
|
|
|
failedJob, err := repo.CreateStagingWithCapacity(ctx, integrationSnapshot("error"), 1, 3, 10)
|
|
require.NoError(t, err)
|
|
const errorCanary = "GUARD_RAW_RESPONSE_CANARY_SECRET"
|
|
require.NoError(t, repo.MarkStagingFailed(ctx, failedJob.ID, "payload_store_failed", "raw guard body: "+errorCanary))
|
|
var code, message string
|
|
require.NoError(t, db.QueryRow(`SELECT last_error_code,last_error_message FROM prompt_audit_jobs WHERE id=$1`, failedJob.ID).Scan(&code, &message))
|
|
require.Equal(t, "payload_store_failed", code)
|
|
require.Equal(t, stableErrorMessage(code), message)
|
|
require.NotContains(t, message, errorCanary)
|
|
require.LessOrEqual(t, len([]rune(message)), 160)
|
|
}
|
|
|
|
func TestPromptAuditRepositoryAdmissionClaimFencingAndEventTransaction(t *testing.T) {
|
|
db := openPromptAuditIntegrationDB(t)
|
|
repo := NewPostgreSQLRepository(db)
|
|
ctx := context.Background()
|
|
|
|
start := make(chan struct{})
|
|
type admissionResult struct {
|
|
job *Job
|
|
err error
|
|
}
|
|
results := make(chan admissionResult, 2)
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 2; i++ {
|
|
wg.Add(1)
|
|
go func(index int) {
|
|
defer wg.Done()
|
|
<-start
|
|
job, err := repo.CreateStagingWithCapacity(ctx, integrationSnapshot(string(rune('a'+index))), 1, 3, 1)
|
|
results <- admissionResult{job: job, err: err}
|
|
}(i)
|
|
}
|
|
close(start)
|
|
wg.Wait()
|
|
close(results)
|
|
var accepted *Job
|
|
rejected := 0
|
|
for result := range results {
|
|
if result.err == nil {
|
|
require.Nil(t, accepted)
|
|
accepted = result.job
|
|
continue
|
|
}
|
|
require.True(t, errors.Is(result.err, ErrQueueFull) || errors.Is(result.err, ErrQueueAdmissionBusy))
|
|
rejected++
|
|
}
|
|
require.NotNil(t, accepted)
|
|
require.Equal(t, 1, rejected)
|
|
stats, err := repo.QueueStats(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(1), stats.Active)
|
|
require.NoError(t, repo.PublishQueued(ctx, accepted.ID))
|
|
|
|
claimStart := make(chan struct{})
|
|
claims := make(chan *Job, 2)
|
|
for i := 0; i < 2; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
<-claimStart
|
|
job, claimed, claimErr := repo.ClaimNextJob(ctx, time.Now().Add(time.Second))
|
|
require.NoError(t, claimErr)
|
|
if claimed {
|
|
claims <- job
|
|
}
|
|
}()
|
|
}
|
|
close(claimStart)
|
|
wg.Wait()
|
|
close(claims)
|
|
claimedJobs := make([]*Job, 0, 1)
|
|
for job := range claims {
|
|
claimedJobs = append(claimedJobs, job)
|
|
}
|
|
require.Len(t, claimedJobs, 1)
|
|
firstClaim := claimedJobs[0]
|
|
require.Equal(t, int64(1), firstClaim.ClaimVersion)
|
|
|
|
reclaimed, err := repo.ReclaimStale(ctx, time.Now().Add(time.Hour), time.Now().Add(time.Hour), 10)
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(1), reclaimed)
|
|
secondClaim, claimed, err := repo.ClaimNextJob(ctx, time.Now().Add(time.Second))
|
|
require.NoError(t, err)
|
|
require.True(t, claimed)
|
|
require.Greater(t, secondClaim.ClaimVersion, firstClaim.ClaimVersion)
|
|
require.ErrorIs(t, repo.RefreshLease(ctx, firstClaim.ID, firstClaim.ClaimVersion, time.Now()), ErrLeaseLost)
|
|
_, err = repo.Complete(ctx, firstClaim, integrationResult(EventCritical), true)
|
|
require.ErrorIs(t, err, ErrLeaseLost)
|
|
|
|
event, err := repo.Complete(ctx, secondClaim, integrationResult(EventCritical), true)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, event)
|
|
var status string
|
|
var eventCount int
|
|
require.NoError(t, db.QueryRow(`SELECT status FROM prompt_audit_jobs WHERE id=$1`, secondClaim.ID).Scan(&status))
|
|
require.Equal(t, "done", status)
|
|
require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM prompt_audit_events WHERE job_id=$1`, secondClaim.ID).Scan(&eventCount))
|
|
require.Equal(t, 1, eventCount)
|
|
|
|
staging, err := repo.CreateStagingWithCapacity(ctx, integrationSnapshot("stale"), 1, 3, 10)
|
|
require.NoError(t, err)
|
|
reclaimed, err = repo.ReclaimStale(ctx, time.Now().Add(time.Hour), time.Now().Add(time.Hour), 10)
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(1), reclaimed)
|
|
require.NoError(t, db.QueryRow(`SELECT status FROM prompt_audit_jobs WHERE id=$1`, staging.ID).Scan(&status))
|
|
require.Equal(t, "failed", status)
|
|
}
|
|
|
|
func TestPromptAuditRepositoryForeignKeysFiltersAndStableIdentitySnapshots(t *testing.T) {
|
|
db := openPromptAuditIntegrationDB(t)
|
|
repo := NewPostgreSQLRepository(db)
|
|
ctx := context.Background()
|
|
userID := insertIdentity(t, db, "users")
|
|
apiKeyID := insertIdentity(t, db, "api_keys")
|
|
groupID := insertIdentity(t, db, "groups")
|
|
snapshot := integrationSnapshot("identity")
|
|
snapshot.UserID, snapshot.APIKeyID, snapshot.GroupID = userID, apiKeyID, &groupID
|
|
event, err := repo.RecordBlocking(ctx, snapshot, 7, integrationResult(EventCritical), true)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, event)
|
|
|
|
start, end := time.Now().Add(-time.Hour), time.Now().Add(time.Hour)
|
|
page, err := repo.ListEvents(ctx, EventFilter{
|
|
Decision: string(EventCritical), RiskLevel: string(RiskCritical), Endpoint: snapshot.Endpoint,
|
|
GroupID: &groupID, UserID: &userID, APIKeyID: &apiKeyID, RequestID: snapshot.RequestID,
|
|
PromptHash: snapshot.PromptHash, Keyword: snapshot.UsernameSnapshot, StartAt: &start, EndAt: &end,
|
|
}, 1, 10)
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(1), page.Total)
|
|
require.Len(t, page.Items, 1)
|
|
require.NotEmpty(t, page.Items[0].IssueSummaries)
|
|
require.Equal(t, snapshot.UsernameSnapshot, page.Items[0].Snapshot.UsernameSnapshot)
|
|
require.Equal(t, snapshot.UserEmailSnapshot, page.Items[0].Snapshot.UserEmailSnapshot)
|
|
require.Equal(t, snapshot.APIKeyNameSnapshot, page.Items[0].Snapshot.APIKeyNameSnapshot)
|
|
|
|
_, err = db.Exec(`DELETE FROM users WHERE id=$1`, userID)
|
|
require.NoError(t, err)
|
|
_, err = db.Exec(`DELETE FROM api_keys WHERE id=$1`, apiKeyID)
|
|
require.NoError(t, err)
|
|
_, err = db.Exec(`DELETE FROM groups WHERE id=$1`, groupID)
|
|
require.NoError(t, err)
|
|
stored, err := repo.GetEvent(ctx, event.ID)
|
|
require.NoError(t, err)
|
|
require.Zero(t, stored.Snapshot.UserID)
|
|
require.Zero(t, stored.Snapshot.APIKeyID)
|
|
require.Nil(t, stored.Snapshot.GroupID)
|
|
require.Equal(t, snapshot.UsernameSnapshot, stored.Snapshot.UsernameSnapshot)
|
|
require.Equal(t, snapshot.UserEmailSnapshot, stored.Snapshot.UserEmailSnapshot)
|
|
require.Equal(t, snapshot.APIKeyNameSnapshot, stored.Snapshot.APIKeyNameSnapshot)
|
|
|
|
_, err = db.Exec(`DELETE FROM prompt_audit_jobs WHERE id=$1`, event.JobID)
|
|
require.NoError(t, err)
|
|
_, err = repo.GetEvent(ctx, event.ID)
|
|
require.ErrorIs(t, err, ErrEventNotFound)
|
|
}
|
|
|
|
func TestPromptAuditRepositoryHighWaterAndSafeDeletion(t *testing.T) {
|
|
db := openPromptAuditIntegrationDB(t)
|
|
repo := NewPostgreSQLRepository(db)
|
|
ctx := context.Background()
|
|
first, err := repo.RecordBlocking(ctx, integrationSnapshot("first"), 1, integrationResult(EventCritical), true)
|
|
require.NoError(t, err)
|
|
second, err := repo.RecordBlocking(ctx, integrationSnapshot("second"), 1, integrationResult(EventCritical), true)
|
|
require.NoError(t, err)
|
|
start, end := time.Now().Add(-time.Hour), time.Now().Add(time.Hour)
|
|
filter := EventFilter{Decision: string(EventCritical), StartAt: &start, EndAt: &end}
|
|
preview, err := repo.PreviewDelete(ctx, filter)
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(2), preview.MatchedCount)
|
|
require.Equal(t, second.ID, preview.SnapshotMaxID)
|
|
require.Equal(t, FilterHash(preview.FilterSummary, preview.SnapshotMaxID), preview.FilterHash)
|
|
|
|
newer, err := repo.RecordBlocking(ctx, integrationSnapshot("newer"), 1, integrationResult(EventCritical), true)
|
|
require.NoError(t, err)
|
|
result, err := repo.DeleteEventsByFilter(ctx, filter, preview.SnapshotMaxID, 1)
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(2), result.DeletedEvents)
|
|
require.Equal(t, int64(2), result.DeletedJobs)
|
|
_, err = repo.GetEvent(ctx, first.ID)
|
|
require.ErrorIs(t, err, ErrEventNotFound)
|
|
_, err = repo.GetEvent(ctx, second.ID)
|
|
require.ErrorIs(t, err, ErrEventNotFound)
|
|
_, err = repo.GetEvent(ctx, newer.ID)
|
|
require.NoError(t, err, "an event created after preview must survive high-water deletion")
|
|
|
|
processingEvent, err := repo.RecordBlocking(ctx, integrationSnapshot("processing"), 1, integrationResult(EventCritical), true)
|
|
require.NoError(t, err)
|
|
_, err = db.Exec(`UPDATE prompt_audit_jobs SET status='processing' WHERE id=$1`, processingEvent.JobID)
|
|
require.NoError(t, err)
|
|
deleteResult, err := repo.DeleteEvent(ctx, processingEvent.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(1), deleteResult.DeletedEvents)
|
|
require.Zero(t, deleteResult.DeletedJobs)
|
|
var remaining int
|
|
require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM prompt_audit_jobs WHERE id=$1`, processingEvent.JobID).Scan(&remaining))
|
|
require.Equal(t, 1, remaining, "processing jobs must not be deleted as orphans")
|
|
|
|
batchOne, err := repo.RecordBlocking(ctx, integrationSnapshot("batch-one"), 1, integrationResult(EventCritical), true)
|
|
require.NoError(t, err)
|
|
batchTwo, err := repo.RecordBlocking(ctx, integrationSnapshot("batch-two"), 1, integrationResult(EventCritical), true)
|
|
require.NoError(t, err)
|
|
ids := []int64{batchTwo.ID, batchOne.ID, batchOne.ID}
|
|
sort.Slice(ids, func(i, j int) bool { return ids[i] > ids[j] })
|
|
batchResult, err := repo.DeleteEventsByIDs(ctx, ids)
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(2), batchResult.DeletedEvents)
|
|
}
|
|
|
|
func TestPromptAuditServiceConfirmationKeepsPostPreviewEventsAndConcurrentDeletesAreSafe(t *testing.T) {
|
|
db := openPromptAuditIntegrationDB(t)
|
|
repo := NewPostgreSQLRepository(db)
|
|
ctx := context.Background()
|
|
now := time.Now().UTC()
|
|
start, end := now.Add(-time.Hour), now.Add(time.Hour)
|
|
filter := EventFilter{Decision: string(EventCritical), StartAt: &start, EndAt: &end}
|
|
|
|
for i := 0; i < 12; i++ {
|
|
_, err := repo.RecordBlocking(ctx, integrationSnapshot(fmt.Sprintf("event-%02d", i)), 1, integrationResult(EventCritical), true)
|
|
require.NoError(t, err)
|
|
}
|
|
service := &PromptService{
|
|
config: &fakeConfigStore{}, repo: repo, payload: NewRedisPayloadStore(nil), clock: fixedClock{now: now},
|
|
}
|
|
preview, err := service.PreviewDelete(ctx, filter, 77)
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(12), preview.MatchedCount)
|
|
|
|
newer, err := repo.RecordBlocking(ctx, integrationSnapshot("post-preview"), 1, integrationResult(EventCritical), true)
|
|
require.NoError(t, err)
|
|
result, err := service.DeleteByFilter(ctx, DeleteByFilterRequest{
|
|
Filter: filter, SnapshotMaxID: preview.SnapshotMaxID, FilterHash: preview.FilterHash,
|
|
ConfirmationToken: preview.ConfirmationToken, Confirm: true,
|
|
}, 77)
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(12), result.DeletedEvents)
|
|
_, err = repo.GetEvent(ctx, newer.ID)
|
|
require.NoError(t, err, "events created after delete-preview must survive")
|
|
|
|
resetPromptAuditIntegrationDB(t, db)
|
|
for i := 0; i < 24; i++ {
|
|
_, err := repo.RecordBlocking(ctx, integrationSnapshot(fmt.Sprintf("race-%02d", i)), 1, integrationResult(EventCritical), true)
|
|
require.NoError(t, err)
|
|
}
|
|
preview, err = repo.PreviewDelete(ctx, filter)
|
|
require.NoError(t, err)
|
|
|
|
type deleteOutcome struct {
|
|
result *DeleteResult
|
|
err error
|
|
}
|
|
outcomes := make(chan deleteOutcome, 2)
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 2; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
deleted, deleteErr := repo.DeleteEventsByFilter(ctx, filter, preview.SnapshotMaxID, 1)
|
|
outcomes <- deleteOutcome{result: deleted, err: deleteErr}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
close(outcomes)
|
|
var deletedTotal int64
|
|
for outcome := range outcomes {
|
|
require.NoError(t, outcome.err)
|
|
require.NotNil(t, outcome.result)
|
|
deletedTotal += outcome.result.DeletedEvents
|
|
}
|
|
require.Equal(t, int64(24), deletedTotal, "concurrent deleters must neither double-count nor strand matching events")
|
|
remaining, err := repo.ListEvents(ctx, filter, 1, 100)
|
|
require.NoError(t, err)
|
|
require.Zero(t, remaining.Total)
|
|
}
|