Files
Mangalord/frontend/src/hooks.server.test.ts
MechaCat02 a8d6da167c bugfix: second-pass audit follow-ups (N1-N4)
Four small follow-ups from the second-pass audit:

- N1: `manga_upload_rolls_back_when_cover_storage_fails` covers the
  manga-side of the transactional rollback path. The chapter case had
  a `FailingStorage` regression test already; this completes the
  symmetric pair. With fail-on-put-index=0, the cover put fails on
  the first call, the transaction aborts, and `SELECT count(*) FROM
  mangas WHERE title = 'Berserk'` is 0.

- N2: The SvelteKit proxy now catches network-layer failures from the
  upstream `fetch` (DNS / connection refused / TLS handshake) and
  returns a 502 with the standard error envelope
  (`code: 'upstream_unavailable'`) instead of letting SvelteKit's
  generic 500 HTML page through. `client.ts` can `.json()` the result
  cleanly so callers see a real ApiError with a meaningful code. The
  underlying cause is logged via `console.error` for the operator.
  Test in hooks.server.test.ts asserts the 502, the JSON envelope, and
  that `resolve` is not called (the proxy short-circuits).

- N3: `GET /api/v1/files/*key` now sets
  `X-Content-Type-Options: nosniff`. The upload-time magic-byte sniff
  is authoritative for what we declare as Content-Type; `nosniff`
  makes the contract explicit so older user-agents can't try to
  re-detect HTML/JS in a polyglot file that survived the sniff. Test
  in api_uploads.rs asserts the header.

- N4: The /bookmarks page used `{#if b.page}` to gate the "— page N"
  display, which falsy-elided a legitimate `page == 0`. Backend now
  rejects `page < 1` for new bookmarks (already shipped in 0.9.4),
  but any pre-0.9.4 row with page=0 still rendered without its
  number. Strengthened to `{#if b.page != null && b.page > 0}`.

Lockstep version bump to 0.10.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:55:53 +02:00

122 lines
4.4 KiB
TypeScript

import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type MockInstance
} from 'vitest';
import { handle } from './hooks.server';
// `BACKEND_URL` is read at module load time, so the values used in the
// asserts below assume the test env didn't set it. `?? 'http://localhost:8080'`
// is the default.
const DEFAULT_BACKEND = 'http://localhost:8080';
function makeEvent(path: string, init?: RequestInit) {
const url = new URL(`http://app.example.com${path}`);
const request = new Request(url, init);
return { url, request } as Parameters<typeof handle>[0]['event'];
}
describe('hooks.server proxy', () => {
let fetchSpy: MockInstance<typeof globalThis.fetch>;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
vi.restoreAllMocks();
});
it('forwards /api/* requests to the backend, preserving status', async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ status: 'ok' }), {
status: 200,
headers: { 'content-type': 'application/json' }
})
);
const resolve = vi.fn();
const event = makeEvent('/api/v1/health');
const resp = await handle({ event, resolve });
expect(resolve).not.toHaveBeenCalled();
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy.mock.calls[0][0]).toBe(`${DEFAULT_BACKEND}/api/v1/health`);
expect(resp.status).toBe(200);
expect(await resp.json()).toEqual({ status: 'ok' });
});
it('passes through the query string', async () => {
fetchSpy.mockResolvedValueOnce(new Response('[]', { status: 200 }));
const resolve = vi.fn();
await handle({
event: makeEvent('/api/v1/mangas?search=narto&limit=10'),
resolve
});
expect(fetchSpy.mock.calls[0][0]).toBe(
`${DEFAULT_BACKEND}/api/v1/mangas?search=narto&limit=10`
);
});
it('strips the host header so the backend sees its own origin', async () => {
fetchSpy.mockResolvedValueOnce(new Response('[]', { status: 200 }));
const resolve = vi.fn();
await handle({
event: makeEvent('/api/v1/health', {
headers: { host: 'app.example.com', cookie: 'mangalord_session=abc' }
}),
resolve
});
const init = fetchSpy.mock.calls[0][1] as RequestInit;
const headers = init.headers as Headers;
expect(headers.get('host')).toBeNull();
// Cookies must still be forwarded — that's how the session reaches axum.
expect(headers.get('cookie')).toBe('mangalord_session=abc');
});
it('forwards request bodies on POST', async () => {
fetchSpy.mockResolvedValueOnce(new Response('{}', { status: 201 }));
const resolve = vi.fn();
await handle({
event: makeEvent('/api/v1/auth/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'alice', password: 'hunter2hunter2' })
}),
resolve
});
const init = fetchSpy.mock.calls[0][1] as RequestInit;
expect(init.method).toBe('POST');
expect(init.body).toBeDefined();
});
it('delegates non-/api requests to SvelteKit', async () => {
const resolve = vi.fn().mockResolvedValue(new Response('page', { status: 200 }));
await handle({ event: makeEvent('/manga/abc'), resolve });
expect(fetchSpy).not.toHaveBeenCalled();
expect(resolve).toHaveBeenCalledTimes(1);
});
it('returns 502 with the standard error envelope when the upstream is unreachable', async () => {
// Silence the console.error the handler emits on failure so
// the test output stays clean.
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
fetchSpy.mockRejectedValueOnce(new TypeError('fetch failed'));
const resolve = vi.fn();
const resp = await handle({ event: makeEvent('/api/v1/health'), resolve });
expect(resolve).not.toHaveBeenCalled();
expect(resp.status).toBe(502);
expect(resp.headers.get('content-type')).toContain('application/json');
const body = await resp.json();
expect(body.error.code).toBe('upstream_unavailable');
expect(errSpy).toHaveBeenCalled();
});
});