Files

427 lines
18 KiB
TypeScript

// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Wall three (spec §3.3): the generated suite calls every tenant-scoped procedure of this app's
// registry as a member of organization A with organization B's slugs and ids, and fails any
// procedure that answers with B's data or writes anything at all.
//
// It runs on the in-memory adapter, which is the repository predicate alone: that is wall one.
// Wall two, what Postgres refuses on its own, is proven against a real Postgres by
// @conusai-kit/data-kysely's own gate, and wall three is proven here for every procedure this
// app registers -- including the ones a module adds tomorrow, because the list comes from the
// registry and a procedure without a fixture fails the suite by name.
import { existsSync, readdirSync } from "node:fs"
import { join } from "node:path"
import { type Actor, buildRegistry, Container, createCaller, type TransportCaller } from "@conusai-kit/core"
import {
createMemoryDataSource,
DATA_SOURCE,
entitiesOf,
JOB_QUEUE,
OBJECT_STORE,
SYSTEM_DATA,
} from "@conusai-kit/data"
import { GUIDES_PLATFORM, GUIDES_REGISTRY } from "@conusai-kit/guides" // kit:guides
import { guidesIsolationExempt, guidesIsolationFixtures, recordingGuidesPlatform } from "@conusai-kit/guides/testing" // kit:guides
import { PROPOSALS_REGISTRY } from "@conusai-kit/proposals"
import { SUPPORT_PLATFORM } from "@conusai-kit/support" // kit:support
// kit:support:begin
import {
recordingSupportPlatform,
supportIsolationExempt,
supportIsolationFixtures,
} from "@conusai-kit/support/testing"
// kit:support:end
import { createTenancyHooks, TENANCY, users } from "@conusai-kit/tenancy"
import {
describeIsolation,
fakeJobQueue,
fakeObjectStore,
type IsolationFixture,
type IsolationTenant,
runIsolation,
snapshotOf,
uploadViaProcedures,
} from "@conusai-kit/testing"
import { describe, expect, it } from "vitest"
import { accountIsolationExempt } from "../modules/account/__tests__/exempt.js"
import { appModules } from "../modules/index.js"
import { KNOWLEDGE_ENABLEMENT, KNOWLEDGE_SEARCH } from "../modules/knowledge/index.js"
import type { ModuleIsolation } from "../modules/notes/__tests__/isolation.js"
const registry = buildRegistry([...appModules])
/**
* The fixtures and the seed rows a module brings in `modules/<id>/__tests__/isolation.ts`, found by
* their place: a module added with `pnpm new:module` is covered without a line here, and a
* procedure it registers with no fixture fails the suite by name. (The older modules' fixtures are
* listed below; they move into their own folders as each is next touched.)
*/
const modulesDir = join(import.meta.dirname, "..", "modules")
const moduleIsolations: ModuleIsolation[] = await Promise.all(
readdirSync(modulesDir)
.filter((id) => existsSync(join(modulesDir, id, "__tests__", "isolation.ts")))
.map(async (id) => {
const loaded = (await import(/* @vite-ignore */ join(modulesDir, id, "__tests__", "isolation.ts"))) as {
isolation: ModuleIsolation
}
return loaded.isolation
}),
)
const entities = entitiesOf(registry, "postgres")
/** The guides module's platform, recording: a refused cross-tenant call must never reach it. */ // kit:guides
const guidesPlatform = recordingGuidesPlatform() // kit:guides
/** The support module's platform, recording: a refused cross-tenant mint must never reach it. */ // kit:support
const supportPlatform = recordingSupportPlatform() // kit:support
/** The app wired the way apps/web wires it, with the in-memory adapter in place of Postgres. */
function makeApp() {
const data = createMemoryDataSource({ entities })
const root = new Container()
root.value(DATA_SOURCE, data)
root.value(SYSTEM_DATA, data)
// The files module's store: a seeded upload needs one, and a refused cross-tenant call never reaches it.
root.value(OBJECT_STORE, fakeObjectStore())
// A module whose writes enqueue work (a job started by a procedure) needs a queue to be driven at all.
root.value(JOB_QUEUE, fakeJobQueue())
root.value(PROPOSALS_REGISTRY, registry)
root.value(GUIDES_PLATFORM, guidesPlatform) // kit:guides
root.value(GUIDES_REGISTRY, registry) // kit:guides
root.value(SUPPORT_PLATFORM, supportPlatform) // kit:support
// The knowledge module's ports, faked: the search answers nothing (the suite asserts a
// cross-tenant call is refused or answered empty), and the enablement checks pass.
root.value(KNOWLEDGE_SEARCH, {
readiness: async () => ({ state: "ready" as const, reasons: [] }),
index: async () => undefined,
search: async () => [],
remove: async () => undefined,
})
root.value(KNOWLEDGE_ENABLEMENT, {
checkEligibility: async () => ({ ok: true as const }),
indexingQuota: async () => Number.MAX_SAFE_INTEGER,
linkManagedQdrant: async () => ({ serviceId: "fake", url: null }),
probeQdrant: async () => ({ ok: true as const }),
operatorQdrant: () => null,
})
const caller = (actor: Actor): TransportCaller => {
const tenancy = createTenancyHooks({ data, registry })
const container = root.child()
container.value(TENANCY, tenancy)
return createCaller({ registry, container, tenancy }, { actor })
}
return { data, caller }
}
const app = makeApp()
async function signUp(email: string): Promise<Actor> {
const user = await app.data.global(users).insert({
issuer: "https://id.example.test",
subject: email,
email,
displayName: email.split("@")[0] ?? email,
locale: "en",
})
return { userId: user.id }
}
async function tenant(slug: string): Promise<IsolationTenant> {
const owner = await signUp(`owner@${slug}.test`)
const call = app.caller(owner)
const org = (await call.callByName("tenancy.org.create", { slug, name: slug })) as {
orgId: string
slug: string
workspaceSlug: string
}
const workspace = (await call.callByName("tenancy.workspace.create", {
org: slug,
slug: `ws-${slug}`,
name: `Workspace ${slug}`,
visibility: "org",
})) as { workspaceId: string; slug: string }
const project = (await call.callByName("tenancy.project.create", {
org: slug,
workspace: workspace.slug,
slug: `p-${slug}`,
name: `Project ${slug}`,
moduleKey: "tenancy",
})) as { projectId: string; slug: string }
const invite = (await call.callByName("tenancy.member.invite", {
org: slug,
email: `invited-${slug}@example.test`,
role: "member",
})) as { invitationId: string; token: string }
const key = (await call.callByName("auth.apiKey.create", { org: slug, name: "ci" })) as { key: { id: string } }
// One upload and one proposal in the project, so a fixture names rows that exist in the other
// tenant: the call has to be refused for being another tenant's, not for naming nothing.
const inProject = { org: slug, workspace: workspace.slug, project: project.slug }
const file = await uploadViaProcedures(call, inProject, {
filename: "seed.pdf",
contentType: "application/pdf",
bytes: new TextEncoder().encode("%PDF-1.4"),
})
const proposal = (await call.callByName(
"proposals.proposal.create",
{
...inProject,
targetKey: "documents.line",
change: {
lineKey: "a".repeat(64),
label: "Reference",
value: "QR-1",
page: 1,
position: 0,
quote: "Reference: QR-1",
confidence: 0.9,
verification: "verified",
edited: false,
},
evidence: [{ fileId: file.fileId, page: 1, quote: "Reference: QR-1" }],
model: "seed",
promptVersion: "seed.v1",
},
{ idempotencyKey: `seed.${slug}` },
)) as { proposalId: string }
// What each module that brings fixtures seeds in this tenant: its ids are what the other tenant's fixtures name.
const seeded: Record<string, string> = {}
for (const isolation of moduleIsolations) Object.assign(seeded, await isolation.seed(call, inProject))
return {
orgSlug: org.slug,
workspaceSlug: workspace.slug,
projectSlug: project.slug,
orgId: org.orgId,
workspaceId: workspace.workspaceId,
projectId: project.projectId,
userId: owner.userId,
email: `owner@${slug}.test`,
extra: {
invitationId: invite.invitationId,
token: invite.token,
apiKeyId: key.key.id,
fileId: file.fileId,
proposalId: proposal.proposalId,
...seeded,
},
}
}
let seeded: Promise<{ a: IsolationTenant; b: IsolationTenant }> | undefined
const tenants = () => {
seeded ??= (async () => ({ a: await tenant("alpha"), b: await tenant("bravo") }))()
return seeded
}
/** The three slugs a project-scoped procedure's input carries, from the tenant it must not reach. */
const inProjectOf = (t: IsolationTenant) => ({ org: t.orgSlug, workspace: t.workspaceSlug, project: t.projectSlug })
/** The other tenant's seeded rows; the ghost tenant has none, so its ids are well-formed and absent. */
const NOWHERE = "00000000-0000-0000-0000-000000000000"
const fileOf = (t: IsolationTenant) => t.extra.fileId ?? NOWHERE
const proposalOf = (t: IsolationTenant) => t.extra.proposalId ?? NOWHERE
/** One fixture per tenant-scoped procedure, built from the *other* tenant's ids. */
const fixtures: Record<string, IsolationFixture> = {
...Object.assign({}, ...moduleIsolations.map((isolation) => isolation.fixtures)),
...guidesIsolationFixtures, // kit:guides
...supportIsolationFixtures, // kit:support
"tenancy.org.get": { input: (t) => ({ org: t.orgSlug }) },
"tenancy.org.update": {
input: (t) => ({ org: t.orgSlug, name: "Updated Name", expectedVersion: 1 }),
},
"tenancy.invitation.list": { input: (t) => ({ org: t.orgSlug }) },
"tenancy.member.list": { input: (t) => ({ org: t.orgSlug }) },
"tenancy.member.invite": { input: (t) => ({ org: t.orgSlug, email: "intruder@example.test", role: "member" }) },
"tenancy.member.change-role": { input: (t) => ({ org: t.orgSlug, userId: t.userId, role: "viewer" }) },
"tenancy.member.remove": { input: (t) => ({ org: t.orgSlug, userId: t.userId }) },
"tenancy.member.leave": { input: (t) => ({ org: t.orgSlug }) },
"tenancy.workspace.list": { input: (t) => ({ org: t.orgSlug }) },
"tenancy.workspace.create": {
input: (t) => ({ org: t.orgSlug, slug: "intruder", name: "Intruder", visibility: "org" }),
},
"tenancy.workspace.set-member": {
input: (t) => ({ org: t.orgSlug, workspace: t.workspaceSlug, userId: t.userId, role: "admin" }),
},
"tenancy.project.list": { input: (t) => ({ org: t.orgSlug, workspace: t.workspaceSlug }) },
"tenancy.project.get": { input: (t) => ({ org: t.orgSlug, workspace: t.workspaceSlug, project: t.projectSlug }) },
"tenancy.project.create": {
input: (t) => ({
org: t.orgSlug,
workspace: t.workspaceSlug,
slug: "intruder",
name: "Intruder",
moduleKey: "tenancy",
}),
},
"auth.apiKey.create": { input: (t) => ({ org: t.orgSlug, name: "intruder" }) },
"auth.apiKey.list": { input: (t) => ({ org: t.orgSlug }) },
"auth.apiKey.revoke": {
input: (t) => ({ org: t.orgSlug, id: t.extra.apiKeyId ?? "00000000-0000-0000-0000-000000000000" }),
},
"files.file.beginUpload": {
input: (t) => ({ ...inProjectOf(t), filename: "intruder.pdf", contentType: "application/pdf", size: 8 }),
},
"files.file.putPart": {
input: (t) => ({ ...inProjectOf(t), fileId: fileOf(t), part: 1, body: new TextEncoder().encode("%PDF-1.4") }),
},
"files.file.completeUpload": {
input: (t) => ({ ...inProjectOf(t), fileId: fileOf(t), parts: [{ number: 1, etag: "etag-intruder" }] }),
},
"files.file.get": { input: (t) => ({ ...inProjectOf(t), fileId: fileOf(t) }) },
"files.file.download": { input: (t) => ({ ...inProjectOf(t), fileId: fileOf(t) }) },
"files.file.list": { input: (t) => inProjectOf(t) },
"files.file.delete": { input: (t) => ({ ...inProjectOf(t), fileId: fileOf(t) }) },
"proposals.proposal.create": {
input: (t) => ({
...inProjectOf(t),
targetKey: "documents.line",
change: {
lineKey: "b".repeat(64),
label: "Intruder",
value: "x",
page: 1,
position: 0,
quote: "Intruder: x",
confidence: 0.5,
edited: false,
},
// Nothing of the other tenant's: `create` reads no id it is given, so the only cross-tenant
// case it has is the slugs, which the resolver refuses. An id of B's here would be echoed
// back in the answer, which is the caller's own input and not B's data.
evidence: [{ fileId: NOWHERE, page: 1 }],
model: "intruder",
promptVersion: "intruder.v1",
}),
},
"proposals.proposal.edit": {
input: (t) => ({
...inProjectOf(t),
proposalId: proposalOf(t),
change: {
lineKey: "c".repeat(64),
label: "Intruder",
value: "x",
page: 1,
position: 0,
quote: "Intruder: x",
confidence: 0.5,
edited: true,
},
}),
},
"proposals.proposal.get": { input: (t) => ({ ...inProjectOf(t), proposalId: proposalOf(t) }) },
"proposals.proposal.list": { input: (t) => inProjectOf(t) },
"proposals.proposal.approve": {
input: (t) => ({ ...inProjectOf(t), proposalId: proposalOf(t), expectedVersion: 1 }),
},
"proposals.proposal.approveMany": {
input: (t) => {
const proposalId = proposalOf(t)
return { ...inProjectOf(t), proposalIds: [proposalId], expectations: { [proposalId]: { expectedVersion: 1 } } }
},
},
"proposals.proposal.reject": {
input: (t) => ({ ...inProjectOf(t), proposalId: proposalOf(t), reason: "intruder" }),
},
"documents.document.list": { input: (t) => inProjectOf(t) },
"documents.document.get": { input: (t) => ({ ...inProjectOf(t), fileId: fileOf(t) }) },
"documents.line.edit": {
input: (t) => ({ ...inProjectOf(t), lineId: proposalOf(t), label: "Intruder", value: "x" }),
},
"documents.export.create": { input: (t) => ({ ...inProjectOf(t), fileId: fileOf(t) }) },
"branding.brand.get": { input: (t) => ({ org: t.orgSlug }) },
"branding.brand.set": { input: (t) => ({ org: t.orgSlug, primary: "#000000", displayName: "Intruder" }) },
"branding.logo.set": {
input: (t) => ({ org: t.orgSlug, variant: "light", contentType: "image/png", body: "iVBORw0KGgoAAAAA" }),
},
"branding.logo.clear": { input: (t) => ({ org: t.orgSlug, variant: "light" }) },
"branding.logo.get": { input: (t) => ({ org: t.orgSlug, variant: "light" }) },
// User-scoped, so the kernel has no scope to resolve -- and it still names an organization,
// which is exactly where a hole hides: `alsoCheck` puts it through the same test with the
// other tenant's own open token.
"tenancy.invitation.accept": { input: (t) => ({ org: t.orgSlug, token: t.extra.token ?? "z".repeat(43) }) },
}
const alsoCheck = ["tenancy.invitation.accept"]
const exempt = {
"tenancy.me.get": "it takes no organization and answers only about the signed-in user",
"tenancy.me.update": "it takes no organization and no id: the only row it can write is the signed-in user's own",
"tenancy.org.create": "a unique slug necessarily answers 409 for a taken one; it writes in no tenant",
"auth.session.current": "it answers only about the signed-in user's own row",
...accountIsolationExempt,
...guidesIsolationExempt, // kit:guides
...supportIsolationExempt, // kit:support
}
const options = async () => {
const { a, b } = await tenants()
return {
registry,
seed: async () => ({ a, b }),
caller: (actor: Actor) => app.caller(actor),
snapshot: () => snapshotOf(app.data, entities, { outbox: app.data.outbox }),
fixtures,
alsoCheck,
exempt,
}
}
describeIsolation(
{
registry,
seed: async () => tenants(),
caller: (actor) => ({
call: async () => undefined as never,
callByName: async (name, input) => app.caller(actor).callByName(name, input),
}),
snapshot: async () => snapshotOf(app.data, entities, { outbox: app.data.outbox }),
fixtures,
alsoCheck,
exempt,
},
{ describe, it },
)
describe("the generated isolation suite", () => {
it("covers every procedure this app registers", async () => {
const result = await runIsolation(await options())
expect(result.failures).toEqual([])
expect(result.passed.length).toBe(registry.isolationFixtures.length + alsoCheck.length)
})
it("covers every procedure of the notes and files modules, by name", async () => {
const result = await runIsolation(await options())
const covered = new Set(result.passed)
const names = [...registry.procedures.keys()].filter((name) => /^(notes|files)\./.test(name))
expect(names.length).toBe(14)
for (const name of names) expect(covered.has(name), name).toBe(true)
})
it("fails, naming it, when a module's own fixture is missing", async () => {
const base = await options()
const { "notes.attachment.remove": _removed, ...rest } = fixtures
const result = await runIsolation({ ...base, fixtures: rest })
expect(result.failures.map((failure) => failure.procedure)).toEqual(["notes.attachment.remove"])
})
// kit:guides:begin
it("reaches the guides platform with no cross-tenant call", async () => {
await runIsolation(await options())
expect(guidesPlatform.calls).toEqual([])
})
// kit:guides:end
// kit:support:begin
it("reaches the support platform with no cross-tenant call", async () => {
await runIsolation(await options())
expect(supportPlatform.calls).toEqual([])
})
// kit:support:end
it("fails, naming it, when a procedure has no fixture", async () => {
const base = await options()
const { "tenancy.member.list": _removed, ...rest } = fixtures
const result = await runIsolation({ ...base, fixtures: rest })
expect(result.failures.map((failure) => failure.procedure)).toEqual(["tenancy.member.list"])
})
})