@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,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixture builders (PLAN.md §19).
|
|
3
|
+
*
|
|
4
|
+
* Each one returns a valid, boring value with every required field filled, and
|
|
5
|
+
* takes an override object. That combination is what stops a test from being a
|
|
6
|
+
* wall of setup: `authUser({ roles: ['admin'] })` says the test is about being
|
|
7
|
+
* an admin, and nothing else.
|
|
8
|
+
*
|
|
9
|
+
* They are typed against `@nage-api/contracts`, so a field renamed in a contract
|
|
10
|
+
* breaks every fixture at compile time rather than at run time — which is the
|
|
11
|
+
* point of having contracts at the bottom of the graph.
|
|
12
|
+
*/
|
|
13
|
+
import type { AuthUser, BaseEntity, Job, JobAction, Paginated, PaginationMeta, QueueJob, RequestContext, SecurityFinding, SessionRecord, SuccessResponse, TokenPair } from '@nage-api/contracts';
|
|
14
|
+
import { Deterministic } from './deterministic.js';
|
|
15
|
+
/** Every builder takes the same shape: a partial override of the result. */
|
|
16
|
+
export type Override<T> = Partial<T>;
|
|
17
|
+
/** An authenticated principal. Member-level by default: the boring case. */
|
|
18
|
+
export declare function authUser(overrides?: Override<AuthUser>): AuthUser;
|
|
19
|
+
/** An administrator, for the "should be allowed" half of an authorization test. */
|
|
20
|
+
export declare function adminUser(overrides?: Override<AuthUser>): AuthUser;
|
|
21
|
+
/** A request context, as the CLS middleware would have built it. */
|
|
22
|
+
export declare function requestContext(overrides?: Override<RequestContext>): RequestContext;
|
|
23
|
+
/** The id, audit and soft-delete columns every framework-managed entity carries. */
|
|
24
|
+
export declare function baseEntity(overrides?: Override<BaseEntity>): BaseEntity;
|
|
25
|
+
/**
|
|
26
|
+
* An entity of the caller's own type, with the managed columns filled in.
|
|
27
|
+
*
|
|
28
|
+
* ```ts
|
|
29
|
+
* const product = entity<Product>({ name: 'Widget', price: 10 });
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export declare function entity<TEntity extends BaseEntity>(fields: Omit<TEntity, keyof BaseEntity> & Partial<BaseEntity>): TEntity;
|
|
33
|
+
export interface JobOverrides<TEntity> {
|
|
34
|
+
readonly action?: JobAction;
|
|
35
|
+
readonly owner?: AuthUser;
|
|
36
|
+
readonly body?: Partial<TEntity>;
|
|
37
|
+
readonly where?: Record<string, unknown>;
|
|
38
|
+
readonly requestId?: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* A `Job<TEntity>` — the retained lifecycle-hook payload (§14.2, §26).
|
|
42
|
+
*
|
|
43
|
+
* Cast at the boundary rather than typed structurally: `Job` is generic over an
|
|
44
|
+
* entity the kit cannot know, and the alternative is every caller writing the
|
|
45
|
+
* same cast.
|
|
46
|
+
*/
|
|
47
|
+
export declare function job<TEntity>(overrides?: JobOverrides<TEntity>): Job<TEntity>;
|
|
48
|
+
/** A queue job, with the correlation id a real publisher would have attached. */
|
|
49
|
+
export declare function queueJob<TName extends string, TPayload>(name: TName, payload: TPayload, overrides?: Override<QueueJob<TName, TPayload>>): QueueJob<TName, TPayload>;
|
|
50
|
+
/** Offset pagination metadata. */
|
|
51
|
+
export declare function paginationMeta(overrides?: Override<PaginationMeta>): PaginationMeta;
|
|
52
|
+
/**
|
|
53
|
+
* A page whose metadata actually matches its records.
|
|
54
|
+
*
|
|
55
|
+
* `count` defaults to the number of records rather than being accepted blind,
|
|
56
|
+
* so a fixture cannot describe an impossible page and a test asserting on
|
|
57
|
+
* pagination cannot lie to itself.
|
|
58
|
+
*/
|
|
59
|
+
export declare function paginated<T>(records: readonly T[], overrides?: Override<PaginationMeta>): Paginated<T>;
|
|
60
|
+
/** A success envelope, as `ResponseInterceptor` would have produced it. */
|
|
61
|
+
export declare function successResponse<TData>(data: TData): SuccessResponse<TData>;
|
|
62
|
+
/** A stored refresh session, hashed and live. */
|
|
63
|
+
export declare function sessionRecord(overrides?: Override<SessionRecord>): SessionRecord;
|
|
64
|
+
/** A token pair, as login would have returned it. */
|
|
65
|
+
export declare function tokenPair(overrides?: Override<TokenPair>): TokenPair;
|
|
66
|
+
/** A security finding, for testing a report renderer or a gate. */
|
|
67
|
+
export declare function securityFinding(overrides?: Override<SecurityFinding>): SecurityFinding;
|
|
68
|
+
/** A fixture set with its own sequence, so two suites cannot interfere. */
|
|
69
|
+
export declare function fixtures(seed: number): {
|
|
70
|
+
readonly random: Deterministic;
|
|
71
|
+
authUser: (overrides?: Override<AuthUser>) => AuthUser;
|
|
72
|
+
requestContext: (overrides?: Override<RequestContext>) => RequestContext;
|
|
73
|
+
};
|
|
74
|
+
//# sourceMappingURL=builders.d.ts.map
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Fixture builders (PLAN.md §19).
|
|
4
|
+
*
|
|
5
|
+
* Each one returns a valid, boring value with every required field filled, and
|
|
6
|
+
* takes an override object. That combination is what stops a test from being a
|
|
7
|
+
* wall of setup: `authUser({ roles: ['admin'] })` says the test is about being
|
|
8
|
+
* an admin, and nothing else.
|
|
9
|
+
*
|
|
10
|
+
* They are typed against `@nage-api/contracts`, so a field renamed in a contract
|
|
11
|
+
* breaks every fixture at compile time rather than at run time — which is the
|
|
12
|
+
* point of having contracts at the bottom of the graph.
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.authUser = authUser;
|
|
16
|
+
exports.adminUser = adminUser;
|
|
17
|
+
exports.requestContext = requestContext;
|
|
18
|
+
exports.baseEntity = baseEntity;
|
|
19
|
+
exports.entity = entity;
|
|
20
|
+
exports.job = job;
|
|
21
|
+
exports.queueJob = queueJob;
|
|
22
|
+
exports.paginationMeta = paginationMeta;
|
|
23
|
+
exports.paginated = paginated;
|
|
24
|
+
exports.successResponse = successResponse;
|
|
25
|
+
exports.sessionRecord = sessionRecord;
|
|
26
|
+
exports.tokenPair = tokenPair;
|
|
27
|
+
exports.securityFinding = securityFinding;
|
|
28
|
+
exports.fixtures = fixtures;
|
|
29
|
+
const deterministic_js_1 = require("./deterministic.js");
|
|
30
|
+
/** An authenticated principal. Member-level by default: the boring case. */
|
|
31
|
+
function authUser(overrides = {}) {
|
|
32
|
+
return {
|
|
33
|
+
id: deterministic_js_1.deterministic.id('u'),
|
|
34
|
+
email: deterministic_js_1.deterministic.email(),
|
|
35
|
+
roles: ['member'],
|
|
36
|
+
permissions: [],
|
|
37
|
+
...overrides,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** An administrator, for the "should be allowed" half of an authorization test. */
|
|
41
|
+
function adminUser(overrides = {}) {
|
|
42
|
+
return authUser({
|
|
43
|
+
roles: ['admin'],
|
|
44
|
+
permissions: ['user:read', 'user:write'],
|
|
45
|
+
...overrides,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/** A request context, as the CLS middleware would have built it. */
|
|
49
|
+
function requestContext(overrides = {}) {
|
|
50
|
+
return {
|
|
51
|
+
requestId: deterministic_js_1.deterministic.id('req'),
|
|
52
|
+
ip: '203.0.113.7',
|
|
53
|
+
userAgent: 'nage-testing/1.0',
|
|
54
|
+
startedAt: deterministic_js_1.FIXTURE_EPOCH_MS,
|
|
55
|
+
...overrides,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** The id, audit and soft-delete columns every framework-managed entity carries. */
|
|
59
|
+
function baseEntity(overrides = {}) {
|
|
60
|
+
return {
|
|
61
|
+
id: deterministic_js_1.deterministic.id('e'),
|
|
62
|
+
created_at: (0, deterministic_js_1.fixtureDate)(),
|
|
63
|
+
updated_at: (0, deterministic_js_1.fixtureDate)(),
|
|
64
|
+
created_by: null,
|
|
65
|
+
updated_by: null,
|
|
66
|
+
deleted_at: null,
|
|
67
|
+
deleted_by: null,
|
|
68
|
+
...overrides,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* An entity of the caller's own type, with the managed columns filled in.
|
|
73
|
+
*
|
|
74
|
+
* ```ts
|
|
75
|
+
* const product = entity<Product>({ name: 'Widget', price: 10 });
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
function entity(fields) {
|
|
79
|
+
return { ...baseEntity(), ...fields };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* A `Job<TEntity>` — the retained lifecycle-hook payload (§14.2, §26).
|
|
83
|
+
*
|
|
84
|
+
* Cast at the boundary rather than typed structurally: `Job` is generic over an
|
|
85
|
+
* entity the kit cannot know, and the alternative is every caller writing the
|
|
86
|
+
* same cast.
|
|
87
|
+
*/
|
|
88
|
+
function job(overrides = {}) {
|
|
89
|
+
return {
|
|
90
|
+
action: overrides.action ?? 'findAll',
|
|
91
|
+
owner: overrides.owner ?? authUser(),
|
|
92
|
+
requestId: overrides.requestId ?? deterministic_js_1.deterministic.id('req'),
|
|
93
|
+
...(overrides.body === undefined ? {} : { body: overrides.body }),
|
|
94
|
+
...(overrides.where === undefined ? {} : { where: overrides.where }),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** A queue job, with the correlation id a real publisher would have attached. */
|
|
98
|
+
function queueJob(name, payload, overrides = {}) {
|
|
99
|
+
return { name, payload, requestId: deterministic_js_1.deterministic.id('req'), ...overrides };
|
|
100
|
+
}
|
|
101
|
+
/** Offset pagination metadata. */
|
|
102
|
+
function paginationMeta(overrides = {}) {
|
|
103
|
+
return { offset: 0, limit: 20, count: 0, ...overrides };
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* A page whose metadata actually matches its records.
|
|
107
|
+
*
|
|
108
|
+
* `count` defaults to the number of records rather than being accepted blind,
|
|
109
|
+
* so a fixture cannot describe an impossible page and a test asserting on
|
|
110
|
+
* pagination cannot lie to itself.
|
|
111
|
+
*/
|
|
112
|
+
function paginated(records, overrides = {}) {
|
|
113
|
+
return {
|
|
114
|
+
records: [...records],
|
|
115
|
+
pagination: paginationMeta({ count: records.length, ...overrides }),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/** A success envelope, as `ResponseInterceptor` would have produced it. */
|
|
119
|
+
function successResponse(data) {
|
|
120
|
+
return {
|
|
121
|
+
success: true,
|
|
122
|
+
data,
|
|
123
|
+
meta: { requestId: deterministic_js_1.deterministic.id('req'), timestamp: (0, deterministic_js_1.fixtureDate)() },
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/** A stored refresh session, hashed and live. */
|
|
127
|
+
function sessionRecord(overrides = {}) {
|
|
128
|
+
const issuedAt = deterministic_js_1.FIXTURE_EPOCH_MS;
|
|
129
|
+
return {
|
|
130
|
+
id: deterministic_js_1.deterministic.id('s'),
|
|
131
|
+
familyId: deterministic_js_1.deterministic.id('fam'),
|
|
132
|
+
userId: deterministic_js_1.deterministic.id('u'),
|
|
133
|
+
hashedToken: deterministic_js_1.deterministic.hex(43),
|
|
134
|
+
issuedAt,
|
|
135
|
+
expiresAt: issuedAt + 30 * 24 * 60 * 60 * 1000,
|
|
136
|
+
...overrides,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/** A token pair, as login would have returned it. */
|
|
140
|
+
function tokenPair(overrides = {}) {
|
|
141
|
+
return {
|
|
142
|
+
accessToken: `header.${deterministic_js_1.deterministic.hex(24)}.signature`,
|
|
143
|
+
refreshToken: deterministic_js_1.deterministic.hex(43),
|
|
144
|
+
expiresIn: 900,
|
|
145
|
+
tokenType: 'Bearer',
|
|
146
|
+
...overrides,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/** A security finding, for testing a report renderer or a gate. */
|
|
150
|
+
function securityFinding(overrides = {}) {
|
|
151
|
+
return {
|
|
152
|
+
code: 'SEC_LEGACY_PATTERN',
|
|
153
|
+
severity: 'medium',
|
|
154
|
+
location: 'config.example',
|
|
155
|
+
message: 'An example finding.',
|
|
156
|
+
remediation: 'Do the safe thing instead.',
|
|
157
|
+
...overrides,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/** A fixture set with its own sequence, so two suites cannot interfere. */
|
|
161
|
+
function fixtures(seed) {
|
|
162
|
+
const random = new deterministic_js_1.Deterministic(seed);
|
|
163
|
+
return {
|
|
164
|
+
random,
|
|
165
|
+
authUser: (overrides = {}) => ({
|
|
166
|
+
id: random.id('u'),
|
|
167
|
+
email: random.email(),
|
|
168
|
+
roles: ['member'],
|
|
169
|
+
permissions: [],
|
|
170
|
+
...overrides,
|
|
171
|
+
}),
|
|
172
|
+
requestContext: (overrides = {}) => ({
|
|
173
|
+
requestId: random.id('req'),
|
|
174
|
+
startedAt: deterministic_js_1.FIXTURE_EPOCH_MS,
|
|
175
|
+
...overrides,
|
|
176
|
+
}),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
//# sourceMappingURL=builders.js.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic values for fixtures (PLAN.md §19: "deterministic seed fixtures;
|
|
3
|
+
* no shared state").
|
|
4
|
+
*
|
|
5
|
+
* A fixture that calls `Math.random()` or `Date.now()` produces a test that
|
|
6
|
+
* passes 999 times and fails once, at 23:59 on the last day of the month, on
|
|
7
|
+
* someone else's machine. Everything here is a pure function of a seed.
|
|
8
|
+
*
|
|
9
|
+
* The generator is a counter-based hash rather than a stateful PRNG on purpose:
|
|
10
|
+
* `sequence(3)` gives the same value whether it is the first call or the
|
|
11
|
+
* thousandth, so a test that adds a fixture above another one does not shift
|
|
12
|
+
* every id below it.
|
|
13
|
+
*/
|
|
14
|
+
/** Fixed epoch for every timestamp a fixture produces: 2026-01-01T12:00:00Z. */
|
|
15
|
+
export declare const FIXTURE_EPOCH_MS: number;
|
|
16
|
+
/**
|
|
17
|
+
* A 32-bit integer hash of `(seed, index)`.
|
|
18
|
+
*
|
|
19
|
+
* This is the SplitMix32 finaliser: cheap, well-distributed, and — the property
|
|
20
|
+
* that matters here — completely stateless.
|
|
21
|
+
*/
|
|
22
|
+
export declare function hash32(seed: number, index: number): number;
|
|
23
|
+
/** A counter-based source of stable pseudo-random values. */
|
|
24
|
+
export declare class Deterministic {
|
|
25
|
+
#private;
|
|
26
|
+
constructor(seed?: number);
|
|
27
|
+
/** The next value in the sequence, as an unsigned 32-bit integer. */
|
|
28
|
+
next(): number;
|
|
29
|
+
/** An integer in `[min, max]`, inclusive. */
|
|
30
|
+
int(min: number, max: number): number;
|
|
31
|
+
/** A hex string of `length` characters, for ids and tokens. */
|
|
32
|
+
hex(length?: number): string;
|
|
33
|
+
/** One element of `values`. Throws on an empty array rather than returning undefined. */
|
|
34
|
+
pick<T>(values: readonly T[]): T;
|
|
35
|
+
/** A stable id like `u-3f9a12`, readable in a failure message. */
|
|
36
|
+
id(prefix?: string): string;
|
|
37
|
+
/**
|
|
38
|
+
* A stable, obviously-fake email.
|
|
39
|
+
*
|
|
40
|
+
* `.test` is reserved by RFC 2606, so a fixture can never accidentally send
|
|
41
|
+
* mail to a real address.
|
|
42
|
+
*/
|
|
43
|
+
email(local?: string): string;
|
|
44
|
+
/** Restart the sequence, so two fixture sets can be built independently. */
|
|
45
|
+
reset(): this;
|
|
46
|
+
get seed(): number;
|
|
47
|
+
}
|
|
48
|
+
/** The shared default sequence. Call `reset()` in a `beforeEach` if order matters. */
|
|
49
|
+
export declare const deterministic: Deterministic;
|
|
50
|
+
/** An ISO timestamp `offsetMs` from the fixture epoch. */
|
|
51
|
+
export declare function fixtureDate(offsetMs?: number): string;
|
|
52
|
+
//# sourceMappingURL=deterministic.d.ts.map
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic values for fixtures (PLAN.md §19: "deterministic seed fixtures;
|
|
4
|
+
* no shared state").
|
|
5
|
+
*
|
|
6
|
+
* A fixture that calls `Math.random()` or `Date.now()` produces a test that
|
|
7
|
+
* passes 999 times and fails once, at 23:59 on the last day of the month, on
|
|
8
|
+
* someone else's machine. Everything here is a pure function of a seed.
|
|
9
|
+
*
|
|
10
|
+
* The generator is a counter-based hash rather than a stateful PRNG on purpose:
|
|
11
|
+
* `sequence(3)` gives the same value whether it is the first call or the
|
|
12
|
+
* thousandth, so a test that adds a fixture above another one does not shift
|
|
13
|
+
* every id below it.
|
|
14
|
+
*/
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.deterministic = exports.Deterministic = exports.FIXTURE_EPOCH_MS = void 0;
|
|
17
|
+
exports.hash32 = hash32;
|
|
18
|
+
exports.fixtureDate = fixtureDate;
|
|
19
|
+
/** Fixed epoch for every timestamp a fixture produces: 2026-01-01T12:00:00Z. */
|
|
20
|
+
exports.FIXTURE_EPOCH_MS = Date.UTC(2026, 0, 1, 12, 0, 0);
|
|
21
|
+
/**
|
|
22
|
+
* A 32-bit integer hash of `(seed, index)`.
|
|
23
|
+
*
|
|
24
|
+
* This is the SplitMix32 finaliser: cheap, well-distributed, and — the property
|
|
25
|
+
* that matters here — completely stateless.
|
|
26
|
+
*/
|
|
27
|
+
function hash32(seed, index) {
|
|
28
|
+
let value = (seed + index * 0x9e37_79b9) | 0;
|
|
29
|
+
value = Math.imul(value ^ (value >>> 16), 0x21f0_aaad);
|
|
30
|
+
value = Math.imul(value ^ (value >>> 15), 0x735a_2d97);
|
|
31
|
+
return (value ^ (value >>> 15)) >>> 0;
|
|
32
|
+
}
|
|
33
|
+
/** A counter-based source of stable pseudo-random values. */
|
|
34
|
+
class Deterministic {
|
|
35
|
+
#seed;
|
|
36
|
+
#index = 0;
|
|
37
|
+
constructor(seed = 1) {
|
|
38
|
+
this.#seed = seed;
|
|
39
|
+
}
|
|
40
|
+
/** The next value in the sequence, as an unsigned 32-bit integer. */
|
|
41
|
+
next() {
|
|
42
|
+
this.#index += 1;
|
|
43
|
+
return hash32(this.#seed, this.#index);
|
|
44
|
+
}
|
|
45
|
+
/** An integer in `[min, max]`, inclusive. */
|
|
46
|
+
int(min, max) {
|
|
47
|
+
if (max < min)
|
|
48
|
+
throw new RangeError(`max (${String(max)}) is below min (${String(min)})`);
|
|
49
|
+
return min + (this.next() % (max - min + 1));
|
|
50
|
+
}
|
|
51
|
+
/** A hex string of `length` characters, for ids and tokens. */
|
|
52
|
+
hex(length = 12) {
|
|
53
|
+
let out = '';
|
|
54
|
+
while (out.length < length)
|
|
55
|
+
out += this.next().toString(16).padStart(8, '0');
|
|
56
|
+
return out.slice(0, length);
|
|
57
|
+
}
|
|
58
|
+
/** One element of `values`. Throws on an empty array rather than returning undefined. */
|
|
59
|
+
pick(values) {
|
|
60
|
+
if (values.length === 0)
|
|
61
|
+
throw new RangeError('pick() needs at least one value');
|
|
62
|
+
// Non-null: the index is taken modulo a non-zero length.
|
|
63
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the guard above
|
|
64
|
+
return values[this.next() % values.length];
|
|
65
|
+
}
|
|
66
|
+
/** A stable id like `u-3f9a12`, readable in a failure message. */
|
|
67
|
+
id(prefix = 'id') {
|
|
68
|
+
return `${prefix}-${this.hex(6)}`;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* A stable, obviously-fake email.
|
|
72
|
+
*
|
|
73
|
+
* `.test` is reserved by RFC 2606, so a fixture can never accidentally send
|
|
74
|
+
* mail to a real address.
|
|
75
|
+
*/
|
|
76
|
+
email(local = 'user') {
|
|
77
|
+
return `${local}-${this.hex(4)}@example.test`;
|
|
78
|
+
}
|
|
79
|
+
/** Restart the sequence, so two fixture sets can be built independently. */
|
|
80
|
+
reset() {
|
|
81
|
+
this.#index = 0;
|
|
82
|
+
return this;
|
|
83
|
+
}
|
|
84
|
+
get seed() {
|
|
85
|
+
return this.#seed;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
exports.Deterministic = Deterministic;
|
|
89
|
+
/** The shared default sequence. Call `reset()` in a `beforeEach` if order matters. */
|
|
90
|
+
exports.deterministic = new Deterministic();
|
|
91
|
+
/** An ISO timestamp `offsetMs` from the fixture epoch. */
|
|
92
|
+
function fixtureDate(offsetMs = 0) {
|
|
93
|
+
return new Date(exports.FIXTURE_EPOCH_MS + offsetMs).toISOString();
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=deterministic.js.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nage-api/testing` — the shared test kit (PLAN.md §19, §24 Phase 9).
|
|
3
|
+
*
|
|
4
|
+
* Four things, in rough order of how much they save:
|
|
5
|
+
*
|
|
6
|
+
* 1. **security packs** — the cases a whole class of implementation must pass,
|
|
7
|
+
* parameterised over an injected harness. When someone finds a new bypass,
|
|
8
|
+
* one case here protects every implementation at once.
|
|
9
|
+
* 2. **`expectNoSecrets`** — the leak check, done properly: non-enumerable
|
|
10
|
+
* `Error` fields, base64 and URL encodings, cyclic objects.
|
|
11
|
+
* 3. **fixtures** — deterministic builders, so a suite cannot fail once a month
|
|
12
|
+
* at midnight.
|
|
13
|
+
* 4. **port doubles** — real implementations of the framework's ports, because
|
|
14
|
+
* a stub agrees with whatever the code does and therefore tests nothing.
|
|
15
|
+
*
|
|
16
|
+
* The package depends only on `@nage-api/contracts`, and on no test runner: the
|
|
17
|
+
* framework standardises on Vitest while generated apps may use Jest (§27.3), so
|
|
18
|
+
* the runner is injected as a `TestApi`.
|
|
19
|
+
*/
|
|
20
|
+
export type * from '@nage-api/contracts';
|
|
21
|
+
export type { Expectation, Pack, TestApi } from './test-api.js';
|
|
22
|
+
export { Deterministic, FIXTURE_EPOCH_MS, deterministic, fixtureDate, hash32, } from './fixtures/deterministic.js';
|
|
23
|
+
export { adminUser, authUser, baseEntity, entity, fixtures, job, paginated, paginationMeta, queueJob, requestContext, securityFinding, sessionRecord, successResponse, tokenPair, type JobOverrides, type Override, } from './fixtures/builders.js';
|
|
24
|
+
export { FakeClock, MemoryKeyValueStore, MemoryRateLimitStore, RecordingLogger, StubSecretProvider, type CapturedLog, } from './doubles/index.js';
|
|
25
|
+
export { COMMON_TEST_SECRETS, expectNoSecrets, mask, scanForLeaks, serialiseDeep, type LeakScanResult, } from './packs/no-leak.js';
|
|
26
|
+
export { describeAuthzBypass, type AttemptResult, type AuthzBypassHarness, } from './packs/authz-bypass.pack.js';
|
|
27
|
+
export { describeQueryAllowList, type QueryAllowListHarness, } from './packs/query-allow-list.pack.js';
|
|
28
|
+
export { MALICIOUS_UPLOADS, describeUploadValidation, type MaliciousFile, type UploadValidationHarness, } from './packs/upload-validation.pack.js';
|
|
29
|
+
export { describeErrorNonLeakage, type ErrorNonLeakageHarness, } from './packs/error-non-leakage.pack.js';
|
|
30
|
+
export { assertCorrelated, assertErrorEnvelope, assertPaginated, assertSuccessEnvelope, } from './assertions/envelope.js';
|
|
31
|
+
export { ContainerHarness, IMAGES, INTERNAL_PORTS, describeWithContainers, unavailableRuntime, urlFor, type ContainerHarnessOptions, type ContainerRuntime, type ContainerService, type ContainerSuiteApi, type StartedContainer, } from './containers/harness.js';
|
|
32
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `@nage-api/testing` — the shared test kit (PLAN.md §19, §24 Phase 9).
|
|
4
|
+
*
|
|
5
|
+
* Four things, in rough order of how much they save:
|
|
6
|
+
*
|
|
7
|
+
* 1. **security packs** — the cases a whole class of implementation must pass,
|
|
8
|
+
* parameterised over an injected harness. When someone finds a new bypass,
|
|
9
|
+
* one case here protects every implementation at once.
|
|
10
|
+
* 2. **`expectNoSecrets`** — the leak check, done properly: non-enumerable
|
|
11
|
+
* `Error` fields, base64 and URL encodings, cyclic objects.
|
|
12
|
+
* 3. **fixtures** — deterministic builders, so a suite cannot fail once a month
|
|
13
|
+
* at midnight.
|
|
14
|
+
* 4. **port doubles** — real implementations of the framework's ports, because
|
|
15
|
+
* a stub agrees with whatever the code does and therefore tests nothing.
|
|
16
|
+
*
|
|
17
|
+
* The package depends only on `@nage-api/contracts`, and on no test runner: the
|
|
18
|
+
* framework standardises on Vitest while generated apps may use Jest (§27.3), so
|
|
19
|
+
* the runner is injected as a `TestApi`.
|
|
20
|
+
*/
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.urlFor = exports.unavailableRuntime = exports.describeWithContainers = exports.INTERNAL_PORTS = exports.IMAGES = exports.ContainerHarness = exports.assertSuccessEnvelope = exports.assertPaginated = exports.assertErrorEnvelope = exports.assertCorrelated = exports.describeErrorNonLeakage = exports.describeUploadValidation = exports.MALICIOUS_UPLOADS = exports.describeQueryAllowList = exports.describeAuthzBypass = exports.serialiseDeep = exports.scanForLeaks = exports.mask = exports.expectNoSecrets = exports.COMMON_TEST_SECRETS = exports.StubSecretProvider = exports.RecordingLogger = exports.MemoryRateLimitStore = exports.MemoryKeyValueStore = exports.FakeClock = exports.tokenPair = exports.successResponse = exports.sessionRecord = exports.securityFinding = exports.requestContext = exports.queueJob = exports.paginationMeta = exports.paginated = exports.job = exports.fixtures = exports.entity = exports.baseEntity = exports.authUser = exports.adminUser = exports.hash32 = exports.fixtureDate = exports.deterministic = exports.FIXTURE_EPOCH_MS = exports.Deterministic = void 0;
|
|
23
|
+
// Fixtures.
|
|
24
|
+
var deterministic_js_1 = require("./fixtures/deterministic.js");
|
|
25
|
+
Object.defineProperty(exports, "Deterministic", { enumerable: true, get: function () { return deterministic_js_1.Deterministic; } });
|
|
26
|
+
Object.defineProperty(exports, "FIXTURE_EPOCH_MS", { enumerable: true, get: function () { return deterministic_js_1.FIXTURE_EPOCH_MS; } });
|
|
27
|
+
Object.defineProperty(exports, "deterministic", { enumerable: true, get: function () { return deterministic_js_1.deterministic; } });
|
|
28
|
+
Object.defineProperty(exports, "fixtureDate", { enumerable: true, get: function () { return deterministic_js_1.fixtureDate; } });
|
|
29
|
+
Object.defineProperty(exports, "hash32", { enumerable: true, get: function () { return deterministic_js_1.hash32; } });
|
|
30
|
+
var builders_js_1 = require("./fixtures/builders.js");
|
|
31
|
+
Object.defineProperty(exports, "adminUser", { enumerable: true, get: function () { return builders_js_1.adminUser; } });
|
|
32
|
+
Object.defineProperty(exports, "authUser", { enumerable: true, get: function () { return builders_js_1.authUser; } });
|
|
33
|
+
Object.defineProperty(exports, "baseEntity", { enumerable: true, get: function () { return builders_js_1.baseEntity; } });
|
|
34
|
+
Object.defineProperty(exports, "entity", { enumerable: true, get: function () { return builders_js_1.entity; } });
|
|
35
|
+
Object.defineProperty(exports, "fixtures", { enumerable: true, get: function () { return builders_js_1.fixtures; } });
|
|
36
|
+
Object.defineProperty(exports, "job", { enumerable: true, get: function () { return builders_js_1.job; } });
|
|
37
|
+
Object.defineProperty(exports, "paginated", { enumerable: true, get: function () { return builders_js_1.paginated; } });
|
|
38
|
+
Object.defineProperty(exports, "paginationMeta", { enumerable: true, get: function () { return builders_js_1.paginationMeta; } });
|
|
39
|
+
Object.defineProperty(exports, "queueJob", { enumerable: true, get: function () { return builders_js_1.queueJob; } });
|
|
40
|
+
Object.defineProperty(exports, "requestContext", { enumerable: true, get: function () { return builders_js_1.requestContext; } });
|
|
41
|
+
Object.defineProperty(exports, "securityFinding", { enumerable: true, get: function () { return builders_js_1.securityFinding; } });
|
|
42
|
+
Object.defineProperty(exports, "sessionRecord", { enumerable: true, get: function () { return builders_js_1.sessionRecord; } });
|
|
43
|
+
Object.defineProperty(exports, "successResponse", { enumerable: true, get: function () { return builders_js_1.successResponse; } });
|
|
44
|
+
Object.defineProperty(exports, "tokenPair", { enumerable: true, get: function () { return builders_js_1.tokenPair; } });
|
|
45
|
+
// Port doubles.
|
|
46
|
+
var index_js_1 = require("./doubles/index.js");
|
|
47
|
+
Object.defineProperty(exports, "FakeClock", { enumerable: true, get: function () { return index_js_1.FakeClock; } });
|
|
48
|
+
Object.defineProperty(exports, "MemoryKeyValueStore", { enumerable: true, get: function () { return index_js_1.MemoryKeyValueStore; } });
|
|
49
|
+
Object.defineProperty(exports, "MemoryRateLimitStore", { enumerable: true, get: function () { return index_js_1.MemoryRateLimitStore; } });
|
|
50
|
+
Object.defineProperty(exports, "RecordingLogger", { enumerable: true, get: function () { return index_js_1.RecordingLogger; } });
|
|
51
|
+
Object.defineProperty(exports, "StubSecretProvider", { enumerable: true, get: function () { return index_js_1.StubSecretProvider; } });
|
|
52
|
+
// Leak detection.
|
|
53
|
+
var no_leak_js_1 = require("./packs/no-leak.js");
|
|
54
|
+
Object.defineProperty(exports, "COMMON_TEST_SECRETS", { enumerable: true, get: function () { return no_leak_js_1.COMMON_TEST_SECRETS; } });
|
|
55
|
+
Object.defineProperty(exports, "expectNoSecrets", { enumerable: true, get: function () { return no_leak_js_1.expectNoSecrets; } });
|
|
56
|
+
Object.defineProperty(exports, "mask", { enumerable: true, get: function () { return no_leak_js_1.mask; } });
|
|
57
|
+
Object.defineProperty(exports, "scanForLeaks", { enumerable: true, get: function () { return no_leak_js_1.scanForLeaks; } });
|
|
58
|
+
Object.defineProperty(exports, "serialiseDeep", { enumerable: true, get: function () { return no_leak_js_1.serialiseDeep; } });
|
|
59
|
+
// Security packs.
|
|
60
|
+
var authz_bypass_pack_js_1 = require("./packs/authz-bypass.pack.js");
|
|
61
|
+
Object.defineProperty(exports, "describeAuthzBypass", { enumerable: true, get: function () { return authz_bypass_pack_js_1.describeAuthzBypass; } });
|
|
62
|
+
var query_allow_list_pack_js_1 = require("./packs/query-allow-list.pack.js");
|
|
63
|
+
Object.defineProperty(exports, "describeQueryAllowList", { enumerable: true, get: function () { return query_allow_list_pack_js_1.describeQueryAllowList; } });
|
|
64
|
+
var upload_validation_pack_js_1 = require("./packs/upload-validation.pack.js");
|
|
65
|
+
Object.defineProperty(exports, "MALICIOUS_UPLOADS", { enumerable: true, get: function () { return upload_validation_pack_js_1.MALICIOUS_UPLOADS; } });
|
|
66
|
+
Object.defineProperty(exports, "describeUploadValidation", { enumerable: true, get: function () { return upload_validation_pack_js_1.describeUploadValidation; } });
|
|
67
|
+
var error_non_leakage_pack_js_1 = require("./packs/error-non-leakage.pack.js");
|
|
68
|
+
Object.defineProperty(exports, "describeErrorNonLeakage", { enumerable: true, get: function () { return error_non_leakage_pack_js_1.describeErrorNonLeakage; } });
|
|
69
|
+
// Envelope assertions.
|
|
70
|
+
var envelope_js_1 = require("./assertions/envelope.js");
|
|
71
|
+
Object.defineProperty(exports, "assertCorrelated", { enumerable: true, get: function () { return envelope_js_1.assertCorrelated; } });
|
|
72
|
+
Object.defineProperty(exports, "assertErrorEnvelope", { enumerable: true, get: function () { return envelope_js_1.assertErrorEnvelope; } });
|
|
73
|
+
Object.defineProperty(exports, "assertPaginated", { enumerable: true, get: function () { return envelope_js_1.assertPaginated; } });
|
|
74
|
+
Object.defineProperty(exports, "assertSuccessEnvelope", { enumerable: true, get: function () { return envelope_js_1.assertSuccessEnvelope; } });
|
|
75
|
+
// Container harness.
|
|
76
|
+
var harness_js_1 = require("./containers/harness.js");
|
|
77
|
+
Object.defineProperty(exports, "ContainerHarness", { enumerable: true, get: function () { return harness_js_1.ContainerHarness; } });
|
|
78
|
+
Object.defineProperty(exports, "IMAGES", { enumerable: true, get: function () { return harness_js_1.IMAGES; } });
|
|
79
|
+
Object.defineProperty(exports, "INTERNAL_PORTS", { enumerable: true, get: function () { return harness_js_1.INTERNAL_PORTS; } });
|
|
80
|
+
Object.defineProperty(exports, "describeWithContainers", { enumerable: true, get: function () { return harness_js_1.describeWithContainers; } });
|
|
81
|
+
Object.defineProperty(exports, "unavailableRuntime", { enumerable: true, get: function () { return harness_js_1.unavailableRuntime; } });
|
|
82
|
+
Object.defineProperty(exports, "urlFor", { enumerable: true, get: function () { return harness_js_1.urlFor; } });
|
|
83
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The authorization-bypass pack (PLAN.md §15.2, §19).
|
|
3
|
+
*
|
|
4
|
+
* Every case here is an attack that works against a plausible implementation.
|
|
5
|
+
* They are collected in the kit rather than in `@nage-api/auth` so that anything
|
|
6
|
+
* else making an access decision — a realtime room policy, a driver's ownership
|
|
7
|
+
* scoping, a generated resource's guard — is held to the same standard.
|
|
8
|
+
*
|
|
9
|
+
* The harness is deliberately abstract: `attempt(credential, target)` returns
|
|
10
|
+
* whether access was granted. That reduces every authorization surface to the
|
|
11
|
+
* one question the pack cares about, and keeps the pack free of HTTP.
|
|
12
|
+
*/
|
|
13
|
+
import type { AuthUser } from '@nage-api/contracts';
|
|
14
|
+
import type { Pack } from '../test-api.js';
|
|
15
|
+
/** The outcome of one attempt. */
|
|
16
|
+
export interface AttemptResult {
|
|
17
|
+
readonly allowed: boolean;
|
|
18
|
+
/** What the caller was told. Scanned for information disclosure. */
|
|
19
|
+
readonly message?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface AuthzBypassHarness {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
/** A principal that legitimately has access, for the control case. */
|
|
24
|
+
readonly authorised: AuthUser;
|
|
25
|
+
/** A principal that legitimately does not. */
|
|
26
|
+
readonly unauthorised: AuthUser;
|
|
27
|
+
/** What the authorised principal may reach. */
|
|
28
|
+
readonly target: string;
|
|
29
|
+
/**
|
|
30
|
+
* Attempt access. `undefined` means "no credential at all".
|
|
31
|
+
*
|
|
32
|
+
* Should resolve rather than throw; map a refusal to `{ allowed: false }` so
|
|
33
|
+
* the pack can inspect the message too.
|
|
34
|
+
*/
|
|
35
|
+
attempt(principal: AuthUser | undefined, target: string): Promise<AttemptResult>;
|
|
36
|
+
/**
|
|
37
|
+
* A principal whose *claims* say they are authorised but whose stored record
|
|
38
|
+
* does not.
|
|
39
|
+
*
|
|
40
|
+
* Optional, because not every surface reads a store — but where one exists,
|
|
41
|
+
* this is the case that separates "checks the token" from "checks the truth".
|
|
42
|
+
*
|
|
43
|
+
* Supply this for a surface that re-derives grants from a store rather than
|
|
44
|
+
* reading them off the credential. The pack detects that shape and skips its
|
|
45
|
+
* look-alike cases there, because the grants it can vary are not the ones being
|
|
46
|
+
* compared — this case is what covers such a surface instead.
|
|
47
|
+
*/
|
|
48
|
+
readonly forgedClaims?: AuthUser;
|
|
49
|
+
/** A target the unauthorised principal owns, if ownership applies. */
|
|
50
|
+
readonly ownTarget?: string;
|
|
51
|
+
}
|
|
52
|
+
export declare const describeAuthzBypass: Pack<AuthzBypassHarness>;
|
|
53
|
+
//# sourceMappingURL=authz-bypass.pack.d.ts.map
|