Files
hanzo.ai/e2e/unit/members.spec.ts
zeekay 6c65e3e941 @hanzo/app 0.2.0: the app SDK, renamed from @hanzo/rooms
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.
2026-10-22 19:07:20 -07:00

262 lines
10 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 { test, expect } from '@playwright/test'
import {
LINK_SEATS,
createInvitation,
inviteLink,
listInvitations,
members,
sendInvitation,
shareable,
withdrawInvitation,
type Invitation,
} from '@hanzo/app/lib/invite'
/**
* An organization's people are everyone who may act in it: its membership rows,
* each with the role IAM stores. A row carries `<home>/<username>` and no name,
* so the reader is named from their own account, a member who lives in the org
* from the org's directory, and anyone else by username. No user record in
* another organization is asked for, and the org's service accounts are left out.
*/
const ORG = 'webby-ai'
/** A membership row exactly as `GET /v1/iam/memberships?org=` answers it. */
const row = (user: string, role: string) => ({
owner: 'admin',
name: `${user}|${ORG}`,
createdTime: '2026-09-01T00:00:00Z',
user,
team: '',
org: ORG,
workspace: '',
project: '',
role,
})
const ROSTER = {
status: 'ok',
msg: '',
data: [
row('joshuafl369/joshuafl369', 'owner'),
row('hanzo/a', 'admin'),
row('hanzo/z', 'admin'),
row('webby-ai/deploy', 'member'),
row('webby-ai/sam', 'member'),
],
data2: 5,
}
/** `GET /v1/iam/users?owner=webby-ai`: the accounts whose home is the org. */
const DIRECTORY = {
users: [
{ owner: ORG, name: 'deploy', type: 'service-account', displayName: 'Deploy (CI)' },
{ owner: ORG, name: 'sam', type: 'normal-user', displayName: 'Sam Lee', email: '[email protected]' },
],
total: 2,
}
const JOSH = { name: 'Joshua Lynch', email: '[email protected]' }
const answer = (status: number, body: unknown) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
/** A browser signed in as Josh, whose token names his home org first and then webby-ai. */
function signedIn(): () => void {
const part = (v: unknown) => btoa(JSON.stringify(v)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
const token = `${part({ alg: 'none' })}.${part({
sub: '5f0c2d0e-8a51-4d3c-9d6b-0c7a1e2f3b4c',
name: 'joshuafl369',
orgs: [
{ org: 'joshuafl369', role: 'admin' },
{ org: ORG, role: 'owner' },
],
})}.`
const store: Record<string, string> = { hanzo_iam_access_token: token, hanzo_iam_current_org: ORG }
const g = globalThis as unknown as { window?: unknown }
const was = g.window
g.window = {
localStorage: { getItem: (k: string) => store[k] ?? null },
location: { hostname: 'hanzo.ai', origin: 'https://hanzo.ai' },
}
return () => {
g.window = was
}
}
/** Answers IAM from `routes`, 403 for anything else, and records what was asked. */
function iam(routes: Record<string, [number, unknown]>): { asked: string[]; restore: () => void } {
const asked: string[] = []
const was = globalThis.fetch
globalThis.fetch = async (input: string | URL | Request, init?: RequestInit) => {
const url = new URL(String(input))
const key = `${init?.method ?? 'GET'} ${url.pathname}${url.search}`
asked.push(key)
const [status, body] = routes[key] ?? [403, { status: 403, error: 'forbidden' }]
return answer(status, body)
}
return {
asked,
restore: () => {
globalThis.fetch = was
},
}
}
test('members reads the roster with its roles, and names people only from records the reader may read', async () => {
const out = signedIn()
const net = iam({
[`GET /v1/iam/memberships?org=${ORG}`]: [200, ROSTER],
[`GET /v1/iam/users?owner=${ORG}`]: [200, DIRECTORY],
})
try {
expect(await members(ORG, JOSH)).toEqual([
{ id: 'joshuafl369/joshuafl369', name: 'Joshua Lynch', email: '[email protected]', role: 'owner', home: false },
{ id: 'hanzo/a', name: 'a', email: '', role: 'admin', home: false },
{ id: 'hanzo/z', name: 'z', email: '', role: 'admin', home: false },
{ id: 'webby-ai/sam', name: 'Sam Lee', email: '[email protected]', role: 'member', home: true },
])
expect(net.asked.filter((u) => /\/v1\/iam\/users\/[^/?]+\/[^/?]+/.test(u)), 'no single user record is read').toEqual([])
expect(net.asked.some((u) => u.includes('/scim/v2/Users')), 'the home directory is not the roster').toBe(false)
} finally {
net.restore()
out()
}
})
test('a reader refused the directory still sees every member, by username, with the stored role', async () => {
const out = signedIn()
const net = iam({ [`GET /v1/iam/memberships?org=${ORG}`]: [200, ROSTER] })
try {
expect((await members(ORG, JOSH)).map((m) => [m.name, m.role])).toEqual([
['Joshua Lynch', 'owner'],
['a', 'admin'],
['z', 'admin'],
['deploy', 'member'],
['sam', 'member'],
])
} finally {
net.restore()
out()
}
})
test('invitations are listed and withdrawn in the organization they belong to', async () => {
const out = signedIn()
const invitation = { owner: ORG, name: 'inv-k7m2p9q4xw', code: 'K7M2P9Q4XW', quota: 1, usedCount: 0, state: 'Active' }
const net = iam({
[`GET /v1/iam/invitations?owner=${ORG}`]: [200, { invitations: [invitation], total: 1 }],
[`DELETE /v1/iam/invitations/${ORG}/inv-k7m2p9q4xw`]: [200, { deleted: true }],
})
try {
expect(await listInvitations(ORG)).toEqual([invitation])
await withdrawInvitation(invitation)
expect(net.asked).toEqual([`GET /v1/iam/invitations?owner=${ORG}`, `DELETE /v1/iam/invitations/${ORG}/inv-k7m2p9q4xw`])
} finally {
net.restore()
out()
}
})
/** Like `iam`, but keeps each write's body so a test can read what was created. */
function writes(routes: Record<string, [number, unknown]>): { bodies: Record<string, unknown>[]; asked: string[]; restore: () => void } {
const bodies: Record<string, unknown>[] = []
const asked: string[] = []
const was = globalThis.fetch
globalThis.fetch = async (input: string | URL | Request, init?: RequestInit) => {
const url = new URL(String(input))
const key = `${init?.method ?? 'GET'} ${url.pathname}${url.search}`
asked.push(key)
if (init?.body) bodies.push(JSON.parse(String(init.body)))
const [status, body] = routes[key] ?? [404, { status: 404 }]
return answer(status, body)
}
return {
bodies,
asked,
restore: () => {
globalThis.fetch = was
},
}
}
test('an invitation IAM answers without a code is refused, not shown as a dead link', async () => {
const out = signedIn()
const net = writes({ [`POST /v1/iam/invitations`]: [200, { status: 'ok' }] })
try {
await expect(createInvitation(ORG, '[email protected]')).rejects.toThrow('IAM made the invitation without a code')
} finally {
net.restore()
out()
}
})
test('an invite link is the one IAM emails: the application, the code and the org on hanzo.id/join', () => {
expect(inviteLink(ORG, 'K7M2P9Q4XW')).toBe(`https://hanzo.id/join?client_id=hanzo-app&invite=K7M2P9Q4XW&org=${ORG}`)
})
test('an email invitation is pinned to its address and redeems once; a link admits LINK_SEATS', async () => {
const out = signedIn()
const minted = { owner: ORG, name: 'inv-x', code: 'BE3NQ8HRSNDLXYL4JFQN', generated: true, quota: 1, usedCount: 0, email: '[email protected]', state: 'Active' }
const net = writes({ [`POST /v1/iam/invitations`]: [200, minted] })
try {
const one = await createInvitation(ORG, '[email protected]')
await createInvitation(ORG, undefined, LINK_SEATS)
expect(net.bodies[0]).toMatchObject({ owner: ORG, email: '[email protected]', quota: 1, usedCount: 0, state: 'Active' })
expect(net.bodies[1]).toMatchObject({ owner: ORG, email: '', quota: 25, usedCount: 0, state: 'Active', displayName: 'Invite link' })
// IAM mints the code: none is sent, and the one it answers is the one used.
for (const body of net.bodies) expect(body).not.toHaveProperty('code')
expect(net.bodies[0].name).toMatch(/^inv-[a-z2-9]{10}$/)
expect(one.code).toBe('BE3NQ8HRSNDLXYL4JFQN')
} finally {
net.restore()
out()
}
})
test('sending asks IAM to email the invitation through hanzo.ai’s application, and reports where it went', async () => {
const out = signedIn()
const net = writes({
[`POST /v1/iam/invitations/${ORG}/inv-k7m2p9q4xw/send`]: [200, { sent: true, to: '[email protected]' }],
})
try {
expect(await sendInvitation({ owner: ORG, name: 'inv-k7m2p9q4xw' })).toBe('[email protected]')
expect(net.asked).toEqual([`POST /v1/iam/invitations/${ORG}/inv-k7m2p9q4xw/send`])
expect(net.bodies).toEqual([{}])
} finally {
net.restore()
out()
}
})
test('a send IAM does not serve, or refuses, is a refusal with a reason — never a success', async () => {
const out = signedIn()
const net = writes({
[`POST /v1/iam/invitations/${ORG}/inv-used/send`]: [400, { status: 400, error: 'the invitation has no seat left' }],
[`POST /v1/iam/invitations/${ORG}/inv-soon/send`]: [429, { status: 429, error: 'this invitation was sent less than a minute ago' }],
[`POST /v1/iam/invitations/${ORG}/inv-dark/send`]: [503, { status: 503, error: 'no email delivery is configured here' }],
[`POST /v1/iam/invitations/${ORG}/inv-gone/send`]: [404, { status: 404, error: 'no such invitation' }],
})
try {
await expect(sendInvitation({ owner: ORG, name: 'inv-missing' })).rejects.toThrow('Invitation emails are not switched on yet')
await expect(sendInvitation({ owner: ORG, name: 'inv-used' })).rejects.toThrow('the invitation has no seat left')
await expect(sendInvitation({ owner: ORG, name: 'inv-soon' })).rejects.toThrow('this invitation was sent less than a minute ago')
await expect(sendInvitation({ owner: ORG, name: 'inv-dark' })).rejects.toThrow('no email delivery is configured here')
await expect(sendInvitation({ owner: ORG, name: 'inv-gone' })).rejects.toThrow('no such invitation')
} finally {
net.restore()
out()
}
})
test('the shared link is the open invitation with seats left, never a pinned one', () => {
const row = (over: Partial<Invitation>): Invitation => ({
owner: ORG, name: 'inv-x', displayName: '', code: 'X', quota: LINK_SEATS, usedCount: 0, state: 'Active', ...over,
})
const link = row({ name: 'inv-link' })
expect(shareable([row({ name: 'inv-mail', email: '[email protected]', quota: 1 }), link])).toBe(link)
expect(shareable([row({ usedCount: LINK_SEATS })])).toBeUndefined()
expect(shareable([row({ state: 'Suspended' })])).toBeUndefined()
expect(shareable([row({ quota: 1 })]), 'a one-use open invitation is a pending invite, not the link').toBeUndefined()
})