@luckystack/test-runner 0.10.1 → 0.11.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/AGENTS.md +7 -0
- package/CHANGELOG.md +28 -1
- package/README.md +13 -66
- package/dist/authEnforcementCheck.d.ts +12 -0
- package/dist/contractCheck.d.ts +24 -0
- package/dist/csrfEnforcementCheck.d.ts +25 -0
- package/dist/customTests.d.ts +145 -0
- package/dist/extensionRegistry.d.ts +83 -0
- package/dist/fuzzCheck.d.ts +12 -0
- package/dist/index.d.ts +49 -557
- package/dist/index.js +560 -304
- package/dist/index.js.map +1 -1
- package/dist/probeRequest.d.ts +48 -0
- package/dist/rateLimitCheck.d.ts +14 -0
- package/dist/reporter.d.ts +3 -0
- package/dist/resetServerState.d.ts +13 -0
- package/dist/resolveTestBaseUrl.d.ts +7 -0
- package/dist/resolveTestEnvironment.d.ts +21 -0
- package/dist/runAllTests.d.ts +88 -0
- package/dist/runAuthEnforcementTests.d.ts +17 -0
- package/dist/runContractTests.d.ts +22 -0
- package/dist/runCsrfEnforcementTests.d.ts +29 -0
- package/dist/runFuzzTests.d.ts +16 -0
- package/dist/runRateLimitTests.d.ts +38 -0
- package/dist/runRegisteredLayers.d.ts +24 -0
- package/dist/sampleInputFor.d.ts +13 -0
- package/dist/schemaSampleInput.d.ts +12 -0
- package/dist/socketProbeTransport.d.ts +9 -0
- package/dist/streamWatcher.d.ts +37 -0
- package/dist/testDataMarker.d.ts +2 -0
- package/dist/testLayerHelpers.d.ts +55 -0
- package/dist/testSocket.d.ts +58 -0
- package/dist/types.d.ts +46 -0
- package/dist/walkEndpoints.d.ts +6 -0
- package/docs/auth-tests.md +4 -223
- package/docs/contract-tests.md +11 -293
- package/docs/extension-hooks.md +4 -460
- package/docs/fuzz-tests.md +4 -245
- package/docs/rate-limit-tests.md +9 -253
- package/package.json +5 -5
- package/CLAUDE.md +0 -104
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { EndpointDescriptor, HttpMethod } from './types.js';
|
|
2
|
+
export interface ProbeRequestInput {
|
|
3
|
+
url: string;
|
|
4
|
+
method: HttpMethod;
|
|
5
|
+
baseUrl: string;
|
|
6
|
+
body?: unknown;
|
|
7
|
+
headers?: Record<string, string>;
|
|
8
|
+
requestTimeoutMs: number;
|
|
9
|
+
}
|
|
10
|
+
export interface ProbeResponse {
|
|
11
|
+
/** HTTP status. Absent when the probe travelled over Socket.io. */
|
|
12
|
+
httpStatus?: number;
|
|
13
|
+
parsed: {
|
|
14
|
+
status?: string;
|
|
15
|
+
errorCode?: string;
|
|
16
|
+
} | null;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Send a single timed fetch probe and parse the JSON envelope. Returns
|
|
20
|
+
* `null` when the request itself fails (network error or abort).
|
|
21
|
+
*/
|
|
22
|
+
export declare const sendProbe: (input: ProbeRequestInput) => Promise<ProbeResponse | null>;
|
|
23
|
+
export interface ProbeTransportRequest {
|
|
24
|
+
endpoint: EndpointDescriptor;
|
|
25
|
+
baseUrl: string;
|
|
26
|
+
/** The route payload — the API `data`, or the sync `data` (the transport adds the sync envelope). */
|
|
27
|
+
body: unknown;
|
|
28
|
+
/** HTTP headers (session Cookie, CSRF). A socket transport reads only the Cookie. */
|
|
29
|
+
headers?: Record<string, string>;
|
|
30
|
+
requestTimeoutMs: number;
|
|
31
|
+
}
|
|
32
|
+
export interface ProbeTransport {
|
|
33
|
+
readonly name: 'http' | 'socket';
|
|
34
|
+
/** Resolve with the parsed reply, or `null` when no reply arrived (network error / timeout). */
|
|
35
|
+
send: (request: ProbeTransportRequest) => Promise<ProbeResponse | null>;
|
|
36
|
+
/** Release held connections. Optional — the HTTP transport holds none. */
|
|
37
|
+
close?: () => Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
export interface HttpProbeTransportOptions {
|
|
40
|
+
/**
|
|
41
|
+
* Room sent as `receiver` on sync probes. Without one the server answers
|
|
42
|
+
* `sync.missingReceiver`, which proves the envelope but not the route.
|
|
43
|
+
*/
|
|
44
|
+
syncReceiver?: string;
|
|
45
|
+
}
|
|
46
|
+
export declare const createHttpProbeTransport: (options?: HttpProbeTransportOptions) => ProbeTransport;
|
|
47
|
+
/** Transport the checks use when the caller supplies none. */
|
|
48
|
+
export declare const defaultProbeTransport: ProbeTransport;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ContractCheckResult, EndpointDescriptor } from './types.js';
|
|
2
|
+
import type { ProbeTransport } from './probeRequest.js';
|
|
3
|
+
export interface RateLimitCheckInput {
|
|
4
|
+
endpoint: EndpointDescriptor;
|
|
5
|
+
baseUrl: string;
|
|
6
|
+
rateLimit: number;
|
|
7
|
+
inputFor?: (endpoint: EndpointDescriptor) => unknown;
|
|
8
|
+
headers?: Record<string, string>;
|
|
9
|
+
/** Per-call request timeout in ms. Defaults to 10000. */
|
|
10
|
+
requestTimeoutMs?: number;
|
|
11
|
+
/** How the probes reach the server. Defaults to HTTP. */
|
|
12
|
+
transport?: ProbeTransport;
|
|
13
|
+
}
|
|
14
|
+
export declare const runRateLimitCheck: (input: RateLimitCheckInput) => Promise<ContractCheckResult>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { TestResetTarget } from '@luckystack/core';
|
|
2
|
+
export interface ResetServerStateInput {
|
|
3
|
+
baseUrl: string;
|
|
4
|
+
token?: string;
|
|
5
|
+
requestTimeoutMs?: number;
|
|
6
|
+
/**
|
|
7
|
+
* Clear only these targets (`?only=`). Omit for the server's default reset,
|
|
8
|
+
* which ALSO deletes every Redis session — including an authenticated sweep's
|
|
9
|
+
* own. The runner itself always passes `['rateLimits']`.
|
|
10
|
+
*/
|
|
11
|
+
only?: TestResetTarget[];
|
|
12
|
+
}
|
|
13
|
+
export declare const resetServerState: ({ baseUrl, token, requestTimeoutMs, only }: ResetServerStateInput) => Promise<boolean>;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface ResolveTestBaseUrlOptions {
|
|
2
|
+
/** Project root containing node_modules/.luckystack/dev-server.json. */
|
|
3
|
+
cwd?: string;
|
|
4
|
+
/** Used when no explicit override or valid live advertisement exists. */
|
|
5
|
+
fallbackUrl?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare const resolveTestBaseUrl: (options?: ResolveTestBaseUrlOptions) => string;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
2
|
+
export interface ResolveTestEnvironmentInput {
|
|
3
|
+
/**
|
|
4
|
+
* Loads the consumer's default-exported `config.ts` object after `.env` files
|
|
5
|
+
* are available. Keeping this as a callback avoids hardcoding a consumer path
|
|
6
|
+
* inside the package and keeps `@luckystack/secret-manager` optional.
|
|
7
|
+
*/
|
|
8
|
+
loadProjectConfig?: () => MaybePromise<unknown>;
|
|
9
|
+
}
|
|
10
|
+
interface RequiredProjectConfigLoader {
|
|
11
|
+
loadProjectConfig: NonNullable<ResolveTestEnvironmentInput['loadProjectConfig']>;
|
|
12
|
+
}
|
|
13
|
+
/** @internal Runtime guard for untyped callers of the public orchestrators. */
|
|
14
|
+
export declare const assertProjectConfigLoader: (input: unknown, caller: 'runAllTests' | 'runCustomTests') => asserts input is RequiredProjectConfigLoader;
|
|
15
|
+
/**
|
|
16
|
+
* Load the project's env files and resolve optional secret-manager pointers for
|
|
17
|
+
* the TEST process. Call before importing integration-test modules that touch a
|
|
18
|
+
* database, Redis, or another env-backed SDK.
|
|
19
|
+
*/
|
|
20
|
+
export declare const resolveTestEnvironment: (input?: ResolveTestEnvironmentInput) => Promise<void>;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { InputSchemaMap } from './sampleInputFor.js';
|
|
2
|
+
import { type ResolveTestEnvironmentInput } from './resolveTestEnvironment.js';
|
|
3
|
+
import type { RunCustomTestsSummary } from './customTests.js';
|
|
4
|
+
import type { ApiMethodMap, ApiMetaMap, RunContractSummary, SyncMetaMap } from './types.js';
|
|
5
|
+
export interface RunAllTestsInput {
|
|
6
|
+
apiMethodMap: ApiMethodMap;
|
|
7
|
+
/**
|
|
8
|
+
* Generated `syncMetaMap` (from `apiTypes.generated.ts`). When given, the
|
|
9
|
+
* sweep layers exercise every sync route too; without it sync routes are
|
|
10
|
+
* not swept (a warning says so).
|
|
11
|
+
*/
|
|
12
|
+
syncMetaMap?: SyncMetaMap;
|
|
13
|
+
/** Generated `syncInputSchemas` (from `apiInputSchemas.generated.ts`) — Zod-valid sync sample payloads. */
|
|
14
|
+
syncInputSchemas?: InputSchemaMap;
|
|
15
|
+
/**
|
|
16
|
+
* How sync routes are probed. Defaults to the project's own
|
|
17
|
+
* `transport.invocation`: `'socket'` → Socket.io (the pipeline real clients
|
|
18
|
+
* hit), `'routed-http'` → the `/sync/*` HTTP route. The CSRF layer always
|
|
19
|
+
* probes over HTTP — CSRF is an HTTP-only guard.
|
|
20
|
+
*/
|
|
21
|
+
syncTransport?: 'socket' | 'http';
|
|
22
|
+
/**
|
|
23
|
+
* Write the full `RunAllTestsSummary` as JSON to this path (relative to the
|
|
24
|
+
* process cwd). Defaults to `TEST_OUTPUT_FILE`, else `test-results.json`;
|
|
25
|
+
* `false` disables the file.
|
|
26
|
+
*/
|
|
27
|
+
outputFile?: string | false;
|
|
28
|
+
/**
|
|
29
|
+
* Lazy loader for the consumer's default `config.ts` export. Required so
|
|
30
|
+
* runAllTests cannot silently skip a configured secret manager; resolution
|
|
31
|
+
* happens in THIS process before any layer or env-backed client is touched.
|
|
32
|
+
*/
|
|
33
|
+
loadProjectConfig: NonNullable<ResolveTestEnvironmentInput['loadProjectConfig']>;
|
|
34
|
+
apiMetaMap: ApiMetaMap;
|
|
35
|
+
apiInputSchemas: InputSchemaMap;
|
|
36
|
+
baseUrl: string;
|
|
37
|
+
/** Cookie name for the session token. Defaults to the project's configured session cookie name (`projectConfig.http.sessionCookieName`). */
|
|
38
|
+
sessionCookieName?: string;
|
|
39
|
+
/** Auth token applied as a `Cookie` header to sweep-layer requests that need a session. */
|
|
40
|
+
authToken?: string;
|
|
41
|
+
/** Endpoints to skip from the sweep layers, e.g. routes that need a file upload. */
|
|
42
|
+
skip?: string[];
|
|
43
|
+
/** Substring filter applied to `<page>/<name>/<version>`. */
|
|
44
|
+
filter?: string;
|
|
45
|
+
/** Disable individual layers (faster local iteration). */
|
|
46
|
+
noSweep?: boolean;
|
|
47
|
+
noFuzz?: boolean;
|
|
48
|
+
noRateLimit?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Disable the CSRF-enforcement layer. Defaults to ON (the layer runs) — but it
|
|
51
|
+
* can only probe past the auth guard with a session, so it is a no-op unless an
|
|
52
|
+
* `authToken` is supplied.
|
|
53
|
+
*/
|
|
54
|
+
noCsrf?: boolean;
|
|
55
|
+
noCustom?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Token sent as `X-Test-Reset-Token` when the rate-limit layer resets the
|
|
58
|
+
* shared bucket between endpoints. The server's `/_test/reset` requires
|
|
59
|
+
* `TEST_RESET_TOKEN` to be set and matching — an unset/wrong token is 403, so
|
|
60
|
+
* `resetBetweenEndpoints` would silently no-op. Defaults to
|
|
61
|
+
* `process.env.TEST_RESET_TOKEN` so a CI run with the env set works with no
|
|
62
|
+
* extra wiring; pass explicitly to override.
|
|
63
|
+
*/
|
|
64
|
+
resetToken?: string;
|
|
65
|
+
/**
|
|
66
|
+
* When a reset token is available, the sweep POSTs
|
|
67
|
+
* `/_test/reset?only=rateLimits` ONCE before the first layer (no bucket left
|
|
68
|
+
* drained by an earlier run) and ONCE after the last (the custom layer and the
|
|
69
|
+
* next run start unthrottled). Only limiter state is cleared: sessions — the
|
|
70
|
+
* `authToken` session above all — survive, so `TEST_RESET_TOKEN` can be set
|
|
71
|
+
* on an authenticated sweep. The reset never touches the app database.
|
|
72
|
+
* Defaults to ON; set `false` to opt out. With no token the bookends are
|
|
73
|
+
* skipped with a logged note (they can't run — `/_test/reset` 403s).
|
|
74
|
+
*/
|
|
75
|
+
resetBookends?: boolean;
|
|
76
|
+
}
|
|
77
|
+
export interface RunAllTestsSummary {
|
|
78
|
+
contract?: RunContractSummary;
|
|
79
|
+
auth?: RunContractSummary;
|
|
80
|
+
rateLimit?: RunContractSummary;
|
|
81
|
+
csrf?: RunContractSummary;
|
|
82
|
+
fuzz?: RunContractSummary;
|
|
83
|
+
custom?: RunCustomTestsSummary;
|
|
84
|
+
totalPassed: number;
|
|
85
|
+
totalFailed: number;
|
|
86
|
+
}
|
|
87
|
+
export declare const runAllTests: (input: RunAllTestsInput) => Promise<RunAllTestsSummary>;
|
|
88
|
+
export declare const logRunAllSummary: (summary: RunAllTestsSummary) => void;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ProbeTransport } from './probeRequest.js';
|
|
2
|
+
import type { ApiMethodMap, ApiMetaMap, ContractCheckResult, EndpointDescriptor, RunContractSummary, SyncMetaMap } from './types.js';
|
|
3
|
+
export interface RunAuthEnforcementTestsInput {
|
|
4
|
+
apiMethodMap: ApiMethodMap;
|
|
5
|
+
apiMetaMap: ApiMetaMap;
|
|
6
|
+
/** Generated `syncMetaMap`. When given, sync routes are swept too. */
|
|
7
|
+
syncMetaMap?: SyncMetaMap;
|
|
8
|
+
baseUrl: string;
|
|
9
|
+
skip?: string[];
|
|
10
|
+
/** Substring filter on `<page>/<name>/<version>`; other routes are not requested at all. */
|
|
11
|
+
filter?: string;
|
|
12
|
+
inputFor?: (endpoint: EndpointDescriptor) => unknown;
|
|
13
|
+
/** Transport for sync routes (e.g. the socket transport). API routes always use HTTP. */
|
|
14
|
+
syncTransport?: ProbeTransport;
|
|
15
|
+
onResult?: (result: ContractCheckResult) => void;
|
|
16
|
+
}
|
|
17
|
+
export declare const runAuthEnforcementTests: (input: RunAuthEnforcementTestsInput) => Promise<RunContractSummary>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ProbeTransport } from './probeRequest.js';
|
|
2
|
+
import type { ApiMethodMap, ContractCheckResult, EndpointDescriptor, RunContractSummary, SyncMetaMap } from './types.js';
|
|
3
|
+
export interface RunContractTestsInput {
|
|
4
|
+
apiMethodMap: ApiMethodMap;
|
|
5
|
+
/** Generated `syncMetaMap`. When given, sync routes are swept too. */
|
|
6
|
+
syncMetaMap?: SyncMetaMap;
|
|
7
|
+
baseUrl: string;
|
|
8
|
+
/**
|
|
9
|
+
* Endpoints to skip. Matched against `<page>/<name>` (version-agnostic) and
|
|
10
|
+
* `<page>/<name>/<version>` (version-specific). Useful for endpoints that
|
|
11
|
+
* are known to need real input (e.g. a file upload).
|
|
12
|
+
*/
|
|
13
|
+
skip?: string[];
|
|
14
|
+
/** Substring filter on `<page>/<name>/<version>`; other routes are not requested at all. */
|
|
15
|
+
filter?: string;
|
|
16
|
+
inputFor?: (endpoint: EndpointDescriptor) => unknown;
|
|
17
|
+
headers?: Record<string, string>;
|
|
18
|
+
/** Transport for sync routes (e.g. the socket transport). API routes always use HTTP. */
|
|
19
|
+
syncTransport?: ProbeTransport;
|
|
20
|
+
onResult?: (result: ContractCheckResult) => void;
|
|
21
|
+
}
|
|
22
|
+
export declare const runContractTests: (input: RunContractTestsInput) => Promise<RunContractSummary>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ProbeTransport } from './probeRequest.js';
|
|
2
|
+
import type { ApiMethodMap, ApiMetaMap, ContractCheckResult, EndpointDescriptor, RunContractSummary, SyncMetaMap } from './types.js';
|
|
3
|
+
export interface RunCsrfEnforcementTestsInput {
|
|
4
|
+
apiMethodMap: ApiMethodMap;
|
|
5
|
+
apiMetaMap: ApiMetaMap;
|
|
6
|
+
/** Generated `syncMetaMap`. When given, sync routes' `/sync/*` HTTP endpoints are probed too. */
|
|
7
|
+
syncMetaMap?: SyncMetaMap;
|
|
8
|
+
baseUrl: string;
|
|
9
|
+
/** Valid session Cookie header (`<name>=<token>`) the probes carry so they reach the CSRF check. */
|
|
10
|
+
authCookie: string;
|
|
11
|
+
skip?: string[];
|
|
12
|
+
/** Substring filter on `<page>/<name>/<version>`; other routes are not requested at all. */
|
|
13
|
+
filter?: string;
|
|
14
|
+
inputFor?: (endpoint: EndpointDescriptor) => unknown;
|
|
15
|
+
/**
|
|
16
|
+
* HTTP transport for sync routes (one carrying the sync `receiver`). CSRF is
|
|
17
|
+
* an HTTP-only guard, so this must be an HTTP transport even when the project
|
|
18
|
+
* syncs over Socket.io — the `/sync/*` HTTP route is reachable either way.
|
|
19
|
+
*/
|
|
20
|
+
syncTransport?: ProbeTransport;
|
|
21
|
+
/** Per-call request timeout in ms forwarded to each check. */
|
|
22
|
+
requestTimeoutMs?: number;
|
|
23
|
+
/** Error code expected on CSRF rejection. Defaults to `auth.csrfMismatch`. */
|
|
24
|
+
expectedErrorCode?: string;
|
|
25
|
+
/** HTTP status expected on CSRF rejection. Defaults to 403; pass `false` to skip. */
|
|
26
|
+
expectedHttpStatus?: number | false;
|
|
27
|
+
onResult?: (result: ContractCheckResult) => void;
|
|
28
|
+
}
|
|
29
|
+
export declare const runCsrfEnforcementTests: (input: RunCsrfEnforcementTestsInput) => Promise<RunContractSummary>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ProbeTransport } from './probeRequest.js';
|
|
2
|
+
import type { ApiMethodMap, ContractCheckResult, RunContractSummary, SyncMetaMap } from './types.js';
|
|
3
|
+
export interface RunFuzzTestsInput {
|
|
4
|
+
apiMethodMap: ApiMethodMap;
|
|
5
|
+
/** Generated `syncMetaMap`. When given, sync routes are swept too. */
|
|
6
|
+
syncMetaMap?: SyncMetaMap;
|
|
7
|
+
baseUrl: string;
|
|
8
|
+
skip?: string[];
|
|
9
|
+
/** Substring filter on `<page>/<name>/<version>`; other routes are not requested at all. */
|
|
10
|
+
filter?: string;
|
|
11
|
+
headers?: Record<string, string>;
|
|
12
|
+
/** Transport for sync routes (e.g. the socket transport). API routes always use HTTP. */
|
|
13
|
+
syncTransport?: ProbeTransport;
|
|
14
|
+
onResult?: (result: ContractCheckResult) => void;
|
|
15
|
+
}
|
|
16
|
+
export declare const runFuzzTests: (input: RunFuzzTestsInput) => Promise<RunContractSummary>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ProbeTransport } from './probeRequest.js';
|
|
2
|
+
import type { ApiMethodMap, ApiMetaMap, ContractCheckResult, EndpointDescriptor, RunContractSummary, SyncMetaMap } from './types.js';
|
|
3
|
+
export interface RunRateLimitTestsInput {
|
|
4
|
+
apiMethodMap: ApiMethodMap;
|
|
5
|
+
apiMetaMap: ApiMetaMap;
|
|
6
|
+
/** Generated `syncMetaMap`. When given, sync routes are swept too. */
|
|
7
|
+
syncMetaMap?: SyncMetaMap;
|
|
8
|
+
baseUrl: string;
|
|
9
|
+
skip?: string[];
|
|
10
|
+
/** Substring filter on `<page>/<name>/<version>`; other routes are not requested at all. */
|
|
11
|
+
filter?: string;
|
|
12
|
+
inputFor?: (endpoint: EndpointDescriptor) => unknown;
|
|
13
|
+
headers?: Record<string, string>;
|
|
14
|
+
/** Transport for sync routes (e.g. the socket transport). API routes always use HTTP. */
|
|
15
|
+
syncTransport?: ProbeTransport;
|
|
16
|
+
/**
|
|
17
|
+
* Max rateLimit value to test. Endpoints with a higher limit are skipped
|
|
18
|
+
* to avoid firing thousands of requests in CI.
|
|
19
|
+
*/
|
|
20
|
+
maxRateLimitToTest?: number;
|
|
21
|
+
/**
|
|
22
|
+
* When true, hit `/_test/reset?only=rateLimits` before each endpoint so
|
|
23
|
+
* limiter state is clean. Only the limiter buckets are cleared — sessions
|
|
24
|
+
* survive, so an authenticated sweep keeps its session. Requires the server
|
|
25
|
+
* to allow the endpoint: `NODE_ENV` in { 'development', 'test' } AND
|
|
26
|
+
* `TEST_RESET_TOKEN` set on the server with a matching `resetToken` here (an
|
|
27
|
+
* unset token is 403, never open). Without resetting, the shared IP bucket
|
|
28
|
+
* leaks state between endpoints.
|
|
29
|
+
*/
|
|
30
|
+
resetBetweenEndpoints?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Token forwarded as `X-Test-Reset-Token` when resetting. Only relevant in
|
|
33
|
+
* staging/preview deploys that enable the endpoint behind a token.
|
|
34
|
+
*/
|
|
35
|
+
resetToken?: string;
|
|
36
|
+
onResult?: (result: ContractCheckResult) => void;
|
|
37
|
+
}
|
|
38
|
+
export declare const runRateLimitTests: (input: RunRateLimitTestsInput) => Promise<RunContractSummary>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { InputSchemaMap } from './sampleInputFor.js';
|
|
2
|
+
import type { TestSummary } from './extensionRegistry.js';
|
|
3
|
+
import type { ApiMethodMap, EndpointDescriptor, SyncMetaMap } from './types.js';
|
|
4
|
+
export interface RunRegisteredLayersInput {
|
|
5
|
+
apiMethodMap: ApiMethodMap;
|
|
6
|
+
/** Generated `syncMetaMap`. When given, layers also run against every sync route. */
|
|
7
|
+
syncMetaMap?: SyncMetaMap;
|
|
8
|
+
/** Session/bearer token forwarded to each layer's `run({ authToken })`. */
|
|
9
|
+
authToken?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Endpoints to skip — matched against `<page>/<name>` (version-agnostic) and
|
|
12
|
+
* `<page>/<name>/<version>`, same convention as the built-in sweeps.
|
|
13
|
+
*/
|
|
14
|
+
skip?: string[];
|
|
15
|
+
/** Substring filter on `<page>/<name>/<version>`, same as the built-in sweeps. */
|
|
16
|
+
filter?: string;
|
|
17
|
+
/** Generated `apiInputSchemas` — source of each API route's `sampleInput`. */
|
|
18
|
+
apiInputSchemas?: InputSchemaMap;
|
|
19
|
+
/** Generated `syncInputSchemas` — source of each sync route's `sampleInput`. */
|
|
20
|
+
syncInputSchemas?: InputSchemaMap;
|
|
21
|
+
/** Custom sample factory; wins over the schema maps when given. */
|
|
22
|
+
inputFor?: (endpoint: EndpointDescriptor) => unknown;
|
|
23
|
+
}
|
|
24
|
+
export declare const runRegisteredLayers: (input: RunRegisteredLayersInput) => Promise<TestSummary>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ZodType } from 'zod';
|
|
2
|
+
import type { EndpointDescriptor } from './types.js';
|
|
3
|
+
export type InputSchemaMap = Partial<Record<string, Partial<Record<string, Partial<Record<string, ZodType | undefined>>>>>>;
|
|
4
|
+
export interface CreateSampleInputForOptions {
|
|
5
|
+
apiInputSchemas?: InputSchemaMap;
|
|
6
|
+
syncInputSchemas?: InputSchemaMap;
|
|
7
|
+
/**
|
|
8
|
+
* Prefix for generated unconstrained strings — pass the run's
|
|
9
|
+
* `createTestDataMarker()` so rows the probes create are identifiable.
|
|
10
|
+
*/
|
|
11
|
+
stringPrefix?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare const createSampleInputFor: (options: CreateSampleInputForOptions) => (endpoint: EndpointDescriptor) => unknown;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { z } from 'zod';
|
|
2
|
+
export interface SampleSchemaOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Marker prefix applied to generated UNCONSTRAINED string values so any rows
|
|
5
|
+
* a sweep creates are identifiable + collision-free (finding #98). Strings
|
|
6
|
+
* that carry a format (email/url/uuid/datetime/…) or a check (min/max/
|
|
7
|
+
* length/regex) are left as-is, since prefixing could break the route's own
|
|
8
|
+
* validation and defeat the contract layer's "valid input" guarantee.
|
|
9
|
+
*/
|
|
10
|
+
stringPrefix?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare const sampleSchemaInput: (schema: z.ZodType, options?: SampleSchemaOptions) => unknown;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ProbeTransport } from './probeRequest.js';
|
|
2
|
+
export interface SocketProbeTransportOptions {
|
|
3
|
+
baseUrl: string;
|
|
4
|
+
/** Room every sync probe targets; authenticated sockets join it first. */
|
|
5
|
+
receiver: string;
|
|
6
|
+
/** Session cookie name, used to read the token out of a probe's Cookie header. */
|
|
7
|
+
sessionCookieName: string;
|
|
8
|
+
}
|
|
9
|
+
export declare const createSocketProbeTransport: (options: SocketProbeTransportOptions) => ProbeTransport;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface StreamChunkFrame {
|
|
2
|
+
/** Per-request correlation id stamped by the server. */
|
|
3
|
+
cb?: string;
|
|
4
|
+
/** Full route name (`sync/<page>/<name>/<version>`). */
|
|
5
|
+
fullName?: string;
|
|
6
|
+
/** Always `'stream'` for chunk frames. */
|
|
7
|
+
status?: 'stream';
|
|
8
|
+
/** Payload fields spread on top of the envelope. */
|
|
9
|
+
[key: string]: unknown;
|
|
10
|
+
}
|
|
11
|
+
export interface StreamWatcher<TChunk extends StreamChunkFrame = StreamChunkFrame> {
|
|
12
|
+
/** Chunks received so far. Array reference is stable; entries are pushed in arrival order. */
|
|
13
|
+
readonly chunks: TChunk[];
|
|
14
|
+
/** Wait until `predicate(chunks)` returns true, or until timeout (default 5000ms). */
|
|
15
|
+
stopAt(predicate: (chunks: TChunk[]) => boolean, timeoutMs?: number): Promise<void>;
|
|
16
|
+
/** Wait until N total chunks received, or until timeout. */
|
|
17
|
+
waitForCount(n: number, timeoutMs?: number): Promise<void>;
|
|
18
|
+
/** Close socket B. Tests SHOULD call this in cleanup; ctx auto-closes all watchers when the test ends. */
|
|
19
|
+
close(): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
export interface OpenStreamWatcherInput {
|
|
22
|
+
/** Server URL (same as `ctx`'s `baseUrl`). */
|
|
23
|
+
baseUrl: string;
|
|
24
|
+
/** Room to join as a pure subscriber. */
|
|
25
|
+
roomCode: string;
|
|
26
|
+
/** Active session token (used for `joinRoom` auth). May be null for routes that don't require auth. */
|
|
27
|
+
token: string | null;
|
|
28
|
+
/**
|
|
29
|
+
* Resolved route name `sync/<page>/<name>/<version>` — chunk frames are
|
|
30
|
+
* filtered by their `fullName` property so a watcher only surfaces chunks
|
|
31
|
+
* for the route its test is bound to.
|
|
32
|
+
*/
|
|
33
|
+
routeFullName: string;
|
|
34
|
+
/** Default 5000ms — overridable per `stopAt` / `waitForCount` call. */
|
|
35
|
+
defaultTimeoutMs?: number;
|
|
36
|
+
}
|
|
37
|
+
export declare const openStreamWatcher: <TChunk extends StreamChunkFrame = StreamChunkFrame>(input: OpenStreamWatcherInput) => Promise<StreamWatcher<TChunk>>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ProbeTransport } from './probeRequest.js';
|
|
2
|
+
import type { ApiMethodMap, ApiMetaMap, ContractCheckResult, EndpointDescriptor, HttpMethod, RouteMetaMap, RunContractSummary, SyncMetaMap } from './types.js';
|
|
3
|
+
export declare const LAYER_KEYS: {
|
|
4
|
+
readonly contract: 'contract';
|
|
5
|
+
readonly auth: 'auth-enforcement';
|
|
6
|
+
readonly rateLimit: 'rate-limit';
|
|
7
|
+
readonly csrf: 'csrf-enforcement';
|
|
8
|
+
readonly fuzz: 'fuzz';
|
|
9
|
+
readonly custom: 'custom';
|
|
10
|
+
};
|
|
11
|
+
export declare const STATE_CHANGING_METHODS: Set<HttpMethod>;
|
|
12
|
+
/** `<page>/<name>/<version>` — the key `filter` and `skip` match against. */
|
|
13
|
+
export declare const routeKey: (endpoint: EndpointDescriptor) => string;
|
|
14
|
+
/** Whether an endpoint matches a substring `filter` (no filter matches all). */
|
|
15
|
+
export declare const matchesFilter: (endpoint: EndpointDescriptor, filter: string | undefined) => boolean;
|
|
16
|
+
export interface SelectEndpointsInput {
|
|
17
|
+
apiMethodMap: ApiMethodMap;
|
|
18
|
+
/** Generated `syncMetaMap`. When given, sync routes are swept too. */
|
|
19
|
+
syncMetaMap?: SyncMetaMap;
|
|
20
|
+
/** Substring filter on `<page>/<name>/<version>`. Non-matching routes are never requested. */
|
|
21
|
+
filter?: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The endpoints a sweep layer exercises: every API route, plus every sync
|
|
25
|
+
* route when a `syncMetaMap` is supplied, narrowed by `filter`. Filtering
|
|
26
|
+
* here — before any request — is what makes `filter` limit the traffic, not
|
|
27
|
+
* just the report.
|
|
28
|
+
*/
|
|
29
|
+
export declare const selectEndpoints: (input: SelectEndpointsInput) => EndpointDescriptor[];
|
|
30
|
+
/** The meta map that describes `endpoint`: `syncMetaMap` for sync routes, else `apiMetaMap`. */
|
|
31
|
+
export declare const metaMapFor: (endpoint: EndpointDescriptor, apiMetaMap: ApiMetaMap, syncMetaMap: SyncMetaMap | undefined) => RouteMetaMap;
|
|
32
|
+
/** The transport for `endpoint`: `syncTransport` for sync routes when given, else HTTP. */
|
|
33
|
+
export declare const transportFor: (endpoint: EndpointDescriptor, syncTransport: ProbeTransport | undefined) => ProbeTransport;
|
|
34
|
+
/**
|
|
35
|
+
* Whether an endpoint is in the explicit skip list. Matched against
|
|
36
|
+
* `<page>/<name>` (version-agnostic) and `<page>/<name>/<version>`
|
|
37
|
+
* (version-specific).
|
|
38
|
+
*/
|
|
39
|
+
export declare const shouldSkip: (endpoint: EndpointDescriptor, skip: string[]) => boolean;
|
|
40
|
+
/** Whether the endpoint's meta entry declares `auth.login: true`. */
|
|
41
|
+
export declare const requiresLogin: (metaMap: RouteMetaMap, endpoint: EndpointDescriptor) => boolean;
|
|
42
|
+
/** Whether the endpoint requires ANY authorization: `auth.login: true` OR a
|
|
43
|
+
* declared non-empty `auth.additional[]` predicate list (role/tenant/ownership
|
|
44
|
+
* guards). The auth sweep must probe BOTH — an additional[]-only route (login:
|
|
45
|
+
* false) is still authz-protected and an unauth call must be rejected. */
|
|
46
|
+
export declare const hasAuthRequirement: (metaMap: RouteMetaMap, endpoint: EndpointDescriptor) => boolean;
|
|
47
|
+
/** Whether the meta map carries an entry for this endpoint at all. */
|
|
48
|
+
export declare const hasMetaEntry: (metaMap: RouteMetaMap, endpoint: EndpointDescriptor) => boolean;
|
|
49
|
+
/**
|
|
50
|
+
* The endpoint's declared numeric rate limit, or `null` when it has none
|
|
51
|
+
* (`false`/`undefined`) or no meta entry exists.
|
|
52
|
+
*/
|
|
53
|
+
export declare const getRateLimit: (metaMap: RouteMetaMap, endpoint: EndpointDescriptor) => number | null;
|
|
54
|
+
/** Aggregate pass/fail/skip counts over a sweep layer's results. */
|
|
55
|
+
export declare const calculateSummary: (results: ContractCheckResult[]) => RunContractSummary;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Socket } from 'socket.io-client';
|
|
2
|
+
export declare const nextResponseIndex: () => number;
|
|
3
|
+
export interface ConnectTestSocketInput {
|
|
4
|
+
baseUrl: string;
|
|
5
|
+
/** Session token, or null for an anonymous socket. */
|
|
6
|
+
token: string | null;
|
|
7
|
+
/** Prefix for error messages (e.g. `watchStream`, `callSync`). */
|
|
8
|
+
label: string;
|
|
9
|
+
/** Session cookie name. Defaults to the project's configured cookie. */
|
|
10
|
+
sessionCookieName?: string;
|
|
11
|
+
connectTimeoutMs?: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Open a socket.io connection authenticated the way a browser would be, and
|
|
15
|
+
* wait until it is connected. On a connect timeout / connect_error the socket
|
|
16
|
+
* is torn down before the error is thrown, so a failed attempt never leaks.
|
|
17
|
+
*/
|
|
18
|
+
export declare const connectTestSocket: (input: ConnectTestSocketInput) => Promise<Socket>;
|
|
19
|
+
/**
|
|
20
|
+
* Close a socket that runs on a session the runner does NOT own (the sweep's
|
|
21
|
+
* `authToken`) without ending that session.
|
|
22
|
+
*
|
|
23
|
+
* With `@luckystack/presence` installed, a token whose last socket closes is
|
|
24
|
+
* logged out once the short disconnect grace window expires — a closed browser
|
|
25
|
+
* tab ends the session. A plain `disconnect()` therefore deleted the caller's
|
|
26
|
+
* session seconds after every sweep. Sending `intentionalDisconnect` first is
|
|
27
|
+
* the signal a real client sends on a tab switch: presence keeps the session
|
|
28
|
+
* and the server closes the socket itself. Without presence nobody listens,
|
|
29
|
+
* so the socket is closed locally after a short wait.
|
|
30
|
+
*/
|
|
31
|
+
export declare const releaseTestSocket: (socket: Socket) => Promise<void>;
|
|
32
|
+
/**
|
|
33
|
+
* Join `room` and wait for the server's ack. Rejects when the server refuses
|
|
34
|
+
* the join (e.g. `auth.required` for an anonymous socket) or never answers.
|
|
35
|
+
*/
|
|
36
|
+
export declare const joinTestRoom: (socket: Socket, room: string, options: {
|
|
37
|
+
label: string;
|
|
38
|
+
timeoutMs?: number;
|
|
39
|
+
}) => Promise<void>;
|
|
40
|
+
export interface EmitSyncInput {
|
|
41
|
+
/** `<page>/<name>/<version>` — the route path without the `sync/` prefix. */
|
|
42
|
+
routePath: string;
|
|
43
|
+
data: unknown;
|
|
44
|
+
receiver: string | undefined;
|
|
45
|
+
ignoreSelf?: boolean;
|
|
46
|
+
timeoutMs: number;
|
|
47
|
+
}
|
|
48
|
+
export type EmitSyncOutcome = {
|
|
49
|
+
kind: 'response';
|
|
50
|
+
payload: unknown;
|
|
51
|
+
} | {
|
|
52
|
+
kind: 'timeout';
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Send one sync request over `socket` exactly like the real client does and
|
|
56
|
+
* resolve with the server's single reply on `sync-<responseIndex>`.
|
|
57
|
+
*/
|
|
58
|
+
export declare const emitSyncOverSocket: (socket: Socket, input: EmitSyncInput) => Promise<EmitSyncOutcome>;
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
2
|
+
export type ApiMethodMap = Partial<Record<string, Partial<Record<string, Partial<Record<string, string>>>>>>;
|
|
3
|
+
export type SyncMethodMap = Partial<Record<string, Partial<Record<string, Partial<Record<string, string>>>>>>;
|
|
4
|
+
export interface ApiMetaEntry {
|
|
5
|
+
method: string;
|
|
6
|
+
auth: {
|
|
7
|
+
login: boolean;
|
|
8
|
+
additional?: Record<string, unknown>[];
|
|
9
|
+
hasAdditional?: boolean;
|
|
10
|
+
};
|
|
11
|
+
rateLimit?: number | false;
|
|
12
|
+
}
|
|
13
|
+
export type ApiMetaMap = Partial<Record<string, Partial<Record<string, Partial<Record<string, ApiMetaEntry>>>>>>;
|
|
14
|
+
export interface SyncMetaEntry {
|
|
15
|
+
auth: ApiMetaEntry['auth'];
|
|
16
|
+
rateLimit?: number | false;
|
|
17
|
+
}
|
|
18
|
+
export type SyncMetaMap = Partial<Record<string, Partial<Record<string, Partial<Record<string, SyncMetaEntry>>>>>>;
|
|
19
|
+
export type RouteMetaEntry = Pick<ApiMetaEntry, 'auth' | 'rateLimit'>;
|
|
20
|
+
export type RouteMetaMap = Partial<Record<string, Partial<Record<string, Partial<Record<string, RouteMetaEntry>>>>>>;
|
|
21
|
+
export interface EndpointDescriptor {
|
|
22
|
+
page: string;
|
|
23
|
+
name: string;
|
|
24
|
+
version: string;
|
|
25
|
+
method: HttpMethod;
|
|
26
|
+
/** `api/<page>/<name>/<version>` or `sync/<page>/<name>/<version>` */
|
|
27
|
+
fullPath: string;
|
|
28
|
+
/** Route kind. Absent means `'api'` — only `walkSyncEndpoints` sets `'sync'`. */
|
|
29
|
+
kind?: 'api' | 'sync';
|
|
30
|
+
}
|
|
31
|
+
export interface ContractCheckResult {
|
|
32
|
+
endpoint: EndpointDescriptor;
|
|
33
|
+
status: 'pass' | 'fail' | 'skipped';
|
|
34
|
+
httpStatus?: number;
|
|
35
|
+
responseStatus?: 'success' | 'error' | 'unknown';
|
|
36
|
+
errorCode?: string;
|
|
37
|
+
reason?: string;
|
|
38
|
+
durationMs: number;
|
|
39
|
+
}
|
|
40
|
+
export interface RunContractSummary {
|
|
41
|
+
total: number;
|
|
42
|
+
passed: number;
|
|
43
|
+
failed: number;
|
|
44
|
+
skipped: number;
|
|
45
|
+
results: ContractCheckResult[];
|
|
46
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { EndpointDescriptor, SyncMetaMap, SyncMethodMap } from './types.js';
|
|
2
|
+
type ApiMethodMap = Partial<Record<string, Partial<Record<string, Partial<Record<string, string>>>>>>;
|
|
3
|
+
export declare const walkEndpoints: (apiMethodMap: ApiMethodMap) => EndpointDescriptor[];
|
|
4
|
+
type SyncRouteMap = Partial<Record<string, Partial<Record<string, Partial<Record<string, unknown>>>>>>;
|
|
5
|
+
export declare const walkSyncEndpoints: (syncMap: SyncMethodMap | SyncMetaMap | SyncRouteMap) => EndpointDescriptor[];
|
|
6
|
+
export {};
|