diff --git a/docs/api.md b/docs/api.md index f6d8383..07ccbcc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -95,6 +95,31 @@ Recognized images are scanned in quarantine, then auto-oriented, resized, stripp File reads support byte ranges. Public file and version responses use a five-minute cache lifetime so an admin takedown is not hidden behind a year-long immutable cache. Potentially active types such as HTML, SVG, XML, JavaScript, and PDF are served as downloads rather than rendered inline; file responses also carry a sandboxed, deny-by-default CSP. +## Delete content + +The token that created a page, file, or guide can delete it permanently. Admin tokens can delete any of them. Deletion also works during publishing lockdown and returns `204 No Content`: + +```sh +curl --fail-with-body --silent --show-error -X DELETE \ + -H "Authorization: Bearer $SCHAFFA_TOKEN" \ + "$SCHAFFA_URL/api/guides/$ID" +``` + +| Endpoint | Removes | +| --- | --- | +| `DELETE /api/pages/:slug` | The page and all of its versions | +| `DELETE /api/files/:id` | One file, addressed by ID or by its public filename such as `.webp` | +| `DELETE /api/guides/:slug` | The guide with all revisions and screenshots | + +A token that does not own the content receives `403`. An unknown ID returns `404`. Anonymous pages cannot be deleted through the API. A guide's attached video is a separate file and stays until it is deleted itself. + +The CLI provides the same operation and accepts either the type with an ID or a public URL: + +```sh +npx schaffa delete guide "$ID" +npx schaffa delete https://schaffa.dev/p/ +``` + ## Administration Administration is intentionally not part of the public HTTP API or OpenAPI contract. Use the protected `/admin` interface to list and remove pages, individual page versions, and files; create and revoke upload or admin tokens; delete users; grant interactive publishing per user; and control publishing lockdown, interactive publishing, signups, and logins. diff --git a/packages/cli/README.md b/packages/cli/README.md index 18b5603..aa0ecbf 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -125,6 +125,17 @@ Queue time can exceed this wait. If the deadline expires, the CLI prints the existing file and status URLs. Check them before uploading again; the pending upload remains on the server and is not cancelled by the CLI deadline. +## Delete something + +Delete a page, file, or guide with the token that created it, or with an admin token: + +```sh +npx schaffa delete guide abc234def567 +npx schaffa delete https://schaffa.dev/f/.webp +``` + +Deletion is permanent. Pages lose all versions and guides lose all revisions. + ## Check credentials and permissions ```sh diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 585d9e2..a301a4d 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -22,9 +22,11 @@ import { parseArgs, promisify } from "node:util"; import { addGuideStep, deleteGuideStep, + deletePublication, finishGuide, type GuideResult, getGuide, + parseDeleteTarget, replaceGuideScreenshot, setGuideVideo, startGuide, @@ -72,6 +74,8 @@ Usage: schaffa guide replace-screenshot --step --screenshot [--json] schaffa guide sync [--manifest ] [--json] schaffa guide finish [--json] + schaffa delete [--token ] [--json] + schaffa delete [--token ] [--json] Environment: SCHAFFA_TOKEN Required for permanent publishing, files, presentations, and guides. @@ -79,6 +83,8 @@ Environment: The guide commands persist the active random slug and edit revision in .schaffa/guide-session.json so an interrupted recording can be resumed. +delete permanently removes a page with all versions, a file, or a guide with all +revisions. It requires the token that created the content or an admin token. Add --video to record or guide record to export and attach a video walkthrough. Video export requires local ffmpeg with libvpx-vp9 and Chromium. Standalone video is local unless --upload is requested. Automatic recordings also keep every original screenshot and a manifest under @@ -150,6 +156,7 @@ async function main(): Promise { return runGuide(args.slice(1)); } if (args[0] === "publish") return runPresentation(args.slice(1)); + if (args[0] === "delete") return runDelete(args.slice(1)); const options = parseCliArgs(args); if ("help" in options) return void process.stdout.write(help); const { json, command: _command, ...uploadOptions } = options; @@ -178,6 +185,29 @@ async function runDoctor(args: string[]): Promise { if (!report.ready) process.exitCode = 1; } +async function runDelete(args: string[]): Promise { + const { values, positionals } = parseArgs({ + args, + allowPositionals: true, + strict: true, + options: { + help: { type: "boolean", short: "h" }, + json: { type: "boolean" }, + token: { type: "string" }, + }, + }); + if (values.help) return void process.stdout.write(help); + const baseUrl = process.env.SCHAFFA_URL || "https://schaffa.dev"; + const target = parseDeleteTarget(positionals, baseUrl); + const token = resolveToken(values); + await deletePublication({ ...target, baseUrl, ...(token ? { token } : {}) }); + process.stdout.write( + values.json + ? `${JSON.stringify({ deleted: true, ...target })}\n` + : `Deleted ${target.kind} ${target.id}.\n`, + ); +} + async function runAutomaticRecorder(args: string[], legacy: boolean): Promise { const { values } = parseArgs({ args, diff --git a/packages/cli/src/client.ts b/packages/cli/src/client.ts index 8ba66ed..dc495ae 100644 --- a/packages/cli/src/client.ts +++ b/packages/cli/src/client.ts @@ -364,6 +364,76 @@ export async function waitForVideoScan(options: { ); } +export type DeleteKind = "page" | "file" | "guide"; + +export interface DeleteTarget { + kind: DeleteKind; + id: string; +} + +const deleteRoutes: Record = { + page: { publicPrefix: "p", apiPrefix: "/api/pages/" }, + file: { publicPrefix: "f", apiPrefix: "/api/files/" }, + guide: { publicPrefix: "g", apiPrefix: "/api/guides/" }, +}; + +/** + * Accepts either ` ` or a public Schaffa URL (`/p/…`, `/f/…`, `/g/…`). + * URLs must belong to the configured origin so an ID is never deleted on the wrong instance. + */ +export function parseDeleteTarget(args: string[], baseUrl = "https://schaffa.dev"): DeleteTarget { + const [first, second] = args; + if (!first || args.length > 2) + throw new Error("delete requires or ."); + const kind = second === undefined ? undefined : first; + if (kind !== undefined && !Object.hasOwn(deleteRoutes, kind)) { + throw new Error("delete kind must be page, file, or guide."); + } + const value = second ?? first; + if (!/^https?:\/\//i.test(value)) { + if (!kind) throw new Error("delete requires or ."); + if (!/^(?!\.+$)[A-Za-z0-9._-]+$/.test(value)) throw new Error("Invalid ID."); + return { kind: kind as DeleteKind, id: value }; + } + const url = new URL(value); + if (url.origin !== canonicalOrigin(baseUrl)) { + throw new Error(`URL does not belong to ${canonicalOrigin(baseUrl)}. Set SCHAFFA_URL.`); + } + const segments = url.pathname.split("/").filter(Boolean); + const [prefix, id] = segments; + const detected = (Object.keys(deleteRoutes) as DeleteKind[]).find( + (candidate) => deleteRoutes[candidate].publicPrefix === prefix, + ); + // Version and revision URLs are rejected so they are not mistaken for a partial delete. + if (!detected || !id || segments.length !== 2) { + throw new Error("URL must be the public URL of a Schaffa page, file, or guide."); + } + if (kind && kind !== detected) throw new Error(`URL points to a ${detected}, not a ${kind}.`); + return { kind: detected, id: decodeURIComponent(id) }; +} + +export async function deletePublication( + options: DeleteTarget & { token?: string; baseUrl?: string; fetch?: typeof fetch }, +): Promise { + if (!options.token) throw new Error("SCHAFFA_TOKEN is required to delete content."); + const response = await (options.fetch || fetch)( + new URL( + `${deleteRoutes[options.kind].apiPrefix}${encodeURIComponent(options.id)}`, + canonicalOrigin(options.baseUrl || "https://schaffa.dev"), + ), + { method: "DELETE", headers: { Authorization: `Bearer ${options.token}` } }, + ); + if (!response.ok) { + const result = parseResponse(await response.text()); + const detail = typeof result.message === "string" ? ` ${result.message}` : ""; + throw new SchaffaRequestError( + response.status, + `Schaffa request failed with HTTP ${response.status}.${detail}`, + typeof result.error === "string" ? result.error : undefined, + ); + } +} + export interface GuideMutationOptions { slug: string; editRevision: number; diff --git a/packages/cli/test/client.test.mjs b/packages/cli/test/client.test.mjs index b79f015..58c918f 100644 --- a/packages/cli/test/client.test.mjs +++ b/packages/cli/test/client.test.mjs @@ -10,8 +10,10 @@ import { addPresentationDownloads, parseCliArgs } from "../dist/cli.js"; import { addGuideStep, deleteGuideStep, + deletePublication, finishGuide, getGuide, + parseDeleteTarget, replaceGuideScreenshot, startGuide, updateGuideStep, @@ -238,6 +240,77 @@ test("reports API errors without exposing the bearer token", async () => { ); }); +test("parses delete targets from kind and ID or from a same-origin public URL", () => { + assert.deepEqual(parseDeleteTarget(["guide", "abc234def567"]), { + kind: "guide", + id: "abc234def567", + }); + assert.deepEqual(parseDeleteTarget(["https://schaffa.dev/p/0123456789abcdef"]), { + kind: "page", + id: "0123456789abcdef", + }); + assert.deepEqual( + parseDeleteTarget(["file", "https://schaffa.dev/f/AAAAAAAAAAAAAAAAAAAAAA.webp"]), + { + kind: "file", + id: "AAAAAAAAAAAAAAAAAAAAAA.webp", + }, + ); + assert.deepEqual( + parseDeleteTarget(["https://self.example/g/abc234def567"], "https://self.example"), + { kind: "guide", id: "abc234def567" }, + ); + assert.throws(() => parseDeleteTarget(["abc234def567"]), /page\|file\|guide/); + assert.throws(() => parseDeleteTarget(["video", "abc"]), /page, file, or guide/); + assert.throws(() => parseDeleteTarget(["guide", "../pages/x"]), /Invalid ID/); + assert.throws(() => parseDeleteTarget(["guide", ".."]), /Invalid ID/); + assert.throws(() => parseDeleteTarget(["toString", "abc"]), /page, file, or guide/); + assert.throws(() => parseDeleteTarget(["https://other.example/g/abc234def567"]), /SCHAFFA_URL/); + assert.throws(() => parseDeleteTarget(["https://schaffa.dev/p/abc/2"]), /public URL/); + assert.throws( + () => parseDeleteTarget(["page", "https://schaffa.dev/g/abc234def567"]), + /points to a guide, not a page/, + ); +}); + +test("deletes content with the bearer token and reports API errors", async () => { + const requests = []; + await deletePublication({ + kind: "guide", + id: "abc234def567", + token, + baseUrl: "https://self.example", + fetch: async (url, init) => { + requests.push({ url: String(url), init }); + return new Response(null, { status: 204 }); + }, + }); + assert.equal(requests[0].url, "https://self.example/api/guides/abc234def567"); + assert.equal(requests[0].init.method, "DELETE"); + assert.equal(requests[0].init.headers.Authorization, `Bearer ${token}`); + + await assert.rejects( + deletePublication({ kind: "page", id: "abc", fetch: async () => new Response(null) }), + /SCHAFFA_TOKEN is required/, + ); + await assert.rejects( + deletePublication({ + kind: "file", + id: "abc", + token, + fetch: async () => + jsonResponse({ error: "forbidden", message: "This token does not own the file." }, 403), + }), + (error) => { + assert.equal(error.status, 403); + assert.equal(error.code, "forbidden"); + assert.match(error.message, /HTTP 403.*does not own the file/); + assert.doesNotMatch(error.message, new RegExp(token)); + return true; + }, + ); +}); + test("drives the incremental guide API with revisions and authorization", async () => { const requests = []; const fakeFetch = async (url, init) => { diff --git a/src/guides.ts b/src/guides.ts index 4a1be11..75e1071 100644 --- a/src/guides.ts +++ b/src/guides.ts @@ -212,6 +212,8 @@ export async function addGuideStep( .get(guide.id) as unknown as { position: number }; db().exec("BEGIN IMMEDIATE"); try { + // The guide may have been deleted while the screenshot was scanned. + loadGuide(guide.id); if (image) insertImage(image); db() .prepare( @@ -315,6 +317,8 @@ export async function replaceGuideScreenshot( const image = await prepareGuideImage(guide, screenshot); db().exec("BEGIN IMMEDIATE"); try { + // The guide may have been deleted while the screenshot was scanned. + loadGuide(guide.id); insertImage(image); db() .prepare( @@ -589,6 +593,15 @@ export async function deleteGuide(slug: string): Promise { await removeGuide(slug); } +export async function deleteOwnedGuide( + slug: string, + tokenId: string, + isAdmin: boolean, +): Promise { + requireOwnedGuide(slug, tokenId, isAdmin); + await deleteGuide(slug); +} + export type GuideSummary = GuideRow & { step_count: number; uploader_id: string; diff --git a/src/openapi.ts b/src/openapi.ts index 7a7a169..3c72213 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -117,6 +117,22 @@ export function openApiDocument() { "503": errorResponse("Publishing is locked or virus scanning is not configured"), }, }, + delete: { + tags: ["Pages"], + summary: "Delete a page", + description: + "Permanently deletes the page and all of its versions. Only the token that created the page or an admin token may delete it.", + operationId: "deletePage", + security: [{ bearerAuth: [] }], + parameters: [slugParameter], + responses: { + "204": { description: "Page deleted" }, + "401": errorResponse("Missing or invalid token"), + "403": errorResponse("The token does not own this page"), + "404": errorResponse("Page not found"), + "422": errorResponse("Invalid slug"), + }, + }, }, "/p/{slug}": pageRead("Read the latest page version", "getLatestPage", [slugParameter]), "/p/{slug}/{version}": pageRead("Read a specific page version", "getPageVersion", [ @@ -172,6 +188,30 @@ export function openApiDocument() { }, }, }, + "/api/files/{id}": { + delete: { + tags: ["Files"], + summary: "Delete a file", + description: + "Permanently deletes an uploaded file. Accepts the file ID or its public filename. Only the token that uploaded the file or an admin token may delete it.", + operationId: "deleteFile", + security: [{ bearerAuth: [] }], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + responses: { + "204": { description: "File deleted" }, + "401": errorResponse("Missing or invalid token"), + "403": errorResponse("The token does not own this file"), + "404": errorResponse("File not found"), + }, + }, + }, "/f/{filename}": { get: { tags: ["Files"], @@ -282,6 +322,21 @@ export function openApiDocument() { "409": errorResponse("Edit revision conflict"), }, }, + delete: { + tags: ["Guides"], + summary: "Delete a guide", + description: + "Permanently deletes the guide, all of its revisions, and its screenshots. Only the token that created the guide or an admin token may delete it.", + operationId: "deleteGuide", + security: [{ bearerAuth: [] }], + parameters: [slugParameter], + responses: { + "204": { description: "Guide deleted" }, + "401": errorResponse("Missing or invalid token"), + "403": errorResponse("The token does not own this guide"), + "404": errorResponse("Guide not found"), + }, + }, }, "/api/guides/{slug}/steps": { post: { diff --git a/src/server.ts b/src/server.ts index 4913452..06ac40a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -27,6 +27,7 @@ import { createGuide, deleteGuide, deleteGuideStep, + deleteOwnedGuide, finishGuide, getGuideImage, getOwnedGuide, @@ -48,8 +49,10 @@ import { pendingScanCount, processNextPendingScan, resetInterruptedScans } from import { canRunInteractivePage, deleteFile, + deleteFileForToken, deleteFileForUser, deletePage, + deletePageForToken, deletePageForUser, deletePageVersion, deletePageVersionForUser, @@ -510,6 +513,11 @@ export function buildServer( if (scanIntervalMs > 0) void runScan(); return reply.code(202).send(result); }); + app.delete<{ Params: { slug: string } }>("/api/pages/:slug", async (request, reply) => { + const auth = requireTokenAuth(request); + await deletePageForToken(auth.id, auth.scopes.has("admin"), request.params.slug); + return reply.code(204).send(); + }); app.post<{ Querystring: { title?: string | string[]; type?: string | string[] } }>( "/api/pages", @@ -557,6 +565,11 @@ export function buildServer( if (scanIntervalMs > 0) void runScan(); return reply.code(202).send(result); }); + app.delete<{ Params: { id: string } }>("/api/files/:id", async (request, reply) => { + const auth = requireTokenAuth(request); + await deleteFileForToken(auth.id, auth.scopes.has("admin"), request.params.id); + return reply.code(204).send(); + }); app.post<{ Body: { title?: unknown; description?: unknown; targetUrl?: unknown; language?: unknown }; @@ -591,6 +604,11 @@ export function buildServer( return guide; }, ); + app.delete<{ Params: { slug: string } }>("/api/guides/:slug", async (request, reply) => { + const auth = requireTokenAuth(request); + await deleteOwnedGuide(request.params.slug, auth.id, auth.scopes.has("admin")); + return reply.code(204).send(); + }); app.post<{ Params: { slug: string }; Body: Record }>( "/api/guides/:slug/steps", async (request, reply) => { @@ -1292,6 +1310,13 @@ function requireApiAuth(request: FastifyRequest, scope: TokenScope) { return requireScope(authenticateToken(token), scope); } +// Deletion is authorized by ownership, so any valid token scope may remove its own content. +function requireTokenAuth(request: FastifyRequest) { + const auth = authenticateToken(bearerToken(request.headers.authorization)); + if (!auth) throw new AppError("A valid bearer token is required.", 401, "unauthorized"); + return auth; +} + function requireInteractiveApiAuth(request: FastifyRequest) { const auth = requireApiAuth(request, "interactive"); const settings = getInstanceSettings(); diff --git a/src/service.ts b/src/service.ts index 35e4e2d..35a37ff 100644 --- a/src/service.ts +++ b/src/service.ts @@ -506,6 +506,24 @@ export async function deletePageForUser(userId: string, slugValue: string): Prom }); } +export async function deletePageForToken( + tokenId: string, + isAdmin: boolean, + slugValue: string, +): Promise { + return serializeMetadataWrite(async () => { + const slug = validateSlug(slugValue); + const page = db().prepare("SELECT owner_token_id FROM pages WHERE slug = ?").get(slug) as + | { owner_token_id: string | null } + | undefined; + if (!page) throw new AppError("Page not found.", 404, "not_found"); + if (page.owner_token_id !== tokenId && !isAdmin) { + throw new AppError("This token does not own the page.", 403, "forbidden"); + } + await deletePageLocked(slug); + }); +} + async function deletePageLocked(slugValue: string): Promise { const slug = validateSlug(slugValue); const result = db().prepare("DELETE FROM pages WHERE slug = ?").run(slug); @@ -598,6 +616,23 @@ export async function deleteFileForUser(userId: string, id: string): Promise { + return serializeMetadataWrite(async () => { + const file = db() + .prepare("SELECT id, created_by_token_id FROM files WHERE id = ? OR filename = ?") + .get(idOrFilename, idOrFilename) as { id: string; created_by_token_id: string } | undefined; + if (!file) throw new AppError("File not found.", 404, "not_found"); + if (file.created_by_token_id !== tokenId && !isAdmin) { + throw new AppError("This token does not own the file.", 403, "forbidden"); + } + await deleteFileLocked(file.id); + }); +} + async function deleteFileLocked(id: string): Promise { if (!isFileId(id)) throw new AppError("File not found.", 404, "not_found"); const result = db().prepare("DELETE FROM files WHERE id = ?").run(id); diff --git a/test/guides.test.ts b/test/guides.test.ts index 16773ab..6543993 100644 --- a/test/guides.test.ts +++ b/test/guides.test.ts @@ -1,12 +1,19 @@ +import { readdir } from "node:fs/promises"; import { app, assert, assertLightNear, assertRedNear, createToken, + dataDir, + db, multipartFields, + path, + releaseStalledScans, + scannerState, sharp, test, + waitForStalledScanner, } from "./server-fixture.js"; test("records, edits, publishes, and revisions a guide incrementally", async () => { @@ -277,6 +284,63 @@ test("records, edits, publishes, and revisions a guide incrementally", async () assert.doesNotMatch(JSON.stringify(unchanged.json()), /must-not-become-public/); }); +test("rejects a screenshot upload cleanly when its guide is deleted during scanning", async () => { + const owner = createToken("guide delete race"); + const auth = { host: "schaffa.test", authorization: `Bearer ${owner.token}` }; + const created = await app.inject({ + method: "POST", + url: "/api/guides", + headers: { ...auth, "content-type": "application/json" }, + payload: { title: "Delete during upload" }, + }); + const slug = created.json().slug as string; + const screenshot = await sharp({ + create: { width: 64, height: 64, channels: 4, background: "#4b5563" }, + }) + .png() + .toBuffer(); + const body = multipartFields( + { step: JSON.stringify({ title: "Capture", description: "Capture the screen." }) }, + "screenshot", + "capture.png", + "image/png", + screenshot, + ); + + scannerState.mode = "stall"; + try { + const upload = app.inject({ + method: "POST", + url: `/api/guides/${slug}/steps`, + headers: { ...auth, "content-type": body.contentType, "if-match": '"1"' }, + payload: body.payload, + }); + await waitForStalledScanner(); + const deleted = await app.inject({ + method: "DELETE", + url: `/api/guides/${slug}`, + headers: auth, + }); + assert.equal(deleted.statusCode, 204); + scannerState.mode = "ok"; + releaseStalledScans(); + const response = await upload; + assert.equal(response.statusCode, 404); + assert.equal(response.json().error, "not_found"); + } finally { + scannerState.mode = "ok"; + releaseStalledScans(); + } + const leftovers = await readdir(path.join(dataDir, "guides", slug), { recursive: true }).catch( + () => [], + ); + assert.deepEqual( + leftovers.filter((name) => name.endsWith(".webp")), + [], + ); + assert.equal(db().prepare("SELECT 1 FROM guide_steps WHERE title = 'Capture'").get(), undefined); +}); + test("accepts only safe web destinations for guides", async () => { const owner = createToken("guide URL owner"); const headers = { diff --git a/test/publications.test.ts b/test/publications.test.ts index f45442b..98c731a 100644 --- a/test/publications.test.ts +++ b/test/publications.test.ts @@ -592,6 +592,71 @@ test("supports emergency page, version, and file takedown", async () => { ); }); +test("deletes pages, files, and guides through the API only for the owning or admin token", async () => { + const owner = createToken("delete owner"); + const stranger = createToken("delete stranger"); + const remove = (url: string, token?: string) => + app.inject({ + method: "DELETE", + url, + headers: { + host: "schaffa.test", + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + }); + const read = async (url: string) => + (await app.inject({ method: "GET", url, headers: { host: "schaffa.test" } })).statusCode; + + const page = await publishHtmlWithToken("api-delete-page", "

