Skip to content

Commit 498f5cf

Browse files
fix(quota): elevate tier on quota-suspended resources during upgrade (#240)
ElevateResourceTiersByTeam (Razorpay subscription.charged webhook) filtered on status IN ('active','paused'), so a tier upgrade did NOT raise the cap on a quota-suspended resource. The worker's storage-quota enforcer flips a row to status='suspended' when it exceeds its tier cap; without the elevation reaching that row, "upgrade to restore access" was a no-op for the very resource that tripped the limit — it stayed below the new (larger) cap's reach. Add 'suspended' to the status filter so the upgrade lifts the suspended row to the new tier. 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 scanner (sweep finding #3, out of this repo). Documented as a worker follow-up in the function doc + test. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 52ec36a commit 498f5cf

2 files changed

Lines changed: 136 additions & 4 deletions

File tree

internal/models/resource.go

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -696,14 +696,19 @@ func UpdateProviderResourceID(ctx context.Context, db *sql.DB, resourceID uuid.U
696696
return nil
697697
}
698698

699-
// ElevateResourceTiersByTeam sets the tier of every active or paused team-owned
700-
// resource to newTier and clears its TTL (expires_at = NULL).
699+
// ElevateResourceTiersByTeam sets the tier of every active, paused, or
700+
// quota-suspended team-owned resource to newTier and clears its TTL
701+
// (expires_at = NULL).
701702
//
702-
// Called from the Razorpay subscription.charged webhook. Picks up two cases:
703+
// Called from the Razorpay subscription.charged webhook. Picks up three cases:
703704
// 1. Resources that are already permanent (expires_at IS NULL) — a hobby
704705
// user upgrading to pro: lift their existing resources to the new tier.
705706
// 2. Resources still on anonymous TTL (expires_at > now()) — a freshly
706707
// claimed user paying for the first time: clear the TTL + set tier.
708+
// 3. Resources the worker's storage-quota enforcer SUSPENDED for exceeding
709+
// their tier cap — an upgrade must raise the cap on the very row that
710+
// tripped it, otherwise "upgrade to restore access" is a no-op for the
711+
// suspended resource and it stays below the new cap's reach.
707712
//
708713
// This is the second half of "pay from day one": claim transfers team
709714
// 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
714719
// re-subscribed would have their resources stuck at the wrong tier, blocking
715720
// the resume flow which re-derives access rights from the resource tier.
716721
//
722+
// Suspended rows are included (added 2026-06-04) for the same reason: a
723+
// quota-suspended resource must carry the higher tier so it is now UNDER the
724+
// new cap. NOTE: this raises the cap only — it does NOT flip status back to
725+
// 'active' or reverse the provider-side CONNECT/ACL REVOKE. That unsuspend
726+
// transition (re-measure usage against the new cap → status='active' +
727+
// provider re-grant + resource.quota_unsuspended audit) lives in the WORKER's
728+
// storage-quota enforcer (sweep finding #3); without that follow-up the row
729+
// here carries the right tier but stays status='suspended' until the worker's
730+
// next scan re-evaluates it. CAVEAT: for postgres/mongo the REVOKE-while-
731+
// suspended can also block the customer from deleting data to get under cap,
732+
// so for those backends the worker's tier-aware re-measure (which an elevated
733+
// tier now satisfies) is the recovery path, not customer self-service delete.
734+
//
717735
// expires_at > now() guards a race with the reaper — we don't resurrect a
718736
// resource whose TTL already elapsed.
719737
// 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
722740
UPDATE resources
723741
SET tier = $1, expires_at = NULL
724742
WHERE team_id = $2
725-
AND status IN ('active', 'paused')
743+
AND status IN ('active', 'paused', 'suspended')
726744
AND (expires_at IS NULL OR expires_at > now())
727745
`, newTier, teamID)
728746
if err != nil {
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package models_test
2+
3+
// resource_elevate_suspended_test.go — regression coverage for the
4+
// quota-suspend recovery trap (sweep finding #4, 2026-06-04).
5+
//
6+
// The worker's storage-quota enforcer flips a resource to status='suspended'
7+
// when it exceeds its tier's storage cap. ElevateResourceTiersByTeam (called
8+
// from the Razorpay subscription.charged webhook) previously filtered on
9+
// status IN ('active','paused') — so a tier upgrade did NOT raise the cap on
10+
// the suspended resource, making "upgrade to restore access" a no-op for the
11+
// very row that tripped the limit. The fix adds 'suspended' to the filter so
12+
// the upgrade lifts the suspended row to the new (higher-cap) tier.
13+
//
14+
// NOTE (worker follow-up #3): the actual unsuspend transition — flipping
15+
// status back to 'active' and reversing the provider-side CONNECT/ACL REVOKE —
16+
// lives in the worker, not the api. This test pins ONLY the api half: the
17+
// suspended row's tier IS elevated.
18+
19+
import (
20+
"context"
21+
"database/sql"
22+
"testing"
23+
24+
"github.com/google/uuid"
25+
"github.com/stretchr/testify/assert"
26+
"github.com/stretchr/testify/require"
27+
28+
"instant.dev/internal/models"
29+
"instant.dev/internal/testhelpers"
30+
)
31+
32+
// insertSuspendedResourceForTest inserts a permanent (no TTL) team-owned
33+
// resource pinned to status='suspended' so we can assert the elevation path
34+
// reaches quota-suspended rows.
35+
func insertSuspendedResourceForTest(t *testing.T, db *sql.DB, teamID uuid.UUID, tier string) uuid.UUID {
36+
t.Helper()
37+
var id uuid.UUID
38+
err := db.QueryRowContext(context.Background(), `
39+
INSERT INTO resources (team_id, token, resource_type, tier, env, status, expires_at)
40+
VALUES ($1, $2, 'redis', $3, 'production', 'suspended', NULL)
41+
RETURNING id
42+
`, teamID, uuid.NewString(), tier).Scan(&id)
43+
require.NoError(t, err)
44+
t.Cleanup(func() { db.Exec(`DELETE FROM resources WHERE id = $1`, id) })
45+
return id
46+
}
47+
48+
// TestElevate_Suspended_TierElevated reproduces the suspend@hobby-cap →
49+
// upgrade-to-pro scenario: a resource the quota enforcer suspended at the
50+
// hobby cap MUST have its tier raised to pro on upgrade, otherwise the new
51+
// (larger) pro cap never applies to it.
52+
func TestElevate_Suspended_TierElevated(t *testing.T) {
53+
requireDBElevate(t)
54+
db, cleanup := testhelpers.SetupTestDB(t)
55+
defer cleanup()
56+
57+
teamID := uuid.MustParse(testhelpers.MustCreateTeamDB(t, db, "pro"))
58+
// Resource was at hobby tier and got suspended for exceeding the hobby cap.
59+
resourceID := insertSuspendedResourceForTest(t, db, teamID, "hobby")
60+
61+
err := models.ElevateResourceTiersByTeam(context.Background(), db, teamID, "pro")
62+
require.NoError(t, err)
63+
64+
var tier, status string
65+
err = db.QueryRow(`SELECT tier, status FROM resources WHERE id = $1`, resourceID).
66+
Scan(&tier, &status)
67+
require.NoError(t, err)
68+
assert.Equal(t, "pro", tier,
69+
"a quota-suspended resource MUST be elevated to the new tier on upgrade")
70+
// The api elevation raises the cap only; the status flip back to 'active'
71+
// (and provider re-grant) is the worker's job (#3). Pin that the api does
72+
// NOT itself unsuspend — so a future api-side change to also flip status
73+
// is a deliberate decision, not an accident.
74+
assert.Equal(t, "suspended", status,
75+
"api elevation raises the tier only; the unsuspend status flip is the worker's job (#3)")
76+
}
77+
78+
// TestElevate_SuspendedFilterIncludesSuspended is the registry-style guard for
79+
// the status filter itself: it inserts one resource per relevant non-terminal
80+
// status (active, paused, suspended) and asserts ALL THREE are elevated by a
81+
// single ElevateResourceTiersByTeam call. If a future edit drops 'suspended'
82+
// (or any of the three) from the filter, this fails — it pins the exact set
83+
// the elevation must cover, not a hand-typed assertion on one row.
84+
func TestElevate_SuspendedFilterIncludesSuspended(t *testing.T) {
85+
requireDBElevate(t)
86+
db, cleanup := testhelpers.SetupTestDB(t)
87+
defer cleanup()
88+
89+
teamID := uuid.MustParse(testhelpers.MustCreateTeamDB(t, db, "pro"))
90+
91+
statuses := []string{"active", "paused", "suspended"}
92+
ids := make(map[string]uuid.UUID, len(statuses))
93+
for _, st := range statuses {
94+
var id uuid.UUID
95+
err := db.QueryRowContext(context.Background(), `
96+
INSERT INTO resources (team_id, token, resource_type, tier, env, status, expires_at)
97+
VALUES ($1, $2, 'redis', 'hobby', 'production', $3, NULL)
98+
RETURNING id
99+
`, teamID, uuid.NewString(), st).Scan(&id)
100+
require.NoError(t, err)
101+
ids[st] = id
102+
t.Cleanup(func() { db.Exec(`DELETE FROM resources WHERE id = $1`, id) })
103+
}
104+
105+
err := models.ElevateResourceTiersByTeam(context.Background(), db, teamID, "pro")
106+
require.NoError(t, err)
107+
108+
for _, st := range statuses {
109+
var tier string
110+
require.NoError(t, db.QueryRow(`SELECT tier FROM resources WHERE id = $1`, ids[st]).Scan(&tier))
111+
assert.Equalf(t, "pro", tier,
112+
"resource with status=%q must be elevated by ElevateResourceTiersByTeam", st)
113+
}
114+
}

0 commit comments

Comments
 (0)