Skip to content
Merged
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
15 changes: 11 additions & 4 deletions e2e/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,21 @@ test.describe('Auth gate', () => {
await expect(page).toHaveURL(/\/login$/)
})

test('login accepts valid token, lands on overview', async ({ page }) => {
await installAPIFake(page)
test('login accepts valid token, lands on the commerce-first destination for its tier', async ({ page }) => {
// COMMERCE-FIRST REDIRECT (2026-06-10, memory
// project_commerce_first_redirect_at_interactions): a plain login (no
// ?next= deep-link) routes by plan tier — free → /pricing, paid +
// upgrade-eligible → /app/billing, team → /app. Pin the mocked tier
// EXPLICITLY so the asserted destination is deterministic.
await installAPIFake(page, { tier: 'hobby' })
await page.goto('/login')
await page.getByTestId('toggle-token-form').click()
await page.getByTestId('token-input').fill('ink_VALID')
await page.getByTestId('login-submit').click()
// LoginPage navigates to /app on success (see LoginPage.tsx).
await expect(page).toHaveURL(/\/app\/?$/)
// hobby = paid + upgrade-eligible → the in-app upgrade/billing surface
// (see src/lib/postAuthDestination.ts; the full per-tier matrix is
// covered by e2e/commerce-first-redirect.spec.ts).
await expect(page).toHaveURL(/\/app\/billing$/)
})

test('OAuth buttons redirect to backend handlers', async ({ page }) => {
Expand Down
106 changes: 106 additions & 0 deletions e2e/commerce-first-redirect.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/* commerce-first-redirect.spec.ts — mocked-contract Playwright gate for the
* COMMERCE-FIRST REDIRECT (2026-06-10,
* memory project_commerce_first_redirect_at_interactions).
*
* The product rule: a successful login is a scarce interaction, so the
* post-auth landing routes by plan tier to push commerce —
* free → /pricing (drive the first purchase)
* paid + upgrade-eligible → /app/billing (show the next tier)
* top tier (team) → /app (no upsell — NEVER a Team checkout)
* — UNLESS the user carried an explicit deep-link (a saved /app/* return_to
* or a /login?next=), which always wins (and prevents pricing→login→pricing
* loops).
*
* This drives the REAL SPA route (LoginCallbackPage) through the REAL src/api
* client with the network mocked at the page.route() boundary, so it runs on
* every web PR (mocked playwright.config.ts, VITE_NO_PROXY=1) and reds the PR
* if the tier→destination wiring breaks. It complements:
* - src/lib/postAuthDestination.test.ts (the pure decision matrix, vitest)
* - src/pages/LoginCallbackPage.test.tsx (component, ../api stubbed)
* by exercising the browser-rendered redirect against the real api client.
*/

import { expect, test, type Page, type Route } from '@playwright/test'

const AUTH_ME_PATH = '**/auth/me'
const SESSION_TOKEN = 'sess_jwt_commerce'

// Catch-all for the dependent dashboard bootstrap fetches that fire once the
// SPA lands on an /app/* route (counts + billing). We don't assert on them —
// we only care WHERE the user was routed — so we stub them to harmless empties
// so the destination page doesn't error mid-render.
const RESOURCES_PATH = /\/api\/v1\/resources(\?[^/]*)?$/
const DEPLOYMENTS_PATH = /\/api\/v1\/deployments(\?[^/]*)?$/
const VAULT_PATH = /\/api\/v1\/vault(\?[^/]*)?$/
const BILLING_PATH = '**/api/v1/billing'

/** Mock GET /auth/me to report the given plan tier. The wire shape is the FLAT
* agent payload ({ ok, user_id, team_id, email, tier }); fetchMe() adapts it
* into { user: { tier } } which postAuthDestination reads. */
async function mockAuthMe(page: Page, tier: string) {
await page.route(AUTH_ME_PATH, (route: Route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true, user_id: 'u1', team_id: 't1', email: 'founder@acme.dev', tier }),
}),
)
}

/** Stub the dashboard bootstrap fetches so an /app/* destination renders. */
async function mockDashboardBootstrap(page: Page) {
await page.route(RESOURCES_PATH, (route: Route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, items: [], total: 0 }) }),
)
await page.route(DEPLOYMENTS_PATH, (route: Route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, items: [], total: 0 }) }),
)
await page.route(VAULT_PATH, (route: Route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, entries: [] }) }),
)
await page.route(BILLING_PATH, (route: Route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, billing: { tier: 'free', subscription_status: 'none' } }) }),
)
}

