@gigzen/populace 1.3.1 → 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 +60 -60
- package/src/config.mjs +48 -4
- package/src/selftest.mjs +72 -0
package/package.json
CHANGED
|
@@ -1,60 +1,60 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@gigzen/populace",
|
|
3
|
-
"version": "1.3.
|
|
4
|
-
"description": "A simulated population that uses your app through its real API, so you can test what needs more than one person.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
"populace": "./src/cli.mjs"
|
|
8
|
-
},
|
|
9
|
-
"main": "./src/index.mjs",
|
|
10
|
-
"exports": {
|
|
11
|
-
".": "./src/index.mjs",
|
|
12
|
-
"./engine": "./src/engine/index.mjs"
|
|
13
|
-
},
|
|
14
|
-
"files": [
|
|
15
|
-
"src",
|
|
16
|
-
"adapters",
|
|
17
|
-
"examples",
|
|
18
|
-
"!**/populace-report.json",
|
|
19
|
-
"!**/populace-report.html",
|
|
20
|
-
"!examples/buzzbuzz/run-test.ps1",
|
|
21
|
-
"populace.config.example.mjs",
|
|
22
|
-
"README.md",
|
|
23
|
-
"LICENSE",
|
|
24
|
-
"action.yml"
|
|
25
|
-
],
|
|
26
|
-
"scripts": {
|
|
27
|
-
"demo": "node src/cli.mjs demo",
|
|
28
|
-
"doctor": "node src/cli.mjs doctor",
|
|
29
|
-
"test": "node src/selftest.mjs",
|
|
30
|
-
"prepublishOnly": "node src/selftest.mjs"
|
|
31
|
-
},
|
|
32
|
-
"engines": {
|
|
33
|
-
"node": ">=18"
|
|
34
|
-
},
|
|
35
|
-
"keywords": [
|
|
36
|
-
"simulation",
|
|
37
|
-
"testing",
|
|
38
|
-
"load-testing",
|
|
39
|
-
"multi-user",
|
|
40
|
-
"synthetic-users",
|
|
41
|
-
"qa"
|
|
42
|
-
],
|
|
43
|
-
"license": "AGPL-3.0-only",
|
|
44
|
-
"dependencies": {},
|
|
45
|
-
"devDependencies": {
|
|
46
|
-
"@supabase/supabase-js": "^2.45.0"
|
|
47
|
-
},
|
|
48
|
-
"author": "Sankur Kundu <sankur.kundu.tw@gmail.com>",
|
|
49
|
-
"repository": {
|
|
50
|
-
"type": "git",
|
|
51
|
-
"url": "git+https://github.com/Shakhtar-Sankur/populace.git"
|
|
52
|
-
},
|
|
53
|
-
"homepage": "https://github.com/Shakhtar-Sankur/populace#readme",
|
|
54
|
-
"bugs": {
|
|
55
|
-
"url": "https://github.com/Shakhtar-Sankur/populace/issues"
|
|
56
|
-
},
|
|
57
|
-
"publishConfig": {
|
|
58
|
-
"access": "public"
|
|
59
|
-
}
|
|
60
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@gigzen/populace",
|
|
3
|
+
"version": "1.3.2",
|
|
4
|
+
"description": "A simulated population that uses your app through its real API, so you can test what needs more than one person.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"populace": "./src/cli.mjs"
|
|
8
|
+
},
|
|
9
|
+
"main": "./src/index.mjs",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.mjs",
|
|
12
|
+
"./engine": "./src/engine/index.mjs"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src",
|
|
16
|
+
"adapters",
|
|
17
|
+
"examples",
|
|
18
|
+
"!**/populace-report.json",
|
|
19
|
+
"!**/populace-report.html",
|
|
20
|
+
"!examples/buzzbuzz/run-test.ps1",
|
|
21
|
+
"populace.config.example.mjs",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE",
|
|
24
|
+
"action.yml"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"demo": "node src/cli.mjs demo",
|
|
28
|
+
"doctor": "node src/cli.mjs doctor",
|
|
29
|
+
"test": "node src/selftest.mjs",
|
|
30
|
+
"prepublishOnly": "node src/selftest.mjs"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18"
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"simulation",
|
|
37
|
+
"testing",
|
|
38
|
+
"load-testing",
|
|
39
|
+
"multi-user",
|
|
40
|
+
"synthetic-users",
|
|
41
|
+
"qa"
|
|
42
|
+
],
|
|
43
|
+
"license": "AGPL-3.0-only",
|
|
44
|
+
"dependencies": {},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@supabase/supabase-js": "^2.45.0"
|
|
47
|
+
},
|
|
48
|
+
"author": "Sankur Kundu <sankur.kundu.tw@gmail.com>",
|
|
49
|
+
"repository": {
|
|
50
|
+
"type": "git",
|
|
51
|
+
"url": "git+https://github.com/Shakhtar-Sankur/populace.git"
|
|
52
|
+
},
|
|
53
|
+
"homepage": "https://github.com/Shakhtar-Sankur/populace#readme",
|
|
54
|
+
"bugs": {
|
|
55
|
+
"url": "https://github.com/Shakhtar-Sankur/populace/issues"
|
|
56
|
+
},
|
|
57
|
+
"publishConfig": {
|
|
58
|
+
"access": "public"
|
|
59
|
+
}
|
|
60
|
+
}
|
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
|
-
]
|
|
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)
|
|
141
|
-
const hit = targets.find((
|
|
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
|
);
|
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
|