Files
hanzo.ai/e2e/gates/silent.spec.ts
zeekay 6c65e3e941 @hanzo/app 0.2.0: the app SDK, renamed from @hanzo/rooms
Every import, the catalog pin and the docs name @hanzo/app and
github.com/hanzoai/app. lib/rooms.ts is lib/app.ts and
components/rooms.tsx is components/app.tsx.
2026-10-22 19:07:20 -07:00

182 lines
8.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test, type Page } from '@playwright/test'
import { serveExport } from './export'
import { person } from './heard'
import { asked, exchanged, issuer, jwt, type Seen } from './issuer'
import { answer } from '../session'
/**
* A RETURNING PERSON IS RECOGNISED, AND NOBODY GOES TO HANZO.ID.
*
* IAM keeps its session cookie on hanzo.ai, so a browser that has held a
* session here (`hz_known`) asks it once per tab, before drawing a form, with a
* POST to this origin that carries no credential (@hanzo/iam `resume`): a code
* signs the reader in with nothing typed, and `login_required` draws the form. A
* stranger's browser holds no session to find, so it asks nothing, and its
* console logs no 400 on any page. No page of hanzo.id is ever navigated to
* (sign-out: apex.spec).
*
* The subject is the export under the apex's own name, over https — PKCE needs
* a secure context — answered from this machine by a route. IAM's calls on
* hanzo.ai are a stub that answers the way the live one does (measured: a code
* request with no credential and no session answers 400 `login_required`), and
* the token and userinfo are read at api.hanzo.ai (`./issuer`).
* Playwright's browser says it is automated, and an automated browser is not
* asked for (`unasked`), so the reader here is a person's browser:
* `navigator.webdriver` reads false.
*/
let port: string
let stop: () => Promise<void>
test.beforeAll(async () => {
const served = await serveExport()
port = new URL(served.url).port
stop = served.close
})
test.afterAll(async () => stop())
const at = (path: string) => `https://hanzo.ai${path}`
const TOKEN = jwt({ sub: 'gate', name: 'gate', email: '[email protected]', orgs: [{ org: 'hanzo', role: 'owner' }] })
/**
* The export at https://hanzo.ai and IAM, holding a session for this browser or
* not: `resume` on this origin answers a code or `login_required`, the rest of
* IAM as `./issuer` answers it. Everything else off this machine is aborted.
*/
async function world(page: Page, session: boolean): Promise<Seen & { popups: string[] }> {
const popups: string[] = []
page.on('popup', (p) => popups.push(p.url()))
await page.route('**/*', (route) => (new URL(route.request().url()).port === port ? route.continue() : route.abort()))
await page.route('https://hanzo.ai/**', async (route) => {
const u = new URL(route.request().url())
return route.fulfill({ response: await route.fetch({ url: `http://127.0.0.1:${port}${u.pathname}${u.search}` }) })
})
const reply = session ? {} : { '/v1/iam/login': { status: 'error', msg: 'please sign in first', code: 'login_required', data: null } }
return Object.assign(await issuer(page, { site: 'https://hanzo.ai', reply, token: TOKEN }), { popups })
}
/** The POSTs `resume` made: the credential endpoint, with no credential in it. */
const resumes = (seen: Seen) => seen.posted.filter((p) => p.path === '/v1/iam/login')
/** What the app reads once it is drawn, answered. */
async function platform(page: Page): Promise<void> {
await answer(page, '/v1/billing/tier', { user: 'gate', plan: 'dev', tier: { name: 'dev', displayName: 'Dev' }, balance: { currency: 'usd' } })
await answer(page, '/v1/billing/balance', { available: 0 })
await answer(page, '/v1/agents', { agents: [] })
await answer(page, '/v1/agents/chat/conversations', { conversations: [] })
await answer(page, '/v1/agent/sessions', { data: [] })
await answer(page, '/v1/projects', [])
}
/** The landing is what `/` draws. */
const landing = (page: Page) => page.locator('[data-landing] h1').first()
/** The sign-in form /login draws. */
const form = (page: Page) => page.getByRole('region', { name: /^(Log in|Sign up)$/ }).getByLabel('Email')
/** A browser that has held a session on this site, as `Identity` marks it (@hanzo/app/lib/destination `recognize`). */
const known = (page: Page) => page.addInitScript(() => localStorage.setItem('hz_known', '1'))
test('a stranger asks IAM nothing on `/`, /login or /signup, so no page logs a 400, and nothing leaves for hanzo.id', async ({ page }) => {
await person(page)
const seen = await world(page, false)
const failed: string[] = []
page.on('response', (r) => {
if (r.status() >= 400 && new URL(r.url()).host === 'hanzo.ai') failed.push(`${r.status()} ${new URL(r.url()).pathname}`)
})
await page.goto(at('/?utm_source=gate'))
await expect(landing(page)).toBeVisible({ timeout: 20_000 })
for (const path of ['/login', '/signup']) {
await page.goto(at(path))
await expect(form(page)).toBeVisible({ timeout: 20_000 })
}
await page.waitForTimeout(1000)
expect(resumes(seen), 'no ask: a stranger holds no session here').toEqual([])
expect(failed, 'no failed response on any page').toEqual([])
expect(asked(seen), 'hanzo.id is neither visited nor asked').toEqual([])
})
test('no session: a known browser’s Log in asks IAM once, on this origin with no credential, and draws the form', async ({ page }) => {
await person(page)
await known(page)
const seen = await world(page, false)
await page.goto(at('/login'))
await expect(form(page)).toBeVisible({ timeout: 20_000 })
// The form is drawn at once; the ask is made beside it.
await expect.poll(() => resumes(seen).length, { timeout: 20_000 }).toBe(1)
const body = JSON.parse(resumes(seen)[0]!.body)
expect(body).toMatchObject({ type: 'code', application: 'hanzo-app', organization: 'hanzo' })
expect(body.username, 'no credential').toBeUndefined()
expect(body.password, 'no credential').toBeUndefined()
expect(asked(seen), 'hanzo.id is neither visited nor asked').toEqual([])
expect(seen.popups).toEqual([])
expect(new URL(page.url()).pathname).toBe('/login')
// Once per tab: the page again asks nothing.
await page.goto(at('/signup'))
await expect(form(page)).toBeVisible()
expect(resumes(seen)).toHaveLength(1)
})
test('a session IAM holds: Log in opens the app with nothing typed, and goes nowhere else', async ({ page }) => {
await person(page)
await known(page)
const seen = await world(page, true)
await platform(page)
await page.goto(at('/login'))
await expect(page.locator('[data-room]')).toBeVisible({ timeout: 20_000 })
expect(new URL(page.url()).pathname).toBe('/')
expect(resumes(seen)).toHaveLength(1)
exchanged(seen)
expect(seen.popups).toEqual([])
expect(await page.evaluate(() => localStorage.getItem('hanzo_iam_access_token'))).toBe(TOKEN)
expect(await page.evaluate(() => sessionStorage.getItem('hz_signin_method')), 'counted as a login by session').toBe('session')
})
test('a callback IAM refused says so in words, centred, with a way back, and logs the code', async ({ page }) => {
await world(page, false)
await page.addInitScript(() => sessionStorage.setItem('hz_signin_method', 'google'))
const warned: string[] = []
page.on('console', (m) => {
if (m.type() === 'warning') warned.push(m.text())
})
await page.setViewportSize({ width: 1440, height: 987 })
await page.goto(at('/auth/callback?error=server_error&error_description=upstream&state=s1'))
const said = page.getByRole('heading', { name: "We couldn't finish signing you in with Google." })
await expect(said).toBeVisible({ timeout: 20_000 })
const box = (await said.boundingBox())!
expect(Math.abs(box.y + box.height / 2 - 987 / 2), 'in the middle of the window, not near its top').toBeLessThan(987 / 4)
await expect(page.getByText('server_error', { exact: true }), 'the code, small, for whoever reports it').toBeVisible()
expect(warned.join(' ')).toContain('server_error')
await page.getByRole('button', { name: 'Try again' }).click()
await page.waitForURL((u) => u.pathname === '/login', { timeout: 20_000 })
})
test('an automated browser is not asked for, and is drawn the form', async ({ page }) => {
const seen = await world(page, true)
await page.goto(at('/login'))
await expect(form(page)).toBeVisible({ timeout: 20_000 })
expect(resumes(seen)).toEqual([])
})
test('a place in the app sends a stranger to sign up, in this tab, and keeps the place', async ({ page }) => {
const { popups } = await world(page, false)
const SESSION = `sess_${'a'.repeat(32)}`
await page.goto(at(`/dev/${SESSION}`))
await page.waitForURL((u) => u.pathname === '/signup', { timeout: 20_000 })
expect(await page.evaluate(() => sessionStorage.getItem('signin.destination'))).toBe(`/dev/${SESSION}`)
expect(new URL(page.url()).searchParams.get('next'), 'and in the address').toBe(`/dev/${SESSION}`)
expect(popups).toEqual([])
})
test('a browser that has signed in here is sent to log in instead', async ({ page }) => {
await world(page, false)
await page.addInitScript(() => localStorage.setItem('hz_known', '1'))
const SESSION = `sess_${'b'.repeat(32)}`
// An old address moves to /dev first, and the sign-in returns there, where
// the door replaces it with its path (app/dev/_door.tsx `moved`).
await page.goto(at(`/?at=${SESSION}`))
await page.waitForURL((u) => u.pathname === '/login', { timeout: 20_000 })
expect(await page.evaluate(() => sessionStorage.getItem('signin.destination'))).toBe(`/dev?at=${SESSION}`)
})