lambder 7.2.4 → 7.3.1
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 +88 -0
- package/README.md +4 -2
- package/dist/api/LambderApiIdempotency.d.ts +7 -0
- package/dist/api/LambderApiIdempotency.js +12 -0
- package/dist/api/LambderApiPipeline.d.ts +30 -1
- package/dist/api/LambderApiPipeline.js +14 -0
- package/dist/api/LambderApiPolicyEngine.d.ts +11 -0
- package/dist/api/LambderApiPolicyEngine.js +8 -0
- package/dist/api/LambderApiRateLimits.d.ts +7 -0
- package/dist/api/LambderApiRateLimits.js +12 -0
- package/dist/core/Lambder.d.ts +35 -1
- package/dist/core/Lambder.js +32 -1
- package/dist/core/LambderFiles.d.ts +7 -0
- package/dist/core/LambderFiles.js +12 -0
- package/dist/index.d.ts +1 -1
- package/dist/invoke/LambderLambdaEvent.d.ts +23 -9
- package/dist/invoke/LambderLambdaEvent.js +41 -16
- package/dist/invoke/lambderHandlerTransport.d.ts +3 -0
- package/dist/invoke/lambderHandlerTransport.js +1 -1
- package/dist/mock.d.ts +2 -0
- package/dist/mock.js +3 -0
- package/dist/session/LambderSessionManager.d.ts +12 -1
- package/dist/session/LambderSessionManager.js +19 -3
- package/dist/shared/util/LambderTestingDoors.d.ts +29 -0
- package/dist/shared/util/LambderTestingDoors.js +29 -0
- package/dist/shared/wire/LambderApiContract.d.ts +50 -0
- package/dist/shared/wire/LambderOutcomeAssertions.d.ts +80 -0
- package/dist/shared/wire/LambderOutcomeAssertions.js +113 -0
- package/dist/testing/LambderTestApp.d.ts +178 -0
- package/dist/testing/LambderTestApp.js +206 -0
- package/dist/testing/LambderTestVisitor.d.ts +155 -0
- package/dist/testing/LambderTestVisitor.js +154 -0
- package/dist/testing.d.ts +26 -0
- package/dist/testing.js +23 -0
- package/package.json +9 -1
|
@@ -31,18 +31,7 @@ const randomRequestId = () => {
|
|
|
31
31
|
return webCrypto.randomUUID();
|
|
32
32
|
return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 10)}`;
|
|
33
33
|
};
|
|
34
|
-
|
|
35
|
-
* The payload-format-2.0 event API Gateway would deliver for this request.
|
|
36
|
-
* `invoke: true` adds the invoke marker headers a server-to-server call
|
|
37
|
-
* carries; a browser-shaped request (the handler transport) leaves them off.
|
|
38
|
-
*
|
|
39
|
-
* The client address is `clientIp` and reaches the callee as
|
|
40
|
-
* requestContext.http.sourceIp only. Writing it as x-forwarded-for as well
|
|
41
|
-
* would put the same fact on a channel a callee may be configured to trust
|
|
42
|
-
* (trustedClientIpHeaders), and the header is the one the caller's own
|
|
43
|
-
* `headers` could otherwise have set.
|
|
44
|
-
*/
|
|
45
|
-
export const synthesizeLambdaHttpEvent = (request, options) => {
|
|
34
|
+
export function synthesizeLambdaHttpEvent(request, options) {
|
|
46
35
|
// The caller's own headers go on first, so the ones this function owns
|
|
47
36
|
// cannot be displaced by them. Forwarding an incoming browser request's
|
|
48
37
|
// headers into `headers` is an ordinary gateway-lambda pattern, and with
|
|
@@ -72,7 +61,45 @@ export const synthesizeLambdaHttpEvent = (request, options) => {
|
|
|
72
61
|
if (request.body !== undefined && !headers["content-type"]) {
|
|
73
62
|
headers["content-type"] = isBinary ? "application/octet-stream" : "application/json";
|
|
74
63
|
}
|
|
64
|
+
const body = request.body === undefined ? undefined : isBinary ? bytesToBase64(request.body) : request.body;
|
|
75
65
|
const now = Date.now();
|
|
66
|
+
if (options.eventFormat === "v1") {
|
|
67
|
+
// A REST API has no cookies array: cookies ride in the Cookie header,
|
|
68
|
+
// and every header is delivered twice, once as its last value and
|
|
69
|
+
// once as the list of all of them.
|
|
70
|
+
if (request.cookies?.length)
|
|
71
|
+
headers.cookie = request.cookies.join("; ");
|
|
72
|
+
const query = request.query && Object.keys(request.query).length ? request.query : null;
|
|
73
|
+
return {
|
|
74
|
+
resource: "/{proxy+}",
|
|
75
|
+
path: request.path,
|
|
76
|
+
httpMethod: request.method,
|
|
77
|
+
headers,
|
|
78
|
+
multiValueHeaders: Object.fromEntries(Object.entries(headers).map(([name, value]) => [name, [value]])),
|
|
79
|
+
queryStringParameters: query,
|
|
80
|
+
multiValueQueryStringParameters: query ? Object.fromEntries(Object.entries(query).map(([name, value]) => [name, [value]])) : null,
|
|
81
|
+
pathParameters: null,
|
|
82
|
+
stageVariables: null,
|
|
83
|
+
// The fields a handler might read, filled plausibly; the rest of
|
|
84
|
+
// a REST API's request context (authorizer, the API key, the
|
|
85
|
+
// Cognito identity) describes a deployment this event has none of.
|
|
86
|
+
requestContext: {
|
|
87
|
+
accountId: "",
|
|
88
|
+
apiId: "lambder-local",
|
|
89
|
+
domainName: request.host,
|
|
90
|
+
httpMethod: request.method,
|
|
91
|
+
identity: { sourceIp: request.clientIp ?? "", userAgent: "lambder-local" },
|
|
92
|
+
path: request.path,
|
|
93
|
+
protocol: "HTTP/1.1",
|
|
94
|
+
requestId: randomRequestId(),
|
|
95
|
+
requestTimeEpoch: now,
|
|
96
|
+
resourcePath: "/{proxy+}",
|
|
97
|
+
stage: "local",
|
|
98
|
+
},
|
|
99
|
+
body: body ?? null,
|
|
100
|
+
isBase64Encoded: isBinary,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
76
103
|
return {
|
|
77
104
|
version: "2.0",
|
|
78
105
|
routeKey: "$default",
|
|
@@ -98,12 +125,10 @@ export const synthesizeLambdaHttpEvent = (request, options) => {
|
|
|
98
125
|
time: new Date(now).toISOString(),
|
|
99
126
|
timeEpoch: now,
|
|
100
127
|
},
|
|
101
|
-
...(
|
|
102
|
-
? { body: isBinary ? bytesToBase64(request.body) : request.body }
|
|
103
|
-
: {}),
|
|
128
|
+
...(body !== undefined ? { body } : {}),
|
|
104
129
|
isBase64Encoded: isBinary,
|
|
105
130
|
};
|
|
106
|
-
}
|
|
131
|
+
}
|
|
107
132
|
/**
|
|
108
133
|
* The body envelope LambderCaller sends, minus the fields only a browser has
|
|
109
134
|
* a value for, as JSON. A plain payload arrives already serialized (the
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Context } from "aws-lambda";
|
|
2
2
|
import type { LambderApiTransport } from "../shared/transport/LambderApiTransport.js";
|
|
3
3
|
import type { LambderHandler } from "../core/LambderCreateOptions.js";
|
|
4
|
+
import type { LambderHttpEventFormat } from "../core/LambderContext.js";
|
|
4
5
|
export type LambderHandlerTransportOptions = {
|
|
5
6
|
/** The Host the handler sees (ctx.host), and the siteHost the envelope carries when the caller has none. Default: the apiPath's own host when it is absolute, otherwise "localhost". */
|
|
6
7
|
host?: string;
|
|
@@ -10,6 +11,8 @@ export type LambderHandlerTransportOptions = {
|
|
|
10
11
|
maxResponseBytes?: number;
|
|
11
12
|
/** Fields of the Lambda context the handler receives. */
|
|
12
13
|
context?: Partial<Context>;
|
|
14
|
+
/** The gateway shape the handler is called with: "v2" (an HTTP API, a Function URL) or "v1" (a REST API). Default: "v2". A handler answers both alike; name the one your deployment delivers when the difference is what you are testing. */
|
|
15
|
+
eventFormat?: LambderHttpEventFormat;
|
|
13
16
|
};
|
|
14
17
|
/**
|
|
15
18
|
* A transport that calls a Lambder handler in this process, the way a
|
|
@@ -50,7 +50,7 @@ export const lambderHandlerTransport = (handler, options = {}) => {
|
|
|
50
50
|
clientIp: request.clientIp ?? clientIp,
|
|
51
51
|
cookies: request.cookies,
|
|
52
52
|
body: JSON.stringify(buildTransportEnvelope({ ...request, siteHost: request.siteHost || host })),
|
|
53
|
-
}, { invoke: false });
|
|
53
|
+
}, { invoke: false, eventFormat: options.eventFormat });
|
|
54
54
|
let result;
|
|
55
55
|
try {
|
|
56
56
|
result = await stopWaitingWhenAborted(handler(event, localLambdaContext("lambder-local", options.context)), request.signal);
|
package/dist/mock.d.ts
CHANGED
|
@@ -26,6 +26,8 @@ export { LambderWebCrypto, LambderPlainSessionCrypto } from "./session/LambderSe
|
|
|
26
26
|
export type { LambderSessionCrypto } from "./session/LambderSessionCrypto.js";
|
|
27
27
|
export { LambderApiRefusal, refuse, LAMBDER_REFUSAL_CODES } from "./shared/wire/LambderApiRefusal.js";
|
|
28
28
|
export type { LambderRefusalMessage } from "./shared/wire/LambderApiRefusal.js";
|
|
29
|
+
export { assertApiSuccess, assertApiFailure } from "./shared/wire/LambderOutcomeAssertions.js";
|
|
30
|
+
export type { LambderExpectedFailure } from "./shared/wire/LambderOutcomeAssertions.js";
|
|
29
31
|
export type { LambderApiRequest } from "./api/LambderApiRequest.js";
|
|
30
32
|
export type { LambderApiAnswer } from "./api/LambderApiAnswer.js";
|
|
31
33
|
export type { LambderSessionRecord } from "./shared/contracts/LambderSessionStore.js";
|
package/dist/mock.js
CHANGED
|
@@ -25,3 +25,6 @@ export { LambderMemoryRateLimiter } from "./stores/LambderMemoryRateLimiter.js";
|
|
|
25
25
|
export { LambderMemoryIdempotencyStore } from "./stores/LambderMemoryIdempotencyStore.js";
|
|
26
26
|
export { LambderWebCrypto, LambderPlainSessionCrypto } from "./session/LambderSessionCrypto.js";
|
|
27
27
|
export { LambderApiRefusal, refuse, LAMBDER_REFUSAL_CODES } from "./shared/wire/LambderApiRefusal.js";
|
|
28
|
+
// The outcome assertions a test over the mock app narrows with; the same two
|
|
29
|
+
// `lambder/testing` exports for a test over the real server.
|
|
30
|
+
export { assertApiSuccess, assertApiFailure } from "./shared/wire/LambderOutcomeAssertions.js";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { LambderSessionCrypto } from "./LambderSessionCrypto.js";
|
|
2
2
|
import type { LambderSessionRecord, LambderSessionStore } from "../shared/contracts/LambderSessionStore.js";
|
|
3
|
+
import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
|
|
3
4
|
/**
|
|
4
5
|
* A freshly created (or regenerated) session: the persisted record plus the
|
|
5
6
|
* RAW cookie secrets, which exist only here and in the cookies the caller
|
|
@@ -92,13 +93,23 @@ export declare const isMintedSessionToken: (token: string) => boolean;
|
|
|
92
93
|
* stored too.
|
|
93
94
|
*/
|
|
94
95
|
export default class LambderSessionManager<SessionData = any> {
|
|
95
|
-
|
|
96
|
+
/** Replaceable through the backend swap alone; see LAMBDER_BACKEND_SWAP. */
|
|
97
|
+
private store;
|
|
96
98
|
private readonly sessionSalt;
|
|
97
99
|
private readonly enableSlidingExpiration;
|
|
98
100
|
private readonly slidingWriteIntervalSeconds;
|
|
99
101
|
private readonly dataRefresh;
|
|
100
102
|
private readonly crypto;
|
|
101
103
|
constructor({ store, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, dataRefresh, crypto, }: LambderSessionManagerOptions<SessionData>);
|
|
104
|
+
/** A store this manager's crypto may sit in front of. Asked of every store it is given, the one at creation and a swapped one alike. */
|
|
105
|
+
private assertCryptoFitsStore;
|
|
106
|
+
/**
|
|
107
|
+
* Puts the manager over another store, for `lambder/testing`. The model
|
|
108
|
+
* (salt, tokens, expiry, dataRefresh) stays this manager's own, so a test
|
|
109
|
+
* runs the app's sessions as configured over a store that dies with the
|
|
110
|
+
* process. Sessions held by the store it leaves are simply out of reach.
|
|
111
|
+
*/
|
|
112
|
+
[LAMBDER_BACKEND_SWAP](store: LambderSessionStore<SessionData>): void;
|
|
102
113
|
/**
|
|
103
114
|
* The salted partition hash of a sessionKey: sha256 of the key followed
|
|
104
115
|
* by the salt, with NO separator between them.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { LambderWebCrypto } from "./LambderSessionCrypto.js";
|
|
2
2
|
import { coerceToError } from "../shared/wire/LambderCrashDetail.js";
|
|
3
3
|
import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
|
|
4
|
+
import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
|
|
4
5
|
/**
|
|
5
6
|
* Wraps errors thrown by the dataRefresh callback so they stay
|
|
6
7
|
* distinguishable from "no session": fetchSessionIfExists() swallows missing
|
|
@@ -79,6 +80,7 @@ export const isMintedSessionToken = (token) => {
|
|
|
79
80
|
* stored too.
|
|
80
81
|
*/
|
|
81
82
|
export default class LambderSessionManager {
|
|
83
|
+
/** Replaceable through the backend swap alone; see LAMBDER_BACKEND_SWAP. */
|
|
82
84
|
store;
|
|
83
85
|
sessionSalt;
|
|
84
86
|
enableSlidingExpiration;
|
|
@@ -96,14 +98,28 @@ export default class LambderSessionManager {
|
|
|
96
98
|
assertPositiveInteger(dataRefresh.ttlSeconds, "session.dataRefresh.ttlSeconds");
|
|
97
99
|
this.dataRefresh = dataRefresh ?? null;
|
|
98
100
|
this.crypto = crypto ?? new LambderWebCrypto();
|
|
101
|
+
this.assertCryptoFitsStore(store);
|
|
102
|
+
if (typeof sessionSalt !== "string" || sessionSalt.length === 0) {
|
|
103
|
+
throw new Error("Lambder: session sessionSalt is empty. It salts the hash that partitions the store, so it has to be a real, stable secret.");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** A store this manager's crypto may sit in front of. Asked of every store it is given, the one at creation and a swapped one alike. */
|
|
107
|
+
assertCryptoFitsStore(store) {
|
|
99
108
|
if (!this.crypto.isCryptographic && !store.isMemoryOnly) {
|
|
100
109
|
throw new Error("Lambder: this session crypto does not hash and does not draw cryptographically random bytes, " +
|
|
101
110
|
"so it may only sit in front of a store that dies with the process. Over a persistent store every " +
|
|
102
111
|
"record would be a usable credential and the sessionSalt would be readable from it.");
|
|
103
112
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Puts the manager over another store, for `lambder/testing`. The model
|
|
116
|
+
* (salt, tokens, expiry, dataRefresh) stays this manager's own, so a test
|
|
117
|
+
* runs the app's sessions as configured over a store that dies with the
|
|
118
|
+
* process. Sessions held by the store it leaves are simply out of reach.
|
|
119
|
+
*/
|
|
120
|
+
[LAMBDER_BACKEND_SWAP](store) {
|
|
121
|
+
this.assertCryptoFitsStore(store);
|
|
122
|
+
this.store = store;
|
|
107
123
|
}
|
|
108
124
|
/**
|
|
109
125
|
* The salted partition hash of a sessionKey: sha256 of the key followed
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The keys of the two doors `lambder/testing` opens on a built instance.
|
|
3
|
+
*
|
|
4
|
+
* Symbols rather than named methods, and exported by no entry point: the
|
|
5
|
+
* package's exports map is what a consumer can import, so only
|
|
6
|
+
* `lambder/testing` can name a key, and nothing on the typed surface of an
|
|
7
|
+
* instance offers either door to a serving app.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Puts other stores under the instance.
|
|
11
|
+
*
|
|
12
|
+
* An app is one module-level instance whose handlers and guards close over it
|
|
13
|
+
* (`app.getSessionController(ctx)`), so a test cannot be handed a copy over
|
|
14
|
+
* other stores: the copy's closures would still reach the original and the
|
|
15
|
+
* production table under it. The stores are therefore replaced in place, and
|
|
16
|
+
* every class that holds one answers to this key with a method that takes the
|
|
17
|
+
* replacement.
|
|
18
|
+
*/
|
|
19
|
+
export declare const LAMBDER_BACKEND_SWAP: unique symbol;
|
|
20
|
+
/**
|
|
21
|
+
* Watches what the instance throws while answering a request.
|
|
22
|
+
*
|
|
23
|
+
* A crash is answered by the app's global error handler, or by the framework's
|
|
24
|
+
* own 500, and either way the answer deliberately says nothing about what was
|
|
25
|
+
* thrown: that is right for a client and useless to a test, whose author needs
|
|
26
|
+
* the error and its stack. The watcher is handed the error and changes nothing
|
|
27
|
+
* about the answer, so what an app does with a crash stays testable.
|
|
28
|
+
*/
|
|
29
|
+
export declare const LAMBDER_CRASH_WATCH: unique symbol;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The keys of the two doors `lambder/testing` opens on a built instance.
|
|
3
|
+
*
|
|
4
|
+
* Symbols rather than named methods, and exported by no entry point: the
|
|
5
|
+
* package's exports map is what a consumer can import, so only
|
|
6
|
+
* `lambder/testing` can name a key, and nothing on the typed surface of an
|
|
7
|
+
* instance offers either door to a serving app.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Puts other stores under the instance.
|
|
11
|
+
*
|
|
12
|
+
* An app is one module-level instance whose handlers and guards close over it
|
|
13
|
+
* (`app.getSessionController(ctx)`), so a test cannot be handed a copy over
|
|
14
|
+
* other stores: the copy's closures would still reach the original and the
|
|
15
|
+
* production table under it. The stores are therefore replaced in place, and
|
|
16
|
+
* every class that holds one answers to this key with a method that takes the
|
|
17
|
+
* replacement.
|
|
18
|
+
*/
|
|
19
|
+
export const LAMBDER_BACKEND_SWAP = Symbol("lambder.backendSwap");
|
|
20
|
+
/**
|
|
21
|
+
* Watches what the instance throws while answering a request.
|
|
22
|
+
*
|
|
23
|
+
* A crash is answered by the app's global error handler, or by the framework's
|
|
24
|
+
* own 500, and either way the answer deliberately says nothing about what was
|
|
25
|
+
* thrown: that is right for a client and useless to a test, whose author needs
|
|
26
|
+
* the error and its stack. The watcher is handed the error and changes nothing
|
|
27
|
+
* about the answer, so what an app does with a crash stays testable.
|
|
28
|
+
*/
|
|
29
|
+
export const LAMBDER_CRASH_WATCH = Symbol("lambder.crashWatch");
|
|
@@ -85,6 +85,56 @@ export type LambderContractEntry<In, Out, Mode extends LambderApiMode, GuardInpu
|
|
|
85
85
|
export type LambderMergeContract<Old, Name extends string, Entry> = Old & {
|
|
86
86
|
[K in Name]: Entry;
|
|
87
87
|
};
|
|
88
|
+
/**
|
|
89
|
+
* The contract as one object type, for the `export interface` a consuming
|
|
90
|
+
* app declares its contract through:
|
|
91
|
+
*
|
|
92
|
+
* ```ts
|
|
93
|
+
* export interface ApiContractType extends LambderFlattenContract<typeof lambder.ApiContract> {}
|
|
94
|
+
* ```
|
|
95
|
+
*
|
|
96
|
+
* Chaining leaves the contract an intersection one member deep per endpoint
|
|
97
|
+
* (LambderMergeContract above), and every `C[K]` written against a type
|
|
98
|
+
* parameter then resolves the property across all of them. That lookup is
|
|
99
|
+
* the atom the reading helpers below are built from, so its cost is paid
|
|
100
|
+
* again by each of them, per endpoint, in every app that registers a mock,
|
|
101
|
+
* declares a needs map, or otherwise reads the contract generically: in a
|
|
102
|
+
* 182-endpoint app one indexed access measured ~3,000 type instantiations
|
|
103
|
+
* and one mock registration ~18,000.
|
|
104
|
+
*
|
|
105
|
+
* Extending an interface is what collapses it. An interface's members are
|
|
106
|
+
* declared, so they are resolved once for the whole declaration rather than
|
|
107
|
+
* per lookup, and the same access measured ~6 instantiations after the
|
|
108
|
+
* change: a 182-endpoint app's frontend type check went from 27.8M
|
|
109
|
+
* instantiations to 7.0M and from 20.2s to 10.6s of check time. The alias
|
|
110
|
+
* form (`type C = LambderFlattenContract<...>`) does NOT do this: a mapped
|
|
111
|
+
* type stays deferred and each lookup pays the full cost again, so the
|
|
112
|
+
* `interface ... extends` spelling is the point.
|
|
113
|
+
*
|
|
114
|
+
* Diagnostics are the same ones, and they read better: a message naming the
|
|
115
|
+
* contract prints the interface by name, where the intersection is printed
|
|
116
|
+
* as a truncated spill of entries.
|
|
117
|
+
*
|
|
118
|
+
* Every endpoint name must be a string literal for an interface to extend
|
|
119
|
+
* the result, which registration through addApi/addSessionApi guarantees.
|
|
120
|
+
*
|
|
121
|
+
* Two things quietly undo it, both of which look like tidying:
|
|
122
|
+
*
|
|
123
|
+
* - `@typescript-eslint/no-empty-object-type` reports the empty body as
|
|
124
|
+
* "equivalent to its supertype" and its fix is a type alias, which is the
|
|
125
|
+
* one spelling that does not collapse anything. Disable the rule on the
|
|
126
|
+
* line rather than taking the fix.
|
|
127
|
+
* - Extending anything but a mapped type loses the inferable index signature.
|
|
128
|
+
* An interface has none of its own, so a hand-written `interface C { ... }`
|
|
129
|
+
* is not assignable to LambderApiContractShape and is rejected by
|
|
130
|
+
* initLambderMock<C>, LambderCaller<C> and LambderInvokeCaller<C>;
|
|
131
|
+
* extending this mapped type is what keeps it. api-contract.test.ts pins
|
|
132
|
+
* that, along with the flattened contract being the same type member for
|
|
133
|
+
* member.
|
|
134
|
+
*/
|
|
135
|
+
export type LambderFlattenContract<C> = {
|
|
136
|
+
[K in keyof C]: C[K];
|
|
137
|
+
};
|
|
88
138
|
/** Guard names referenced by a guards option, whichever of its three forms is used. */
|
|
89
139
|
export type LambderGuardNamesIn<TOpt> = TOpt extends string ? TOpt : TOpt extends readonly (infer N extends string)[] ? N : TOpt extends object ? keyof TOpt & string : never;
|
|
90
140
|
/** The endpoint's mode; a contract written without one admits either. */
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two assertions over a call's outcome, for tests.
|
|
3
|
+
*
|
|
4
|
+
* An outcome is a discriminated union, so a test that expects a refusal has
|
|
5
|
+
* to narrow before it can read what the refusal carries, and the narrowing is
|
|
6
|
+
* the same three lines every time: check `ok`, branch on it, check `reason`.
|
|
7
|
+
* Written by hand, the failing case prints "expected false to be true" and
|
|
8
|
+
* says nothing about what actually came back, which is the one thing worth
|
|
9
|
+
* knowing when a call that should have been refused went through, or crashed
|
|
10
|
+
* instead.
|
|
11
|
+
*
|
|
12
|
+
* These narrow through an `asserts` signature, so the lines after one read
|
|
13
|
+
* the arm it proved, and they throw a plain Error naming what the outcome
|
|
14
|
+
* was. No test runner is imported: the same two functions serve vitest, jest
|
|
15
|
+
* and node:test, from `lambder/testing` over a real server and from
|
|
16
|
+
* `lambder/mock` over a mock one. Pure and dependency-free, like the outcome
|
|
17
|
+
* vocabulary they read.
|
|
18
|
+
*
|
|
19
|
+
* Typed structurally over `ok` and `reason` rather than over
|
|
20
|
+
* LambderApiOutcome, so a LambderInvokeOutcome, whose failure side names
|
|
21
|
+
* other reasons, is narrowed by the same functions and a misspelled reason is
|
|
22
|
+
* a compile error against whichever union was passed.
|
|
23
|
+
*/
|
|
24
|
+
/** What both callers' outcomes have in common: the discriminant, and a reason on the failure side. */
|
|
25
|
+
type LambderOutcomeShape = {
|
|
26
|
+
ok: true;
|
|
27
|
+
} | {
|
|
28
|
+
ok: false;
|
|
29
|
+
reason: string;
|
|
30
|
+
};
|
|
31
|
+
/** Every reason the failure side of an outcome union can carry. */
|
|
32
|
+
type LambderFailureReasonOf<TOutcome> = TOutcome extends {
|
|
33
|
+
ok: false;
|
|
34
|
+
reason: infer TReason;
|
|
35
|
+
} ? TReason : never;
|
|
36
|
+
/**
|
|
37
|
+
* The failure arms that can carry one of the given reasons, each narrowed to
|
|
38
|
+
* it. Per arm rather than through Extract: one arm may carry several reasons
|
|
39
|
+
* (`network`, `timeout`, `server` and `unknown` share theirs), and Extract
|
|
40
|
+
* would drop that arm for any single one of them.
|
|
41
|
+
*/
|
|
42
|
+
type LambderFailureWithReason<TOutcome, TReason> = TOutcome extends {
|
|
43
|
+
ok: false;
|
|
44
|
+
reason: infer TArmReason;
|
|
45
|
+
} ? [TReason & TArmReason] extends [never] ? never : TOutcome & {
|
|
46
|
+
reason: TReason & TArmReason;
|
|
47
|
+
} : never;
|
|
48
|
+
/** What else a failure is expected to carry, beside its reason. */
|
|
49
|
+
export type LambderExpectedFailure = {
|
|
50
|
+
/** The refusal's machine-readable code (`errorMessage.code`), e.g. a LAMBDER_REFUSAL_CODES value or the app's own. */
|
|
51
|
+
code?: string;
|
|
52
|
+
/** The HTTP status the answer came with. */
|
|
53
|
+
status?: number;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Asserts that a call succeeded, and narrows the outcome to its success arm,
|
|
57
|
+
* so `outcome.payload` reads directly on the next line.
|
|
58
|
+
*
|
|
59
|
+
* ```typescript
|
|
60
|
+
* const outcome = await visitor.apiOutcome("order.create", { sku });
|
|
61
|
+
* assertApiSuccess(outcome);
|
|
62
|
+
* expect(outcome.payload?.orderId).toBeDefined();
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export declare function assertApiSuccess<TOutcome extends LambderOutcomeShape>(outcome: TOutcome): asserts outcome is Extract<TOutcome, {
|
|
66
|
+
ok: true;
|
|
67
|
+
}>;
|
|
68
|
+
/**
|
|
69
|
+
* Asserts that a call failed, with the given reason when one is named, and
|
|
70
|
+
* narrows the outcome to the arms that reason can be, so what it carries
|
|
71
|
+
* (`zodError` after "validation", `response` after an envelope reason,
|
|
72
|
+
* `error` after the rest) reads directly on the next line.
|
|
73
|
+
*
|
|
74
|
+
* ```typescript
|
|
75
|
+
* assertApiFailure(await member.apiOutcome("org.delete", { id }), "notAuthorized");
|
|
76
|
+
* assertApiFailure(await guest.apiOutcome("signup", form), "errorMessage", { code: LAMBDER_REFUSAL_CODES.rateLimited, status: 429 });
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
export declare function assertApiFailure<TOutcome extends LambderOutcomeShape, TReason extends LambderFailureReasonOf<TOutcome> = LambderFailureReasonOf<TOutcome>>(outcome: TOutcome, reason?: TReason, expected?: LambderExpectedFailure): asserts outcome is LambderFailureWithReason<TOutcome, TReason>;
|
|
80
|
+
export {};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two assertions over a call's outcome, for tests.
|
|
3
|
+
*
|
|
4
|
+
* An outcome is a discriminated union, so a test that expects a refusal has
|
|
5
|
+
* to narrow before it can read what the refusal carries, and the narrowing is
|
|
6
|
+
* the same three lines every time: check `ok`, branch on it, check `reason`.
|
|
7
|
+
* Written by hand, the failing case prints "expected false to be true" and
|
|
8
|
+
* says nothing about what actually came back, which is the one thing worth
|
|
9
|
+
* knowing when a call that should have been refused went through, or crashed
|
|
10
|
+
* instead.
|
|
11
|
+
*
|
|
12
|
+
* These narrow through an `asserts` signature, so the lines after one read
|
|
13
|
+
* the arm it proved, and they throw a plain Error naming what the outcome
|
|
14
|
+
* was. No test runner is imported: the same two functions serve vitest, jest
|
|
15
|
+
* and node:test, from `lambder/testing` over a real server and from
|
|
16
|
+
* `lambder/mock` over a mock one. Pure and dependency-free, like the outcome
|
|
17
|
+
* vocabulary they read.
|
|
18
|
+
*
|
|
19
|
+
* Typed structurally over `ok` and `reason` rather than over
|
|
20
|
+
* LambderApiOutcome, so a LambderInvokeOutcome, whose failure side names
|
|
21
|
+
* other reasons, is narrowed by the same functions and a misspelled reason is
|
|
22
|
+
* a compile error against whichever union was passed.
|
|
23
|
+
*/
|
|
24
|
+
const MAX_DESCRIBED_VALUE_LENGTH = 300;
|
|
25
|
+
/** A value as it can be printed in an assertion message: JSON, cut short, never throwing over a cyclic one. */
|
|
26
|
+
const describeValue = (value) => {
|
|
27
|
+
let text;
|
|
28
|
+
try {
|
|
29
|
+
text = JSON.stringify(value) ?? String(value);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
text = String(value);
|
|
33
|
+
}
|
|
34
|
+
return text.length > MAX_DESCRIBED_VALUE_LENGTH ? `${text.slice(0, MAX_DESCRIBED_VALUE_LENGTH)}...` : text;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* The error a failure carries, which becomes the cause of the assertion's
|
|
38
|
+
* own: a test runner prints the chain, so an app that crashed under
|
|
39
|
+
* `lambder/testing` shows the handler's stack under the failed assertion.
|
|
40
|
+
*/
|
|
41
|
+
const errorOf = (outcome) => {
|
|
42
|
+
const error = outcome.error;
|
|
43
|
+
return error instanceof Error ? error : undefined;
|
|
44
|
+
};
|
|
45
|
+
/** One line saying what an outcome was, for the message of an assertion it failed. */
|
|
46
|
+
const describeOutcome = (outcome) => {
|
|
47
|
+
if (outcome.ok)
|
|
48
|
+
return `a success carrying ${describeValue(outcome.payload)}`;
|
|
49
|
+
const failure = outcome;
|
|
50
|
+
const details = [];
|
|
51
|
+
if (failure.status !== undefined)
|
|
52
|
+
details.push(`status ${failure.status}`);
|
|
53
|
+
if (failure.errorMessage !== undefined)
|
|
54
|
+
details.push(`errorMessage ${describeValue(failure.errorMessage)}`);
|
|
55
|
+
if (failure.zodError !== undefined)
|
|
56
|
+
details.push(`zodError ${describeValue(failure.zodError.message)}`);
|
|
57
|
+
// The error's own message, and its cause when it has one: an in-process
|
|
58
|
+
// transport reports a handler that threw as a failure whose cause is
|
|
59
|
+
// what actually threw, and that is the line a test author needs.
|
|
60
|
+
if (failure.error instanceof Error) {
|
|
61
|
+
const cause = failure.error.cause instanceof Error ? ` (cause: ${failure.error.cause.message})` : "";
|
|
62
|
+
details.push(`error "${failure.error.message}"${cause}`);
|
|
63
|
+
}
|
|
64
|
+
return `a failure with reason "${failure.reason}"${details.length ? `, ${details.join(", ")}` : ""}`;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Asserts that a call succeeded, and narrows the outcome to its success arm,
|
|
68
|
+
* so `outcome.payload` reads directly on the next line.
|
|
69
|
+
*
|
|
70
|
+
* ```typescript
|
|
71
|
+
* const outcome = await visitor.apiOutcome("order.create", { sku });
|
|
72
|
+
* assertApiSuccess(outcome);
|
|
73
|
+
* expect(outcome.payload?.orderId).toBeDefined();
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
export function assertApiSuccess(outcome) {
|
|
77
|
+
if (!outcome.ok)
|
|
78
|
+
throw new Error(`Expected the call to succeed, but it was ${describeOutcome(outcome)}.`, { cause: errorOf(outcome) });
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Asserts that a call failed, with the given reason when one is named, and
|
|
82
|
+
* narrows the outcome to the arms that reason can be, so what it carries
|
|
83
|
+
* (`zodError` after "validation", `response` after an envelope reason,
|
|
84
|
+
* `error` after the rest) reads directly on the next line.
|
|
85
|
+
*
|
|
86
|
+
* ```typescript
|
|
87
|
+
* assertApiFailure(await member.apiOutcome("org.delete", { id }), "notAuthorized");
|
|
88
|
+
* assertApiFailure(await guest.apiOutcome("signup", form), "errorMessage", { code: LAMBDER_REFUSAL_CODES.rateLimited, status: 429 });
|
|
89
|
+
* ```
|
|
90
|
+
*/
|
|
91
|
+
export function assertApiFailure(outcome, reason, expected = {}) {
|
|
92
|
+
const wanted = [
|
|
93
|
+
reason !== undefined ? `reason "${String(reason)}"` : null,
|
|
94
|
+
expected.code !== undefined ? `code "${expected.code}"` : null,
|
|
95
|
+
expected.status !== undefined ? `status ${expected.status}` : null,
|
|
96
|
+
].filter((part) => part !== null).join(", ");
|
|
97
|
+
const refuse = () => {
|
|
98
|
+
throw new Error(`Expected the call to fail${wanted ? ` with ${wanted}` : ""}, but it was ${describeOutcome(outcome)}.`, { cause: errorOf(outcome) });
|
|
99
|
+
};
|
|
100
|
+
if (outcome.ok)
|
|
101
|
+
return refuse();
|
|
102
|
+
if (reason !== undefined && outcome.reason !== reason)
|
|
103
|
+
return refuse();
|
|
104
|
+
if (expected.code !== undefined) {
|
|
105
|
+
// Only the structured errorMessage carries a code; a plain string has none to match.
|
|
106
|
+
const errorMessage = outcome.errorMessage;
|
|
107
|
+
const code = errorMessage && typeof errorMessage === "object" ? errorMessage.code : undefined;
|
|
108
|
+
if (code !== expected.code)
|
|
109
|
+
return refuse();
|
|
110
|
+
}
|
|
111
|
+
if (expected.status !== undefined && outcome.status !== expected.status)
|
|
112
|
+
return refuse();
|
|
113
|
+
}
|