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