Files
classroomio/apps/dashboard/src/lib/utils/proxy-api-request.test.ts
T
2ceafcc6d1 fix(dashboard): stop ERR_CONTENT_DECODING_FAILED on auth/proxy (#779)
* fix(dashboard): prevent ERR_CONTENT_DECODING_FAILED on API proxy

Node fetch decompresses gzip/br upstream bodies while leaving
Content-Encoding set, so browsers fail auth/proxy responses with
net::ERR_CONTENT_DECODING_FAILED. Request identity encoding and strip
encoding headers from proxied responses. Also allow data: fonts in CSP
and skip UserJot on self-hosted.

Co-authored-by: Rotimi Best <[email protected]>

* fix(dashboard): preserve Set-Cookie and use Jest for proxy tests

Copy Set-Cookie via getSetCookie/append so multi-cookie auth responses
are not collapsed on Expires commas. Convert proxy tests to Jest and
add CSP domain normalization coverage.

Co-authored-by: Rotimi Best <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
2026-07-23 17:43:52 +01:00

135 lines
4.7 KiB
TypeScript

import { buildProxiedApiResponse, proxyRequestToApi, shouldForwardToApi } from './proxy-api-request';
describe('shouldForwardToApi', () => {
it('forwards /proxy and /api/auth paths', () => {
expect(shouldForwardToApi('/proxy')).toBe(true);
expect(shouldForwardToApi('/proxy/account')).toBe(true);
expect(shouldForwardToApi('/api/auth')).toBe(true);
expect(shouldForwardToApi('/api/auth/get-session')).toBe(true);
});
it('does not forward unrelated paths', () => {
expect(shouldForwardToApi('/login')).toBe(false);
expect(shouldForwardToApi('/api/csp-report')).toBe(false);
});
});
describe('buildProxiedApiResponse', () => {
it('strips content-encoding and content-length so the browser does not re-decode', async () => {
const upstream = new Response('{"user":null}', {
status: 200,
headers: {
'content-type': 'application/json',
'content-encoding': 'gzip',
'content-length': '13',
'transfer-encoding': 'chunked'
}
});
const proxied = buildProxiedApiResponse(upstream);
expect(proxied.status).toBe(200);
expect(proxied.headers.get('content-type')).toBe('application/json');
expect(proxied.headers.get('content-encoding')).toBeNull();
expect(proxied.headers.get('content-length')).toBeNull();
expect(proxied.headers.get('transfer-encoding')).toBeNull();
expect(await proxied.text()).toBe('{"user":null}');
});
it('preserves multiple Set-Cookie headers without collapsing Expires commas', async () => {
const upstreamHeaders = new Headers({
'content-type': 'application/json',
'content-encoding': 'gzip'
});
upstreamHeaders.append(
'set-cookie',
'better-auth.session_token=abc; Path=/; HttpOnly; Expires=Wed, 21 Oct 2026 07:28:00 GMT'
);
upstreamHeaders.append(
'set-cookie',
'better-auth.csrf_token=xyz; Path=/; HttpOnly; Expires=Thu, 22 Oct 2026 07:28:00 GMT'
);
const proxied = buildProxiedApiResponse(
new Response('{"ok":true}', {
status: 200,
headers: upstreamHeaders
})
);
expect(proxied.headers.getSetCookie()).toEqual([
'better-auth.session_token=abc; Path=/; HttpOnly; Expires=Wed, 21 Oct 2026 07:28:00 GMT',
'better-auth.csrf_token=xyz; Path=/; HttpOnly; Expires=Thu, 22 Oct 2026 07:28:00 GMT'
]);
expect(proxied.headers.get('content-encoding')).toBeNull();
expect(await proxied.text()).toBe('{"ok":true}');
});
});
describe('proxyRequestToApi', () => {
const originalPrivateServerUrl = process.env.PRIVATE_SERVER_URL;
const originalFetch = global.fetch;
afterEach(() => {
if (originalPrivateServerUrl === undefined) {
delete process.env.PRIVATE_SERVER_URL;
} else {
process.env.PRIVATE_SERVER_URL = originalPrivateServerUrl;
}
global.fetch = originalFetch;
jest.restoreAllMocks();
});
it('returns 502 when PRIVATE_SERVER_URL is missing', async () => {
delete process.env.PRIVATE_SERVER_URL;
const response = await proxyRequestToApi(new Request('https://app.example/api/auth/get-session'));
expect(response.status).toBe(502);
expect(await response.text()).toBe('API upstream not configured');
});
it('requests identity encoding and returns a stripped response', async () => {
process.env.PRIVATE_SERVER_URL = 'http://api.internal:3081';
const fetchMock = jest.fn().mockResolvedValue(
new Response('{"session":null}', {
status: 200,
headers: {
'content-type': 'application/json',
'content-encoding': 'gzip',
'content-length': '16'
}
})
);
global.fetch = fetchMock as typeof fetch;
const response = await proxyRequestToApi(
new Request('https://dashboard.example/api/auth/get-session', {
headers: {
accept: 'application/json',
'accept-encoding': 'gzip, deflate, br',
cookie: 'better-auth.session_token=test'
}
})
);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [upstreamUrl, init] = fetchMock.mock.calls[0] as [URL, RequestInit];
expect(String(upstreamUrl)).toBe('http://api.internal:3081/api/auth/get-session');
expect(init.method).toBe('GET');
const upstreamHeaders = new Headers(init.headers);
expect(upstreamHeaders.get('accept-encoding')).toBe('identity');
expect(upstreamHeaders.get('host')).toBe('api.internal:3081');
expect(upstreamHeaders.get('x-forwarded-host')).toBe('dashboard.example');
expect(upstreamHeaders.get('cookie')).toBe('better-auth.session_token=test');
expect(response.status).toBe(200);
expect(response.headers.get('content-encoding')).toBeNull();
expect(response.headers.get('content-length')).toBeNull();
expect(await response.text()).toBe('{"session":null}');
});
});