@catalystiq/test-kit 0.3.2 → 0.4.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.
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * 1. A database URL in the test environment whose host is on the repo's production list is refused. An entry matches the
5
5
  * host itself, any subdomain of it, and, for a host whose first label is a compute endpoint id (`ep-…`), that
6
- * endpoint with or without its `-pooler` suffix.
6
+ * endpoint with or without its `-pooler` suffix. A host is read in every spelling a client resolves
7
+ * (`canonicalHost`): percent-encoded, in fullwidth letters, with a soft hyphen inside.
7
8
  * 2. A database URL in the test environment that names the same database as any `.env*` file at the repo root is
8
9
  * refused: those files hold a developer's own (perhaps production) connection, and a test must never reach it. Every
9
10
  * `postgres://` text in the files counts, in any variable, quoted any way, or in a comment, except the lines whose key
@@ -14,10 +15,80 @@ import { join } from 'node:path';
14
15
  const LOOPBACK = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
15
16
  /** A Postgres URL anywhere in a text. */
16
17
  const POSTGRES_URL = /postgres(?:ql)?:\/\/[^\s'"`]+/gi;
18
+ /** A value that holds a Postgres URL, or opens with a Postgres scheme however it goes on (`postgres:user:pw@host/db`). */
19
+ const HOLDS_SCHEME = /postgres(?:ql)?:\/\/|^postgres(?:ql)?:/i;
20
+ /** A value without the white space and control characters it opens with, which a URL parser drops there. */
21
+ function withoutLeadingControls(value) {
22
+ let start = 0;
23
+ while (start < value.length && value.charCodeAt(start) <= 0x20)
24
+ start++;
25
+ return value.slice(start);
26
+ }
17
27
  /** The text's own punctuation after a URL is not part of it: `(postgres://h/db)` names `db`. */
18
28
  function trimmedUrl(match) {
19
29
  return match.replace(/[)>,;.]+$/, '');
20
30
  }
