All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 46s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
256 lines
8.4 KiB
TypeScript
256 lines
8.4 KiB
TypeScript
// The app-wide middleware: error rendering, CORS, OG tags for crawlers, and
|
|
// static file serving with its cache rules.
|
|
import {
|
|
BASE_URL,
|
|
createUser,
|
|
get,
|
|
insertDump,
|
|
insertPlaylist,
|
|
request,
|
|
test,
|
|
TEST_DIR,
|
|
} from "./helpers.ts";
|
|
import { assertEquals, assertFalse, assertStringIncludes } from "@std/assert";
|
|
import { Application } from "@oak/oak";
|
|
import { errorMiddleware } from "../middleware/error.ts";
|
|
import { ogMiddleware } from "../middleware/og.ts";
|
|
import { routeStaticFilesFrom } from "../lib/static.ts";
|
|
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
|
|
|
async function handle(
|
|
app: Application,
|
|
path: string,
|
|
headers: HeadersInit = {},
|
|
) {
|
|
const res = await app.handle(new Request(`${BASE_URL}${path}`, { headers }));
|
|
if (!res) throw new Error("no response");
|
|
return { res, text: await res.text() };
|
|
}
|
|
|
|
Deno.test("errorMiddleware: APIExceptions keep their status, anything else is a bare 500", async () => {
|
|
const app = new Application();
|
|
app.use(errorMiddleware);
|
|
app.use((ctx) => {
|
|
if (ctx.request.url.pathname === "/api-error") {
|
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Nope");
|
|
}
|
|
throw new Error("secret internals");
|
|
});
|
|
const known = await handle(app, "/api-error");
|
|
assertEquals(known.res.status, 404);
|
|
assertEquals(JSON.parse(known.text), {
|
|
success: false,
|
|
error: { code: "NOT_FOUND", message: "Nope" },
|
|
});
|
|
const unknown = await handle(app, "/boom");
|
|
assertEquals(unknown.res.status, 500);
|
|
assertEquals(JSON.parse(unknown.text).error, {
|
|
code: "SERVER_ERROR",
|
|
message: "Unexpected server error",
|
|
});
|
|
});
|
|
|
|
test("CORS: allowed origins get the headers, others don't", async () => {
|
|
const allowed = await get("/api/categories", {
|
|
headers: { Origin: "http://allowed.test" },
|
|
});
|
|
assertEquals(
|
|
allowed.headers.get("access-control-allow-origin"),
|
|
"http://allowed.test",
|
|
);
|
|
// The public URL is always allowed.
|
|
const self = await get("/api/categories", { headers: { Origin: BASE_URL } });
|
|
assertEquals(self.headers.get("access-control-allow-origin"), BASE_URL);
|
|
const evil = await get("/api/categories", {
|
|
headers: { Origin: "http://evil.test" },
|
|
});
|
|
assertEquals(evil.status, 200);
|
|
assertEquals(evil.headers.get("access-control-allow-origin"), null);
|
|
|
|
const preflight = await request("OPTIONS", "/api/dumps", {
|
|
headers: {
|
|
Origin: "http://allowed.test",
|
|
"Access-Control-Request-Method": "POST",
|
|
"Access-Control-Request-Headers": "authorization,content-type",
|
|
},
|
|
});
|
|
assertEquals(preflight.status, 204);
|
|
assertEquals(
|
|
preflight.headers.get("access-control-allow-origin"),
|
|
"http://allowed.test",
|
|
);
|
|
});
|
|
|
|
test("GET /ws: refuses other origins, and plain requests from allowed ones", async () => {
|
|
assertEquals(
|
|
(await get("/ws", { headers: { Origin: "http://evil.test" } })).status,
|
|
403,
|
|
);
|
|
assertEquals((await get("/ws")).status, 403);
|
|
assertEquals(
|
|
(await get("/ws", { headers: { Origin: "http://allowed.test" } })).status,
|
|
426,
|
|
);
|
|
});
|
|
|
|
function ogApp() {
|
|
const app = new Application();
|
|
app.use(ogMiddleware);
|
|
app.use((ctx) => {
|
|
ctx.response.body = "fallthrough";
|
|
});
|
|
return app;
|
|
}
|
|
|
|
test("ogMiddleware: dump pages get escaped OG tags", async () => {
|
|
const user = await createUser();
|
|
const dump = insertDump({
|
|
userId: user.id,
|
|
title: `Tom & "Jerry" <script>`,
|
|
comment: "A comment",
|
|
richContent: {
|
|
type: "generic",
|
|
url: "x",
|
|
thumbnailUrl: "https://img.test/a.jpg",
|
|
},
|
|
});
|
|
const { res, text } = await handle(ogApp(), `/dumps/${dump.slug}`);
|
|
assertEquals(res.headers.get("content-type"), "text/html; charset=utf-8");
|
|
assertStringIncludes(
|
|
text,
|
|
`<meta property="og:title" content="Tom & "Jerry" <script>" />`,
|
|
);
|
|
assertStringIncludes(
|
|
text,
|
|
`<title>Tom & "Jerry" <script></title>`,
|
|
);
|
|
assertStringIncludes(
|
|
text,
|
|
`<meta property="og:description" content="A comment" />`,
|
|
);
|
|
assertStringIncludes(
|
|
text,
|
|
`<meta property="og:image" content="https://img.test/a.jpg" />`,
|
|
);
|
|
assertStringIncludes(text, `content="summary_large_image"`);
|
|
assertStringIncludes(
|
|
text,
|
|
`<meta property="og:url" content="${BASE_URL}/dumps/${dump.slug}" />`,
|
|
);
|
|
// The index's own title is replaced, not duplicated.
|
|
assertEquals(text.match(/<title>/g)?.length, 1);
|
|
assertFalse(text.includes("__SITE_NAME__"));
|
|
});
|
|
|
|
test("ogMiddleware: no artwork falls back to the site icon as a small card", async () => {
|
|
const user = await createUser();
|
|
const dump = insertDump({ userId: user.id, title: "Plain" });
|
|
const { text } = await handle(ogApp(), `/dumps/${dump.id}`);
|
|
assertStringIncludes(text, `content="${BASE_URL}/apple-touch-icon.png"`);
|
|
assertStringIncludes(text, `<meta name="twitter:card" content="summary" />`);
|
|
const file = insertDump({
|
|
userId: user.id,
|
|
fileName: "a.png",
|
|
fileMime: "image/png",
|
|
});
|
|
assertStringIncludes(
|
|
(await handle(ogApp(), `/dumps/${file.id}`)).text,
|
|
`content="${BASE_URL}/api/files/${file.id}"`,
|
|
);
|
|
});
|
|
|
|
test("ogMiddleware: users and public playlists; private things fall through", async () => {
|
|
const user = await createUser();
|
|
const app = ogApp();
|
|
assertStringIncludes(
|
|
(await handle(app, `/users/${user.username}/~/votes`)).text,
|
|
`<meta property="og:title" content="${user.username}" />`,
|
|
);
|
|
const pub = insertPlaylist({ userId: user.id, title: "Open list" });
|
|
assertStringIncludes(
|
|
(await handle(app, `/playlists/${pub.slug}`)).text,
|
|
"Open list",
|
|
);
|
|
const priv = insertPlaylist({
|
|
userId: user.id,
|
|
title: "Closed",
|
|
isPublic: false,
|
|
});
|
|
assertEquals(
|
|
(await handle(app, `/playlists/${priv.slug}`)).text,
|
|
"fallthrough",
|
|
);
|
|
const secret = insertDump({
|
|
userId: user.id,
|
|
title: "Secret",
|
|
isPrivate: true,
|
|
});
|
|
assertEquals((await handle(app, `/dumps/${secret.id}`)).text, "fallthrough");
|
|
assertEquals((await handle(app, `/dumps/nope`)).text, "fallthrough");
|
|
assertEquals(
|
|
(await handle(app, `/api/dumps/${secret.id}`)).text,
|
|
"fallthrough",
|
|
);
|
|
assertEquals((await handle(app, `/favicon.ico`)).text, "fallthrough");
|
|
});
|
|
|
|
Deno.test("routeStaticFilesFrom: index, SPA fallback, cache rules, precompressed files", async () => {
|
|
const root = `${TEST_DIR}/static-${crypto.randomUUID()}`;
|
|
await Deno.mkdir(`${root}/assets`, { recursive: true });
|
|
await Deno.writeTextFile(
|
|
`${root}/index.html`,
|
|
`<html><head><title>__SITE_NAME__</title></head><body>__BANDCAMP_PLAYER__</body></html>`,
|
|
);
|
|
const js = "console.log('app');".repeat(50);
|
|
await Deno.writeTextFile(`${root}/assets/app-abc123.js`, js);
|
|
const br = new Uint8Array([1, 2, 3]);
|
|
await Deno.writeFile(`${root}/assets/app-abc123.js.br`, br);
|
|
await Deno.writeTextFile(`${root}/robots.txt`, "User-agent: *");
|
|
|
|
const app = new Application();
|
|
app.use(routeStaticFilesFrom([root]));
|
|
|
|
const index = await handle(app, "/");
|
|
assertEquals(index.res.headers.get("cache-control"), "no-cache");
|
|
assertEquals(
|
|
index.text,
|
|
"<html><head><title>gerbeur</title></head><body>embed</body></html>",
|
|
);
|
|
|
|
const spa = await handle(app, "/users/someone");
|
|
assertEquals(spa.res.status, 200);
|
|
assertEquals(spa.text, index.text);
|
|
|
|
// No Accept-Encoding header: the plain file, not the .br sibling.
|
|
const asset = await handle(app, "/assets/app-abc123.js");
|
|
assertEquals(asset.text, js);
|
|
assertEquals(asset.res.headers.get("content-encoding"), null);
|
|
assertStringIncludes(asset.res.headers.get("cache-control")!, "immutable");
|
|
assertStringIncludes(
|
|
asset.res.headers.get("cache-control")!,
|
|
"max-age=31536000",
|
|
);
|
|
assertStringIncludes(asset.res.headers.get("vary")!, "Accept-Encoding");
|
|
|
|
const compressed = await handle(app, "/assets/app-abc123.js", {
|
|
"Accept-Encoding": "br, gzip",
|
|
});
|
|
assertEquals(compressed.res.headers.get("content-encoding"), "br");
|
|
assertEquals(compressed.text, "\x01\x02\x03"); // the .br file's bytes
|
|
|
|
const plain = await handle(app, "/robots.txt");
|
|
assertEquals(plain.text, "User-agent: *");
|
|
assertFalse(
|
|
(plain.res.headers.get("cache-control") ?? "").includes("immutable"),
|
|
);
|
|
|
|
// A chunk from an older build 404s rather than being answered with the index.
|
|
const stale = await handle(app, "/assets/app-old999.js");
|
|
assertEquals(stale.res.status, 404);
|
|
assertFalse(stale.text.includes("<html>"));
|
|
assertEquals(
|
|
(await handle(app, "/emojibase/17.0.0/missing.json")).res.status,
|
|
404,
|
|
);
|
|
});
|