Skip to content

Commit 4627e3a

Browse files
Merge branch 'main' into dependabot/npm_and_yarn/npm-minor-patch-96f7302f30
2 parents 151edde + a71cf40 commit 4627e3a

22 files changed

Lines changed: 1565 additions & 48 deletions

e2e/auth.spec.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,21 @@ test.describe('Auth gate', () => {
2828
await expect(page).toHaveURL(/\/login$/)
2929
})
3030

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

4148
test('OAuth buttons redirect to backend handlers', async ({ page }) => {

e2e/claim-conversion.spec.ts

Lines changed: 390 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/* commerce-first-redirect.spec.ts — mocked-contract Playwright gate for the
2+
* COMMERCE-FIRST REDIRECT (2026-06-10,
3+
* memory project_commerce_first_redirect_at_interactions).
4+
*
5+
* The product rule: a successful login is a scarce interaction, so the
6+
* post-auth landing routes by plan tier to push commerce —
7+
* free → /pricing (drive the first purchase)
8+
* paid + upgrade-eligible → /app/billing (show the next tier)
9+
* top tier (team) → /app (no upsell — NEVER a Team checkout)
10+
* — UNLESS the user carried an explicit deep-link (a saved /app/* return_to
11+
* or a /login?next=), which always wins (and prevents pricing→login→pricing
12+
* loops).
13+
*
14+
* This drives the REAL SPA route (LoginCallbackPage) through the REAL src/api
15+
* client with the network mocked at the page.route() boundary, so it runs on
16+
* every web PR (mocked playwright.config.ts, VITE_NO_PROXY=1) and reds the PR
17+
* if the tier→destination wiring breaks. It complements:
18+
* - src/lib/postAuthDestination.test.ts (the pure decision matrix, vitest)
19+
* - src/pages/LoginCallbackPage.test.tsx (component, ../api stubbed)
20+
* by exercising the browser-rendered redirect against the real api client.
21+
*/
22+
23+
import { expect, test, type Page, type Route } from '@playwright/test'
24+
25+
const AUTH_ME_PATH = '**/auth/me'
26+
const SESSION_TOKEN = 'sess_jwt_commerce'
27+
28+
// Catch-all for the dependent dashboard bootstrap fetches that fire once the
29+
// SPA lands on an /app/* route (counts + billing). We don't assert on them —
30+
// we only care WHERE the user was routed — so we stub them to harmless empties
31+
// so the destination page doesn't error mid-render.
32+
const RESOURCES_PATH = /\/api\/v1\/resources(\?[^/]*)?$/
33+
const DEPLOYMENTS_PATH = /\/api\/v1\/deployments(\?[^/]*)?$/
34+
const VAULT_PATH = /\/api\/v1\/vault(\?[^/]*)?$/
35+
const BILLING_PATH = '**/api/v1/billing'
36+
37+
/** Mock GET /auth/me to report the given plan tier. The wire shape is the FLAT
38+
* agent payload ({ ok, user_id, team_id, email, tier }); fetchMe() adapts it
39+
* into { user: { tier } } which postAuthDestination reads. */
40+
async function mockAuthMe(page: Page, tier: string) {
41+
await page.route(AUTH_ME_PATH, (route: Route) =>
42+
route.fulfill({
43+
status: 200,
44+
contentType: 'application/json',
45+
body: JSON.stringify({ ok: true, user_id: 'u1', team_id: 't1', email: 'founder@acme.dev', tier }),
46+
}),
47+
)
48+
}
49+
50+
/** Stub the dashboard bootstrap fetches so an /app/* destination renders. */
51+
async function mockDashboardBootstrap(page: Page) {
52+
await page.route(RESOURCES_PATH, (route: Route) =>
53+
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, items: [], total: 0 }) }),
54+
)
55+
await page.route(DEPLOYMENTS_PATH, (route: Route) =>
56+
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, items: [], total: 0 }) }),
57+
)
58+
await page.route(VAULT_PATH, (route: Route) =>
59+
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, entries: [] }) }),
60+
)
61+
await page.route(BILLING_PATH, (route: Route) =>
62+
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, billing: { tier: 'free', subscription_status: 'none' } }) }),
63+
)
64+
}
65+
66+
test.describe('commerce-first redirect — post-auth landing by tier', () => {
67+
test('free tier lands on /pricing (drive the first purchase)', async ({ page }) => {
68+
await mockAuthMe(page, 'free')
69+
await mockDashboardBootstrap(page)
70+
await page.goto(`/login/callback?session_token=${SESSION_TOKEN}`)
71+
await expect(page).toHaveURL(/\/pricing$/)
72+
})
73+
74+
test('paid+eligible tier (pro) lands on /app/billing (show the next tier)', async ({ page }) => {
75+
await mockAuthMe(page, 'pro')
76+
await mockDashboardBootstrap(page)
77+
await page.goto(`/login/callback?session_token=${SESSION_TOKEN}`)
78+
await expect(page).toHaveURL(/\/app\/billing$/)
79+
})
80+
81+
test('top tier (team) lands on /app — never a Team checkout', async ({ page }) => {
82+
await mockAuthMe(page, 'team')
83+
await mockDashboardBootstrap(page)
84+
await page.goto(`/login/callback?session_token=${SESSION_TOKEN}`)
85+
await expect(page).toHaveURL(/\/app\/?$/)
86+
// Hard guard: a team user must NOT be pushed to a commerce surface.
87+
await expect(page).not.toHaveURL(/\/pricing$/)
88+
await expect(page).not.toHaveURL(/\/app\/billing$/)
89+
})
90+
91+
test('an explicit /app deep-link (saved return_to) overrides the free-tier pricing push', async ({ page }) => {
92+
await mockAuthMe(page, 'free')
93+
await mockDashboardBootstrap(page)
94+
// Seed the 401-interceptor's saved destination before the callback runs.
95+
// We use /app/resources (a stable page that just lists the empty resource
96+
// set we mocked) so the test asserts the deep-link wins without depending
97+
// on a page that itself redirects (e.g. CheckoutPage auto-fires checkout).
98+
await page.addInitScript(() => {
99+
try { localStorage.setItem('instanode.return_to', '/app/resources') } catch {}
100+
})
101+
await page.goto(`/login/callback?session_token=${SESSION_TOKEN}`)
102+
// Deep-link wins — the user lands on the saved destination, NOT /pricing.
103+
await expect(page).toHaveURL(/\/app\/resources$/)
104+
await expect(page).not.toHaveURL(/\/pricing$/)
105+
})
106+
})

