Files

205 lines
9.5 KiB
TypeScript

// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The example module in a browser (plan item AT2.2): add a note, attach a file through the files
// module's Blob upload, change and remove things, and delete the note -- with axe on every screen it
// shows. Another organization's member sees none of it.
import { readFileSync } from "node:fs"
import { expect, type Page, test } from "@playwright/test"
import { call, callRaw, makeOrganization, platformState, signIn, unique, violations } from "./support/app"
const MINUTES = Buffer.from("Agreed on Friday: ship the pilot.\n")
async function uploadText(page: Page, scope: Awaited<ReturnType<typeof makeOrganization>>, name: string) {
const begun = await call<{ fileId: string; parts: Array<{ number: number }> }>(page, "files.file.beginUpload", {
...scope,
filename: name,
contentType: "text/plain",
size: 1,
})
const csrf = await page.locator('meta[name="csrf-token"]').getAttribute("content")
const part = begun.parts[0]
if (!part) throw new Error("the one-byte upload did not return a part")
const sent = await page.request.put(
`/api/v1/files.file.putPart?${new URLSearchParams({ ...scope, fileId: begun.fileId, part: String(part.number) })}`,
{
data: Buffer.from("x"),
headers: {
"content-type": "application/octet-stream",
"x-csrf-token": csrf ?? "",
"sec-fetch-site": "same-origin",
},
},
)
expect(sent.ok()).toBe(true)
const receipt = (await sent.json()) as { etag: string }
await call(page, "files.file.completeUpload", {
...scope,
fileId: begun.fileId,
parts: [{ number: part.number, etag: receipt.etag }],
})
return begun.fileId
}
test("a note takes attachments, is edited and removed, and every screen passes axe", async ({ page }) => {
await signIn(page, `${unique("author")}@example.test`)
const { org, workspace, project } = await makeOrganization(page)
const base = `/${org}/${workspace}/${project}/notes`
// the organization's home links to the module from the manifest's navigation entry
await page.goto(`/${org}`)
await expect(page.getByRole("link", { name: "Notes", exact: true })).toBeVisible()
await page.goto(base)
await expect(page.getByRole("heading", { level: 1, name: "Notes" })).toBeVisible()
await expect(page.getByText("No notes yet.")).toBeVisible()
expect(await violations(page)).toEqual([])
await page.getByLabel("Title").fill("Kick-off")
await page.getByLabel("Text").fill("Agenda and owners")
await page.getByRole("button", { name: "Add note" }).click()
// the note's own page
await expect(page.getByRole("heading", { level: 1, name: "Kick-off" })).toBeVisible()
await expect(page.getByText("Nothing is attached.")).toBeVisible()
expect(await violations(page)).toEqual([])
// an upload through the files module (the platform's Blob store), then the link
await page.getByLabel("Attach a file").setInputFiles({ name: "minutes.txt", mimeType: "text/plain", buffer: MINUTES })
await page.getByRole("button", { name: "Attach", exact: true }).click()
await expect(page.getByRole("row", { name: /minutes\.txt.*text\/plain.*1 KB/ })).toBeVisible()
await expect(page.getByText("1 attachment", { exact: true })).toBeVisible()
expect(await violations(page)).toEqual([])
const download = page.waitForEvent("download")
await page.getByRole("link", { name: "Download minutes.txt" }).click()
const downloaded = await download
expect(downloaded.suggestedFilename()).toBe("minutes.txt")
const downloadedPath = await downloaded.path()
expect(readFileSync(downloadedPath)).toEqual(MINUTES)
// what the worker sent the platform: ONE audit event and ONE usage fact for the upload, one audit event
// and one usage fact for the note, one audit event for the link
const noteId = page.url().split("/").pop() ?? ""
const { files } = await call<{ files: Array<{ fileId: string }> }>(page, "files.file.list", {
org,
workspace,
project,
})
expect(files).toHaveLength(1)
const fileId = files[0]?.fileId ?? ""
await expect(async () => {
const state = await platformState()
// The target carries the organization as `<org id>/<resource id>` (src/server/audit.ts), so a
// resource matches by its trailing segment.
const audited = (id: string) =>
state.audit.filter((event) => event.resource_id?.endsWith(`/${id}`)).map((event) => event.action)
expect(audited(fileId)).toEqual(["files.file_uploaded"])
expect(audited(noteId).sort()).toEqual(["notes.attachment_added", "notes.note_created"])
const metered = (key: string) => state.usage.filter((fact) => fact.idempotency_key === key)
expect(metered(`files_bytes_stored.${fileId}`)).toEqual([
expect.objectContaining({ meter: "files_bytes_stored", quantity: String(MINUTES.length) }),
])
expect(metered(`notes_created.${noteId}`)).toEqual([
expect.objectContaining({ meter: "notes_created", quantity: "1" }),
])
// and the bytes are in the platform's Blob store, once, under the organization's own prefix
expect(
state.blobs.filter((blob) => blob.size === MINUTES.length && blob.pathname.includes(`/files/`)),
).toHaveLength(1)
}).toPass({ timeout: 30_000 })
// edit: the form carries the version it was read at
await page.getByLabel("Title").fill("Kick-off call")
await page.getByRole("button", { name: "Save changes" }).click()
await expect(page.getByRole("heading", { level: 1, name: "Kick-off call" })).toBeVisible()
// the list shows the note with its attachment count
await page.goto(base)
await expect(page.getByRole("row", { name: /Kick-off call.*1/ })).toBeVisible()
expect(await violations(page)).toEqual([])
// remove the attachment, then delete the note after being asked
await page.getByRole("link", { name: "Open Kick-off call" }).click()
await page.getByRole("button", { name: "Remove minutes.txt" }).click()
await expect(page.getByText("Nothing is attached.")).toBeVisible()
await page.getByRole("button", { name: "Delete note" }).click()
expect(await violations(page)).toEqual([])
await page.getByRole("button", { name: "Yes, delete the note" }).click()
await expect(page.getByText("No notes yet.")).toBeVisible()
})
test("a second editor's stale save is refused and says so", async ({ page }) => {
await signIn(page, `${unique("author")}@example.test`)
const { org, workspace, project } = await makeOrganization(page)
const base = `/${org}/${workspace}/${project}/notes`
await page.goto(base)
await page.getByLabel("Title").fill("Plan")
await page.getByRole("button", { name: "Add note" }).click()
await expect(page.getByRole("heading", { level: 1, name: "Plan" })).toBeVisible()
// the same note opened in a second tab, saved there first
const second = await page.context().newPage()
await second.goto(page.url())
await second.getByLabel("Title").fill("Plan v2")
await second.getByRole("button", { name: "Save changes" }).click()
await expect(second.getByRole("heading", { level: 1, name: "Plan v2" })).toBeVisible()
// the first tab still holds version 1
await page.getByLabel("Title").fill("Plan v3")
await page.getByRole("button", { name: "Save changes" }).click()
await expect(page.getByRole("alert")).toBeVisible()
expect(await violations(page)).toEqual([])
await second.close()
})
test("concurrent attachments cannot exceed the per-note cap", async ({ page }) => {
await signIn(page, `${unique("quota-owner")}@example.test`)
const scope = await makeOrganization(page)
const note = await call<{ noteId: string }>(page, "notes.note.create", {
...scope,
title: "Attachment quota",
body: "",
})
const existingFiles: string[] = []
for (let index = 0; index < 19; index += 1) {
const fileId = await uploadText(page, scope, `quota-${index}.txt`)
existingFiles.push(fileId)
await call(page, "notes.attachment.add", { ...scope, noteId: note.noteId, fileId })
}
const contenders = await Promise.all([
uploadText(page, scope, "quota-race-a.txt"),
uploadText(page, scope, "quota-race-b.txt"),
])
const csrf = await page.locator('meta[name="csrf-token"]').getAttribute("content")
const results = await Promise.all(
contenders.map((fileId) =>
callRaw(page.request, csrf ?? "", "notes.attachment.add", { ...scope, noteId: note.noteId, fileId }),
),
)
expect(results.map((result) => result.status()).sort()).toEqual([200, 409])
const detail = await call<{ attachments: unknown[] }>(page, "notes.note.get", { ...scope, noteId: note.noteId })
expect(detail.attachments).toHaveLength(20)
expect(existingFiles).toHaveLength(19)
})
test("another organization's member does not see the notes", async ({ page, browser }) => {
await signIn(page, `${unique("owner")}@example.test`)
const mine = await makeOrganization(page)
const stranger = await browser.newPage()
await signIn(stranger, `${unique("stranger")}@example.test`)
const response = await stranger.goto(`/${mine.org}/${mine.workspace}/${mine.project}/notes`)
expect(response?.status()).toBe(404)
await expect(stranger.getByRole("heading", { level: 1, name: "Page not found" })).toBeVisible()
await stranger.close()
})
test("a path the module does not know is the not-found page", async ({ page }) => {
await signIn(page, `${unique("author")}@example.test`)
const { org, workspace, project } = await makeOrganization(page)
const unknownModule = await page.goto(`/${org}/${workspace}/${project}/nothing-here`)
expect(unknownModule?.status()).toBe(404)
const unknownNote = await page.goto(`/${org}/${workspace}/${project}/notes/00000000-0000-0000-0000-000000000000`)
expect(unknownNote?.status()).toBe(404)
})