mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
e2e/auth.spec.ts drove a local sign-in UI — a "Welcome Back" heading, email and password fields, a signup flow with a terms checkbox. None of it exists. HIP-0111 makes Hanzo IAM the one canonical login surface, and app/(marketing)/login/page.tsx now only calls `login()` from @hanzo/iam/react and renders "Redirecting to sign in…". Every assertion failed against production for that reason alone: the test outlived the UI it described. Rewritten around the contract that replaced it, measured against production rather than assumed. /login and /signup both hand off to hanzo.id with response_type=code, scope=openid profile email, code_challenge_method=S256, a state, and redirect_uri=https://hanzo.ai/auth/callback — so the spec now asserts the parts that carry the security properties, PKCE above all. Without code_challenge_method=S256 this is a bare authorization-code flow, and nothing else in the repo would notice. The most valuable assertion is the NEGATIVE one: no route on the marketing site may render a password field. A local login form would not fail loudly — it would look like a feature, and quietly become a second place where credentials are typed. That is exactly the drift HIP-0111 exists to prevent, so it is now a test rather than a paragraph. One thing checked before it became a bug report: the SDK lands on `/login/oauth/authorize` while discovery advertises `/v1/iam/oauth/authorize`, which looks like the legacy path CLAUDE.md forbids. It is not — the canonical path REDIRECTS to it, verified with a full parameter set, so arriving there is the correct end state. Verified: tsc clean; 3/3 pass against https://hanzo.ai; and both guards fail on purpose — flipping the expected PKCE method to `plain` reddens the hand-off test, and inverting the password-field count reddens the guard. Also run this round, unchanged and green: catalog-agreement 3/3 (site taxonomy still agrees with commerce's catalog), billing 3/3, dashboard 8/8. Co-authored-by: Hanzo Dev <[email protected]>
72 lines
3.3 KiB
TypeScript
72 lines
3.3 KiB
TypeScript
// Sign-in on hanzo.ai is a HAND-OFF, not a form.
|
|
//
|
|
// This file used to drive a local login form — "Welcome Back", an email and a
|
|
// password field, a signup flow with a terms checkbox. That UI was deliberately
|
|
// removed: HIP-0111 makes Hanzo IAM the one canonical login surface, and
|
|
// app/(marketing)/login/page.tsx now does nothing but call `login()` from
|
|
// @hanzo/iam/react and render "Redirecting to sign in…". Every assertion in the
|
|
// old file failed against production for that reason alone — the test outlived
|
|
// the UI it described.
|
|
//
|
|
// What is worth testing is the contract that replaced it, and the most valuable
|
|
// assertion is the NEGATIVE one: the marketing site must never grow its own
|
|
// password field again. A local form would not fail loudly — it would look like
|
|
// a feature, and quietly become a second place where credentials are typed.
|
|
//
|
|
// Verified against production while writing this: /login and /signup both land
|
|
// on hanzo.id with response_type=code, scope=openid profile email,
|
|
// code_challenge_method=S256 and redirect_uri=https://hanzo.ai/auth/callback.
|
|
// The canonical `/v1/iam/oauth/authorize` that discovery advertises REDIRECTS to
|
|
// `/login/oauth/authorize`, so arriving at the latter is the correct end state
|
|
// of the former and not the legacy path it resembles.
|
|
|
|
import { test, expect } from '@playwright/test'
|
|
|
|
for (const entry of ['/login', '/signup']) {
|
|
test(`${entry} hands off to IAM instead of rendering a form`, async ({ page }) => {
|
|
test.setTimeout(45000)
|
|
await page.goto(entry)
|
|
|
|
// Before the hand-off completes, the stub must not be collecting credentials.
|
|
expect(
|
|
await page.locator('input[type="password"]').count(),
|
|
`${entry} renders a password field on hanzo.ai — HIP-0111 makes IAM the ONE login UI`,
|
|
).toBe(0)
|
|
|
|
// The redirect is client-side (useEffect -> login()), so wait for the URL to
|
|
// leave rather than for text on a page that is in the middle of leaving.
|
|
await page
|
|
.waitForURL((url) => url.host.includes('hanzo.id'), { timeout: 20000 })
|
|
.catch(() => {})
|
|
|
|
const landed = new URL(page.url())
|
|
expect(landed.host, `${entry} should hand off to IAM, landed on ${page.url()}`).toContain(
|
|
'hanzo.id',
|
|
)
|
|
|
|
// The parts of the request that carry the security properties. PKCE in
|
|
// particular: without code_challenge_method=S256 this is a bare code flow.
|
|
const q = landed.searchParams
|
|
expect(q.get('response_type'), 'response_type').toBe('code')
|
|
expect(q.get('code_challenge_method'), 'PKCE method must be S256').toBe('S256')
|
|
expect(q.get('code_challenge'), 'PKCE challenge present').toBeTruthy()
|
|
expect(q.get('state'), 'state present (CSRF)').toBeTruthy()
|
|
expect(q.get('scope') ?? '', 'scope').toContain('openid')
|
|
expect(q.get('redirect_uri') ?? '', 'callback must return to this site').toContain(
|
|
'/auth/callback',
|
|
)
|
|
})
|
|
}
|
|
|
|
test('no route on the marketing site collects a password', async ({ page }) => {
|
|
// The regression guard for the rule above, across the auth-adjacent routes
|
|
// that would be the tempting place to add a form back.
|
|
for (const route of ['/', '/account', '/pricing', '/contact']) {
|
|
await page.goto(route)
|
|
expect(
|
|
await page.locator('input[type="password"]').count(),
|
|
`${route} renders a password field — credentials belong to IAM only`,
|
|
).toBe(0)
|
|
}
|
|
})
|