From 913a37a1e07ccc22ce9bca85dc72a78acc067f6e Mon Sep 17 00:00:00 2001 From: Ricardo Date: Mon, 24 Aug 2026 00:24:18 +0200 Subject: [PATCH] test: verify this configuration by publishing through it Adds a smoke test that publishes a note through the Micropub API, checks the file the content store received, and deletes it again. It runs on push and weekly, so a change in a published `@indiekit/*` package fails here rather than the next time somebody clones this repository. The GitHub content store is pointed at a local stub through its `baseUrl` option, so the test needs no access token, makes no network request and writes to no repository, while still exercising the plug-ins, preset, publication and syndicator options this repository ships. `test/indiekit.config.ci.js` imports the published configuration and overrides that one endpoint, so the configuration under test is the real one. Asserted: the publish returns 202 with a Location header, the note is filed at the Jekyll collection path the preset defines, the stored file contains a marker unique to the run, `q=config` advertises the post types, `q=syndicate-to` advertises the syndicator, and deleting the post removes the file. Not asserted: that the reported URL serves the post. This repository has no site generator, so that half belongs with a starter that does. The test names the missing-PASSWORD_SECRET case explicitly. Without it Indiekit redirects every route to first-run setup, and because redirects are followed the failure otherwise surfaces as an unexplained 200. --- .github/workflows/ci.yml | 54 ++++++++ README.md | 24 ++++ package-lock.json | 3 + package.json | 8 +- test/github-stub.mjs | 83 +++++++++++++ test/indiekit.config.ci.js | 13 ++ test/smoke.mjs | 249 +++++++++++++++++++++++++++++++++++++ 7 files changed, 432 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 test/github-stub.mjs create mode 100644 test/indiekit.config.ci.js create mode 100644 test/smoke.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a83e8a7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: smoke + +on: + push: + pull_request: + schedule: + # Weekly. The point of a scheduled run is that a change in a published + # `@indiekit/*` package fails here, rather than the first time somebody + # clones this repository and finds it does not work. + - cron: "0 7 * * 1" + +jobs: + smoke: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [24] + mongodb-version: [8] + env: + PUBLICATION_URL: http://localhost:8090 + SECRET: ci-secret-not-used-outside-this-job + # Placeholders. `test/indiekit.config.ci.js` points the content store at + # the stub in `test/github-stub.mjs`, so nothing reaches api.github.com + # and no repository is written to. No secrets are needed, which also + # means this workflow runs on pull requests from forks. + GITHUB_USER: example-user + GITHUB_REPO: example-repo + GITHUB_BRANCH: main + GITHUB_TOKEN: stub-token-not-real + GITHUB_API_PORT: 3001 + MASTODON_URL: https://mastodon.example + MASTODON_USER: "@example" + MASTODON_ACCESS_TOKEN: stub-token-not-real + MONGO_URL: mongodb://localhost:27017/example-config + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: ${{ matrix.node-version }} + # Deleting a post needs the posts collection, so a database is required. + - name: Start MongoDB + uses: supercharge/mongodb-github-action@1.12.1 + with: + mongodb-version: ${{ matrix.mongodb-version }} + # `npm ci` installs exactly what the lockfile pins, so a scheduled run + # using it would re-test the same versions for ever. The weekly run + # resolves the `^1.0.0-beta` ranges instead — what a new clone actually + # gets — so a change in a published package shows up here. + - name: Install dependencies + run: ${{ github.event_name == 'schedule' && 'npm install --no-audit' || 'npm ci' }} + + # The test starts and stops the server itself, so there is no instance + # for it to publish into by mistake. + - run: npm run smoke diff --git a/README.md b/README.md index 45493ab..c3374af 100644 --- a/README.md +++ b/README.md @@ -82,3 +82,27 @@ docker compose up --build Click the button to use this configuration as the basis of a new service deployed with Railway: [![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/gEboK6?referralCode=bCd1gL) + +## Smoke test + +A single test checks that this configuration still works: it publishes a note +through the Micropub API, verifies the file the content store received, and +deletes it again. + +The GitHub content store is pointed at a local stub (`test/github-stub.mjs`) +through its `baseUrl` option, so the test needs no access token, makes no +network request, and writes to no repository — while still exercising the +plug-ins, preset, publication and syndicator options configured here. + +It runs on every push and weekly, so a change in a published `@indiekit/*` +package is caught here rather than by the next person to clone this repository. + +The test starts and stops its own server, and refuses to run if the ports it +needs are already in use, so it cannot publish into a server it did not start. +All it needs is a MongoDB — deleting a post requires one — and the `.env` +described above: + +```sh +npm install +npm run smoke +``` diff --git a/package-lock.json b/package-lock.json index af02885..88b54ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,9 @@ "@indiekit/store-github": "^1.0.0-beta", "@indiekit/syndicator-mastodon": "^1.0.0-beta" }, + "devDependencies": { + "jsonwebtoken": "^9.0.2" + }, "engines": { "node": ">=24.17", "npm": ">=11" diff --git a/package.json b/package.json index a230dc0..e17f6fa 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "url": "git+https://github.com/getindiekit/example-config.git" }, "scripts": { - "start": "indiekit serve" + "start": "indiekit serve", + "smoke": "node test/smoke.mjs" }, "dependencies": { "@indiekit/indiekit": "^1.0.0-beta", @@ -34,5 +35,8 @@ "npm": ">=11" }, "type": "module", - "private": true + "private": true, + "devDependencies": { + "jsonwebtoken": "^9.0.2" + } } diff --git a/test/github-stub.mjs b/test/github-stub.mjs new file mode 100644 index 0000000..7497731 --- /dev/null +++ b/test/github-stub.mjs @@ -0,0 +1,83 @@ +import http from "node:http"; + +/** + * A stand-in for the GitHub contents API, holding files in memory. + * + * `@indiekit/store-github` takes a `baseUrl`, so pointing it here exercises the + * real store, preset and Micropub endpoint without a token, a network call, or + * a write to anybody’s repository. + * The port is fixed rather than ephemeral: Indiekit is started before the test + * runs and needs the URL up front. + * @see {@link https://docs.github.com/en/rest/repos/contents} + * @param {number} port - Port to listen on + * @returns {Promise} Server, its base URL, and the files it holds + */ +export const startGithubStub = async (port) => { + /** @type {Map} */ + const files = new Map(); + /** @type {object[]} Bodies of every write, so the test can check the store + * sent what the GitHub API actually requires, not merely that it sent + * something this permissive stub was willing to accept. */ + const writes = []; + let counter = 0; + + const server = http.createServer((request, response) => { + // Everything after `/contents/`, minus the `?ref=` the store appends + const [pathname] = request.url.split("?"); + const filePath = decodeURIComponent( + pathname.replace(/^\/repos\/[^/]+\/[^/]+\/contents\//, ""), + ); + + const send = (status, body) => { + response.writeHead(status, { "content-type": "application/json" }); + response.end(JSON.stringify(body)); + }; + + let body = ""; + request.on("data", (chunk) => (body += chunk)); + request.on("end", () => { + const sent = body ? JSON.parse(body) : {}; + + switch (request.method) { + case "GET": { + const file = files.get(filePath); + // A miss must not be 2xx: `createFile` reads this to decide whether + // the file already exists, and would skip the write if it were. + return file ? send(200, file) : send(404, { message: "Not Found" }); + } + + case "PUT": { + const sha = `sha${++counter}`; + writes.push({ filePath, ...sent }); + files.set(filePath, { content: sent.content, sha }); + return send(201, { + content: { html_url: `https://github.example/${filePath}`, sha }, + }); + } + + case "DELETE": { + files.delete(filePath); + return send(200, { commit: { sha: `sha${++counter}` } }); + } + + default: { + return send(405, { message: "Method Not Allowed" }); + } + } + }); + }); + + await new Promise((resolve) => server.listen(port, "127.0.0.1", resolve)); + + return { + baseUrl: `http://127.0.0.1:${port}`, + /** @returns {string|undefined} Decoded file content */ + read: (filePath) => { + const file = files.get(filePath); + return file && Buffer.from(file.content, "base64").toString("utf8"); + }, + paths: () => [...files.keys()], + writes: () => writes, + close: () => new Promise((resolve) => server.close(resolve)), + }; +}; diff --git a/test/indiekit.config.ci.js b/test/indiekit.config.ci.js new file mode 100644 index 0000000..d796a28 --- /dev/null +++ b/test/indiekit.config.ci.js @@ -0,0 +1,13 @@ +import process from "node:process"; + +import config from "../indiekit.config.js"; + +/** + * The published configuration, with only the GitHub API endpoint redirected at + * the stub in `test/github-stub.mjs`. Everything else — plug-ins, preset, + * publication and syndicator options — is exactly what this repository ships, + * so the smoke test exercises the real configuration rather than a copy of it. + */ +config["@indiekit/store-github"].baseUrl = process.env.GITHUB_API_URL; + +export default config; diff --git a/test/smoke.mjs b/test/smoke.mjs new file mode 100644 index 0000000..98fd10f --- /dev/null +++ b/test/smoke.mjs @@ -0,0 +1,249 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import net from "node:net"; +import process from "node:process"; + +import jwt from "jsonwebtoken"; + +import { startGithubStub } from "./github-stub.mjs"; + +if (existsSync(".env")) process.loadEnvFile(".env"); + +const PORT = Number(process.env.SMOKE_PORT || 3010); +const API_PORT = Number(process.env.GITHUB_API_PORT || 3001); +const INDIEKIT = `http://localhost:${PORT}`; +const SITE = process.env.PUBLICATION_URL; +const SECRET = process.env.SECRET; +const MARKER = `SMOKE_${Math.random().toString(36).slice(2, 10)}`; + +if (!SECRET || !SITE) { + console.error("SECRET and PUBLICATION_URL must be set; see .env.example."); + process.exit(1); +} + +const results = []; +const check = (name, ok, detail = "") => { + results.push({ name, ok }); + console.log(`${ok ? "ok " : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`); +}; + +/** Run an assertion; a throw becomes a FAIL for `name` rather than a crash. */ +const step = async (name, fn) => { + try { + await fn(); + } catch (error) { + check(name, false, error.message); + } +}; + +/** + * Refuse to run if the port is taken. Otherwise the server this test starts + * would fail to bind, the poll below would succeed against whatever is already + * listening, and the publish would land in a server this test does not own. + * @param {number} port - Port to test + * @returns {Promise} Port is free + */ +const portIsFree = (port) => + new Promise((resolve) => { + const probe = net.createServer(); + probe.once("error", () => resolve(false)); + probe.once("listening", () => probe.close(() => resolve(true))); + probe.listen(port, "127.0.0.1"); + }); + +for (const [port, what] of [ + [PORT, "Indiekit"], + [API_PORT, "the GitHub stub"], +]) { + if (!(await portIsFree(port))) { + console.error( + `Port ${port} is in use, and this test needs it for ${what}.\n` + + `It starts its own server rather than publishing into one it did not\n` + + `start. Stop what is listening, or set SMOKE_PORT / GITHUB_API_PORT.`, + ); + process.exit(1); + } +} + +const github = await startGithubStub(API_PORT); + +/** + * This test publishes and deletes, so it starts the server it tests rather + * than trusting one to already be running. An externally started Indiekit + * could be configured against the real GitHub API, and the publish would land + * in a repository we do not own before anything could notice. + * + * `PASSWORD_SECRET` only has to be present: Indiekit redirects every route to + * first-run setup without it, and compares it only when signing in with a + * password, which this test never does. + */ +const server = spawn( + "node", + [ + "node_modules/.bin/indiekit", + "serve", + "--config", + "test/indiekit.config.ci.js", + "--port", + String(PORT), + ], + { + env: { + ...process.env, + GITHUB_API_URL: github.baseUrl, + PASSWORD_SECRET: "smoke-test-never-compared", + }, + stdio: ["ignore", "pipe", "pipe"], + }, +); + +let serverOutput = ""; +for (const stream of [server.stdout, server.stderr]) { + stream.on("data", (chunk) => (serverOutput += chunk)); +} + +const shutDown = async () => { + server.kill(); + await github.close(); +}; + +const waitForServer = async (timeoutMs = 30_000) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (server.exitCode !== null) { + throw new Error(`server exited (${server.exitCode}):\n${serverOutput}`); + } + try { + await fetch(`${INDIEKIT}/`); + return; + } catch { + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } + throw new Error(`server never answered on ${INDIEKIT}:\n${serverOutput}`); +}; + +try { + await waitForServer(); +} catch (error) { + console.error(error.message); + await shutDown(); + process.exit(1); +} + +const token = jwt.sign({ me: SITE, scope: "create delete" }, SECRET, { + expiresIn: "10m", +}); + +const micropub = (body) => + fetch(`${INDIEKIT}/micropub`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams(body), + }); + +const query = async (q) => { + const response = await fetch(`${INDIEKIT}/micropub?q=${q}`, { + headers: { authorization: `Bearer ${token}` }, + }); + if (!response.ok) throw new Error(`q=${q} returned ${response.status}`); + return response.json(); +}; + +let created, location, filePath; + +await step("publish returns 202", async () => { + created = await micropub({ h: "entry", content: `${MARKER} smoke test` }); + check("publish returns 202", created.status === 202, `got ${created.status}`); +}); + +await step("response carries a Location header", async () => { + if (!created) throw new Error("no response from publish request"); + location = created.headers.get("location"); + check( + "response carries a Location header", + Boolean(location), + location || "none", + ); +}); + +await step("post is filed in the preset's collection", async () => { + [filePath] = github.paths(); + // @indiekit/preset-jekyll files each post type in its own Jekyll collection + const expected = /^_notes\/\d{4}-\d{2}-\d{2}-[^/]+\.md$/; + check( + "post is filed in the preset's collection", + expected.test(String(filePath)), + String(filePath), + ); +}); + +await step("stored file contains this run's marker", async () => { + if (!filePath) throw new Error("nothing was written to the store"); + const content = github.read(filePath) || ""; + check( + "stored file contains this run's marker", + content.includes(MARKER), + MARKER, + ); +}); + +// A stub accepts whatever it is sent, so without this the test would pass on a +// request the real API would reject. +await step("write is a request the GitHub API would accept", async () => { + const [write] = github.writes(); + if (!write) throw new Error("the store made no write"); + const valid = + write.branch === (process.env.GITHUB_BRANCH || "main") && + typeof write.message === "string" && + write.message.length > 0 && + Buffer.from(write.content, "base64").toString("base64") === write.content; + check( + "write is a request the GitHub API would accept", + valid, + `branch=${write.branch} message=${JSON.stringify(write.message)}`, + ); +}); + +await step("q=config advertises post types", async () => { + const config = await query("config"); + const types = (config["post-types"] || []).map((type) => type.type); + check( + "q=config advertises post types", + types.includes("note"), + types.join(", "), + ); +}); + +await step("q=syndicate-to advertises the syndicator", async () => { + const { "syndicate-to": targets = [] } = await query("syndicate-to"); + check( + "q=syndicate-to advertises the syndicator", + targets.length > 0, + targets.map((target) => target.uid).join(", "), + ); +}); + +await step("deleting the post removes it from the store", async () => { + if (!location) throw new Error("no Location header to delete"); + const deleted = await micropub({ action: "delete", url: location }); + if (!deleted.ok) { + throw new Error( + `delete returned ${deleted.status}: ${(await deleted.text()).slice(0, 200)}`, + ); + } + check( + "deleting the post removes it from the store", + !github.paths().includes(filePath), + String(filePath), + ); +}); + +await shutDown(); + +const failed = results.filter((result) => !result.ok).length; +console.log(`\n${results.length - failed}/${results.length} assertions passed`); +process.exit(failed === 0 ? 0 : 1);