31
+ /** The code points IDNA mapping ignores, so a host holding one resolves without it: a soft hyphen, a zero-width space. */
32
+ const IGNORED = /\p{Default_Ignorable_Code_Point}/gu;
33
+ /** The ideographic full stop, which IDNA mapping reads as a dot (NFKC has already made the fullwidth and halfwidth ones a dot or this). */
34
+ const FULL_STOPS = /\u3002/g;
35
+ /** The tab and line breaks a URL parser drops from anywhere in a URL, before it reads anything else. */
36
+ const URL_DROPPED = /[\t\n\r]/g;
37
+ /** A text with each run of `%XX` escapes decoded as UTF-8, so one malformed `%` elsewhere cannot hide the rest. */
38
+ function percentDecoded(text) {
39
+ return text.replace(/(?:%[0-9a-f]{2})+/gi, (run) => Buffer.from(run.replaceAll('%', ''), 'hex').toString('utf8'));
40
+ }
41
+ /** A text percent-decoded and IDNA-mapped, near enough: compatibility forms as ASCII (a fullwidth `ep-` is `ep-`), lowercased. */
42
+ function mapped(text) {
43
+ return percentDecoded(text).normalize('NFKC').replace(IGNORED, '').replace(FULL_STOPS, '.').toLowerCase();
44
+ }
45
+ /**
46
+ * A host as a client resolves it, in ASCII where it has an ASCII reading: without the tab and line breaks a URL
47
+ * parser drops (dropped first, as it does, so one inside a `%XX` escape does not break the escape), then {@link mapped}. A `postgres:` URL keeps its host as written, but the Neon driver re-reads the string as an
48
+ * `http:` URL and node-postgres percent-decodes it, so `ep-…`, `e%70-…` and `ep-pr<soft hyphen>od-…` all connect to
49
+ * `ep-prod-…`. It drops more than a URL parser does (a guard errs on refusing).
50
+ */
51
+ export function canonicalHost(text) {
52
+ return mapped(text.replace(URL_DROPPED, '')).replace(URL_DROPPED, '');
53
+ }
54
+ /** A backslash escape of a libpq keyword/value string (`host=e\p-…`, `host='ep\-…'`): the client reads the character after it. */
55
+ const LIBPQ_ESCAPE = /\\([^\\])/g;
56
+ /**
57
+ * A host as an `http:` URL reads it, or null: an IPv4 address in any numeric spelling as dotted decimal (`0x0a.0.0.5`,
58
+ * `167772165` and `10.5` are `10.0.0.5`), an internationalised name in its `xn--` form. Only a bare host is read.
59
+ */
60
+ function urlHost(host) {
61
+ if (host === '' || /[\s/?#@\\:[\]%]/.test(host))
62
+ return null;
63
+ try {
64
+ return new URL(`http://${host}/`).hostname || null;
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ /**
71
+ * Every reading of a text a host is looked for in: as written, mapped, and as {@link canonicalHost} reads it (a
72
+ * dropped character joins what stood on either side of it, which can hide a host as well as show one), and the last
73
+ * two without libpq's backslash escapes. Then the same of the text percent-decoded, up to three times: a route encoded
74
+ * twice (`options=endpoint%253D…`) is decoded by the client that reads the parameter.
75
+ */
76
+ export function hostSpellings(text) {
77
+ const spellings = new Set();
78
+ let form = text;
79
+ for (let round = 0; round < 4; round++) {
80
+ spellings.add(form);
81
+ for (const text of new Set([form, form.replace(LIBPQ_ESCAPE, '$1')])) {
82
+ for (const spelling of [mapped(text), canonicalHost(text)])
83
+ spellings.add(spelling);
84
+ }
85
+ const next = percentDecoded(form);
86
+ if (next === form)
87
+ break;
88
+ form = next;
89
+ }
90
+ return [...spellings];
91
+ }
21
92
  /** The endpoint id of a host whose first label is one (`ep-quiet-sun-123-pooler.region.example` → `ep-quiet-sun-123`). */
22
93
  export function endpointOf(host) {
23
94
  const first = host.toLowerCase().split('.')[0] ?? '';
@@ -25,6 +96,15 @@ export function endpointOf(host) {
25
96
  return null;
26
97
  return first.endsWith('-pooler') ? first.slice(0, -'-pooler'.length) : first;
27
98
  }
99
+ /** A URL part percent-decoded, or as written when an escape in it is malformed (`app_%s`, a template). */
100
+ function decodedOrAsWritten(part) {
101
+ try {
102
+ return decodeURIComponent(part);
103
+ }
104
+ catch {
105
+ return part;
106
+ }
107
+ }
28
108
  /**
29
109
  * The one database a URL names, as a comparable key: `endpoint:<id>` for an endpoint host, else
30
110
  * `<host>:<port>/<database>` with every loopback name read as `localhost`. Null when the text is not a Postgres URL.
@@ -39,11 +119,11 @@ export function databasePlace(url) {
39
119
  }
40
120
  if (parsed.protocol !== 'postgres:' && parsed.protocol !== 'postgresql:')
41
121
  return null;
42
- const host = parsed.hostname.toLowerCase();
122
+ const host = canonicalHost(parsed.hostname);
43
123
  const endpoint = endpointOf(host);
44
124
  if (endpoint)
45
125
  return `endpoint:${endpoint}`;
46
- const database = decodeURIComponent(parsed.pathname.replace(/^\//, '')) || decodeURIComponent(parsed.username) || 'postgres';
126
+ const database = decodedOrAsWritten(parsed.pathname.replace(/^\//, '')) || decodedOrAsWritten(parsed.username) || 'postgres';
47
127
  return `${LOOPBACK.has(host) || host === '' ? 'localhost' : host}:${parsed.port || '5432'}/${database}`;
48
128
  }
49
129
  /** Every Postgres URL in a text. */
@@ -78,40 +158,159 @@ export function protectedPlaces(root, testDatabaseEnv = []) {
78
158
  }
79
159
  return [...places].sort();
80
160
  }
81
- /** Whether `host` is on the production list. */
82
- export function isProductionHost(host, productionHosts) {
83
- const h = host.toLowerCase();
84
- const endpoint = endpointOf(h);
85
- return productionHosts.some((raw) => {
86
- const entry = raw.trim().toLowerCase();
87
- if (entry === '')
88
- return false;
89
- if (h === entry || h.endsWith(`.${entry}`))
90
- return true;
91
- return endpoint !== null && endpointOf(entry) === endpoint;
161
+ /** A host whose every label is a number: an IPv4 address in some form, or a version, a port, a count. */
162
+ const NUMERIC_HOST = /^(?:0x[0-9a-f]*|\d+)(?:\.(?:0x[0-9a-f]*|\d+))*$/i;
163
+ /**
164
+ * Every reading of one host: each of its {@link hostSpellings} lowercased, and as an `http:` URL reads it (before and
165
+ * after lowercasing: a capital sigma lowercases by its position, and a URL maps it its own way). A trailing dot is the
166
+ * same name, fully qualified. With `numeric` false a host of numbers alone is not read as an address.
167
+ */
168
+ function hostReadings(host, numeric) {
169
+ const readings = new Set();
170
+ for (const spelling of hostSpellings(host)) {
171
+ const written = spelling.replace(/\.+$/, '');
172
+ const lower = written.toLowerCase();
173
+ readings.add(lower);
174
+ for (const form of new Set([written, lower])) {
175
+ if (!numeric && NUMERIC_HOST.test(form))
176
+ continue;
177
+ const read = urlHost(form);
178
+ if (read)
179
+ readings.add(read);
180
+ }
181
+ }
182
+ return [...readings];
183
+ }
184
+ /** Each list entry with the forms it is compared in: as written and as a URL reads it (a name in its `xn--` form). */
185
+ function listedForms(productionHosts) {
186
+ return productionHosts
187
+ .map((raw) => raw.trim())
188
+ .filter((entry) => entry !== '')
189
+ .map((entry) => {
190
+ const lower = entry.toLowerCase();
191
+ return { entry, forms: [...new Set([lower, urlHost(entry) ?? lower])] };
92
192
  });
93
193
  }
94
- /** Throws when `url` names a production host or a database a `.env*` file names. The message never carries the URL. */
194
+ /** The list entry one of a host's readings is: the host itself, a subdomain of it, or the same endpoint id. */
195
+ function entryOf(readings, listed) {
196
+ for (const h of readings) {
197
+ const endpoint = endpointOf(h);
198
+ for (const { entry, forms } of listed) {
199
+ if (forms.some((form) => h === form || h.endsWith(`.${form}`) || (endpoint !== null && endpointOf(form) === endpoint)))
200
+ return entry;
201
+ }
202
+ }
203
+ return null;
204
+ }
205
+ /** Whether `host` is on the production list, in any spelling a client resolves to a listed host ({@link hostReadings}). */
206
+ export function isProductionHost(host, productionHosts) {
207
+ return entryOf(hostReadings(host, true), listedForms(productionHosts)) !== null;
208
+ }
209
+ /** A host-shaped run of a value: labels of letters, digits and `-`, joined by dots (`ep-…-pooler.c-1.example`, `ep-…`). */
210
+ const HOST_TOKEN = /[a-z0-9-]+(?:\.[a-z0-9-]+)*/gi;
211
+ /** A character outside ASCII. */
212
+ export const NON_ASCII = /[\u0080-\u{10ffff}]/u;
213
+ /**
214
+ * The same with any character outside ASCII in its labels: an internationalised name, which a client resolves in its
215
+ * `xn--` form. Any character, not letters alone: a name may hold a middle dot or a zero-width non-joiner.
216
+ */
217
+ const UNICODE_HOST_TOKEN = /(?:[a-z0-9-]|[\u0080-\u{10ffff}])+(?:\.(?:[a-z0-9-]|[\u0080-\u{10ffff}])+)*/giu;
218
+ /**
219
+ * Where a host stands: after a URL's `//` or `\\`, after a Postgres scheme and one slash, as a keyword/value
220
+ * string's `host=` or `hostaddr=`, or after another host of a list (`db.example:5432,`).
221
+ */
222
+ const HOST_POSITION = /(?:[/\\]{2}|postgres(?:ql)?:[/\\]|host(?:addr)?\s*=\s*['"]?|[a-z](?::\d+)?,)$/i;
223
+ /** Straight after a Postgres scheme: a host when a path, a port or a query follows it (`postgres:10.5/db`), else an image tag (`postgres:10.5`). */
224
+ const SCHEME_BEFORE = /postgres(?:ql)?:$/i;
225
+ /** The text before a URL's `@`, back to the last white space: a URL's own (`//`, a Postgres scheme), not `name@1.2.3`. */
226
+ const URL_BEFORE_AT = /(?:[/\\]{2}|postgres(?:ql)?:)\S*@$/i;
227
+ /** Whether the token at `index` of `text` stands where a host does. */
228
+ function inHostPosition(text, index, length) {
229
+ const before = text.slice(Math.max(0, index - 512), index);
230
+ if (SCHEME_BEFORE.test(before))
231
+ return /^[/\\:?]/.test(text.slice(index + length));
232
+ return HOST_POSITION.test(before) || URL_BEFORE_AT.test(before);
233
+ }
234
+ /**
235
+ * The production list entry a value names anywhere in it, or null. One pass over every host-shaped token of the value,
236
+ * in each of its {@link hostSpellings} (the characters a URL parser drops are dropped before the tokens are cut), so
237
+ * every connection-string shape counts: a URL (one host or several), its `?host=` and `options=endpoint%3D…`, a
238
+ * keyword/value string (`host=… dbname=…`), `PGHOST` (with a port), `PGOPTIONS endpoint=…`. A token of numbers alone is
239
+ * read as an address only where a host stands ({@link inHostPosition}): elsewhere it is a version or a count
240
+ * (`npm/10.8.2` is not `10.8.0.2`, `web@10.0.5` is not `10.0.0.5`).
241
+ */
242
+ export function productionEntryIn(value, productionHosts) {
243
+ const listed = listedForms(productionHosts);
244
+ const seen = new Set();
245
+ for (const text of hostSpellings(value)) {
246
+ const tokens = [...text.matchAll(HOST_TOKEN)];
247
+ // Only a token with a character outside ASCII is new here: the rest are the tokens above.
248
+ for (const m of text.matchAll(UNICODE_HOST_TOKEN))
249
+ if (NON_ASCII.test(m[0]))
250
+ tokens.push(m);
251
+ for (const m of tokens) {
252
+ const numeric = NUMERIC_HOST.test(m[0]) && inHostPosition(text, m.index, m[0].length);
253
+ const key = `${numeric ? 1 : 0}${m[0]}`;
254
+ if (seen.has(key))
255
+ continue;
256
+ seen.add(key);
257
+ const entry = entryOf(hostReadings(m[0], numeric), listed);
258
+ if (entry)
259
+ return entry;
260
+ }
261
+ }
262
+ return null;
263
+ }
264
+ /** The refusal of a production database: it carries the matched list entry, never the value or any part of it. */
265
+ function productionRefusal(label, entry) {
266
+ return new Error(`test-kit: ${label} names a PRODUCTION database host (${entry}). Tests never connect to production.`);
267
+ }
268
+ /**
269
+ * Throws when `url` names a production list entry (as its host, or anywhere else in it: `?host=`, `options=endpoint…`;
270
+ * also when it does not parse as a URL at all) or a database a `.env*` file names. The message carries the matched list entry, never the URL or any part of it.
271
+ */
95
272
  export function assertSafeDatabaseUrl(url, rules, label = 'a database URL') {
96
- const place = databasePlace(url);
97
- if (place === null)
98
- return;
99
- const host = new URL(url).hostname;
100
- if (isProductionHost(host, rules.productionHosts)) {
101
- throw new Error(`test-kit: ${label} names a PRODUCTION database host (${host}). Tests never connect to production.`);
273
+ // The host as a postgres: URL holds it and as an http: URL reads the same string, which is how the Neon driver
274
+ // reads it: a backslash ends the host there (`prod\@other` is `prod`), and `postgres:///host` has one.
275
+ const hosts = [];
276
+ for (const reading of [url, url.replace(/^[a-z][a-z0-9+.-]*:/i, 'http:')]) {
277
+ try {
278
+ hosts.push(new URL(reading).hostname);
279
+ }
280
+ catch {
281
+ // A reading that does not parse names no host; the search below still reads the text.
282
+ }
102
283
  }
103
- if (rules.protectedPlaces.includes(place)) {
284
+ const listed = listedForms(rules.productionHosts);
285
+ const entry = entryOf(hosts.filter((host) => host !== '').flatMap((host) => hostReadings(host, true)), listed) ?? productionEntryIn(url, rules.productionHosts);
286
+ if (entry)
287
+ throw productionRefusal(label, entry);
288
+ const place = databasePlace(url);
289
+ if (place !== null && rules.protectedPlaces.includes(place)) {
104
290
  throw new Error(`test-kit: ${label} names the same database as a .env* file at the repository root. ` +
105
291
  'A test database must be its own (a container, or a branch made for the run).');
106
292
  }
107
293
  }
108
- /** Runs {@link assertSafeDatabaseUrl} over every Postgres URL any variable of `env` holds. */
294
+ /**
295
+ * Runs {@link assertSafeDatabaseUrl} over every Postgres URL any variable of `env` holds: in the value as written, and
296
+ * without the characters a URL parser drops (a tab, a line break, a zero-width no-break space), which would otherwise
297
+ * end the URL where one stands.
298
+ */
109
299
  export function assertSafeDatabaseEnv(env, rules) {
110
300
  for (const [name, value] of Object.entries(env)) {
111
301
  if (!value)
112
302
  continue;
113
- for (const url of postgresUrlsIn(value))
114
- assertSafeDatabaseUrl(url, rules, `$${name}`);
303
+ for (const text of new Set([value, value.replace(URL_DROPPED, '').replace(IGNORED, '')])) {
304
+ for (const url of postgresUrlsIn(text))
305
+ assertSafeDatabaseUrl(url, rules, `$${name}`);
306
+ }
307
+ // The scan above ends a URL at a quote or a space (a password may hold one) and reads no scheme without `//`
308
+ // (`postgres:u:p@host/db`, which an `http:` reading still resolves): the whole of such a value is searched too.
309
+ if (HOLDS_SCHEME.test(withoutLeadingControls(value.replace(URL_DROPPED, '').replace(IGNORED, '')))) {
310
+ const entry = productionEntryIn(value, rules.productionHosts);
311
+ if (entry)
312
+ throw productionRefusal(`$${name}`, entry);
313
+ }
115
314
  }
116
315
  }
117
316
  /** The hosts of every Postgres URL `env` holds: what the network guard lets through once the URLs passed the check. */
@@ -125,6 +324,9 @@ export function databaseHostsIn(env) {
125
324
  const host = new URL(url).hostname;
126
325
  if (host)
127
326
  hosts.add(host.toLowerCase());
327
+ // The same host as the clients resolve it, which is where the socket goes.
328
+ if (host)
329
+ hosts.add(canonicalHost(host));
128
330
  }
129
331
  catch {
130
332
  // not a URL after all
@@ -57,7 +57,7 @@ export function installNetworkGuard(allowHosts = []) {
57
57
  s.realFetch = realFetch;
58
58
  const guardedFetch = (async (input, init) => {
59
59
  const url = requestUrl(input);
60
- let host = '';
60
+ let host;
61
61
  try {
62
62
  const parsed = new URL(url);
63
63
  if (parsed.protocol === 'data:' || parsed.protocol === 'blob:')
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@catalystiq/test-kit",
3
- "version": "0.3.2",
4
- "description": "Shared test setup for Vitest, a fake MCP Apps host for jsdom and Playwright, the local Neon test-database run, and the local environment guard.",
3
+ "version": "0.4.0",
4
+ "description": "Shared test setup for Vitest, a fake MCP Apps host for jsdom and Playwright, the local Neon test-database run, the local environment guard, the database endpoint reader and the install hardening check.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "sideEffects": true,
@@ -42,6 +42,15 @@
42
42
  "types": "./dist/env/index.d.ts",
43
43
  "import": "./dist/env/index.js"
44
44
  },
45
+ "./neon-endpoint": {
46
+ "types": "./dist/neon-endpoint/index.d.ts",
47
+ "import": "./dist/neon-endpoint/index.js",
48
+ "default": "./dist/neon-endpoint/index.js"
49
+ },
50
+ "./install": {
51
+ "types": "./dist/install/index.d.ts",
52
+ "import": "./dist/install/index.js"
53
+ },
45
54
  "./neon-test-db": {
46
55
  "types": "./dist/neon-test-db/neon-test-db.d.mts",
47
56
  "import": "./dist/neon-test-db/neon-test-db.mjs"
@@ -53,6 +62,7 @@
53
62
  "tk-check-allowlist": "dist/bin/check-allowlist.js",
54
63
  "tk-dev-guard": "dist/bin/dev-guard.js",
55
64
  "tk-check-env-example": "dist/bin/check-env-example.js",
65
+ "tk-check-install-scripts": "dist/bin/check-install-scripts.js",
56
66
  "tk-neon-test-db": "dist/neon-test-db/neon-test-db.mjs"
57
67
  },
58
68
  "files": [
@@ -94,7 +104,7 @@
94
104
  }
95
105
  },
96
106
  "devDependencies": {
97
- "@eslint/js": "9.39.5",
107
+ "@eslint/js": "10.0.1",
98
108
  "@modelcontextprotocol/client": "2.4.0",
99
109
  "@modelcontextprotocol/core": "2.4.0",
100
110
  "@modelcontextprotocol/ext-apps": "2.0.4",
@@ -107,7 +117,7 @@
107
117
  "@types/react": "19.3.0",
108
118
  "@types/react-dom": "19.3.0",
109
119
  "esbuild": "0.28.2",
110
- "eslint": "9.39.5",
120
+ "eslint": "10.12.0",
111
121
  "jsdom": "30.1.3",
112
122
  "react": "19.3.1",
113
123
  "react-dom": "19.3.1",