wren/lost-property Public Public: anyone can read this; nobody can change it.
workbenchthis machine
Files wren/1-build-lost-property
| 1 | import { expect, test } from "bun:test"; |
| 2 | import { mkdtempSync } from "node:fs"; |
| 3 | import { tmpdir } from "node:os"; |
| 4 | import { join } from "node:path"; |
| 5 | |
| 6 | process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-")); |
| 7 | process.env.PORT = "0"; |
| 8 | const { server } = await import("./server.ts"); |
| 9 | const base = `http://${server.hostname}:${server.port}`; |
| 10 | |
| 11 | async function accession(description: string): Promise<string> { |
| 12 | const form = new FormData(); |
| 13 | form.set("description", description); |
| 14 | const posted = await fetch(base + "/items", { method: "POST", body: form, redirect: "manual" }); |
| 15 | expect(posted.status).toBe(303); |
| 16 | return posted.headers.get("location")!; |
| 17 | } |
| 18 | |
| 19 | test("serves the entrance", async () => { |
| 20 | const response = await fetch(base + "/"); |
| 21 | expect(response.status).toBe(200); |
| 22 | expect(await response.text()).toContain("<h1>lost-property</h1>"); |
| 23 | }); |
| 24 | |
| 25 | test("accessions a lost item with a catalogue number and a vitrine", async () => { |
| 26 | const location = await accession("a single brown leather glove, left hand"); |
| 27 | expect(location).toMatch(/^\/items\/\d+$/); |
| 28 | const page = await (await fetch(base + location)).text(); |
| 29 | expect(page).toContain("a single brown leather glove"); |
| 30 | expect(page).toMatch(/LP \d{4}\.\d{4}/); |
| 31 | expect(page).toContain("Outerwear and Umbrellas"); |
| 32 | }); |
| 33 | |
| 34 | test("lists an accessioned item on the entrance", async () => { |
| 35 | await accession("a set of house keys on a red fob"); |
| 36 | const page = await (await fetch(base + "/")).text(); |
| 37 | expect(page).toContain("a set of house keys on a red fob"); |
| 38 | }); |
| 39 | |
| 40 | test("an empty description is not accessioned", async () => { |
| 41 | const form = new FormData(); |
| 42 | form.set("description", " "); |
| 43 | const posted = await fetch(base + "/items", { method: "POST", body: form, redirect: "manual" }); |
| 44 | expect(posted.status).toBe(303); |
| 45 | expect(posted.headers.get("location")).toBe("/"); |
| 46 | }); |
| 47 | |
| 48 | test("a case that does not exist answers 404", async () => { |
| 49 | const response = await fetch(base + "/items/999999"); |
| 50 | expect(response.status).toBe(404); |
| 51 | }); |
| 52 | |
| 53 | test("a visitor leaves a note saying they saw one like it", async () => { |
| 54 | const location = await accession("reading glasses, tortoiseshell frame"); |
| 55 | const form = new FormData(); |
| 56 | form.set("kind", "sighting"); |
| 57 | form.set("body", "Saw one just like this at the market on Tuesday."); |
| 58 | const posted = await fetch(base + location + "/notes", { method: "POST", body: form, redirect: "manual" }); |
| 59 | expect(posted.status).toBe(303); |
| 60 | const page = await (await fetch(base + location)).text(); |
| 61 | expect(page).toContain("Saw one like it"); |
| 62 | expect(page).toContain("Saw one just like this at the market on Tuesday."); |
| 63 | }); |
| 64 | |
| 65 | test("a visitor leaves a note saying they have the other half", async () => { |
| 66 | const location = await accession("a single earring, gold hoop"); |
| 67 | const form = new FormData(); |
| 68 | form.set("kind", "match"); |
| 69 | form.set("body", "I have the matching earring, email me."); |
| 70 | await fetch(base + location + "/notes", { method: "POST", body: form, redirect: "manual" }); |
| 71 | const page = await (await fetch(base + location)).text(); |
| 72 | expect(page).toContain("Has the other half"); |
| 73 | expect(page).toContain("I have the matching earring, email me."); |
| 74 | }); |
| 75 | |
| 76 | test("a note needs a valid kind", async () => { |
| 77 | const location = await accession("a paperback book, dog-eared"); |
| 78 | const form = new FormData(); |
| 79 | form.set("kind", "not-a-real-kind"); |
| 80 | form.set("body", "hello"); |
| 81 | const posted = await fetch(base + location + "/notes", { method: "POST", body: form, redirect: "manual" }); |
| 82 | expect(posted.status).toBe(400); |
| 83 | }); |
| 84 | |
| 85 | test("a note cannot be left on a case that does not exist", async () => { |
| 86 | const form = new FormData(); |
| 87 | form.set("kind", "sighting"); |
| 88 | form.set("body", "hello"); |
| 89 | const posted = await fetch(base + "/items/999999/notes", { method: "POST", body: form, redirect: "manual" }); |
| 90 | expect(posted.status).toBe(404); |
| 91 | }); |
| 92 | |
| 93 | test("stands on the security floor", async () => { |
| 94 | const response = await fetch(base + "/"); |
| 95 | expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); |
| 96 | expect(response.headers.get("x-content-type-options")).toBe("nosniff"); |
| 97 | }); |
| 98 | |
| 99 | test("a bug answers with the floor, not the runtime's page", async () => { |
| 100 | // A percent-escape that is not one makes the search params throw. |
| 101 | const response = await fetch(base + "/items", { |
| 102 | method: "POST", |
| 103 | headers: { "content-type": "application/x-www-form-urlencoded" }, |
| 104 | body: "description=%E0%A4%A", |
| 105 | }); |
| 106 | expect(response.status).toBe(200); |
| 107 | expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); |
| 108 | }); |
| 109 | |
| 110 | test("a body is capped by the bytes read, whatever it claims", async () => { |
| 111 | // The cap counts bytes as they arrive and never reads Content-Length, so |
| 112 | // a chunked body with no length is refused the same way this one is. |
| 113 | const response = await fetch(base + "/items", { |
| 114 | method: "POST", |
| 115 | headers: { "content-type": "application/x-www-form-urlencoded" }, |
| 116 | body: "description=" + "x".repeat(1024 * 1024), |
| 117 | }); |
| 118 | expect(response.status).toBe(413); |
| 119 | }); |
| 120 | |
| 121 | test("HEAD sees the app up", async () => { |
| 122 | const response = await server.fetch(new Request(base + "/", { method: "HEAD" })); |
| 123 | expect(response.status).toBe(200); |
| 124 | expect(await response.text()).toBe(""); |
| 125 | }); |
| 126 | |
| 127 | test("HEAD on a case answers like GET without a body", async () => { |
| 128 | const location = await accession("a stuffed teddy bear, missing an eye"); |
| 129 | const response = await server.fetch(new Request(base + location, { method: "HEAD" })); |
| 130 | expect(response.status).toBe(200); |
| 131 | expect(await response.text()).toBe(""); |
| 132 | }); |
| 133 | |
| 134 | test("refuses a cross-site post", async () => { |
| 135 | const form = new FormData(); |
| 136 | form.set("description", "from another site"); |
| 137 | const posted = await fetch(base + "/items", { |
| 138 | method: "POST", |
| 139 | body: form, |
| 140 | redirect: "manual", |
| 141 | headers: { origin: "https://evil.example", "sec-fetch-site": "cross-site" }, |
| 142 | }); |
| 143 | expect(posted.status).toBe(403); |
| 144 | expect(await (await fetch(base + "/")).text()).not.toContain("from another site"); |
| 145 | }); |
| 146 | |
| 147 | test("escapes a description that carries markup", async () => { |
| 148 | const location = await accession("<script>alert(1)</script>"); |
| 149 | const page = await (await fetch(base + location)).text(); |
| 150 | expect(page).not.toContain("<script>alert(1)</script>"); |
| 151 | expect(page).toContain("<script>"); |
| 152 | }); |