test.describe('commerce-first redirect — post-auth landing by tier', () => {
test('free tier lands on /pricing (drive the first purchase)', async ({ page }) => {
await mockAuthMe(page, 'free')
await mockDashboardBootstrap(page)
await page.goto(`/login/callback?session_token=${SESSION_TOKEN}`)
await expect(page).toHaveURL(/\/pricing$/)
})

test('paid+eligible tier (pro) lands on /app/billing (show the next tier)', async ({ page }) => {
await mockAuthMe(page, 'pro')
await mockDashboardBootstrap(page)
await page.goto(`/login/callback?session_token=${SESSION_TOKEN}`)
await expect(page).toHaveURL(/\/app\/billing$/)
})

test('top tier (team) lands on /app — never a Team checkout', async ({ page }) => {
await mockAuthMe(page, 'team')
await mockDashboardBootstrap(page)
await page.goto(`/login/callback?session_token=${SESSION_TOKEN}`)
await expect(page).toHaveURL(/\/app\/?$/)
// Hard guard: a team user must NOT be pushed to a commerce surface.
await expect(page).not.toHaveURL(/\/pricing$/)
await expect(page).not.toHaveURL(/\/app\/billing$/)
})

test('an explicit /app deep-link (saved return_to) overrides the free-tier pricing push', async ({ page }) => {
await mockAuthMe(page, 'free')
await mockDashboardBootstrap(page)
// Seed the 401-interceptor's saved destination before the callback runs.
// We use /app/resources (a stable page that just lists the empty resource
// set we mocked) so the test asserts the deep-link wins without depending
// on a page that itself redirects (e.g. CheckoutPage auto-fires checkout).
await page.addInitScript(() => {
try { localStorage.setItem('instanode.return_to', '/app/resources') } catch {}
})
await page.goto(`/login/callback?session_token=${SESSION_TOKEN}`)
// Deep-link wins — the user lands on the saved destination, NOT /pricing.
await expect(page).toHaveURL(/\/app\/resources$/)
await expect(page).not.toHaveURL(/\/pricing$/)
})
})
12 changes: 10 additions & 2 deletions e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,15 @@ export async function mockAdminPromoIssue(
})
}

