lambder 7.2.5 → 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 +27 -0
- package/dist/core/Lambder.js +24 -0
- package/dist/core/LambderFiles.d.ts +7 -0
- package/dist/core/LambderFiles.js +12 -0
- 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/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
|
@@ -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");
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type { Context } from "aws-lambda";
|
|
2
|
+
import type Lambder from "../core/Lambder.js";
|
|
3
|
+
import { type LambderHttpEventFormat } from "../core/LambderContext.js";
|
|
4
|
+
import type { LambderHandler } from "../core/LambderCreateOptions.js";
|
|
5
|
+
import type LambderSessionManager from "../session/LambderSessionManager.js";
|
|
6
|
+
import type { LambderFileSource } from "../shared/contracts/LambderFileSource.js";
|
|
7
|
+
import type { LambderIdempotencyStore } from "../shared/contracts/LambderIdempotencyStore.js";
|
|
8
|
+
import type { LambderRateLimiter } from "../shared/contracts/LambderRateLimiter.js";
|
|
9
|
+
import type { LambderSessionStore } from "../shared/contracts/LambderSessionStore.js";
|
|
10
|
+
import type { LambderApiContractShape } from "../shared/wire/LambderApiContract.js";
|
|
11
|
+
import { LambderTestVisitor, type LambderTestVisitorArgs, type LambderTestVisitorOptions } from "./LambderTestVisitor.js";
|
|
12
|
+
/**
|
|
13
|
+
* What a test app may be told. Nothing is required: `lambderTestApp(lambder)`
|
|
14
|
+
* is a working test app over memory stores.
|
|
15
|
+
*
|
|
16
|
+
* The stores sit where create() takes them (`session.store`,
|
|
17
|
+
* `rateLimits.limiter`, `idempotency.store`, `files`), naming only the part a
|
|
18
|
+
* test replaces; everything else the app configured there stays in force.
|
|
19
|
+
*/
|
|
20
|
+
export type LambderTestAppOptions = {
|
|
21
|
+
/** The host visitors browse unless they name their own. Default: "localhost". An app that scopes its session cookie to a domain needs a host under it. */
|
|
22
|
+
host?: string;
|
|
23
|
+
/**
|
|
24
|
+
* The gateway shape the handler is called with: "v2" (an HTTP API, a
|
|
25
|
+
* Function URL) or "v1" (a REST API). Default: "v2". A handler answers
|
|
26
|
+
* both alike through its context, so this matters to code that reads the
|
|
27
|
+
* raw `ctx.event`, and to anyone who wants the suite to run on exactly
|
|
28
|
+
* what production delivers.
|
|
29
|
+
*/
|
|
30
|
+
eventFormat?: LambderHttpEventFormat;
|
|
31
|
+
/** Default: a fresh LambderMemorySessionStore. Pass your own to run the suite over another store (DynamoDB Local, say). */
|
|
32
|
+
session?: {
|
|
33
|
+
store?: LambderSessionStore<any>;
|
|
34
|
+
};
|
|
35
|
+
/** Default: a fresh LambderMemoryRateLimiter. */
|
|
36
|
+
rateLimits?: {
|
|
37
|
+
limiter?: LambderRateLimiter;
|
|
38
|
+
};
|
|
39
|
+
/** Default: a fresh LambderMemoryIdempotencyStore. */
|
|
40
|
+
idempotency?: {
|
|
41
|
+
store?: LambderIdempotencyStore;
|
|
42
|
+
};
|
|
43
|
+
/** Default: the app's own source, which suits one that reads a local folder. Pass a LambderLocalFileSource over fixtures for an app whose production source is S3 or HTTP. */
|
|
44
|
+
files?: LambderFileSource;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* A Lambder instance as a test app takes it: any instance, read for its
|
|
48
|
+
* session data type and, through the ApiContract property rather than the
|
|
49
|
+
* class parameter, for its contract. The property is what lets a large app
|
|
50
|
+
* name its flattened contract interface explicitly
|
|
51
|
+
* (`lambderTestApp<SessionData, ApiContractType>(lambder)`) and keep the
|
|
52
|
+
* cheap type check that interface exists for.
|
|
53
|
+
*/
|
|
54
|
+
export type LambderTestedInstance<TSessionData, TContract> = Lambder<TSessionData, any, any, any, any, any, any, any> & {
|
|
55
|
+
readonly ApiContract: TContract;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* A real Lambder app under test: the instance an app already has, with memory
|
|
59
|
+
* stores put under it in place, and as many simulated browsers in front of it
|
|
60
|
+
* as a test needs. No HTTP, no AWS, and nothing in the app restructured.
|
|
61
|
+
*
|
|
62
|
+
* The app's own declarations all run as written: its guards, its named
|
|
63
|
+
* rate-limit policies, its idempotency settings, its session salt, cookie
|
|
64
|
+
* options and dataRefresh, its hooks and error handlers. Only where things
|
|
65
|
+
* rest is replaced, and from the moment this is created the stores the app
|
|
66
|
+
* was configured with are out of the instance's reach, so a test cannot touch
|
|
67
|
+
* a production table even by mistake. What the app reaches on its own (its
|
|
68
|
+
* database, a mailer) is the app's to replace.
|
|
69
|
+
*
|
|
70
|
+
* The sibling of LambderMockApp, which serves a contract from mock handlers:
|
|
71
|
+
* the same verbs (`signIn`, `signOut`, `expireSessionData`, `reset`) over the
|
|
72
|
+
* real handlers instead.
|
|
73
|
+
*
|
|
74
|
+
* Time is not this class's: fake `Date` with the test runner
|
|
75
|
+
* (`vi.useFakeTimers({ toFake: ["Date"] })`), which moves the framework, the
|
|
76
|
+
* memory stores and the app's own handlers together.
|
|
77
|
+
*
|
|
78
|
+
* One test app per instance: a second one puts its own stores under the same
|
|
79
|
+
* instance and takes over its crash watch, so the first stops seeing either.
|
|
80
|
+
*/
|
|
81
|
+
export declare class LambderTestApp<TContract extends LambderApiContractShape = any, TSessionData = any> {
|
|
82
|
+
/** The instance's handler: what `event()` and every visitor call. */
|
|
83
|
+
readonly handler: LambderHandler;
|
|
84
|
+
/** The host visitors browse unless they name their own. */
|
|
85
|
+
readonly host: string;
|
|
86
|
+
/** The session store now under the instance, for assertions; null when the app has no sessions. A LambderMemorySessionStore unless one was given. */
|
|
87
|
+
readonly sessionStore: LambderSessionStore<TSessionData> | null;
|
|
88
|
+
/** The rate limiter now under the instance; null when the app declares no rate limits. */
|
|
89
|
+
readonly rateLimiter: LambderRateLimiter | null;
|
|
90
|
+
/** The idempotency store now under the instance; null when the app has no idempotency. */
|
|
91
|
+
readonly idempotencyStore: LambderIdempotencyStore | null;
|
|
92
|
+
private readonly lambder;
|
|
93
|
+
private readonly wiring;
|
|
94
|
+
/** The memory stores this test app made itself, which reset() empties. One the test supplied is the test's: nothing here knows what else holds it. */
|
|
95
|
+
private readonly ownStores;
|
|
96
|
+
private visitorCount;
|
|
97
|
+
private resetCount;
|
|
98
|
+
private readonly crashList;
|
|
99
|
+
/**
|
|
100
|
+
* The call a crash happened under. The app answers a crash with a 500
|
|
101
|
+
* that says nothing about it, so the error has to travel beside the
|
|
102
|
+
* answer, and with calls running concurrently (a duplicate sent while
|
|
103
|
+
* the original is in flight is an ordinary idempotency test) only the
|
|
104
|
+
* async context says which call a crash belongs to.
|
|
105
|
+
*/
|
|
106
|
+
private readonly crashScope;
|
|
107
|
+
constructor(lambder: LambderTestedInstance<TSessionData, TContract>, options?: LambderTestAppOptions);
|
|
108
|
+
/**
|
|
109
|
+
* Every error the app threw while answering a request since the last
|
|
110
|
+
* reset, in order: what reached its global error handler, or the
|
|
111
|
+
* framework's last-resort 500. The answers themselves say nothing about
|
|
112
|
+
* what was thrown, so this is where a test reads it, and
|
|
113
|
+
* `expect(app.crashes).toEqual([])` is how one says nothing crashed.
|
|
114
|
+
* A refusal is not a crash, and neither is an error an `event()` rejects
|
|
115
|
+
* with, which the test already holds.
|
|
116
|
+
*/
|
|
117
|
+
get crashes(): readonly Error[];
|
|
118
|
+
/** The session manager, for tests that inspect or manipulate sessions directly. Throws when the app has no sessions. */
|
|
119
|
+
get sessionManager(): LambderSessionManager<TSessionData>;
|
|
120
|
+
/**
|
|
121
|
+
* A new simulated browser: its own cookie jar, and its own address unless
|
|
122
|
+
* one is given. A stranger until it signs in, through the app's own login
|
|
123
|
+
* API or through `signIn`.
|
|
124
|
+
*/
|
|
125
|
+
visitor<TProvidedGuards extends string = never>(...[options]: LambderTestVisitorArgs<LambderTestVisitorOptions<TContract, TProvidedGuards>, TProvidedGuards>): LambderTestVisitor<TContract, TSessionData, TProvidedGuards>;
|
|
126
|
+
/**
|
|
127
|
+
* A new visitor, already signed in: `visitor()` followed by its
|
|
128
|
+
* `signIn()`. The session is minted by the app's own session model, so
|
|
129
|
+
* no login endpoint has to exist or be called.
|
|
130
|
+
*
|
|
131
|
+
* ```typescript
|
|
132
|
+
* const admin = await app.signIn("user:ada", { userId: "ada", role: "admin" });
|
|
133
|
+
* expect(await admin.api("org.rename", { name })).toEqual({ ok: true });
|
|
134
|
+
* ```
|
|
135
|
+
*/
|
|
136
|
+
signIn<TProvidedGuards extends string = never>(sessionKey: string, data: TSessionData, ...[options]: LambderTestVisitorArgs<LambderTestVisitorOptions<TContract, TProvidedGuards> & {
|
|
137
|
+
ttlSeconds?: number;
|
|
138
|
+
}, TProvidedGuards>): Promise<LambderTestVisitor<TContract, TSessionData, TProvidedGuards>>;
|
|
139
|
+
/**
|
|
140
|
+
* Ends every session of the subject, the way "log out everywhere" does.
|
|
141
|
+
* A visitor signed in as that subject keeps its cookies, as a browser
|
|
142
|
+
* would, so its next call is what a real one's would be: answered
|
|
143
|
+
* sessionExpired, with the stale cookies evicted.
|
|
144
|
+
*/
|
|
145
|
+
signOut(sessionKey: string): Promise<void>;
|
|
146
|
+
/** Marks the subject's session data stale, so the next read renews it through the app's dataRefresh. */
|
|
147
|
+
expireSessionData(sessionKey: string): Promise<void>;
|
|
148
|
+
/**
|
|
149
|
+
* Hands the handler an event that is not an HTTP request (a schedule, an
|
|
150
|
+
* SNS or SQS delivery), which is how an addAction handler runs, with a
|
|
151
|
+
* Lambda context filled in. Resolves to whatever the action returned.
|
|
152
|
+
*
|
|
153
|
+
* ```typescript
|
|
154
|
+
* await app.event({ source: "aws.events", "detail-type": "Scheduled Event" });
|
|
155
|
+
* ```
|
|
156
|
+
*/
|
|
157
|
+
event(event: unknown, context?: Partial<Context>): Promise<unknown>;
|
|
158
|
+
/**
|
|
159
|
+
* Rewinds what accumulated: sessions, rate-limit counters and replay
|
|
160
|
+
* records in the stores this test app made, the crashes it recorded, and
|
|
161
|
+
* the cookies of every visitor it created (each empties its jar the next
|
|
162
|
+
* time it is used). For a beforeEach. The app's own data (its database)
|
|
163
|
+
* is the app's to rewind.
|
|
164
|
+
*/
|
|
165
|
+
reset(): void;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Puts a built Lambder instance under test. See LambderTestApp.
|
|
169
|
+
*
|
|
170
|
+
* ```typescript
|
|
171
|
+
* import { lambderTestApp } from "lambder/testing";
|
|
172
|
+
* import { lambder } from "../src/index.js";
|
|
173
|
+
*
|
|
174
|
+
* const app = lambderTestApp(lambder);
|
|
175
|
+
* beforeEach(() => app.reset());
|
|
176
|
+
* ```
|
|
177
|
+
*/
|
|
178
|
+
export declare const lambderTestApp: <TSessionData = any, TContract extends LambderApiContractShape = any>(lambder: LambderTestedInstance<TSessionData, TContract>, options?: LambderTestAppOptions) => LambderTestApp<TContract, TSessionData>;
|