Files
2026-07-13 13:39:12 +08:00

242 lines
9.7 KiB
TypeScript

/**
* Unit tests for src/lib/services/htmlRewriter.ts
*
* Tests HTML rewriting for the embedded-service reverse proxy:
* - <base href> injection
* - Path-absolute URL rewriting in <a>, <link>, <script>, <img>, <form>, <iframe>, <source>
* - Non-rewrite cases: external URLs, protocol-relative, mailto:, javascript:
* - Edge cases: HTML without <head>, empty href, multi-URL srcset
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { rewriteHtml } from "../../../src/lib/services/htmlRewriter.ts";
const PREFIX = "/dashboard/providers/services/9router/embed";
// Helper: assert string contains substring
function assertContains(haystack: string, needle: string, msg?: string): void {
assert.ok(
haystack.includes(needle),
msg ?? `Expected output to contain: ${JSON.stringify(needle)}\nActual: ${haystack}`
);
}
function assertNotContains(haystack: string, needle: string, msg?: string): void {
assert.ok(
!haystack.includes(needle),
msg ?? `Expected output NOT to contain: ${JSON.stringify(needle)}\nActual: ${haystack}`
);
}
// ─── base tag injection ───────────────────────────────────────────────────────
describe("rewriteHtml — <base> injection", () => {
it("inserts <base href> as first child of <head>", () => {
const input = "<html><head></head><body></body></html>";
const out = rewriteHtml(input, PREFIX);
assertContains(out, `<base href="${PREFIX}/">`);
});
it("inserts <base href> before other head children", () => {
const input = "<html><head><title>T</title></head><body></body></html>";
const out = rewriteHtml(input, PREFIX);
const baseIdx = out.indexOf("<base ");
const titleIdx = out.indexOf("<title>");
assert.ok(
baseIdx < titleIdx,
`<base> should appear before <title>; base=${baseIdx}, title=${titleIdx}`
);
});
it("strips trailing slash from prefix before forming base href", () => {
const out = rewriteHtml("<html><head></head><body></body></html>", `${PREFIX}/`);
assertContains(out, `<base href="${PREFIX}/">`);
// Should not have double slash
assertNotContains(out, `<base href="${PREFIX}//">`);
});
it("handles HTML without <head> by creating one", () => {
const out = rewriteHtml("<html><body><p>hello</p></body></html>", PREFIX);
assertContains(out, `<base href="${PREFIX}/">`);
});
it("updates existing <base> href instead of inserting a second one", () => {
const input = '<html><head><base href="/old/"></head><body></body></html>';
const out = rewriteHtml(input, PREFIX);
assertContains(out, `<base href="${PREFIX}/">`);
assertNotContains(out, 'href="/old/"');
// Exactly one <base tag
const matches = out.match(/<base /g);
assert.equal(matches?.length ?? 0, 1, "should have exactly one <base> element");
});
});
// ─── link rewriting ───────────────────────────────────────────────────────────
describe("rewriteHtml — <a href> rewriting", () => {
it("rewrites path-absolute <a href> to prefixed URL", () => {
const input = '<html><head></head><body><a href="/foo">link</a></body></html>';
const out = rewriteHtml(input, PREFIX);
assertContains(out, `href="${PREFIX}/foo"`);
});
it("does NOT rewrite https:// external URL", () => {
const input = '<html><head></head><body><a href="https://example.com/foo">x</a></body></html>';
const out = rewriteHtml(input, PREFIX);
assertContains(out, 'href="https://example.com/foo"');
});
it("does NOT rewrite http:// external URL", () => {
const input = '<html><head></head><body><a href="http://external.com">x</a></body></html>';
const out = rewriteHtml(input, PREFIX);
assertContains(out, 'href="http://external.com"');
});
it("does NOT rewrite protocol-relative URL (//cdn)", () => {
const input = '<html><head></head><body><a href="//cdn.example.com/x">x</a></body></html>';
const out = rewriteHtml(input, PREFIX);
assertContains(out, 'href="//cdn.example.com/x"');
});
it("does NOT rewrite mailto: URL", () => {
const input =
'<html><head></head><body><a href="mailto:admin@example.com">email</a></body></html>';
const out = rewriteHtml(input, PREFIX);
assertContains(out, 'href="mailto:admin@example.com"');
});
it("does NOT rewrite javascript: URL", () => {
const input = '<html><head></head><body><a href="javascript:void(0)">x</a></body></html>';
const out = rewriteHtml(input, PREFIX);
assertContains(out, 'href="javascript:void(0)"');
});
it("does NOT rewrite relative URL (no leading slash)", () => {
const input = '<html><head></head><body><a href="relative/path">x</a></body></html>';
const out = rewriteHtml(input, PREFIX);
assertContains(out, 'href="relative/path"');
});
it("does NOT rewrite anchor-only href", () => {
const input = '<html><head></head><body><a href="#section">x</a></body></html>';
const out = rewriteHtml(input, PREFIX);
assertContains(out, 'href="#section"');
});
});
// ─── other element rewriting ──────────────────────────────────────────────────
describe("rewriteHtml — other element attributes", () => {
it("rewrites <link href='/stylesheet.css'>", () => {
const input =
'<html><head><link rel="stylesheet" href="/styles.css"></head><body></body></html>';
const out = rewriteHtml(input, PREFIX);
assertContains(out, `href="${PREFIX}/styles.css"`);
});
it("rewrites <script src='/app.js'>", () => {
const input = '<html><head></head><body><script src="/app.js"></script></body></html>';
const out = rewriteHtml(input, PREFIX);
assertContains(out, `src="${PREFIX}/app.js"`);
});
it("rewrites <img src='/logo.png'>", () => {
const input = '<html><head></head><body><img src="/logo.png"></body></html>';
const out = rewriteHtml(input, PREFIX);
assertContains(out, `src="${PREFIX}/logo.png"`);
});
it("rewrites <form action='/submit'>", () => {
const input =
'<html><head></head><body><form action="/submit"><button>Go</button></form></body></html>';
const out = rewriteHtml(input, PREFIX);
assertContains(out, `action="${PREFIX}/submit"`);
});
it("rewrites <iframe src='/frame'>", () => {
const input = '<html><head></head><body><iframe src="/frame"></iframe></body></html>';
const out = rewriteHtml(input, PREFIX);
assertContains(out, `src="${PREFIX}/frame"`);
});
it("rewrites <source src='/video.mp4'>", () => {
const input = '<html><head></head><body><source src="/video.mp4"></body></html>';
const out = rewriteHtml(input, PREFIX);
assertContains(out, `src="${PREFIX}/video.mp4"`);
});
it("does NOT rewrite img srcset with commas (multi-URL — v1 limitation)", () => {
const input =
'<html><head></head><body><img srcset="/img.png 1x, /img@2x.png 2x"></body></html>';
const out = rewriteHtml(input, PREFIX);
// Original srcset must be preserved unchanged
assertContains(out, 'srcset="/img.png 1x, /img@2x.png 2x"');
});
});
// ─── non-anchor markup preservation ──────────────────────────────────────────
describe("rewriteHtml — preserves non-affected markup", () => {
it("preserves text content inside elements", () => {
const input = "<html><head></head><body><p>Hello World</p></body></html>";
const out = rewriteHtml(input, PREFIX);
assertContains(out, "Hello World");
});
it("preserves existing non-rewritten attributes", () => {
const input =
'<html><head></head><body><a href="/foo" class="nav" id="link1">x</a></body></html>';
const out = rewriteHtml(input, PREFIX);
assertContains(out, 'class="nav"');
assertContains(out, 'id="link1"');
});
it("preserves data attributes unchanged", () => {
const input =
'<html><head></head><body><div data-url="/foo" data-val="bar"></div></body></html>';
const out = rewriteHtml(input, PREFIX);
// data-url is not in REWRITABLE_ATTRS — stays as-is
assertContains(out, 'data-url="/foo"');
});
it("preserves empty href unchanged", () => {
const input = '<html><head></head><body><a href="">empty</a></body></html>';
const out = rewriteHtml(input, PREFIX);
assertContains(out, 'href=""');
});
});
// ─── edge cases ───────────────────────────────────────────────────────────────
describe("rewriteHtml — edge cases", () => {
it("handles empty HTML string gracefully", () => {
const out = rewriteHtml("", PREFIX);
assertContains(out, `<base href="${PREFIX}/">`);
});
it("handles HTML fragment (no <html> tag) — parse5 auto-completes tree", () => {
const out = rewriteHtml('<a href="/foo">link</a>', PREFIX);
assertContains(out, `<base href="${PREFIX}/">`);
assertContains(out, `href="${PREFIX}/foo"`);
});
it("multiple path-absolute URLs in same document are all rewritten", () => {
const input = [
"<html><head>",
'<link href="/a.css">',
"</head><body>",
'<a href="/page1">1</a>',
'<a href="/page2">2</a>',
'<script src="/app.js"></script>',
"</body></html>",
].join("");
const out = rewriteHtml(input, PREFIX);
assertContains(out, `href="${PREFIX}/a.css"`);
assertContains(out, `href="${PREFIX}/page1"`);
assertContains(out, `href="${PREFIX}/page2"`);
assertContains(out, `src="${PREFIX}/app.js"`);
});
});