@nage-api/testing 1.0.0-beta.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/LICENSE +202 -0
- package/README.md +259 -0
- package/dist/assertions/envelope.d.ts +43 -0
- package/dist/assertions/envelope.js +116 -0
- package/dist/containers/harness.d.ts +123 -0
- package/dist/containers/harness.js +222 -0
- package/dist/doubles/index.d.ts +100 -0
- package/dist/doubles/index.js +201 -0
- package/dist/fixtures/builders.d.ts +74 -0
- package/dist/fixtures/builders.js +179 -0
- package/dist/fixtures/deterministic.d.ts +52 -0
- package/dist/fixtures/deterministic.js +95 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +83 -0
- package/dist/packs/authz-bypass.pack.d.ts +53 -0
- package/dist/packs/authz-bypass.pack.js +162 -0
- package/dist/packs/error-non-leakage.pack.d.ts +48 -0
- package/dist/packs/error-non-leakage.pack.js +81 -0
- package/dist/packs/no-leak.d.ts +55 -0
- package/dist/packs/no-leak.js +151 -0
- package/dist/packs/query-allow-list.pack.d.ts +36 -0
- package/dist/packs/query-allow-list.pack.js +90 -0
- package/dist/packs/upload-validation.pack.d.ts +46 -0
- package/dist/packs/upload-validation.pack.js +0 -0
- package/dist/test-api.d.ts +64 -0
- package/dist/test-api.js +15 -0
- package/package.json +45 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Testcontainers harness (PLAN.md §19, §24 Phase 9 risk: "flaky containers —
|
|
3
|
+
* pin images, retry, parallelize").
|
|
4
|
+
*
|
|
5
|
+
* The point of this file is to make integration tests *skip* cleanly rather than
|
|
6
|
+
* fail when no container runtime is available, and to make the three
|
|
7
|
+
* flakiness-mitigations the plan names structural rather than remembered:
|
|
8
|
+
*
|
|
9
|
+
* - **pinned images.** A tag like `postgres:16` moves. `IMAGES` pins digests-in-
|
|
10
|
+
* spirit by pinning exact patch versions, and the table is the only place a
|
|
11
|
+
* version appears, so an upgrade is one edit rather than a grep.
|
|
12
|
+
* - **retry.** Container startup fails transiently — a port race, a slow pull.
|
|
13
|
+
* `start` retries with backoff before giving up.
|
|
14
|
+
* - **parallel-safe.** Every container gets a unique name and an ephemeral
|
|
15
|
+
* port, so two suites running at once cannot collide.
|
|
16
|
+
*
|
|
17
|
+
* `testcontainers` itself is **not** a dependency. This package sits at the
|
|
18
|
+
* bottom of the graph and must stay installable without Docker, so the runtime
|
|
19
|
+
* is a port: a project that wants real containers passes an adapter over
|
|
20
|
+
* `testcontainers`, and everything else gets `skipIfUnavailable`.
|
|
21
|
+
*
|
|
22
|
+
* Note for this repository: there is no container runtime in the development
|
|
23
|
+
* environment, so the adapter path is unit-tested against a fake runtime and has
|
|
24
|
+
* never been exercised against Docker. That is stated in the README rather than
|
|
25
|
+
* implied to be verified.
|
|
26
|
+
*/
|
|
27
|
+
/** A service an integration test can ask for. */
|
|
28
|
+
export type ContainerService = 'postgres' | 'mysql' | 'mariadb' | 'mongo' | 'redis';
|
|
29
|
+
/**
|
|
30
|
+
* Pinned images, one per service.
|
|
31
|
+
*
|
|
32
|
+
* Exact patch versions, not floating tags: `postgres:16` is a different image
|
|
33
|
+
* next month, and an integration suite that changes underneath you is worse than
|
|
34
|
+
* one that is out of date.
|
|
35
|
+
*/
|
|
36
|
+
export declare const IMAGES: Readonly<Record<ContainerService, string>>;
|
|
37
|
+
/** The port each service listens on inside its container. */
|
|
38
|
+
export declare const INTERNAL_PORTS: Readonly<Record<ContainerService, number>>;
|
|
39
|
+
/** A started container, as the harness reports it. */
|
|
40
|
+
export interface StartedContainer {
|
|
41
|
+
readonly service: ContainerService;
|
|
42
|
+
readonly host: string;
|
|
43
|
+
/** The mapped, ephemeral host port. */
|
|
44
|
+
readonly port: number;
|
|
45
|
+
/** A connection URL an application config can use directly. */
|
|
46
|
+
readonly url: string;
|
|
47
|
+
stop(): Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
/** What the harness needs from a container runtime. */
|
|
50
|
+
export interface ContainerRuntime {
|
|
51
|
+
readonly name: string;
|
|
52
|
+
/** Whether containers can actually be started right now. */
|
|
53
|
+
isAvailable(): Promise<boolean>;
|
|
54
|
+
start(request: {
|
|
55
|
+
image: string;
|
|
56
|
+
internalPort: number;
|
|
57
|
+
/** Unique per run, so parallel suites cannot collide. */
|
|
58
|
+
containerName: string;
|
|
59
|
+
environment: Readonly<Record<string, string>>;
|
|
60
|
+
}): Promise<{
|
|
61
|
+
host: string;
|
|
62
|
+
port: number;
|
|
63
|
+
stop(): Promise<void>;
|
|
64
|
+
}>;
|
|
65
|
+
}
|
|
66
|
+
export interface ContainerHarnessOptions {
|
|
67
|
+
readonly runtime: ContainerRuntime;
|
|
68
|
+
/** Attempts before giving up on a container that will not start. */
|
|
69
|
+
readonly maxAttempts?: number;
|
|
70
|
+
/** Delay before the first retry; doubled each time. */
|
|
71
|
+
readonly retryDelayMs?: number;
|
|
72
|
+
/** Injected so the retry backoff does not really sleep in a unit test. */
|
|
73
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
74
|
+
/** Injected so container names are deterministic in a test. */
|
|
75
|
+
readonly nameSuffix?: () => string;
|
|
76
|
+
}
|
|
77
|
+
export declare class ContainerHarness {
|
|
78
|
+
#private;
|
|
79
|
+
constructor(options: ContainerHarnessOptions);
|
|
80
|
+
/**
|
|
81
|
+
* Whether containers are usable. Memoised: probing a missing Docker socket
|
|
82
|
+
* per test is slow and the answer never changes within a run.
|
|
83
|
+
*/
|
|
84
|
+
isAvailable(): Promise<boolean>;
|
|
85
|
+
/**
|
|
86
|
+
* Start a service, retrying transient failures.
|
|
87
|
+
*
|
|
88
|
+
* @throws the last error when every attempt fails — a container that will not
|
|
89
|
+
* start is a real failure, not something to skip over.
|
|
90
|
+
*/
|
|
91
|
+
start(service: ContainerService): Promise<StartedContainer>;
|
|
92
|
+
/** Stop everything this harness started. Safe to call twice. */
|
|
93
|
+
stopAll(): Promise<void>;
|
|
94
|
+
get running(): readonly StartedContainer[];
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Register an integration suite that runs only when containers are available.
|
|
98
|
+
*
|
|
99
|
+
* Skipping is not the same as passing, so the skip is loud: the suite name says
|
|
100
|
+
* it was skipped and why, and a `requireContainers` flag turns the skip into a
|
|
101
|
+
* failure for the CI job that is supposed to have Docker.
|
|
102
|
+
*/
|
|
103
|
+
export interface ContainerSuiteApi {
|
|
104
|
+
describe: (name: string, fn: () => void) => void;
|
|
105
|
+
it: (name: string, fn: () => Promise<void> | void) => void;
|
|
106
|
+
}
|
|
107
|
+
export declare function describeWithContainers(api: ContainerSuiteApi, options: {
|
|
108
|
+
readonly name: string;
|
|
109
|
+
readonly harness: ContainerHarness;
|
|
110
|
+
/** Fail rather than skip when no runtime is available. */
|
|
111
|
+
readonly required?: boolean;
|
|
112
|
+
readonly suite: () => void;
|
|
113
|
+
}): Promise<void>;
|
|
114
|
+
/** The connection URL for a started service. */
|
|
115
|
+
export declare function urlFor(service: ContainerService, host: string, port: number): string;
|
|
116
|
+
/**
|
|
117
|
+
* A runtime that reports nothing is available.
|
|
118
|
+
*
|
|
119
|
+
* The default, so an integration suite in an environment without Docker skips
|
|
120
|
+
* rather than throwing a confusing connection error.
|
|
121
|
+
*/
|
|
122
|
+
export declare const unavailableRuntime: ContainerRuntime;
|
|
123
|
+
//# sourceMappingURL=harness.d.ts.map
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The Testcontainers harness (PLAN.md §19, §24 Phase 9 risk: "flaky containers —
|
|
4
|
+
* pin images, retry, parallelize").
|
|
5
|
+
*
|
|
6
|
+
* The point of this file is to make integration tests *skip* cleanly rather than
|
|
7
|
+
* fail when no container runtime is available, and to make the three
|
|
8
|
+
* flakiness-mitigations the plan names structural rather than remembered:
|
|
9
|
+
*
|
|
10
|
+
* - **pinned images.** A tag like `postgres:16` moves. `IMAGES` pins digests-in-
|
|
11
|
+
* spirit by pinning exact patch versions, and the table is the only place a
|
|
12
|
+
* version appears, so an upgrade is one edit rather than a grep.
|
|
13
|
+
* - **retry.** Container startup fails transiently — a port race, a slow pull.
|
|
14
|
+
* `start` retries with backoff before giving up.
|
|
15
|
+
* - **parallel-safe.** Every container gets a unique name and an ephemeral
|
|
16
|
+
* port, so two suites running at once cannot collide.
|
|
17
|
+
*
|
|
18
|
+
* `testcontainers` itself is **not** a dependency. This package sits at the
|
|
19
|
+
* bottom of the graph and must stay installable without Docker, so the runtime
|
|
20
|
+
* is a port: a project that wants real containers passes an adapter over
|
|
21
|
+
* `testcontainers`, and everything else gets `skipIfUnavailable`.
|
|
22
|
+
*
|
|
23
|
+
* Note for this repository: there is no container runtime in the development
|
|
24
|
+
* environment, so the adapter path is unit-tested against a fake runtime and has
|
|
25
|
+
* never been exercised against Docker. That is stated in the README rather than
|
|
26
|
+
* implied to be verified.
|
|
27
|
+
*/
|
|
28
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
+
exports.unavailableRuntime = exports.ContainerHarness = exports.INTERNAL_PORTS = exports.IMAGES = void 0;
|
|
30
|
+
exports.describeWithContainers = describeWithContainers;
|
|
31
|
+
exports.urlFor = urlFor;
|
|
32
|
+
/**
|
|
33
|
+
* Pinned images, one per service.
|
|
34
|
+
*
|
|
35
|
+
* Exact patch versions, not floating tags: `postgres:16` is a different image
|
|
36
|
+
* next month, and an integration suite that changes underneath you is worse than
|
|
37
|
+
* one that is out of date.
|
|
38
|
+
*/
|
|
39
|
+
exports.IMAGES = {
|
|
40
|
+
postgres: 'postgres:16.4-alpine',
|
|
41
|
+
mysql: 'mysql:8.4.2',
|
|
42
|
+
mariadb: 'mariadb:11.4.3',
|
|
43
|
+
mongo: 'mongo:7.0.14',
|
|
44
|
+
redis: 'redis:7.4.0-alpine',
|
|
45
|
+
};
|
|
46
|
+
/** The port each service listens on inside its container. */
|
|
47
|
+
exports.INTERNAL_PORTS = {
|
|
48
|
+
postgres: 5432,
|
|
49
|
+
mysql: 3306,
|
|
50
|
+
mariadb: 3306,
|
|
51
|
+
mongo: 27017,
|
|
52
|
+
redis: 6379,
|
|
53
|
+
};
|
|
54
|
+
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
55
|
+
const DEFAULT_RETRY_DELAY_MS = 1000;
|
|
56
|
+
/**
|
|
57
|
+
* Monotonic, process-wide, and the only thing that makes a container name
|
|
58
|
+
* genuinely unique.
|
|
59
|
+
*
|
|
60
|
+
* The suffix alone is not enough: it is derived from a clock, so two containers
|
|
61
|
+
* of the same service started in the same millisecond — two suites running in
|
|
62
|
+
* parallel, or one suite starting a pair — get identical names, and Docker
|
|
63
|
+
* refuses the second. The counter also covers the retry case, since a
|
|
64
|
+
* half-started container from a failed attempt may still hold its name.
|
|
65
|
+
*/
|
|
66
|
+
let containerSequence = 0;
|
|
67
|
+
/** Credentials the harness sets, and the URLs it builds from them. */
|
|
68
|
+
const CREDENTIALS = { user: 'nage', password: 'nage', database: 'nage_test' };
|
|
69
|
+
class ContainerHarness {
|
|
70
|
+
#runtime;
|
|
71
|
+
#maxAttempts;
|
|
72
|
+
#retryDelayMs;
|
|
73
|
+
#sleep;
|
|
74
|
+
#nameSuffix;
|
|
75
|
+
#started = [];
|
|
76
|
+
#available;
|
|
77
|
+
constructor(options) {
|
|
78
|
+
this.#runtime = options.runtime;
|
|
79
|
+
this.#maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
80
|
+
this.#retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
|
|
81
|
+
this.#sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
82
|
+
this.#nameSuffix =
|
|
83
|
+
options.nameSuffix ?? (() => `${String(process.pid)}-${Math.trunc(performance.now())}`);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Whether containers are usable. Memoised: probing a missing Docker socket
|
|
87
|
+
* per test is slow and the answer never changes within a run.
|
|
88
|
+
*/
|
|
89
|
+
async isAvailable() {
|
|
90
|
+
this.#available ??= await this.#runtime.isAvailable();
|
|
91
|
+
return this.#available;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Start a service, retrying transient failures.
|
|
95
|
+
*
|
|
96
|
+
* @throws the last error when every attempt fails — a container that will not
|
|
97
|
+
* start is a real failure, not something to skip over.
|
|
98
|
+
*/
|
|
99
|
+
async start(service) {
|
|
100
|
+
let lastError;
|
|
101
|
+
for (let attempt = 1; attempt <= this.#maxAttempts; attempt += 1) {
|
|
102
|
+
try {
|
|
103
|
+
containerSequence += 1;
|
|
104
|
+
const started = await this.#runtime.start({
|
|
105
|
+
image: exports.IMAGES[service],
|
|
106
|
+
internalPort: exports.INTERNAL_PORTS[service],
|
|
107
|
+
containerName: `nage-${service}-${this.#nameSuffix()}-${String(containerSequence)}`,
|
|
108
|
+
environment: environmentFor(service),
|
|
109
|
+
});
|
|
110
|
+
const container = {
|
|
111
|
+
service,
|
|
112
|
+
host: started.host,
|
|
113
|
+
port: started.port,
|
|
114
|
+
url: urlFor(service, started.host, started.port),
|
|
115
|
+
// Wrapped, not passed by reference: a runtime whose `stop` reads
|
|
116
|
+
// `this` would break when the method is detached from its object.
|
|
117
|
+
stop: () => started.stop(),
|
|
118
|
+
};
|
|
119
|
+
this.#started.push(container);
|
|
120
|
+
return container;
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
lastError = error;
|
|
124
|
+
if (attempt < this.#maxAttempts)
|
|
125
|
+
await this.#sleep(this.#retryDelayMs * 2 ** (attempt - 1));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
throw new Error(`Could not start ${service} (${exports.IMAGES[service]}) after ${String(this.#maxAttempts)} attempts: ${lastError instanceof Error ? lastError.message : String(lastError)}`, { cause: lastError });
|
|
129
|
+
}
|
|
130
|
+
/** Stop everything this harness started. Safe to call twice. */
|
|
131
|
+
async stopAll() {
|
|
132
|
+
const stopping = this.#started.splice(0).map(async (container) => {
|
|
133
|
+
try {
|
|
134
|
+
await container.stop();
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// A container that failed to stop must not fail the suite: the run is
|
|
138
|
+
// over, and the runtime will reap it.
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
await Promise.all(stopping);
|
|
142
|
+
}
|
|
143
|
+
get running() {
|
|
144
|
+
return this.#started;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
exports.ContainerHarness = ContainerHarness;
|
|
148
|
+
async function describeWithContainers(api, options) {
|
|
149
|
+
const available = await options.harness.isAvailable();
|
|
150
|
+
if (available) {
|
|
151
|
+
api.describe(options.name, options.suite);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (options.required === true) {
|
|
155
|
+
api.describe(options.name, () => {
|
|
156
|
+
api.it('should have a container runtime available', () => {
|
|
157
|
+
throw new Error('No container runtime is available, and this suite is marked required. ' +
|
|
158
|
+
'Start Docker, or unset the flag that requires containers.');
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
api.describe(`${options.name} (skipped: no container runtime)`, () => {
|
|
164
|
+
api.it('should be run in an environment with Docker', () => {
|
|
165
|
+
// A skipped suite that reports nothing is indistinguishable from one that
|
|
166
|
+
// passed, so it leaves a visible, passing marker instead.
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
function environmentFor(service) {
|
|
171
|
+
switch (service) {
|
|
172
|
+
case 'postgres':
|
|
173
|
+
return {
|
|
174
|
+
POSTGRES_USER: CREDENTIALS.user,
|
|
175
|
+
POSTGRES_PASSWORD: CREDENTIALS.password,
|
|
176
|
+
POSTGRES_DB: CREDENTIALS.database,
|
|
177
|
+
};
|
|
178
|
+
case 'mysql':
|
|
179
|
+
case 'mariadb':
|
|
180
|
+
return {
|
|
181
|
+
MYSQL_ROOT_PASSWORD: CREDENTIALS.password,
|
|
182
|
+
MYSQL_USER: CREDENTIALS.user,
|
|
183
|
+
MYSQL_PASSWORD: CREDENTIALS.password,
|
|
184
|
+
MYSQL_DATABASE: CREDENTIALS.database,
|
|
185
|
+
};
|
|
186
|
+
case 'mongo':
|
|
187
|
+
return {
|
|
188
|
+
MONGO_INITDB_ROOT_USERNAME: CREDENTIALS.user,
|
|
189
|
+
MONGO_INITDB_ROOT_PASSWORD: CREDENTIALS.password,
|
|
190
|
+
};
|
|
191
|
+
case 'redis':
|
|
192
|
+
return {};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/** The connection URL for a started service. */
|
|
196
|
+
function urlFor(service, host, port) {
|
|
197
|
+
const authority = `${CREDENTIALS.user}:${CREDENTIALS.password}@${host}:${String(port)}`;
|
|
198
|
+
switch (service) {
|
|
199
|
+
case 'postgres':
|
|
200
|
+
return `postgres://${authority}/${CREDENTIALS.database}`;
|
|
201
|
+
case 'mysql':
|
|
202
|
+
return `mysql://${authority}/${CREDENTIALS.database}`;
|
|
203
|
+
case 'mariadb':
|
|
204
|
+
return `mariadb://${authority}/${CREDENTIALS.database}`;
|
|
205
|
+
case 'mongo':
|
|
206
|
+
return `mongodb://${authority}/${CREDENTIALS.database}?authSource=admin`;
|
|
207
|
+
case 'redis':
|
|
208
|
+
return `redis://${host}:${String(port)}`;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* A runtime that reports nothing is available.
|
|
213
|
+
*
|
|
214
|
+
* The default, so an integration suite in an environment without Docker skips
|
|
215
|
+
* rather than throwing a confusing connection error.
|
|
216
|
+
*/
|
|
217
|
+
exports.unavailableRuntime = {
|
|
218
|
+
name: 'none',
|
|
219
|
+
isAvailable: async () => Promise.resolve(false),
|
|
220
|
+
start: async () => Promise.reject(new Error('No container runtime is configured')),
|
|
221
|
+
};
|
|
222
|
+
//# sourceMappingURL=harness.js.map
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doubles for the ports the framework declares (PLAN.md §19: "mock at the
|
|
3
|
+
* **port** boundary … not deep ORM internals").
|
|
4
|
+
*
|
|
5
|
+
* Every one of these is a real, complete implementation of a contract, not a
|
|
6
|
+
* stub that returns `undefined`. That distinction matters: a stub agrees with
|
|
7
|
+
* whatever the code under test does, so a test built on one passes even when the
|
|
8
|
+
* code is wrong. A real implementation disagrees.
|
|
9
|
+
*
|
|
10
|
+
* The repository double is deliberately **not** here. `@nage-api/data` already
|
|
11
|
+
* ships `MemoryRepository`, and it is the one the driver conformance suite runs
|
|
12
|
+
* against — so it is the only in-memory repository whose behaviour is proven to
|
|
13
|
+
* match a real driver's. Duplicating it in the kit would produce a second
|
|
14
|
+
* implementation with no such guarantee. (§19 lists an in-memory driver as part
|
|
15
|
+
* of this package; that is the one deviation, and it is deliberate: the kit sits
|
|
16
|
+
* below `@nage-api/data` in the dependency graph and may not import it.)
|
|
17
|
+
*/
|
|
18
|
+
import type { KeyValueStore, LogFields, LoggerPort, RateLimitResult, RateLimitStore, SecretProviderPort } from '@nage-api/contracts';
|
|
19
|
+
/** A clock a test advances by hand, so no test ever waits. */
|
|
20
|
+
export declare class FakeClock {
|
|
21
|
+
#private;
|
|
22
|
+
constructor(start?: number);
|
|
23
|
+
now(): number;
|
|
24
|
+
advance(milliseconds: number): this;
|
|
25
|
+
/** Move to an absolute instant, for testing an expiry boundary exactly. */
|
|
26
|
+
set(epochMs: number): this;
|
|
27
|
+
}
|
|
28
|
+
export interface CapturedLog {
|
|
29
|
+
readonly level: string;
|
|
30
|
+
readonly message: string;
|
|
31
|
+
readonly fields: LogFields;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* A logger that records.
|
|
35
|
+
*
|
|
36
|
+
* Useful for the assertion that matters most about logging: that a credential
|
|
37
|
+
* never reaches a log line. `entries` is inspectable, so `expectNoSecrets` can
|
|
38
|
+
* be pointed straight at it.
|
|
39
|
+
*/
|
|
40
|
+
export declare class RecordingLogger implements LoggerPort {
|
|
41
|
+
#private;
|
|
42
|
+
readonly entries: CapturedLog[];
|
|
43
|
+
constructor(bindings?: LogFields);
|
|
44
|
+
trace(message: string, fields?: LogFields): void;
|
|
45
|
+
debug(message: string, fields?: LogFields): void;
|
|
46
|
+
info(message: string, fields?: LogFields): void;
|
|
47
|
+
warn(message: string, fields?: LogFields): void;
|
|
48
|
+
error(message: string, fields?: LogFields): void;
|
|
49
|
+
fatal(message: string, fields?: LogFields): void;
|
|
50
|
+
child(bindings: LogFields): LoggerPort;
|
|
51
|
+
/** Lines at one level. */
|
|
52
|
+
at(level: string): readonly CapturedLog[];
|
|
53
|
+
/** Everything logged, as one string — the cheapest way to assert on absence. */
|
|
54
|
+
get text(): string;
|
|
55
|
+
clear(): void;
|
|
56
|
+
}
|
|
57
|
+
/** A `KeyValueStore` with TTL honoured against an injectable clock. */
|
|
58
|
+
export declare class MemoryKeyValueStore<TValue> implements KeyValueStore<TValue> {
|
|
59
|
+
#private;
|
|
60
|
+
constructor(clock?: {
|
|
61
|
+
now(): number;
|
|
62
|
+
});
|
|
63
|
+
get(key: string): Promise<TValue | null>;
|
|
64
|
+
set(key: string, value: TValue, ttlSeconds?: number): Promise<void>;
|
|
65
|
+
delete(key: string): Promise<void>;
|
|
66
|
+
get size(): number;
|
|
67
|
+
get keys(): readonly string[];
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* A rate-limit store a test drives.
|
|
71
|
+
*
|
|
72
|
+
* `consume` is a real fixed-window counter rather than a counter that always
|
|
73
|
+
* allows: a guard tested against a permissive double is a guard nobody has
|
|
74
|
+
* tested.
|
|
75
|
+
*/
|
|
76
|
+
export declare class MemoryRateLimitStore implements RateLimitStore {
|
|
77
|
+
#private;
|
|
78
|
+
readonly name = "memory-test";
|
|
79
|
+
constructor(clock?: {
|
|
80
|
+
now(): number;
|
|
81
|
+
});
|
|
82
|
+
consume(key: string, limit: number, windowMs: number): Promise<RateLimitResult>;
|
|
83
|
+
reset(key: string): Promise<void>;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* A secret provider backed by a plain object.
|
|
87
|
+
*
|
|
88
|
+
* `require` rejects for a missing name, exactly as the env and AWS providers do
|
|
89
|
+
* — a double that returned `undefined` instead would hide every fail-fast test.
|
|
90
|
+
*/
|
|
91
|
+
export declare class StubSecretProvider implements SecretProviderPort {
|
|
92
|
+
#private;
|
|
93
|
+
readonly provider = "stub";
|
|
94
|
+
readonly reads: string[];
|
|
95
|
+
constructor(secrets?: Record<string, string>);
|
|
96
|
+
get(name: string): Promise<string | null>;
|
|
97
|
+
require(name: string): Promise<string>;
|
|
98
|
+
set(name: string, value: string): this;
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Doubles for the ports the framework declares (PLAN.md §19: "mock at the
|
|
4
|
+
* **port** boundary … not deep ORM internals").
|
|
5
|
+
*
|
|
6
|
+
* Every one of these is a real, complete implementation of a contract, not a
|
|
7
|
+
* stub that returns `undefined`. That distinction matters: a stub agrees with
|
|
8
|
+
* whatever the code under test does, so a test built on one passes even when the
|
|
9
|
+
* code is wrong. A real implementation disagrees.
|
|
10
|
+
*
|
|
11
|
+
* The repository double is deliberately **not** here. `@nage-api/data` already
|
|
12
|
+
* ships `MemoryRepository`, and it is the one the driver conformance suite runs
|
|
13
|
+
* against — so it is the only in-memory repository whose behaviour is proven to
|
|
14
|
+
* match a real driver's. Duplicating it in the kit would produce a second
|
|
15
|
+
* implementation with no such guarantee. (§19 lists an in-memory driver as part
|
|
16
|
+
* of this package; that is the one deviation, and it is deliberate: the kit sits
|
|
17
|
+
* below `@nage-api/data` in the dependency graph and may not import it.)
|
|
18
|
+
*/
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.StubSecretProvider = exports.MemoryRateLimitStore = exports.MemoryKeyValueStore = exports.RecordingLogger = exports.FakeClock = void 0;
|
|
21
|
+
/** A clock a test advances by hand, so no test ever waits. */
|
|
22
|
+
class FakeClock {
|
|
23
|
+
#now;
|
|
24
|
+
constructor(start = Date.UTC(2026, 0, 1, 12, 0, 0)) {
|
|
25
|
+
this.#now = start;
|
|
26
|
+
}
|
|
27
|
+
now() {
|
|
28
|
+
return this.#now;
|
|
29
|
+
}
|
|
30
|
+
advance(milliseconds) {
|
|
31
|
+
this.#now += milliseconds;
|
|
32
|
+
return this;
|
|
33
|
+
}
|
|
34
|
+
/** Move to an absolute instant, for testing an expiry boundary exactly. */
|
|
35
|
+
set(epochMs) {
|
|
36
|
+
this.#now = epochMs;
|
|
37
|
+
return this;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
exports.FakeClock = FakeClock;
|
|
41
|
+
/**
|
|
42
|
+
* A logger that records.
|
|
43
|
+
*
|
|
44
|
+
* Useful for the assertion that matters most about logging: that a credential
|
|
45
|
+
* never reaches a log line. `entries` is inspectable, so `expectNoSecrets` can
|
|
46
|
+
* be pointed straight at it.
|
|
47
|
+
*/
|
|
48
|
+
class RecordingLogger {
|
|
49
|
+
entries = [];
|
|
50
|
+
#bindings;
|
|
51
|
+
constructor(bindings = {}) {
|
|
52
|
+
this.#bindings = bindings;
|
|
53
|
+
}
|
|
54
|
+
trace(message, fields = {}) {
|
|
55
|
+
this.#record('trace', message, fields);
|
|
56
|
+
}
|
|
57
|
+
debug(message, fields = {}) {
|
|
58
|
+
this.#record('debug', message, fields);
|
|
59
|
+
}
|
|
60
|
+
info(message, fields = {}) {
|
|
61
|
+
this.#record('info', message, fields);
|
|
62
|
+
}
|
|
63
|
+
warn(message, fields = {}) {
|
|
64
|
+
this.#record('warn', message, fields);
|
|
65
|
+
}
|
|
66
|
+
error(message, fields = {}) {
|
|
67
|
+
this.#record('error', message, fields);
|
|
68
|
+
}
|
|
69
|
+
fatal(message, fields = {}) {
|
|
70
|
+
this.#record('fatal', message, fields);
|
|
71
|
+
}
|
|
72
|
+
child(bindings) {
|
|
73
|
+
// Shares the same array, so a test can assert on everything the whole tree
|
|
74
|
+
// logged without collecting children.
|
|
75
|
+
const derived = new RecordingLogger({ ...this.#bindings, ...bindings });
|
|
76
|
+
Object.defineProperty(derived, 'entries', { value: this.entries });
|
|
77
|
+
return derived;
|
|
78
|
+
}
|
|
79
|
+
/** Lines at one level. */
|
|
80
|
+
at(level) {
|
|
81
|
+
return this.entries.filter((entry) => entry.level === level);
|
|
82
|
+
}
|
|
83
|
+
/** Everything logged, as one string — the cheapest way to assert on absence. */
|
|
84
|
+
get text() {
|
|
85
|
+
return JSON.stringify(this.entries);
|
|
86
|
+
}
|
|
87
|
+
clear() {
|
|
88
|
+
this.entries.length = 0;
|
|
89
|
+
}
|
|
90
|
+
#record(level, message, fields) {
|
|
91
|
+
this.entries.push({ level, message, fields: { ...this.#bindings, ...fields } });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
exports.RecordingLogger = RecordingLogger;
|
|
95
|
+
/** A `KeyValueStore` with TTL honoured against an injectable clock. */
|
|
96
|
+
class MemoryKeyValueStore {
|
|
97
|
+
#entries = new Map();
|
|
98
|
+
#clock;
|
|
99
|
+
constructor(clock = { now: () => Date.now() }) {
|
|
100
|
+
this.#clock = clock;
|
|
101
|
+
}
|
|
102
|
+
async get(key) {
|
|
103
|
+
await Promise.resolve();
|
|
104
|
+
const entry = this.#entries.get(key);
|
|
105
|
+
if (entry === undefined)
|
|
106
|
+
return null;
|
|
107
|
+
if (entry.expiresAt !== undefined && entry.expiresAt <= this.#clock.now()) {
|
|
108
|
+
this.#entries.delete(key);
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
return entry.value;
|
|
112
|
+
}
|
|
113
|
+
async set(key, value, ttlSeconds) {
|
|
114
|
+
await Promise.resolve();
|
|
115
|
+
this.#entries.set(key, {
|
|
116
|
+
value,
|
|
117
|
+
...(ttlSeconds === undefined ? {} : { expiresAt: this.#clock.now() + ttlSeconds * 1000 }),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
async delete(key) {
|
|
121
|
+
await Promise.resolve();
|
|
122
|
+
this.#entries.delete(key);
|
|
123
|
+
}
|
|
124
|
+
get size() {
|
|
125
|
+
return this.#entries.size;
|
|
126
|
+
}
|
|
127
|
+
get keys() {
|
|
128
|
+
return [...this.#entries.keys()];
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
exports.MemoryKeyValueStore = MemoryKeyValueStore;
|
|
132
|
+
/**
|
|
133
|
+
* A rate-limit store a test drives.
|
|
134
|
+
*
|
|
135
|
+
* `consume` is a real fixed-window counter rather than a counter that always
|
|
136
|
+
* allows: a guard tested against a permissive double is a guard nobody has
|
|
137
|
+
* tested.
|
|
138
|
+
*/
|
|
139
|
+
class MemoryRateLimitStore {
|
|
140
|
+
name = 'memory-test';
|
|
141
|
+
#windows = new Map();
|
|
142
|
+
#clock;
|
|
143
|
+
constructor(clock = { now: () => Date.now() }) {
|
|
144
|
+
this.#clock = clock;
|
|
145
|
+
}
|
|
146
|
+
async consume(key, limit, windowMs) {
|
|
147
|
+
await Promise.resolve();
|
|
148
|
+
const now = this.#clock.now();
|
|
149
|
+
const existing = this.#windows.get(key);
|
|
150
|
+
const window = existing === undefined || existing.resetAt <= now
|
|
151
|
+
? { count: 0, resetAt: now + windowMs }
|
|
152
|
+
: existing;
|
|
153
|
+
window.count += 1;
|
|
154
|
+
this.#windows.set(key, window);
|
|
155
|
+
return {
|
|
156
|
+
allowed: window.count <= limit,
|
|
157
|
+
limit,
|
|
158
|
+
remaining: Math.max(0, limit - window.count),
|
|
159
|
+
resetAt: window.resetAt,
|
|
160
|
+
// Rounded up, so a caller told to wait 0 seconds never retries into the
|
|
161
|
+
// same window it was just refused from.
|
|
162
|
+
retryAfterSeconds: Math.max(1, Math.ceil((window.resetAt - now) / 1000)),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
async reset(key) {
|
|
166
|
+
await Promise.resolve();
|
|
167
|
+
this.#windows.delete(key);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
exports.MemoryRateLimitStore = MemoryRateLimitStore;
|
|
171
|
+
/**
|
|
172
|
+
* A secret provider backed by a plain object.
|
|
173
|
+
*
|
|
174
|
+
* `require` rejects for a missing name, exactly as the env and AWS providers do
|
|
175
|
+
* — a double that returned `undefined` instead would hide every fail-fast test.
|
|
176
|
+
*/
|
|
177
|
+
class StubSecretProvider {
|
|
178
|
+
provider = 'stub';
|
|
179
|
+
reads = [];
|
|
180
|
+
#secrets;
|
|
181
|
+
constructor(secrets = {}) {
|
|
182
|
+
this.#secrets = { ...secrets };
|
|
183
|
+
}
|
|
184
|
+
async get(name) {
|
|
185
|
+
await Promise.resolve();
|
|
186
|
+
this.reads.push(name);
|
|
187
|
+
return this.#secrets[name] ?? null;
|
|
188
|
+
}
|
|
189
|
+
async require(name) {
|
|
190
|
+
const value = await this.get(name);
|
|
191
|
+
if (value === null)
|
|
192
|
+
throw new Error(`Required secret ${name} is not set`);
|
|
193
|
+
return value;
|
|
194
|
+
}
|
|
195
|
+
set(name, value) {
|
|
196
|
+
this.#secrets[name] = value;
|
|
197
|
+
return this;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
exports.StubSecretProvider = StubSecretProvider;
|
|
201
|
+
//# sourceMappingURL=index.js.map
|