Files
template-app/test/notes-flow.test.ts
T

342 lines
16 KiB
TypeScript

// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The notes module through the kernel's caller, the files module, the outbox relay's behaviour and
// the worker's job runner: what is proven is the wiring and not a unit. The three things its plan item
// asks for are here by name -- an upload records one usage event and one audit event, the flag
// switches attachments off, and a tenant's notes and files are its own.
import { MAX_ATTACHMENTS, NOTES_CREATED_METER, type NotesFlags, type NoteView } from "@app/modules/notes"
import { AppError, Container } from "@conusai-kit/core"
import { systemActor } from "@conusai-kit/core/worker"
import { FILES_AUDIT, FILES_SCOPE, FILES_USAGE } from "@conusai-kit/files"
import { uploadViaProcedures } from "@conusai-kit/testing"
import { describe, expect, it } from "vitest"
import { bytesOf, makeNotesApp, type NotesApp, type SeededProject } from "./support/notes-app.js"
interface Detail {
note: NoteView
attachments: Array<{ attachmentId: string; fileId: string; filename: string; contentType: string; size: number }>
attachmentsEnabled: boolean
}
async function fresh(flags?: NotesFlags): Promise<{ app: NotesApp; project: SeededProject }> {
const app = makeNotesApp(flags ? { flags } : {})
return { app, project: await app.seedProject("acme", "owner@acme.test") }
}
const run = (project: SeededProject, procedure: string, input: Record<string, unknown> = {}) =>
project.call.callByName(procedure, { ...project.slugs, ...input })
const create = async (project: SeededProject, title = "Kick-off", body = "Agenda") =>
(await run(project, "notes.note.create", { title, body })) as NoteView
const detail = async (project: SeededProject, noteId: string) =>
(await run(project, "notes.note.get", { noteId })) as Detail
const refusal = async (promise: Promise<unknown>): Promise<AppError> => {
try {
await promise
} catch (error) {
if (error instanceof AppError) return error
throw error
}
throw new Error("expected a refusal")
}
describe("notes", () => {
it("creates, lists, reads, edits and deletes a note", async () => {
const { project } = await fresh()
const note = await create(project)
expect(note).toMatchObject({ title: "Kick-off", body: "Agenda", version: 1, attachmentCount: 0 })
const listed = (await run(project, "notes.note.list")) as { notes: NoteView[] }
expect(listed.notes.map((entry) => entry.noteId)).toEqual([note.noteId])
const edited = (await run(project, "notes.note.update", {
noteId: note.noteId,
title: "Kick-off call",
body: "Agenda and owners",
expectedVersion: note.version,
})) as NoteView
expect(edited).toMatchObject({ title: "Kick-off call", version: 2 })
expect((await detail(project, note.noteId)).note.body).toBe("Agenda and owners")
await run(project, "notes.note.delete", { noteId: note.noteId })
expect(((await run(project, "notes.note.list")) as { notes: NoteView[] }).notes).toEqual([])
expect((await refusal(run(project, "notes.note.get", { noteId: note.noteId }))).status).toBe(404)
})
it("refuses a second editor's stale save with 409, so nothing is overwritten silently", async () => {
const { project } = await fresh()
const note = await create(project)
await run(project, "notes.note.update", { noteId: note.noteId, title: "A", body: "", expectedVersion: 1 })
const stale = await refusal(
run(project, "notes.note.update", { noteId: note.noteId, title: "B", body: "", expectedVersion: 1 }),
)
expect(stale.status).toBe(409)
expect((await detail(project, note.noteId)).note.title).toBe("A")
})
it("lets a viewer read and nothing else, and answers a write as the kernel answers a stranger: 404", async () => {
const { app, project } = await fresh()
const note = await create(project)
const viewer = await app.addMember(project, "viewer@acme.test", "viewer")
const asViewer = (procedure: string, input: Record<string, unknown>) =>
viewer.call.callByName(procedure, { ...project.slugs, ...input })
expect(((await asViewer("notes.note.list", {})) as { notes: unknown[] }).notes).toHaveLength(1)
expect((await refusal(asViewer("notes.note.create", { title: "x", body: "" }))).status).toBe(404)
expect(
(await refusal(asViewer("notes.note.update", { noteId: note.noteId, title: "x", body: "", expectedVersion: 1 })))
.status,
).toBe(404)
expect((await refusal(asViewer("notes.note.delete", { noteId: note.noteId }))).status).toBe(404)
})
})
describe("what a note records, exactly once", () => {
it("writes one audit event and one usage fact per note created, and none of its text", async () => {
const { app, project } = await fresh()
const note = await create(project, "Secret plan", "Body text")
expect(app.audit.events).toEqual([])
await app.deliver()
expect(app.audit.events.map((event) => event.action)).toEqual(["notes.note_created"])
expect(app.audit.events[0]).toMatchObject({
targetId: `${project.orgId}/${note.noteId}`,
actorId: project.owner.userId,
})
expect(JSON.stringify(app.audit.events)).not.toContain("Secret plan")
expect(JSON.stringify(app.audit.events)).not.toContain("Body text")
expect(app.usage.facts).toEqual([
{
meter: NOTES_CREATED_METER,
quantity: 1,
customer: project.orgId,
idempotencyKey: `${NOTES_CREATED_METER}.${note.noteId}`,
},
])
// The record is published: a second pass of the relay delivers nothing more.
expect(await app.deliver()).toBe(0)
})
it("gives a redelivered record the same audit event id and usage key, so the platform sees one", async () => {
const { app, project } = await fresh()
await create(project)
const record = (await app.data.outbox.pending(10))[0]
expect(record).toBeDefined()
const subscriber = app.registry.events["notes.NoteCreated"]?.subscribers[0]
const container = new Container()
container.value(FILES_SCOPE, { level: "org", actor: systemActor("app.relay"), orgId: record?.orgId ?? "" })
container.value(FILES_AUDIT, app.audit)
container.value(FILES_USAGE, app.usage)
const deliverOnce = () =>
subscriber?.handler(
{ name: "notes.NoteCreated", payload: record?.payload },
{ container, correlationId: record?.id ?? "" },
)
await deliverOnce()
await deliverOnce()
expect(app.audit.events).toHaveLength(2)
expect(app.audit.events[0]?.eventId).toBeTruthy()
expect(app.audit.events[0]?.eventId).toBe(app.audit.events[1]?.eventId)
expect(app.usage.facts[0]?.idempotencyKey).toBe(app.usage.facts[1]?.idempotencyKey)
})
it("audits an edit once per version, so two edits are two events", async () => {
const { app, project } = await fresh()
const note = await create(project)
await run(project, "notes.note.update", { noteId: note.noteId, title: "A", body: "", expectedVersion: 1 })
await run(project, "notes.note.update", { noteId: note.noteId, title: "B", body: "", expectedVersion: 2 })
await app.deliver()
const edits = app.audit.events.filter((event) => event.action === "notes.note_updated")
expect(edits).toHaveLength(2)
expect(new Set(edits.map((event) => event.eventId)).size).toBe(2)
})
})
describe("attachments", () => {
it("records one usage event and one audit event for an upload, and one more audit event for the link", async () => {
const { app, project } = await fresh()
const note = await create(project)
await app.deliver()
app.audit.events.length = 0
app.usage.facts.length = 0
const stored = await app.upload(project, "minutes.txt", "agreed on Friday")
await app.deliver()
expect(app.audit.events.map((event) => event.action)).toEqual(["files.file_uploaded"])
expect(app.usage.facts).toEqual([
expect.objectContaining({ meter: "files_bytes_stored", quantity: stored.size, customer: project.orgId }),
])
const attached = (await run(project, "notes.attachment.add", { noteId: note.noteId, fileId: stored.fileId })) as {
attachmentId: string
filename: string
}
expect(attached.filename).toBe("minutes.txt")
await app.deliver()
expect(app.audit.events.map((event) => event.action)).toEqual(["files.file_uploaded", "notes.attachment_added"])
expect(app.usage.facts).toHaveLength(1)
const seen = await detail(project, note.noteId)
expect(seen.attachments).toEqual([
expect.objectContaining({ fileId: stored.fileId, filename: "minutes.txt", contentType: "text/plain" }),
])
expect(seen.note.attachmentCount).toBe(1)
})
it("links a file once: attaching it again answers with the first link and records nothing more", async () => {
const { app, project } = await fresh()
const note = await create(project)
const stored = await app.upload(project)
const first = (await run(project, "notes.attachment.add", { noteId: note.noteId, fileId: stored.fileId })) as {
attachmentId: string
}
const second = (await run(project, "notes.attachment.add", { noteId: note.noteId, fileId: stored.fileId })) as {
attachmentId: string
}
expect(second.attachmentId).toBe(first.attachmentId)
await app.deliver()
expect(app.audit.events.filter((event) => event.action === "notes.attachment_added")).toHaveLength(1)
})
it("refuses a file that is not this project's, and one that does not exist, alike", async () => {
const { app, project } = await fresh()
const note = await create(project)
const other = await app.seedProject("globex", "owner@globex.test")
const foreign = await app.upload(other)
expect(
(await refusal(run(project, "notes.attachment.add", { noteId: note.noteId, fileId: foreign.fileId }))).status,
).toBe(404)
expect(
(
await refusal(
run(project, "notes.attachment.add", { noteId: note.noteId, fileId: "00000000-0000-0000-0000-000000000000" }),
)
).status,
).toBe(404)
})
it(`allows at most ${MAX_ATTACHMENTS} attachments to one note`, async () => {
const { app, project } = await fresh()
const note = await create(project)
for (let at = 0; at < MAX_ATTACHMENTS; at += 1) {
const stored = await app.upload(project, `f${at}.txt`, `body ${at}`)
await run(project, "notes.attachment.add", { noteId: note.noteId, fileId: stored.fileId })
}
const extra = await app.upload(project, "extra.txt", "one too many")
expect(
(await refusal(run(project, "notes.attachment.add", { noteId: note.noteId, fileId: extra.fileId }))).status,
).toBe(409)
})
it("refuses a file type a note does not carry, though the files module stores it", async () => {
const { project } = await fresh()
const note = await create(project)
const sheet = await uploadViaProcedures(project.call, project.slugs, {
filename: "plan.csv",
contentType: "text/csv",
bytes: bytesOf("a,b"),
})
const refused = await refusal(run(project, "notes.attachment.add", { noteId: note.noteId, fileId: sheet.fileId }))
expect(refused.status).toBe(422)
expect((await detail(project, note.noteId)).attachments).toEqual([])
})
it("removes one link, audits it, and leaves the file in the project", async () => {
const { app, project } = await fresh()
const note = await create(project)
const stored = await app.upload(project)
const link = (await run(project, "notes.attachment.add", { noteId: note.noteId, fileId: stored.fileId })) as {
attachmentId: string
}
await run(project, "notes.attachment.remove", { noteId: note.noteId, attachmentId: link.attachmentId })
expect((await detail(project, note.noteId)).attachments).toEqual([])
expect(((await run(project, "files.file.list")) as { files: unknown[] }).files).toHaveLength(1)
await app.deliver()
expect(app.audit.events.map((event) => event.action)).toContain("notes.attachment_removed")
})
it("does not remove another note's attachment through this note", async () => {
const { app, project } = await fresh()
const first = await create(project, "First")
const second = await create(project, "Second")
const stored = await app.upload(project)
const link = (await run(project, "notes.attachment.add", { noteId: first.noteId, fileId: stored.fileId })) as {
attachmentId: string
}
expect(
(
await refusal(
run(project, "notes.attachment.remove", { noteId: second.noteId, attachmentId: link.attachmentId }),
)
).status,
).toBe(404)
expect((await detail(project, first.noteId)).attachments).toHaveLength(1)
})
it("deleting a note removes its links, audits how many, and keeps the files", async () => {
const { app, project } = await fresh()
const note = await create(project)
for (const name of ["a.txt", "b.txt"]) {
const stored = await app.upload(project, name, name)
await run(project, "notes.attachment.add", { noteId: note.noteId, fileId: stored.fileId })
}
await run(project, "notes.note.delete", { noteId: note.noteId })
await app.deliver()
const deleted = app.audit.events.find((event) => event.action === "notes.note_deleted")
expect(deleted?.data).toMatchObject({ attachmentCount: 2 })
expect(((await run(project, "files.file.list")) as { files: unknown[] }).files).toHaveLength(2)
})
it("drops a deleted file's links when the files module has deleted the object", async () => {
const { app, project } = await fresh()
const note = await create(project)
const stored = await app.upload(project)
await run(project, "notes.attachment.add", { noteId: note.noteId, fileId: stored.fileId })
await run(project, "files.file.delete", { fileId: stored.fileId })
// The marking transaction, then the job that deletes the object and publishes `files.FileDeleted`.
await app.deliver()
await app.runJobs()
await app.deliver()
const seen = await detail(project, note.noteId)
expect(seen.attachments).toEqual([])
expect(seen.note.attachmentCount).toBe(0)
})
it("is switched off by the notes.attachments flag: the link is refused and the page is told", async () => {
const off: NotesFlags = { enabled: () => false }
const { app, project } = await fresh(off)
const note = await create(project)
const stored = await app.upload(project)
expect(
(await refusal(run(project, "notes.attachment.add", { noteId: note.noteId, fileId: stored.fileId }))).status,
).toBe(403)
expect((await detail(project, note.noteId)).attachmentsEnabled).toBe(false)
})
it("is on by default, with no flag service bound", async () => {
const { project } = await fresh()
const note = await create(project)
expect((await detail(project, note.noteId)).attachmentsEnabled).toBe(true)
})
})
describe("tenants", () => {
it("never shows one organization's notes or attachments to another", async () => {
const { app, project } = await fresh()
const other = await app.seedProject("globex", "owner@globex.test")
const note = await create(project)
const stored = await app.upload(project)
await run(project, "notes.attachment.add", { noteId: note.noteId, fileId: stored.fileId })
expect(((await run(other, "notes.note.list")) as { notes: unknown[] }).notes).toEqual([])
expect((await refusal(run(other, "notes.note.get", { noteId: note.noteId }))).status).toBe(404)
// The other tenant's own slugs with this tenant's ids: the same 404 as an id nobody has.
expect(
(await refusal(run(other, "notes.attachment.add", { noteId: note.noteId, fileId: stored.fileId }))).status,
).toBe(404)
// This tenant's caller naming the other organization's slugs is refused outright.
const crossed = await refusal(project.call.callByName("notes.note.list", { ...other.slugs }))
expect([403, 404]).toContain(crossed.status)
})
})