diff --git a/internal/models/resource.go b/internal/models/resource.go index 632814b..2992958 100644 --- a/internal/models/resource.go +++ b/internal/models/resource.go @@ -696,14 +696,19 @@ func UpdateProviderResourceID(ctx context.Context, db *sql.DB, resourceID uuid.U return nil } -// ElevateResourceTiersByTeam sets the tier of every active or paused team-owned -// resource to newTier and clears its TTL (expires_at = NULL). +// ElevateResourceTiersByTeam sets the tier of every active, paused, or +// quota-suspended team-owned resource to newTier and clears its TTL +// (expires_at = NULL). // -// Called from the Razorpay subscription.charged webhook. Picks up two cases: +// Called from the Razorpay subscription.charged webhook. Picks up three cases: // 1. Resources that are already permanent (expires_at IS NULL) — a hobby // user upgrading to pro: lift their existing resources to the new tier. // 2. Resources still on anonymous TTL (expires_at > now()) — a freshly // claimed user paying for the first time: clear the TTL + set tier. +// 3. Resources the worker's storage-quota enforcer SUSPENDED for exceeding +// their tier cap — an upgrade must raise the cap on the very row that +// tripped it, otherwise "upgrade to restore access" is a no-op for the +// suspended resource and it stays below the new cap's reach. // // This is the second half of "pay from day one": claim transfers team // ownership but does NOT clear the TTL or change tier. Only payment does. @@ -714,6 +719,19 @@ func UpdateProviderResourceID(ctx context.Context, db *sql.DB, resourceID uuid.U // re-subscribed would have their resources stuck at the wrong tier, blocking // the resume flow which re-derives access rights from the resource tier. // +// Suspended rows are included (added 2026-06-04) for the same reason: a +// quota-suspended resource must carry the higher tier so it is now UNDER the +// new cap. NOTE: this raises the cap only — it does NOT flip status back to +// 'active' or reverse the provider-side CONNECT/ACL REVOKE. That unsuspend +// transition (re-measure usage against the new cap → status='active' + +// provider re-grant + resource.quota_unsuspended audit) lives in the WORKER's +// storage-quota enforcer (sweep finding #3); without that follow-up the row +// here carries the right tier but stays status='suspended' until the worker's +// next scan re-evaluates it. CAVEAT: for postgres/mongo the REVOKE-while- +// suspended can also block the customer from deleting data to get under cap, +// so for those backends the worker's tier-aware re-measure (which an elevated +// tier now satisfies) is the recovery path, not customer self-service delete. +// // expires_at > now() guards a race with the reaper — we don't resurrect a // resource whose TTL already elapsed. // Applies across all environments — one upgrade lifts dev, staging, and prod. @@ -722,7 +740,7 @@ func ElevateResourceTiersByTeam(ctx context.Context, db *sql.DB, teamID uuid.UUI UPDATE resources SET tier = $1, expires_at = NULL WHERE team_id = $2 - AND status IN ('active', 'paused') + AND status IN ('active', 'paused', 'suspended') AND (expires_at IS NULL OR expires_at > now()) `, newTier, teamID) if err != nil { diff --git a/internal/models/resource_elevate_suspended_test.go b/internal/models/resource_elevate_suspended_test.go new file mode 100644 index 0000000..738f52d --- /dev/null +++ b/internal/models/resource_elevate_suspended_test.go @@ -0,0 +1,114 @@ +package models_test + +// resource_elevate_suspended_test.go — regression coverage for the +// quota-suspend recovery trap (sweep finding #4, 2026-06-04). +// +// The worker's storage-quota enforcer flips a resource to status='suspended' +// when it exceeds its tier's storage cap. ElevateResourceTiersByTeam (called +// from the Razorpay subscription.charged webhook) previously filtered on +// status IN ('active','paused') — so a tier upgrade did NOT raise the cap on +// the suspended resource, making "upgrade to restore access" a no-op for the +// very row that tripped the limit. The fix adds 'suspended' to the filter so +// the upgrade lifts the suspended row to the new (higher-cap) tier. +// +// NOTE (worker follow-up #3): the actual unsuspend transition — flipping +// status back to 'active' and reversing the provider-side CONNECT/ACL REVOKE — +// lives in the worker, not the api. This test pins ONLY the api half: the +// suspended row's tier IS elevated. + +import ( + "context" + "database/sql" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/models" + "instant.dev/internal/testhelpers" +) + +// insertSuspendedResourceForTest inserts a permanent (no TTL) team-owned +// resource pinned to status='suspended' so we can assert the elevation path +// reaches quota-suspended rows. +func insertSuspendedResourceForTest(t *testing.T, db *sql.DB, teamID uuid.UUID, tier string) uuid.UUID { + t.Helper() + var id uuid.UUID + err := db.QueryRowContext(context.Background(), ` + INSERT INTO resources (team_id, token, resource_type, tier, env, status, expires_at) + VALUES ($1, $2, 'redis', $3, 'production', 'suspended', NULL) + RETURNING id + `, teamID, uuid.NewString(), tier).Scan(&id) + require.NoError(t, err) + t.Cleanup(func() { db.Exec(`DELETE FROM resources WHERE id = $1`, id) }) + return id +} + +// TestElevate_Suspended_TierElevated reproduces the suspend@hobby-cap → +// upgrade-to-pro scenario: a resource the quota enforcer suspended at the +// hobby cap MUST have its tier raised to pro on upgrade, otherwise the new +// (larger) pro cap never applies to it. +func TestElevate_Suspended_TierElevated(t *testing.T) { + requireDBElevate(t) + db, cleanup := testhelpers.SetupTestDB(t) + defer cleanup() + + teamID := uuid.MustParse(testhelpers.MustCreateTeamDB(t, db, "pro")) + // Resource was at hobby tier and got suspended for exceeding the hobby cap. + resourceID := insertSuspendedResourceForTest(t, db, teamID, "hobby") + + err := models.ElevateResourceTiersByTeam(context.Background(), db, teamID, "pro") + require.NoError(t, err) + + var tier, status string + err = db.QueryRow(`SELECT tier, status FROM resources WHERE id = $1`, resourceID). + Scan(&tier, &status) + require.NoError(t, err) + assert.Equal(t, "pro", tier, + "a quota-suspended resource MUST be elevated to the new tier on upgrade") + // The api elevation raises the cap only; the status flip back to 'active' + // (and provider re-grant) is the worker's job (#3). Pin that the api does + // NOT itself unsuspend — so a future api-side change to also flip status + // is a deliberate decision, not an accident. + assert.Equal(t, "suspended", status, + "api elevation raises the tier only; the unsuspend status flip is the worker's job (#3)") +} + +// TestElevate_SuspendedFilterIncludesSuspended is the registry-style guard for +// the status filter itself: it inserts one resource per relevant non-terminal +// status (active, paused, suspended) and asserts ALL THREE are elevated by a +// single ElevateResourceTiersByTeam call. If a future edit drops 'suspended' +// (or any of the three) from the filter, this fails — it pins the exact set +// the elevation must cover, not a hand-typed assertion on one row. +func TestElevate_SuspendedFilterIncludesSuspended(t *testing.T) { + requireDBElevate(t) + db, cleanup := testhelpers.SetupTestDB(t) + defer cleanup() + + teamID := uuid.MustParse(testhelpers.MustCreateTeamDB(t, db, "pro")) + + statuses := []string{"active", "paused", "suspended"} + ids := make(map[string]uuid.UUID, len(statuses)) + for _, st := range statuses { + var id uuid.UUID + err := db.QueryRowContext(context.Background(), ` + INSERT INTO resources (team_id, token, resource_type, tier, env, status, expires_at) + VALUES ($1, $2, 'redis', 'hobby', 'production', $3, NULL) + RETURNING id + `, teamID, uuid.NewString(), st).Scan(&id) + require.NoError(t, err) + ids[st] = id + t.Cleanup(func() { db.Exec(`DELETE FROM resources WHERE id = $1`, id) }) + } + + err := models.ElevateResourceTiersByTeam(context.Background(), db, teamID, "pro") + require.NoError(t, err) + + for _, st := range statuses { + var tier string + require.NoError(t, db.QueryRow(`SELECT tier FROM resources WHERE id = $1`, ids[st]).Scan(&tier)) + assert.Equalf(t, "pro", tier, + "resource with status=%q must be elevated by ElevateResourceTiersByTeam", st) + } +}