mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +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.
262 lines
10 KiB
TypeScript
262 lines
10 KiB
TypeScript
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()
|
||
})
|