Files
zeekay 001da43c40 The gates read what the product does now where it changed on purpose
- bot: the pricing section is "Included in $100+ Max plans" since e229ebc4.
- crew, together: once something is said the faces under the composer are
  out of the way (@hanzo/rooms b7fbadd); the header is where the room is
  read and where one leaves.
- limits: the composer is Dev's since @hanzo/rooms 0f921c5, so at a laptop
  the fallback banner's message takes two lines beside its actions.
- members: the reader's own row reads as his account, "(you)", with no menu
  (Members.tsx since 5e216f28); the gate asserted the mark was absent.
- leaderboard: /benchmarks also asks the research API for the coding board
  and each mcp.* tool (891bcc56); every address is answered from its
  snapshot, so the console holds no CORS refusal from the real network.
- page-kit: an email body is mail, not a page (lib/publish MAIL, 1b6bcb6a),
  and its hidden preheader is a 1px line on purpose.
- weight: an import written inside a code sample (a template string, the
  server pages that highlight samples since 11265a21) is text, not an import.
- signin: a stranger on /chat is sent to this site's sign-up (e022a96b), so
  the note is read from the origin's sessionStorage across the hop.
2026-10-15 09:42:14 -07:00

283 lines
12 KiB
TypeScript

import { expect, test, type Page } from '@playwright/test'
import { serveExport } from './export'
import { signIn } from '../session'
/**
* AN ORGANIZATION'S MEMBERS, AS ITS OWNER READS THEM.
*
* Josh lives in his personal organization and owns webby-ai, where a and z act
* from theirs. IAM answers the roster from `GET /v1/iam/memberships?org=`, whose
* rows carry the member's `<home>/<username>` and role and no name, and it lets
* Josh read no record in a and z's home organization. So a and z read as the
* username the row carries, Josh reads as his own account, and no user record
* outside webby-ai is asked for.
*
* Read at Settings → Members (/settings/organization/members), the one home of
* the roster and both ways in. Measured on the shipped bytes at a laptop and a
* phone; the `webkit` project runs this file in WebKit too.
*/
const ORG = 'webby-ai'
const JOSH = {
owner: 'joshuafl369',
name: 'joshuafl369',
displayName: 'Joshua Lynch',
email: '[email protected]',
}
/** IAM's rows for webby-ai: `{ status, data, data2 }`, one per member. */
const ROSTER = [
{ user: 'hanzo/a', role: 'admin' },
{ user: 'hanzo/z', role: 'admin' },
{ user: `${JOSH.owner}/${JOSH.name}`, role: 'owner' },
].map((m) => ({
owner: 'admin',
name: `${m.user}|${ORG}`,
createdTime: '2026-09-01T00:00:00Z',
user: m.user,
team: '',
org: ORG,
workspace: '',
project: '',
role: m.role,
}))
const INVITATION = {
owner: ORG,
name: 'inv-k7m2p9q4xw',
displayName: 'Invitation for [email protected]',
code: 'K7M2P9Q4XW',
quota: 1,
usedCount: 0,
email: '[email protected]',
state: 'Active',
}
const SIZES = [
{ width: 1440, height: 900 },
{ width: 390, height: 844 },
] as const
const json = (status: number, body: unknown) => ({
status,
contentType: 'application/json',
body: JSON.stringify(body),
})
/** What the page asked IAM for, and how each invitations read was answered. */
interface Seen {
users: string[]
invitations: { url: string; status: number }[]
}
/** IAM as it answers Josh: his own record, webby-ai's roster and directory, and a 403 for any other org's user. */
async function iam(page: Page): Promise<Seen> {
const seen: Seen = { users: [], invitations: [] }
await page.route('**/v1/**', (route) => route.fulfill(json(200, {})))
await signIn(page, {
sub: '5f0c2d0e-8a51-4d3c-9d6b-0c7a1e2f3b4c',
name: JOSH.name,
email: JOSH.email,
orgs: [
{ org: JOSH.owner, role: 'admin' },
{ org: ORG, role: 'owner' },
],
})
await page.addInitScript((org) => localStorage.setItem('hanzo_iam_current_org', org), ORG)
await page.route(
(url) => url.pathname === '/v1/iam/account',
(route) =>
route.fulfill(
json(200, { status: 'ok', data: { ...JOSH, id: '5f0c2d0e-8a51-4d3c-9d6b-0c7a1e2f3b4c', type: 'normal-user' } }),
),
)
await page.route(
(url) => url.pathname === '/v1/iam/memberships',
(route) => {
const org = new URL(route.request().url()).searchParams.get('org')
return route.fulfill(
org === ORG ? json(200, { status: 'ok', msg: '', data: ROSTER, data2: ROSTER.length }) : json(400, { status: 'error', msg: 'auth:Unauthorized operation' }),
)
},
)
await page.route(
(url) => url.pathname === '/v1/iam/users' || url.pathname.startsWith('/v1/iam/users/'),
(route) => {
const url = new URL(route.request().url())
seen.users.push(url.pathname + url.search)
// webby-ai's own directory, which Josh may read as its owner: nobody lives there.
if (url.pathname === '/v1/iam/users' && url.searchParams.get('owner') === ORG) {
return route.fulfill(json(200, { users: [], total: 0 }))
}
if (url.pathname === `/v1/iam/users/${JOSH.owner}/${JOSH.name}`) {
return route.fulfill(json(200, { ...JOSH, type: 'normal-user' }))
}
return route.fulfill(json(403, { status: 403, error: 'forbidden' }))
},
)
await page.route(
(url) => url.pathname === '/v1/iam/invitations',
(route) => {
const url = new URL(route.request().url())
const status = url.searchParams.get('owner') === ORG ? 200 : 403
seen.invitations.push({ url: url.pathname + url.search, status })
return route.fulfill(
status === 200 ? json(200, { invitations: [INVITATION], total: 1 }) : json(403, { status: 403, error: 'forbidden' }),
)
},
)
return seen
}
test.describe('organization members', () => {
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 MEMBERS = '/settings/organization/members'
for (const size of SIZES) {
test(`at ${size.width}px the owner reads as owner, each member by what IAM names, and nothing runs off the side`, async ({ page }, info) => {
const faults: string[] = []
page.on('console', (m) => {
if (m.type() === 'error') faults.push(m.text())
})
page.on('pageerror', (e) => faults.push(String(e)))
await page.setViewportSize(size)
const seen = await iam(page)
await page.goto(`${base}${MEMBERS}`)
// THE ROLE AS STORED, and a menu on every row but the reader's own, which
// reads as his account and is marked his (Members.tsx, since 5e216f28).
await expect(page.getByRole('button', { name: 'Actions for a' })).toBeVisible({ timeout: 30_000 })
await expect(page.getByRole('button', { name: 'Actions for z' })).toBeVisible()
await expect(page.getByText(`${JOSH.displayName} (you)`)).toBeVisible()
await expect(page.getByRole('button', { name: `Actions for ${JOSH.displayName}` })).toHaveCount(0)
await expect(page.getByText('Owner', { exact: true }).first()).toBeVisible()
// NAMED FROM WHAT THE READER MAY READ, and no record in another org asked.
const foreign = seen.users.filter((u) => /^\/v1\/iam\/users\/[^/]+\/[^/]+/.test(u) && !u.includes(`/${JOSH.owner}/${JOSH.name}`))
expect(foreign, 'no single user record outside the reader is read').toEqual([])
// THE INVITATIONS NAME THEIR ORGANIZATION, and the answer is drawn.
await expect(page.getByText(INVITATION.email, { exact: true })).toBeVisible()
expect(seen.invitations.length, 'the invitations were read').toBeGreaterThan(0)
for (const read of seen.invitations) {
expect(read.url, 'the invitations read names webby-ai').toContain(`owner=${ORG}`)
expect(read.status, 'the invitations read is answered, not refused').not.toBe(403)
}
expect(
await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth),
'no sideways page scroll',
).toBe(false)
expect(faults.filter((f) => /#418|hydrat/i.test(f)), 'no hydration mismatch').toEqual([])
await page.screenshot({ path: info.outputPath(`members-${size.width}.png`), fullPage: true })
})
}
// TWO WAYS IN, AND THE PAGE SAYS WHICH HAPPENED. An email invitation is sent
// by IAM and says so only when IAM answers that it sent it; a send IAM refuses
// keeps the invitation and hands over its link. The shared link is its own
// control, made on request, open to LINK_SEATS people.
test('an invitation is emailed or shared by link, and the page says which happened', async ({ page }, info) => {
await page.setViewportSize({ width: 1440, height: 900 })
await iam(page)
const created: Record<string, unknown>[] = []
let refuse = false
await page.addInitScript(() => {
const w = window as unknown as { copied: string[] }
w.copied = []
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: async (t: string) => void w.copied.push(t) },
})
})
await page.route(
(url) => url.pathname === '/v1/iam/invitations',
(route) => {
if (route.request().method() === 'POST') {
const row = { ...route.request().postDataJSON(), code: `MINTED${created.length}QXRT7VBNK2HZ8P`, generated: true, usedCount: 0 }
created.push(row)
return route.fulfill(json(200, row))
}
return route.fulfill(json(200, { invitations: [...created, INVITATION], total: created.length + 1 }))
},
)
const sends: unknown[] = []
await page.route(
(url) => /^\/v1\/iam\/invitations\/[^/]+\/[^/]+\/send$/.test(url.pathname),
(route) => {
sends.push(route.request().postDataJSON())
const name = new URL(route.request().url()).pathname.split('/')[5]
const inv = [...created, INVITATION].find((r) => r.name === name)
return route.fulfill(
refuse ? json(503, { status: 503, error: 'no email delivery is configured here' }) : json(200, { sent: true, to: inv?.email }),
)
},
)
const copied = () => page.evaluate(() => (window as unknown as { copied: string[] }).copied)
const link = (inv: Record<string, unknown>) => `https://hanzo.id/join?client_id=hanzo-app&invite=${inv.code}&org=${ORG}`
await page.goto(`${base}${MEMBERS}`)
const address = page.getByLabel('Email address to invite')
await expect(address).toBeVisible({ timeout: 30_000 })
// Sent: IAM answered, and only then does the page say so.
await address.fill('[email protected]')
await page.getByRole('button', { name: 'Send invite' }).click()
await expect(page.getByText('Invitation sent to [email protected]')).toBeVisible()
expect(created[0]).toMatchObject({ owner: ORG, email: '[email protected]', quota: 1, state: 'Active' })
expect(sends).toEqual([{}])
// Refused: the invitation stands, the reason is said, and its link is offered.
refuse = true
await address.fill('[email protected]')
await page.getByRole('button', { name: 'Send invite' }).click()
const alert = page.getByRole('alert').filter({ hasText: '[email protected]' })
await expect(alert).toContainText('The invitation for [email protected] was made, and the email was not sent: no email delivery is configured here')
await expect(page.getByText(/Invitation sent to bo@webby\.ai/)).toHaveCount(0)
await alert.getByRole('button', { name: 'Copy invitation link' }).click()
await expect.poll(copied).toContain(link(created[1]))
// The shared link: made on request, open to anyone, counted, and copied.
await page.getByRole('button', { name: 'Create invite link' }).click()
await expect(page.getByText('Anyone with this link can join · 0 of 25 used.')).toBeVisible()
expect(created[2]).toMatchObject({ owner: ORG, quota: 25, state: 'Active' })
expect(created[2].email ?? '', 'the link is pinned to nobody').toBe('')
const shared = page.getByRole('region', { name: 'Invite link' })
await expect(shared.getByText(link(created[2]))).toBeVisible()
await shared.getByRole('button', { name: 'Copy invite link' }).click()
await expect.poll(async () => (await copied()).at(-1)).toBe(link(created[2]))
// A pinned invitation can be sent again from the list.
refuse = false
await page.getByRole('button', { name: 'Resend' }).last().click()
await expect(page.getByText(`Invitation sent to ${INVITATION.email}`)).toBeVisible()
await page.screenshot({ path: info.outputPath('invite-1440.png'), fullPage: true })
})
// THE ACCOUNT HAS ONE HOME. Every /account address is its Settings page, so a
// bookmark lands where the thing now lives (components/account/Forward.tsx).
for (const [from, to] of [
['/account', '/settings'],
['/account/settings', '/settings'],
['/account/organization', '/settings/organization/members'],
['/account/billing', '/settings/organization/billing'],
['/account/invoices', '/settings/organization/billing'],
['/account/usage', '/settings/organization/usage'],
]) {
test(`${from} is ${to}`, async ({ page }) => {
await iam(page)
await page.goto(`${base}${from}`)
await page.waitForURL(`${base}${to}`, { timeout: 20_000 })
})
}
})