@classytic/arc-testkit 0.6.0 → 0.7.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.
- package/README.md +26 -5
- package/dist/conformance.d.mts +129 -70
- package/dist/conformance.mjs +207 -61
- package/dist/index.d.mts +5 -6
- package/dist/mongo-commands.d.mts +6 -6
- package/dist/mongo-explain.d.mts +57 -5
- package/dist/mongo-explain.mjs +71 -1
- package/dist/mongo.d.mts +122 -10
- package/dist/mongo.mjs +295 -8
- package/dist/pool.d.mts +13 -8
- package/dist/pool.mjs +24 -21
- package/dist/process-cleanup-1iBIS_o_.mjs +66 -0
- package/dist/story.d.mts +38 -38
- package/llms.txt +2 -2
- package/package.json +6 -3
- package/skills/arc-testkit/SKILL.md +3 -4
- package/skills/arc-testkit/references/conformance.md +15 -14
- package/skills/arc-testkit/references/fixtures.md +12 -0
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ The ecosystem's contributor contract for the `@classytic/arc-*` module fleet: **
|
|
|
10
10
|
npm i -D @classytic/arc-testkit mongodb-memory-server
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
`mongoose` (>=9.4
|
|
13
|
+
`@classytic/arc` (>=2.49.0) is an optional peer: only `.` (`bootModuleApp`) imports it; the guards and stories take a booted app from the caller, and a client project that uses only the client drift guard installs neither arc nor mongoose. `mongoose` (>=9.11.4) is needed by `.`, `/pool`, `/mongo` and `/mongo-commands`; `/conformance` and `/story` need only `vitest` (>=4.1.11, tested on 4.1.11 and 5.0.5) and a booted arc app. `mongodb-memory-server` is imported lazily by the default database.
|
|
14
14
|
|
|
15
15
|
## Usage
|
|
16
16
|
|
|
@@ -47,6 +47,10 @@ Pass a `TestDatabaseFactory` (from `@classytic/arc/testing`) as `database`, for
|
|
|
47
47
|
const t = await bootModuleApp(mods, { database: dbPool.factory });
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
+
## Temp dirs of killed runs
|
|
51
|
+
|
|
52
|
+
Every in-memory server writes a `mongo-mem-*` data dir. `mongoServerProvider` removes it on stop, after a failed start, and synchronously when the process exits without teardown. A run killed outright (task manager, a terminated worker) cannot clean up, so before its first server each process removes `mongo-mem-*` dirs that are older than `TEST_MONGO_SWEEP_HOURS` (default 6; `off` disables) and whose `mongod.lock` names no live process. Set `TEST_MONGO_TMPDIR` (for example `.tmp/mongo`) to keep the data dirs under one project-local dir instead of the system temp dir.
|
|
53
|
+
|
|
50
54
|
## API
|
|
51
55
|
|
|
52
56
|
| Export | |
|
|
@@ -56,8 +60,8 @@ const t = await bootModuleApp(mods, { database: dbPool.factory });
|
|
|
56
60
|
| `BootModuleAppOptions` | `{ replset?, database?, app?, actor?, ports? }` |
|
|
57
61
|
| `ModulesInput`, `ModuleTestApp` | the modules argument and the boot result types |
|
|
58
62
|
| `/pool` | `createTestDatabasePool`, `publishPoolServers` |
|
|
59
|
-
| `/mongo` | `mongoServerProvider`, `safeWorkerCount`, `safePoolSize`, `awaitIndexBuilds`, `withDbName`, `cacheSizeGB`, `mongodMemoryArgs` |
|
|
60
|
-
| `/mongo-commands`, `/mongo-explain` | `countCommands`, `assertConstantCommands`, `summarizeExplain`, `assertIndexServed` |
|
|
63
|
+
| `/mongo` | `mongoServerProvider`, `safeWorkerCount`, `safePoolSize`, `awaitIndexBuilds`, `withDbName`, `cacheSizeGB`, `mongodMemoryArgs`, `sweepStaleMongoMemoryDirs`, `launchMongoMemory`, `stopOnProcessExit` |
|
|
64
|
+
| `/mongo-commands`, `/mongo-explain` | `countCommands`, `assertConstantCommands`, `summarizeExplain`, `assertIndexServed`, `explainCaptured` |
|
|
61
65
|
| `/conformance` | `check*` / `register*` for list envelope, route schemas, naming, OpenAPI, client drift, collection routes, error contract; `expectClean`, `injectCaller`, `fetchOpenApi`, `routeTable` |
|
|
62
66
|
| `/story` | `describeStory`, `createCast`, `createStoryClock`, `createStoryAssertions`, `createEventLog`, `expectIdempotent`, `expectSweepsIdempotent` |
|
|
63
67
|
|
|
@@ -65,7 +69,7 @@ const t = await bootModuleApp(mods, { database: dbPool.factory });
|
|
|
65
69
|
|
|
66
70
|
```ts
|
|
67
71
|
import { assertConstantCommands, countCommands } from '@classytic/arc-testkit/mongo-commands';
|
|
68
|
-
import { assertIndexServed } from '@classytic/arc-testkit/mongo-explain';
|
|
72
|
+
import { assertIndexServed, explainCaptured } from '@classytic/arc-testkit/mongo-explain';
|
|
69
73
|
|
|
70
74
|
// What the driver sent (the connection needs `monitorCommands: true` — it refuses otherwise).
|
|
71
75
|
const { total, byCollection } = await countCommands(conn, () => placeOrder(), { collections: ['orders'] });
|
|
@@ -73,6 +77,9 @@ const { total, byCollection } = await countCommands(conn, () => placeOrder(), {
|
|
|
73
77
|
await assertConstantCommands(conn, (n) => reader.page(n), [5, 50]);
|
|
74
78
|
// No COLLSCAN, an index scanned, optionally a named one and a docs-examined ceiling.
|
|
75
79
|
assertIndexServed(await Model.find(filter).explain('executionStats'), { index: 'org_state', maxDocsExamined: 40 });
|
|
80
|
+
// A query built inside a repository or plugin: capture what it sent, explain exactly that.
|
|
81
|
+
const { explain } = await explainCaptured(conn, () => service.claim(now), { collection: 'timers', commands: ['findAndModify'] });
|
|
82
|
+
assertIndexServed(explain, { index: 'state_due', maxDocsExamined: 6 });
|
|
76
83
|
```
|
|
77
84
|
|
|
78
85
|
## Conformance guards and story kit (opt-in, need `vitest`)
|
|
@@ -88,7 +95,21 @@ registerOpenApiGuard({ spec: () => fetchOpenApi(admin), app: () => t.app, anonym
|
|
|
88
95
|
```
|
|
89
96
|
|
|
90
97
|
Every guard is also a `check*` function returning the problems found (empty when green). Exception
|
|
91
|
-
lists fail when an entry matches nothing.
|
|
98
|
+
lists fail when an entry matches nothing.
|
|
99
|
+
|
|
100
|
+
The refusal guard calls every mutating route as an anonymous and a signed-in caller with no grant; each call must be
|
|
101
|
+
refused AND leave your state snapshot unchanged. arc validates an action body before the verb's permission, so give
|
|
102
|
+
it a body per verb; exclusions can differ per caller:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
registerRefusalDoesNoWorkGuard({
|
|
106
|
+
name: 'no role: every write and every verb is refused',
|
|
107
|
+
app: () => t.app, anonymous: () => injectCaller(t.app), unprivileged: () => injectCaller(t.app, noRole), snapshot,
|
|
108
|
+
paramValue: ({ resource }) => (resource === 'ticket' ? seeded.ticketId : '000000000000000000000001'),
|
|
109
|
+
actionBodies: { 'ticket.close': { reason: 'Sample reason' }, 'ticket.reopen': {} },
|
|
110
|
+
exclude: { anonymous: PUBLIC_WRITES, unprivileged: { ...PUBLIC_WRITES, ...SELF_SERVICE } },
|
|
111
|
+
});
|
|
112
|
+
``` `@classytic/arc-testkit/story` holds `describeStory`,
|
|
92
113
|
`createCast`, `createStoryClock`, `createStoryAssertions` (outbox / events / feed / mail / audit through
|
|
93
114
|
readers you inject) and `expectIdempotent`; see the module docs.
|
|
94
115
|
|
package/dist/conformance.d.mts
CHANGED
|
@@ -18,12 +18,16 @@ interface AppLike {
|
|
|
18
18
|
includeHooks?: boolean;
|
|
19
19
|
}): string;
|
|
20
20
|
}
|
|
21
|
-
/** `app.arc.registry.getAll()` entries, as far as the
|
|
21
|
+
/** `app.arc.registry.getAll()` entries, as far as the guards read them. */
|
|
22
22
|
interface RegistryEntryLike {
|
|
23
23
|
name: string;
|
|
24
|
+
/** The resource's route prefix (`/widgets`); the app may mount it under a further prefix. */
|
|
25
|
+
prefix?: string;
|
|
26
|
+
/** `id: false` mounts the verb on `POST <prefix>/action` instead of `POST <prefix>/:id/action`. */
|
|
24
27
|
actions?: Array<{
|
|
25
28
|
name: string;
|
|
26
29
|
schema?: unknown;
|
|
30
|
+
id?: boolean;
|
|
27
31
|
}>;
|
|
28
32
|
customRoutes?: Array<{
|
|
29
33
|
method: string;
|
|
@@ -50,21 +54,21 @@ interface InjectCaller extends Caller {
|
|
|
50
54
|
delete(path: string): Promise<Reply>;
|
|
51
55
|
}
|
|
52
56
|
/** A `Caller` over `app.inject`, JSON in and out, sending `headers` (an auth token) with every request. */
|
|
53
|
-
declare function injectCaller(app: AppLike, headers?: Record<string, string>): InjectCaller;
|
|
57
|
+
export declare function injectCaller(app: AppLike, headers?: Record<string, string>): InjectCaller;
|
|
54
58
|
interface RouteRow {
|
|
55
59
|
path: string;
|
|
56
60
|
methods: string[];
|
|
57
61
|
}
|
|
58
62
|
/** The app's route table as `{ path, methods }` rows, read from Fastify's radix-tree dump. */
|
|
59
|
-
declare function routeTable(app: Pick<AppLike, "printRoutes">): RouteRow[];
|
|
63
|
+
export declare function routeTable(app: Pick<AppLike, "printRoutes">): RouteRow[];
|
|
60
64
|
/** `/a/{id}/b`, `/a/:id|:other/b` and `/a/:id/b/` are one shape: `/a/:x/b`. */
|
|
61
|
-
declare function shapeOf(path: string): string;
|
|
65
|
+
export declare function shapeOf(path: string): string;
|
|
62
66
|
/** An exception list: bare keys, or `key -> reason`. */
|
|
63
67
|
type Exceptions = Iterable<string> | Readonly<Record<string, string>>;
|
|
64
68
|
/** Fails with the problems listed, one per line; passes when there are none. Use it with the `check*` functions inside a host's own tests. */
|
|
65
|
-
declare function expectClean(what: string, problems: readonly string[]): void;
|
|
66
|
-
declare const OPENAPI_METHODS: readonly ["get", "put", "post", "delete", "patch"];
|
|
67
|
-
interface OpenApiOperation {
|
|
69
|
+
export declare function expectClean(what: string, problems: readonly string[]): void;
|
|
70
|
+
export declare const OPENAPI_METHODS: readonly ["get", "put", "post", "delete", "patch"];
|
|
71
|
+
export interface OpenApiOperation {
|
|
68
72
|
[extension: string]: unknown;
|
|
69
73
|
security?: unknown[];
|
|
70
74
|
requestBody?: {
|
|
@@ -78,7 +82,7 @@ interface OpenApiOperation {
|
|
|
78
82
|
}>;
|
|
79
83
|
}>;
|
|
80
84
|
}
|
|
81
|
-
interface OpenApiSchema {
|
|
85
|
+
export interface OpenApiSchema {
|
|
82
86
|
$ref?: string;
|
|
83
87
|
type?: string;
|
|
84
88
|
properties?: Record<string, OpenApiSchema>;
|
|
@@ -86,9 +90,10 @@ interface OpenApiSchema {
|
|
|
86
90
|
allOf?: OpenApiSchema[];
|
|
87
91
|
anyOf?: OpenApiSchema[];
|
|
88
92
|
enum?: unknown[];
|
|
93
|
+
const?: unknown;
|
|
89
94
|
items?: OpenApiSchema;
|
|
90
95
|
}
|
|
91
|
-
interface OpenApiDocument {
|
|
96
|
+
export interface OpenApiDocument {
|
|
92
97
|
openapi?: string;
|
|
93
98
|
security?: unknown[];
|
|
94
99
|
paths: Record<string, Record<string, OpenApiOperation>>;
|
|
@@ -97,13 +102,13 @@ interface OpenApiDocument {
|
|
|
97
102
|
};
|
|
98
103
|
}
|
|
99
104
|
/** Read the document from the running app (default `/openapi.json`). */
|
|
100
|
-
declare function fetchOpenApi(caller: Caller, path?: string): Promise<OpenApiDocument>;
|
|
101
|
-
interface PublicOperation {
|
|
105
|
+
export declare function fetchOpenApi(caller: Caller, path?: string): Promise<OpenApiDocument>;
|
|
106
|
+
export interface PublicOperation {
|
|
102
107
|
/** A path template as it appears in the document, or a test for it. */
|
|
103
108
|
path: string | RegExp | ((path: string) => boolean);
|
|
104
109
|
why: string;
|
|
105
110
|
}
|
|
106
|
-
interface OpenApiOptions {
|
|
111
|
+
export interface OpenApiOptions {
|
|
107
112
|
spec: Source<OpenApiDocument>;
|
|
108
113
|
/** The booted app: enables the "names every route" check. */
|
|
109
114
|
app?: Source<AppLike>;
|
|
@@ -125,74 +130,84 @@ interface OpenApiOptions {
|
|
|
125
130
|
secretPatterns?: readonly RegExp[];
|
|
126
131
|
}
|
|
127
132
|
/** The document is OpenAPI 3, prefixed, and has no trailing-slash path. */
|
|
128
|
-
declare function checkOpenApiShape(opts: OpenApiOptions): Promise<string[]>;
|
|
133
|
+
export declare function checkOpenApiShape(opts: OpenApiOptions): Promise<string[]>;
|
|
129
134
|
/** Every route in the table is documented (once, in its bare form). */
|
|
130
|
-
declare function checkRoutesDocumented(opts: OpenApiOptions): Promise<string[]>;
|
|
135
|
+
export declare function checkRoutesDocumented(opts: OpenApiOptions): Promise<string[]>;
|
|
131
136
|
/**
|
|
132
137
|
* Every operation requires a security scheme (its own `security`, else the document's), except the
|
|
133
138
|
* public list; a public rule that matches no operation fails as stale.
|
|
134
139
|
*/
|
|
135
|
-
declare function checkSecurity(opts: OpenApiOptions): Promise<string[]>;
|
|
140
|
+
export declare function checkSecurity(opts: OpenApiOptions): Promise<string[]>;
|
|
136
141
|
/** An anonymous GET of every documented, protected, parameterless path is refused with 401. */
|
|
137
|
-
declare function checkAnonymousRefused(opts: OpenApiOptions, expected?: number): Promise<string[]>;
|
|
142
|
+
export declare function checkAnonymousRefused(opts: OpenApiOptions, expected?: number): Promise<string[]>;
|
|
138
143
|
/** The serialised document carries no configuration value. */
|
|
139
|
-
declare function checkNoSecrets(opts: OpenApiOptions): Promise<string[]>;
|
|
144
|
+
export declare function checkNoSecrets(opts: OpenApiOptions): Promise<string[]>;
|
|
140
145
|
/** Registers the OpenAPI guards as vitest tests (the optional ones only when their input is given). */
|
|
141
|
-
declare function registerOpenApiGuard(opts: OpenApiOptions): void;
|
|
142
|
-
interface ClientRoute {
|
|
146
|
+
export declare function registerOpenApiGuard(opts: OpenApiOptions): void;
|
|
147
|
+
export interface ClientRoute {
|
|
143
148
|
method: string;
|
|
144
149
|
path: string;
|
|
145
150
|
/** Where the client emits it, named in the failure. */
|
|
146
151
|
source?: string;
|
|
147
152
|
}
|
|
148
153
|
/** Spec path templates as matchers: `/api/x/{id}/y` accepts `/api/x/anything/y` and `/api/x/:id/y`. */
|
|
149
|
-
declare function specMatcher(spec: OpenApiDocument): (method: string, path: string) => string | undefined;
|
|
154
|
+
export declare function specMatcher(spec: OpenApiDocument): (method: string, path: string) => string | undefined;
|
|
150
155
|
/** Client routes the document does not have (sorted, deduplicated, with their source). */
|
|
151
|
-
declare function missingClientRoutes(spec: OpenApiDocument, routes: readonly ClientRoute[]): string[];
|
|
152
|
-
declare function resolveRef(spec: OpenApiDocument, schema: OpenApiSchema | undefined): OpenApiSchema | undefined;
|
|
156
|
+
export declare function missingClientRoutes(spec: OpenApiDocument, routes: readonly ClientRoute[]): string[];
|
|
157
|
+
export declare function resolveRef(spec: OpenApiDocument, schema: OpenApiSchema | undefined): OpenApiSchema | undefined;
|
|
153
158
|
/** Every property name a schema offers, through refs and unions. */
|
|
154
|
-
declare function propertyNames(spec: OpenApiDocument, schema: OpenApiSchema | undefined, depth?: number): Set<string>;
|
|
155
|
-
|
|
159
|
+
export declare function propertyNames(spec: OpenApiDocument, schema: OpenApiSchema | undefined, depth?: number): Set<string>;
|
|
160
|
+
/**
|
|
161
|
+
* Every literal value a schema accepts: `enum` and `const` at its root, through refs and unions,
|
|
162
|
+
* and on its top-level properties (an action router's `action` discriminator).
|
|
163
|
+
*/
|
|
164
|
+
export declare function schemaValues(spec: OpenApiDocument, schema: OpenApiSchema | undefined, depth?: number): Set<unknown>;
|
|
165
|
+
export interface BodyFieldCheck {
|
|
156
166
|
name?: string;
|
|
157
167
|
method: string;
|
|
158
168
|
/** The path template as the document spells it. */
|
|
159
169
|
path: string;
|
|
160
170
|
/** Fields the client reads or sends. */
|
|
161
171
|
fields: readonly string[];
|
|
172
|
+
/**
|
|
173
|
+
* Literal values the client sends in this body (an action router's verbs): each must be an
|
|
174
|
+
* `enum` / `const` value the schema declares (`schemaValues`). Default: none checked.
|
|
175
|
+
*/
|
|
176
|
+
values?: readonly unknown[];
|
|
162
177
|
/** Which body to read. Default: `request`. */
|
|
163
178
|
in?: "request" | "response";
|
|
164
179
|
/** Response status to read. Default: 200. */
|
|
165
180
|
status?: string;
|
|
166
181
|
}
|
|
167
|
-
declare function bodyFieldProblems(spec: OpenApiDocument, check: BodyFieldCheck): string[];
|
|
168
|
-
interface ClientDriftOptions {
|
|
182
|
+
export declare function bodyFieldProblems(spec: OpenApiDocument, check: BodyFieldCheck): string[];
|
|
183
|
+
export interface ClientDriftOptions {
|
|
169
184
|
spec: Source<OpenApiDocument>;
|
|
170
185
|
routes: Source<readonly ClientRoute[]>;
|
|
171
186
|
bodyFields?: readonly BodyFieldCheck[];
|
|
172
187
|
/** At least this many client routes must be supplied (guards against an empty probe). Default: 1. */
|
|
173
188
|
minRoutes?: number;
|
|
174
189
|
}
|
|
175
|
-
declare function checkClientDrift(opts: ClientDriftOptions): Promise<string[]>;
|
|
190
|
+
export declare function checkClientDrift(opts: ClientDriftOptions): Promise<string[]>;
|
|
176
191
|
/** Registers the drift guard as vitest tests. */
|
|
177
|
-
declare function registerClientDriftGuard(opts: ClientDriftOptions): void;
|
|
192
|
+
export declare function registerClientDriftGuard(opts: ClientDriftOptions): void;
|
|
178
193
|
/** Write the document to `file` (the server side of the dump: `npm run openapi:dump`). */
|
|
179
|
-
declare function dumpOpenApi(spec: OpenApiDocument, file: string): void;
|
|
194
|
+
export declare function dumpOpenApi(spec: OpenApiDocument, file: string): void;
|
|
180
195
|
/**
|
|
181
196
|
* Read a dumped document. Throws when the dump is missing or older than any `.ts` file under
|
|
182
197
|
* `sources`, so a stale document can never pass for the current API. `regenerate` names the command
|
|
183
198
|
* in the message.
|
|
184
199
|
*/
|
|
185
|
-
declare function loadOpenApiDump(file: string, opts?: {
|
|
200
|
+
export declare function loadOpenApiDump(file: string, opts?: {
|
|
186
201
|
sources?: readonly string[];
|
|
187
202
|
regenerate?: string;
|
|
188
203
|
}): OpenApiDocument;
|
|
189
|
-
interface CollectionRoute {
|
|
204
|
+
export interface CollectionRoute {
|
|
190
205
|
/** The bare collection path, e.g. `/api/things`. */
|
|
191
206
|
path: string;
|
|
192
207
|
/** The verbs the client sends to it. */
|
|
193
208
|
methods: readonly string[];
|
|
194
209
|
}
|
|
195
|
-
interface CollectionRoutesOptions {
|
|
210
|
+
export interface CollectionRoutesOptions {
|
|
196
211
|
app: Source<AppLike>;
|
|
197
212
|
collections: readonly CollectionRoute[];
|
|
198
213
|
/** Bare GET paths that must answer 200 for `caller` (and the trailing-slash form is not what is registered). */
|
|
@@ -201,11 +216,11 @@ interface CollectionRoutesOptions {
|
|
|
201
216
|
paths: readonly string[];
|
|
202
217
|
};
|
|
203
218
|
}
|
|
204
|
-
declare function checkCollectionRoutes(opts: CollectionRoutesOptions): Promise<string[]>;
|
|
219
|
+
export declare function checkCollectionRoutes(opts: CollectionRoutesOptions): Promise<string[]>;
|
|
205
220
|
/** Registers the collection route guard as a vitest test. */
|
|
206
|
-
declare function registerCollectionRoutesGuard(opts: CollectionRoutesOptions): void;
|
|
207
|
-
type ErrorClass = "validation" | "unauthorized" | "forbidden" | "notFound" | "unknownRoute" | "conflict" | "duplicateKey";
|
|
208
|
-
interface ErrorProbe {
|
|
221
|
+
export declare function registerCollectionRoutesGuard(opts: CollectionRoutesOptions): void;
|
|
222
|
+
export type ErrorClass = "validation" | "unauthorized" | "forbidden" | "notFound" | "unknownRoute" | "conflict" | "duplicateKey";
|
|
223
|
+
export interface ErrorProbe {
|
|
209
224
|
/** Send the request that fails this way. */
|
|
210
225
|
provoke: () => Promise<Reply>;
|
|
211
226
|
/** Default: 400 validation, 401, 403, 404, 409. */
|
|
@@ -217,7 +232,7 @@ interface ErrorProbe {
|
|
|
217
232
|
/** Every `details[].code` equals this. Default: `duplicate_key` for duplicateKey. */
|
|
218
233
|
detailCode?: string;
|
|
219
234
|
}
|
|
220
|
-
interface ErrorContractOptions {
|
|
235
|
+
export interface ErrorContractOptions {
|
|
221
236
|
probes: Partial<Record<ErrorClass, ErrorProbe | readonly ErrorProbe[]>>;
|
|
222
237
|
/** An authenticated caller for the built-in `unknownRoute` probe (so a 404 is not a 401). */
|
|
223
238
|
caller?: Source<Caller>;
|
|
@@ -226,25 +241,25 @@ interface ErrorContractOptions {
|
|
|
226
241
|
/** Keys allowed on the body. Default: the ErrorContract keys. */
|
|
227
242
|
allowedKeys?: readonly string[];
|
|
228
243
|
}
|
|
229
|
-
declare const ERROR_CLASSES: readonly ErrorClass[];
|
|
244
|
+
export declare const ERROR_CLASSES: readonly ErrorClass[];
|
|
230
245
|
/** The reasons a reply is not the expected `ErrorContract` (empty: it is one). */
|
|
231
|
-
declare function errorContractProblems(reply: Reply, expected: {
|
|
246
|
+
export declare function errorContractProblems(reply: Reply, expected: {
|
|
232
247
|
status: number;
|
|
233
248
|
code?: string | RegExp;
|
|
234
249
|
details?: "some" | "none";
|
|
235
250
|
detailCode?: string;
|
|
236
251
|
}, allowedKeys?: readonly string[]): string[];
|
|
237
252
|
/** Throws unless the reply is the expected `ErrorContract`; returns the body. */
|
|
238
|
-
declare function expectErrorContract(reply: Reply, expected: {
|
|
253
|
+
export declare function expectErrorContract(reply: Reply, expected: {
|
|
239
254
|
status: number;
|
|
240
255
|
code?: string | RegExp;
|
|
241
256
|
details?: "some" | "none";
|
|
242
257
|
detailCode?: string;
|
|
243
258
|
}): Record<string, unknown>;
|
|
244
|
-
declare function checkErrorContracts(opts: ErrorContractOptions): Promise<string[]>;
|
|
259
|
+
export declare function checkErrorContracts(opts: ErrorContractOptions): Promise<string[]>;
|
|
245
260
|
/** Registers the error contract guard as vitest tests. */
|
|
246
|
-
declare function registerErrorContractGuard(opts: ErrorContractOptions): void;
|
|
247
|
-
interface FilterDeclaration {
|
|
261
|
+
export declare function registerErrorContractGuard(opts: ErrorContractOptions): void;
|
|
262
|
+
export interface FilterDeclaration {
|
|
248
263
|
/** The list route, concrete or a template (`/api/items`). */
|
|
249
264
|
route: string;
|
|
250
265
|
/** Filters the screen sends. `op` defaults to `eq`. */
|
|
@@ -257,14 +272,14 @@ interface FilterDeclaration {
|
|
|
257
272
|
/** Sort fields the screen offers (`-name` and `name` are the same field). */
|
|
258
273
|
sorts?: readonly string[];
|
|
259
274
|
}
|
|
260
|
-
interface FilterDeclarationsOptions {
|
|
275
|
+
export interface FilterDeclarationsOptions {
|
|
261
276
|
spec: Source<OpenApiDocument>;
|
|
262
277
|
declarations: Source<readonly FilterDeclaration[]>;
|
|
263
278
|
}
|
|
264
|
-
declare function checkFilterDeclarations(opts: FilterDeclarationsOptions): Promise<string[]>;
|
|
279
|
+
export declare function checkFilterDeclarations(opts: FilterDeclarationsOptions): Promise<string[]>;
|
|
265
280
|
/** Registers the declaration check as a vitest test. */
|
|
266
|
-
declare function registerFilterDeclarationsGuard(opts: FilterDeclarationsOptions): void;
|
|
267
|
-
interface ListEnvelopeOptions {
|
|
281
|
+
export declare function registerFilterDeclarationsGuard(opts: FilterDeclarationsOptions): void;
|
|
282
|
+
export interface ListEnvelopeOptions {
|
|
268
283
|
app: Source<AppLike>;
|
|
269
284
|
/** An identity allowed to read every list (an admin). */
|
|
270
285
|
caller: Source<Caller>;
|
|
@@ -286,25 +301,25 @@ interface ListEnvelopeOptions {
|
|
|
286
301
|
expectLists?: readonly string[];
|
|
287
302
|
}
|
|
288
303
|
/** The reasons a body is not a canonical list (empty: it is one). */
|
|
289
|
-
declare function listEnvelopeProblems(body: unknown, methods?: readonly string[], forbidden?: readonly string[]): string[];
|
|
290
|
-
interface ListEnvelopeResult {
|
|
304
|
+
export declare function listEnvelopeProblems(body: unknown, methods?: readonly string[], forbidden?: readonly string[]): string[];
|
|
305
|
+
export interface ListEnvelopeResult {
|
|
291
306
|
problems: string[];
|
|
292
307
|
/** Paths that answered a list and were checked. */
|
|
293
308
|
lists: string[];
|
|
294
309
|
/** Static GET paths considered (after prefix and skip). */
|
|
295
310
|
probed: string[];
|
|
296
311
|
}
|
|
297
|
-
declare function checkListEnvelopes(opts: ListEnvelopeOptions): Promise<ListEnvelopeResult>;
|
|
312
|
+
export declare function checkListEnvelopes(opts: ListEnvelopeOptions): Promise<ListEnvelopeResult>;
|
|
298
313
|
/** Registers the list envelope guard as vitest tests. */
|
|
299
|
-
declare function registerListEnvelopeGuard(opts: ListEnvelopeOptions): void;
|
|
300
|
-
interface PathRule {
|
|
314
|
+
export declare function registerListEnvelopeGuard(opts: ListEnvelopeOptions): void;
|
|
315
|
+
export interface PathRule {
|
|
301
316
|
name: string;
|
|
302
317
|
/** Routes whose path matches belong to the family. */
|
|
303
318
|
match: RegExp;
|
|
304
319
|
/** The complete `METHOD /path` set the family may have. */
|
|
305
320
|
allowed: readonly string[];
|
|
306
321
|
}
|
|
307
|
-
interface NamingOptions {
|
|
322
|
+
export interface NamingOptions {
|
|
308
323
|
app: Source<AppLike>;
|
|
309
324
|
/** Action verbs must match. Default: camelCase. */
|
|
310
325
|
verbPattern?: RegExp;
|
|
@@ -317,16 +332,43 @@ interface NamingOptions {
|
|
|
317
332
|
pathRules?: readonly PathRule[];
|
|
318
333
|
forbiddenPaths?: readonly RegExp[];
|
|
319
334
|
}
|
|
320
|
-
declare function checkNaming(opts: NamingOptions): Promise<string[]>;
|
|
335
|
+
export declare function checkNaming(opts: NamingOptions): Promise<string[]>;
|
|
321
336
|
/** Registers the naming guard as vitest tests. */
|
|
322
|
-
declare function registerNamingGuard(opts: NamingOptions): void;
|
|
323
|
-
interface MutatingRoute {
|
|
337
|
+
export declare function registerNamingGuard(opts: NamingOptions): void;
|
|
338
|
+
export interface MutatingRoute {
|
|
324
339
|
method: string;
|
|
325
340
|
/** A concrete path (no `:param`); give `body` when the route needs one to reach its gate. */
|
|
326
341
|
path: string;
|
|
327
342
|
body?: unknown;
|
|
328
343
|
}
|
|
329
|
-
|
|
344
|
+
/** The two callers the guard sends requests as. */
|
|
345
|
+
export type RefusalCaller = "anonymous" | "unprivileged";
|
|
346
|
+
/** Exclusions per caller: a route one caller may legitimately use is still checked for the other. */
|
|
347
|
+
export interface CallerExceptions {
|
|
348
|
+
readonly anonymous?: Exceptions;
|
|
349
|
+
readonly unprivileged?: Exceptions;
|
|
350
|
+
}
|
|
351
|
+
/** What an `actionBodies` factory is told about the call it builds a body for. */
|
|
352
|
+
export interface ActionBodyContext {
|
|
353
|
+
/** Registry name of the resource (`app.arc.registry`). */
|
|
354
|
+
readonly resource: string;
|
|
355
|
+
/** The action verb. */
|
|
356
|
+
readonly action: string;
|
|
357
|
+
readonly caller: RefusalCaller;
|
|
358
|
+
/** The concrete path the body is sent to. */
|
|
359
|
+
readonly path: string;
|
|
360
|
+
}
|
|
361
|
+
/** A verb's extra body fields (`action` is added by the guard), or a factory called once per call. */
|
|
362
|
+
export type ActionBodySource = Readonly<Record<string, unknown>> | ((ctx: ActionBodyContext) => Readonly<Record<string, unknown>> | Promise<Readonly<Record<string, unknown>>>);
|
|
363
|
+
/** What a `paramValue` function is told about the derived route. */
|
|
364
|
+
export interface ParamValueContext {
|
|
365
|
+
readonly method: string;
|
|
366
|
+
/** The route-table path, `:params` and all. */
|
|
367
|
+
readonly path: string;
|
|
368
|
+
/** Registry name of the resource whose prefix the path carries (longest match), when the app has arc's registry. */
|
|
369
|
+
readonly resource: string | undefined;
|
|
370
|
+
}
|
|
371
|
+
export interface RefusalOptions {
|
|
330
372
|
/** The booted app. Required unless `routes` is given. */
|
|
331
373
|
app?: Source<AppLike>;
|
|
332
374
|
/** Authenticated, but holding none of the privileges the mutating routes require. */
|
|
@@ -343,22 +385,39 @@ interface RefusalOptions {
|
|
|
343
385
|
routes?: Source<readonly MutatingRoute[]>;
|
|
344
386
|
/** Only table routes whose path starts with this. */
|
|
345
387
|
prefix?: string;
|
|
346
|
-
/**
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
388
|
+
/**
|
|
389
|
+
* Calls that legitimately accept the caller, with the reason: `METHOD /path` keys (as the route
|
|
390
|
+
* table spells them), or `resource.verb` for one action verb (with `actionBodies`). One list
|
|
391
|
+
* excludes for both callers; `{ anonymous, unprivileged }` excludes per caller. An exclusion that
|
|
392
|
+
* matches nothing is reported as stale.
|
|
393
|
+
*/
|
|
394
|
+
exclude?: Exceptions | CallerExceptions;
|
|
395
|
+
/**
|
|
396
|
+
* Value put in every `:param` of a derived path. Default: a 24-hex id. A function picks one per
|
|
397
|
+
* route, e.g. a seeded row's id for a resource whose lookup would otherwise answer 404 before the gate.
|
|
398
|
+
*/
|
|
399
|
+
paramValue?: string | ((ctx: ParamValueContext) => string | Promise<string>);
|
|
400
|
+
/**
|
|
401
|
+
* A body per arc action verb, keyed `resource.verb` (registry name and verb). When given, every
|
|
402
|
+
* derived action route (`POST …/:id/action`, `POST …/action`) is called once per verb it mounts,
|
|
403
|
+
* with `{ ...body, action: verb }`. A registered verb with no body (and no exclusion) is reported,
|
|
404
|
+
* and so is a body for a verb that is not checked. Needs `app` (booted by arc).
|
|
405
|
+
*/
|
|
406
|
+
actionBodies?: Readonly<Record<string, ActionBodySource>>;
|
|
350
407
|
/** Statuses that count as a refusal. Default: 401 and 403. */
|
|
351
408
|
refusedStatuses?: readonly number[];
|
|
352
409
|
/** Milliseconds to let a late handler finish before the second snapshot. Default: 25. */
|
|
353
410
|
settleMs?: number;
|
|
354
|
-
/** At least this many routes must be checked. Default: 1. */
|
|
411
|
+
/** At least this many calls (routes, or route and verb) must be checked. Default: 1. */
|
|
355
412
|
minRoutes?: number;
|
|
413
|
+
/** Title of the test `registerRefusalDoesNoWorkGuard` registers. */
|
|
414
|
+
name?: string;
|
|
356
415
|
}
|
|
357
416
|
/** Problems found; empty when every refusal answered and left the snapshot unchanged. */
|
|
358
|
-
declare function checkRefusalDoesNoWork(opts: RefusalOptions): Promise<string[]>;
|
|
359
|
-
/** Registers the refusal guard as a vitest test. */
|
|
360
|
-
declare function registerRefusalDoesNoWorkGuard(opts: RefusalOptions): void;
|
|
361
|
-
interface RouteSchemaOptions {
|
|
417
|
+
export declare function checkRefusalDoesNoWork(opts: RefusalOptions): Promise<string[]>;
|
|
418
|
+
/** Registers the refusal guard as a vitest test (title: `name`, or the default). */
|
|
419
|
+
export declare function registerRefusalDoesNoWorkGuard(opts: RefusalOptions): void;
|
|
420
|
+
export interface RouteSchemaOptions {
|
|
362
421
|
app: Source<AppLike>;
|
|
363
422
|
/** `<resource>.<action>` keys that need no schema (package-mounted actions), with the reason. */
|
|
364
423
|
actionExceptions?: Exceptions;
|
|
@@ -369,12 +428,12 @@ interface RouteSchemaOptions {
|
|
|
369
428
|
/** At least this many actions must exist (guards against reading an empty registry). Default: 1. */
|
|
370
429
|
minActions?: number;
|
|
371
430
|
}
|
|
372
|
-
interface RouteSchemaResult {
|
|
431
|
+
export interface RouteSchemaResult {
|
|
373
432
|
problems: string[];
|
|
374
433
|
actions: number;
|
|
375
434
|
routes: number;
|
|
376
435
|
}
|
|
377
|
-
declare function checkRouteSchemas(opts: RouteSchemaOptions): Promise<RouteSchemaResult>;
|
|
436
|
+
export declare function checkRouteSchemas(opts: RouteSchemaOptions): Promise<RouteSchemaResult>;
|
|
378
437
|
/** Registers the schema guard as vitest tests. */
|
|
379
|
-
declare function registerRouteSchemaGuard(opts: RouteSchemaOptions): void;
|
|
380
|
-
export {
|
|
438
|
+
export declare function registerRouteSchemaGuard(opts: RouteSchemaOptions): void;
|
|
439
|
+
export type { AppLike, Caller, Exceptions, InjectCaller, RegistryEntryLike, Reply, RouteRow, Source };
|