@gigzen/populace 1.3.0 → 1.3.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gigzen/populace",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "A simulated population that uses your app through its real API, so you can test what needs more than one person.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -336,7 +336,17 @@ async function run() {
336
336
  });
337
337
  process.stdin.on("error", () => {}); // a closed pipe is not a reason to fail a run
338
338
  process.stdin.resume();
339
- process.stdin.unref();
339
+ /* unref() is NOT on every stdin. Node hands you a different stream
340
+ depending on what stdin is attached to, and only some of them are
341
+ sockets: with stdin redirected from a file or the null device — CI
342
+ runners, `docker run` without -i, most automation — it is an
343
+ fs.ReadStream, which has no unref, and calling it threw
344
+ "process.stdin.unref is not a function" before the run had started.
345
+ This whole branch exists to make non-interactive use work, so it was
346
+ failing in exactly the case it was written for. Nothing is lost when it
347
+ is absent: those streams reach EOF immediately and never hold the loop
348
+ open, which is all unref was buying. */
349
+ process.stdin.unref?.();
340
350
  }
341
351
 
342
352
  await world.populate();
package/src/config.mjs CHANGED
@@ -16,6 +16,40 @@ export class ConfigError extends Error {}
16
16
 
17
17
  const strip = (u) => String(u || "").trim().replace(/\/+$/, "").toLowerCase();
18
18
 
