Files
template-app/test/live.test.ts
T
2026-11-14 09:56:45 +00:00

140 lines
6.2 KiB
TypeScript

// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Who may open which live stream (@conusai-kit/host/postgres `createHostLive`, umbrella TS6.2), decided with the
// kernel's own tenancy over this app's registry: a member gets the collection for a topic they
// resolve, with its ids; a signed-out request is 401; another organization's slug and a workspace
// the caller does not resolve are 404; an entity that is not a collection is denied, and so is a
// project collection asked for the whole organization; a member removed meanwhile is revoked at the
// next re-check, and one whose role lost the read permission of a granted collection is told
// `grants_changed`. The stream itself is proven by tests/live-conformance and e2e/notes-live.spec.ts.
import type { Principal } from "@conusai-kit/auth"
import { createHostLive, createLiveGrants } from "@conusai-kit/host/postgres"
import { createTenancyHooks, orgMemberships, TENANCY } from "@conusai-kit/tenancy"
import { describe, expect, it } from "vitest"
import { makeApp, type Person } from "../modules/account/__tests__/harness.js"
const app = makeApp()
let principal: Principal | null = null
const quiet = {
log: { info: () => undefined, warn: () => undefined, error: () => undefined },
captureException: () => undefined,
span: <T>(_name: string, _attributes: unknown, fn: () => T): T => fn(),
}
const { authorize } = createHostLive({
runtime: () => ({ registry: app.registry, observe: quiet }),
requestContext: async () => {
const container = app.root.child()
container.value(TENANCY, createTenancyHooks({ data: app.data, registry: app.registry }))
return { principal, container }
},
log: () => undefined,
grants: createLiveGrants(),
})
const as = (person: Person | null) => {
principal = person ? ({ actor: person.actor, method: "bearer" } as Principal) : null
}
const open = (org: string, body: { entities: string[]; topics: Array<{ ws: string; project?: string }> }) =>
authorize(new Request(`http://app.test/api/v1/orgs/${org}/live`, { method: "POST" }), {
protocol: "clp/1",
after: null,
...body,
})
async function orgWithProject(person: Person, slug: string) {
const call = person.call()
const created = (await call.callByName("tenancy.org.create", { slug, name: slug })) as { orgId: string }
await call.callByName("tenancy.workspace.create", { org: slug, slug: "main", name: "Main", visibility: "org" })
await call.callByName("tenancy.project.create", {
org: slug,
workspace: "main",
slug: "notes",
name: "Notes",
moduleKey: "notes",
})
return created.orgId
}
describe("the live stream's authorization", async () => {
const ada = await app.signUp("ada-live@example.test")
const grace = await app.signUp("grace-live@example.test")
const acme = await orgWithProject(ada, "acme-live")
await orgWithProject(grace, "globex-live")
const topic = { ws: "main", project: "notes" }
it("grants a member the collection for a topic they resolve, with its ids, and denies the rest", async () => {
as(ada)
const grant = await open("acme-live", { entities: ["notes.note", "notes.secret"], topics: [topic] })
expect(grant.org).toBe(acme)
expect(grant.granted.entities).toEqual(["notes.note"])
expect(grant.granted.topics[0]?.ids.org).toBe(acme)
expect(grant.granted.topics[0]?.ids.ws).toBeTruthy()
expect(grant.granted.topics[0]?.ids.project).toBeTruthy()
expect(grant.denied).toEqual([{ entity: "notes.secret", reason: "unknown" }])
expect(await grant.recheck()).toBeNull()
})
it("denies a project collection for the whole organization", async () => {
as(ada)
const grant = await open("acme-live", { entities: ["notes.note"], topics: [] })
expect(grant.granted.entities).toEqual([])
expect(grant.denied).toEqual([{ entity: "notes.note", reason: "scope" }])
})
it("refuses a signed-out request, another organization's slug and a workspace the caller does not resolve", async () => {
as(null)
await expect(open("acme-live", { entities: ["notes.note"], topics: [topic] })).rejects.toMatchObject({
status: 401,
})
as(grace)
await expect(open("acme-live", { entities: ["notes.note"], topics: [topic] })).rejects.toMatchObject({
status: 404,
})
as(ada)
await expect(open("acme-live", { entities: ["notes.note"], topics: [{ ws: "nowhere" }] })).rejects.toMatchObject({
status: 404,
})
await expect(open("globex-live", { entities: ["notes.note"], topics: [topic] })).rejects.toMatchObject({
status: 404,
})
})
it("ends a stream with grants_changed once the member's role no longer reads a granted collection", async () => {
const linus = await app.signUp("linus-live@example.test")
const invitation = (await ada.call().callByName("tenancy.member.invite", {
org: "acme-live",
email: linus.email,
role: "member",
})) as { token: string }
await linus.call().callByName("tenancy.invitation.accept", { org: "acme-live", token: invitation.token })
as(linus)
const grant = await open("acme-live", { entities: ["notes.note"], topics: [topic] })
expect(grant.granted.entities).toEqual(["notes.note"])
expect(await grant.recheck()).toBeNull()
// A custom organization role that reads the organization and nothing of the notes module.
const role = (await ada.call().callByName("tenancy.role.create", {
org: "acme-live",
name: "Directory only",
level: "org",
permissions: ["tenancy.org.read", "tenancy.workspace.read", "tenancy.project.read"],
})) as { key: string }
await ada.call().callByName("tenancy.member.assign", {
org: "acme-live",
userId: linus.actor.userId,
level: "org",
roleKey: role.key,
})
expect(await grant.recheck()).toBe("grants_changed")
})
it("revokes a member who was removed, at the next re-check", async () => {
as(ada)
const grant = await open("acme-live", { entities: ["notes.note"], topics: [topic] })
const scope = { level: "org" as const, orgId: acme, actor: ada.actor }
const rows = (await app.data.forScope(scope).of(orgMemberships).list({})).items
for (const row of rows) await app.data.forScope(scope).of(orgMemberships).delete(row.id)
expect(await grant.recheck()).toBe("membership")
})
})