scripts/publish.test.mjs
scripts/publish.test.mjsBrowse 4 files
3,256 tokens
11,401 bytes
Token encoding: o200k_base
Snapshot 5b913e7
← Back to SKILL.md
1import assert from "node:assert/strict";2import { execFileSync } from "node:child_process";3import {4 chmodSync,5 existsSync,6 mkdirSync,7 mkdtempSync,8 readFileSync,9 rmSync,10 statSync,11 writeFileSync,12} from "node:fs";13import { tmpdir } from "node:os";14import { dirname, join } from "node:path";15import { fileURLToPath } from "node:url";16import { after, test } from "node:test";17 18const scriptPath = join(dirname(fileURLToPath(import.meta.url)), "publish.sh");19const tempDirs = new Set();20 21after(() => {22 for (const dir of tempDirs) {23 rmSync(dir, { force: true, recursive: true });24 }25});26 27function createSite(name = "paperclip-page-test") {28 const siteDir = mkdtempSync(join(tmpdir(), `${name}-`));29 tempDirs.add(siteDir);30 writeFileSync(join(siteDir, "index.html"), "<!doctype html><title>Paperclip</title>\n");31 return siteDir;32}33 34function writeExecutable(path, body) {35 writeFileSync(path, body, { mode: 0o755 });36 chmodSync(path, 0o755);37}38 39function writeState(siteDir, state) {40 mkdirSync(join(siteDir, ".paperclip-page"), { recursive: true });41 writeFileSync(join(siteDir, ".paperclip-page", "state.json"), `${JSON.stringify(state)}\n`);42}43 44function runPublish(args, env = {}) {45 try {46 return {47 output: execFileSync("bash", [scriptPath, ...args], {48 encoding: "utf8",49 env: {50 ...process.env,51 PAPERCLIP_PAGE_BUCKET: "paperclip-pages-test",52 PAPERCLIP_PAGE_BASE_URL: "https://pages.example.test/",53 ...env,54 },55 stdio: ["ignore", "pipe", "pipe"],56 }),57 status: 0,58 };59 } catch (error) {60 return {61 output: `${error.stdout ?? ""}${error.stderr ?? ""}`,62 status: error.status ?? 1,63 };64 }65}66 67test("publish helper stays executable", () => {68 assert.equal(statSync(scriptPath).mode & 0o111, 0o111);69});70 71test("dry run validates and prints the planned target without requiring AWS", () => {72 const result = runPublish([73 createSite(),74 "--slug",75 "demo-page",76 "--dry-run",77 ]);78 79 assert.equal(result.status, 0);80 assert.match(result.output, /^paperclip-page dry run$/m);81 assert.match(result.output, /^mode: publish$/m);82 assert.match(result.output, /^bucket: paperclip-pages-test$/m);83 assert.match(result.output, /^prefix: demo-page\/$/m);84 assert.match(result.output, /^url: https:\/\/pages\.example\.test\/demo-page\/$/m);85});86 87test("dry run normalizes a safe default prefix", () => {88 const result = runPublish(89 [createSite(), "--slug", "demo-page", "--dry-run"],90 { PAPERCLIP_PAGE_DEFAULT_PREFIX: "/reports/launches/" },91 );92 93 assert.equal(result.status, 0);94 assert.match(result.output, /^prefix: reports\/launches\/demo-page\/$/m);95 assert.match(result.output, /^url: https:\/\/pages\.example\.test\/reports\/launches\/demo-page\/$/m);96});97 98test("dry run update requires matching local ownership state", () => {99 const siteDir = createSite();100 const missingState = runPublish([siteDir, "--slug", "demo-page", "--update", "--dry-run"]);101 102 assert.notEqual(missingState.status, 0);103 assert.match(missingState.output, /requires ownership state/);104 105 writeState(siteDir, {106 bucket: "paperclip-pages-test",107 prefix: "demo-page/",108 });109 110 const result = runPublish([siteDir, "--slug", "demo-page", "--update", "--dry-run"]);111 112 assert.equal(result.status, 0);113 assert.match(result.output, /^mode: update$/m);114});115 116test("rejects nested slugs", () => {117 const result = runPublish([createSite(), "--slug", "nested/path", "--dry-run"]);118 119 assert.notEqual(result.status, 0);120 assert.match(result.output, /slug must be one path segment/);121});122 123test("rejects hidden files in the source tree", () => {124 const siteDir = createSite();125 mkdirSync(join(siteDir, "assets"));126 writeFileSync(join(siteDir, "assets", ".secret"), "do not publish\n");127 128 const result = runPublish([siteDir, "--slug", "demo-page", "--dry-run"]);129 130 assert.notEqual(result.status, 0);131 assert.match(result.output, /hidden files and dot paths are not allowed/);132});133 134test("namespaced page keys require both halves of the pair", () => {135 const result = runPublish(136 [createSite(), "--slug", "demo-page", "--dry-run"],137 { PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID: "AKIAPAGEUPLOADER" },138 );139 140 assert.notEqual(result.status, 0);141 assert.match(result.output, /must be set together/);142});143 144test("namespaced session token requires the namespaced key pair", () => {145 const result = runPublish(146 [createSite(), "--slug", "demo-page", "--dry-run"],147 { PAPERCLIP_PAGE_AWS_SESSION_TOKEN: "page-session-token" },148 );149 150 assert.notEqual(result.status, 0);151 assert.match(result.output, /requires the PAPERCLIP_PAGE_AWS_\* key pair/);152});153 154test("namespaced page keys conflict with PAPERCLIP_PAGE_AWS_PROFILE", () => {155 const result = runPublish(156 [createSite(), "--slug", "demo-page", "--dry-run"],157 {158 PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID: "AKIAPAGEUPLOADER",159 PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY: "page-secret",160 PAPERCLIP_PAGE_AWS_PROFILE: "paperclip-page-uploader",161 },162 );163 164 assert.notEqual(result.status, 0);165 assert.match(result.output, /not both/);166});167 168test("namespaced page keys are scoped to the helper's aws calls", () => {169 const siteDir = createSite();170 const binDir = mkdtempSync(join(tmpdir(), "paperclip-page-bin-"));171 tempDirs.add(binDir);172 const envDump = join(binDir, "aws-env.txt");173 174 writeExecutable(175 join(binDir, "aws"),176 `#!/usr/bin/env bash177set -euo pipefail178{179 echo "AWS_ACCESS_KEY_ID=\${AWS_ACCESS_KEY_ID:-<unset>}"180 echo "AWS_SECRET_ACCESS_KEY=\${AWS_SECRET_ACCESS_KEY:-<unset>}"181 echo "AWS_SESSION_TOKEN=\${AWS_SESSION_TOKEN:-<unset>}"182 echo "AWS_PROFILE=\${AWS_PROFILE:-<unset>}"183} >"${envDump}"184while [[ "$1" == "--region" || "$1" == "--profile" ]]; do185 shift 2186done187if [[ "$1" == "s3api" ]]; then188 echo "None"189 exit 0190fi191if [[ "$1" == "s3" && "$2" == "sync" ]]; then192 exit 0193fi194echo "unexpected aws call: $*" >&2195exit 1196`,197 );198 writeExecutable(199 join(binDir, "curl"),200 `#!/usr/bin/env bash201exit 0202`,203 );204 205 const result = runPublish([siteDir, "--slug", "demo-page"], {206 AWS_REGION: "us-east-1",207 PATH: `${binDir}:${process.env.PATH}`,208 AWS_ACCESS_KEY_ID: "AKIAAMBIENTIDENTITY",209 AWS_SECRET_ACCESS_KEY: "ambient-secret",210 AWS_SESSION_TOKEN: "ambient-session-token",211 AWS_PROFILE: "ambient-profile",212 PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID: "AKIAPAGEUPLOADER",213 PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY: "page-secret",214 });215 216 assert.equal(result.status, 0);217 218 const seen = readFileSync(envDump, "utf8");219 assert.match(seen, /^AWS_ACCESS_KEY_ID=AKIAPAGEUPLOADER$/m);220 assert.match(seen, /^AWS_SECRET_ACCESS_KEY=page-secret$/m);221 assert.match(seen, /^AWS_SESSION_TOKEN=<unset>$/m);222 assert.match(seen, /^AWS_PROFILE=<unset>$/m);223});224 225test("credential values never pass through an external env command's argv", () => {226 const siteDir = createSite();227 const binDir = mkdtempSync(join(tmpdir(), "paperclip-page-bin-"));228 tempDirs.add(binDir);229 const envArgvDump = join(binDir, "env-argv.txt");230 231 writeExecutable(232 join(binDir, "aws"),233 `#!/usr/bin/env bash234set -euo pipefail235while [[ "$1" == "--region" || "$1" == "--profile" ]]; do236 shift 2237done238if [[ "$1" == "s3api" ]]; then239 echo "None"240 exit 0241fi242if [[ "$1" == "s3" && "$2" == "sync" ]]; then243 exit 0244fi245echo "unexpected aws call: $*" >&2246exit 1247`,248 );249 writeExecutable(250 join(binDir, "curl"),251 `#!/usr/bin/env bash252exit 0253`,254 );255 // Shim env: record every argv it is invoked with, then behave normally.256 // Credentials in that argv would be world-readable via /proc/<pid>/cmdline.257 writeExecutable(258 join(binDir, "env"),259 `#!/bin/bash260printf '%s\\n' "$@" >>"${envArgvDump}"261exec /usr/bin/env "$@"262`,263 );264 265 const result = runPublish([siteDir, "--slug", "demo-page"], {266 AWS_REGION: "us-east-1",267 PATH: `${binDir}:${process.env.PATH}`,268 PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID: "AKIAPAGEUPLOADER",269 PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY: "page-secret-argv-canary",270 PAPERCLIP_PAGE_AWS_SESSION_TOKEN: "page-session-argv-canary",271 });272 273 assert.equal(result.status, 0);274 275 const argvSeen = existsSync(envArgvDump) ? readFileSync(envArgvDump, "utf8") : "";276 assert.doesNotMatch(argvSeen, /page-secret-argv-canary/);277 assert.doesNotMatch(argvSeen, /page-session-argv-canary/);278 assert.doesNotMatch(argvSeen, /AKIAPAGEUPLOADER/);279});280 281test("PAPERCLIP_PAGE_AWS_PROFILE strips ambient static credentials", () => {282 const siteDir = createSite();283 const binDir = mkdtempSync(join(tmpdir(), "paperclip-page-bin-"));284 tempDirs.add(binDir);285 const envDump = join(binDir, "aws-env.txt");286 287 writeExecutable(288 join(binDir, "aws"),289 `#!/usr/bin/env bash290set -euo pipefail291profile="<none>"292while [[ "$1" == "--region" || "$1" == "--profile" ]]; do293 if [[ "$1" == "--profile" ]]; then294 profile="$2"295 fi296 shift 2297done298{299 echo "PROFILE_ARG=$profile"300 echo "AWS_ACCESS_KEY_ID=\${AWS_ACCESS_KEY_ID:-<unset>}"301 echo "AWS_SECRET_ACCESS_KEY=\${AWS_SECRET_ACCESS_KEY:-<unset>}"302 echo "AWS_SESSION_TOKEN=\${AWS_SESSION_TOKEN:-<unset>}"303 echo "AWS_PROFILE=\${AWS_PROFILE:-<unset>}"304} >"${envDump}"305if [[ "$1" == "s3api" ]]; then306 echo "None"307 exit 0308fi309if [[ "$1" == "s3" && "$2" == "sync" ]]; then310 exit 0311fi312echo "unexpected aws call: $*" >&2313exit 1314`,315 );316 writeExecutable(317 join(binDir, "curl"),318 `#!/usr/bin/env bash319exit 0320`,321 );322 323 const result = runPublish([siteDir, "--slug", "demo-page"], {324 AWS_REGION: "us-east-1",325 PATH: `${binDir}:${process.env.PATH}`,326 AWS_ACCESS_KEY_ID: "AKIAAMBIENTIDENTITY",327 AWS_SECRET_ACCESS_KEY: "ambient-secret",328 AWS_SESSION_TOKEN: "ambient-session-token",329 AWS_PROFILE: "ambient-profile",330 PAPERCLIP_PAGE_AWS_PROFILE: "paperclip-page-uploader",331 });332 333 assert.equal(result.status, 0);334 335 const seen = readFileSync(envDump, "utf8");336 assert.match(seen, /^PROFILE_ARG=paperclip-page-uploader$/m);337 assert.match(seen, /^AWS_ACCESS_KEY_ID=<unset>$/m);338 assert.match(seen, /^AWS_SECRET_ACCESS_KEY=<unset>$/m);339 assert.match(seen, /^AWS_SESSION_TOKEN=<unset>$/m);340 assert.match(seen, /^AWS_PROFILE=<unset>$/m);341});342 343test("live publish writes state before URL verification", () => {344 const siteDir = createSite();345 const binDir = mkdtempSync(join(tmpdir(), "paperclip-page-bin-"));346 tempDirs.add(binDir);347 348 writeExecutable(349 join(binDir, "aws"),350 `#!/usr/bin/env bash351set -euo pipefail352while [[ "$1" == "--region" || "$1" == "--profile" ]]; do353 shift 2354done355if [[ "$1" == "s3api" ]]; then356 echo "None"357 exit 0358fi359if [[ "$1" == "s3" && "$2" == "sync" ]]; then360 exit 0361fi362echo "unexpected aws call: $*" >&2363exit 1364`,365 );366 writeExecutable(367 join(binDir, "curl"),368 `#!/usr/bin/env bash369set -euo pipefail370echo "simulated CloudFront propagation miss" >&2371exit 22372`,373 );374 375 const result = runPublish([siteDir, "--slug", "demo-page"], {376 AWS_REGION: "us-east-1",377 PATH: `${binDir}:${process.env.PATH}`,378 });379 380 assert.notEqual(result.status, 0);381 assert.match(result.output, /simulated CloudFront propagation miss/);382 assert.equal(existsSync(join(siteDir, ".paperclip-page", "state.json")), true);383 384 const state = JSON.parse(readFileSync(join(siteDir, ".paperclip-page", "state.json"), "utf8"));385 assert.equal(state.bucket, "paperclip-pages-test");386 assert.equal(state.prefix, "demo-page/");387 assert.equal(state.url, "https://pages.example.test/demo-page/");388});389