e2e/fixtures.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,15 @@ export async function mockAdminPromoIssue(
286286
})
287287
}
288288

289-
export async function installAPIFake(page: Page) {
289+
// The plan tier installAPIFake's /auth/me reports unless a test overrides it.
290+
// COMMERCE-FIRST REDIRECT (2026-06-10): the post-auth landing routes by this
291+
// tier (hobby = paid + upgrade-eligible → /app/billing), so any spec that
292+
// drives a real login flow must pass/assume the tier EXPLICITLY rather than
293+
// relying on this default silently — see auth.spec.ts.
294+
export const FAKE_TIER = 'hobby'
295+
296+
export async function installAPIFake(page: Page, opts: { tier?: string } = {}) {
297+
const tier = opts.tier ?? FAKE_TIER
290298
// GET /auth/me — agent API shape
291299
await page.route('**/auth/me', (route: Route) =>
292300
route.fulfill({
@@ -297,7 +305,7 @@ export async function installAPIFake(page: Page) {
297305
user_id: FAKE_USER,
298306
team_id: FAKE_TEAM,
299307
email: 'aanya@example.com',
300-
tier: 'hobby',
308+
tier,
301309
trial_ends_at: null,
302310
}),
303311
}),

e2e/funnel-recovery.spec.ts

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
/* funnel-recovery.spec.ts — mocked-contract Playwright gate for the
2+
* auth/claim funnel-recovery surfaces shipped 2026-06-10:
3+
*
4+
* F4 — the magic-link "we sent a link" state is no longer a silent
5+
* dead-end: it offers a Resend affordance + a GitHub-OAuth fallback
6+
* (email delivery is 100%-failing while the Brevo sender is
7+
* unvalidated, so this is the only path off the screen).
8+
* F6 — the /claim dead-ends (tokenless "Missing claim link" + invalid/
9+
* expired token) surface GitHub OAuth as a primary recovery CTA.
10+
* D2 — the CLI device-flow: /login?cli_session=<id> forwards the id
11+
* through the OAuth/magic-link return_to so LoginCallbackPage can
12+
* POST /auth/cli/{id}/complete after sign-in.
13+
*
14+
* Runs under the DEFAULT mocked config (playwright.config.ts → VITE_NO_PROXY=1,
15+
* same-origin), so every page.route() glob below intercepts the SPA's fetch and
16+
* no upstream api is contacted. This is the browser-rendered, real-src/api layer
17+
* that complements the vitest component tests (which stub the api module).
18+
*/
19+
20+
import { expect, test, type Page, type Route } from '@playwright/test'
21+
22+
// ─── Constants ───────────────────────────────────────────────────────────────
23+
const EMAIL_START_PATH = '**/auth/email/start'
24+
const AUTH_ME_PATH = '**/auth/me'
25+
const CLI_COMPLETE_PATH = /\/auth\/cli\/[^/]+\/complete$/
26+
const TEST_EMAIL = 'founder@acme.dev'
27+
const CLI_SESSION_ID = 'cli_sess_abc123'
28+
const SESSION_TOKEN = 'sess_jwt_callback'
29+
30+
/** Mock POST /auth/email/start → 202 (the api returns 202 regardless of
31+
* whether the email exists). Captures the request body so the test can assert
32+
* the return_to carries the cli_session when present. */
33+
async function mockEmailStart(page: Page, captured: { body?: any; count: number }) {
34+
await page.route(EMAIL_START_PATH, (route: Route) => {
35+
if (route.request().method() !== 'POST') return route.continue()
36+
captured.count += 1
37+
captured.body = JSON.parse(route.request().postData() ?? '{}')
38+
return route.fulfill({ status: 202, contentType: 'application/json', body: '{}' })
39+
})
40+
}
41+
42+
/** Mock GET /auth/me → 200 so the callback page's post-token verification
43+
* succeeds and it proceeds to navigation. The tier is EXPLICIT because the
44+
* COMMERCE-FIRST REDIRECT (2026-06-10) routes the post-auth landing by it:
45+
* free → /pricing, paid+eligible → /app/billing, team → /app. */
46+
async function mockAuthMe(page: Page, tier: string) {
47+
await page.route(AUTH_ME_PATH, (route: Route) =>
48+
route.fulfill({
49+
status: 200,
50+
contentType: 'application/json',
51+
body: JSON.stringify({ ok: true, user_id: 'u1', team_id: 't1', email: TEST_EMAIL, tier }),
52+
}),
53+
)
54+
}
55+
56+
// ─── F4: magic-link sent state is not a dead-end ─────────────────────────────
57+
58+
test.describe('F4 — magic-link recovery affordances', () => {
59+
async function reachSentState(page: Page) {
60+
await page.getByTestId('email-input').fill(TEST_EMAIL)
61+
await page.getByTestId('email-submit').click()
62+
await expect(page.getByTestId('magic-link-sent')).toBeVisible()
63+
}
64+
65+
test('the sent state renders Resend + GitHub-fallback controls', async ({ page }) => {
66+
const cap = { count: 0 } as { body?: any; count: number }
67+
await mockEmailStart(page, cap)
68+
await page.goto('/login')
69+
await reachSentState(page)
70+
await expect(page.getByTestId('magic-link-resend')).toBeVisible()
71+
await expect(page.getByTestId('magic-link-github-fallback')).toBeVisible()
72+
})
73+
74+
test('Resend re-fires POST /auth/email/start', async ({ page }) => {
75+
const cap = { count: 0 } as { body?: any; count: number }
76+
await mockEmailStart(page, cap)
77+
await page.goto('/login')
78+
await reachSentState(page)
79+
expect(cap.count).toBe(1)
80+
await page.getByTestId('magic-link-resend').click()
81+
await expect.poll(() => cap.count).toBe(2)
82+
})
83+
84+
test('the GitHub fallback navigates to the OAuth start handler', async ({ page }) => {
85+
const cap = { count: 0 } as { body?: any; count: number }
86+
await mockEmailStart(page, cap)
87+
// The github/start redirect leaves the SPA — intercept it so the test
88+
// doesn't navigate to the real api. Asserting the URL we were sent to.
89+
await page.route('**/auth/github/start*', (route: Route) =>
90+
route.fulfill({ status: 200, contentType: 'text/html', body: '<html>oauth start</html>' }),
91+
)
92+
await page.goto('/login')
93+
await reachSentState(page)
94+
await Promise.all([
95+
page.waitForURL(/\/auth\/github\/start\?return_to=/),
96+
page.getByTestId('magic-link-github-fallback').click(),
97+
])
98+
})
99+
})
100+
101+
// ─── F6: claim dead-ends surface GitHub OAuth ────────────────────────────────
102+
103+
test.describe('F6 — claim funnel recovery via GitHub OAuth', () => {
104+
test('the tokenless "Missing claim link" state surfaces a GitHub CTA', async ({ page }) => {
105+
await page.goto('/claim')
106+
await expect(page.getByText(/missing claim link/i)).toBeVisible()
107+
await expect(page.getByTestId('claim-github-oauth')).toBeVisible()
108+
})
109+
110+
test('the invalid/expired-link state surfaces a GitHub CTA', async ({ page }) => {
111+
await page.goto('/claim?t=not-a-valid-jwt-blob')
112+
await expect(page.getByTestId('claim-invalid')).toBeVisible()
113+
await expect(page.getByTestId('claim-github-oauth')).toBeVisible()
114+
})
115+
116+
test('the GitHub CTA navigates to the OAuth start handler', async ({ page }) => {
117+
await page.route('**/auth/github/start*', (route: Route) =>
118+
route.fulfill({ status: 200, contentType: 'text/html', body: '<html>oauth start</html>' }),
119+
)
120+
await page.goto('/claim')
121+
await Promise.all([
122+
page.waitForURL(/\/auth\/github\/start\?return_to=/),
123+
page.getByTestId('claim-github-oauth').click(),
124+
])
125+
})
126+
})
127+
128+
// ─── D2: CLI device-flow — cli_session preserved + completed ─────────────────
129+
130+
test.describe('D2 — CLI device-flow completion', () => {
131+
test('LoginPage forwards cli_session into the magic-link return_to', async ({ page }) => {
132+
const cap = { count: 0 } as { body?: any; count: number }
133+
await mockEmailStart(page, cap)
134+
await page.goto(`/login?cli_session=${CLI_SESSION_ID}`)
135+
await page.getByTestId('email-input').fill(TEST_EMAIL)
136+
await page.getByTestId('email-submit').click()
137+
await expect(page.getByTestId('magic-link-sent')).toBeVisible()
138+
// The return_to the SPA sent the api must carry the cli_session so the
139+
// post-auth callback can complete the device flow.
140+
expect(cap.body?.return_to).toContain(`/login/callback?cli_session=${CLI_SESSION_ID}`)
141+
})
142+
143+
// The post-completion landing follows the COMMERCE-FIRST REDIRECT
144+
// (2026-06-10, memory project_commerce_first_redirect_at_interactions):
145+
// the CLI got its token via POST /auth/cli/{id}/complete, so the browser
146+
// tab is a scarce free interaction — a free-tier user is pushed to
147+
// /pricing (NOT /app). The cli_session is not a deep-link; only an
148+
// explicit ?next= / saved return_to overrides the tier rule.
149+
150+
test('the callback POSTs /auth/cli/{id}/complete then lands a free user on /pricing', async ({ page }) => {
151+
await mockAuthMe(page, 'free')
152+
const completeCap = { id: '', count: 0 }
153+
await page.route(CLI_COMPLETE_PATH, (route: Route) => {
154+
completeCap.count += 1
155+
// Pull the session id out of the path: /auth/cli/<id>/complete
156+
const m = new URL(route.request().url()).pathname.match(/\/auth\/cli\/([^/]+)\/complete$/)
157+
completeCap.id = m ? decodeURIComponent(m[1]) : ''
158+
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) })
159+
})
160+
161+
// The callback uses the legacy ?session_token path (no cookie exchange
162+
// needed for the mock) + ?cli_session to trigger completion.
163+
await page.goto(`/login/callback?session_token=${SESSION_TOKEN}&cli_session=${CLI_SESSION_ID}`)
164+
// free tier → commerce-first push to /pricing after the device flow
165+
// completes; the CLI itself is already unblocked by the POST below.
166+
await expect(page).toHaveURL(/\/pricing$/)
167+
expect(completeCap.count).toBe(1)
168+
expect(completeCap.id).toBe(CLI_SESSION_ID)
169+
})
170+
171+
test('a cli-completion failure does NOT block the user sign-in (still lands post-auth)', async ({ page }) => {
172+
await mockAuthMe(page, 'free')
173+
await page.route(CLI_COMPLETE_PATH, (route: Route) =>
174+
route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: 'session_not_found' }) }),
175+
)
176+
await page.goto(`/login/callback?session_token=${SESSION_TOKEN}&cli_session=${CLI_SESSION_ID}`)
177+
// completeCliSession swallows the error; the browser user must still
178+
// reach the signed-in landing (free tier → /pricing, commerce-first).
179+
await expect(page).toHaveURL(/\/pricing$/)
180+
})
181+
182+
test('no cli_session → the callback never calls /auth/cli/.../complete', async ({ page }) => {
183+
await mockAuthMe(page, 'free')
184+
let completeCalled = false
185+
await page.route(CLI_COMPLETE_PATH, (route: Route) => {
186+
completeCalled = true
187+
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) })
188+
})
189+
await page.goto(`/login/callback?session_token=${SESSION_TOKEN}`)
190+
// free tier → /pricing (commerce-first post-auth landing).
191+
await expect(page).toHaveURL(/\/pricing$/)
192+
expect(completeCalled).toBe(false)
193+
})
194+
})

0 commit comments

Comments
 (0)