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

255 lines
11 KiB
TypeScript

// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The web process's tenancy rules around a request's caller (apps/web/src/server/tenancy-calls.ts,
// ADR-062), over the app's real registry and kit-tenancy's procedures:
//
// - an invitation, once committed, admits its address through @conusai-kit/auth's real platform client
// against the fake admission endpoint of @conusai-kit/testing, audited with ids only
// - with no admission credential the invitation still answers, and the skip is logged with its reason
// - a refused or unreachable admission is logged and audited as a failure, never thrown to the inviter
// - an invitation that is refused (a role above the inviter's) asks for no admission at all
// - with self-service off, only the app-access role `admin` founds an organization; a token without the
// claim is not `admin`
// - on the Convex profile a grant-changing write is announced to the organization's streams here
import { createPlatformIdentityAdmission } from "@conusai-kit/auth"
import { Container } from "@conusai-kit/core"
import { GRANTS_CHANGED } from "@conusai-kit/tenancy"
import { fakeIdentityAdmission } from "@conusai-kit/testing"
import { describe, expect, it, vi } from "vitest"
import { type TenancyCallOptions, withTenancyCalls } from "../apps/web/src/server/tenancy-calls.js"
import { identityLinkOf, organizationSelfServiceOf } from "../config/src/access.js"
import { makeApp, type Person } from "../modules/account/__tests__/harness.js"
const app = makeApp()
type Audited = Parameters<TenancyCallOptions["audit"]>[1]
function wrapped(person: Person, overrides: Partial<TenancyCallOptions> = {}) {
const audited: Audited[] = []
const log = { info: vi.fn(), warn: vi.fn() }
const caller = withTenancyCalls(person.call(), {
principal: { actor: person.actor },
container: new Container(),
selfServiceOrganizations: true,
admission: () => ({ skipped: "no OIDC_ADMISSION_TOKEN" }),
audit: async (_action, event) => {
audited.push(event)
},
log,
...overrides,
})
return { caller, audited, log }
}
describe("an invitation's admission (ADR-062 decision 4)", async () => {
const ada = await app.signUp("ada-admission@example.test")
await ada.call().callByName("tenancy.org.create", { slug: "acme-admission", name: "Acme" })
it("admits the invited address once the invitation committed, and audits ids only", async () => {
const platform = fakeIdentityAdmission()
const admission = createPlatformIdentityAdmission({
apiUrl: platform.apiUrl,
linkId: platform.linkId,
token: platform.token,
fetch: platform.fetch,
})
const { caller, audited, log } = wrapped(ada, { admission: () => admission })
const invitation = (await caller.callByName("tenancy.member.invite", {
org: "acme-admission",
email: " New.Person@Example.test ",
role: "member",
})) as { invitationId: string; token: string }
expect(invitation.token.length).toBeGreaterThan(16)
expect(platform.admissions).toEqual([
expect.objectContaining({ email: "new.person@example.test", invitationId: invitation.invitationId }),
])
expect(platform.calls[0]).toMatchObject({ method: "POST", credential: "valid", status: 202 })
expect(audited).toEqual([{ orgSlug: "acme-admission", invitationId: invitation.invitationId, outcome: "success" }])
// Neither the log nor the audit event carries the address.
expect(JSON.stringify([log.info.mock.calls, log.warn.mock.calls, audited])).not.toContain("example.test")
})
it("answers the invitation without an admission credential, and logs why it skipped", async () => {
const { caller, audited, log } = wrapped(ada)
const invitation = (await caller.callByName("tenancy.member.invite", {
org: "acme-admission",
email: "skipped@example.test",
role: "viewer",
})) as { invitationId: string }
expect(invitation.invitationId).toBeTruthy()
expect(audited).toEqual([])
expect(log.info).toHaveBeenCalledWith("invitation: admission skipped: no OIDC_ADMISSION_TOKEN", {
"conusai.invitation_id": invitation.invitationId,
})
})
it("answers the invitation when the admission client cannot be built", async () => {
const { caller, log } = wrapped(ada, {
admission: () => {
throw new Error("set OIDC_AUDIENCE")
},
})
const invitation = (await caller.callByName("tenancy.member.invite", {
org: "acme-admission",
email: "unbuilt@example.test",
role: "viewer",
})) as { invitationId: string }
expect(log.info).toHaveBeenCalledWith(
"invitation: admission skipped: the admission client could not be built (Error)",
{
"conusai.invitation_id": invitation.invitationId,
},
)
})
it.each([
[429, "rate_limited"],
[401, "unauthorized"],
[503, "unavailable"],
["network", "unavailable"],
] as const)(
"keeps the invitation when the platform answers %s, logged and audited as %s",
async (refusal, reason) => {
const platform = fakeIdentityAdmission()
platform.refuseNext(refusal)
const admission = createPlatformIdentityAdmission({
apiUrl: platform.apiUrl,
linkId: platform.linkId,
token: platform.token,
fetch: platform.fetch,
})
const { caller, audited, log } = wrapped(ada, { admission: () => admission })
const email = `refused-${String(refusal)}@example.test`
const invitation = (await caller.callByName("tenancy.member.invite", {
org: "acme-admission",
email,
role: "viewer",
})) as { invitationId: string }
expect(invitation.invitationId).toBeTruthy()
expect(platform.admissions).toEqual([])
expect(log.warn).toHaveBeenCalledWith(`invitation: admission failed: ${reason}`, {
"conusai.invitation_id": invitation.invitationId,
})
expect(audited).toEqual([
{ orgSlug: "acme-admission", invitationId: invitation.invitationId, outcome: "failure", reason },
])
},
)
it("asks for no admission when the invitation itself is refused", async () => {
const grace = await app.signUp("grace-admission@example.test")
const joined = (await ada.call().callByName("tenancy.member.invite", {
org: "acme-admission",
email: grace.email,
role: "admin",
})) as { token: string }
await grace.call().callByName("tenancy.invitation.accept", { org: "acme-admission", token: joined.token })
const platform = fakeIdentityAdmission()
const admission = createPlatformIdentityAdmission({
apiUrl: platform.apiUrl,
linkId: platform.linkId,
token: platform.token,
fetch: platform.fetch,
})
const { caller, audited } = wrapped(grace, { admission: () => admission })
// An administrator cannot invite an owner (kit-tenancy's ceiling): nothing committed, nothing admitted.
await expect(
caller.callByName("tenancy.member.invite", { org: "acme-admission", email: "owner@example.test", role: "owner" }),
).rejects.toMatchObject({ status: 403 })
expect(platform.calls).toEqual([])
expect(audited).toEqual([])
})
})
describe("founding an organization when self-service is off (ADR-062 decision 5)", async () => {
const linus = await app.signUp("linus-founder@example.test")
it("refuses a person without the app-access role, and one whose token carries no claim", async () => {
for (const principal of [{ actor: linus.actor }, { actor: linus.actor, appAccess: { role: "user" as const } }]) {
const { caller } = wrapped(linus, { selfServiceOrganizations: false, principal })
await expect(
caller.callByName("tenancy.org.create", { slug: "refused-founder", name: "Refused" }),
).rejects.toMatchObject({ status: 403, code: "forbidden" })
}
// Nothing was written: the slug is still free for the operator below.
expect(
((await linus.call().callByName("tenancy.me.get", undefined)) as { organizations: unknown[] }).organizations,
).toEqual([])
})
it("lets the app-access role admin found one, and anyone when self-service is on", async () => {
const admin = wrapped(linus, {
selfServiceOrganizations: false,
principal: { actor: linus.actor, appAccess: { role: "admin" } },
})
await expect(
admin.caller.callByName("tenancy.org.create", { slug: "refused-founder", name: "Ops" }),
).resolves.toMatchObject({
slug: "refused-founder",
})
const open = wrapped(linus, { selfServiceOrganizations: true })
await expect(
open.caller.callByName("tenancy.org.create", { slug: "self-founded", name: "Self" }),
).resolves.toMatchObject({
slug: "self-founded",
})
})
})
describe("a grant change made in the Convex deployment", async () => {
const ada = await app.signUp("ada-remote-grants@example.test")
await ada.call().callByName("tenancy.org.create", { slug: "acme-remote", name: "Acme" })
const grace = await app.signUp("grace-remote-grants@example.test")
const invitation = (await ada.call().callByName("tenancy.member.invite", {
org: "acme-remote",
email: grace.email,
role: "member",
})) as { token: string }
await grace.call().callByName("tenancy.invitation.accept", { org: "acme-remote", token: invitation.token })
it("is announced to this process's streams of the organization, and only of a streamed one", async () => {
const container = new Container()
const announced: string[] = []
container.value(GRANTS_CHANGED, { grantsChanged: async (orgId) => void announced.push(orgId) })
const orgIdOf = (slug: string) => (slug === "acme-remote" ? "org-id-of-acme" : undefined)
const { caller } = wrapped(ada, { container, remoteGrants: { orgIdOf } })
await caller.callByName("tenancy.member.change-role", {
org: "acme-remote",
userId: grace.actor.userId,
role: "viewer",
})
expect(announced).toEqual(["org-id-of-acme"])
// A read is no grant change.
await caller.callByName("tenancy.member.list", { org: "acme-remote" })
expect(announced).toEqual(["org-id-of-acme"])
})
})
describe("the access settings of the deployment", () => {
it("reads APP_ORGANIZATION_SELF_SERVICE as true by default and refuses anything but true or false", () => {
expect(organizationSelfServiceOf({})).toBe(true)
expect(organizationSelfServiceOf({ APP_ORGANIZATION_SELF_SERVICE: "" })).toBe(true)
expect(organizationSelfServiceOf({ APP_ORGANIZATION_SELF_SERVICE: "false" })).toBe(false)
expect(() => organizationSelfServiceOf({ APP_ORGANIZATION_SELF_SERVICE: "no" })).toThrow(
"set APP_ORGANIZATION_SELF_SERVICE to true or false",
)
})
it("takes the identity link with its admission credential, skips without both, and refuses one of the two", () => {
expect(identityLinkOf({})).toEqual({
skipped: "no OIDC_ADMISSION_TOKEN: the identity link issued no admission credential to this app",
})
expect(identityLinkOf({ OIDC_LINK_ID: "4f1c2a7e-6b1d", OIDC_ADMISSION_TOKEN: "cai_adm_x" })).toEqual({
linkId: "4f1c2a7e-6b1d",
})
for (const env of [{ OIDC_LINK_ID: "4f1c" }, { OIDC_ADMISSION_TOKEN: "cai_adm_secret" }]) {
expect(() => identityLinkOf(env)).toThrow("set OIDC_LINK_ID and OIDC_ADMISSION_TOKEN together")
}
// The credential is never quoted back.
expect(() => identityLinkOf({ OIDC_ADMISSION_TOKEN: "cai_adm_secret" })).not.toThrow(/cai_adm_secret/)
expect(() => identityLinkOf({ OIDC_LINK_ID: "../other", OIDC_ADMISSION_TOKEN: "t" })).toThrow(
"set OIDC_LINK_ID to the identity link's id",
)
})
})