import test from "node:test"; import assert from "node:assert/strict"; import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat, utimes, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { readSitemapSyncConfig, syncSitemaps, type SitemapSyncConfig, type SitemapTransfer } from "../scripts/sync-sitemap"; import nextConfig from "../next.config"; const index = (names: string[]) => Buffer.from(`\ufeff\r\n\r\n${names.map((name) => `https://old.example/sitemaps/${name}2026-10-12`).join("\r\n")}\r\n\r\n`); const shard = Buffer.from(`\r\nhttps://old.example/play/original/?a=1&b=22026-10-120.80\r\n`); async function fixture() { const root = await mkdtemp(join(tmpdir(), "huakui-sitemap-sync-test-")); const remote = join(root, "remote"), destination = join(root, "local"); await mkdir(remote); const requests: string[] = []; const config: SitemapSyncConfig = { host: "readonly@example.test", port: 22889, sourceDirectory: "/backend/h5/template/pages/sitemaps", destinationDirectory: destination }; const put = async (name: string, bytes: string | Buffer, modification = new Date("2026-10-12T03:04:05Z")) => { const file = join(remote, name); await writeFile(file, bytes); await utimes(file, modification, modification); return file; }; const transfer: SitemapTransfer = async (name, target) => { requests.push(name); const source = join(remote, name), info = await stat(source); await copyFile(source, target); await utimes(target, info.atime, info.mtime); }; return { root, remote, destination, requests, config, put, transfer, close: () => rm(root, { recursive: true, force: true }) }; } test("sync configuration uses development, local overrides and then process values", async () => { const f = await fixture(); try { await mkdir(join(f.root, "config")); await writeFile(join(f.root, "config/development.env"), "SITEMAP_SYNC_HOST=readonly@base.test\nSITEMAP_SYNC_PORT=22889\nSITEMAP_SYNC_DIR=/base/sitemaps\nSITEMAP_DIR=./template/pages/sitemaps\n"); await writeFile(join(f.root, "config/development.local.env"), "SITEMAP_SYNC_HOST=readonly@local.test\nSITEMAP_SYNC_DIR=/local/sitemaps\n"); await writeFile(join(f.root, "config/production.env"), "SITEMAP_SYNC_HOST=wrong@production.test\n"); assert.deepEqual(await readSitemapSyncConfig(f.root, { APP_ENV: "development", SITEMAP_SYNC_HOST: undefined }), { host: "readonly@local.test", port: 22889, sourceDirectory: "/local/sitemaps", destinationDirectory: join(f.root, "template/pages/sitemaps"), }); assert.deepEqual(await readSitemapSyncConfig(f.root, { SITEMAP_SYNC_HOST: "readonly@process.test", SITEMAP_SYNC_PORT: "2222", SITEMAP_SYNC_DIR: "/process/sitemaps", SITEMAP_DIR: "./snapshot" }), { host: "readonly@process.test", port: 2222, sourceDirectory: "/process/sitemaps", destinationDirectory: join(f.root, "snapshot"), }); for (const APP_ENV of ["test", "production"]) await assert.rejects(readSitemapSyncConfig(f.root, { APP_ENV, SITEMAP_DIR: "/backend/generated" }), /only supports APP_ENV=development/); } finally { await f.close(); } }); test("SSH configuration rejects unsafe parameters before transfer or local writes", async () => { const f = await fixture(); try { const cases: Partial[] = [ { host: "-oProxyCommand=evil" }, { host: "user@host;echo" }, { host: "user@$(command)" }, { port: 0 }, { port: 65536 }, { port: 22.5 }, { sourceDirectory: "relative/path" }, { sourceDirectory: "/backend/../outside" }, { sourceDirectory: "/backend/$(command)" }, { sourceDirectory: "/backend/space here" }, { destinationDirectory: "/" }, { destinationDirectory: "bad\npath" }, ]; for (const values of cases) await assert.rejects(syncSitemaps({ ...f.config, ...values }, f.transfer)); assert.deepEqual(f.requests, []); await assert.rejects(stat(f.destination), { code: "ENOENT" }); } finally { await f.close(); } }); test("sync reads only indexed shards and preserves bytes, original URLs, mtimes and unrelated local files", async () => { const f = await fixture(); try { const names = ["sitemap-pages.xml", "sitemap-playletdetail.xml", "sitemap-seriesplay.xml"]; await f.put("sitemap.xml", index([...names, names[0]!])); for (const [number, name] of names.entries()) await f.put(name, shard, new Date(Date.UTC(2026, 9, 12, 3, 4, number + 1))); await f.put("sitemap-not-indexed.xml", "do not copy remote directory"); await mkdir(f.destination); await writeFile(join(f.destination, "README.md"), "keep local documentation"); await writeFile(join(f.destination, "sitemap-old.xml"), "keep unreferenced generation"); const result = await syncSitemaps(f.config, f.transfer); assert.deepEqual(result, { directory: f.destination, files: [...names, "sitemap.xml"] }); assert.deepEqual(f.requests, ["sitemap.xml", ...names, "sitemap.xml"]); for (const name of result.files) { assert.deepEqual(await readFile(join(f.destination, name)), await readFile(join(f.remote, name))); assert.equal((await stat(join(f.destination, name))).mtimeMs, (await stat(join(f.remote, name))).mtimeMs); } assert.equal(await readFile(join(f.destination, "README.md"), "utf8"), "keep local documentation"); assert.equal(await readFile(join(f.destination, "sitemap-old.xml"), "utf8"), "keep unreferenced generation"); assert.deepEqual((await readdir(f.destination)).sort(), [...result.files, "README.md", "sitemap-old.xml"].sort()); } finally { await f.close(); } }); test("invalid, non-XML and unsafe index references never replace an existing local generation", async () => { const f = await fixture(); try { await mkdir(f.destination); await writeFile(join(f.destination, "sitemap.xml"), "existing local index"); const invalid = [ "", "server error", "", ']>'+"\n", "https://old.example/sitemaps/sitemap-a.xml?token=secret", "https://old.example/sitemaps/sitemap-a.xml#hash", "https://user:password@old.example/sitemaps/sitemap-a.xml", "file:///sitemaps/sitemap-a.xml", "https://old.example/sitemaps/../private.xml", "https://old.example/sitemaps/sitemap-%2Fa.xml", "https://old.example/sitemaps/sitemap-a.xmlhttps://old.example/sitemaps/sitemap-b.xml", "&unknown;", '', "]]>", "trailing text", Buffer.from([0xff, 0xfe]), ]; for (const contents of invalid) { await f.put("sitemap.xml", contents); await assert.rejects(syncSitemaps(f.config, f.transfer)); assert.equal(await readFile(join(f.destination, "sitemap.xml"), "utf8"), "existing local index"); assert.deepEqual(await readdir(f.destination), ["sitemap.xml"]); } } finally { await f.close(); } }); test("missing or invalid referenced shards abort without replacing local files", async () => { const f = await fixture(); try { await mkdir(f.destination); await writeFile(join(f.destination, "sitemap.xml"), "existing index"); await writeFile(join(f.destination, "sitemap-pages.xml"), "existing shard"); await f.put("sitemap.xml", index(["sitemap-pages.xml", "sitemap-seriesplay.xml"])); await f.put("sitemap-pages.xml", shard); await assert.rejects(syncSitemaps(f.config, f.transfer), { code: "ENOENT" }); const invalid = ["", "", "", "not an absolute URL", "https://valid.test/", "unexpected"]; for (const contents of invalid) { await f.put("sitemap-seriesplay.xml", contents); await assert.rejects(syncSitemaps(f.config, f.transfer)); } assert.equal(await readFile(join(f.destination, "sitemap.xml"), "utf8"), "existing index"); assert.equal(await readFile(join(f.destination, "sitemap-pages.xml"), "utf8"), "existing shard"); assert.deepEqual((await readdir(f.destination)).sort(), ["sitemap-pages.xml", "sitemap.xml"]); } finally { await f.close(); } }); test("an index changing during download fails before creating the local output directory", async () => { const f = await fixture(); try { await f.put("sitemap.xml", index(["sitemap-pages.xml"])); await f.put("sitemap-pages.xml", shard); let reads = 0; const transfer: SitemapTransfer = async (name, destination) => { if (name === "sitemap.xml" && ++reads === 2) await f.put("sitemap.xml", index(["sitemap-seriesplay.xml"])); await f.transfer(name, destination); }; await assert.rejects(syncSitemaps(f.config, transfer), /index changed during synchronization/); await assert.rejects(stat(f.destination), { code: "ENOENT" }); } finally { await f.close(); } }); test("valid empty indexes and empty URL sets remain byte-identical instead of generating content", async () => { const f = await fixture(); try { await f.put("sitemap.xml", index([])); assert.deepEqual((await syncSitemaps(f.config, f.transfer)).files, ["sitemap.xml"]); assert.deepEqual(await readFile(join(f.destination, "sitemap.xml")), index([])); await f.put("sitemap.xml", index(["sitemap-empty.xml"])); const empty = ''; await f.put("sitemap-empty.xml", empty); await syncSitemaps(f.config, f.transfer); assert.equal(await readFile(join(f.destination, "sitemap-empty.xml"), "utf8"), empty); } finally { await f.close(); } }); test("Next traces robots and media keys while explicitly excluding backend and developer sitemap files", () => { const includes = nextConfig.outputFileTracingIncludes?.["/*"] || []; const excludes = nextConfig.outputFileTracingExcludes?.["/*"] || []; assert.ok(includes.includes("./config/secrets/m3u8.key")); assert.ok(includes.includes("./template/pages/robots.txt")); assert.equal(includes.some((pattern) => /sitemap/.test(pattern)), false); assert.ok(excludes.includes("./template/pages/sitemaps/**/*")); assert.ok(excludes.includes("./template/pages/sitemap.xml")); assert.ok(excludes.includes("./data/sitemaps/**/*")); });