@luckystack/test-runner 0.14.0 → 0.16.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/CHANGELOG.md +17 -0
- package/dist/contractCheck.d.ts +6 -0
- package/dist/customTests.d.ts +22 -7
- package/dist/index.d.ts +1 -1
- package/dist/index.js +247 -126
- package/dist/index.js.map +1 -1
- package/dist/probeRequest.d.ts +2 -2
- package/dist/runAllTests.d.ts +19 -2
- package/dist/runAuthEnforcementTests.d.ts +3 -1
- package/dist/runContractTests.d.ts +15 -1
- package/dist/runFuzzTests.d.ts +9 -1
- package/dist/runRateLimitTests.d.ts +3 -1
- package/dist/testLayerHelpers.d.ts +12 -2
- package/dist/testSocket.d.ts +13 -0
- package/docs/contract-tests.md +7 -1
- package/docs/fuzz-tests.md +1 -1
- package/package.json +4 -4
package/dist/probeRequest.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export interface ProbeRequestInput {
|
|
|
8
8
|
requestTimeoutMs: number;
|
|
9
9
|
}
|
|
10
10
|
export interface ProbeResponse {
|
|
11
|
-
/** HTTP status.
|
|
11
|
+
/** HTTP status. Over Socket.io: the envelope's `httpStatus`, when it carries one. */
|
|
12
12
|
httpStatus?: number;
|
|
13
13
|
parsed: {
|
|
14
14
|
status?: string;
|
|
@@ -25,7 +25,7 @@ export interface ProbeTransportRequest {
|
|
|
25
25
|
baseUrl: string;
|
|
26
26
|
/** The route payload — the API `data`, or the sync `data` (the transport adds the sync envelope). */
|
|
27
27
|
body: unknown;
|
|
28
|
-
/** HTTP headers (session Cookie, CSRF). A socket transport reads only the
|
|
28
|
+
/** HTTP headers (session Cookie or Bearer, CSRF). A socket transport reads only the session. */
|
|
29
29
|
headers?: Record<string, string>;
|
|
30
30
|
requestTimeoutMs: number;
|
|
31
31
|
}
|
package/dist/runAllTests.d.ts
CHANGED
|
@@ -19,6 +19,19 @@ export interface RunAllTestsInput {
|
|
|
19
19
|
* probes over HTTP — CSRF is an HTTP-only guard.
|
|
20
20
|
*/
|
|
21
21
|
syncTransport?: 'socket' | 'http';
|
|
22
|
+
/**
|
|
23
|
+
* How API routes are probed. Defaults to the project's own
|
|
24
|
+
* `transport.invocation`: `'socket'` → Socket.io (the `apiRequest` pipeline
|
|
25
|
+
* real clients hit), `'routed-http'` → the `/api/*` HTTP route. The CSRF
|
|
26
|
+
* layer always probes over HTTP — CSRF is an HTTP-only guard.
|
|
27
|
+
*/
|
|
28
|
+
apiTransport?: 'socket' | 'http';
|
|
29
|
+
/**
|
|
30
|
+
* Routes designed to fail on the server (e.g. a playground route that throws
|
|
31
|
+
* on purpose), matched like `skip`. The contract layer lets their shaped 5xx
|
|
32
|
+
* pass; any other route answering 5xx fails it.
|
|
33
|
+
*/
|
|
34
|
+
expectServerError?: string[];
|
|
22
35
|
/**
|
|
23
36
|
* Write the full `RunAllTestsSummary` as JSON to this path (relative to the
|
|
24
37
|
* process cwd). Defaults to `TEST_OUTPUT_FILE`, else `test-results.json`;
|
|
@@ -34,9 +47,13 @@ export interface RunAllTestsInput {
|
|
|
34
47
|
apiMetaMap: ApiMetaMap;
|
|
35
48
|
apiInputSchemas: InputSchemaMap;
|
|
36
49
|
baseUrl: string;
|
|
37
|
-
/** Cookie name for the session token. Defaults to the project's configured session cookie name (`projectConfig.http.sessionCookieName`). */
|
|
50
|
+
/** Cookie name for the session token. Defaults to the project's configured session cookie name (`projectConfig.http.sessionCookieName`, with `http.sessionCookiePrefix` applied). */
|
|
38
51
|
sessionCookieName?: string;
|
|
39
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* Session token for sweep-layer requests that need a session: sent as the
|
|
54
|
+
* session `Cookie` in cookie mode, as `Authorization: Bearer` in token mode
|
|
55
|
+
* (`session.basedToken`).
|
|
56
|
+
*/
|
|
40
57
|
authToken?: string;
|
|
41
58
|
/** Endpoints to skip from the sweep layers, e.g. routes that need a file upload. */
|
|
42
59
|
skip?: string[];
|
|
@@ -10,8 +10,10 @@ export interface RunAuthEnforcementTestsInput {
|
|
|
10
10
|
/** Substring filter on `<page>/<name>/<version>`; other routes are not requested at all. */
|
|
11
11
|
filter?: string;
|
|
12
12
|
inputFor?: (endpoint: EndpointDescriptor) => unknown;
|
|
13
|
-
/** Transport for sync routes (e.g. the socket transport).
|
|
13
|
+
/** Transport for sync routes (e.g. the socket transport). Defaults to HTTP. */
|
|
14
14
|
syncTransport?: ProbeTransport;
|
|
15
|
+
/** Transport for API routes (e.g. the socket transport). Defaults to HTTP. */
|
|
16
|
+
apiTransport?: ProbeTransport;
|
|
15
17
|
onResult?: (result: ContractCheckResult) => void;
|
|
16
18
|
}
|
|
17
19
|
export declare const runAuthEnforcementTests: (input: RunAuthEnforcementTestsInput) => Promise<RunContractSummary>;
|
|
@@ -11,12 +11,26 @@ export interface RunContractTestsInput {
|
|
|
11
11
|
* are known to need real input (e.g. a file upload).
|
|
12
12
|
*/
|
|
13
13
|
skip?: string[];
|
|
14
|
+
/**
|
|
15
|
+
* Routes designed to fail on the server (e.g. a playground route that throws
|
|
16
|
+
* on purpose), matched like `skip`. Their shaped 5xx reply passes; every
|
|
17
|
+
* other route's 5xx fails the layer.
|
|
18
|
+
*/
|
|
19
|
+
expectServerError?: string[];
|
|
14
20
|
/** Substring filter on `<page>/<name>/<version>`; other routes are not requested at all. */
|
|
15
21
|
filter?: string;
|
|
16
22
|
inputFor?: (endpoint: EndpointDescriptor) => unknown;
|
|
17
23
|
headers?: Record<string, string>;
|
|
18
|
-
/** Transport for sync routes (e.g. the socket transport).
|
|
24
|
+
/** Transport for sync routes (e.g. the socket transport). Defaults to HTTP. */
|
|
19
25
|
syncTransport?: ProbeTransport;
|
|
26
|
+
/** Transport for API routes (e.g. the socket transport). Defaults to HTTP. */
|
|
27
|
+
apiTransport?: ProbeTransport;
|
|
28
|
+
/**
|
|
29
|
+
* When given, rate limits are reset (`/_test/reset?only=rateLimits`) before
|
|
30
|
+
* each route, so the shared per-IP bucket cannot fill up mid-sweep and turn
|
|
31
|
+
* the remaining probes into limiter rejections.
|
|
32
|
+
*/
|
|
33
|
+
resetToken?: string;
|
|
20
34
|
onResult?: (result: ContractCheckResult) => void;
|
|
21
35
|
}
|
|
22
36
|
export declare const runContractTests: (input: RunContractTestsInput) => Promise<RunContractSummary>;
|
package/dist/runFuzzTests.d.ts
CHANGED
|
@@ -9,8 +9,16 @@ export interface RunFuzzTestsInput {
|
|
|
9
9
|
/** Substring filter on `<page>/<name>/<version>`; other routes are not requested at all. */
|
|
10
10
|
filter?: string;
|
|
11
11
|
headers?: Record<string, string>;
|
|
12
|
-
/** Transport for sync routes (e.g. the socket transport).
|
|
12
|
+
/** Transport for sync routes (e.g. the socket transport). Defaults to HTTP. */
|
|
13
13
|
syncTransport?: ProbeTransport;
|
|
14
|
+
/** Transport for API routes (e.g. the socket transport). Defaults to HTTP. */
|
|
15
|
+
apiTransport?: ProbeTransport;
|
|
16
|
+
/**
|
|
17
|
+
* When given, rate limits are reset (`/_test/reset?only=rateLimits`) before
|
|
18
|
+
* each route, so the shared per-IP bucket cannot fill up mid-sweep and turn
|
|
19
|
+
* the remaining payloads into limiter rejections.
|
|
20
|
+
*/
|
|
21
|
+
resetToken?: string;
|
|
14
22
|
onResult?: (result: ContractCheckResult) => void;
|
|
15
23
|
}
|
|
16
24
|
export declare const runFuzzTests: (input: RunFuzzTestsInput) => Promise<RunContractSummary>;
|
|
@@ -11,8 +11,10 @@ export interface RunRateLimitTestsInput {
|
|
|
11
11
|
filter?: string;
|
|
12
12
|
inputFor?: (endpoint: EndpointDescriptor) => unknown;
|
|
13
13
|
headers?: Record<string, string>;
|
|
14
|
-
/** Transport for sync routes (e.g. the socket transport).
|
|
14
|
+
/** Transport for sync routes (e.g. the socket transport). Defaults to HTTP. */
|
|
15
15
|
syncTransport?: ProbeTransport;
|
|
16
|
+
/** Transport for API routes (e.g. the socket transport). Defaults to HTTP. */
|
|
17
|
+
apiTransport?: ProbeTransport;
|
|
16
18
|
/**
|
|
17
19
|
* Max rateLimit value to test. Endpoints with a higher limit are skipped
|
|
18
20
|
* to avoid firing thousands of requests in CI.
|
|
@@ -29,8 +29,18 @@ export interface SelectEndpointsInput {
|
|
|
29
29
|
export declare const selectEndpoints: (input: SelectEndpointsInput) => EndpointDescriptor[];
|
|
30
30
|
/** The meta map that describes `endpoint`: `syncMetaMap` for sync routes, else `apiMetaMap`. */
|
|
31
31
|
export declare const metaMapFor: (endpoint: EndpointDescriptor, apiMetaMap: ApiMetaMap, syncMetaMap: SyncMetaMap | undefined) => RouteMetaMap;
|
|
32
|
-
/**
|
|
33
|
-
|
|
32
|
+
/**
|
|
33
|
+
* The transport for `endpoint`: `syncTransport` for sync routes and
|
|
34
|
+
* `apiTransport` for API routes when given, else HTTP.
|
|
35
|
+
*/
|
|
36
|
+
export declare const transportFor: (endpoint: EndpointDescriptor, syncTransport: ProbeTransport | undefined, apiTransport?: ProbeTransport) => ProbeTransport;
|
|
37
|
+
/**
|
|
38
|
+
* Whether sweep headers carry a session: a session Cookie (cookie mode) or an
|
|
39
|
+
* `Authorization: Bearer` token (token mode). Layers that must not fire junk
|
|
40
|
+
* at mutating routes treat both as an authenticated sweep.
|
|
41
|
+
*/
|
|
42
|
+
export declare const hasSessionHeaders: (headers: Record<string, string> | undefined) => boolean;
|
|
43
|
+
export declare const isRateLimitedCode: (errorCode: string | undefined) => boolean;
|
|
34
44
|
/**
|
|
35
45
|
* Whether an endpoint is in the explicit skip list. Matched against
|
|
36
46
|
* `<page>/<name>` (version-agnostic) and `<page>/<name>/<version>`
|
package/dist/testSocket.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Socket } from 'socket.io-client';
|
|
2
|
+
export declare const projectSessionCookieName: () => string;
|
|
2
3
|
export declare const nextResponseIndex: () => number;
|
|
3
4
|
export interface ConnectTestSocketInput {
|
|
4
5
|
baseUrl: string;
|
|
@@ -56,3 +57,15 @@ export type EmitSyncOutcome = {
|
|
|
56
57
|
* resolve with the server's single reply on `sync-<responseIndex>`.
|
|
57
58
|
*/
|
|
58
59
|
export declare const emitSyncOverSocket: (socket: Socket, input: EmitSyncInput) => Promise<EmitSyncOutcome>;
|
|
60
|
+
export interface EmitApiInput {
|
|
61
|
+
/** `<page>/<name>/<version>` — the route path without the `api/` prefix. */
|
|
62
|
+
routePath: string;
|
|
63
|
+
data: unknown;
|
|
64
|
+
timeoutMs: number;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Send one API request over `socket` exactly like the real client does in
|
|
68
|
+
* socket mode and resolve with the server's single reply on
|
|
69
|
+
* `apiResponse-<responseIndex>`.
|
|
70
|
+
*/
|
|
71
|
+
export declare const emitApiOverSocket: (socket: Socket, input: EmitApiInput) => Promise<EmitSyncOutcome>;
|
package/docs/contract-tests.md
CHANGED
|
@@ -4,13 +4,19 @@ Generated route maps drive a smoke check over the API surface so tests catch mis
|
|
|
4
4
|
|
|
5
5
|
## Probes follow the project transport
|
|
6
6
|
|
|
7
|
-
A route is only meaningfully tested over the path real users hit (ADR 0064). API
|
|
7
|
+
A route is only meaningfully tested over the path real users hit (ADR 0064). API and sync routes both go over the project's own `transport.invocation`: Socket.io in a socket-mode project (the default), the routed `/api/*` and `/sync/*` HTTP paths in a routed-HTTP project; the caller may override each. The CSRF layer always probes over HTTP, because CSRF is an HTTP-only guard. Layer-5 `ctx.callApi` and `ctx.callSync` follow the same rule, so a socket-mode project's custom tests run through the socket pipelines and differences between the pipelines surface as failures instead of being masked.
|
|
8
8
|
|
|
9
9
|
- Sync routes are swept only when the sweep receives the generated sync metadata (and sync input schemas for valid samples). Without it the sweep warns and covers API routes only.
|
|
10
10
|
- All sync probes in one run target a single throwaway room that the sweep's own session joins, so room-membership enforcement sees a member rather than rejecting every probe.
|
|
11
11
|
- Sockets the runner opens on the caller's session close as an intentional disconnect, the signal a real client sends; a plain close would let presence log that session out shortly after the sweep.
|
|
12
12
|
- GET probes carry their payload in the query string under the reserved key the real client uses (ADR 0062); `fetch` cannot send a GET body, and dropping the payload made every GET route with a required field fail validation.
|
|
13
13
|
|
|
14
|
+
## What counts as a pass
|
|
15
|
+
|
|
16
|
+
A shaped error envelope is not enough on its own. A routing error (unknown route, wrong method, invalid name) means the generated route map and the live server disagree, and a 5xx or execution-failed reply means the handler crashed on a well-formed probe; both fail the layer. A route that is designed to fail on the server is listed explicitly as expected to error, so its 5xx passes without hiding everyone else's. A rate-limited reply never reached validation or the handler, so it is reported as skipped rather than passed; with a reset token the sweep clears rate limits between routes so the shared per-IP bucket does not fill up mid-sweep.
|
|
17
|
+
|
|
18
|
+
In token mode (`session.basedToken`) the sweep sends its session as a Bearer token, the token-mode credential, and skips the CSRF layer: token-mode requests carry no ambient cookie, so the server does not enforce CSRF, and probing would only run mutating handlers.
|
|
19
|
+
|
|
14
20
|
## Sweep boundaries
|
|
15
21
|
|
|
16
22
|
A route filter narrows the selection before any request is sent; filtered-out routes are never called. An authenticated sweep calls every selected route with its own session, logout-type routes included: a route that destroys the current session (logout, account deletion, session revoke) leaves every later layer running anonymously, so skip such routes explicitly.
|
package/docs/fuzz-tests.md
CHANGED
|
@@ -4,4 +4,4 @@ This slow layer deliberately sends malformed inputs to exercise error shaping an
|
|
|
4
4
|
|
|
5
5
|
In an authenticated sweep, state-changing routes are reported as skipped so junk bodies cannot mutate the test database. Every sync route counts as state-changing, so an authenticated sweep does not fuzz sync routes at all; an anonymous sweep does. GET probes carry the junk payload in the query string like the real client (ADR 0062).
|
|
6
6
|
|
|
7
|
-
A rejected unauthenticated request does not prove protected handler input was fuzzed; create authenticated, project-specific tests where necessary. Keep test traffic away from production and avoid payloads that trigger irreversible side effects. The fuzz source defines the actual probe catalogue; do not duplicate it as a static list here.
|
|
7
|
+
A rate-limited reply means the payload never reached validation or the handler, so a route whose probes hit the limiter is reported as skipped, not passed; with a reset token the sweep clears rate limits before each route. A rejected unauthenticated request does not prove protected handler input was fuzzed; create authenticated, project-specific tests where necessary. Keep test traffic away from production and avoid payloads that trigger irreversible side effects. The fuzz source defines the actual probe catalogue; do not duplicate it as a static list here.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luckystack/test-runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public",
|
|
@@ -54,11 +54,11 @@
|
|
|
54
54
|
"test": "vitest run"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@luckystack/core": "^0.
|
|
57
|
+
"@luckystack/core": "^0.16.0"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
|
-
"@luckystack/login": "^0.
|
|
61
|
-
"@luckystack/secret-manager": "^0.
|
|
60
|
+
"@luckystack/login": "^0.16.0",
|
|
61
|
+
"@luckystack/secret-manager": "^0.16.0",
|
|
62
62
|
"socket.io-client": "^4.8.0",
|
|
63
63
|
"zod": "^4.0.0"
|
|
64
64
|
},
|