import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; import { handle, shouldBypassProxyTimeout, stripHopByHopHeaders } 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[0]['event']; } /** Like {@link makeEvent} but lets a test inject a specific `signal` * on the underlying Request — the path SvelteKit uses to surface the * client-disconnect signal. Used by the abort-chain tests. */ function makeEventWithSignal(path: string, signal: AbortSignal) { const url = new URL(`http://app.example.com${path}`); const request = new Request(url, { signal }); return { url, request } as Parameters[0]['event']; } describe('hooks.server proxy', () => { let fetchSpy: MockInstance; 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(); }); it('strips every hop-by-hop header listed in RFC 7230 §6.1', async () => { // Defence in depth: axum doesn't emit these, but a future // middleware that did would otherwise leak per-connection // state across the proxy boundary. fetchSpy.mockResolvedValueOnce(new Response('[]', { status: 200 })); const resolve = vi.fn(); await handle({ event: makeEvent('/api/v1/health', { headers: { host: 'app.example.com', 'content-length': '0', connection: 'keep-alive', 'keep-alive': 'timeout=5', 'proxy-authenticate': 'Basic realm=x', 'proxy-authorization': 'Basic xyz', te: 'trailers', trailer: 'Expires', 'transfer-encoding': 'chunked', upgrade: 'websocket', // A non-hop-by-hop header to ensure non-targets // aren't accidentally stripped. 'x-custom': 'pass-through' } }), resolve }); const init = fetchSpy.mock.calls[0][1] as RequestInit; const headers = init.headers as Headers; for (const h of [ 'host', 'content-length', 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade' ]) { expect(headers.get(h), `${h} should be stripped`).toBeNull(); } expect(headers.get('x-custom')).toBe('pass-through'); }); it('strips hop-by-hop headers from the proxied RESPONSE', async () => { // The upstream's connection-scoped headers must not ride along on // the re-streamed response (RFC 7230 §6.1). A normal header passes. fetchSpy.mockResolvedValueOnce( new Response('[]', { status: 200, headers: { connection: 'keep-alive', 'keep-alive': 'timeout=5', 'transfer-encoding': 'chunked', upgrade: 'h2c', 'content-type': 'application/json' } }) ); const resolve = vi.fn(); const resp = await handle({ event: makeEvent('/api/v1/health'), resolve }); for (const h of ['connection', 'keep-alive', 'transfer-encoding', 'upgrade']) { expect(resp.headers.get(h), `${h} should be stripped from response`).toBeNull(); } // A normal response header survives. expect(resp.headers.get('content-type')).toContain('application/json'); }); it('aborts and returns 502 when the upstream stalls past the timeout', async () => { const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); // Simulate an aborted fetch (AbortController.abort() raises a // DOMException with name 'AbortError' on Node's fetch). The // handler should treat it as the same upstream_unavailable // 502 it uses for any other network failure. const abortErr = new DOMException('aborted', 'AbortError'); fetchSpy.mockRejectedValueOnce(abortErr); const resolve = vi.fn(); const resp = await handle({ event: makeEvent('/api/v1/slow'), resolve }); expect(resp.status).toBe(502); const body = await resp.json(); expect(body.error.code).toBe('upstream_unavailable'); expect(errSpy).toHaveBeenCalled(); }); it('attaches an AbortSignal to the upstream fetch so it can time out', async () => { fetchSpy.mockResolvedValueOnce(new Response('[]', { status: 200 })); const resolve = vi.fn(); await handle({ event: makeEvent('/api/v1/health'), resolve }); const init = fetchSpy.mock.calls[0][1] as RequestInit; expect(init.signal).toBeInstanceOf(AbortSignal); // The signal hasn't fired (handler returned in time), but its // presence is the contract this test is pinning. expect(init.signal?.aborted).toBe(false); }); // T2 — client-disconnect abort chain (0.87.9). Closing an admin tab // must propagate into the upstream fetch so the backend's SSE // broadcast::Receiver + spawned task tear down immediately. Before // 0.87.9 the upstream fetch kept running until the proxy wall-clock // timeout fired. it('chains an already-aborted client signal into the upstream signal immediately', async () => { // Capture the signal the proxy hands fetch. let upstream: AbortSignal | undefined; fetchSpy.mockImplementation(async (_url, init) => { upstream = (init as RequestInit).signal ?? undefined; return new Response('[]', { status: 200 }); }); const resolve = vi.fn(); // Build an event with an ALREADY-aborted request signal — the // shape SvelteKit hands us after a client disconnect that has // already settled. const ctrl = new AbortController(); ctrl.abort(); const event = makeEventWithSignal('/api/v1/health', ctrl.signal); await handle({ event, resolve }); expect(upstream).toBeInstanceOf(AbortSignal); expect(upstream?.aborted).toBe(true); }); it('returns 502 when fetch itself rejects with AbortError on an already-aborted signal', async () => { // Production Node fetch rejects synchronously with AbortError if // the signal is already aborted on entry — the prior test mocks // fetch to resolve 200, which leaves the rejection-path // observation uncovered. Mirror Node's real behaviour and // confirm the handler maps the rejection to 502 / upstream_unavailable // (same envelope as any other network failure). const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); fetchSpy.mockRejectedValueOnce(new DOMException('aborted', 'AbortError')); const resolve = vi.fn(); const ctrl = new AbortController(); ctrl.abort(); const event = makeEventWithSignal('/api/v1/health', ctrl.signal); const resp = await handle({ event, resolve }); expect(resp.status).toBe(502); const body = await resp.json(); expect(body.error.code).toBe('upstream_unavailable'); expect(errSpy).toHaveBeenCalled(); }); it('chains a mid-flight client-disconnect into the upstream signal', async () => { // The mid-flight shape: request.signal is live when the handler // starts, then aborts while the upstream fetch is in progress. const clientCtrl = new AbortController(); let upstream: AbortSignal | undefined; fetchSpy.mockImplementation(async (_url, init) => { upstream = (init as RequestInit).signal ?? undefined; // Let the handler's chaining listener attach (it runs on // the next microtask after `handle()` calls fetch), THEN // trip the client signal. `AbortSignal#dispatchEvent` is // synchronous, so the listener runs in-line — no further // tick needed; we observe `upstream.aborted` immediately. await Promise.resolve(); clientCtrl.abort(); return new Response('[]', { status: 200 }); }); const resolve = vi.fn(); const event = makeEventWithSignal('/api/v1/health', clientCtrl.signal); await handle({ event, resolve }); expect(upstream?.aborted).toBe(true); }); }); // ---------------------------------------------------------------------------- // T1 — SSE bypass for the wall-clock proxy timeout. // ---------------------------------------------------------------------------- describe('shouldBypassProxyTimeout', () => { it('returns true for an SSE Accept header', () => { const h = new Headers({ accept: 'text/event-stream' }); expect(shouldBypassProxyTimeout(h)).toBe(true); }); it('matches case-insensitively', () => { const h = new Headers({ accept: 'TEXT/EVENT-STREAM' }); expect(shouldBypassProxyTimeout(h)).toBe(true); }); it('matches when SSE is one of several Accept values', () => { const h = new Headers({ accept: 'text/event-stream, application/json' }); expect(shouldBypassProxyTimeout(h)).toBe(true); }); it('returns false for plain JSON / HTML requests', () => { expect( shouldBypassProxyTimeout(new Headers({ accept: 'application/json' })) ).toBe(false); expect(shouldBypassProxyTimeout(new Headers({ accept: 'text/html' }))).toBe( false ); }); it('returns false when Accept is absent', () => { expect(shouldBypassProxyTimeout(new Headers())).toBe(false); }); }); describe('stripHopByHopHeaders', () => { it('removes hop-by-hop headers and keeps the rest', () => { const src = new Headers({ connection: 'keep-alive', 'transfer-encoding': 'chunked', 'content-type': 'application/json', 'x-request-id': 'abc' }); const out = stripHopByHopHeaders(src); expect(out.get('connection')).toBeNull(); expect(out.get('transfer-encoding')).toBeNull(); expect(out.get('content-type')).toBe('application/json'); expect(out.get('x-request-id')).toBe('abc'); }); it('does not mutate the source headers', () => { const src = new Headers({ connection: 'close' }); stripHopByHopHeaders(src); expect(src.get('connection')).toBe('close'); }); });