Skip to content

Commit 651fbef

Browse files
test(ci): Wave 3a+4 — multi-tier e2e factory with_resources + Razorpay test-card payment integration
Wave 3a (#60) + Wave 4 (#61) api-side, per docs/ci/01-CI-INTEGRATION-DESIGN.md. Both are test-infrastructure, token/secret-gated, inert in prod. Part A — multi-tier test-user factory (extends /internal/e2e/account) - The tier param + team/growth-400 gate + paid-tier elevation already shipped in the prior factory PR; this adds the remaining brief item: with_resources. - with_resources=true pre-seeds a small set of FAST, row-only resources (webhook + cache) on the minted team — synchronous, no backend RPC, tier- snapshotted at the team's tier via the same CreateResource→MarkResourceActive two-phase lifecycle a real provision uses — so a journey can start populated. Seeded tokens surface on the response (seeded_tokens/seeded_count, always a non-null array). Rows are reaped with the team by ReapAccount. - Default tier stays "free" (NOT pro): the brief assumed current default was pro, but it has always been free — changing it would silently hand CI a paid tier and break the existing empty-body test. Kept free; documented. - Tests (registry-iterating, rule 18): every allowed tier round-trips + reflects in the minted team's plan_tier (anonymous→free team plan); every blocked tier (team/growth) → 400 tier_not_allowed; with_resources seeds exactly the handler's seed set as active/owned/tier-snapshotted rows; omitting it seeds nothing. team-tier-rejected proof retained. Part B — Razorpay test-card payment integration (Approach B, webhook-injection) - New billing_testcard_payment_test.go: real-backend, runs in the api test gate against test Postgres (NOT the live-k8s e2e suite). Razorpay TEST MODE needs no recurring approval, so this is unblocked despite the prod live-checkout operator gate. - Constructs the EXACT subscription.charged body (raw bytes, signed in place, created_at inside the ±5-min replay window), signs hex(HMAC-SHA256(rawBody, secret)) via the shared signRazorpayPayload (parity with verifyRazorpaySignature), POSTs to /razorpay/webhook with X-Razorpay-Signature + x-razorpay-event-id. - Handler maps subscription→team via resolveTeamFromNotes: notes.team_id (UUID, primary) → fallback GetTeamByRazorpaySubscriptionID(sub.id). Test stamps notes.team_id. - Asserts: tier→pro AND an active permanent resource elevated to pro (ElevateResourceTiersByTeam, inside UpgradeTeamAllTiersWithSubscription) AND the upgrade contract surfaces (plans.Registry resolves pro limits > free for the elevated resource — the /api/v1/capabilities source of truth). - Negatives: tampered body → 400 no upgrade; wrong secret → 400 no upgrade. - Idempotency: same x-razorpay-event-id twice → deduped:true + exactly one razorpay_webhook_events row + tier upgraded once. - Failure path: payment.failed → 200, NO upgrade (state asserted, not delivery; failure email is webhook-gated per project_payment_failure_email_coverage). Verify: go build/vet ./... green; new factory + payment tests pass on a fresh test DB. (Local full -p 1 surfaces pre-existing pollution flakes in untouched packages — models TestLinkGitHubID, storage anon-quota cap — that pass on a clean DB; CI's ephemeral DB is authoritative.) Awaiting web wave to drive UI journeys against the multi-tier factory. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 006aa6b commit 651fbef

4 files changed

Lines changed: 551 additions & 14 deletions

File tree

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
package handlers_test
2+
3+
// billing_testcard_payment_test.go — Wave 4 (docs/ci/01-CI-INTEGRATION-DESIGN.md
4+
// §Razorpay, Approach B): the deterministic webhook-injection payment
5+
// integration test. Runs in the api test gate against the test Postgres — NOT
6+
// the live-k8s e2e suite (that lives in e2e/plan_upgrade_e2e_test.go and drives
7+
// the real cluster). This is the per-PR, hermetic, real-backend proof that the
8+
// free → upgrade → Pro payment path works end-to-end through RazorpayWebhook.
9+
//
10+
// Razorpay TEST MODE needs no recurring approval (the prod live-checkout gate is
11+
// an operator/account blocker, not a code one), so Approach B is green TODAY:
12+
// it self-signs the exact subscription.charged webhook body and POSTs it,
13+
// exercising the real handler path the live Razorpay would hit.
14+
//
15+
// Coverage (mirrors the brief):
16+
// - mint a free cohort team (Part A factory model path via testhelpers).
17+
// - construct the EXACT subscription.charged body (raw bytes, signed in place
18+
// — never re-marshalled after signing, so the HMAC matches verbatim).
19+
// - sign hex(HMAC-SHA256(rawBody, webhookSecret)) — reusing the SAME
20+
// signRazorpayPayload the existing suite + the verifier agree on.
21+
// - assert tier upgraded to pro AND active permanent resources were elevated
22+
// (ElevateResourceTiersByTeam ran inside UpgradeTeamAllTiersWithSubscription).
23+
// - assert the upgrade contract surfaces: plans.Registry resolves pro limits
24+
// that are strictly larger than free, and the elevated resource now carries
25+
// the pro snapshot — a sanity check that the elevation is real, not just a
26+
// plan_tier column flip.
27+
// - NEGATIVES: tampered body / wrong secret → 400, NO upgrade.
28+
// - IDEMPOTENCY: same x-razorpay-event-id twice → exactly one upgrade.
29+
// - FAILURE PATH: payment.failed → no upgrade (+ the grace/audit state the
30+
// handler produces; we assert state, not email delivery — the failure email
31+
// is webhook-gated per project_payment_failure_email_coverage).
32+
33+
import (
34+
"bytes"
35+
"database/sql"
36+
"encoding/json"
37+
"net/http"
38+
"net/http/httptest"
39+
"os"
40+
"testing"
41+
"time"
42+
43+
"github.com/google/uuid"
44+
"github.com/stretchr/testify/assert"
45+
"github.com/stretchr/testify/require"
46+
47+
"instant.dev/internal/plans"
48+
"instant.dev/internal/testhelpers"
49+
)
50+
51+
// testCardSkipUnlessDB skips the suite when no test Postgres is wired (mirrors
52+
// dunningWebhookSkipUnlessDB so a baseline `go test -short` with no DB is clean).
53+
func testCardSkipUnlessDB(t *testing.T) {
54+
t.Helper()
55+
if os.Getenv("TEST_DATABASE_URL") == "" {
56+
t.Skip("billing test-card payment: TEST_DATABASE_URL not set")
57+
}
58+
}
59+
60+
// nowUnix returns the current Unix-second timestamp — used to stamp the
61+
// webhook body's created_at inside the handler's ±5-min replay window so the
62+
// timestamp guard (verifyRazorpayTimestamp) accepts the event.
63+
func nowUnix() int64 { return time.Now().Unix() }
64+
65+
// subscriptionChargedRawBody builds the EXACT subscription.charged JSON body the
66+
// handler reads, with a `created_at` inside the ±5-min replay window so the
67+
// timestamp guard accepts it. Returns the raw bytes — the caller signs THESE
68+
// bytes and POSTs THEM unchanged (re-marshalling after signing would change the
69+
// byte order and break the HMAC).
70+
//
71+
// teamID is stamped into notes.team_id (resolveTeamFromNotes' primary path).
72+
// planID is the Pro plan_id so planIDToTier resolves "pro".
73+
func subscriptionChargedRawBody(t *testing.T, teamID, subID, planID string, createdAt int64) []byte {
74+
t.Helper()
75+
subEntity, err := json.Marshal(map[string]any{
76+
"id": subID,
77+
"entity": "subscription",
78+
"plan_id": planID,
79+
"status": "active",
80+
"notes": map[string]any{"team_id": teamID},
81+
})
82+
require.NoError(t, err)
83+
payEntity, err := json.Marshal(map[string]any{
84+
"id": "pay_test_" + uuid.NewString()[:12],
85+
"entity": "payment",
86+
"status": "captured",
87+
"amount": 410000,
88+
"currency": "INR",
89+
})
90+
require.NoError(t, err)
91+
event := map[string]any{
92+
"id": "evt_test_" + uuid.NewString(),
93+
"entity": "event",
94+
"event": "subscription.charged",
95+
"created_at": createdAt,
96+
"payload": map[string]any{
97+
"subscription": map[string]any{"entity": json.RawMessage(subEntity)},
98+
"payment": map[string]any{"entity": json.RawMessage(payEntity)},
99+
},
100+
}
101+
body, err := json.Marshal(event)
102+
require.NoError(t, err)
103+
return body
104+
}
105+
106+
// postSignedWebhookRaw signs the EXACT bytes with the given secret (reusing the
107+
// shared signRazorpayPayload — the same primitive verifyRazorpaySignature
108+
// checks, guaranteeing parity) and POSTs them unchanged, optionally setting
109+
// X-Razorpay-Event-Id. Returns the response.
110+
func postSignedWebhookRaw(t *testing.T, app interface {
111+
Test(*http.Request, ...int) (*http.Response, error)
112+
}, secret string, body []byte, eventID string,
113+
) *http.Response {
114+
t.Helper()
115+
sig := signRazorpayPayload(t, secret, body)
116+
req := httptest.NewRequest(http.MethodPost, "/razorpay/webhook", bytes.NewReader(body))
117+
req.Header.Set("Content-Type", "application/json")
118+
req.Header.Set("X-Razorpay-Signature", sig)
119+
if eventID != "" {
120+
req.Header.Set("X-Razorpay-Event-Id", eventID)
121+
}
122+
resp, err := app.Test(req, 5000)
123+
require.NoError(t, err)
124+
return resp
125+
}
126+
127+
// ── HAPPY PATH: free → subscription.charged(pro) → pro + resources elevated ──
128+
129+
func TestBillingTestCard_SubscriptionCharged_UpgradesAndElevatesResources(t *testing.T) {
130+
testCardSkipUnlessDB(t)
131+
db, cleanDB := testhelpers.SetupTestDB(t)
132+
defer cleanDB()
133+
app, cfg := billingWebhookDBApp(t, db)
134+
135+
teamID := testhelpers.MustCreateTeamDB(t, db, "free")
136+
defer db.Exec(`DELETE FROM teams WHERE id = $1::uuid`, teamID)
137+
138+
// A real active permanent resource, minted under the free tier — it must be
139+
// elevated to pro by the charge (ElevateResourceTiersByTeam).
140+
_, resID := seedActiveResource(t, db, teamID, "redis", "free")
141+
142+
body := subscriptionChargedRawBody(t, teamID, "sub_"+uuid.NewString(), cfg.RazorpayPlanIDPro, nowUnix())
143+
resp := postSignedWebhookRaw(t, app, testWebhookSecret, body, "evt_"+uuid.NewString())
144+
require.Equal(t, http.StatusOK, resp.StatusCode, "valid signed subscription.charged must 200")
145+
146+
// Tier upgraded.
147+
var planTier string
148+
require.NoError(t, db.QueryRow(`SELECT plan_tier FROM teams WHERE id = $1::uuid`, teamID).Scan(&planTier))
149+
assert.Equal(t, "pro", planTier, "subscription.charged with the Pro plan_id must upgrade the team to pro")
150+
151+
// Active permanent resource elevated to the pro snapshot.
152+
var resTier string
153+
require.NoError(t, db.QueryRow(`SELECT tier FROM resources WHERE id = $1::uuid`, resID).Scan(&resTier))
154+
assert.Equal(t, "pro", resTier, "ElevateResourceTiersByTeam must lift the active resource to pro")
155+
156+
// Upgrade contract surface: pro limits resolve strictly larger than free,
157+
// and the elevated resource now resolves the larger limit. Reads the live
158+
// plans.Registry (not hardcoded numbers) so a plans.yaml change can't drift
159+
// this assertion into a lie.
160+
reg := plans.Default()
161+
freeRedis := reg.StorageLimitMB("free", "redis")
162+
proRedis := reg.StorageLimitMB("pro", "redis")
163+
require.Greater(t, proRedis, freeRedis,
164+
"sanity: pro redis limit must exceed free (else the elevation proves nothing)")
165+
assert.Equal(t, proRedis, reg.StorageLimitMB(resTier, "redis"),
166+
"the elevated resource's tier must resolve pro-tier limits")
167+
}
168+
169+
// ── NEGATIVE: tampered body → 400, NO upgrade ───────────────────────────────
170+
171+
func TestBillingTestCard_TamperedBody_Rejected_NoUpgrade(t *testing.T) {
172+
testCardSkipUnlessDB(t)
173+
db, cleanDB := testhelpers.SetupTestDB(t)
174+
defer cleanDB()
175+
app, cfg := billingWebhookDBApp(t, db)
176+
177+
teamID := testhelpers.MustCreateTeamDB(t, db, "free")
178+
defer db.Exec(`DELETE FROM teams WHERE id = $1::uuid`, teamID)
179+
180+
body := subscriptionChargedRawBody(t, teamID, "sub_"+uuid.NewString(), cfg.RazorpayPlanIDPro, nowUnix())
181+
// Sign the ORIGINAL body, then tamper a byte AFTER signing → HMAC no longer
182+
// matches the bytes on the wire.
183+
sig := signRazorpayPayload(t, testWebhookSecret, body)
184+
tampered := append([]byte{}, body...)
185+
tampered[len(tampered)/2] ^= 0xFF // flip a byte in the middle
186+
187+
req := httptest.NewRequest(http.MethodPost, "/razorpay/webhook", bytes.NewReader(tampered))
188+
req.Header.Set("Content-Type", "application/json")
189+
req.Header.Set("X-Razorpay-Signature", sig)
190+
resp, err := app.Test(req, 5000)
191+
require.NoError(t, err)
192+
require.Equal(t, http.StatusBadRequest, resp.StatusCode, "tampered body must be rejected 400")
193+
194+
assertNotUpgraded(t, db, teamID)
195+
}
196+
197+
// ── NEGATIVE: wrong secret → 400, NO upgrade ────────────────────────────────
198+
199+
func TestBillingTestCard_WrongSecret_Rejected_NoUpgrade(t *testing.T) {
200+
testCardSkipUnlessDB(t)
201+
db, cleanDB := testhelpers.SetupTestDB(t)
202+
defer cleanDB()
203+
app, cfg := billingWebhookDBApp(t, db)
204+
205+
teamID := testhelpers.MustCreateTeamDB(t, db, "free")
206+
defer db.Exec(`DELETE FROM teams WHERE id = $1::uuid`, teamID)
207+
208+
body := subscriptionChargedRawBody(t, teamID, "sub_"+uuid.NewString(), cfg.RazorpayPlanIDPro, nowUnix())
209+
// Sign with a DIFFERENT secret than the handler is configured with.
210+
resp := postSignedWebhookRaw(t, app, "totally-the-wrong-secret", body, "evt_"+uuid.NewString())
211+
require.Equal(t, http.StatusBadRequest, resp.StatusCode, "wrong-secret signature must be rejected 400")
212+
213+
assertNotUpgraded(t, db, teamID)
214+
}
215+
216+
// ── IDEMPOTENCY: same x-razorpay-event-id twice → exactly one upgrade ────────
217+
218+
func TestBillingTestCard_DuplicateEventID_UpgradesExactlyOnce(t *testing.T) {
219+
testCardSkipUnlessDB(t)
220+
db, cleanDB := testhelpers.SetupTestDB(t)
221+
defer cleanDB()
222+
app, cfg := billingWebhookDBApp(t, db)
223+
224+
teamID := testhelpers.MustCreateTeamDB(t, db, "free")
225+
defer db.Exec(`DELETE FROM teams WHERE id = $1::uuid`, teamID)
226+
227+
// ONE body + ONE event_id, replayed.
228+
body := subscriptionChargedRawBody(t, teamID, "sub_"+uuid.NewString(), cfg.RazorpayPlanIDPro, nowUnix())
229+
eventID := "evt_replay_" + uuid.NewString()
230+
231+
resp1 := postSignedWebhookRaw(t, app, testWebhookSecret, body, eventID)
232+
require.Equal(t, http.StatusOK, resp1.StatusCode, "first delivery must 200")
233+
var planAfterFirst string
234+
require.NoError(t, db.QueryRow(`SELECT plan_tier FROM teams WHERE id = $1::uuid`, teamID).Scan(&planAfterFirst))
235+
require.Equal(t, "pro", planAfterFirst, "first delivery must upgrade to pro")
236+
237+
resp2 := postSignedWebhookRaw(t, app, testWebhookSecret, body, eventID)
238+
require.Equal(t, http.StatusOK, resp2.StatusCode, "replayed delivery must 200")
239+
var replay struct {
240+
OK bool `json:"ok"`
241+
Deduped bool `json:"deduped"`
242+
}
243+
require.NoError(t, decodeJSON(resp2, &replay))
244+
assert.True(t, replay.Deduped,
245+
"the second delivery of the same event_id must be deduped (upgrade state machine fires exactly once)")
246+
247+
// The dedup table must hold exactly one row for this event_id — the
248+
// load-bearing "exactly once" proof (the tier column would read pro after a
249+
// re-fire too, so the count is the real guard).
250+
var n int
251+
require.NoError(t, db.QueryRow(`SELECT count(*) FROM razorpay_webhook_events WHERE event_id = $1`, eventID).Scan(&n))
252+
assert.Equal(t, 1, n, "exactly one dedup row must exist for a replayed event_id")
253+
}
254+
255+
// ── FAILURE PATH: payment.failed → no upgrade ───────────────────────────────
256+
257+
func TestBillingTestCard_PaymentFailed_NoUpgrade(t *testing.T) {
258+
testCardSkipUnlessDB(t)
259+
db, cleanDB := testhelpers.SetupTestDB(t)
260+
defer cleanDB()
261+
app, _ := billingWebhookDBApp(t, db)
262+
263+
teamID := testhelpers.MustCreateTeamDB(t, db, "free")
264+
defer db.Exec(`DELETE FROM teams WHERE id = $1::uuid`, teamID)
265+
266+
// A payment.failed carrying notes.team_id (so the handler resolves the team)
267+
// must NOT upgrade — a declined card never grants a tier.
268+
payEntity, err := json.Marshal(map[string]any{
269+
"id": "pay_test_" + uuid.NewString()[:12],
270+
"entity": "payment",
271+
"status": "failed",
272+
"amount": 410000,
273+
"currency": "INR",
274+
"attempt_count": 1,
275+
"error_description": "Card declined (test)",
276+
"notes": map[string]any{"team_id": teamID},
277+
})
278+
require.NoError(t, err)
279+
event := map[string]any{
280+
"id": "evt_test_" + uuid.NewString(),
281+
"entity": "event",
282+
"event": "payment.failed",
283+
"created_at": nowUnix(),
284+
"payload": map[string]any{
285+
"payment": map[string]any{"entity": json.RawMessage(payEntity)},
286+
},
287+
}
288+
body, err := json.Marshal(event)
289+
require.NoError(t, err)
290+
291+
resp := postSignedWebhookRaw(t, app, testWebhookSecret, body, "evt_"+uuid.NewString())
292+
require.Equal(t, http.StatusOK, resp.StatusCode, "payment.failed must 200 (acknowledged, not retried)")
293+
294+
// The team must stay on free — the declined payment grants nothing.
295+
assertNotUpgraded(t, db, teamID)
296+
}
297+
298+
// assertNotUpgraded asserts the team is still on the free tier (no upgrade
299+
// leaked through) — the shared negative-path invariant.
300+
func assertNotUpgraded(t *testing.T, db *sql.DB, teamID string) {
301+
t.Helper()
302+
var planTier string
303+
require.NoError(t, db.QueryRow(`SELECT plan_tier FROM teams WHERE id = $1::uuid`, teamID).Scan(&planTier))
304+
assert.Equal(t, "free", planTier, "a rejected/failed payment must NOT upgrade the team")
305+
}

0 commit comments

Comments
 (0)