mandrel-platform 0.17.2 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,372 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * apply-uptime-monitors.test.mjs — node:test suite for the shared Better
4
+ * Stack monitor schema + apply unit (Story #180).
5
+ *
6
+ * Exercises the pure config parser/validator, the pure diff, and the full
7
+ * apply orchestration against an injected Better Stack client seam — no real
8
+ * network calls. The CLI's skip-with-notice (no token) and config-validation
9
+ * paths are exercised via `execFileSync` against the real script, capturing
10
+ * stdout/exit code.
11
+ *
12
+ * Run: node scripts/apply-uptime-monitors.test.mjs (or `node --test scripts/`)
13
+ */
14
+
15
+ import assert from "node:assert/strict";
16
+ import { execFileSync, execFile } from "node:child_process";
17
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import { promisify } from "node:util";
22
+ import { test } from "node:test";
23
+
24
+ const execFileAsync = promisify(execFile);
25
+
26
+ import {
27
+ DEFAULT_CHECK_FREQUENCY_SECONDS,
28
+ parseMonitorConfig,
29
+ diffMonitors,
30
+ createBetterStackClient,
31
+ applyMonitorConfig,
32
+ } from "./apply-uptime-monitors.mjs";
33
+
34
+ const CLI = fileURLToPath(new URL("./apply-uptime-monitors.mjs", import.meta.url));
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // parseMonitorConfig
38
+ // ---------------------------------------------------------------------------
39
+
40
+ test("parseMonitorConfig accepts a bare array of monitor entries", () => {
41
+ const { monitors } = parseMonitorConfig([{ url: "https://api.example.com/health" }]);
42
+ assert.equal(monitors.length, 1);
43
+ assert.equal(monitors[0].url, "https://api.example.com/health");
44
+ assert.equal(monitors[0].name, "api.example.com");
45
+ assert.equal(monitors[0].alertEmail, null);
46
+ assert.equal(monitors[0].checkFrequency, DEFAULT_CHECK_FREQUENCY_SECONDS);
47
+ });
48
+
49
+ test("parseMonitorConfig accepts a wrapped { monitors: [...] } object", () => {
50
+ const { monitors } = parseMonitorConfig({
51
+ monitors: [{ url: "https://x.example.com", name: "x", alertEmail: "a@b.com", checkFrequency: 60 }],
52
+ });
53
+ assert.deepEqual(monitors, [
54
+ { url: "https://x.example.com", name: "x", alertEmail: "a@b.com", checkFrequency: 60 },
55
+ ]);
56
+ });
57
+
58
+ test("parseMonitorConfig rejects a non-array, non-{monitors} shape", () => {
59
+ assert.throws(() => parseMonitorConfig({ foo: "bar" }), /must be a JSON array/);
60
+ });
61
+
62
+ test("parseMonitorConfig rejects an entry missing a valid url", () => {
63
+ assert.throws(() => parseMonitorConfig([{ url: "not-a-url" }]), /valid http\(s\) "url"/);
64
+ assert.throws(() => parseMonitorConfig([{}]), /valid http\(s\) "url"/);
65
+ });
66
+
67
+ test("parseMonitorConfig rejects a non-positive-integer checkFrequency", () => {
68
+ assert.throws(
69
+ () => parseMonitorConfig([{ url: "https://x.example.com", checkFrequency: -5 }]),
70
+ /"checkFrequency" must be a positive integer/
71
+ );
72
+ assert.throws(
73
+ () => parseMonitorConfig([{ url: "https://x.example.com", checkFrequency: "30" }]),
74
+ /"checkFrequency" must be a positive integer/
75
+ );
76
+ });
77
+
78
+ test("parseMonitorConfig rejects non-string name/alertEmail", () => {
79
+ assert.throws(() => parseMonitorConfig([{ url: "https://x.example.com", name: 5 }]), /"name" must be a string/);
80
+ assert.throws(
81
+ () => parseMonitorConfig([{ url: "https://x.example.com", alertEmail: 5 }]),
82
+ /"alertEmail" must be a string/
83
+ );
84
+ });
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // diffMonitors
88
+ // ---------------------------------------------------------------------------
89
+
90
+ test("diffMonitors classifies a brand-new url as toCreate", () => {
91
+ const desired = [{ url: "https://new.example.com", name: "new", alertEmail: null, checkFrequency: 30 }];
92
+ const { toCreate, toUpdate, unchanged } = diffMonitors(desired, []);
93
+ assert.equal(toCreate.length, 1);
94
+ assert.equal(toUpdate.length, 0);
95
+ assert.equal(unchanged.length, 0);
96
+ });
97
+
98
+ test("diffMonitors classifies a matching, identical url as unchanged", () => {
99
+ const desired = [{ url: "https://x.example.com", name: "x", alertEmail: null, checkFrequency: 30 }];
100
+ const live = [{ id: "1", url: "https://x.example.com", name: "x", checkFrequency: 30 }];
101
+ const { toCreate, toUpdate, unchanged } = diffMonitors(desired, live);
102
+ assert.equal(toCreate.length, 0);
103
+ assert.equal(toUpdate.length, 0);
104
+ assert.deepEqual(unchanged, ["https://x.example.com"]);
105
+ });
106
+
107
+ test("diffMonitors classifies a url with a drifted name/frequency as toUpdate", () => {
108
+ const desired = [{ url: "https://x.example.com", name: "renamed", alertEmail: null, checkFrequency: 60 }];
109
+ const live = [{ id: "1", url: "https://x.example.com", name: "x", checkFrequency: 30 }];
110
+ const { toUpdate } = diffMonitors(desired, live);
111
+ assert.equal(toUpdate.length, 1);
112
+ assert.equal(toUpdate[0].id, "1");
113
+ });
114
+
115
+ test("diffMonitors url matching is trailing-slash and case insensitive", () => {
116
+ const desired = [{ url: "https://X.example.com/", name: "x", alertEmail: null, checkFrequency: 30 }];
117
+ const live = [{ id: "1", url: "https://x.example.com", name: "x", checkFrequency: 30 }];
118
+ const { toCreate, unchanged } = diffMonitors(desired, live);
119
+ assert.equal(toCreate.length, 0);
120
+ assert.deepEqual(unchanged, ["https://X.example.com/"]);
121
+ });
122
+
123
+ test("diffMonitors never proposes deleting a live monitor absent from desired (additive apply only)", () => {
124
+ const desired = [{ url: "https://kept.example.com", name: "kept", alertEmail: null, checkFrequency: 30 }];
125
+ const live = [
126
+ { id: "1", url: "https://kept.example.com", name: "kept", checkFrequency: 30 },
127
+ { id: "2", url: "https://hand-added.example.com", name: "manual" },
128
+ ];
129
+ const result = diffMonitors(desired, live);
130
+ assert.ok(!("toDelete" in result), "diff result carries no deletion bucket at all");
131
+ });
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // createBetterStackClient (injected fetch seam)
135
+ // ---------------------------------------------------------------------------
136
+
137
+ function fakeFetch(responses) {
138
+ const calls = [];
139
+ return {
140
+ calls,
141
+ fetchImpl: async (url, init) => {
142
+ calls.push({ url, method: init?.method ?? "GET", body: init?.body });
143
+ const key = `${init?.method ?? "GET"} ${url}`;
144
+ const entry = responses[key];
145
+ if (!entry) throw new Error(`unexpected fetch call: ${key}`);
146
+ return {
147
+ ok: entry.status < 400,
148
+ status: entry.status,
149
+ statusText: entry.statusText ?? "",
150
+ json: async () => entry.body,
151
+ text: async () => JSON.stringify(entry.body),
152
+ };
153
+ },
154
+ };
155
+ }
156
+
157
+ test("createBetterStackClient.listMonitors normalizes the Better Stack payload shape", async () => {
158
+ const { fetchImpl, calls } = fakeFetch({
159
+ "GET https://uptime.betterstack.com/api/v2/monitors": {
160
+ status: 200,
161
+ body: {
162
+ data: [
163
+ {
164
+ id: "42",
165
+ attributes: { url: "https://a.example.com", pronounceable_name: "a", check_frequency: 30, email: "a@b.com" },
166
+ },
167
+ ],
168
+ },
169
+ },
170
+ });
171
+ const client = createBetterStackClient({ token: "tok", fetchImpl });
172
+ const monitors = await client.listMonitors();
173
+ assert.deepEqual(monitors, [
174
+ { id: "42", url: "https://a.example.com", name: "a", checkFrequency: 30, alertEmail: "a@b.com" },
175
+ ]);
176
+ assert.equal(calls[0].url, "https://uptime.betterstack.com/api/v2/monitors");
177
+ });
178
+
179
+ test("createBetterStackClient surfaces a non-ok response as a thrown error", async () => {
180
+ const { fetchImpl } = fakeFetch({
181
+ "GET https://uptime.betterstack.com/api/v2/monitors": { status: 401, statusText: "Unauthorized", body: {} },
182
+ });
183
+ const client = createBetterStackClient({ token: "bad", fetchImpl });
184
+ await assert.rejects(() => client.listMonitors(), /401/);
185
+ });
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // applyMonitorConfig — full orchestration against an injected client
189
+ // ---------------------------------------------------------------------------
190
+
191
+ function fakeClient({ live = [] } = {}) {
192
+ const created = [];
193
+ const updated = [];
194
+ return {
195
+ created,
196
+ updated,
197
+ listMonitors: async () => live,
198
+ createMonitor: async (entry) => {
199
+ created.push(entry);
200
+ return { id: `new-${created.length}` };
201
+ },
202
+ updateMonitor: async (id, entry) => {
203
+ updated.push({ id, entry });
204
+ return { id };
205
+ },
206
+ };
207
+ }
208
+
209
+ test("applyMonitorConfig dry-run computes the plan without calling create/update", async () => {
210
+ const client = fakeClient({ live: [] });
211
+ const result = await applyMonitorConfig({
212
+ config: { monitors: [{ url: "https://x.example.com", name: "x", alertEmail: null, checkFrequency: 30 }] },
213
+ client,
214
+ dryRun: true,
215
+ });
216
+ assert.deepEqual(result.created, ["https://x.example.com"]);
217
+ assert.equal(result.dryRun, true);
218
+ assert.equal(client.created.length, 0, "dry-run must not call createMonitor");
219
+ });
220
+
221
+ test("applyMonitorConfig --apply issues create for new monitors", async () => {
222
+ const client = fakeClient({ live: [] });
223
+ const result = await applyMonitorConfig({
224
+ config: { monitors: [{ url: "https://x.example.com", name: "x", alertEmail: null, checkFrequency: 30 }] },
225
+ client,
226
+ dryRun: false,
227
+ });
228
+ assert.deepEqual(result.created, ["https://x.example.com"]);
229
+ assert.equal(client.created.length, 1);
230
+ });
231
+
232
+ test("applyMonitorConfig falls back to defaultAlertEmail when an entry sets none", async () => {
233
+ const client = fakeClient({ live: [] });
234
+ await applyMonitorConfig({
235
+ config: { monitors: [{ url: "https://x.example.com", name: "x", alertEmail: null, checkFrequency: 30 }] },
236
+ client,
237
+ dryRun: false,
238
+ defaultAlertEmail: "oncall@example.com",
239
+ });
240
+ assert.equal(client.created[0].alertEmail, "oncall@example.com");
241
+ });
242
+
243
+ test("applyMonitorConfig issues update for a drifted existing monitor", async () => {
244
+ const client = fakeClient({ live: [{ id: "1", url: "https://x.example.com", name: "old", checkFrequency: 30 }] });
245
+ const result = await applyMonitorConfig({
246
+ config: { monitors: [{ url: "https://x.example.com", name: "new", alertEmail: null, checkFrequency: 30 }] },
247
+ client,
248
+ dryRun: false,
249
+ });
250
+ assert.deepEqual(result.updated, ["https://x.example.com"]);
251
+ assert.equal(client.updated[0].id, "1");
252
+ });
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // CLI — skip-with-notice and config-validation paths (real process spawn)
256
+ // ---------------------------------------------------------------------------
257
+
258
+ let tmpDir;
259
+
260
+ test("CLI skip-with-notice: no BETTERSTACK_API_TOKEN exits 0 with a notice, no crash", () => {
261
+ tmpDir = mkdtempSync(join(tmpdir(), "uptime-monitors-"));
262
+ const configPath = join(tmpDir, "monitors.json");
263
+ writeFileSync(configPath, JSON.stringify([{ url: "https://x.example.com" }]));
264
+ const out = execFileSync("node", [CLI, "--config", configPath], {
265
+ encoding: "utf8",
266
+ env: { ...process.env, BETTERSTACK_API_TOKEN: "" },
267
+ });
268
+ assert.match(out, /skipping uptime-monitor apply/);
269
+ rmSync(tmpDir, { recursive: true, force: true });
270
+ });
271
+
272
+ test("CLI exits non-zero on an invalid config file even with a token set", () => {
273
+ tmpDir = mkdtempSync(join(tmpdir(), "uptime-monitors-"));
274
+ const configPath = join(tmpDir, "monitors.json");
275
+ writeFileSync(configPath, JSON.stringify({ not: "a monitor list" }));
276
+ assert.throws(() => {
277
+ execFileSync("node", [CLI, "--config", configPath], {
278
+ encoding: "utf8",
279
+ env: { ...process.env, BETTERSTACK_API_TOKEN: "tok" },
280
+ });
281
+ }, /Command failed/);
282
+ rmSync(tmpDir, { recursive: true, force: true });
283
+ });
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Consumer-caller simulation — exercises the SAME invocation shape
287
+ // .github/workflows/uptime-apply.yml's "Apply uptime monitors" step
288
+ // constructs, end to end, against a real (mocked-transport) HTTP server.
289
+ // This is the in-repo stand-in for "the caller path is exercised": rather
290
+ // than only unit-testing the pure functions in isolation, this spins up a
291
+ // local HTTP server that speaks the Better Stack v2 monitors contract (list
292
+ // + create), then invokes the CLI exactly as the reusable workflow's `run:`
293
+ // step does (`node apply-uptime-monitors.mjs --config <path> [--dry-run|
294
+ // --apply]`, token/alert-email via env vars), against a monitor-config
295
+ // fixture shaped like the one templates/workflows/uptime-apply.yml points a
296
+ // real consumer at. See docs/reusable-workflows.md ("uptime-apply.yml") for
297
+ // the documented caller contract this simulates.
298
+ // ---------------------------------------------------------------------------
299
+
300
+ import { createServer } from "node:http";
301
+
302
+ function startFakeBetterStackServer() {
303
+ const requests = [];
304
+ let monitors = [];
305
+ const server = createServer((req, res) => {
306
+ const chunks = [];
307
+ req.on("data", (c) => chunks.push(c));
308
+ req.on("end", () => {
309
+ const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : null;
310
+ requests.push({ method: req.method, url: req.url, body });
311
+ res.setHeader("Content-Type", "application/json");
312
+ if (req.method === "GET" && req.url === "/monitors") {
313
+ res.writeHead(200);
314
+ res.end(JSON.stringify({ data: monitors }));
315
+ return;
316
+ }
317
+ if (req.method === "POST" && req.url === "/monitors") {
318
+ const id = String(monitors.length + 1);
319
+ monitors = [
320
+ ...monitors,
321
+ { id, attributes: { url: body.url, pronounceable_name: body.pronounceable_name, check_frequency: body.check_frequency, email: body.email } },
322
+ ];
323
+ res.writeHead(201);
324
+ res.end(JSON.stringify({ data: { id } }));
325
+ return;
326
+ }
327
+ res.writeHead(404);
328
+ res.end(JSON.stringify({ error: "not found" }));
329
+ });
330
+ });
331
+ return { server, requests, getMonitors: () => monitors };
332
+ }
333
+
334
+ test("consumer-caller simulation: end-to-end CLI invocation against a live (local) Better Stack server, dry-run", async () => {
335
+ const { server, requests } = startFakeBetterStackServer();
336
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
337
+ const { port } = server.address();
338
+
339
+ tmpDir = mkdtempSync(join(tmpdir(), "uptime-monitors-e2e-"));
340
+ const configPath = join(tmpDir, "monitors.json");
341
+ // Same shape as templates/workflows/uptime-apply.yml's <MONITOR_CONFIG_PATH>
342
+ // consumer fixture (e.g. infra/uptime/monitors.json).
343
+ writeFileSync(
344
+ configPath,
345
+ JSON.stringify([{ url: "https://smoke.example.com/health", name: "smoke", checkFrequency: 60 }])
346
+ );
347
+
348
+ // execFileAsync (not execFileSync): the fake server above runs IN THIS
349
+ // SAME PROCESS. A synchronous spawn would block this process's event loop
350
+ // while waiting on the child, which would in turn prevent the server's own
351
+ // request handler (which needs that same event loop) from ever running —
352
+ // a classic single-process self-deadlock. The async variant yields the
353
+ // event loop back so the server can answer the child's HTTP request.
354
+ const { stdout: out } = await execFileAsync(
355
+ "node",
356
+ [CLI, "--config", configPath, "--dry-run", "--alert-email", "oncall@example.com"],
357
+ {
358
+ encoding: "utf8",
359
+ env: { ...process.env, BETTERSTACK_API_TOKEN: "fake-token", BETTERSTACK_API_BASE_OVERRIDE: `http://127.0.0.1:${port}` },
360
+ }
361
+ );
362
+
363
+ await new Promise((resolve) => server.close(resolve));
364
+ rmSync(tmpDir, { recursive: true, force: true });
365
+
366
+ // Dry-run must have hit the real (local) list endpoint to compute the plan
367
+ // ... this is only meaningful once the CLI honors a base-URL override, so
368
+ // this assertion also locks in that seam for future local/offline runs.
369
+ assert.match(out, /would create/);
370
+ assert.ok(requests.some((r) => r.method === "GET" && r.url === "/monitors"), "dry-run calls the list endpoint");
371
+ assert.ok(!requests.some((r) => r.method === "POST"), "dry-run never issues a create/update call");
372
+ });