Skip to content
Open
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
37 changes: 36 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,29 @@ jobs:
- run: npm run build
- run: npm test

# Playwright browser/UI gate — MANDATORY.
#
# This job drives a real browser through the user-facing flows a
# dashboard change must never silently break: auth guards, the claim
# flow, dashboard render, and — the headline — the payments/upgrade
# journey (checkout success / failure / already-on-plan, invoice
# rendering, the 429 retry hint). The API and Razorpay are
# page.route()-mocked (hermetic — VITE_NO_PROXY=1, playwright.config.ts
# MOCKED mode), so the suite is deterministic and creates nothing.
#
# HARD GATE: a UI regression fails this job. For it to BLOCK a PR, the
# `playwright (mandatory UI gate)` check must be listed as a required
# status check in the branch-protection rule for `main` (Settings →
# Branches → main → Require status checks to pass). Adding the job here
# makes the check available; the operator must tick it as required.
#
# `npm run test:e2e:ci` is the exact local equivalent of the run step —
# run it before pushing a dashboard change.
#
# If you add a dashboard route or change a user-facing flow, add the
# matching e2e/*.spec.ts coverage so this gate keeps protecting it.
playwright:
name: playwright (mandatory UI gate)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
Expand All @@ -33,4 +55,17 @@ jobs:

- run: npm ci
- run: npx playwright install --with-deps chromium
- run: VITE_NO_PROXY=1 npx playwright test --project=chromium
# Hermetic mocked run — identical to `npm run test:e2e:ci` locally.
- run: npm run test:e2e:ci

# Upload the HTML report + traces so a red gate is debuggable
# without a local re-run. Runs even on failure.
- name: Upload Playwright report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: |
playwright-report/
test-results/
retention-days: 14
60 changes: 60 additions & 0 deletions e2e/auth-guards.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/* auth-guards.spec.ts — Chrome-MCP suite S2 (Authentication), automated.
*
* auth.spec.ts already covers login render / 401 reject / valid token /
* OAuth button / signed-in landing. This spec extends S2 to the parts a
* dashboard change is most likely to break silently:
* - EVERY gated /app/* route bounces an unauthenticated visitor to
* /login (a new route added without AuthGate is the classic leak);
* - the deep-linked checkout intent survives the login bounce — the
* user lands back at /app/checkout?plan=pro, not a generic dashboard
* (the funnel-drop the Chrome-MCP S1-F3 finding flagged).
*
* Hermetic: page.route()-mocked. Creates nothing; no teardown.
*/

import { expect, test } from '@playwright/test'
import { installAPIFake } from './fixtures'

// Every authenticated surface. A route added to App.tsx without AuthGate
// would let an unauthenticated visitor through — this list is the guard.
const GATED_ROUTES = [
'/app',
'/app/resources',
'/app/deployments',
'/app/vault',
'/app/team',
'/app/billing',
'/app/settings',
'/app/checkout?plan=pro&frequency=monthly',
'/app/admin/customers',
]

test.describe('Auth guards — gated routes (S2.6)', () => {
for (const route of GATED_ROUTES) {
test(`unauthenticated visit to ${route} redirects to /login`, async ({ page }) => {
await page.goto(route)
await expect(page).toHaveURL(/\/login(\?.*)?$/)
await expect(page.getByRole('heading', { name: /Sign in/i })).toBeVisible()
})
}
})