export async function installAPIFake(page: Page) {
// The plan tier installAPIFake's /auth/me reports unless a test overrides it.
// COMMERCE-FIRST REDIRECT (2026-06-10): the post-auth landing routes by this
// tier (hobby = paid + upgrade-eligible → /app/billing), so any spec that
// drives a real login flow must pass/assume the tier EXPLICITLY rather than
// relying on this default silently — see auth.spec.ts.
export const FAKE_TIER = 'hobby'

export async function installAPIFake(page: Page, opts: { tier?: string } = {}) {
const tier = opts.tier ?? FAKE_TIER
// GET /auth/me — agent API shape
await page.route('**/auth/me', (route: Route) =>
route.fulfill({
Expand All @@ -297,7 +305,7 @@ export async function installAPIFake(page: Page) {
user_id: FAKE_USER,
team_id: FAKE_TEAM,
email: 'aanya@example.com',
tier: 'hobby',
tier,
trial_ends_at: null,
}),
}),
Expand Down
36 changes: 24 additions & 12 deletions e2e/funnel-recovery.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,15 @@ async function mockEmailStart(page: Page, captured: { body?: any; count: number
}

/** Mock GET /auth/me → 200 so the callback page's post-token verification
* succeeds and it proceeds to navigation. */
async function mockAuthMe(page: Page) {
* succeeds and it proceeds to navigation. The tier is EXPLICIT because the
* COMMERCE-FIRST REDIRECT (2026-06-10) routes the post-auth landing by it:
* free → /pricing, paid+eligible → /app/billing, team → /app. */
async function mockAuthMe(page: Page, tier: string) {
await page.route(AUTH_ME_PATH, (route: Route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true, user_id: 'u1', team_id: 't1', email: TEST_EMAIL, tier: 'free' }),
body: JSON.stringify({ ok: true, user_id: 'u1', team_id: 't1', email: TEST_EMAIL, tier }),
}),
)
}
Expand Down Expand Up @@ -138,8 +140,15 @@ test.describe('D2 — CLI device-flow completion', () => {
expect(cap.body?.return_to).toContain(`/login/callback?cli_session=${CLI_SESSION_ID}`)
})

test('the callback POSTs /auth/cli/{id}/complete then lands the user on /app', async ({ page }) => {
await mockAuthMe(page)
// The post-completion landing follows the COMMERCE-FIRST REDIRECT
// (2026-06-10, memory project_commerce_first_redirect_at_interactions):
// the CLI got its token via POST /auth/cli/{id}/complete, so the browser
// tab is a scarce free interaction — a free-tier user is pushed to
// /pricing (NOT /app). The cli_session is not a deep-link; only an
// explicit ?next= / saved return_to overrides the tier rule.

test('the callback POSTs /auth/cli/{id}/complete then lands a free user on /pricing', async ({ page }) => {
await mockAuthMe(page, 'free')
const completeCap = { id: '', count: 0 }
await page.route(CLI_COMPLETE_PATH, (route: Route) => {
completeCap.count += 1
Expand All @@ -152,31 +161,34 @@ test.describe('D2 — CLI device-flow completion', () => {
// The callback uses the legacy ?session_token path (no cookie exchange
// needed for the mock) + ?cli_session to trigger completion.
await page.goto(`/login/callback?session_token=${SESSION_TOKEN}&cli_session=${CLI_SESSION_ID}`)
await expect(page).toHaveURL(/\/app\/?$/)
// free tier → commerce-first push to /pricing after the device flow
// completes; the CLI itself is already unblocked by the POST below.
await expect(page).toHaveURL(/\/pricing$/)
expect(completeCap.count).toBe(1)
expect(completeCap.id).toBe(CLI_SESSION_ID)
})

test('a cli-completion failure does NOT block the user sign-in (still lands on /app)', async ({ page }) => {
await mockAuthMe(page)
test('a cli-completion failure does NOT block the user sign-in (still lands post-auth)', async ({ page }) => {
await mockAuthMe(page, 'free')
await page.route(CLI_COMPLETE_PATH, (route: Route) =>
route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: 'session_not_found' }) }),
)
await page.goto(`/login/callback?session_token=${SESSION_TOKEN}&cli_session=${CLI_SESSION_ID}`)
// completeCliSession swallows the error; the browser user must still
// reach the app.
await expect(page).toHaveURL(/\/app\/?$/)
// reach the signed-in landing (free tier → /pricing, commerce-first).
await expect(page).toHaveURL(/\/pricing$/)
})

test('no cli_session → the callback never calls /auth/cli/.../complete', async ({ page }) => {
await mockAuthMe(page)
await mockAuthMe(page, 'free')
let completeCalled = false
await page.route(CLI_COMPLETE_PATH, (route: Route) => {
completeCalled = true
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) })
})
await page.goto(`/login/callback?session_token=${SESSION_TOKEN}`)
await expect(page).toHaveURL(/\/app\/?$/)
// free tier → /pricing (commerce-first post-auth landing).
await expect(page).toHaveURL(/\/pricing$/)
expect(completeCalled).toBe(false)
})
})
124 changes: 124 additions & 0 deletions src/lib/postAuthDestination.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/* postAuthDestination.test.ts — exhaustive per-tier + next-precedence
* coverage for the commerce-first redirect decision (2026-06-10).
*
* The matrix this pins:
* tier=free → /pricing
* tier∈{hobby,hobby_plus,pro,growth} → /app/billing
* tier=team → /app (top tier, NEVER a Team checkout)
* tier∈{anonymous,'',unknown} → /app (degrade — no commerce push)
* any explicit safe `next` → next (deep-link always wins)
* unsafe `next` (off-origin/protocol-relative) → falls through to tier rule
*/

import { describe, it, expect } from 'vitest'
import { TIER_RANK, type Tier } from '../api'
import {
postAuthDestination,
isSafeInternalNext,
DEST_PRICING,
DEST_BILLING,
DEST_DASHBOARD,
} from './postAuthDestination'

