mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +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.
308 lines
16 KiB
TypeScript
308 lines
16 KiB
TypeScript
import { expect, test, type Page, type Request } from '@playwright/test'
|
|
import { serveExport } from './export'
|
|
import { stream, type Wire } from './heard'
|
|
import { API, DISCOVERY, contract, issuer, pkce, type Seen as Iam } from './issuer'
|
|
import { signIn } from '../session'
|
|
import { CLIENT } from '@hanzo/app/lib/api'
|
|
|
|
/**
|
|
* A SUPPORT SESSION IS OPENED BY A LINK, MARKED ON EVERY PAGE, NEVER RENEWED, AND
|
|
* HANDED BACK WHEN IT ENDS.
|
|
*
|
|
* IAM's support flow (hanzoai/iam internal/oidc/impersonate.go) sends a SuperAdmin
|
|
* to `/auth/initiate?iss=<issuer>&login_hint=impersonation:<id>`. Measured on the
|
|
* shipped bytes, served with the edge's headers (lib/edge.ts), hanzo.id answered
|
|
* on this machine: a link that is not a support sign-in for this site is refused in
|
|
* words and signs nobody out; the right one starts a PKCE sign-in as the client this
|
|
* site signs in as, carrying the hint unchanged and leaking it nowhere; IAM's token
|
|
* (`imp`, the operator in `act`, no refresh token) lands on `/` under a banner with
|
|
* no dismiss; every end — Exit, the SDK's own sixty seconds early, a token found
|
|
* expired — revokes the token and leaves nothing of the person behind; and nothing
|
|
* is written or counted as the person.
|
|
*/
|
|
|
|
const b64 = (o: object) => Buffer.from(JSON.stringify(o)).toString('base64url')
|
|
const jwt = (claims: object) => [b64({ alg: 'none', typ: 'JWT' }), b64(claims), ''].join('.')
|
|
const now = () => Math.floor(Date.now() / 1000)
|
|
|
|
const ISSUER = DISCOVERY.issuer
|
|
const SECRET = '9b2e6c0f4a7d41e3'
|
|
const HINT = `impersonation:${SECRET}`
|
|
const ACT = { sub: 'u-z', owner: 'admin', name: 'z' }
|
|
const PERSON = { sub: 'u-alice', name: 'alice', email: '[email protected]' }
|
|
const BANNER = 'Signed in as alice by admin/z — support session'
|
|
/** One stream flush (@hanzo/event `flushIntervalMs`) and a margin. */
|
|
const FLUSH = 6000
|
|
|
|
/** A support session's token, as IAM mints it. */
|
|
const SUPPORT = (exp: number) => jwt({ ...PERSON, imp: true, act: ACT, exp, orgs: [{ org: 'acme', role: 'member' }] })
|
|
/** The operator's own session on this site, which a link must not end. */
|
|
const EARLIER = jwt({ sub: 'u-z', name: 'z', email: '[email protected]', exp: now() + 3600, orgs: [{ org: 'admin', role: 'admin' }] })
|
|
|
|
let base: string
|
|
let stop: () => Promise<void>
|
|
|
|
test.beforeAll(async () => {
|
|
const served = await serveExport()
|
|
base = served.url
|
|
stop = served.close
|
|
})
|
|
test.afterAll(async () => stop())
|
|
|
|
const initiate = (search: string) => `${base}/auth/initiate?${search}`
|
|
const link = (hint = HINT, iss = ISSUER) => initiate(`iss=${encodeURIComponent(iss)}&login_hint=${encodeURIComponent(hint)}`)
|
|
|
|
interface Seen extends Iam {
|
|
/** Every request the page made, with its headers and body. */
|
|
requests: Request[]
|
|
console: string[]
|
|
events: Wire[]
|
|
}
|
|
|
|
/**
|
|
* The world, answered: IAM by the gates' one helper (`./issuer`), minting a support
|
|
* session's token (`imp`, the operator in `act`, no refresh token) and sending the
|
|
* authorize page back by `back`; this origin's /v1 with an empty answer; the stream
|
|
* recorded. Every request is kept, with its headers. Everything else fails.
|
|
*/
|
|
async function world(page: Page, back?: (authorize: URL) => string): Promise<Seen> {
|
|
const requests: Request[] = []
|
|
const console: string[] = []
|
|
page.on('console', (m) => console.push(m.text()))
|
|
page.on('request', (r) => requests.push(r))
|
|
await page.route('**/*', (route) => (new URL(route.request().url()).origin === base ? route.continue() : route.abort()))
|
|
await page.route(`${base}/v1/**`, (route) => route.fulfill({ json: {} }))
|
|
const events = await stream(page)
|
|
const iam = await issuer(page, { token: SUPPORT(now() + 900), authorize: back })
|
|
return Object.assign(iam, { requests, console, events })
|
|
}
|
|
|
|
/** The bodies posted to IAM's token endpoint, in order. */
|
|
const grants = (seen: Seen) =>
|
|
seen.requests
|
|
.filter((r) => r.url() === `${API}/v1/iam/oauth/token` && r.method() === 'POST')
|
|
.map((r) => new URLSearchParams(r.postData() ?? ''))
|
|
|
|
/** This browser, once: `token` in the SDK's store with a stale refresh token beside it, and `keep`. */
|
|
function seed(page: Page, token: string, exp: number, keep: Record<string, string> = {}) {
|
|
return page.addInitScript(
|
|
({ token, exp, keep }) => {
|
|
if (sessionStorage.getItem('gate.seeded')) return
|
|
sessionStorage.setItem('gate.seeded', '1')
|
|
localStorage.setItem('hanzo_iam_access_token', token)
|
|
localStorage.setItem('hanzo_iam_refresh_token', 'r-earlier')
|
|
localStorage.setItem('hanzo_iam_expires_at', String(exp * 1000))
|
|
for (const [k, v] of Object.entries(keep)) localStorage.setItem(k, v)
|
|
},
|
|
{ token, exp, keep },
|
|
)
|
|
}
|
|
|
|
/** The person's own things this browser holds during a support session. */
|
|
const THEIRS = { 'hanzo:who': 'u-alice', hanzo_custom_threads_v1: '[{"id":"ct-alice","title":"Acme offsite"}]' }
|
|
|
|
/** What this browser holds, read between navigations (one in flight answers null). */
|
|
const stored = (page: Page) =>
|
|
page
|
|
.evaluate(() => ({
|
|
access: localStorage.getItem('hanzo_iam_access_token'),
|
|
refresh: localStorage.getItem('hanzo_iam_refresh_token'),
|
|
who: localStorage.getItem('hanzo:who'),
|
|
threads: localStorage.getItem('hanzo_custom_threads_v1'),
|
|
}))
|
|
.catch(() => null)
|
|
|
|
const mark = (page: Page) => page.getByRole('status', { name: 'Support session' })
|
|
const refreshes = (seen: Seen) => grants(seen).filter((b) => b.get('grant_type') === 'refresh_token').length
|
|
const sent = (seen: Seen, path: string) =>
|
|
seen.requests.filter((r) => new URL(r.url()).pathname === path && r.method() !== 'GET' && r.method() !== 'OPTIONS')
|
|
|
|
/**
|
|
* Every request that carried the hint, in its address, body or Referer, but the
|
|
* two that must: the link's own page load and the authorize at hanzo.id.
|
|
*/
|
|
const leaks = (seen: Seen) =>
|
|
seen.requests
|
|
.filter((r) => {
|
|
const url = new URL(r.url())
|
|
const own = url.origin === base && url.pathname === '/auth/initiate' && r.isNavigationRequest()
|
|
return !own && url.pathname !== '/v1/iam/oauth/authorize'
|
|
})
|
|
.filter((r) => [r.url(), r.postData() ?? '', r.headers().referer ?? ''].some((s) => s.includes(SECRET)))
|
|
.map((r) => `${r.method()} ${r.url()} referer=${r.headers().referer ?? ''}`)
|
|
|
|
test.describe('the link', () => {
|
|
for (const [what, address, refusal] of [
|
|
['another issuer', () => link(HINT, 'https://evil.example'), 'This sign-in link is not for this site.'],
|
|
['no hint', () => initiate(`iss=${encodeURIComponent(ISSUER)}`), 'This sign-in link is not a support sign-in.'],
|
|
['a person as the hint', () => link('[email protected]'), 'This sign-in link is not a support sign-in.'],
|
|
] as const) {
|
|
test(`naming ${what} is refused in words, starts nothing, leaks nothing and signs nobody out`, async ({ page }) => {
|
|
const seen = await world(page)
|
|
await seed(page, EARLIER, now() + 3600, { 'hanzo:who': 'u-z' })
|
|
await page.goto(address())
|
|
await expect(page.getByRole('heading', { name: refusal })).toBeVisible()
|
|
expect(new URL(page.url()).search, 'the query left the address').toBe('')
|
|
expect(await page.content(), 'the hint is never drawn').not.toContain(SECRET)
|
|
await page.waitForTimeout(FLUSH)
|
|
expect(seen.authorize, 'no sign-in started').toEqual([])
|
|
expect(await stored(page), 'the session in this browser is untouched').toMatchObject({ access: EARLIER, refresh: 'r-earlier', who: 'u-z' })
|
|
expect(leaks(seen)).toEqual([])
|
|
expect(seen.console.filter((m) => m.includes(SECRET))).toEqual([])
|
|
})
|
|
}
|
|
|
|
test('the page goes out with no referrer, so nothing it loads names its address', async ({ page }) => {
|
|
const seen = await world(page)
|
|
const document = page.waitForResponse((r) => new URL(r.url()).pathname === '/auth/initiate')
|
|
await page.goto(link(HINT, 'https://evil.example'))
|
|
expect((await document).headers()['referrer-policy']).toBe('no-referrer')
|
|
await expect(page.getByRole('heading', { name: 'This sign-in link is not for this site.' })).toBeVisible()
|
|
const loaded = seen.requests.filter((r) => new URL(r.url()).origin === base && !r.isNavigationRequest())
|
|
expect(loaded.length, 'the page loaded its own stylesheets and scripts').toBeGreaterThan(0)
|
|
// A font a stylesheet asks for names the stylesheet, which is not this page.
|
|
expect(loaded.filter((r) => (r.headers().referer ?? '').includes('/auth/initiate')).map((r) => r.url())).toEqual([])
|
|
})
|
|
|
|
test('the issuer\'s link signs in as this site\'s client, lands on `/` under the banner, and Exit hands the token back', async ({ page }) => {
|
|
const seen = await world(page, (a) => `${base}/auth/callback?code=c0de&state=${a.searchParams.get('state')}`)
|
|
await seed(page, EARLIER, now() + 3600, { 'hanzo:who': 'u-z' })
|
|
await page.goto(link())
|
|
|
|
// A fresh PKCE sign-in at hanzo.id, as the one client this site signs in as —
|
|
// the one IAM_IMPERSONATION_APPS must name — carrying the hint as IAM minted it.
|
|
const q = (await pkce(seen, base)).searchParams
|
|
expect(seen.authorize).toHaveLength(1)
|
|
expect(q.get('client_id')).toBe(CLIENT)
|
|
expect(CLIENT).toBe('hanzo-app')
|
|
expect(q.get('login_hint')).toBe(HINT)
|
|
|
|
await expect(mark(page)).toHaveText(new RegExp(`^${BANNER}`), { timeout: 20_000 })
|
|
await expect.poll(() => new URL(page.url()).pathname).toBe('/')
|
|
expect(grants(seen).map((b) => b.get('grant_type'))).toEqual(['authorization_code'])
|
|
expect(grants(seen)[0].get('code_verifier'), 'the code is redeemed with this browser\'s verifier').toBeTruthy()
|
|
// IAM issued no refresh token; the earlier session's does not stay to renew this one.
|
|
await expect.poll(async () => (await stored(page)).refresh).toBeNull()
|
|
await expect(mark(page).getByRole('button')).toHaveText('Exit')
|
|
|
|
// Nothing is identified or counted as the person, and the hint went nowhere.
|
|
// The one identify allowed is the operator's own earlier session, on the
|
|
// link's page before the hop to hanzo.id: that page is the operator's browser
|
|
// signed in as itself (nothing is cleared first), and whether its identify
|
|
// flushes before the page leaves is a race (1 run in 3 here). Its address
|
|
// carries no hint (`leaks` below). Once the support session lands, nothing
|
|
// is identified at all, the person or anyone.
|
|
await page.waitForTimeout(FLUSH)
|
|
const before = (e: Wire & { path?: string; distinctId?: string }) => e.path === '/auth/initiate' && e.distinctId === ACT.sub
|
|
expect(seen.events.filter((e) => e.type === 'identify' && !before(e))).toEqual([])
|
|
expect(seen.events.filter((e) => e.event === 'login_completed')).toEqual([])
|
|
expect(leaks(seen)).toEqual([])
|
|
expect(seen.console.filter((m) => m.includes(SECRET))).toEqual([])
|
|
|
|
const support = (await stored(page)).access
|
|
await page.evaluate((v) => localStorage.setItem('hanzo_custom_threads_v1', v), THEIRS.hanzo_custom_threads_v1)
|
|
await mark(page).getByRole('button', { name: 'Exit' }).click()
|
|
await expect.poll(() => seen.revoked.map((b) => b.get('token'))).toEqual([support])
|
|
expect(Object.fromEntries(seen.revoked[0])).toMatchObject({ token_type_hint: 'access_token', client_id: CLIENT })
|
|
await expect(mark(page)).toHaveCount(0)
|
|
await expect.poll(() => stored(page)).toEqual({ access: null, refresh: null, who: null, threads: null })
|
|
expect(new URL(page.url()).pathname).toBe('/')
|
|
expect(refreshes(seen)).toBe(0)
|
|
contract(seen)
|
|
})
|
|
|
|
test('a sign-in IAM refuses leaves the session this browser had', async ({ page }) => {
|
|
const seen = await world(
|
|
page,
|
|
(a) => `${base}/auth/callback?error=access_denied&error_description=refused&state=${a.searchParams.get('state')}`,
|
|
)
|
|
await seed(page, EARLIER, now() + 3600, { 'hanzo:who': 'u-z' })
|
|
await page.goto(link())
|
|
await expect.poll(() => seen.authorize.length, { timeout: 15_000 }).toBe(1)
|
|
await expect(page.getByRole('button', { name: 'Try again' })).toBeVisible({ timeout: 15_000 })
|
|
expect(await stored(page)).toMatchObject({ access: EARLIER, refresh: 'r-earlier', who: 'u-z' })
|
|
expect(grants(seen)).toEqual([])
|
|
contract(seen)
|
|
})
|
|
})
|
|
|
|
test.describe('the end', () => {
|
|
test('a browser reopened on an expired support session hands it back and keeps nothing of the person', async ({ page }) => {
|
|
const seen = await world(page)
|
|
const token = SUPPORT(now() - 30)
|
|
await seed(page, token, now() - 30, THEIRS)
|
|
await page.goto(`${base}/`)
|
|
await expect.poll(() => seen.revoked.map((b) => b.get('token')), { timeout: 15_000 }).toEqual([token])
|
|
await expect.poll(() => stored(page), { timeout: 15_000 }).toEqual({ access: null, refresh: null, who: null, threads: null })
|
|
await expect(mark(page)).toHaveCount(0)
|
|
expect(refreshes(seen)).toBe(0)
|
|
})
|
|
|
|
test('the SDK\'s own end, sixty seconds early, is the same end: no refresh, the token handed back, nothing kept', async ({ page }) => {
|
|
const seen = await world(page)
|
|
// Sixty-five seconds left: the SDK reaches for a renewal at sixty before the end.
|
|
const token = SUPPORT(now() + 65)
|
|
await seed(page, token, now() + 65, THEIRS)
|
|
await page.goto(`${base}/`)
|
|
await expect(mark(page)).toHaveText(new RegExp(`^${BANNER}`), { timeout: 20_000 })
|
|
await expect.poll(async () => (await stored(page)).refresh).toBeNull()
|
|
await expect.poll(() => seen.revoked.map((b) => b.get('token')), { timeout: 30_000 }).toEqual([token])
|
|
await expect.poll(() => stored(page), { timeout: 15_000 }).toEqual({ access: null, refresh: null, who: null, threads: null })
|
|
await expect(mark(page)).toHaveCount(0)
|
|
expect(refreshes(seen), 'no refresh grant was ever sent').toBe(0)
|
|
})
|
|
})
|
|
|
|
test.describe('nothing as the person', () => {
|
|
/**
|
|
* A browser holding kept affiliate and referral codes, settled on a page of the
|
|
* site's own (no room: the rooms keep their own look in step, @hanzo/app `useLook`).
|
|
*/
|
|
async function settle(page: Page, seen: Seen) {
|
|
await page.goto(`${base}/pricing`)
|
|
await expect.poll(() => seen.requests.some((r) => new URL(r.url()).pathname === '/v1/iam/oauth/userinfo'), { timeout: 20_000 }).toBe(true)
|
|
await page.waitForTimeout(FLUSH)
|
|
}
|
|
const CODES = { hz_aff: 'AFF1', hz_ref: 'REF1' }
|
|
async function support(page: Page): Promise<Seen> {
|
|
const seen = await world(page)
|
|
await seed(page, SUPPORT(now() + 900), now() + 900, { ...CODES, 'hanzo:who': 'u-alice' })
|
|
await settle(page, seen)
|
|
await expect(mark(page)).toBeVisible()
|
|
return seen
|
|
}
|
|
const tags = (seen: Seen) => seen.requests.filter((r) => new URL(r.url()).pathname === '/v1/project/tags')
|
|
|
|
test('a support session binds no affiliate and claims no referral for the person\'s org', async ({ page }) => {
|
|
const seen = await support(page)
|
|
expect(sent(seen, '/v1/affiliate/attribute')).toEqual([])
|
|
expect(sent(seen, '/v1/referral/claim')).toEqual([])
|
|
})
|
|
|
|
test('a support session writes nothing to the person\'s preferences', async ({ page }) => {
|
|
const seen = await support(page)
|
|
expect(sent(seen, '/v1/iam/preferences').map((r) => r.postData())).toEqual([])
|
|
})
|
|
|
|
test('a support session asks for the tag set as nobody', async ({ page }) => {
|
|
const seen = await support(page)
|
|
expect(tags(seen).length, 'the tag set was asked for').toBeGreaterThan(0)
|
|
expect(tags(seen).map((r) => r.headers().authorization ?? null).filter(Boolean)).toEqual([])
|
|
})
|
|
|
|
test('an ordinary session does each of them, so the support session\'s silence is measured', async ({ page }) => {
|
|
const seen = await world(page)
|
|
await page.addInitScript((codes) => {
|
|
for (const [k, v] of Object.entries(codes)) localStorage.setItem(k, v)
|
|
}, CODES)
|
|
await signIn(page, { sub: 'u-bob', name: 'bob', email: '[email protected]' })
|
|
await settle(page, seen)
|
|
await expect(mark(page)).toHaveCount(0)
|
|
await expect.poll(() => seen.events.filter((e) => e.type === 'identify').length, { timeout: 20_000 }).toBeGreaterThan(0)
|
|
expect(sent(seen, '/v1/affiliate/attribute').length).toBeGreaterThan(0)
|
|
expect(sent(seen, '/v1/referral/claim').length).toBeGreaterThan(0)
|
|
expect(sent(seen, '/v1/iam/preferences').length).toBeGreaterThan(0)
|
|
expect(tags(seen).some((r) => (r.headers().authorization ?? '').startsWith('Bearer '))).toBe(true)
|
|
})
|
|
})
|