// 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 = {}) => 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): Promise => { 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) => 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) }) })