mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
fix(seed-contract): address review round 2 — NaN/empty-string validation, Error cause, parity CI wiring
P2 — Non-finite ttlSeconds/maxStaleMin bypassed validation.
`typeof NaN === 'number'` and `NaN > 0 === false` meant a NaN duration
passed the old typeof+<=0 checks and would have poisoned TTLs once
validateDescriptor is wired into runSeed. Now gated by Number.isFinite,
which rejects NaN and ±Infinity. Tests added for NaN/Infinity on both
fields.
P2 — Empty/whitespace-only strings for domain/resource/canonicalKey/sourceVersion
bypassed validation. Added .trim() === '' rejection + tests per field.
This mattered because canonicalKey='' would have landed writes at the
empty key and seed-meta under a blank resource namespace.
P3 — SeedContractError silently dropped the Error v2 cause option.
Constructor now forwards { cause } through super() so err.cause works
with standard tooling (Node's default stack printer, Sentry chained-cause
serialization). resolveRecordCount's manual err.cause = err assignment
was replaced with the options-bag form. Test added for both constructor
direct-use and the resolveRecordCount wrap path.
P3 — Parity verifier was not on an automated path. Added
tests/seed-envelope-parity.test.mjs which spawns scripts/verify-seed-envelope-parity.mjs
via execFile; non-zero exit (drift) → test fails. Now runs as part of
`npm run test:data` (tsx --test tests/*.test.mjs). Drift injection
confirmed: sed -i modifying api/_seed-envelope.js makes the test fail
with 'Command failed' from execFile.
51 tests total (was 39). All green on clean tree.
This commit is contained in:
@@ -8,8 +8,11 @@
|
||||
// contract is enforced at runtime. PR 3 hard-fails the conformance test.
|
||||
|
||||
export class SeedContractError extends Error {
|
||||
constructor(message, { descriptor, field } = {}) {
|
||||
super(message);
|
||||
constructor(message, { descriptor, field, cause } = {}) {
|
||||
// Pass `cause` through the standard Error options bag (Node ≥16.9) so the
|
||||
// usual Error causal-chain tooling works (`err.cause`, Node's default
|
||||
// stack printer, Sentry's chained-cause serializer).
|
||||
super(message, cause !== undefined ? { cause } : undefined);
|
||||
this.name = 'SeedContractError';
|
||||
this.descriptor = descriptor;
|
||||
this.field = field;
|
||||
@@ -82,14 +85,26 @@ export function validateDescriptor(descriptor) {
|
||||
}
|
||||
}
|
||||
|
||||
if (descriptor.ttlSeconds <= 0) {
|
||||
throw new SeedContractError('runSeed descriptor ttlSeconds must be > 0', { descriptor, field: 'ttlSeconds' });
|
||||
// Non-empty-string fields. `typeof 'string'` accepts '' which would let a
|
||||
// seeder publish to key '' and write seed-meta under a blank resource.
|
||||
for (const field of ['domain', 'resource', 'canonicalKey', 'sourceVersion']) {
|
||||
if (descriptor[field].trim() === '') {
|
||||
throw new SeedContractError(`runSeed descriptor field "${field}" must be a non-empty string`, { descriptor, field });
|
||||
}
|
||||
}
|
||||
|
||||
// Finite positive numbers. `typeof NaN === 'number'` and `NaN > 0 === false`
|
||||
// means a NaN ttl/age would pass the typeof+<=0 check and then poison
|
||||
// expiry/freshness once enforced at runtime. Number.isFinite rejects NaN and
|
||||
// ±Infinity.
|
||||
if (!Number.isFinite(descriptor.ttlSeconds) || descriptor.ttlSeconds <= 0) {
|
||||
throw new SeedContractError('runSeed descriptor ttlSeconds must be a finite number > 0', { descriptor, field: 'ttlSeconds' });
|
||||
}
|
||||
if (!Number.isInteger(descriptor.schemaVersion) || descriptor.schemaVersion < 1) {
|
||||
throw new SeedContractError('runSeed descriptor schemaVersion must be a positive integer', { descriptor, field: 'schemaVersion' });
|
||||
}
|
||||
if (descriptor.maxStaleMin <= 0) {
|
||||
throw new SeedContractError('runSeed descriptor maxStaleMin must be > 0', { descriptor, field: 'maxStaleMin' });
|
||||
if (!Number.isFinite(descriptor.maxStaleMin) || descriptor.maxStaleMin <= 0) {
|
||||
throw new SeedContractError('runSeed descriptor maxStaleMin must be a finite number > 0', { descriptor, field: 'maxStaleMin' });
|
||||
}
|
||||
|
||||
if (descriptor.populationMode != null && descriptor.populationMode !== 'scheduled' && descriptor.populationMode !== 'on_demand') {
|
||||
@@ -121,9 +136,10 @@ export function resolveRecordCount(declareRecords, data) {
|
||||
try {
|
||||
count = declareRecords(data);
|
||||
} catch (err) {
|
||||
const wrapped = new SeedContractError(`declareRecords threw: ${err && err.message ? err.message : err}`, { field: 'declareRecords' });
|
||||
wrapped.cause = err;
|
||||
throw wrapped;
|
||||
throw new SeedContractError(
|
||||
`declareRecords threw: ${err && err.message ? err.message : err}`,
|
||||
{ field: 'declareRecords', cause: err }
|
||||
);
|
||||
}
|
||||
if (typeof count !== 'number' || !Number.isInteger(count) || count < 0) {
|
||||
throw new SeedContractError(
|
||||
|
||||
@@ -71,6 +71,33 @@ test('validateDescriptor: rejects ttlSeconds <= 0', () => {
|
||||
assert.throws(() => validateDescriptor(minimalDescriptor({ ttlSeconds: -5 })), SeedContractError);
|
||||
});
|
||||
|
||||
test('validateDescriptor: rejects non-finite ttlSeconds (NaN, Infinity)', () => {
|
||||
// `typeof NaN === "number"` and `NaN > 0 === false`, so the old check
|
||||
// silently accepted it; Number.isFinite is what we actually want.
|
||||
assert.throws(() => validateDescriptor(minimalDescriptor({ ttlSeconds: NaN })), SeedContractError);
|
||||
assert.throws(() => validateDescriptor(minimalDescriptor({ ttlSeconds: Infinity })), SeedContractError);
|
||||
assert.throws(() => validateDescriptor(minimalDescriptor({ ttlSeconds: -Infinity })), SeedContractError);
|
||||
});
|
||||
|
||||
test('validateDescriptor: rejects non-finite maxStaleMin (NaN, Infinity)', () => {
|
||||
assert.throws(() => validateDescriptor(minimalDescriptor({ maxStaleMin: NaN })), SeedContractError);
|
||||
assert.throws(() => validateDescriptor(minimalDescriptor({ maxStaleMin: Infinity })), SeedContractError);
|
||||
});
|
||||
|
||||
for (const field of ['domain', 'resource', 'canonicalKey', 'sourceVersion']) {
|
||||
test(`validateDescriptor: rejects empty string for "${field}"`, () => {
|
||||
const err = expectThrows(() => validateDescriptor(minimalDescriptor({ [field]: '' })));
|
||||
assert.ok(err instanceof SeedContractError);
|
||||
assert.equal(err.field, field);
|
||||
});
|
||||
|
||||
test(`validateDescriptor: rejects whitespace-only string for "${field}"`, () => {
|
||||
const err = expectThrows(() => validateDescriptor(minimalDescriptor({ [field]: ' ' })));
|
||||
assert.ok(err instanceof SeedContractError);
|
||||
assert.equal(err.field, field);
|
||||
});
|
||||
}
|
||||
|
||||
test('validateDescriptor: rejects non-integer schemaVersion', () => {
|
||||
assert.throws(() => validateDescriptor(minimalDescriptor({ schemaVersion: 1.5 })), SeedContractError);
|
||||
assert.throws(() => validateDescriptor(minimalDescriptor({ schemaVersion: 0 })), SeedContractError);
|
||||
@@ -115,6 +142,13 @@ test('resolveRecordCount: wraps thrown errors with SeedContractError + cause', (
|
||||
assert.equal(err.cause?.message, 'boom');
|
||||
});
|
||||
|
||||
test('SeedContractError: accepts cause via options bag (Error v2 spec)', () => {
|
||||
const underlying = new TypeError('inner');
|
||||
const err = new SeedContractError('wrap', { field: 'declareRecords', cause: underlying });
|
||||
assert.equal(err.cause, underlying);
|
||||
assert.equal(err.field, 'declareRecords');
|
||||
});
|
||||
|
||||
test('resolveRecordCount: rejects non-function declareRecords', () => {
|
||||
assert.throws(() => resolveRecordCount('not a function', {}), SeedContractError);
|
||||
});
|
||||
|
||||
35
tests/seed-envelope-parity.test.mjs
Normal file
35
tests/seed-envelope-parity.test.mjs
Normal file
@@ -0,0 +1,35 @@
|
||||
// Drift check for the seed-envelope helpers.
|
||||
//
|
||||
// `scripts/verify-seed-envelope-parity.mjs` diffs function bodies between:
|
||||
// - scripts/_seed-envelope-source.mjs (source of truth)
|
||||
// - api/_seed-envelope.js (edge-safe mirror)
|
||||
//
|
||||
// This test runs the verifier as a child process during `npm run test:data`
|
||||
// so drift between the two JS copies fails CI. Without this, someone could
|
||||
// hand-edit api/_seed-envelope.js and the parity guarantee — which is the
|
||||
// central invariant PR #3095 introduced — would silently erode.
|
||||
//
|
||||
// The TS mirror at server/_shared/seed-envelope.ts is validated by
|
||||
// `npm run typecheck` and reviewed manually (see header comment in that file).
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const execFileP = promisify(execFile);
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const verifier = resolve(here, '..', 'scripts', 'verify-seed-envelope-parity.mjs');
|
||||
|
||||
test('seed-envelope parity: source ↔ edge mirror stay in sync', async () => {
|
||||
const { stdout, stderr } = await execFileP(process.execPath, [verifier], {
|
||||
timeout: 10_000,
|
||||
});
|
||||
// Verifier prints a one-line OK on success. Any drift would exit non-zero,
|
||||
// which execFile surfaces as a thrown error rejecting the promise, so
|
||||
// reaching this line means the verifier succeeded.
|
||||
assert.match(stdout, /parity: OK/);
|
||||
assert.equal(stderr.trim(), '');
|
||||
});
|
||||
Reference in New Issue
Block a user