mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
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.
182 lines
8.9 KiB
TypeScript
182 lines
8.9 KiB
TypeScript
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}`)
|
||
})
|