Files
Codeman/test/static-cache-headers.test.ts
Codeman maintainer 8a54b331e3 fix(deps): clear production npm advisories, fix sw.js caching regression
Resolves the four advisories that reach the production dependency tree. The
other 16 npm audit reports are devDependencies-only (Remotion, Puppeteer,
postcss, the eslint/tsx toolchain) and never ship to users.

- @fastify/static 9.1.3 -> 10.1.3  GHSA-8pvw-jcv7-9cmj (authz bypass via
  non-canonical URL paths). Covers <=10.1.1, so all of 9.x is affected and
  the fix exists only on the 10.x line.
- find-my-way 9.6.0 -> 9.8.0       GHSA-c96f-x56v-gq3h (HTTP/2 DDoS)
- fast-uri 3.1.2 -> 3.1.5          GHSA-v2hh-gcrm-f6hx (host confusion)
- brace-expansion -> 5.0.9/1.1.18  GHSA-3jxr-9vmj-r5cp (expansion DoS)

The last three are transitive and needed only a lockfile re-resolve, so no
overrides were introduced.

The @fastify/static major changes setHeaders' first argument from a Node
ServerResponse to a FastifyReply. Two consequences:

1. res.setHeader() -> reply.header(). The v9 body throws TypeError from
   inside the plugin on every static request.
2. Precedence flips, silently. The callback used to write to the raw
   response and lose to the route's staged reply headers; it now writes to
   the reply and wins. That gave /sw.js a year of immutable in place of the
   no-cache, no-store its route sets, pinning a service worker on every
   client with no server-side recovery. A route that already set
   Cache-Control now keeps it.

Verified against v9 to confirm the sw.js behaviour is a regression and not
a pre-existing bug.

ws appears in npm audit but production is on 8.21.0, outside the vulnerable
range; the only affected copy is bundled under @remotion/renderer (dev-only,
and remotion is pinned at 4.0.473 because the compositor refuses to start on
a version mismatch).

Adds test/static-cache-headers.test.ts, which drives a real server and covers
a caching contract that had no test at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 23:24:22 +02:00

98 lines
4.4 KiB
TypeScript

/**
* @fileoverview `@fastify/static`'s `setHeaders` callback is what sets Cache-Control
* on every asset Codeman serves, and v10 silently changed its contract.
*
* In v9 the first argument was a Node `ServerResponse`, so the body called
* `res.setHeader(...)`. In v10 it is a `FastifyReply`, which has no `setHeader`, so
* the v9 body throws `TypeError: res.setHeader is not a function` from inside
* `@fastify/static` on EVERY static request. Nothing in the type-checker catches a
* revert (the callback's parameter is inferred), and nothing else in the suite reads
* these headers, so without this file the whole caching contract is untested.
*
* That contract is load-bearing: assets are served `immutable` for a year, and
* `index.html` must revalidate every time or a deploy leaves browsers on stale
* markup (see `cacheBustAssets` in server.ts).
*
* These tests drive a REAL WebServer on purpose. Asserting against an inline
* re-registration of the plugin would keep passing after a revert in server.ts.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { WebServer } from '../src/web/server.js';
const TEST_PORT = 3183;
/**
* Fetch and fully drain the body. Both halves matter for teardown: an unconsumed
* body leaves undici holding the socket, and a pooled keep-alive socket makes
* `server.stop()` wait for it, which times out the afterAll hook.
*/
async function get(url: string): Promise<Response> {
const res = await fetch(url, { headers: { connection: 'close' } });
await res.arrayBuffer();
return res;
}
describe('static asset Cache-Control headers', () => {
let server: WebServer;
let baseUrl: string;
beforeAll(async () => {
server = new WebServer(TEST_PORT, false, true);
await server.start();
baseUrl = `http://localhost:${TEST_PORT}`;
});
afterAll(async () => {
await server.stop();
}, 60000);
it('serves a static asset at all (proves setHeaders did not throw)', async () => {
// The v9-form regression surfaces here first: @fastify/static invokes setHeaders
// while streaming, so a TypeError inside it takes the response down rather than
// merely omitting a header.
const res = await get(`${baseUrl}/app.js`);
expect(res.status).toBe(200);
});
it('marks long-lived assets immutable', async () => {
const res = await get(`${baseUrl}/app.js`);
expect(res.headers.get('cache-control')).toBe('public, max-age=31536000, immutable');
});
it('makes static HTML revalidate so deploys are picked up', async () => {
// ⚠️ upload.html, NOT index.html. `/index.html` has its own explicit route that
// answers from renderIndexHtml() and never reaches @fastify/static, so asserting
// on it passes even with setHeaders fully broken (verified: reverting server.ts
// to the v9 form fails the two /app.js tests and leaves an index.html assertion
// green). upload.html has no route of its own, so it is the only HTML that
// actually exercises the `.html` branch of setHeaders.
const res = await get(`${baseUrl}/upload.html`);
expect(res.status).toBe(200);
expect(res.headers.get('cache-control')).toBe('no-cache');
});
it('lets a route keep the Cache-Control it set, so sw.js stays uncached', async () => {
// Regression guard for the OTHER half of the v10 change. setHeaders runs for
// sendFile() too, and v10 moved it from the raw response onto the reply, which
// flipped precedence: the callback started overriding routes instead of being
// overridden by them. That gave /sw.js `immutable, max-age=31536000` in place of
// the `no-cache, no-store` its route sets — a service worker pinned for a year,
// which is unrecoverable from the server side because clients stop asking.
// Measured against v9 to confirm this is the historical behaviour, not a guess.
const res = await get(`${baseUrl}/sw.js`);
expect(res.status).toBe(200);
expect(res.headers.get('cache-control')).toBe('no-cache, no-store');
});
it('serves the rendered index with no-cache too, via its own route', async () => {
// Different code path from the above, same contract: index.html is generated per
// request (cacheBustAssets stamps ?v= onto every asset ref), so caching it would
// pin browsers to the asset versions current at deploy time.
const res = await get(`${baseUrl}/index.html`);
expect(res.status).toBe(200);
expect(res.headers.get('cache-control')).toContain('no-cache');
});
});