368 lines
16 KiB
TypeScript
368 lines
16 KiB
TypeScript
// The archive procedures end to end: the caller, the tenancy resolver, the module and the real generated
|
|
// client against the business-services fake -- what a member sees, what the service gets, and each
|
|
// refusal as the typed state a page shows.
|
|
|
|
import { describeBusinessRefusal } from "@conusai-kit/business/refusals"
|
|
import type { AppError } from "@conusai-kit/core"
|
|
import { entitiesOf } from "@conusai-kit/data"
|
|
import { TENANCY_ALSO_CHECK, tenancyIsolationExempt, tenancyIsolationFixtures } from "@conusai-kit/tenancy/testing"
|
|
import { type IsolationTenant, runIsolation, snapshotOf } from "@conusai-kit/testing"
|
|
import { describe, expect, it } from "vitest"
|
|
import { sdkArchiveService } from "../adapters/service.js"
|
|
import { requestKey } from "../core/archive.js"
|
|
import { archiveApp, type Tenant } from "./app.js"
|
|
import { archiveIsolationFixtures } from "./isolation.js"
|
|
|
|
const refusal = async (promise: Promise<unknown>): Promise<AppError> => {
|
|
try {
|
|
await promise
|
|
} catch (error) {
|
|
return error as AppError
|
|
}
|
|
throw new Error("the call was not refused")
|
|
}
|
|
|
|
type App = ReturnType<typeof archiveApp>
|
|
|
|
const retention = async (t: Tenant, code = "contracts", keepFor = "P10Y") =>
|
|
t.call.callByName("archive.retention.create", {
|
|
...t.slugs,
|
|
code,
|
|
keepFor,
|
|
from: "created",
|
|
requestId: crypto.randomUUID(),
|
|
})
|
|
|
|
const register = async (app: App, t: Tenant, title = "Signed contract", retentionClass = "contracts") =>
|
|
(await t.call.callByName("archive.document.register", {
|
|
...t.slugs,
|
|
title,
|
|
retentionClass,
|
|
fileId: await app.storedFile(t),
|
|
requestId: crypto.randomUUID(),
|
|
})) as { documentId: string; revision: string }
|
|
|
|
describe("the archive procedures", () => {
|
|
it("accepts incidental date-like digits in either UUID component without merging project keys", async () => {
|
|
const app = archiveApp()
|
|
const service = sdkArchiveService({ client: app.client, orgSlug: app.fake.orgSlug })
|
|
const incidental = "0195a3c4-e12a-7b3f-8a1b-39001010000f"
|
|
const ordinary = "0195a3c4-e12a-7b3f-8a1b-aaaaaaaaaaaa"
|
|
const first = { code: "first", keepFor: "P10Y", from: "created" as const }
|
|
const key = requestKey({ projectId: incidental }, ordinary)
|
|
expect(key).toBe(`app-project:${incidental}:${ordinary}`)
|
|
await service.createRetentionClass({ ...first, key })
|
|
await service.createRetentionClass({ ...first, key })
|
|
await service.createRetentionClass({
|
|
code: "second",
|
|
keepFor: "P10Y",
|
|
from: "created",
|
|
key: requestKey({ projectId: ordinary }, incidental),
|
|
})
|
|
await service.createRetentionClass({
|
|
code: "third",
|
|
keepFor: "P10Y",
|
|
from: "created",
|
|
key: requestKey({ projectId: ordinary }, ordinary),
|
|
})
|
|
expect(app.fake.archive.retentionClasses.size).toBe(3)
|
|
})
|
|
|
|
it("accepts a request UUID with incidental date-like digits and replays its receipt", async () => {
|
|
const app = archiveApp()
|
|
const t = await app.tenant("alpha")
|
|
const request = {
|
|
...t.slugs,
|
|
code: "contracts",
|
|
keepFor: "P10Y",
|
|
from: "created",
|
|
requestId: "0195a3c4-e12a-7b3f-8a1b-39001010000f",
|
|
}
|
|
expect(await t.call.callByName("archive.retention.create", request)).toEqual({ code: "contracts" })
|
|
expect(await t.call.callByName("archive.retention.create", request)).toEqual({ code: "contracts" })
|
|
expect(app.fake.archive.retentionClasses.size).toBe(1)
|
|
})
|
|
|
|
it("register a document once per request, list it, and read it with its versions", async () => {
|
|
const app = archiveApp()
|
|
const t = await app.tenant("alpha")
|
|
await retention(t)
|
|
const fileId = await app.storedFile(t)
|
|
const request = {
|
|
...t.slugs,
|
|
title: "Signed contract",
|
|
kind: "380",
|
|
retentionClass: "contracts",
|
|
fileId,
|
|
requestId: crypto.randomUUID(),
|
|
}
|
|
const receipt = (await t.call.callByName("archive.document.register", request)) as {
|
|
documentId: string
|
|
revision: string
|
|
}
|
|
expect(receipt.revision).toBe("1")
|
|
expect(await t.call.callByName("archive.document.register", request)).toEqual(receipt)
|
|
expect(app.fake.archive.documents.size).toBe(1)
|
|
const listed = await t.call.callByName("archive.document.list", t.slugs)
|
|
expect(listed).toMatchObject({ rows: [{ documentId: receipt.documentId, title: "Signed contract", kind: "380" }] })
|
|
|
|
const read = await t.call.callByName("archive.document.get", { ...t.slugs, documentId: receipt.documentId })
|
|
expect(read).toMatchObject({
|
|
document: { status: "active", retentionClass: "contracts", legalHold: false, revision: "1", disposed: false },
|
|
versions: [{ sequence: 1, sha256: "a".repeat(64), mediaType: "application/pdf", size: "1234" }],
|
|
})
|
|
expect(JSON.parse(JSON.stringify(read))).toEqual(read)
|
|
const [version] = [...app.fake.archive.versions.values()]
|
|
expect(version?.blob_ref).toBe(`conusai://blob/files/${fileId}`)
|
|
})
|
|
|
|
it("append a version over the revision it read, and refuse a stale one", async () => {
|
|
const app = archiveApp()
|
|
const t = await app.tenant("alpha")
|
|
await retention(t)
|
|
const { documentId } = await register(app, t)
|
|
const added = await t.call.callByName("archive.version.add", {
|
|
...t.slugs,
|
|
documentId,
|
|
revision: "1",
|
|
fileId: await app.storedFile(t, "b".repeat(64), 99),
|
|
requestId: crypto.randomUUID(),
|
|
})
|
|
expect(added).toEqual({ documentId, revision: "2" })
|
|
const stale = await refusal(
|
|
t.call.callByName("archive.version.add", {
|
|
...t.slugs,
|
|
documentId,
|
|
revision: "1",
|
|
fileId: await app.storedFile(t, "c".repeat(64), 7),
|
|
requestId: crypto.randomUUID(),
|
|
}),
|
|
)
|
|
expect(stale.problem).toMatchObject({ status: 409, code: "business_conflict" })
|
|
const read = (await t.call.callByName("archive.document.get", { ...t.slugs, documentId })) as {
|
|
versions: Array<{ sequence: number; size: string }>
|
|
}
|
|
expect(read.versions.map((version) => [version.sequence, version.size])).toEqual([
|
|
[1, "1234"],
|
|
[2, "99"],
|
|
])
|
|
})
|
|
|
|
it("keep retention: a class, a hold that blocks disposal, the due list, and disposal when it has ended", async () => {
|
|
let now = new Date("2026-11-02T10:00:00Z").getTime()
|
|
const app = archiveApp({ clock: () => new Date(now) })
|
|
const t = await app.tenant("alpha")
|
|
await retention(t, "short", "P1D")
|
|
expect(await t.call.callByName("archive.retention.list", t.slugs)).toMatchObject({
|
|
rows: [{ code: "short", keepFor: "P1D", from: "created" }],
|
|
})
|
|
const { documentId } = await register(app, t, "Delivery note", "short")
|
|
const request = () => ({ ...t.slugs, documentId, requestId: crypto.randomUUID() })
|
|
|
|
const early = await refusal(t.call.callByName("archive.document.dispose", { ...request(), revision: "1" }))
|
|
expect(early.problem).toMatchObject({ status: 409, code: "business_retention_active" })
|
|
|
|
expect(await t.call.callByName("archive.document.hold", { ...request(), revision: "1", legalHold: true })).toEqual({
|
|
documentId,
|
|
revision: "2",
|
|
})
|
|
now += 2 * 24 * 3600 * 1000
|
|
expect(await t.call.callByName("archive.disposal.list", t.slugs)).toEqual({ rows: [] })
|
|
const held = await refusal(t.call.callByName("archive.document.dispose", { ...request(), revision: "2" }))
|
|
expect(held.problem).toMatchObject({ status: 409, code: "business_legal_hold" })
|
|
// The person reads the kit's sentence for the refusal, in their language.
|
|
expect(describeBusinessRefusal(held, "lt")).toBe(
|
|
"Šiam įrašui taikomas teisinis sulaikymas, todėl jo keisti ar ištrinti negalima.",
|
|
)
|
|
|
|
await t.call.callByName("archive.document.hold", { ...request(), revision: "2", legalHold: false })
|
|
expect(await t.call.callByName("archive.disposal.list", t.slugs)).toMatchObject({ rows: [{ documentId }] })
|
|
await t.call.callByName("archive.document.dispose", { ...request(), revision: "3" })
|
|
const read = await t.call.callByName("archive.document.get", { ...t.slugs, documentId })
|
|
expect(read).toMatchObject({ document: { status: "disposed", disposed: true } })
|
|
})
|
|
|
|
it("show the service's refusals as the kit's codes", async () => {
|
|
const app = archiveApp()
|
|
const t = await app.tenant("alpha")
|
|
const missing = await refusal(t.call.callByName("archive.document.get", { ...t.slugs, documentId: "nobody" }))
|
|
expect(missing.problem).toMatchObject({ status: 404, code: "business_not_found" })
|
|
const noClass = await refusal(register(app, t, "Signed contract", "nothing"))
|
|
expect(noClass.problem).toMatchObject({ status: 404, code: "business_not_found" })
|
|
const bad = await refusal(
|
|
t.call.callByName("archive.retention.create", {
|
|
...t.slugs,
|
|
code: "bad",
|
|
keepFor: "ten years",
|
|
from: "created",
|
|
requestId: crypto.randomUUID(),
|
|
}),
|
|
)
|
|
expect(bad.problem).toMatchObject({ status: 400, code: "business_invalid" })
|
|
})
|
|
|
|
it("answer a disabled service, a missing scope and a silent service with the platform's own codes", async () => {
|
|
const app = archiveApp({ archive: false })
|
|
const t = await app.tenant("alpha")
|
|
const disabled = await refusal(t.call.callByName("archive.document.list", t.slugs))
|
|
expect(disabled.problem).toMatchObject({ status: 404, code: "service_not_enabled" })
|
|
app.fake.enable("archive")
|
|
app.fake.revoke("archive.retention:write")
|
|
const scoped = await refusal(retention(t))
|
|
expect(scoped.problem).toMatchObject({ status: 403, code: "service_permission_missing" })
|
|
expect(scoped.problem.detail).not.toContain("archive.retention:write")
|
|
app.fake.refuseNext(503)
|
|
const silent = await refusal(t.call.callByName("archive.document.list", t.slugs))
|
|
expect(silent.problem).toMatchObject({ status: 503, code: "business_unavailable" })
|
|
})
|
|
|
|
it("let a viewer read and refuse their writes before the service is called", async () => {
|
|
const app = archiveApp()
|
|
const t = await app.tenant("alpha")
|
|
await retention(t)
|
|
const { documentId } = await register(app, t)
|
|
const viewer = await app.member(t, "viewer@alpha.test", "viewer")
|
|
expect(await viewer.callByName("archive.document.list", t.slugs)).toMatchObject({ rows: [{ documentId }] })
|
|
const before = app.fake.calls.length
|
|
// A member without the permission is answered as a non-member is (kit-core's caller, spec §3.3).
|
|
for (const [name, input] of [
|
|
[
|
|
"archive.document.register",
|
|
{ title: "No", retentionClass: "contracts", fileId: await app.storedFile(t), requestId: crypto.randomUUID() },
|
|
],
|
|
["archive.document.hold", { documentId, revision: "1", legalHold: true, requestId: crypto.randomUUID() }],
|
|
["archive.document.dispose", { documentId, revision: "1", requestId: crypto.randomUUID() }],
|
|
["archive.retention.create", { code: "no", keepFor: "P1Y", from: "created", requestId: crypto.randomUUID() }],
|
|
] as const) {
|
|
const refused = await refusal(viewer.callByName(name, { ...t.slugs, ...input }))
|
|
expect(refused.problem.status, name).toBe(404)
|
|
}
|
|
expect(app.fake.calls.length).toBe(before)
|
|
})
|
|
|
|
it("let an editor register and keep retention to an administrator", async () => {
|
|
const app = archiveApp()
|
|
const t = await app.tenant("alpha")
|
|
await retention(t)
|
|
const { documentId } = await register(app, t)
|
|
const editor = await app.member(t, "editor@alpha.test", "member")
|
|
const registered = await editor.callByName("archive.document.register", {
|
|
...t.slugs,
|
|
title: "Editor's document",
|
|
retentionClass: "contracts",
|
|
fileId: await app.storedFile(t, "d".repeat(64)),
|
|
requestId: crypto.randomUUID(),
|
|
})
|
|
expect(registered).toMatchObject({ revision: "1" })
|
|
const refused = await refusal(
|
|
editor.callByName("archive.document.hold", {
|
|
...t.slugs,
|
|
documentId,
|
|
revision: "1",
|
|
legalHold: true,
|
|
requestId: crypto.randomUUID(),
|
|
}),
|
|
)
|
|
expect(refused.problem.status).toBe(404)
|
|
})
|
|
|
|
it("need no scope beyond the ones `kit module:on archive` asks for", async () => {
|
|
let now = new Date("2026-11-02T10:00:00Z").getTime()
|
|
const app = archiveApp({
|
|
clock: () => new Date(now),
|
|
scopes: [
|
|
"archive.documents:read",
|
|
"archive.documents:write",
|
|
"archive.holds:write",
|
|
"archive.retention:read",
|
|
"archive.retention:write",
|
|
"archive.disposal:read",
|
|
"archive.disposal:write",
|
|
],
|
|
})
|
|
const t = await app.tenant("alpha")
|
|
await retention(t, "short", "P1D")
|
|
const { documentId } = await register(app, t, "Delivery note", "short")
|
|
await t.call.callByName("archive.version.add", {
|
|
...t.slugs,
|
|
documentId,
|
|
revision: "1",
|
|
fileId: await app.storedFile(t, "b".repeat(64)),
|
|
requestId: crypto.randomUUID(),
|
|
})
|
|
await t.call.callByName("archive.document.list", t.slugs)
|
|
await t.call.callByName("archive.document.get", { ...t.slugs, documentId })
|
|
await t.call.callByName("archive.retention.list", t.slugs)
|
|
const request = () => ({ ...t.slugs, documentId, requestId: crypto.randomUUID() })
|
|
await t.call.callByName("archive.document.hold", { ...request(), revision: "2", legalHold: true })
|
|
await t.call.callByName("archive.document.hold", { ...request(), revision: "3", legalHold: false })
|
|
now += 2 * 24 * 3600 * 1000
|
|
await t.call.callByName("archive.disposal.list", t.slugs)
|
|
await t.call.callByName("archive.document.dispose", { ...request(), revision: "4" })
|
|
})
|
|
|
|
it("refuse another organization's file: a document cannot be made of a record its project does not hold", async () => {
|
|
const app = archiveApp()
|
|
const alpha = await app.tenant("alpha")
|
|
const bravo = await app.tenant("bravo")
|
|
await retention(bravo)
|
|
const theirs = await app.storedFile(alpha)
|
|
const before = app.fake.calls.length
|
|
const refused = await refusal(
|
|
bravo.call.callByName("archive.document.register", {
|
|
...bravo.slugs,
|
|
title: "Not mine",
|
|
retentionClass: "contracts",
|
|
fileId: theirs,
|
|
requestId: crypto.randomUUID(),
|
|
}),
|
|
)
|
|
expect(refused.problem.status).toBe(404)
|
|
expect(app.fake.calls.length).toBe(before)
|
|
expect(app.fake.archive.documents.size).toBe(0)
|
|
})
|
|
|
|
it("refuse a record of another platform organization: the token reaches only its own", async () => {
|
|
const app = archiveApp()
|
|
const t = await app.tenant("alpha")
|
|
await retention(t)
|
|
const { documentId } = await register(app, t)
|
|
const elsewhere = sdkArchiveService({ client: app.client, orgSlug: "another-organization" })
|
|
await expect(elsewhere.getDocument(documentId)).rejects.toMatchObject({ problem: { status: 404 } })
|
|
})
|
|
})
|
|
|
|
describe("the archive module's tenant isolation", () => {
|
|
it("refuses every procedure called with another organization's slugs, and never reaches the service", async () => {
|
|
const app = archiveApp()
|
|
const tenant = async (slug: string): Promise<IsolationTenant> => {
|
|
const t = await app.tenant(slug)
|
|
return {
|
|
orgSlug: t.orgSlug,
|
|
workspaceSlug: t.workspaceSlug,
|
|
projectSlug: t.projectSlug,
|
|
orgId: t.orgId,
|
|
workspaceId: t.workspaceId,
|
|
projectId: t.projectId,
|
|
userId: t.owner.userId,
|
|
email: `owner@${slug}.test`,
|
|
extra: {},
|
|
}
|
|
}
|
|
const a = await tenant("alpha")
|
|
const b = await tenant("bravo")
|
|
const result = await runIsolation({
|
|
registry: app.registry,
|
|
seed: async () => ({ a, b }),
|
|
caller: (actor) => app.caller(actor),
|
|
snapshot: () => snapshotOf(app.data, entitiesOf(app.registry, "postgres"), { outbox: app.data.outbox }),
|
|
fixtures: { ...tenancyIsolationFixtures(), ...archiveIsolationFixtures },
|
|
alsoCheck: [...TENANCY_ALSO_CHECK],
|
|
exempt: tenancyIsolationExempt,
|
|
})
|
|
expect(result.failures).toEqual([])
|
|
for (const name of Object.keys(archiveIsolationFixtures)) expect(result.passed, name).toContain(name)
|
|
expect(app.fake.calls).toEqual([])
|
|
})
|
|
})
|