import { execFileSync } from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_DENY, fetchDecofile, findPlaintextSecrets, hasEncryptedSecretRef, matchesGlob, writeDecofileToDir, } from "./sync-blocks-bot"; function tmpBlocksDir(files: Record = {}): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sync-blocks-bot-")); const out = path.join(dir, ".deco", "blocks"); fs.mkdirSync(out, { recursive: true }); for (const [file, content] of Object.entries(files)) { fs.writeFileSync(path.join(out, file), `${JSON.stringify(content, null, 2)}\n`); } return out; } const ls = (dir: string) => fs.readdirSync(dir).sort(); const read = (dir: string, file: string) => JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8")) as Record; describe("matchesGlob", () => { it("matches whole keys with a * wildcard", () => { expect(matchesGlob("Site", "Site")).toBe(true); expect(matchesGlob("Sitemap", "Site")).toBe(false); expect(matchesGlob("pages-Home", "pages-*")).toBe(true); expect(matchesGlob("deco-vtex", "deco-*")).toBe(true); // dots are literal, not "any char" expect(matchesGlob("axb", "a.b")).toBe(false); }); }); describe("hasEncryptedSecretRef", () => { it("finds a {name, encrypted} ref at any depth", () => { expect(hasEncryptedSecretRef({ appKey: { name: "BANANA", encrypted: "3a714de1" } })).toBe(true); expect(hasEncryptedSecretRef({ a: [{ b: { name: "X", encrypted: "y" } }] })).toBe(true); expect(hasEncryptedSecretRef({ name: "X", encrypted: "" })).toBe(false); expect(hasEncryptedSecretRef({ sections: [{ __resolveType: "site/x.tsx" }] })).toBe(false); }); }); describe("findPlaintextSecrets", () => { it("flags credential-shaped props holding a raw string", () => { expect(findPlaintextSecrets({ appToken: "abcdefghijklmnop" })).toEqual(["appToken"]); expect(findPlaintextSecrets({ nested: { api_key: "abcdefghijklmnop" } })).toEqual([ "nested.api_key", ]); expect(findPlaintextSecrets({ list: [{ password: "abcdefghijklmnop" }] })).toEqual([ "list[0].password", ]); }); it("ignores a bare `key` prop — CMS content is full of them", () => { // real shape: every VTEX PLP loader block carries selectedFacets[].key expect( findPlaintextSecrets({ selectedFacets: [{ key: "productClusterIds", value: "140" }] }), ).toEqual([]); // …but a qualified key still counts expect(findPlaintextSecrets({ apiKey: "abcdefghijklmnop" })).toEqual(["apiKey"]); expect(findPlaintextSecrets({ "private-key": "abcdefghijklmnop" })).toEqual(["private-key"]); }); it("does not flag encrypted refs, urls, prose or short values", () => { expect(findPlaintextSecrets({ appKey: { name: "X", encrypted: "abcdefghijklmnop" } })).toEqual( [], ); expect(findPlaintextSecrets({ tokenUrl: "https://x.com/very/long/path" })).toEqual([]); expect(findPlaintextSecrets({ secret: "short" })).toEqual([]); expect(findPlaintextSecrets({ password: "uma frase com espacos" })).toEqual([]); expect(findPlaintextSecrets({ apiKey: "{{ FROM_ENV_VAR_HERE }}" })).toEqual([]); }); }); describe("writeDecofileToDir", () => { it("writes one single-encoded file per block and skips unchanged ones", () => { const out = tmpBlocksDir(); const remote = { "pages-Home": { name: "Home", path: "/", sections: [] }, "pages-A B": { name: "A B", path: "/a-b", sections: [] }, }; const first = writeDecofileToDir(remote, { out }); expect(first.added.sort()).toEqual(["pages-A B", "pages-Home"]); expect(ls(out)).toEqual(["pages-A%20B.json", "pages-Home.json"]); expect(read(out, "pages-Home.json").path).toBe("/"); const second = writeDecofileToDir(remote, { out }); expect(second.unchanged).toBe(2); expect(second.added).toEqual([]); expect(second.updated).toEqual([]); }); it("compares content, not bytes — minified or reordered locals are not churn", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sync-blocks-bot-")); const out = path.join(dir, "blocks"); fs.mkdirSync(out, { recursive: true }); // how the Studio daemon / old sync bot write it: one minified line, no newline fs.writeFileSync(path.join(out, "pages-Home.json"), '{"sections":[],"path":"/","name":"Home"}'); const report = writeDecofileToDir( { "pages-Home": { name: "Home", path: "/", sections: [] } }, { out }, ); expect(report.unchanged).toBe(1); expect(report.updated).toEqual([]); // untouched: no reformat commit expect(fs.readFileSync(path.join(out, "pages-Home.json"), "utf-8")).toBe( '{"sections":[],"path":"/","name":"Home"}', ); // a real content change does get written, pretty-printed const changed = writeDecofileToDir({ "pages-Home": { name: "Home v2", path: "/" } }, { out }); expect(changed.updated).toEqual(["pages-Home"]); expect(fs.readFileSync(path.join(out, "pages-Home.json"), "utf-8")).toContain('\n "name"'); }); it("denies keys by glob, defaulting to the Site block", () => { const out = tmpBlocksDir({ "Site.json": { seo: { title: "local" } } }); const report = writeDecofileToDir( { Site: { seo: { title: "prod" } }, "pages-Home": { path: "/" } }, { out }, ); expect(DEFAULT_DENY).toContain("Site"); expect(report.denied).toEqual(["Site"]); expect(read(out, "Site.json").seo).toEqual({ title: "local" }); const custom = writeDecofileToDir({ "pages-Home": { path: "/x" } }, { out, deny: ["pages-*"] }); expect(custom.denied).toEqual(["pages-Home"]); }); it("never overwrites a block that carries an encrypted secret", () => { const out = tmpBlocksDir({ "deco-vtex.json": { account: "loja", appKey: { name: "KEY", encrypted: "local" } }, }); const report = writeDecofileToDir( { "deco-vtex": { account: "outra", appKey: { name: "KEY", encrypted: "prod" } } }, { out }, ); expect(report.protectedSecretBlocks).toEqual(["deco-vtex"]); expect(read(out, "deco-vtex.json").account).toBe("loja"); const forced = writeDecofileToDir( { "deco-vtex": { account: "outra", appKey: { name: "KEY", encrypted: "prod" } } }, { out, allowSecretBlocks: true }, ); expect(forced.updated).toEqual(["deco-vtex"]); }); it("overwrites a legacy double-encoded filename in place instead of duplicating it", () => { const out = tmpBlocksDir({ "pages-A%2520B.json": { name: "old", path: "/a-b" } }); const report = writeDecofileToDir({ "pages-A B": { name: "new", path: "/a-b" } }, { out }); expect(report.updated).toEqual(["pages-A B"]); expect(ls(out)).toEqual(["pages-A%2520B.json"]); expect(read(out, "pages-A%2520B.json").name).toBe("new"); }); it("collapses colliding encodings onto the canonical name", () => { const out = tmpBlocksDir({ "pages-A%2520B.json": { name: "stale", path: "/a-b" }, "pages-A%20B.json": { name: "also stale", path: "/a-b" }, }); const report = writeDecofileToDir({ "pages-A B": { name: "new", path: "/a-b" } }, { out }); expect(report.updated).toEqual(["pages-A B"]); expect(ls(out)).toEqual(["pages-A%20B.json"]); expect(read(out, "pages-A%20B.json").name).toBe("new"); }); it("prunes blocks absent upstream, but keeps denied and secret-bearing ones", () => { const out = tmpBlocksDir({ "pages-Gone.json": { name: "gone", path: "/gone" }, "pages-Home.json": { name: "home", path: "/" }, "Site.json": { seo: {} }, "deco-vtex.json": { appKey: { name: "KEY", encrypted: "local" } }, }); const report = writeDecofileToDir( { "pages-Home": { name: "home", path: "/" } }, { out, prune: true, }, ); expect(report.removed).toEqual(["pages-Gone"]); expect(report.denied).toEqual(["Site"]); expect(report.protectedSecretBlocks).toEqual(["deco-vtex"]); expect(ls(out)).toEqual(["Site.json", "deco-vtex.json", "pages-Home.json"]); }); it("--dry-run touches nothing", () => { const out = tmpBlocksDir({ "pages-Gone.json": { path: "/gone" } }); const report = writeDecofileToDir( { "pages-Home": { path: "/" } }, { out, prune: true, dryRun: true, }, ); expect(report.added).toEqual(["pages-Home"]); expect(report.removed).toEqual(["pages-Gone"]); expect(ls(out)).toEqual(["pages-Gone.json"]); }); it("skips non-object payload values and reports plaintext secrets", () => { const out = tmpBlocksDir(); const report = writeDecofileToDir( { broken: "not a block", leak: { appToken: "abcdefghijklmnop" } }, { out }, ); expect(report.skipped).toEqual(["broken"]); expect(report.plaintextSecrets).toEqual(["leak.appToken"]); }); }); describe("fetchDecofile", () => { afterEach(() => vi.unstubAllGlobals()); const stub = (body: string, init: { status?: number; headers?: Record } = {}) => { vi.stubGlobal( "fetch", vi.fn( async () => new Response(body, { status: init.status ?? 200, headers: { "content-type": "application/json", ...init.headers }, }), ), ); }; it("returns the parsed decofile plus the ETag revision", async () => { stub(JSON.stringify({ "pages-Home": { path: "/" } }), { headers: { etag: '"abc123"' } }); const result = await fetchDecofile("https://x.com/.decofile"); expect(result.blocks).toEqual({ "pages-Home": { path: "/" } }); expect(result.revision).toBe('"abc123"'); }); it("rejects a non-200, a non-JSON content-type, a non-object payload and an oversized body", async () => { stub("{}", { status: 500 }); await expect(fetchDecofile("https://x.com/.decofile")).rejects.toThrow(/responded 500/); stub("", { headers: { "content-type": "text/html" } }); await expect(fetchDecofile("https://x.com/.decofile")).rejects.toThrow(/expected JSON/); stub("[]"); await expect(fetchDecofile("https://x.com/.decofile")).rejects.toThrow(/an array/); stub(JSON.stringify({ a: { b: 1 } })); await expect(fetchDecofile("https://x.com/.decofile", { maxBytes: 4 })).rejects.toThrow( /over the 4 byte cap/, ); }); }); describe("CLI entrypoint", () => { // Regression: invoked through the package `bin`, argv[1] is the // node_modules/.bin symlink while import.meta.url is the real file. A string // compare of the two fails, main() never runs, and the CLI exits 0 having // printed nothing — a silently green CI job. Exercise the symlink path. it("runs when invoked through a bin symlink", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sync-blocks-bin-")); const link = path.join(dir, "deco-sync-blocks-bot"); fs.symlinkSync(path.join(__dirname, "sync-blocks-bot.ts"), link); const out = execFileSync(process.execPath, [require.resolve("tsx/cli"), link, "--help"], { encoding: "utf-8", }); expect(out).toContain("--fail-on-plaintext-secret"); }); });