mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
162 lines
7.7 KiB
TypeScript
162 lines
7.7 KiB
TypeScript
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')
|
||
})
|
||
})
|