test.describe('Checkout intent survives the login bounce (S1.4 / S5)', () => {
test('deep-linked /app/checkout?plan=pro returns there after login', async ({ page }) => {
await installAPIFake(page)
// An unauthenticated user follows a marketing CTA to the Pro checkout.
await page.goto('/app/checkout?plan=pro&frequency=monthly')
// AuthGate bounces to /login, carrying the intent in router state.
await expect(page).toHaveURL(/\/login(\?.*)?$/)

// Log in with a token. LoginPage reads loc.state.from and navigates
// back to the original checkout deep link — the funnel is preserved.
await page.getByTestId('toggle-token-form').click()
await page.getByTestId('token-input').fill('ink_VALID')
await page.getByTestId('login-submit').click()

// Lands back on the checkout page for the Pro plan — NOT a generic
// /app dashboard. This is the S1-F3 funnel-drop guard.
await expect(page).toHaveURL(/\/app\/checkout\?plan=pro/)
})
})
194 changes: 194 additions & 0 deletions e2e/claim-flow.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
/* claim-flow.spec.ts — Chrome-MCP suite S3 (Claim flow), automated.
*
* The claim flow is the anonymous→owned funnel: an agent provisions a
* 24h-TTL resource, the user opens /claim?t=<jwt>, sees a preview, enters
* an email, and is dropped into the post-claim payment funnel. Before this
* spec there was ZERO Playwright coverage of /claim — a regression in the
* JWT decode, the preview render, the single-use 409 path, or the funnel
* countdown would have shipped silently.
*
* All API calls are page.route()-mocked (CLAUDE.md convention 10) so the
* suite is hermetic — it creates nothing on any backend and needs no
* teardown. The claim JWT is a hand-built unsigned token: ClaimPage only
* base64-decodes the payload client-side to render the preview; the real
* signature check happens server-side on POST /claim, which we mock.
*/

import { expect, test, type Route } from '@playwright/test'
import { FAKE_RAZORPAY_SHORT_URL, FAKE_TEAM } from './fixtures'

// buildClaimJWT — assembles an unsigned 3-segment JWT whose payload carries
// the resource-type + token arrays ClaimPage.decodeJWT() reads. The page
// never verifies the signature, so a literal "sig" third segment is fine.
function buildClaimJWT(payload: Record<string, unknown>): string {
const b64 = (o: unknown) =>
Buffer.from(JSON.stringify(o)).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
return `${b64({ alg: 'HS256', typ: 'JWT' })}.${b64(payload)}.sig`
}

const VALID_CLAIM_JWT = buildClaimJWT({
rt: ['postgres', 'redis'],
tok: ['11111111aaaa', '22222222bbbb'],
exp: Math.floor(Date.now() / 1000) + 3600,
})

test.describe('Claim flow (S3)', () => {
test('S3.0 — missing token renders the "Missing claim link" guard', async ({ page }) => {
await page.goto('/claim')
await expect(page.getByRole('heading', { name: /missing claim link/i })).toBeVisible()
})

test('S3.1 — claim preview shows resource types before claiming', async ({ page }) => {
await page.goto(`/claim?t=${VALID_CLAIM_JWT}`)
// The preview card lists each resource decoded from the JWT.
const preview = page.getByTestId('claim-preview')
await expect(preview).toBeVisible()
await expect(preview.getByText('postgres')).toBeVisible()
await expect(preview.getByText('redis')).toBeVisible()
// Email entry form is present — claim hasn't happened yet.
await expect(page.getByTestId('claim-email')).toBeVisible()
})

test('S3.5 — malformed/expired token surfaces the invalid-link banner', async ({ page }) => {
// A non-JWT string fails decodeJWT() → previewErr branch. Before §10.21
// this rendered a blank email form looking like a normal claim.
await page.goto('/claim?t=not-a-real-jwt')
await expect(page.getByTestId('claim-invalid')).toBeVisible()
await expect(page.getByTestId('claim-invalid-error')).toBeVisible()
await expect(page.getByTestId('claim-invalid-pricing')).toBeVisible()
})

test('S3.4 — successful claim drops the user into the payment funnel', async ({ page }) => {
// POST /claim → session token. The page then mints a PAT and lists
// resources to drive the countdown. Mock all three.
await page.route('**/claim', (route: Route) => {
if (route.request().method() !== 'POST') return route.continue()
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true, session_token: 'sess_FAKE_JWT', team_id: FAKE_TEAM }),
})
})
await page.route('**/api/v1/auth/api-keys', (route: Route) => {
if (route.request().method() !== 'POST') return route.continue()
return route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({ ok: true, id: 'k_new', name: 'dashboard-session', key: 'ink_CLAIMED' }),
})
})
await page.route(/\/api\/v1\/resources(\?[^/]*)?$/, (route: Route) => {
if (route.request().method() !== 'GET') return route.continue()
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
ok: true,
total: 1,
items: [
{
id: '11111111-aaaa-bbbb-cccc-000000000001',
token: '11111111-aaaa-bbbb-cccc-000000000001',
resource_type: 'postgres',
name: 'agent-db',
env: 'production',
tier: 'anonymous',
status: 'active',
storage_bytes: 0,
storage_limit_bytes: 10_000_000,
storage_exceeded: false,
connections_in_use: 0,
connections_limit: 2,
created_at: new Date().toISOString(),
team_id: FAKE_TEAM,
// 24h TTL — drives the funnel countdown.
expires_at: new Date(Date.now() + 23 * 3600_000).toISOString(),
},
],
}),
})
})

