@classytic/arc-testkit 0.4.0 → 0.6.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 +44 -12
- package/dist/conformance.d.mts +380 -0
- package/dist/conformance.mjs +831 -0
- package/dist/index.d.mts +0 -2
- package/dist/index.mjs +1 -2
- package/dist/mongo-commands.d.mts +27 -0
- package/dist/mongo-commands.mjs +53 -0
- package/dist/mongo-explain.d.mts +32 -0
- package/dist/mongo-explain.mjs +66 -0
- package/dist/mongo.d.mts +0 -2
- package/dist/mongo.mjs +1 -2
- package/dist/pool.d.mts +0 -2
- package/dist/pool.mjs +3 -4
- package/dist/story.d.mts +202 -0
- package/dist/story.mjs +330 -0
- package/llms.txt +33 -0
- package/package.json +31 -3
- package/skills/arc-testkit/SKILL.md +113 -0
- package/skills/arc-testkit/references/conformance.md +104 -0
- package/skills/arc-testkit/references/fixtures.md +92 -0
- package/skills/arc-testkit/references/list-filters.md +3 -0
- package/skills/arc-testkit/references/story.md +80 -0
package/README.md
CHANGED
|
@@ -7,10 +7,10 @@ The ecosystem's contributor contract for the `@classytic/arc-*` module fleet: **
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npm i -D @classytic/arc-testkit
|
|
10
|
+
npm i -D @classytic/arc-testkit mongodb-memory-server
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
`mongoose` (>=9.4.1) is needed by `.`, `/pool`, `/mongo` and `/mongo-commands`; `/conformance` and `/story` need only `vitest` (>=3) and a booted arc app. `mongodb-memory-server` is imported lazily by the default database.
|
|
14
14
|
|
|
15
15
|
## Usage
|
|
16
16
|
|
|
@@ -39,26 +39,58 @@ const t = await bootModuleApp(({ connection }) => [createReservationModule({ con
|
|
|
39
39
|
{ replset: true });
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
## Bring your own
|
|
42
|
+
## Bring your own database
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
Pass a `TestDatabaseFactory` (from `@classytic/arc/testing`) as `database`, for example a pooled one from `createTestDatabasePool(mongoServerProvider({ mongoose })).factory` (`/pool`, `/mongo`):
|
|
45
45
|
|
|
46
46
|
```ts
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const t = await bootModuleApp(mods, {
|
|
50
|
-
backend: mongoMemoryBackend({ uri: process.env.ATLAS_TEST_URI }), // real cluster
|
|
51
|
-
});
|
|
47
|
+
const t = await bootModuleApp(mods, { database: dbPool.factory });
|
|
52
48
|
```
|
|
53
49
|
|
|
54
50
|
## API
|
|
55
51
|
|
|
56
52
|
| Export | |
|
|
57
53
|
| --- | --- |
|
|
58
|
-
| `bootModuleApp(modules, options?)` | boot arc around modules
|
|
59
|
-
| `TestBackend` | the backend seam — `setup() → { ctx: { connection, uri }, teardown }` |
|
|
54
|
+
| `bootModuleApp(modules, options?)` | boot arc around modules; returns `{ app, uri, connection, exports(name), close }` |
|
|
60
55
|
| `TestkitContext` | what a module factory receives: `{ connection, mongoUri }` |
|
|
61
|
-
| `BootModuleAppOptions` | `{ replset?, app?,
|
|
56
|
+
| `BootModuleAppOptions` | `{ replset?, database?, app?, actor?, ports? }` |
|
|
57
|
+
| `ModulesInput`, `ModuleTestApp` | the modules argument and the boot result types |
|
|
58
|
+
| `/pool` | `createTestDatabasePool`, `publishPoolServers` |
|
|
59
|
+
| `/mongo` | `mongoServerProvider`, `safeWorkerCount`, `safePoolSize`, `awaitIndexBuilds`, `withDbName`, `cacheSizeGB`, `mongodMemoryArgs` |
|
|
60
|
+
| `/mongo-commands`, `/mongo-explain` | `countCommands`, `assertConstantCommands`, `summarizeExplain`, `assertIndexServed` |
|
|
61
|
+
| `/conformance` | `check*` / `register*` for list envelope, route schemas, naming, OpenAPI, client drift, collection routes, error contract; `expectClean`, `injectCaller`, `fetchOpenApi`, `routeTable` |
|
|
62
|
+
| `/story` | `describeStory`, `createCast`, `createStoryClock`, `createStoryAssertions`, `createEventLog`, `expectIdempotent`, `expectSweepsIdempotent` |
|
|
63
|
+
|
|
64
|
+
## Cost and plan assertions (MongoDB)
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { assertConstantCommands, countCommands } from '@classytic/arc-testkit/mongo-commands';
|
|
68
|
+
import { assertIndexServed } from '@classytic/arc-testkit/mongo-explain';
|
|
69
|
+
|
|
70
|
+
// What the driver sent (the connection needs `monitorCommands: true` — it refuses otherwise).
|
|
71
|
+
const { total, byCollection } = await countCommands(conn, () => placeOrder(), { collections: ['orders'] });
|
|
72
|
+
// Same commands at every size, and at least one: a per-item read fails here.
|
|
73
|
+
await assertConstantCommands(conn, (n) => reader.page(n), [5, 50]);
|
|
74
|
+
// No COLLSCAN, an index scanned, optionally a named one and a docs-examined ceiling.
|
|
75
|
+
assertIndexServed(await Model.find(filter).explain('executionStats'), { index: 'org_state', maxDocsExamined: 40 });
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Conformance guards and story kit (opt-in, need `vitest`)
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
// tests/conformance.test.ts — one call per guard, parameters are yours
|
|
82
|
+
import { fetchOpenApi, registerListEnvelopeGuard, registerOpenApiGuard, registerRouteSchemaGuard } from '@classytic/arc-testkit/conformance';
|
|
83
|
+
|
|
84
|
+
registerListEnvelopeGuard({ app: () => t.app, caller: () => admin, prefix: '/api', notLists: { '/api/me': 'one document' } });
|
|
85
|
+
registerRouteSchemaGuard({ app: () => t.app, actionExceptions: { 'media.signed-url': 'package-mounted' } });
|
|
86
|
+
registerOpenApiGuard({ spec: () => fetchOpenApi(admin), app: () => t.app, anonymous: () => anon, prefix: '/api',
|
|
87
|
+
publicOperations: [{ path: '/api/auth/sign-in', why: 'sign-in' }] });
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Every guard is also a `check*` function returning the problems found (empty when green). Exception
|
|
91
|
+
lists fail when an entry matches nothing. `@classytic/arc-testkit/story` holds `describeStory`,
|
|
92
|
+
`createCast`, `createStoryClock`, `createStoryAssertions` (outbox / events / feed / mail / audit through
|
|
93
|
+
readers you inject) and `expectIdempotent`; see the module docs.
|
|
62
94
|
|
|
63
95
|
## License
|
|
64
96
|
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/** A lazily supplied value: build it in `beforeAll`, hand the suite a getter. */
|
|
2
|
+
type Source<T> = T | (() => T | Promise<T>);
|
|
3
|
+
interface InjectResponse {
|
|
4
|
+
statusCode: number;
|
|
5
|
+
body: string;
|
|
6
|
+
headers: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
/** What the suites read of a booted arc app. Fastify's instance satisfies it. */
|
|
9
|
+
interface AppLike {
|
|
10
|
+
inject(opts: {
|
|
11
|
+
method: string;
|
|
12
|
+
url: string;
|
|
13
|
+
headers?: Record<string, string>;
|
|
14
|
+
payload?: unknown;
|
|
15
|
+
}): PromiseLike<InjectResponse>;
|
|
16
|
+
printRoutes(opts?: {
|
|
17
|
+
commonPrefix?: boolean;
|
|
18
|
+
includeHooks?: boolean;
|
|
19
|
+
}): string;
|
|
20
|
+
}
|
|
21
|
+
/** `app.arc.registry.getAll()` entries, as far as the schema guard reads them. */
|
|
22
|
+
interface RegistryEntryLike {
|
|
23
|
+
name: string;
|
|
24
|
+
actions?: Array<{
|
|
25
|
+
name: string;
|
|
26
|
+
schema?: unknown;
|
|
27
|
+
}>;
|
|
28
|
+
customRoutes?: Array<{
|
|
29
|
+
method: string;
|
|
30
|
+
path: string;
|
|
31
|
+
schema?: {
|
|
32
|
+
body?: unknown;
|
|
33
|
+
};
|
|
34
|
+
}>;
|
|
35
|
+
}
|
|
36
|
+
interface Reply {
|
|
37
|
+
status: number;
|
|
38
|
+
body: unknown;
|
|
39
|
+
headers?: Record<string, unknown>;
|
|
40
|
+
}
|
|
41
|
+
/** Sends requests as one identity. A host test client (`admin.get(path)`) already fits. */
|
|
42
|
+
interface Caller {
|
|
43
|
+
get(path: string): Promise<Reply>;
|
|
44
|
+
}
|
|
45
|
+
interface InjectCaller extends Caller {
|
|
46
|
+
request(method: string, path: string, body?: unknown): Promise<Reply>;
|
|
47
|
+
post(path: string, body?: unknown): Promise<Reply>;
|
|
48
|
+
put(path: string, body?: unknown): Promise<Reply>;
|
|
49
|
+
patch(path: string, body?: unknown): Promise<Reply>;
|
|
50
|
+
delete(path: string): Promise<Reply>;
|
|
51
|
+
}
|
|
52
|
+
/** 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;
|
|
54
|
+
interface RouteRow {
|
|
55
|
+
path: string;
|
|
56
|
+
methods: string[];
|
|
57
|
+
}
|
|
58
|
+
/** 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[];
|
|
60
|
+
/** `/a/{id}/b`, `/a/:id|:other/b` and `/a/:id/b/` are one shape: `/a/:x/b`. */
|
|
61
|
+
declare function shapeOf(path: string): string;
|
|
62
|
+
/** An exception list: bare keys, or `key -> reason`. */
|
|
63
|
+
type Exceptions = Iterable<string> | Readonly<Record<string, string>>;
|
|
64
|
+
/** 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 {
|
|
68
|
+
[extension: string]: unknown;
|
|
69
|
+
security?: unknown[];
|
|
70
|
+
requestBody?: {
|
|
71
|
+
content?: Record<string, {
|
|
72
|
+
schema?: OpenApiSchema;
|
|
73
|
+
}>;
|
|
74
|
+
};
|
|
75
|
+
responses?: Record<string, {
|
|
76
|
+
content?: Record<string, {
|
|
77
|
+
schema?: OpenApiSchema;
|
|
78
|
+
}>;
|
|
79
|
+
}>;
|
|
80
|
+
}
|
|
81
|
+
interface OpenApiSchema {
|
|
82
|
+
$ref?: string;
|
|
83
|
+
type?: string;
|
|
84
|
+
properties?: Record<string, OpenApiSchema>;
|
|
85
|
+
oneOf?: OpenApiSchema[];
|
|
86
|
+
allOf?: OpenApiSchema[];
|
|
87
|
+
anyOf?: OpenApiSchema[];
|
|
88
|
+
enum?: unknown[];
|
|
89
|
+
items?: OpenApiSchema;
|
|
90
|
+
}
|
|
91
|
+
interface OpenApiDocument {
|
|
92
|
+
openapi?: string;
|
|
93
|
+
security?: unknown[];
|
|
94
|
+
paths: Record<string, Record<string, OpenApiOperation>>;
|
|
95
|
+
components?: {
|
|
96
|
+
schemas?: Record<string, OpenApiSchema>;
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/** Read the document from the running app (default `/openapi.json`). */
|
|
100
|
+
declare function fetchOpenApi(caller: Caller, path?: string): Promise<OpenApiDocument>;
|
|
101
|
+
interface PublicOperation {
|
|
102
|
+
/** A path template as it appears in the document, or a test for it. */
|
|
103
|
+
path: string | RegExp | ((path: string) => boolean);
|
|
104
|
+
why: string;
|
|
105
|
+
}
|
|
106
|
+
interface OpenApiOptions {
|
|
107
|
+
spec: Source<OpenApiDocument>;
|
|
108
|
+
/** The booted app: enables the "names every route" check. */
|
|
109
|
+
app?: Source<AppLike>;
|
|
110
|
+
/** An unauthenticated caller: enables the "protected GETs refuse anonymous" check. */
|
|
111
|
+
anonymous?: Source<Caller>;
|
|
112
|
+
/** Every documented path starts with this, except the public ones. */
|
|
113
|
+
prefix?: string;
|
|
114
|
+
/** Operations that are public on purpose; each must still match something. */
|
|
115
|
+
publicOperations?: readonly PublicOperation[];
|
|
116
|
+
/** Routes the document does not have to name (matched on the route-table path). */
|
|
117
|
+
undocumented?: RegExp;
|
|
118
|
+
/** Document paths never probed anonymously. Default: parameterised paths and `/stream`. */
|
|
119
|
+
skipAnonymous?: RegExp;
|
|
120
|
+
/** At least this many operations must be documented. Default: 1. */
|
|
121
|
+
minOperations?: number;
|
|
122
|
+
/** Configuration values (secrets, connection strings) that must not appear in the document; ignored under 6 characters. */
|
|
123
|
+
secretValues?: Source<readonly (string | undefined)[]>;
|
|
124
|
+
/** Patterns that must not match the serialised document. Default: mongodb and redis URLs. */
|
|
125
|
+
secretPatterns?: readonly RegExp[];
|
|
126
|
+
}
|
|
127
|
+
/** The document is OpenAPI 3, prefixed, and has no trailing-slash path. */
|
|
128
|
+
declare function checkOpenApiShape(opts: OpenApiOptions): Promise<string[]>;
|
|
129
|
+
/** Every route in the table is documented (once, in its bare form). */
|
|
130
|
+
declare function checkRoutesDocumented(opts: OpenApiOptions): Promise<string[]>;
|
|
131
|
+
/**
|
|
132
|
+
* Every operation requires a security scheme (its own `security`, else the document's), except the
|
|
133
|
+
* public list; a public rule that matches no operation fails as stale.
|
|
134
|
+
*/
|
|
135
|
+
declare function checkSecurity(opts: OpenApiOptions): Promise<string[]>;
|
|
136
|
+
/** An anonymous GET of every documented, protected, parameterless path is refused with 401. */
|
|
137
|
+
declare function checkAnonymousRefused(opts: OpenApiOptions, expected?: number): Promise<string[]>;
|
|
138
|
+
/** The serialised document carries no configuration value. */
|
|
139
|
+
declare function checkNoSecrets(opts: OpenApiOptions): Promise<string[]>;
|
|
140
|
+
/** 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 {
|
|
143
|
+
method: string;
|
|
144
|
+
path: string;
|
|
145
|
+
/** Where the client emits it, named in the failure. */
|
|
146
|
+
source?: string;
|
|
147
|
+
}
|
|
148
|
+
/** 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;
|
|
150
|
+
/** 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;
|
|
153
|
+
/** Every property name a schema offers, through refs and unions. */
|
|
154
|
+
declare function propertyNames(spec: OpenApiDocument, schema: OpenApiSchema | undefined, depth?: number): Set<string>;
|
|
155
|
+
interface BodyFieldCheck {
|
|
156
|
+
name?: string;
|
|
157
|
+
method: string;
|
|
158
|
+
/** The path template as the document spells it. */
|
|
159
|
+
path: string;
|
|
160
|
+
/** Fields the client reads or sends. */
|
|
161
|
+
fields: readonly string[];
|
|
162
|
+
/** Which body to read. Default: `request`. */
|
|
163
|
+
in?: "request" | "response";
|
|
164
|
+
/** Response status to read. Default: 200. */
|
|
165
|
+
status?: string;
|
|
166
|
+
}
|
|
167
|
+
declare function bodyFieldProblems(spec: OpenApiDocument, check: BodyFieldCheck): string[];
|
|
168
|
+
interface ClientDriftOptions {
|
|
169
|
+
spec: Source<OpenApiDocument>;
|
|
170
|
+
routes: Source<readonly ClientRoute[]>;
|
|
171
|
+
bodyFields?: readonly BodyFieldCheck[];
|
|
172
|
+
/** At least this many client routes must be supplied (guards against an empty probe). Default: 1. */
|
|
173
|
+
minRoutes?: number;
|
|
174
|
+
}
|
|
175
|
+
declare function checkClientDrift(opts: ClientDriftOptions): Promise<string[]>;
|
|
176
|
+
/** Registers the drift guard as vitest tests. */
|
|
177
|
+
declare function registerClientDriftGuard(opts: ClientDriftOptions): void;
|
|
178
|
+
/** Write the document to `file` (the server side of the dump: `npm run openapi:dump`). */
|
|
179
|
+
declare function dumpOpenApi(spec: OpenApiDocument, file: string): void;
|
|
180
|
+
/**
|
|
181
|
+
* Read a dumped document. Throws when the dump is missing or older than any `.ts` file under
|
|
182
|
+
* `sources`, so a stale document can never pass for the current API. `regenerate` names the command
|
|
183
|
+
* in the message.
|
|
184
|
+
*/
|
|
185
|
+
declare function loadOpenApiDump(file: string, opts?: {
|
|
186
|
+
sources?: readonly string[];
|
|
187
|
+
regenerate?: string;
|
|
188
|
+
}): OpenApiDocument;
|
|
189
|
+
interface CollectionRoute {
|
|
190
|
+
/** The bare collection path, e.g. `/api/things`. */
|
|
191
|
+
path: string;
|
|
192
|
+
/** The verbs the client sends to it. */
|
|
193
|
+
methods: readonly string[];
|
|
194
|
+
}
|
|
195
|
+
interface CollectionRoutesOptions {
|
|
196
|
+
app: Source<AppLike>;
|
|
197
|
+
collections: readonly CollectionRoute[];
|
|
198
|
+
/** Bare GET paths that must answer 200 for `caller` (and the trailing-slash form is not what is registered). */
|
|
199
|
+
served?: {
|
|
200
|
+
caller: Source<Caller>;
|
|
201
|
+
paths: readonly string[];
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
declare function checkCollectionRoutes(opts: CollectionRoutesOptions): Promise<string[]>;
|
|
205
|
+
/** 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 {
|
|
209
|
+
/** Send the request that fails this way. */
|
|
210
|
+
provoke: () => Promise<Reply>;
|
|
211
|
+
/** Default: 400 validation, 401, 403, 404, 409. */
|
|
212
|
+
status?: number;
|
|
213
|
+
/** The exact `code`, or a test for it. Default: any non-empty string. */
|
|
214
|
+
code?: string | RegExp;
|
|
215
|
+
/** `some`: `details` is a non-empty array. `none`: absent. Default: some for validation and duplicateKey. */
|
|
216
|
+
details?: "some" | "none";
|
|
217
|
+
/** Every `details[].code` equals this. Default: `duplicate_key` for duplicateKey. */
|
|
218
|
+
detailCode?: string;
|
|
219
|
+
}
|
|
220
|
+
interface ErrorContractOptions {
|
|
221
|
+
probes: Partial<Record<ErrorClass, ErrorProbe | readonly ErrorProbe[]>>;
|
|
222
|
+
/** An authenticated caller for the built-in `unknownRoute` probe (so a 404 is not a 401). */
|
|
223
|
+
caller?: Source<Caller>;
|
|
224
|
+
/** Every class listed here must have a probe. Default: all seven. */
|
|
225
|
+
require?: readonly ErrorClass[];
|
|
226
|
+
/** Keys allowed on the body. Default: the ErrorContract keys. */
|
|
227
|
+
allowedKeys?: readonly string[];
|
|
228
|
+
}
|
|
229
|
+
declare const ERROR_CLASSES: readonly ErrorClass[];
|
|
230
|
+
/** The reasons a reply is not the expected `ErrorContract` (empty: it is one). */
|
|
231
|
+
declare function errorContractProblems(reply: Reply, expected: {
|
|
232
|
+
status: number;
|
|
233
|
+
code?: string | RegExp;
|
|
234
|
+
details?: "some" | "none";
|
|
235
|
+
detailCode?: string;
|
|
236
|
+
}, allowedKeys?: readonly string[]): string[];
|
|
237
|
+
/** Throws unless the reply is the expected `ErrorContract`; returns the body. */
|
|
238
|
+
declare function expectErrorContract(reply: Reply, expected: {
|
|
239
|
+
status: number;
|
|
240
|
+
code?: string | RegExp;
|
|
241
|
+
details?: "some" | "none";
|
|
242
|
+
detailCode?: string;
|
|
243
|
+
}): Record<string, unknown>;
|
|
244
|
+
declare function checkErrorContracts(opts: ErrorContractOptions): Promise<string[]>;
|
|
245
|
+
/** Registers the error contract guard as vitest tests. */
|
|
246
|
+
declare function registerErrorContractGuard(opts: ErrorContractOptions): void;
|
|
247
|
+
interface FilterDeclaration {
|
|
248
|
+
/** The list route, concrete or a template (`/api/items`). */
|
|
249
|
+
route: string;
|
|
250
|
+
/** Filters the screen sends. `op` defaults to `eq`. */
|
|
251
|
+
filters?: readonly {
|
|
252
|
+
queryField: string;
|
|
253
|
+
op?: string;
|
|
254
|
+
}[];
|
|
255
|
+
/** The screen sends free-text search. */
|
|
256
|
+
search?: boolean;
|
|
257
|
+
/** Sort fields the screen offers (`-name` and `name` are the same field). */
|
|
258
|
+
sorts?: readonly string[];
|
|
259
|
+
}
|
|
260
|
+
interface FilterDeclarationsOptions {
|
|
261
|
+
spec: Source<OpenApiDocument>;
|
|
262
|
+
declarations: Source<readonly FilterDeclaration[]>;
|
|
263
|
+
}
|
|
264
|
+
declare function checkFilterDeclarations(opts: FilterDeclarationsOptions): Promise<string[]>;
|
|
265
|
+
/** Registers the declaration check as a vitest test. */
|
|
266
|
+
declare function registerFilterDeclarationsGuard(opts: FilterDeclarationsOptions): void;
|
|
267
|
+
interface ListEnvelopeOptions {
|
|
268
|
+
app: Source<AppLike>;
|
|
269
|
+
/** An identity allowed to read every list (an admin). */
|
|
270
|
+
caller: Source<Caller>;
|
|
271
|
+
/** Only GET routes under this prefix are probed. Default: all. */
|
|
272
|
+
prefix?: string;
|
|
273
|
+
/** Static GET paths that do not answer a list (single documents, tables, probes), with the reason. */
|
|
274
|
+
notLists?: Exceptions;
|
|
275
|
+
/** Paths that answer their own shape (package-owned routes), with the reason. */
|
|
276
|
+
customShapes?: Exceptions;
|
|
277
|
+
/** GET paths never probed (streams, downloads). Default: `/(stream|export|content|download)(/|$)`. */
|
|
278
|
+
skip?: RegExp;
|
|
279
|
+
/** Keys a list never carries beside `data`. */
|
|
280
|
+
forbiddenKeys?: readonly string[];
|
|
281
|
+
/** Page kinds accepted when a body carries `method`. Default: offset and keyset. */
|
|
282
|
+
methods?: readonly ("offset" | "keyset")[];
|
|
283
|
+
/** At least this many routes must be probed. Default: 1. */
|
|
284
|
+
minRoutes?: number;
|
|
285
|
+
/** Lists that must be among the checked ones (guards against a probe silently skipping them). */
|
|
286
|
+
expectLists?: readonly string[];
|
|
287
|
+
}
|
|
288
|
+
/** 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 {
|
|
291
|
+
problems: string[];
|
|
292
|
+
/** Paths that answered a list and were checked. */
|
|
293
|
+
lists: string[];
|
|
294
|
+
/** Static GET paths considered (after prefix and skip). */
|
|
295
|
+
probed: string[];
|
|
296
|
+
}
|
|
297
|
+
declare function checkListEnvelopes(opts: ListEnvelopeOptions): Promise<ListEnvelopeResult>;
|
|
298
|
+
/** Registers the list envelope guard as vitest tests. */
|
|
299
|
+
declare function registerListEnvelopeGuard(opts: ListEnvelopeOptions): void;
|
|
300
|
+
interface PathRule {
|
|
301
|
+
name: string;
|
|
302
|
+
/** Routes whose path matches belong to the family. */
|
|
303
|
+
match: RegExp;
|
|
304
|
+
/** The complete `METHOD /path` set the family may have. */
|
|
305
|
+
allowed: readonly string[];
|
|
306
|
+
}
|
|
307
|
+
interface NamingOptions {
|
|
308
|
+
app: Source<AppLike>;
|
|
309
|
+
/** Action verbs must match. Default: camelCase. */
|
|
310
|
+
verbPattern?: RegExp;
|
|
311
|
+
/** Verbs owned by packages the host cannot change, with the reason. */
|
|
312
|
+
exemptVerbs?: Exceptions;
|
|
313
|
+
/** Verbs that must exist (guards against reading an empty registry). */
|
|
314
|
+
requiredVerbs?: readonly string[];
|
|
315
|
+
/** At least this many verbs must exist. Default: 1. */
|
|
316
|
+
minVerbs?: number;
|
|
317
|
+
pathRules?: readonly PathRule[];
|
|
318
|
+
forbiddenPaths?: readonly RegExp[];
|
|
319
|
+
}
|
|
320
|
+
declare function checkNaming(opts: NamingOptions): Promise<string[]>;
|
|
321
|
+
/** Registers the naming guard as vitest tests. */
|
|
322
|
+
declare function registerNamingGuard(opts: NamingOptions): void;
|
|
323
|
+
interface MutatingRoute {
|
|
324
|
+
method: string;
|
|
325
|
+
/** A concrete path (no `:param`); give `body` when the route needs one to reach its gate. */
|
|
326
|
+
path: string;
|
|
327
|
+
body?: unknown;
|
|
328
|
+
}
|
|
329
|
+
interface RefusalOptions {
|
|
330
|
+
/** The booted app. Required unless `routes` is given. */
|
|
331
|
+
app?: Source<AppLike>;
|
|
332
|
+
/** Authenticated, but holding none of the privileges the mutating routes require. */
|
|
333
|
+
unprivileged?: Source<InjectCaller>;
|
|
334
|
+
/** No credentials. */
|
|
335
|
+
anonymous?: Source<InjectCaller>;
|
|
336
|
+
/**
|
|
337
|
+
* The host's state fingerprint: anything JSON-like that changes when a handler did work (row
|
|
338
|
+
* counts, a state machine's phase, an outbox length). Compared with deep equality, before and
|
|
339
|
+
* after every call.
|
|
340
|
+
*/
|
|
341
|
+
snapshot: () => unknown | Promise<unknown>;
|
|
342
|
+
/** The routes to call. Default: every POST/PUT/PATCH/DELETE in the route table. */
|
|
343
|
+
routes?: Source<readonly MutatingRoute[]>;
|
|
344
|
+
/** Only table routes whose path starts with this. */
|
|
345
|
+
prefix?: string;
|
|
346
|
+
/** `METHOD /path` keys (as the route table spells them) that legitimately accept the caller, with the reason. */
|
|
347
|
+
exclude?: Exceptions;
|
|
348
|
+
/** Value put in every `:param` of a derived path. Default: a 24-hex id. */
|
|
349
|
+
paramValue?: string;
|
|
350
|
+
/** Statuses that count as a refusal. Default: 401 and 403. */
|
|
351
|
+
refusedStatuses?: readonly number[];
|
|
352
|
+
/** Milliseconds to let a late handler finish before the second snapshot. Default: 25. */
|
|
353
|
+
settleMs?: number;
|
|
354
|
+
/** At least this many routes must be checked. Default: 1. */
|
|
355
|
+
minRoutes?: number;
|
|
356
|
+
}
|
|
357
|
+
/** 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 {
|
|
362
|
+
app: Source<AppLike>;
|
|
363
|
+
/** `<resource>.<action>` keys that need no schema (package-mounted actions), with the reason. */
|
|
364
|
+
actionExceptions?: Exceptions;
|
|
365
|
+
/** `<resource> <METHOD> <path>` keys of body-bearing routes that need no body schema, with the reason. */
|
|
366
|
+
routeExceptions?: Exceptions;
|
|
367
|
+
/** HTTP methods that must declare a body. Default: POST, PUT, PATCH. */
|
|
368
|
+
bodyMethods?: readonly string[];
|
|
369
|
+
/** At least this many actions must exist (guards against reading an empty registry). Default: 1. */
|
|
370
|
+
minActions?: number;
|
|
371
|
+
}
|
|
372
|
+
interface RouteSchemaResult {
|
|
373
|
+
problems: string[];
|
|
374
|
+
actions: number;
|
|
375
|
+
routes: number;
|
|
376
|
+
}
|
|
377
|
+
declare function checkRouteSchemas(opts: RouteSchemaOptions): Promise<RouteSchemaResult>;
|
|
378
|
+
/** Registers the schema guard as vitest tests. */
|
|
379
|
+
declare function registerRouteSchemaGuard(opts: RouteSchemaOptions): void;
|
|
380
|
+
export { type AppLike, BodyFieldCheck, type Caller, ClientDriftOptions, ClientRoute, CollectionRoute, CollectionRoutesOptions, ERROR_CLASSES, ErrorClass, ErrorContractOptions, ErrorProbe, type Exceptions, FilterDeclaration, FilterDeclarationsOptions, type InjectCaller, ListEnvelopeOptions, ListEnvelopeResult, MutatingRoute, NamingOptions, OPENAPI_METHODS, OpenApiDocument, OpenApiOperation, OpenApiOptions, OpenApiSchema, PathRule, PublicOperation, RefusalOptions, type RegistryEntryLike, type Reply, type RouteRow, RouteSchemaOptions, RouteSchemaResult, type Source, bodyFieldProblems, checkAnonymousRefused, checkClientDrift, checkCollectionRoutes, checkErrorContracts, checkFilterDeclarations, checkListEnvelopes, checkNaming, checkNoSecrets, checkOpenApiShape, checkRefusalDoesNoWork, checkRouteSchemas, checkRoutesDocumented, checkSecurity, dumpOpenApi, errorContractProblems, expectClean, expectErrorContract, fetchOpenApi, injectCaller, listEnvelopeProblems, loadOpenApiDump, missingClientRoutes, propertyNames, registerClientDriftGuard, registerCollectionRoutesGuard, registerErrorContractGuard, registerFilterDeclarationsGuard, registerListEnvelopeGuard, registerNamingGuard, registerOpenApiGuard, registerRefusalDoesNoWorkGuard, registerRouteSchemaGuard, resolveRef, routeTable, shapeOf, specMatcher };
|