|
| 1 | +/* claim-flow.spec.ts — Chrome-MCP suite S3 (Claim flow), automated. |
| 2 | + * |
| 3 | + * The claim flow is the anonymous→owned funnel: an agent provisions a |
| 4 | + * 24h-TTL resource, the user opens /claim?t=<jwt>, sees a preview, enters |
| 5 | + * an email, and is dropped into the post-claim payment funnel. Before this |
| 6 | + * spec there was ZERO Playwright coverage of /claim — a regression in the |
| 7 | + * JWT decode, the preview render, the single-use 409 path, or the funnel |
| 8 | + * countdown would have shipped silently. |
| 9 | + * |
| 10 | + * All API calls are page.route()-mocked (CLAUDE.md convention 10) so the |
| 11 | + * suite is hermetic — it creates nothing on any backend and needs no |
| 12 | + * teardown. The claim JWT is a hand-built unsigned token: ClaimPage only |
| 13 | + * base64-decodes the payload client-side to render the preview; the real |
| 14 | + * signature check happens server-side on POST /claim, which we mock. |
| 15 | + */ |
| 16 | + |
| 17 | +import { expect, test, type Route } from '@playwright/test' |
| 18 | +import { FAKE_RAZORPAY_SHORT_URL, FAKE_TEAM } from './fixtures' |
| 19 | + |
| 20 | +// buildClaimJWT — assembles an unsigned 3-segment JWT whose payload carries |
| 21 | +// the resource-type + token arrays ClaimPage.decodeJWT() reads. The page |
| 22 | +// never verifies the signature, so a literal "sig" third segment is fine. |
| 23 | +function buildClaimJWT(payload: Record<string, unknown>): string { |
| 24 | + const b64 = (o: unknown) => |
| 25 | + Buffer.from(JSON.stringify(o)).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_') |
| 26 | + return `${b64({ alg: 'HS256', typ: 'JWT' })}.${b64(payload)}.sig` |
| 27 | +} |
| 28 | + |
| 29 | +const VALID_CLAIM_JWT = buildClaimJWT({ |
| 30 | + rt: ['postgres', 'redis'], |
| 31 | + tok: ['11111111aaaa', '22222222bbbb'], |
| 32 | + exp: Math.floor(Date.now() / 1000) + 3600, |
| 33 | +}) |
| 34 | + |
| 35 | +test.describe('Claim flow (S3)', () => { |
| 36 | + test('S3.0 — missing token renders the "Missing claim link" guard', async ({ page }) => { |
| 37 | + await page.goto('/claim') |
| 38 | + await expect(page.getByRole('heading', { name: /missing claim link/i })).toBeVisible() |
| 39 | + }) |
| 40 | + |
| 41 | + test('S3.1 — claim preview shows resource types before claiming', async ({ page }) => { |
| 42 | + await page.goto(`/claim?t=${VALID_CLAIM_JWT}`) |
| 43 | + // The preview card lists each resource decoded from the JWT. |
| 44 | + const preview = page.getByTestId('claim-preview') |
| 45 | + await expect(preview).toBeVisible() |
| 46 | + await expect(preview.getByText('postgres')).toBeVisible() |
| 47 | + await expect(preview.getByText('redis')).toBeVisible() |
| 48 | + // Email entry form is present — claim hasn't happened yet. |
| 49 | + await expect(page.getByTestId('claim-email')).toBeVisible() |
| 50 | + }) |
| 51 | + |
| 52 | + test('S3.5 — malformed/expired token surfaces the invalid-link banner', async ({ page }) => { |
| 53 | + // A non-JWT string fails decodeJWT() → previewErr branch. Before §10.21 |
| 54 | + // this rendered a blank email form looking like a normal claim. |
| 55 | + await page.goto('/claim?t=not-a-real-jwt') |
| 56 | + await expect(page.getByTestId('claim-invalid')).toBeVisible() |
| 57 | + await expect(page.getByTestId('claim-invalid-error')).toBeVisible() |
| 58 | + await expect(page.getByTestId('claim-invalid-pricing')).toBeVisible() |
| 59 | + }) |
| 60 | + |
| 61 | + test('S3.4 — successful claim drops the user into the payment funnel', async ({ page }) => { |
| 62 | + // POST /claim → session token. The page then mints a PAT and lists |
| 63 | + // resources to drive the countdown. Mock all three. |
| 64 | + await page.route('**/claim', (route: Route) => { |
| 65 | + if (route.request().method() !== 'POST') return route.continue() |
| 66 | + return route.fulfill({ |
| 67 | + status: 200, |
| 68 | + contentType: 'application/json', |
| 69 | + body: JSON.stringify({ ok: true, session_token: 'sess_FAKE_JWT', team_id: FAKE_TEAM }), |
| 70 | + }) |
| 71 | + }) |
| 72 | + await page.route('**/api/v1/auth/api-keys', (route: Route) => { |
| 73 | + if (route.request().method() !== 'POST') return route.continue() |
| 74 | + return route.fulfill({ |
| 75 | + status: 201, |
| 76 | + contentType: 'application/json', |
| 77 | + body: JSON.stringify({ ok: true, id: 'k_new', name: 'dashboard-session', key: 'ink_CLAIMED' }), |
| 78 | + }) |
| 79 | + }) |
| 80 | + await page.route(/\/api\/v1\/resources(\?[^/]*)?$/, (route: Route) => { |
| 81 | + if (route.request().method() !== 'GET') return route.continue() |
| 82 | + return route.fulfill({ |
| 83 | + status: 200, |
| 84 | + contentType: 'application/json', |
| 85 | + body: JSON.stringify({ |
| 86 | + ok: true, |
| 87 | + total: 1, |
| 88 | + items: [ |
| 89 | + { |
| 90 | + id: '11111111-aaaa-bbbb-cccc-000000000001', |
| 91 | + token: '11111111-aaaa-bbbb-cccc-000000000001', |
| 92 | + resource_type: 'postgres', |
| 93 | + name: 'agent-db', |
| 94 | + env: 'production', |
| 95 | + tier: 'anonymous', |
| 96 | + status: 'active', |
| 97 | + storage_bytes: 0, |
| 98 | + storage_limit_bytes: 10_000_000, |
| 99 | + storage_exceeded: false, |
| 100 | + connections_in_use: 0, |
| 101 | + connections_limit: 2, |
| 102 | + created_at: new Date().toISOString(), |
| 103 | + team_id: FAKE_TEAM, |
| 104 | + // 24h TTL — drives the funnel countdown. |
| 105 | + expires_at: new Date(Date.now() + 23 * 3600_000).toISOString(), |
| 106 | + }, |
| 107 | + ], |
| 108 | + }), |
| 109 | + }) |
| 110 | + }) |
| 111 | + |
| 112 | + await page.goto(`/claim?t=${VALID_CLAIM_JWT}`) |
| 113 | + await page.getByTestId('claim-email').fill('founder@example.com') |
| 114 | + await page.getByTestId('claim-submit').click() |
| 115 | + |
| 116 | + // Post-claim funnel: countdown banner + both checkout CTAs. |
| 117 | + await expect(page.getByTestId('claim-funnel')).toBeVisible() |
| 118 | + await expect(page.getByTestId('claim-countdown')).toBeVisible() |
| 119 | + // The countdown shows a real HH:MM:SS, not the "—" no-data placeholder. |
| 120 | + await expect(page.getByTestId('claim-countdown-value')).not.toHaveText('—') |
| 121 | + await expect(page.getByTestId('claim-checkout-hobby')).toBeVisible() |
| 122 | + await expect(page.getByTestId('claim-checkout-pro')).toBeVisible() |
| 123 | + }) |
| 124 | + |
| 125 | + test('S3.3 — single-use claim: a 409 replay surfaces the conflict error', async ({ page }) => { |
| 126 | + // POST /claim returns 409 — the JWT was already consumed (atomic |
| 127 | + // single-use claim, CLAUDE.md convention 7). |
| 128 | + await page.route('**/claim', (route: Route) => { |
| 129 | + if (route.request().method() !== 'POST') return route.continue() |
| 130 | + return route.fulfill({ |
| 131 | + status: 409, |
| 132 | + contentType: 'application/json', |
| 133 | + body: JSON.stringify({ ok: false, error: 'already_claimed', message: 'This claim link was already used.' }), |
| 134 | + }) |
| 135 | + }) |
| 136 | + |
| 137 | + await page.goto(`/claim?t=${VALID_CLAIM_JWT}`) |
| 138 | + await page.getByTestId('claim-email').fill('founder@example.com') |
| 139 | + await page.getByTestId('claim-submit').click() |
| 140 | + |
| 141 | + // The error stage surfaces the 409 message — no crash, no funnel. |
| 142 | + await expect(page.getByTestId('claim-error')).toBeVisible() |
| 143 | + await expect(page.getByTestId('claim-error')).toContainText(/already used/i) |
| 144 | + }) |
| 145 | + |
| 146 | + test('S3.6 — funnel "Keep my resources" CTA opens Razorpay checkout', async ({ page }) => { |
| 147 | + await page.route('**/claim', (route: Route) => { |
| 148 | + if (route.request().method() !== 'POST') return route.continue() |
| 149 | + return route.fulfill({ |
| 150 | + status: 200, |
| 151 | + contentType: 'application/json', |
| 152 | + body: JSON.stringify({ ok: true, session_token: 'sess_FAKE_JWT', team_id: FAKE_TEAM }), |
| 153 | + }) |
| 154 | + }) |
| 155 | + await page.route('**/api/v1/auth/api-keys', (route: Route) => |
| 156 | + route.fulfill({ |
| 157 | + status: 201, |
| 158 | + contentType: 'application/json', |
| 159 | + body: JSON.stringify({ ok: true, id: 'k_new', name: 'dashboard-session', key: 'ink_CLAIMED' }), |
| 160 | + }), |
| 161 | + ) |
| 162 | + await page.route(/\/api\/v1\/resources(\?[^/]*)?$/, (route: Route) => |
| 163 | + route.fulfill({ |
| 164 | + status: 200, |
| 165 | + contentType: 'application/json', |
| 166 | + body: JSON.stringify({ ok: true, total: 0, items: [] }), |
| 167 | + }), |
| 168 | + ) |
| 169 | + // The checkout call returns a Razorpay short_url. We intercept the |
| 170 | + // navigation to the mock URL so the test stays hermetic — it asserts |
| 171 | + // the redirect was attempted without ever loading rzp.io. |
| 172 | + let navigatedTo: string | null = null |
| 173 | + await page.route('**/api/v1/billing/checkout', (route: Route) => { |
| 174 | + if (route.request().method() !== 'POST') return route.continue() |
| 175 | + return route.fulfill({ |
| 176 | + status: 200, |
| 177 | + contentType: 'application/json', |
| 178 | + body: JSON.stringify({ ok: true, short_url: FAKE_RAZORPAY_SHORT_URL }), |
| 179 | + }) |
| 180 | + }) |
| 181 | + await page.route(FAKE_RAZORPAY_SHORT_URL + '**', (route: Route) => { |
| 182 | + navigatedTo = route.request().url() |
| 183 | + return route.fulfill({ status: 200, contentType: 'text/html', body: '<html><body>razorpay stub</body></html>' }) |
| 184 | + }) |
| 185 | + |
| 186 | + await page.goto(`/claim?t=${VALID_CLAIM_JWT}`) |
| 187 | + await page.getByTestId('claim-email').fill('founder@example.com') |
| 188 | + await page.getByTestId('claim-submit').click() |
| 189 | + await expect(page.getByTestId('claim-funnel')).toBeVisible() |
| 190 | + |
| 191 | + await page.getByTestId('claim-checkout-hobby').click() |
| 192 | + await expect.poll(() => navigatedTo).toContain('rzp.io') |
| 193 | + }) |
| 194 | +}) |
0 commit comments