205 lines
9.5 KiB
TypeScript
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)
|
|
})
|