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.
128 lines
6.0 KiB
TypeScript
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');
|
|
});
|
|
});
|