19
+ /**
20
+ * The hostname inside a string, however it was written.
21
+ *
22
+ * Everything here is a variation a person actually types: a trailing slash, a
23
+ * path on the end, no scheme at all, a port, capitals, stray whitespace. All of
24
+ * them must come out as the same host, because all of them are the same
25
+ * database.
26
+ *
27
+ * Returns null for a string that is not a host. That matters more than it
28
+ * looks: the denylist is compared against every string in the target block,
29
+ * keys and schema names included, and those must not be mistaken for hosts.
30
+ */
31
+ function hostOf(value) {
32
+ const s = strip(value).replace(/^[a-z][a-z0-9+.-]*:\/\//, "").replace(/^[^/@]*@/, "");
33
+ const host = s.split(/[/?#]/)[0].split(":")[0];
34
+ return /^[a-z0-9.-]+\.[a-z]{2,}$/.test(host) ? host.replace(/^\.+|\.+$/g, "") : null;
35
+ }
36
+
37
+ /**
38
+ * Is this target one of the hosts the customer forbade?
39
+ *
40
+ * Host equality, or a subdomain of a forbidden host — so naming
41
+ * `example.com` also stops `api.example.com`, which is what someone listing
42
+ * their production domain means.
43
+ *
44
+ * It is deliberately NOT a substring test. A substring test refuses
45
+ * `key: "k"` because some forbidden URL happens to contain the letter k, and a
46
+ * guard that cries wolf over a single character is a guard people switch off.
47
+ */
48
+ function sameHost(target, denied) {
49
+ if (!target || !denied) return false;
50
+ return target === denied || target.endsWith(`.${denied}`) || denied.endsWith(`.${target}`);
51
+ }
52
+
19
53
  /** Pull every string out of a nested object, so we can scan a whole target block. */
20
54
  function stringsIn(value, found = []) {
21
55
  if (typeof value === "string") found.push(value);
@@ -134,14 +168,24 @@ export function guardProduction(config) {
134
168
  .split(",")
135
169
  .map((s) => s.trim())
136
170
  .filter(Boolean),
137
- ].map(strip);
171
+ ];
172
+
173
+ // A denied entry that is not a host is kept as a literal, so a customer who
174
+ // writes something unusual in there still gets an exact match rather than
175
+ // silently nothing.
176
+ const deniedHosts = denied.map(hostOf).filter(Boolean);
177
+ const deniedLiterals = denied.filter((d) => !hostOf(d)).map(strip).filter(Boolean);
138
178
 
139
179
  if (denied.length) {
140
- const targets = stringsIn(config.target).map(strip).filter(Boolean);
141
- const hit = targets.find((t) => denied.some((d) => d && (t === d || t.includes(d) || d.includes(t))));
180
+ const targets = stringsIn(config.target);
181
+ const hit = targets.find((raw) => {
182
+ const host = hostOf(raw);
183
+ if (host && deniedHosts.some((d) => sameHost(host, d))) return true;
184
+ return deniedLiterals.includes(strip(raw));
185
+ });
142
186
  if (hit) {
143
187
  refuse(
144
- `The target matches a host listed in neverRunAgainst:\n ${hit}`,
188
+ `The target matches a host listed in neverRunAgainst:\n ${hostOf(hit) || strip(hit)}`,
145
189
  `Simulated people must never be visible to real users.\n` +
146
190
  ` Point \`target\` at a separate test environment.`,
147
191
  );
@@ -78,20 +78,20 @@ export function renderHtmlReport(report) {
78
78
  <style>
79
79
  :root{
80
80
  --paper:#F4F5F7;--card:#fff;--ink:#12181D;--body:#3A454E;--muted:#6B7883;
81
- --rule:#DDE2E7;--accent:#A85A0B;--ok:#2C6E4C;--bad:#A63127;--warn:#9A6A08;
81
+ --rule:#DDE2E7;--accent:#4338CA;--ok:#2C6E4C;--bad:#A63127;--warn:#9A6A08;
82
82
  --bar:#C9D4DC;--barbad:#E0A99F;
83
83
  --mono:ui-monospace,"SF Mono","Cascadia Mono",Menlo,Consolas,monospace;
84
84
  --sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;
85
85
  }
86
86
  @media (prefers-color-scheme:dark){:root{
87
87
  --paper:#0E1418;--card:#161E24;--ink:#E8EEF3;--body:#B0BDC7;--muted:#7B8994;
88
- --rule:#26323A;--accent:#E0A45C;--ok:#5FAB80;--bad:#DE7568;--warn:#D2A24E;
88
+ --rule:#26323A;--accent:#A5B4FC;--ok:#5FAB80;--bad:#DE7568;--warn:#D2A24E;
89
89
  --bar:#2E3A43;--barbad:#5C332C;}}
90
90
  :root[data-theme="dark"]{--paper:#0E1418;--card:#161E24;--ink:#E8EEF3;--body:#B0BDC7;
91
- --muted:#7B8994;--rule:#26323A;--accent:#E0A45C;--ok:#5FAB80;--bad:#DE7568;
91
+ --muted:#7B8994;--rule:#26323A;--accent:#A5B4FC;--ok:#5FAB80;--bad:#DE7568;
92
92
  --warn:#D2A24E;--bar:#2E3A43;--barbad:#5C332C;}
93
93
  :root[data-theme="light"]{--paper:#F4F5F7;--card:#fff;--ink:#12181D;--body:#3A454E;
94
- --muted:#6B7883;--rule:#DDE2E7;--accent:#A85A0B;--ok:#2C6E4C;--bad:#A63127;
94
+ --muted:#6B7883;--rule:#DDE2E7;--accent:#4338CA;--ok:#2C6E4C;--bad:#A63127;
95
95
  --warn:#9A6A08;--bar:#C9D4DC;--barbad:#E0A99F;}
96
96
  *{box-sizing:border-box}
97
97
  body{margin:0;background:var(--paper);color:var(--body);font-family:var(--sans);
package/src/selftest.mjs CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  normaliseError,
26
26
  summarise,
27
27
  } from "./instrument.mjs";
28
+ import { guardProduction } from "./config.mjs";
28
29
  import { buildReport, renderReport } from "./report.mjs";
29
30
  import { canSignInOnly, CONTRACT_METHODS, coverageOf, isStub } from "./contract.mjs";
30
31
  import { diagnose } from "./diagnose.mjs";
@@ -506,6 +507,77 @@ check("doctor names the cleanup mode", () => {
506
507
  );
507
508
  });
508
509
 
510
+ // --- 4c-bis. the denylist, which is the one thing that must never be wrong --
511
+ //
512
+ // Everything else in this file protects a report. This protects a database
513
+ // that real people's accounts are in. It had no coverage until a substring
514
+ // match was found refusing `key: "k"` — and the same test showed the far worse
515
+ // half: because the comparison included the scheme, writing the forbidden host
516
+ // as http:// instead of https:// walked straight past it.
517
+ //
518
+ // So the rule is hosts, not strings, and both directions are asserted: every
519
+ // way of spelling a forbidden host is refused, and nothing else is.
520
+
521
+ const FORBIDDEN = "prod.example.com";
522
+ const guarded = (target) => {
523
+ try {
524
+ guardProduction({
525
+ _file: "x", environment: "test",
526
+ neverRunAgainst: [`https://${FORBIDDEN}`], target,
527
+ });
528
+ return "ran";
529
+ } catch { return "refused"; }
530
+ };
531
+
532
+ for (const [how, target] of Object.entries({
533
+ "written plainly": `https://${FORBIDDEN}`,
534
+ "with no scheme": FORBIDDEN,
535
+ "over http instead of https": `http://${FORBIDDEN}`,
536
+ "as a websocket url": `wss://${FORBIDDEN}/realtime/v1`,
537
+ "as a postgres connection string": `postgresql://postgres:pw@${FORBIDDEN}:5432/postgres`,
538
+ "with credentials in front of it": `https://user:secret@${FORBIDDEN}`,
539
+ "with a port": `https://${FORBIDDEN}:443`,
540
+ "with a path, query and hash": `https://${FORBIDDEN}/rest/v1?a=1#b`,
541
+ "in capitals with a trailing slash": `HTTPS://${FORBIDDEN.toUpperCase()}/`,
542
+ "padded with whitespace": ` https://${FORBIDDEN} `,
543
+ "as a subdomain of it": `https://api.${FORBIDDEN}`,
544
+ "buried in a nested field": { url: "http://127.0.0.1:54321", replica: `https://${FORBIDDEN}` },
545
+ "hidden in an array": ["http://127.0.0.1:54321", `https://${FORBIDDEN}`],
546
+ })) {
547
+ check(`a forbidden host is refused ${how}`, () => {
548
+ assert.equal(guarded(target), "refused",
549
+ "this is the guard that keeps simulated people out of a real database");
550
+ });
551
+ }
552
+
553
+ check("a short field value is not mistaken for a forbidden host", () => {
554
+ // "e", "com" and "pro" are all substrings of prod.example.com. None is a host.
555
+ assert.equal(guarded({ url: "http://127.0.0.1:54321", key: "e", schema: "com", pool: "pro" }), "ran");
556
+ });
557
+
558
+ check("a different host on the same domain still runs", () => {
559
+ assert.equal(guarded({ url: "https://staging.example.com", key: "k" }), "ran");
560
+ });
561
+
562
+ check("naming a domain also covers its subdomains", () => {
563
+ // Someone who forbids example.com means all of it, not the apex alone.
564
+ assert.equal(guarded(`https://api.${FORBIDDEN}`), "refused");
565
+ });
566
+
567
+ check("an empty denylist warns rather than silently allowing", () => {
568
+ const c = guardProduction({ _file: "x", environment: "test", target: { url: "https://anything.example" } });
569
+ assert.ok((c._warnings || []).some((w) => /neverRunAgainst is empty/.test(w)));
570
+ });
571
+
572
+ check("a non-production environment is still required", () => {
573
+ for (const environment of ["production", "prod", undefined, "", "live"]) {
574
+ assert.throws(() => guardProduction({
575
+ _file: "x", environment, target: { url: "http://127.0.0.1:54321" },
576
+ neverRunAgainst: [`https://${FORBIDDEN}`],
577
+ }), `environment "${environment}" must not be accepted`);
578
+ }
579
+ });
580
+
509
581
  // --- 4d. teardown must not claim removals it did not make ------------------
510
582
  // selfDestruct() does nothing when there is no account and no deleteUser, and
511
583
  // teardown counted both as successes — so a run could print "Cleanup complete