describe('postAuthDestination — per-tier landing (no next)', () => {
it('free → /pricing (drive the first purchase)', () => {
expect(postAuthDestination('free')).toBe(DEST_PRICING)
expect(DEST_PRICING).toBe('/pricing')
})

it.each<Tier>(['hobby', 'hobby_plus', 'pro', 'growth'])(
'paid+upgrade-eligible tier %s → /app/billing',
(tier) => {
expect(postAuthDestination(tier)).toBe(DEST_BILLING)
},
)

it('team (top tier) → /app, NEVER a Team checkout', () => {
expect(postAuthDestination('team')).toBe(DEST_DASHBOARD)
// Hard guard: a team user must never be sent to a billing/pricing
// commerce surface (Team is gated / contact-sales).
expect(postAuthDestination('team')).not.toBe(DEST_BILLING)
expect(postAuthDestination('team')).not.toBe(DEST_PRICING)
})

it('anonymous → /app (shouldn’t reach here post-auth; degrade safely)', () => {
expect(postAuthDestination('anonymous')).toBe(DEST_DASHBOARD)
})

it.each(['', 'enterprise', 'mystery_tier', null, undefined])(
'unknown/empty tier %p → /app (degrade, never upsell blind)',
(tier) => {
expect(postAuthDestination(tier as any)).toBe(DEST_DASHBOARD)
},
)

// Registry-iterating regression test (rule 18): every tier in the canonical
// ladder resolves to exactly one of the three known destinations — a future
// tier added to TIER_RANK can never resolve to an unexpected/empty path or
// (critically) to a commerce surface for the top tier.
it('every tier in TIER_RANK resolves to a known, non-Team-checkout destination', () => {
const allowed = new Set([DEST_PRICING, DEST_BILLING, DEST_DASHBOARD])
for (const tier of Object.keys(TIER_RANK)) {
const dest = postAuthDestination(tier)
expect(allowed.has(dest)).toBe(true)
// The top tier is NEVER routed to a purchase surface.
if (tier === 'team') {
expect(dest).toBe(DEST_DASHBOARD)
}
}
})
})

describe('postAuthDestination — explicit next precedence', () => {
it.each<Tier>(['anonymous', 'free', 'hobby', 'hobby_plus', 'pro', 'growth', 'team'])(
'a safe internal next overrides the %s tier rule (deep-link wins)',
(tier) => {
expect(postAuthDestination(tier, '/app/checkout?plan=pro')).toBe('/app/checkout?plan=pro')
},
)

it('honours a saved return_to deep-link to /app/billing', () => {
expect(postAuthDestination('free', '/app/billing')).toBe('/app/billing')
})

it('honours a deep-link to /pricing itself without looping back to a tier rule', () => {
// A user explicitly headed to /pricing must land on /pricing regardless of
// tier — this is the anti-loop guarantee (pricing→login→pricing).
expect(postAuthDestination('pro', '/pricing')).toBe('/pricing')
})

it('ignores an empty next and falls through to the tier rule', () => {
expect(postAuthDestination('free', '')).toBe(DEST_PRICING)
expect(postAuthDestination('pro', null)).toBe(DEST_BILLING)
expect(postAuthDestination('team', undefined)).toBe(DEST_DASHBOARD)
})

it('ignores an off-origin next (absolute URL) and falls through to the tier rule', () => {
expect(postAuthDestination('free', 'https://evil.example.com')).toBe(DEST_PRICING)
expect(postAuthDestination('pro', 'http://evil.example.com/app')).toBe(DEST_BILLING)
})

it('ignores a protocol-relative next ("//host") and falls through to the tier rule', () => {
expect(postAuthDestination('free', '//evil.example.com/app')).toBe(DEST_PRICING)
})

it('ignores a relative (non-slash-prefixed) next and falls through', () => {
expect(postAuthDestination('team', 'app/billing')).toBe(DEST_DASHBOARD)
})
})

describe('isSafeInternalNext', () => {
it.each(['/app', '/app/billing', '/pricing', '/app/checkout?plan=pro&frequency=monthly'])(
'accepts safe internal path %p',
(next) => {
expect(isSafeInternalNext(next)).toBe(true)
},
)

it.each(['', null, undefined, 'app/billing', 'https://x.com', 'http://x.com', '//x.com', 'javascript:alert(1)'])(
'rejects unsafe/empty next %p',
(next) => {
expect(isSafeInternalNext(next as any)).toBe(false)
},
)
})
Loading
Loading