fix(imaging): support .hab assets, cache avatars/badges, harden routes
CI / check (push) Successful in 37s
CI / tests-integration (push) Successful in 2m2s
CI / tests-unit (push) Successful in 2m9s
CI / tests-ui (push) Successful in 2m44s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m12s

The hotel moved from .nitro to .hab bundles, so the imager must treat .hab
like .nitro and the public avatar endpoints must accept the figure strings
the client sends (including extra trailing segments such as `.0`, which the
old FIGURE_RE rejected with a 400).

- imager-upstream: relax FIGURE_RE, extend parseAvatarQuery test coverage
- api/imaging/avatar + /imaging: shared validation, placeholder and ETag/304
- api/imaging/badge: single-flight upstream fetch and conditional responses
- imager-http/imager-png: strong ETag helpers and APNG passthrough guard
- csp + next/image: unoptimized external images, tightened CSP
- deps: override postcss-selector-parser to ^7.1.6
- also fold in the pending drizzle draft snapshot and stray test cleanup
This commit is contained in:
openhands committed 2026-10-14 20:40:50 +02:00
1 parent 56676effc3
commit f3407ebfdf
29 files changed
+15134 -93

No files matched your search

+107
View File
@@ -0,0 +1,107 @@
// @ts-nocheck
import { rm } from "node:fs/promises";
import { NextRequest } from "next/server";
import { afterAll, afterEach, beforeEach, expect, it, vi } from "vitest";
import { GET } from "../app/api/imaging/badge/route";
const SCRATCH_ROOT = `${process.cwd()}/storage/imaging/test-badge-route-${process.pid}`;
beforeEach(async () => {
// Cold disk cache for every test so the candidate walk actually runs.
await rm(SCRATCH_ROOT, { recursive: true, force: true });
vi.stubEnv("IMAGING_CACHE_ROOT", SCRATCH_ROOT);
// A configured CDN short-circuits the database lookup for badge_base_url.
vi.stubEnv("BADGE_URL", "https://badges.test/album1584");
globalThis.imagingBadgeFetches?.clear?.();
});
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
afterAll(async () => {
await rm(SCRATCH_ROOT, { recursive: true, force: true });
});
function gifResponse() {
return new Response(new Uint8Array([1, 2, 3]), {
headers: { "content-type": "image/gif" },
});
}
it("shares a single upstream fetch across concurrent cold-cache requests", async () => {
let calls = 0;
vi.stubGlobal(
"fetch",
vi.fn(async () => {
calls += 1;
await new Promise((resolve) => setTimeout(resolve, 20));
return gifResponse();
}),
);
const request = () =>
new NextRequest("https://hotel.test/api/imaging/badge?code=ADM");
const [a, b] = await Promise.all([GET(request()), GET(request())]);
expect(a.status).toBe(200);
expect(b.status).toBe(200);
expect(a.headers.get("content-type")).toBe("image/gif");
expect(calls).toBe(1);
});
it("serves a previously fetched badge from disk without re-fetching", async () => {
const fetcher = vi.fn(async () => gifResponse());
vi.stubGlobal("fetch", fetcher);
const request = new NextRequest(
"https://hotel.test/api/imaging/badge?code=HABBO",
);
const first = await GET(request);
expect(first.status).toBe(200);
const callsAfterFirst = fetcher.mock.calls.length;
expect(callsAfterFirst).toBeGreaterThan(0);
const second = await GET(request);
expect(second.status).toBe(200);
expect(second.headers.get("x-imager-source")).toBe("cache");
expect(fetcher.mock.calls.length).toBe(callsAfterFirst);
});
it("revalidates a cached badge with a 304 when the client already has it", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => gifResponse()),
);
const url = "https://hotel.test/api/imaging/badge?code=ETAG";
const first = await GET(new NextRequest(url));
const etag = first.headers.get("etag");
expect(etag).toBeTruthy();
const second = await GET(
new NextRequest(url, { headers: { "if-none-match": etag } }),
);
expect(second.status).toBe(304);
expect(second.headers.get("etag")).toBe(etag);
});
it("serves a placeholder when every candidate is unavailable", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("nope", { status: 404 })),
);
const result = await GET(
new NextRequest("https://hotel.test/api/imaging/badge?code=GONE"),
);
expect(result.status).toBe(200);
expect(result.headers.get("content-type")).toBe("image/svg+xml");
expect(result.headers.get("x-imager-source")).toBe("unavailable");
});
it("rejects malformed badge codes without contacting any candidate", async () => {
const fetcher = vi.fn();
vi.stubGlobal("fetch", fetcher);
const result = await GET(
new NextRequest("https://hotel.test/api/imaging/badge?code=../bad"),
);
expect(result.status).toBe(400);
expect(fetcher).not.toHaveBeenCalled();
});