diff --git a/internal/jobs/billing_reconciler_integration_test.go b/internal/jobs/billing_reconciler_integration_test.go new file mode 100644 index 0000000..7b3f540 --- /dev/null +++ b/internal/jobs/billing_reconciler_integration_test.go @@ -0,0 +1,151 @@ +package jobs + +// billing_reconciler_integration_test.go — REAL-Postgres integration test for +// the billing reconciler's terminal-downgrade round-trip. +// +// Per INTEGRATION-COVERAGE-PLAN-2026-06-04.md §5 #1, billing_reconciler is a +// Tier-1 (money-adjacent) job whose DB round-trip was fake-tested only — the +// sibling billing_reconciler_test.go drives Work() with a stubGrace and asserts +// the stub's call counts, never the persisted teams row. THIS test seeds a real +// paid (pro) team carrying a Razorpay subscription id, runs Work() against a +// live platform Postgres with a stub fetcher that reports the subscription +// `cancelled` (a terminal status), and asserts the worker actually wrote: +// +// - teams.plan_tier flipped pro → "hobby" (terminalDowngradeTier — never the +// ephemeral "free" tier; the cross-repo contract D28 F1), AND +// - a subscription.canceled audit_log row was emitted for the team (the +// event the email forwarder dispatches the cancellation email from). +// +// The Razorpay leg stays stubbed (no live Razorpay — CLAUDE P0: recurring not +// enabled, plan §4 BLOCKED-bypass: drive the state directly). The DB leg — +// the candidate SELECT, the UpdatePlanTier UPDATE, the audit INSERT — is REAL. +// This is the convergence a sqlmock test cannot prove: that the downgrade +// UPDATE's WHERE id = $2 matched the live row and the audit row landed. +// +// Determinism on a shared local DB: the reconciler scans EVERY team with a +// non-empty stripe_customer_id, not just ours. The stub fetcher therefore +// keys on subscription_id — it returns `cancelled` ONLY for the seeded team's +// subscription and a benign `active`-at-hobby (a no-op for any already-hobby +// team) for everything else, so the sweep cannot mutate unrelated rows in a way +// that perturbs this assertion. grace is left nil so the real dbGracePeriodOpener +// runs against the live DB (its TerminateActiveGracePeriod is an idempotent +// no-op when the team has no active grace row). +// +// GATING: testhelpers.SetupTestDB skips under -short / no-DB, so the regular +// `make gate` stays green without a Postgres; the non-short CI integration job +// runs it for real. + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/riverqueue/river" + "github.com/riverqueue/river/rivertype" + + "instant.dev/worker/internal/testhelpers" +) + +// subKeyedFetcher is a subscriptionFetcher that returns a caller-supplied +// status for ONE target subscription id and a benign no-op status (`active` at +// the lowest paid tier) for every other id. Keyed-by-sub so the reconciler's +// full-table sweep cannot perturb the assertion on a shared DB. +type subKeyedFetcher struct { + targetSub string + targetStatus string + planID string +} + +func (f *subKeyedFetcher) FetchSubscriptionForReconciler(_ context.Context, subID string) (*reconcilerSubscriptionDetails, error) { + if subID == f.targetSub { + return &reconcilerSubscriptionDetails{ + Status: f.targetStatus, + PlanID: f.planID, + PaidCount: 1, + }, nil + } + // Any other team in the shared DB: report `created` — a guaranteed + // no-action status (rzpStatusClassNoAction). This is strictly safer than + // `active`, which (with an unrecognised plan_id) resolves to "hobby" and + // could upgrade an anonymous/free team that happens to carry a + // subscription id in a polluted local DB. `created` mutates nothing, so + // the sweep touches ONLY the seeded target team. + return &reconcilerSubscriptionDetails{Status: "created", PlanID: "", PaidCount: 0}, nil +} + +func fakeBillingJob() *river.Job[BillingReconcilerArgs] { + return &river.Job[BillingReconcilerArgs]{JobRow: &rivertype.JobRow{ID: 1}} +} + +// TestIntegration_BillingReconciler_TerminalDowngradePersistsHobby seeds a +// pro-tier team with a Razorpay subscription id, runs the reconciler with a +// fetcher that reports that subscription `cancelled`, and asserts the worker +// persisted plan_tier='hobby' to the REAL team row AND emitted the +// subscription.canceled audit row. +func TestIntegration_BillingReconciler_TerminalDowngradePersistsHobby(t *testing.T) { + db, cleanup := testhelpers.SetupTestDB(t) + defer cleanup() + + subID := "sub_itest_" + uuid.New().String()[:12] + teamID := testhelpers.SeedTeamWithSubscription(t, db, "pro", subID) + + fetcher := &subKeyedFetcher{targetSub: subID, targetStatus: "cancelled"} + // grace == nil → real dbGracePeriodOpener against the live DB. + w := NewBillingReconcilerWorker(db, fetcher, nil) + + if err := w.Work(context.Background(), fakeBillingJob()); err != nil { + t.Fatalf("Work: %v", err) + } + + // The team's plan_tier must have flipped pro → hobby (terminalDowngradeTier). + got := testhelpers.TeamPlanTier(t, db, teamID) + if got != terminalDowngradeTier { + t.Errorf("plan_tier = %q after terminal downgrade, want %q (terminalDowngradeTier)", got, terminalDowngradeTier) + } + if got == "free" { + t.Error("terminal downgrade landed on 'free' — must be 'hobby' (D28 F1: 'free' strands permanent paid resources)") + } + + // A subscription.canceled audit row must have been emitted for the team — + // this is what the event-email forwarder dispatches the cancellation + // confirmation email from. + if n := testhelpers.CountAuditLogByTeam(t, db, teamID, "subscription.canceled"); n < 1 { + t.Errorf("subscription.canceled audit rows = %d, want >= 1 (the downgrade must emit the cancellation audit)", n) + } +} + +// TestIntegration_BillingReconciler_ActiveNoDriftLeavesTierUntouched is the +// complement: a paid team Razorpay reports `active` at its CURRENT tier must +// NOT be re-written (no gap → continue) and NO audit row is emitted. This pins +// the live "DB tier already at/above expected" no-op branch against real data — +// a regression that always-writes would burn a tier UPDATE + a spurious +// upgrade email every 15-minute tick. It is also fast: the target sorts first +// and the stub reports a no-action for everything else. +func TestIntegration_BillingReconciler_ActiveNoDriftLeavesTierUntouched(t *testing.T) { + db, cleanup := testhelpers.SetupTestDB(t) + defer cleanup() + + subID := "sub_itest_" + uuid.New().String()[:12] + teamID := testhelpers.SeedTeamWithSubscription(t, db, "pro", subID) + + // Razorpay reports active; with an empty plan_id the expected tier resolves + // to "hobby" — and the team is already "pro" (>= hobby) → no-op. + fetcher := &subKeyedFetcher{targetSub: subID, targetStatus: "active"} + w := NewBillingReconcilerWorker(db, fetcher, nil) + + if err := w.Work(context.Background(), fakeBillingJob()); err != nil { + t.Fatalf("Work: %v", err) + } + + // Tier untouched (still pro — NOT downgraded to the resolved-hobby). + if got := testhelpers.TeamPlanTier(t, db, teamID); got != "pro" { + t.Errorf("plan_tier = %q for an at-or-above-expected team, want \"pro\" (untouched)", got) + } + // No upgrade / cancel audit emitted for a no-op. + if n := testhelpers.CountAuditLogByTeam(t, db, teamID, "subscription.upgraded"); n != 0 { + t.Errorf("subscription.upgraded audit rows = %d for a no-op tick, want 0", n) + } + if n := testhelpers.CountAuditLogByTeam(t, db, teamID, "subscription.canceled"); n != 0 { + t.Errorf("subscription.canceled audit rows = %d for a no-op tick, want 0", n) + } +} diff --git a/internal/jobs/team_deletion_executor_integration_test.go b/internal/jobs/team_deletion_executor_integration_test.go new file mode 100644 index 0000000..0b76b94 --- /dev/null +++ b/internal/jobs/team_deletion_executor_integration_test.go @@ -0,0 +1,135 @@ +package jobs + +// team_deletion_executor_integration_test.go — REAL-Postgres integration test +// for the team-deletion purge cascade. This is the §5 #1 single highest-value +// data-loss gap: GDPR Article 17 right-to-be-forgotten teardown that, if it +// silently fails or scrubs the wrong rows, is both a compliance breach and a +// data-loss incident (cf. the 2026-06-03 truehomie-db DROP incident class). +// +// The sibling team_deletion_executor_test.go drives Work() with fake S3 / k8s +// clients and asserts the destruction *attempts* against those fakes. THIS test +// seeds a real team whose 30-day grace window has elapsed, with real resources +// (carrying connection_url + key_prefix secrets), real users (carrying PII), +// and a real deployment, then runs Work() against a live platform Postgres with +// provisioner/S3/k8s all nil (fail-open per NewTeamDeletionExecutorWorker) and +// asserts the PERSISTED purge cascade: +// +// - teams.status flipped deletion_requested → tombstoned, tombstoned_at set, +// - resources.connection_url scrubbed to NULL, key_prefix blanked to '', +// - users.email scrubbed to the deleted-@tombstoned.invalid placeholder +// (NULL would violate the NOT NULL UNIQUE constraint), +// - a team.tombstoned audit_log row emitted. +// +// The S3 / k8s / gRPC legs are nil-skipped (CI has no bucket / cluster / +// provisioner; the job is fail-open by design). The DB leg — the candidate +// scan (deletion_requested_at + 30d < now()), the status flip, the +// scrub transaction, the audit emit — is REAL and is the integration target. +// A sqlmock test passes whether or not the scrub UPDATE's WHERE team_id = $1 +// matched the seeded rows; this proves it did, against the real schema's +// constraints (the users.email NOT NULL UNIQUE that forces the placeholder +// instead of a NULL). +// +// GATING: testhelpers.SetupTestDB skips under -short / no-DB. + +import ( + "context" + "testing" + + "github.com/riverqueue/river" + "github.com/riverqueue/river/rivertype" + + "instant.dev/worker/internal/testhelpers" +) + +func fakeTeamDeletionJob() *river.Job[TeamDeletionExecutorArgs] { + return &river.Job[TeamDeletionExecutorArgs]{JobRow: &rivertype.JobRow{ID: 1}} +} + +// TestIntegration_TeamDeletionExecutor_PurgeCascadeTombstones seeds a team +// past its 30-day grace window with secret-bearing resources, PII-bearing +// users, and a deployment, runs the executor with no external clients, and +// asserts the full persisted tombstone cascade. +func TestIntegration_TeamDeletionExecutor_PurgeCascadeTombstones(t *testing.T) { + db, cleanup := testhelpers.SetupTestDB(t) + defer cleanup() + + // Grace window elapsed (40 > 30 days) → the candidate scan selects it. + teamID := testhelpers.SeedTeamPendingDeletion(t, db, "pro", 40) + resID := testhelpers.SeedResourceWithSecret(t, db, teamID, "postgres") + userID := testhelpers.SeedUser(t, db, teamID, "purge-itest-"+teamID.String()[:8]+"@example.com") + // A deployment so fetchTeamDeployAppIDs has a row to enumerate (the k8s + // leg is nil-skipped, but the row must not break the pure-DB cascade). + _ = testhelpers.SeedDeployment(t, db, teamID, "healthy", "app-purge-itest") + + // provisioner / s3 / k8s all nil → fail-open; pure-DB cascade only. + w := NewTeamDeletionExecutorWorker(db, nil, nil, nil, "") + + if err := w.Work(context.Background(), fakeTeamDeletionJob()); err != nil { + t.Fatalf("Work: %v", err) + } + + // 1. Team tombstoned. + if got := testhelpers.TeamStatus(t, db, teamID); got != "tombstoned" { + t.Errorf("team status = %q after purge, want \"tombstoned\"", got) + } + + // 2. Resource secrets scrubbed. + connURL, keyPrefix := testhelpers.ResourceSecretFields(t, db, resID) + if connURL.Valid { + t.Errorf("resource connection_url = %q after purge, want NULL", connURL.String) + } + if keyPrefix != "" { + t.Errorf("resource key_prefix = %q after purge, want empty", keyPrefix) + } + + // 3. User PII scrubbed to the tombstone placeholder (NOT the original email, + // NOT NULL — the NOT NULL UNIQUE constraint forces the placeholder). + email := testhelpers.UserEmail(t, db, userID) + wantPrefix := "deleted-" + userID.String() + if email != wantPrefix+"@tombstoned.invalid" { + t.Errorf("user email = %q after purge, want %q@tombstoned.invalid", email, wantPrefix) + } + + // 4. team.tombstoned audit row emitted. + if n := testhelpers.CountAuditLogByTeam(t, db, teamID, auditKindTombstoned); n < 1 { + t.Errorf("%s audit rows = %d, want >= 1", auditKindTombstoned, n) + } +} + +// TestIntegration_TeamDeletionExecutor_WithinGraceNotPurged is the safety +// complement: a team still INSIDE its 30-day grace window (the customer can +// still restore) MUST NOT be tombstoned. This pins the candidate scan's time +// predicate against real data — a regression that ignores the grace window +// would destroy data a customer is legally entitled to recover. Asserting the +// no-op is as important as asserting the purge: the truehomie-class incident is +// "the destroy path ran when it should not have." +func TestIntegration_TeamDeletionExecutor_WithinGraceNotPurged(t *testing.T) { + db, cleanup := testhelpers.SetupTestDB(t) + defer cleanup() + + // Only 5 days elapsed (< 30) → still restorable → must NOT be swept. + teamID := testhelpers.SeedTeamPendingDeletion(t, db, "pro", 5) + resID := testhelpers.SeedResourceWithSecret(t, db, teamID, "postgres") + + w := NewTeamDeletionExecutorWorker(db, nil, nil, nil, "") + + if err := w.Work(context.Background(), fakeTeamDeletionJob()); err != nil { + t.Fatalf("Work: %v", err) + } + + // Team still in deletion_requested (untouched), NOT tombstoned. + if got := testhelpers.TeamStatus(t, db, teamID); got != "deletion_requested" { + t.Errorf("team status = %q for an in-grace team, want \"deletion_requested\" (must not be swept)", got) + } + + // Resource secret still present (NOT scrubbed). + connURL, _ := testhelpers.ResourceSecretFields(t, db, resID) + if !connURL.Valid || connURL.String == "" { + t.Error("resource connection_url was scrubbed for an in-grace team — the grace window predicate failed (data-loss regression)") + } + + // No tombstone audit. + if n := testhelpers.CountAuditLogByTeam(t, db, teamID, auditKindTombstoned); n != 0 { + t.Errorf("%s audit rows = %d for an in-grace team, want 0", auditKindTombstoned, n) + } +} diff --git a/internal/testhelpers/billing_deletion.go b/internal/testhelpers/billing_deletion.go new file mode 100644 index 0000000..6d5fd8f --- /dev/null +++ b/internal/testhelpers/billing_deletion.go @@ -0,0 +1,257 @@ +package testhelpers + +// billing_deletion.go — harness extensions for the Tier-1 (data-loss / money- +// adjacent) worker jobs covered by INTEGRATION-COVERAGE-PLAN-2026-06-04.md +// §5 #1: billing_reconciler (terminal downgrade) and team_deletion_executor +// (purge cascade). Kept in a separate file from testhelpers.go so the Wave-2 +// T2 additions are reviewable on their own and merge cleanly behind the +// Wave-1 harness PR. +// +// Like testhelpers.go, every seed/read here targets the FULL api-migrated +// platform schema (the integration job applies all api migrations before +// running these tests; a developer box has the same). The columns these +// helpers touch beyond the testhelpers.go subset — teams.stripe_customer_id / +// deletion_requested_at / tombstoned_at, resources.connection_url / +// key_prefix, the users table — exist in that migrated schema. Where a column +// is absent on a bare harness DB, the helper degrades via isUndefinedColumn +// (the same fall-back testhelpers.SeedDeployment uses) so the harness still +// loads against a non-migrated DB even though these particular Tier-1 tests +// require the migrated one. + +import ( + "database/sql" + "testing" + "time" + + "github.com/google/uuid" +) + +// lowSortingTeamID returns a UUID that sorts at the very front of an +// `ORDER BY id` scan (it begins with all-zero high bytes) while remaining +// unique per call (random low 48 bits). The billing reconciler's candidate +// query is `... ORDER BY id LIMIT 100`; against a heavily-polluted shared test +// DB (a developer box can accumulate tens of thousands of stray teams) a +// random-UUID seed sorts past position 100 and is never processed, making the +// assertion flake on the environment rather than the code. Seeding the target +// team with a guaranteed-first id makes the test deterministic on ANY DB +// state — fresh CI or polluted local — without a production-code scope hook. +func lowSortingTeamID() uuid.UUID { + r := uuid.New() + // Zero the first 10 bytes; keep the last 6 random for uniqueness. The + // result is a valid UUID that lexically precedes any organically-generated + // (v4) id, whose first bytes are effectively never all zero. + for i := 0; i < 10; i++ { + r[i] = 0 + } + return r +} + +// SeedTeamWithSubscription inserts an active paid team that sorts FIRST in the +// billing reconciler's `ORDER BY id LIMIT 100` candidate scan (see +// lowSortingTeamID) and stamps its Razorpay subscription id, returning both. +// Use this for billing-reconciler integration tests so the seeded team is +// always within the batch regardless of how many other subscription-bearing +// teams the shared DB carries. Cleaned up after the test. +func SeedTeamWithSubscription(t *testing.T, db *sql.DB, planTier, subscriptionID string) uuid.UUID { + t.Helper() + if planTier == "" { + planTier = "pro" + } + id := lowSortingTeamID() + if _, err := db.Exec( + `INSERT INTO teams (id, name, plan_tier, status, stripe_customer_id) + VALUES ($1, $2, $3, 'active', $4)`, + id, "itest-bill-"+id.String()[24:], planTier, subscriptionID, + ); err != nil { + tFatalf(t, "SeedTeamWithSubscription: %v", err) + return uuid.Nil + } + t.Cleanup(func() { + _, _ = db.Exec(`DELETE FROM teams WHERE id = $1`, id) + }) + return id +} + +// SetTeamSubscription stamps a team's Razorpay subscription id (stored in the +// legacy-named teams.stripe_customer_id column) so the billing reconciler's +// candidate query (WHERE stripe_customer_id IS NOT NULL) selects it. The +// reconciler keys its per-team Razorpay fetch on this value, so a stub +// fetcher can branch on it to return a terminal/active/grace status for ONLY +// the seeded team — which is what makes the assertion deterministic against a +// shared local DB that may carry other subscription-bearing teams. +func SetTeamSubscription(t *testing.T, db *sql.DB, teamID uuid.UUID, subscriptionID string) { + t.Helper() + if _, err := db.Exec( + `UPDATE teams SET stripe_customer_id = $1 WHERE id = $2`, + subscriptionID, teamID, + ); err != nil { + tFatalf(t, "SetTeamSubscription: %v", err) + return + } +} + +// TeamPlanTier reads back teams.plan_tier for a row — the integration +// assertion target for the billing reconciler's terminal downgrade +// (plan_tier flips pro → hobby) and for the deletion executor's tombstone. +func TeamPlanTier(t *testing.T, db *sql.DB, teamID uuid.UUID) string { + t.Helper() + var tier string + if err := db.QueryRow( + `SELECT plan_tier FROM teams WHERE id = $1`, teamID, + ).Scan(&tier); err != nil { + tFatalf(t, "TeamPlanTier: %v", err) + return "" + } + return tier +} + +// TeamStatus reads back teams.status — the integration assertion target for +// the deletion executor (deletion_requested → tombstoned). +func TeamStatus(t *testing.T, db *sql.DB, teamID uuid.UUID) string { + t.Helper() + var status string + if err := db.QueryRow( + `SELECT status FROM teams WHERE id = $1`, teamID, + ).Scan(&status); err != nil { + tFatalf(t, "TeamStatus: %v", err) + return "" + } + return status +} + +// CountAuditLogByTeam returns how many audit_log rows of the given kind +// reference the given team id. Used to assert the billing reconciler's +// subscription.canceled emit and the deletion executor's team.tombstoned emit +// — both write team-scoped audit rows the event-email forwarder later +// dispatches from. Counting by (team_id, kind) — rather than the +// metadata->>'deploy_id' path CountAuditLog uses — is the right key for these +// team-lifecycle events. +func CountAuditLogByTeam(t *testing.T, db *sql.DB, teamID uuid.UUID, kind string) int { + t.Helper() + var n int + if err := db.QueryRow( + `SELECT count(*) FROM audit_log WHERE team_id = $1 AND kind = $2`, + teamID, kind, + ).Scan(&n); err != nil { + tFatalf(t, "CountAuditLogByTeam: %v", err) + return 0 + } + return n +} + +// SeedTeamPendingDeletion inserts a team already in status='deletion_requested' +// with deletion_requested_at set graceElapsedDays in the PAST, so the +// team_deletion_executor's candidate query (deletion_requested_at + 30d < +// now()) selects it. Pass a value > 30 to model a team whose grace window has +// elapsed. The team carries a non-empty stripe_customer_id + name so the +// tombstone step's NULL-out of those PII columns is observable. Cleaned up +// after the test. +func SeedTeamPendingDeletion(t *testing.T, db *sql.DB, planTier string, graceElapsedDays int) uuid.UUID { + t.Helper() + if planTier == "" { + planTier = "pro" + } + id := uuid.New() + requestedAt := time.Now().UTC().Add(-time.Duration(graceElapsedDays) * 24 * time.Hour) + if _, err := db.Exec(` + INSERT INTO teams (id, name, plan_tier, status, stripe_customer_id, deletion_requested_at) + VALUES ($1, $2, $3, 'deletion_requested', $4, $5) + `, id, "itest-del-"+id.String()[:8], planTier, "sub_"+id.String()[:12], requestedAt); err != nil { + tFatalf(t, "SeedTeamPendingDeletion: %v", err) + return uuid.Nil + } + t.Cleanup(func() { + _, _ = db.Exec(`DELETE FROM teams WHERE id = $1`, id) + }) + return id +} + +// SeedResourceWithSecret inserts a resources row carrying a non-empty +// connection_url + key_prefix — the customer-data fields the deletion +// executor NULLs/blanks in its tombstone transaction. Returns the row id so +// the test can read those fields back and assert they were scrubbed. Cleaned +// up after the test. +func SeedResourceWithSecret(t *testing.T, db *sql.DB, teamID uuid.UUID, resourceType string) uuid.UUID { + t.Helper() + id := uuid.New() + token := uuid.New() + if _, err := db.Exec(` + INSERT INTO resources + (id, team_id, token, resource_type, tier, status, connection_url, key_prefix, created_at) + VALUES ($1, $2, $3, $4, 'pro', 'active', $5, $6, now()) + `, id, teamID, token, resourceType, + "postgres://itest-secret@localhost:5432/db_"+id.String()[:8], + "prefix-"+id.String()[:8]+"/", + ); err != nil { + tFatalf(t, "SeedResourceWithSecret: %v", err) + return uuid.Nil + } + t.Cleanup(func() { + _, _ = db.Exec(`DELETE FROM resources WHERE id = $1`, id) + }) + return id +} + +// ResourceSecretFields reads back resources.connection_url + key_prefix for a +// row — the deletion-executor assertion target (connection_url → NULL, +// key_prefix → ”). +func ResourceSecretFields(t *testing.T, db *sql.DB, id uuid.UUID) (connURL sql.NullString, keyPrefix string) { + t.Helper() + if err := db.QueryRow( + `SELECT connection_url, COALESCE(key_prefix, '') FROM resources WHERE id = $1`, id, + ).Scan(&connURL, &keyPrefix); err != nil { + tFatalf(t, "ResourceSecretFields: %v", err) + return sql.NullString{}, "" + } + return connURL, keyPrefix +} + +// SeedUser inserts a users row owned by the team with the given email so the +// deletion executor's PII-scrub step (email → deleted-@tombstoned.invalid, +// github_id/google_id → NULL) is observable. Returns the user id. Cleaned up +// after the test (the team-cascade also tidies it; this is belt-and-braces for +// a test that seeds a user without a cascading team delete). +func SeedUser(t *testing.T, db *sql.DB, teamID uuid.UUID, email string) uuid.UUID { + t.Helper() + id := uuid.New() + // github_id is seeded non-NULL so the scrub-to-NULL is observable. The + // prod users table carries more NOT NULL columns than the bare harness + // shape; this INSERT targets the api-migrated schema (the only schema + // these Tier-1 tests run against). github_id is a TEXT/bigint depending on + // migration; pass a string-shaped id which both accept via implicit cast, + // falling back to a NULL github_id if the column rejects it. + _, err := db.Exec(` + INSERT INTO users (id, team_id, email, github_id) + VALUES ($1, $2, $3, $4) + `, id, teamID, email, "gh-"+id.String()[:8]) + if err != nil { + // Fall back without github_id (bare schema / type mismatch): the + // email scrub is the load-bearing assertion regardless. + _, err = db.Exec(` + INSERT INTO users (id, team_id, email) + VALUES ($1, $2, $3) + `, id, teamID, email) + } + if err != nil { + tFatalf(t, "SeedUser: %v", err) + return uuid.Nil + } + t.Cleanup(func() { + _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, id) + }) + return id +} + +// UserEmail reads back users.email for a row — the deletion-executor PII-scrub +// assertion target. +func UserEmail(t *testing.T, db *sql.DB, id uuid.UUID) string { + t.Helper() + var email string + if err := db.QueryRow( + `SELECT email FROM users WHERE id = $1`, id, + ).Scan(&email); err != nil { + tFatalf(t, "UserEmail: %v", err) + return "" + } + return email +} diff --git a/internal/testhelpers/billing_deletion_smoke_test.go b/internal/testhelpers/billing_deletion_smoke_test.go new file mode 100644 index 0000000..3d5bd1b --- /dev/null +++ b/internal/testhelpers/billing_deletion_smoke_test.go @@ -0,0 +1,237 @@ +package testhelpers + +// billing_deletion_smoke_test.go — in-package coverage for the Tier-1 +// (billing-reconciler / team-deletion) harness extensions in billing_deletion.go. +// +// WHY THIS EXISTS +// --------------- +// Same per-package coverage-attribution reason as testhelpers_smoke_test.go: the +// Seed*/read helpers in billing_deletion.go live in a non-_test.go file so the +// jobs-package integration tests can import them, but Go credits their line +// coverage to `internal/jobs` (the caller's package), not `internal/testhelpers`. +// Without an in-package test, every line of billing_deletion.go reads as 0% in +// diff-cover and reds the 100%-patch-coverage gate (the exact failure on PR #89, +// identical to the PR #87 testhelpers.go failure). This file mirrors +// testhelpers_smoke_test.go, the established convention for giving a test-harness +// package its own coverage. +// +// Each helper's t.Fatalf-equivalent failure arm routes through the package's +// tFatalf seam (the same seam testhelpers.go uses); the error arms are exercised +// by swapping that seam for a recording stub and driving the arm with a +// deliberately-closed DB (a test seam, not a behavioural change — real callers +// still get a genuine t.Fatalf via the default seam). +// +// GATING: the DB-backed test routes through SetupTestDB, which skips when no +// Postgres is reachable — so it skips cleanly on the no-DB workflows +// (deploy.yml `-short`, ci.yml `-race`) and runs against coverage.yml's postgres +// service (which exports TEST_DATABASE_URL and applies the api migrations these +// helpers' columns require) + any developer DB with the migrated schema. + +import ( + "database/sql" + "testing" + + "github.com/google/uuid" +) + +// TestLowSortingTeamID covers lowSortingTeamID: the result must (a) have its +// first 10 bytes zeroed (so it sorts at the front of an ORDER BY id scan) and +// (b) be unique across calls (random low 6 bytes). Pure logic, no DB — runs on +// every workflow. +func TestLowSortingTeamID(t *testing.T) { + t.Parallel() + a := lowSortingTeamID() + b := lowSortingTeamID() + for i := 0; i < 10; i++ { + if a[i] != 0 { + t.Fatalf("lowSortingTeamID: byte %d = %d, want 0 (must sort first)", i, a[i]) + } + } + if a == b { + t.Fatal("lowSortingTeamID returned identical ids on two calls — not unique") + } + if a == uuid.Nil { + t.Fatal("lowSortingTeamID returned the nil UUID (low bytes not randomized)") + } +} + +// TestBillingDeletionErrorArms drives every fallible helper against a CLOSED +// *sql.DB so each Exec/QueryRow fails — covering the tFatalf error arm (via the +// recording seam) of SeedTeamWithSubscription, SetTeamSubscription, TeamPlanTier, +// TeamStatus, CountAuditLogByTeam, SeedTeamPendingDeletion, SeedResourceWithSecret, +// ResourceSecretFields, SeedUser and UserEmail. SeedUser additionally exercises +// its fallback INSERT (the first Exec errors → the second Exec is attempted → +// also errors → tFatalf), covering the fallback branch's failure path. +func TestBillingDeletionErrorArms(t *testing.T) { + closed, err := sql.Open("postgres", "postgres://postgres@127.0.0.1:1/x?sslmode=disable") + if err != nil { + t.Fatalf("open placeholder db: %v", err) + } + _ = closed.Close() // every subsequent Exec/QueryRow now errors. + + fatals, _ := swapFatalSeams(t) + id := uuid.New() + + if got := SeedTeamWithSubscription(t, closed, "pro", "sub_x"); got != uuid.Nil { + t.Fatalf("SeedTeamWithSubscription on closed db = %v, want Nil", got) + } + SetTeamSubscription(t, closed, id, "sub_y") + if got := TeamPlanTier(t, closed, id); got != "" { + t.Fatalf("TeamPlanTier on closed db = %q, want \"\"", got) + } + if got := TeamStatus(t, closed, id); got != "" { + t.Fatalf("TeamStatus on closed db = %q, want \"\"", got) + } + if got := CountAuditLogByTeam(t, closed, id, "team.tombstoned"); got != 0 { + t.Fatalf("CountAuditLogByTeam on closed db = %d, want 0", got) + } + if got := SeedTeamPendingDeletion(t, closed, "pro", 40); got != uuid.Nil { + t.Fatalf("SeedTeamPendingDeletion on closed db = %v, want Nil", got) + } + if got := SeedResourceWithSecret(t, closed, id, "postgres"); got != uuid.Nil { + t.Fatalf("SeedResourceWithSecret on closed db = %v, want Nil", got) + } + if connURL, keyPrefix := ResourceSecretFields(t, closed, id); connURL.Valid || keyPrefix != "" { + t.Fatalf("ResourceSecretFields on closed db = (%+v, %q), want (NULL, \"\")", connURL, keyPrefix) + } + if got := SeedUser(t, closed, id, "x@example.com"); got != uuid.Nil { + t.Fatalf("SeedUser on closed db = %v, want Nil", got) + } + if got := UserEmail(t, closed, id); got != "" { + t.Fatalf("UserEmail on closed db = %q, want \"\"", got) + } + + // 10 distinct fallible call paths each recorded at least one Fatalf. + if len(*fatals) < 10 { + t.Fatalf("expected >=10 recorded Fatalf arms against the closed DB, got %d: %v", len(*fatals), *fatals) + } +} + +// TestIntegration_BillingDeletionRoundTrip drives every DB-backed helper against +// a real Postgres so the happy paths + both default-tier branches (planTier=="") +// and the SeedUser fallback-INSERT branch carry real line coverage. Mirrors the +// jobs-package integration tests (billing_reconciler / team_deletion_executor) +// but exercises the harness in its OWN package so the coverage is attributed +// here. +func TestIntegration_BillingDeletionRoundTrip(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + ensureSchema(t, db) // ensure the shared deployment_events/etc tables exist (idempotent). + + // Unique per-run subscription suffix so a prior crash that skipped t.Cleanup + // can't collide with the teams.stripe_customer_id UNIQUE constraint on rerun. + sfx := uuid.NewString() + + // SeedTeamWithSubscription: explicit tier; then SetTeamSubscription rewrites + // the subscription id, then TeamPlanTier reads the tier back. + teamA := SeedTeamWithSubscription(t, db, "pro", "sub_round_a_"+sfx) + if teamA == uuid.Nil { + t.Fatal("SeedTeamWithSubscription returned nil uuid") + } + SetTeamSubscription(t, db, teamA, "sub_round_a2_"+sfx) + if got := TeamPlanTier(t, db, teamA); got != "pro" { + t.Fatalf("TeamPlanTier(teamA) = %q, want pro", got) + } + if got := TeamStatus(t, db, teamA); got != "active" { + t.Fatalf("TeamStatus(teamA) = %q, want active", got) + } + + // "" -> default-pro branch of SeedTeamWithSubscription. + teamDefault := SeedTeamWithSubscription(t, db, "", "sub_round_default_"+sfx) + if got := TeamPlanTier(t, db, teamDefault); got != "pro" { + t.Fatalf("TeamPlanTier(teamDefault, blank-tier) = %q, want pro (default)", got) + } + + // SeedTeamPendingDeletion: explicit tier + the "" -> default-pro branch. + teamDel := SeedTeamPendingDeletion(t, db, "hobby", 40) + if teamDel == uuid.Nil { + t.Fatal("SeedTeamPendingDeletion returned nil uuid") + } + if got := TeamStatus(t, db, teamDel); got != "deletion_requested" { + t.Fatalf("TeamStatus(teamDel) = %q, want deletion_requested", got) + } + if got := TeamPlanTier(t, db, teamDel); got != "hobby" { + t.Fatalf("TeamPlanTier(teamDel) = %q, want hobby", got) + } + teamDelDefault := SeedTeamPendingDeletion(t, db, "", 40) + if got := TeamPlanTier(t, db, teamDelDefault); got != "pro" { + t.Fatalf("TeamPlanTier(teamDelDefault, blank-tier) = %q, want pro (default)", got) + } + + // SeedResourceWithSecret + ResourceSecretFields: the secret fields read back + // non-empty before any tombstone scrub. + resID := SeedResourceWithSecret(t, db, teamDel, "postgres") + if resID == uuid.Nil { + t.Fatal("SeedResourceWithSecret returned nil uuid") + } + connURL, keyPrefix := ResourceSecretFields(t, db, resID) + if !connURL.Valid || connURL.String == "" { + t.Fatalf("ResourceSecretFields connURL = %+v, want a non-empty value", connURL) + } + if keyPrefix == "" { + t.Fatal("ResourceSecretFields keyPrefix is empty, want a seeded prefix") + } + + // SeedUser (happy path: first INSERT with github_id succeeds) + UserEmail. + userID := SeedUser(t, db, teamDel, "smoke-user-"+teamDel.String()[:8]+"@example.com") + if userID == uuid.Nil { + t.Fatal("SeedUser returned nil uuid") + } + if email := UserEmail(t, db, userID); email == "" { + t.Fatal("UserEmail returned empty for a seeded user") + } + + // SeedUser fallback-INSERT branch: drop github_id so the first INSERT 42703s + // and the fallback INSERT (without github_id) runs and succeeds. Restored + // after fn. + withoutGithubIDColumn(t, db, func() { + bareUser := SeedUser(t, db, teamDel, "smoke-user-bare-"+teamDel.String()[:8]+"@example.com") + if bareUser == uuid.Nil { + t.Fatal("SeedUser fallback-INSERT branch returned nil id") + } + if email := UserEmail(t, db, bareUser); email == "" { + t.Fatal("UserEmail returned empty for the fallback-INSERT user") + } + }) + + // CountAuditLogByTeam: write a team-scoped audit row then count it (found + // arm), and assert a zero count for an unrelated kind (not-found arm). + if _, err := db.Exec( + `INSERT INTO audit_log (team_id, actor, kind, summary) + VALUES ($1, 'worker', 'team.tombstoned', 'tombstoned')`, teamDel, + ); err != nil { + t.Fatalf("insert audit_log row: %v", err) + } + if n := CountAuditLogByTeam(t, db, teamDel, "team.tombstoned"); n != 1 { + t.Fatalf("CountAuditLogByTeam(found) = %d, want 1", n) + } + if n := CountAuditLogByTeam(t, db, teamDel, "subscription.canceled"); n != 0 { + t.Fatalf("CountAuditLogByTeam(absent kind) = %d, want 0", n) + } +} + +// withoutGithubIDColumn drops users.github_id for the duration of fn, then +// restores it (nullable — the harness only needs the column to exist for the +// happy path). If the column was already absent (bare harness DB), fn runs +// unchanged. Mirrors withoutAppIDColumn in testhelpers_smoke_test.go. +func withoutGithubIDColumn(t *testing.T, db *sql.DB, fn func()) { + t.Helper() + var had bool + if err := db.QueryRow(` + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'github_id')`).Scan(&had); err != nil { + t.Fatalf("probe github_id column: %v", err) + } + if had { + if _, err := db.Exec(`ALTER TABLE users DROP COLUMN github_id`); err != nil { + t.Fatalf("drop github_id: %v", err) + } + defer func() { + if _, err := db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS github_id TEXT`); err != nil { + t.Fatalf("restore github_id: %v", err) + } + }() + } + fn() +}