342 lines
16 KiB
TypeScript
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)
|
|
})
|
|
})
|