Delete me

", owner.token); + const pageSlug = String(page.json().slug); + assert.equal((await remove(`/api/pages/${pageSlug}`)).statusCode, 401); + const foreignPage = await remove(`/api/pages/${pageSlug}`, stranger.token); + assert.equal(foreignPage.statusCode, 403); + assert.equal(foreignPage.json().error, "forbidden"); + assert.equal(await read(`/p/${pageSlug}`), 200); + const deletedPage = await remove(`/api/pages/${pageSlug}`, owner.token); + assert.equal(deletedPage.statusCode, 204); + assert.equal(deletedPage.body, ""); + assert.equal(await read(`/p/${pageSlug}`), 404); + assert.equal((await remove(`/api/pages/${pageSlug}`, owner.token)).statusCode, 404); + + const body = multipart("file", "remove.txt", "text/plain", "remove me"); + const file = await app.inject({ + method: "POST", + url: "/api/files", + headers: { + host: "schaffa.test", + authorization: `Bearer ${owner.token}`, + "content-type": body.contentType, + }, + payload: body.payload, + }); + await finishPendingScans(); + const filePath = new URL(file.json().publicUrl).pathname; + const filename = filePath.split("/").at(-1) as string; + assert.equal((await remove(`/api/files/${filename}`, stranger.token)).statusCode, 403); + assert.equal(await read(filePath), 200); + assert.equal((await remove(`/api/files/${filename}`, owner.token)).statusCode, 204); + assert.equal(await read(filePath), 404); + assert.equal((await remove(`/api/files/${file.json().id}`, owner.token)).statusCode, 404); + + const guide = await app.inject({ + method: "POST", + url: "/api/guides", + headers: { + host: "schaffa.test", + authorization: `Bearer ${owner.token}`, + "content-type": "application/json", + }, + payload: { title: "Delete me" }, + }); + const guideSlug = String(guide.json().slug); + assert.equal((await remove(`/api/guides/${guideSlug}`, stranger.token)).statusCode, 403); + assert.equal((await remove(`/api/guides/${guideSlug}`, bootstrapToken)).statusCode, 204); + assert.equal(db().prepare("SELECT 1 FROM guides WHERE slug = ?").get(guideSlug), undefined); + assert.equal((await remove(`/api/guides/${guideSlug}`, owner.token)).statusCode, 404); +}); + test("enforces a persistent per-token upload rate limit", async () => { const limited = createToken("rate-limited"); for (let index = 0; index < config.authenticatedUploadsPerHour; index += 1) {