await page.goto(`/claim?t=${VALID_CLAIM_JWT}`)
await page.getByTestId('claim-email').fill('founder@example.com')
await page.getByTestId('claim-submit').click()

// Post-claim funnel: countdown banner + both checkout CTAs.
await expect(page.getByTestId('claim-funnel')).toBeVisible()
await expect(page.getByTestId('claim-countdown')).toBeVisible()
// The countdown shows a real HH:MM:SS, not the "—" no-data placeholder.
await expect(page.getByTestId('claim-countdown-value')).not.toHaveText('—')
await expect(page.getByTestId('claim-checkout-hobby')).toBeVisible()
await expect(page.getByTestId('claim-checkout-pro')).toBeVisible()
})

test('S3.3 — single-use claim: a 409 replay surfaces the conflict error', async ({ page }) => {
// POST /claim returns 409 — the JWT was already consumed (atomic
// single-use claim, CLAUDE.md convention 7).
await page.route('**/claim', (route: Route) => {
if (route.request().method() !== 'POST') return route.continue()
return route.fulfill({
status: 409,
contentType: 'application/json',
body: JSON.stringify({ ok: false, error: 'already_claimed', message: 'This claim link was already used.' }),
})
})

await page.goto(`/claim?t=${VALID_CLAIM_JWT}`)
await page.getByTestId('claim-email').fill('founder@example.com')
await page.getByTestId('claim-submit').click()

// The error stage surfaces the 409 message — no crash, no funnel.
await expect(page.getByTestId('claim-error')).toBeVisible()
await expect(page.getByTestId('claim-error')).toContainText(/already used/i)
})

test('S3.6 — funnel "Keep my resources" CTA opens Razorpay checkout', async ({ page }) => {
await page.route('**/claim', (route: Route) => {
if (route.request().method() !== 'POST') return route.continue()
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true, session_token: 'sess_FAKE_JWT', team_id: FAKE_TEAM }),
})
})
await page.route('**/api/v1/auth/api-keys', (route: Route) =>
route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({ ok: true, id: 'k_new', name: 'dashboard-session', key: 'ink_CLAIMED' }),
}),
)
await page.route(/\/api\/v1\/resources(\?[^/]*)?$/, (route: Route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true, total: 0, items: [] }),
}),
)
// The checkout call returns a Razorpay short_url. We intercept the
// navigation to the mock URL so the test stays hermetic — it asserts
// the redirect was attempted without ever loading rzp.io.
let navigatedTo: string | null = null
await page.route('**/api/v1/billing/checkout', (route: Route) => {
if (route.request().method() !== 'POST') return route.continue()
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true, short_url: FAKE_RAZORPAY_SHORT_URL }),
})
})
await page.route(FAKE_RAZORPAY_SHORT_URL + '**', (route: Route) => {
navigatedTo = route.request().url()
return route.fulfill({ status: 200, contentType: 'text/html', body: '<html><body>razorpay stub</body></html>' })
})

await page.goto(`/claim?t=${VALID_CLAIM_JWT}`)
await page.getByTestId('claim-email').fill('founder@example.com')
await page.getByTestId('claim-submit').click()
await expect(page.getByTestId('claim-funnel')).toBeVisible()

await page.getByTestId('claim-checkout-hobby').click()
await expect.poll(() => navigatedTo).toContain('rzp.io')
})
})
Loading
Loading