Files
gitea/sidecar/rewrite.test.ts
T
officer 61d43cd5f6 match the appSubUrl shape Gitea actually emits
The regex expected JSON — "appSubUrl":"…" — and Gitea emits a JavaScript object
literal: unquoted key, single quotes, `appSubUrl: ''`. So the single
highest-value rewrite in this file silently did nothing on every real page,
while every attribute around it rewrote perfectly.

The test used the shape I guessed rather than the shape Gitea produces, which
is why it passed. Now covers both, with the real form taken verbatim from a
live response, and asserts the quote style survives — swapping it would stop
the literal parsing.
2026-08-19 09:14:45 +00:00

128 lines
6.0 KiB
TypeScript

import { describe, expect, test } from 'bun:test';
import { rewriteHtml, rewriteLocation, rewriteSetCookie, rewriteAttributes, rewriteAppSubUrl } from './rewrite';
const BASE = '/api/gitea/_web';
const ORIGINS = ['http://localhost:3000', 'https://gitea.officer.dev'];
// Every test here is a way the frame breaks in a manner that looks like Gitea being broken rather than
// the proxy. That is the whole difficulty: the page renders, and then a click, a login or an asset goes
// somewhere Officer knows nothing about.
describe('attributes', () => {
test('root-absolute href, src and action are prefixed', () => {
const html = '<a href="/user/login"><img src="/assets/img/logo.svg"><form action="/repo/new">';
const out = rewriteAttributes(html, BASE);
expect(out).toContain(`href="${BASE}/user/login"`);
expect(out).toContain(`src="${BASE}/assets/img/logo.svg"`);
expect(out).toContain(`action="${BASE}/repo/new"`);
});
test('a relative href is already correct and must not be touched', () => {
expect(rewriteAttributes('<a href="settings">', BASE)).toBe('<a href="settings">');
});
test('mailto, data and protocol-relative are left alone', () => {
const html = '<a href="mailto:a@b.c"><img src="data:image/png;base64,AAA"><script src="//cdn.example/x.js">';
expect(rewriteAttributes(html, BASE)).toBe(html);
});
test('`..` is collapsed before prefixing, or the browser normalises the prefix back off', () => {
expect(rewriteAttributes('<img src="/../../assets/x.png">', BASE)).toContain(`src="${BASE}/assets/x.png"`);
});
});
describe('appSubUrl — the line that makes Gitea correct itself', () => {
test('is filled in, so Gitea client JS builds prefixed URLs natively', () => {
const html = '<script>window.config = {"appUrl":"http://localhost:3000/","appSubUrl":""};</script>';
expect(rewriteAppSubUrl(html, BASE)).toContain(`"appSubUrl":"${BASE}"`);
});
test('an existing subpath is replaced, not appended to', () => {
expect(rewriteAppSubUrl('{"appSubUrl":"/git"}', BASE)).toBe(`{"appSubUrl":"${BASE}"}`);
});
// The shape Gitea ACTUALLY emits — a JavaScript object literal, unquoted key, single quotes. The first
// version of this only matched the JSON form, so it silently did nothing on every real page while the
// attributes around it rewrote perfectly. Taken verbatim from a live response.
test('the JS object-literal form Gitea really emits', () => {
const real = "window.config = {\n appUrl: 'https://gitea.officer.dev/',\n appSubUrl: '',\n};";
const out = rewriteAppSubUrl(real, BASE);
expect(out).toContain(`appSubUrl: '${BASE}'`);
// The quote style is preserved, or the literal stops parsing.
expect(out).not.toContain(`appSubUrl: "${BASE}"`);
});
});
describe('absolute URLs built from ROOT_URL', () => {
test('the upstream origin becomes the proxy base — these escape to a third-party origin otherwise', () => {
const out = rewriteHtml('<a href="https://gitea.officer.dev/owner/repo">', BASE, ORIGINS);
expect(out).toContain(`href="${BASE}/owner/repo"`);
expect(out).not.toContain('gitea.officer.dev');
});
test('the loopback origin is rewritten too, since that is what the sidecar actually fetches', () => {
expect(rewriteHtml('<a href="http://localhost:3000/explore">', BASE, ORIGINS)).toContain(`${BASE}/explore`);
});
test('an unrelated host is left alone', () => {
const html = '<a href="https://github.com/some/repo">';
expect(rewriteHtml(html, BASE, ORIGINS)).toBe(html);
});
});
describe('redirects — the most visible failure, because the frame just leaves', () => {
test('a root-absolute Location is prefixed', () => {
expect(rewriteLocation('/user/login', BASE, ORIGINS)).toBe(`${BASE}/user/login`);
});
test('a full URL on the upstream origin is brought back inside', () => {
expect(rewriteLocation('https://gitea.officer.dev/dashboard', BASE, ORIGINS)).toBe(`${BASE}/dashboard`);
});
test('an already-prefixed Location is not prefixed twice', () => {
expect(rewriteLocation(`${BASE}/dashboard`, BASE, ORIGINS)).toBe(`${BASE}/dashboard`);
});
test('an external redirect is left alone — OAuth goes where it goes', () => {
expect(rewriteLocation('https://accounts.google.com/o/oauth2/auth', BASE, ORIGINS)).toBe(
'https://accounts.google.com/o/oauth2/auth',
);
});
});
describe('the origin Gitea declares about itself', () => {
test('is discovered from window.config, so ROOT_URL needs no second setting', () => {
// The sidecar only knows where it FETCHES from — usually loopback. The public origin lives in the
// page, and an unrewritten absolute link on it navigates the frame off to a third-party origin where
// the session cookie is dropped.
const html = '<script>window.config={"appUrl":"https://git.example.com/","appSubUrl":""}</script>' +
'<a href="https://git.example.com/owner/repo">go</a>';
const out = rewriteHtml(html, BASE, ['http://localhost:3000']);
expect(out).toContain(`href="${BASE}/owner/repo"`);
expect(out).not.toContain('git.example.com/owner');
});
test('a page without one still rewrites what it was told about', () => {
expect(rewriteHtml('<a href="http://localhost:3000/x">', BASE, ['http://localhost:3000'])).toContain(
`href="${BASE}/x"`,
);
});
});
describe('cookies', () => {
test('Path is narrowed to the frame, so Gitea session cookies stay out of the platform API', () => {
expect(rewriteSetCookie('i_like_gitea=abc; Path=/; HttpOnly', BASE)).toBe(
`i_like_gitea=abc; Path=${BASE}; HttpOnly`,
);
});
test('a cookie with no Path gets one', () => {
expect(rewriteSetCookie('csrf=xyz; HttpOnly', BASE)).toBe(`csrf=xyz; HttpOnly; Path=${BASE}`);
});
test('Secure is left exactly as Gitea set it — adding it breaks http, removing it weakens https', () => {
expect(rewriteSetCookie('s=1; Path=/; Secure; SameSite=Lax', BASE)).toContain('Secure');
expect(rewriteSetCookie('s=1; Path=/', BASE)).not.toContain('Secure');
});
});