Files

162 lines
7.7 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 BrowserContext, type Page } from '@playwright/test'
import { serveExport } from './export'
import { claims, contract, issuer, jwt, type Seen } from './issuer'
/**
* EVERY TAB FOLLOWS THE ONE SESSION.
*
* The session is one store every tab of the site shares, and the SDK in each tab
* keeps its own copy of it, told nothing when another tab changes the store.
* lib/auth/session.ts `follow` is the subject: a tab whose stored token stops
* naming the person it was drawn for — signed out, or someone else signed in —
* reloads onto what the store now holds, and a refresh for the same person
* moves nothing.
*
* Measured with two tabs of one browser on the shipped bytes. Dev is open in
* one as `a2 · a23`; the other signs out through the site's own account menu,
* the issuer answered here. What must not happen is what the platform refuses:
* a call carrying `X-Org-Id` with no bearer, which it answers as nobody
* (`a validated principal is required`). A tab left with nobody does not send
* anyone to hanzo.id while that sign-out is still on its way.
*/
const PERSON = { sub: '1a84d451-8990-41bc-84a2-173a82863a4d', name: 'a2', org: 'a23' }
/** A token the SDK reads as live, for `sub` in `org`. */
const token = (sub: string, name: string, org: string, exp?: number) =>
jwt({ sub, name, email: `${name}@hanzo.ai`, orgs: [{ org, role: 'owner' }], ...(exp ? { exp } : {}) })
/** What reached the platform: each call's path, org and whether it carried a bearer. */
type Call = { path: string; org: string; bearer: boolean }
/**
* One browser signed in as PERSON, seeded ONCE for the whole context: a
* per-document seed would put the tokens back into the page a sign-out lands
* on, and the sign-out is the subject.
*/
async function browser(context: BrowserContext, base: string): Promise<{ calls: Call[]; seen: Seen }> {
const calls: Call[] = []
await context.addInitScript(
({ t, sub }) => {
if (localStorage.getItem('gate.seeded')) return
localStorage.setItem('gate.seeded', '1')
localStorage.setItem('hanzo_iam_access_token', t)
localStorage.setItem('hanzo_iam_id_token', t)
localStorage.setItem('hanzo_iam_refresh_token', 'refresh')
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3600_000))
localStorage.setItem('hanzo:who', sub)
},
{ t: token(PERSON.sub, PERSON.name, PERSON.org), sub: PERSON.sub },
)
// IAM: userinfo read at api.hanzo.ai for whoever the bearer names, hanzo.id recorded.
const seen = await issuer(context)
// The platform, which this export reaches on its own origin: every read
// answered empty, every call recorded.
const site = new URL(base).origin
await context.route(
(u) => u.origin === site && u.pathname.startsWith('/v1/'),
(route) => {
const req = route.request()
const url = new URL(req.url())
const h = req.headers()
if (req.method() !== 'OPTIONS') calls.push({ path: url.pathname, org: h['x-org-id'] ?? '', bearer: Boolean(h.authorization) })
if (url.pathname === '/v1/billing/tier') {
const c = claims(h.authorization)
return route.fulfill({
json: {
user: c?.sub ?? '',
tier: { name: 'dev', displayName: 'Dev', dailyCreditsCents: 0, maxAgents: 8, unlimitedAgents: false },
balance: { currency: 'usd', prepaidAvailable: 0, creditsRemaining: 0, dailyRemaining: 0, effectiveAvailable: 0 },
},
})
}
if (url.pathname.endsWith('/stream')) return route.fulfill({ contentType: 'text/event-stream', body: '' })
return route.fulfill({ json: { sessions: [], data: [], targets: [] } })
},
)
return { calls, seen }
}
/** Open Dev and wait for it to be drawn for `who`. */
async function dev(page: Page, base: string, who: string): Promise<void> {
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${base}/dev`)
await expect(page.getByText('Build what’s next.')).toBeVisible({ timeout: 20_000 })
await expect(page.getByRole('button', { name: `Account: ${who}` })).toBeVisible({ timeout: 20_000 })
// A mark only this document carries: gone means the tab reloaded.
await page.evaluate(() => ((window as unknown as { gateKept: boolean }).gateKept = true))
}
const kept = (page: Page) => page.evaluate(() => (window as unknown as { gateKept?: boolean }).gateKept === true)
test.describe('every tab follows the one session', () => {
let base: string
let stop: () => Promise<void>
test.beforeAll(async () => {
const served = await serveExport()
base = served.url
stop = served.close
})
test.afterAll(async () => stop())
test('a sign-out in another tab takes Dev back to Dev’s page, and Dev sends nothing as nobody', async ({ context }) => {
const { calls, seen } = await browser(context, base)
const work = await context.newPage()
await dev(work, base, `${PERSON.name} · ${PERSON.org}`)
await expect
.poll(() => calls.some((c) => c.org === PERSON.org && c.bearer), { message: 'Dev reads the platform as a2, in a23' })
.toBe(true)
// The other tab signs out through the site's own account menu.
const other = await context.newPage()
await other.goto(`${base}/pricing`)
const me = other.locator('header[data-slot="masthead"] button[aria-haspopup="menu"]')
await expect(me).toBeVisible({ timeout: 20_000 })
await me.click()
await other.getByRole('menuitem', { name: 'Sign out' }).click()
// Signed out on this site: IAM is told by calls answered here, and the tab
// lands on this site's sign-in, never on hanzo.id.
await other.waitForURL((u) => u.origin === base && u.pathname === '/login' && u.searchParams.get('from') === 'logout', { timeout: 20_000 })
// Dev's tab reloads signed out, and bare /dev signed out is Dev's page
// (app/dev/_door.tsx), with the silent ask spent: Open Dev signs the reader in
// from the form rather than being answered by the session being ended.
await expect(work.getByRole('heading', { level: 1 })).toHaveText(/Describe the change/, { timeout: 20_000 })
expect(new URL(work.url()).pathname).toBe('/dev')
await expect(work.getByText('Build what’s next.')).toHaveCount(0)
expect(await work.evaluate(() => sessionStorage.getItem('signin.probed'))).toBe('1')
// Its last call before it left carried a bearer; none went out as nobody.
expect(calls.filter((c) => c.org && !c.bearer), 'a call naming an org with nothing to vouch for it').toEqual([])
contract(seen)
})
test('a refresh in another tab leaves Dev where it is', async ({ context }) => {
await browser(context, base)
const work = await context.newPage()
await dev(work, base, `${PERSON.name} · ${PERSON.org}`)
const other = await context.newPage()
await other.goto(`${base}/robots.txt`)
await other.evaluate((t) => localStorage.setItem('hanzo_iam_access_token', t), token(PERSON.sub, PERSON.name, PERSON.org, Math.floor(Date.now() / 1000) + 7200))
await work.waitForTimeout(1500)
expect(await kept(work), 'the same person, so the same page').toBe(true)
expect(new URL(work.url()).pathname).toBe('/dev')
})
test('someone else signing in in another tab redraws Dev as them', async ({ context }) => {
await browser(context, base)
const work = await context.newPage()
await dev(work, base, `${PERSON.name} · ${PERSON.org}`)
const other = await context.newPage()
await other.goto(`${base}/robots.txt`)
await other.evaluate((t) => localStorage.setItem('hanzo_iam_access_token', t), token('b0b', 'bob', 'bobs'))
await expect(work.getByRole('button', { name: 'Account: bob · bobs' })).toBeVisible({ timeout: 20_000 })
expect(await kept(work), 'redrawn, not patched').toBe(false)
expect(await work.evaluate(() => localStorage.getItem('hanzo:who'))).toBe('b0b')
})
})