Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions internal/db/migrations/069_pending_checkouts_cascade.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-- 069_pending_checkouts_cascade — make pending_checkouts.team_id cascade on team delete.
--
-- WHY THIS EXISTS
-- ---------------
-- 053_pending_checkouts created the FK as `team_id UUID NOT NULL REFERENCES
-- teams(id)` with NO `ON DELETE CASCADE` — the ONLY team-child table that omits
-- it (every other child: deployments, stacks, api_keys, audit_log, vault,
-- custom_domains, pending_deletions, pending_propagations, … all CASCADE).
--
-- DeleteTeamHard (the e2e-account reap) and the worker team_deletion_executor
-- both delete a team with a single `DELETE FROM teams`, relying on the children
-- to cascade. A team that ever started a checkout has a pending_checkouts row,
-- so the delete fails:
-- pq: update or delete on table "teams" violates foreign key constraint
-- "pending_checkouts_team_id_fkey" on table "pending_checkouts"
-- This surfaced the moment test-cohort checkout was armed (a cohort upgrade
-- creates a pending_checkouts row) — the reap 503'd and the cohort team leaked,
-- breaking the rule-24 "cohort data is always reaped" guarantee.
--
-- Fix: align the FK with every other team-child table — ON DELETE CASCADE. A
-- resolved or unresolved pending_checkouts row is per-team bookkeeping; when the
-- team is gone the row is meaningless, so cascading the delete is correct.
-- Idempotent (DROP IF EXISTS + re-ADD) so it is safe to re-run.

ALTER TABLE pending_checkouts DROP CONSTRAINT IF EXISTS pending_checkouts_team_id_fkey;
ALTER TABLE pending_checkouts
ADD CONSTRAINT pending_checkouts_team_id_fkey
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE;
36 changes: 36 additions & 0 deletions internal/models/e2e_account_models_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,42 @@ func TestDeleteTeamHard_DeletesAndIsIdempotent(t *testing.T) {
require.False(t, deleted, "re-delete of a gone team is a clean no-op")
}

// TestDeleteTeamHard_CascadesPendingCheckouts is the regression guard for
// migration 069: a team that started a checkout has a pending_checkouts row, and
// before 069 that FK had no ON DELETE CASCADE — so DeleteTeamHard (the e2e reap)
// failed with `pending_checkouts_team_id_fkey` and the cohort team LEAKED. This
// surfaced the instant test-cohort checkout was armed (a cohort Pro upgrade
// creates a pending_checkouts row → the reap 503'd). With the cascade, deleting
// the team removes its pending_checkouts rows and the reap succeeds.
func TestDeleteTeamHard_CascadesPendingCheckouts(t *testing.T) {
skipUnlessE2EModelsDB(t)
ctx := context.Background()
db, clean := testhelpers.SetupTestDB(t)
defer clean()

team, err := models.CreateTestCohortTeam(ctx, db, "cohort-with-checkout")
require.NoError(t, err)

// Seed a pending_checkouts row (the exact FK that used to block the delete).
subID := "sub_test_" + uuid.NewString()
_, err = db.ExecContext(ctx,
`INSERT INTO pending_checkouts (subscription_id, team_id, customer_email, plan_tier)
VALUES ($1, $2, $3, $4)`,
subID, team.ID, "cohort@example.com", "pro")
require.NoError(t, err, "seed pending_checkouts")

// Pre-069 this returned the FK-violation error; with the cascade it succeeds.
deleted, err := models.DeleteTeamHard(ctx, db, team.ID)
require.NoError(t, err, "DeleteTeamHard must cascade pending_checkouts, not error on the FK")
require.True(t, deleted)

// The child row is gone too (cascaded), not orphaned.
var n int
require.NoError(t, db.QueryRowContext(ctx,
`SELECT count(*) FROM pending_checkouts WHERE subscription_id = $1`, subID).Scan(&n))
require.Equal(t, 0, n, "pending_checkouts row must be cascade-deleted with the team")
}

func TestMarkTeamResourcesForReaper_RetiersAndExpires(t *testing.T) {
skipUnlessE2EModelsDB(t)
ctx := context.Background()
Expand Down
Loading