lambder 6.0.2 → 7.0.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 +2316 -0
- package/README.md +60 -33
- package/dist/api/LambderApiAnswer.d.ts +40 -0
- package/dist/api/LambderApiAnswer.js +19 -0
- package/dist/api/LambderApiCallContext.d.ts +38 -0
- package/dist/api/LambderApiCallContext.js +13 -0
- package/dist/api/LambderApiDefinition.d.ts +18 -0
- package/dist/api/LambderApiDefinition.js +1 -0
- package/dist/api/LambderApiEnvelope.d.ts +67 -0
- package/dist/api/LambderApiEnvelope.js +180 -0
- package/dist/api/LambderApiGuards.d.ts +302 -0
- package/dist/api/LambderApiGuards.js +134 -0
- package/dist/api/LambderApiIdempotency.d.ts +122 -0
- package/dist/api/LambderApiIdempotency.js +330 -0
- package/dist/api/LambderApiPipeline.d.ts +134 -0
- package/dist/api/LambderApiPipeline.js +221 -0
- package/dist/api/LambderApiPolicyEngine.d.ts +36 -0
- package/dist/api/LambderApiPolicyEngine.js +77 -0
- package/dist/api/LambderApiRateLimits.d.ts +206 -0
- package/dist/api/LambderApiRateLimits.js +239 -0
- package/dist/api/LambderApiRequest.d.ts +101 -0
- package/dist/api/LambderApiRequest.js +129 -0
- package/dist/api/LambderApiValidationRefusal.d.ts +32 -0
- package/dist/api/LambderApiValidationRefusal.js +40 -0
- package/dist/client/LambderCaller.d.ts +62 -55
- package/dist/client/LambderCaller.js +147 -90
- package/dist/client/lambderFetchTransport.d.ts +9 -0
- package/dist/client/lambderFetchTransport.js +71 -0
- package/dist/client.d.ts +20 -10
- package/dist/client.js +11 -5
- package/dist/core/Lambder.d.ts +117 -253
- package/dist/core/Lambder.js +374 -341
- package/dist/core/LambderContext.d.ts +54 -44
- package/dist/core/LambderContext.js +41 -110
- package/dist/core/LambderCreateOptions.d.ts +285 -0
- package/dist/core/LambderCreateOptions.js +44 -0
- package/dist/core/LambderFiles.d.ts +1 -45
- package/dist/core/LambderFiles.js +18 -38
- package/dist/core/LambderIndexHtml.d.ts +37 -0
- package/dist/core/LambderIndexHtml.js +87 -0
- package/dist/core/LambderPolicyBuilders.d.ts +17 -0
- package/dist/core/LambderPolicyBuilders.js +16 -0
- package/dist/core/LambderPublicFiles.d.ts +5 -2
- package/dist/core/LambderPublicFiles.js +7 -2
- package/dist/core/LambderResolver.d.ts +8 -6
- package/dist/core/LambderResponse.d.ts +29 -11
- package/dist/core/LambderResponse.js +96 -49
- package/dist/core/LambderResponseBuilder.d.ts +18 -14
- package/dist/core/LambderResponseBuilder.js +19 -25
- package/dist/core/LambderRouting.d.ts +18 -7
- package/dist/core/LambderRouting.js +17 -7
- package/dist/core/LambderTemplatingEngine.d.ts +0 -62
- package/dist/core/LambderTemplatingEngine.js +7 -3
- package/dist/index.d.ts +85 -32
- package/dist/index.js +44 -16
- package/dist/invoke/LambderInvokeCaller.d.ts +46 -139
- package/dist/invoke/LambderInvokeCaller.js +140 -335
- package/dist/invoke/LambderInvokeOutcome.d.ts +165 -0
- package/dist/invoke/LambderInvokeOutcome.js +129 -0
- package/dist/invoke/LambderLambdaEvent.d.ts +81 -0
- package/dist/invoke/LambderLambdaEvent.js +187 -0
- package/dist/invoke/lambderHandlerTransport.d.ts +36 -0
- package/dist/invoke/lambderHandlerTransport.js +89 -0
- package/dist/mock/LambderMockApp.d.ts +352 -0
- package/dist/mock/LambderMockApp.js +815 -0
- package/dist/mock/LambderMockBrowserCookies.d.ts +55 -0
- package/dist/mock/LambderMockBrowserCookies.js +76 -0
- package/dist/mock/LambderMockCallRecorder.d.ts +85 -0
- package/dist/mock/LambderMockCallRecorder.js +183 -0
- package/dist/mock/LambderMockCreateOptions.d.ts +161 -0
- package/dist/mock/LambderMockCreateOptions.js +9 -0
- package/dist/mock/LambderMockEntryRegistry.d.ts +52 -0
- package/dist/mock/LambderMockEntryRegistry.js +126 -0
- package/dist/mock/LambderMockFailureInjector.d.ts +60 -0
- package/dist/mock/LambderMockFailureInjector.js +138 -0
- package/dist/mock/LambderMockTypes.d.ts +421 -0
- package/dist/mock/LambderMockTypes.js +8 -0
- package/dist/mock/lambderMockConsoleLogger.d.ts +16 -0
- package/dist/mock/lambderMockConsoleLogger.js +35 -0
- package/dist/mock/lambderMockInvokeTransport.d.ts +50 -0
- package/dist/mock/lambderMockInvokeTransport.js +52 -0
- package/dist/mock/lambderMockMswHandler.d.ts +99 -0
- package/dist/mock/lambderMockMswHandler.js +126 -0
- package/dist/mock.d.ts +34 -0
- package/dist/mock.js +27 -0
- package/dist/session/LambderSessionController.d.ts +199 -30
- package/dist/session/LambderSessionController.js +396 -82
- package/dist/session/LambderSessionCrypto.d.ts +66 -0
- package/dist/session/LambderSessionCrypto.js +101 -0
- package/dist/session/LambderSessionManager.d.ts +118 -80
- package/dist/session/LambderSessionManager.js +212 -184
- package/dist/shared/LambderI18n.d.ts +6 -6
- package/dist/shared/LambderI18n.js +1 -1
- package/dist/shared/contracts/LambderFileSource.d.ts +33 -0
- package/dist/shared/contracts/LambderFileSource.js +19 -0
- package/dist/shared/contracts/LambderIdempotencyStore.d.ts +66 -0
- package/dist/shared/contracts/LambderIdempotencyStore.js +12 -0
- package/dist/shared/contracts/LambderRateLimiter.d.ts +71 -0
- package/dist/shared/contracts/LambderRateLimiter.js +24 -0
- package/dist/shared/contracts/LambderSessionStore.d.ts +72 -0
- package/dist/shared/contracts/LambderSessionStore.js +13 -0
- package/dist/shared/transport/LambderApiTransport.d.ts +139 -0
- package/dist/shared/transport/LambderApiTransport.js +65 -0
- package/dist/shared/transport/LambderCookieJar.d.ts +121 -0
- package/dist/shared/transport/LambderCookieJar.js +246 -0
- package/dist/shared/transport/lambderCookieJarTransport.d.ts +30 -0
- package/dist/shared/transport/lambderCookieJarTransport.js +60 -0
- package/dist/shared/util/LambderBase64.d.ts +10 -0
- package/dist/shared/util/LambderBase64.js +27 -0
- package/dist/shared/util/LambderCallAbort.d.ts +62 -0
- package/dist/shared/util/LambderCallAbort.js +80 -0
- package/dist/shared/util/LambderClientIp.d.ts +32 -0
- package/dist/shared/util/LambderClientIp.js +56 -0
- package/dist/shared/util/LambderExpiringMap.d.ts +119 -0
- package/dist/shared/util/LambderExpiringMap.js +217 -0
- package/dist/shared/util/LambderKeyFields.d.ts +32 -0
- package/dist/shared/util/LambderKeyFields.js +34 -0
- package/dist/shared/util/LambderNodeModules.d.ts +9 -0
- package/dist/shared/util/LambderNodeModules.js +39 -0
- package/dist/shared/util/LambderOptionChecks.d.ts +17 -0
- package/dist/shared/util/LambderOptionChecks.js +33 -0
- package/dist/shared/util/LambderResponseBrand.d.ts +20 -0
- package/dist/shared/util/LambderResponseBrand.js +18 -0
- package/dist/shared/util/LambderTextDigest.d.ts +17 -0
- package/dist/shared/util/LambderTextDigest.js +34 -0
- package/dist/shared/util/LambderTypeUtilities.d.ts +33 -0
- package/dist/shared/util/LambderTypeUtilities.js +8 -0
- package/dist/shared/wire/LambderAnswerHeaders.d.ts +60 -0
- package/dist/shared/wire/LambderAnswerHeaders.js +94 -0
- package/dist/shared/wire/LambderApiContract.d.ts +129 -0
- package/dist/shared/wire/LambderApiOptionValues.d.ts +39 -0
- package/dist/shared/wire/LambderApiOptionValues.js +11 -0
- package/dist/shared/wire/LambderApiOutcome.d.ts +128 -0
- package/dist/shared/{LambderApiOutcome.js → wire/LambderApiOutcome.js} +16 -9
- package/dist/shared/{LambderApiError.d.ts → wire/LambderApiRefusal.d.ts} +48 -26
- package/dist/shared/{LambderApiError.js → wire/LambderApiRefusal.js} +13 -11
- package/dist/shared/wire/LambderCallOptions.d.ts +171 -0
- package/dist/shared/wire/LambderCallOptions.js +17 -0
- package/dist/shared/{LambderCompressionCodec.d.ts → wire/LambderCompressionCodec.d.ts} +10 -6
- package/dist/shared/{LambderCompressionCodec.js → wire/LambderCompressionCodec.js} +67 -23
- package/dist/shared/{LambderCompressionOption.d.ts → wire/LambderCompressionOption.d.ts} +1 -1
- package/dist/shared/{LambderCompressionOption.js → wire/LambderCompressionOption.js} +3 -4
- package/dist/shared/{LambderCrashDetail.d.ts → wire/LambderCrashDetail.d.ts} +10 -0
- package/dist/shared/{LambderCrashDetail.js → wire/LambderCrashDetail.js} +30 -0
- package/dist/shared/wire/LambderHttpStatus.d.ts +12 -0
- package/dist/shared/wire/LambderHttpStatus.js +1 -0
- package/dist/shared/{LambderRequestPayload.d.ts → wire/LambderRequestPayload.d.ts} +25 -17
- package/dist/shared/{LambderRequestPayload.js → wire/LambderRequestPayload.js} +29 -52
- package/dist/shared/wire/LambderSessionCookieNames.d.ts +9 -0
- package/dist/shared/wire/LambderSessionCookieNames.js +9 -0
- package/dist/stores/LambderDdbCache.d.ts +12 -9
- package/dist/stores/LambderDdbCache.js +56 -47
- package/dist/stores/{LambderDdbIdempotency.d.ts → LambderDdbIdempotencyStore.d.ts} +41 -31
- package/dist/stores/LambderDdbIdempotencyStore.js +319 -0
- package/dist/stores/LambderDdbRateLimiter.d.ts +30 -49
- package/dist/stores/LambderDdbRateLimiter.js +47 -45
- package/dist/stores/LambderDdbSdk.d.ts +83 -6
- package/dist/stores/LambderDdbSdk.js +83 -2
- package/dist/stores/LambderDdbSessionStore.d.ts +65 -0
- package/dist/stores/LambderDdbSessionStore.js +161 -0
- package/dist/stores/LambderHttpFileSource.d.ts +1 -1
- package/dist/stores/LambderHttpFileSource.js +10 -1
- package/dist/stores/LambderLocalFileSource.d.ts +15 -0
- package/dist/stores/LambderLocalFileSource.js +28 -0
- package/dist/stores/LambderMemoryIdempotencyStore.d.ts +63 -0
- package/dist/stores/LambderMemoryIdempotencyStore.js +113 -0
- package/dist/stores/LambderMemoryRateLimiter.d.ts +34 -0
- package/dist/stores/LambderMemoryRateLimiter.js +64 -0
- package/dist/stores/LambderMemorySessionStore.d.ts +48 -0
- package/dist/stores/LambderMemorySessionStore.js +74 -0
- package/dist/stores/LambderS3FileSource.d.ts +1 -1
- package/dist/stores/LambderS3FileSource.js +1 -1
- package/package.json +21 -19
- package/dist/client/LambderMSW.d.ts +0 -69
- package/dist/client/LambderMSW.js +0 -121
- package/dist/policies/LambderApiGuards.d.ts +0 -256
- package/dist/policies/LambderApiGuards.js +0 -94
- package/dist/policies/LambderApiIdempotency.d.ts +0 -58
- package/dist/policies/LambderApiIdempotency.js +0 -219
- package/dist/policies/LambderApiPolicies.d.ts +0 -42
- package/dist/policies/LambderApiPolicies.js +0 -52
- package/dist/policies/LambderApiRateLimits.d.ts +0 -132
- package/dist/policies/LambderApiRateLimits.js +0 -119
- package/dist/shared/LambderApiContract.d.ts +0 -57
- package/dist/shared/LambderApiOutcome.d.ts +0 -69
- package/dist/shared/LambderCallOptions.d.ts +0 -71
- package/dist/shared/LambderCallOptions.js +0 -16
- package/dist/shared/node-polyfills.d.ts +0 -4
- package/dist/shared/node-polyfills.js +0 -58
- package/dist/stores/LambderDdbIdempotency.js +0 -229
- package/dist/testing.d.ts +0 -9
- package/dist/testing.js +0 -8
- /package/dist/shared/{LambderApiContract.js → wire/LambderApiContract.js} +0 -0
- /package/dist/{core → shared/wire}/LambderCookie.d.ts +0 -0
- /package/dist/{core → shared/wire}/LambderCookie.js +0 -0
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
import { LambderApiPipeline } from "../api/LambderApiPipeline.js";
|
|
2
|
+
import { readApiEnvelope, cookieValuesByName, lowercaseHeaderNames } from "../api/LambderApiRequest.js";
|
|
3
|
+
import { createApiCallContext } from "../api/LambderApiCallContext.js";
|
|
4
|
+
import { toHttpAnswer } from "../api/LambderApiAnswer.js";
|
|
5
|
+
import { getAnswerHeader } from "../shared/wire/LambderAnswerHeaders.js";
|
|
6
|
+
import { buildApiEnvelope, envelopeAnswer, crashAnswer, } from "../api/LambderApiEnvelope.js";
|
|
7
|
+
import { LambderApiRefusal, LAMBDER_REFUSAL_CODES } from "../shared/wire/LambderApiRefusal.js";
|
|
8
|
+
import { coerceToError } from "../shared/wire/LambderCrashDetail.js";
|
|
9
|
+
import { buildTransportEnvelope } from "../shared/transport/LambderApiTransport.js";
|
|
10
|
+
import { lambderCookieJarTransport } from "../shared/transport/lambderCookieJarTransport.js";
|
|
11
|
+
import { LambderCookieJar } from "../shared/transport/LambderCookieJar.js";
|
|
12
|
+
import { serializeClearCookie } from "../shared/wire/LambderCookie.js";
|
|
13
|
+
import { LOOPBACK_CLIENT_IP, normalizeClientIp } from "../shared/util/LambderClientIp.js";
|
|
14
|
+
import { lambderGuardBuilder } from "../api/LambderApiGuards.js";
|
|
15
|
+
import { lambderRateLimitKeyBuilder } from "../api/LambderApiRateLimits.js";
|
|
16
|
+
import { LambderMockFailureInjector, LambderMockTransportError } from "./LambderMockFailureInjector.js";
|
|
17
|
+
import { LambderMockCallRecorder } from "./LambderMockCallRecorder.js";
|
|
18
|
+
import { LambderMockEntryRegistry } from "./LambderMockEntryRegistry.js";
|
|
19
|
+
import { LambderMockBrowserCookies } from "./LambderMockBrowserCookies.js";
|
|
20
|
+
import { LambderMemoryRateLimiter } from "../stores/LambderMemoryRateLimiter.js";
|
|
21
|
+
import { LambderMemoryIdempotencyStore } from "../stores/LambderMemoryIdempotencyStore.js";
|
|
22
|
+
import { LambderMemorySessionStore } from "../stores/LambderMemorySessionStore.js";
|
|
23
|
+
import LambderSessionManager from "../session/LambderSessionManager.js";
|
|
24
|
+
import { isWebCryptoAvailable, LambderPlainSessionCrypto } from "../session/LambderSessionCrypto.js";
|
|
25
|
+
import { DEFAULT_SESSION_CSRF_COOKIE_KEY, DEFAULT_SESSION_TOKEN_COOKIE_KEY } from "../shared/wire/LambderSessionCookieNames.js";
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Construction
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
/** The transport with its jar attached, which is what makes the jar reachable without a second creation call. */
|
|
30
|
+
const transportCarrying = (transport, jar) => Object.assign(transport, { cookieJar: jar });
|
|
31
|
+
const DEFAULT_CALL_LOG_SIZE = 200;
|
|
32
|
+
const DEFAULT_SESSION_TTL_SECONDS = 30 * 24 * 60 * 60;
|
|
33
|
+
/**
|
|
34
|
+
* The host the runtime's cookies belong to when the app names none: the page
|
|
35
|
+
* the mock is running in, or "localhost" where there is no page (a Node test).
|
|
36
|
+
*/
|
|
37
|
+
const defaultCookieHost = () => globalThis.location?.host || "localhost";
|
|
38
|
+
/**
|
|
39
|
+
* The mock runtime: the API core (LambderApiPipeline, the same class the
|
|
40
|
+
* Lambda server runs) over memory stores, with a registry of typed mock
|
|
41
|
+
* handlers where the server has app handlers, and mock guards where it has
|
|
42
|
+
* app guards. Everything the protocol does (envelope, refusals, sessions
|
|
43
|
+
* and their cookies, guards, rate limits, idempotency, the version gate)
|
|
44
|
+
* happens in the core; this class only resolves a name to an entry, wraps
|
|
45
|
+
* the handler's return into the envelope, and adds what a mock needs on
|
|
46
|
+
* top: failure injection, latency, a subscription, a call log, reset.
|
|
47
|
+
*
|
|
48
|
+
* Create one with initLambderMock<Contract, SessionData>().create(...),
|
|
49
|
+
* which fixes the contract and session types first so everything else is
|
|
50
|
+
* inferred from the options.
|
|
51
|
+
*/
|
|
52
|
+
export class LambderMockApp {
|
|
53
|
+
apiVersion;
|
|
54
|
+
/** The memory stores, for assertions and reset; null for a subsystem that is off or backed by a store of yours. */
|
|
55
|
+
sessionStore;
|
|
56
|
+
rateLimiter;
|
|
57
|
+
idempotencyStore;
|
|
58
|
+
tokenCookieKey;
|
|
59
|
+
csrfCookieKey;
|
|
60
|
+
/**
|
|
61
|
+
* The client IP a transport request carrying none is read as. Public
|
|
62
|
+
* because an adapter has to read the same default the direct transport
|
|
63
|
+
* uses: the MSW adapter had a hardcoded "127.0.0.1" of its own, so an app
|
|
64
|
+
* that set defaultClientIp saw one address through the transport and
|
|
65
|
+
* another through the service worker, and a per-IP rate limit counted two
|
|
66
|
+
* clients where there was one.
|
|
67
|
+
*/
|
|
68
|
+
defaultClientIp;
|
|
69
|
+
/** The host this runtime's cookies belong to (see the cookieHost option). */
|
|
70
|
+
cookieHost;
|
|
71
|
+
/**
|
|
72
|
+
* The API core, every protocol step of it. Private: the mock's surface is
|
|
73
|
+
* the app, and a consumer reaching past it would be configuring the
|
|
74
|
+
* server's pipeline through a development tool. The three adapters take
|
|
75
|
+
* what they need from the app's own methods (handleRequest,
|
|
76
|
+
* requestFromTransport), which is why none of them names this.
|
|
77
|
+
*/
|
|
78
|
+
pipeline;
|
|
79
|
+
/** The cookie scope signIn plants under, so signOut can name the same one when it clears them. */
|
|
80
|
+
sessionCookieOptions;
|
|
81
|
+
sessionTtlSeconds;
|
|
82
|
+
onReset;
|
|
83
|
+
revealHandlerErrors;
|
|
84
|
+
/** Injected failures, the offline switch and the configured latency (see LambderMockFailureInjector). */
|
|
85
|
+
failures;
|
|
86
|
+
/** Subscriptions and the bounded call log (see LambderMockCallRecorder). */
|
|
87
|
+
recorder;
|
|
88
|
+
/** Registered entries and the overrides over them (see LambderMockEntryRegistry). */
|
|
89
|
+
registry = new LambderMockEntryRegistry();
|
|
90
|
+
/** The jars the runtime owns and what it planted in document.cookie (see LambderMockBrowserCookies). */
|
|
91
|
+
browserCookies = new LambderMockBrowserCookies();
|
|
92
|
+
constructor(options) {
|
|
93
|
+
this.apiVersion = options.apiVersion ?? null;
|
|
94
|
+
this.failures = new LambderMockFailureInjector({ apiVersion: this.apiVersion, latency: options.latency ?? 0 });
|
|
95
|
+
this.recorder = new LambderMockCallRecorder({ callLogSize: options.callLogSize ?? DEFAULT_CALL_LOG_SIZE });
|
|
96
|
+
// The loopback address when nothing names a client, as a request from
|
|
97
|
+
// the page itself, and the same constant the in-process handler
|
|
98
|
+
// transport defaults to. Normalized here rather than at every reader:
|
|
99
|
+
// one textual form per address is what makes a per-IP rate limit's
|
|
100
|
+
// counter one counter, and this value is read by the direct transport,
|
|
101
|
+
// by the MSW adapter and by every request that names no client.
|
|
102
|
+
this.defaultClientIp = normalizeClientIp(options.defaultClientIp ?? LOOPBACK_CLIENT_IP);
|
|
103
|
+
this.cookieHost = options.cookieHost ?? defaultCookieHost();
|
|
104
|
+
this.onReset = options.onReset ?? null;
|
|
105
|
+
this.revealHandlerErrors = options.revealHandlerErrors ?? true;
|
|
106
|
+
const sessionOptions = options.sessions === true ? {} : options.sessions || null;
|
|
107
|
+
this.sessionTtlSeconds = sessionOptions?.ttlSeconds ?? DEFAULT_SESSION_TTL_SECONDS;
|
|
108
|
+
this.tokenCookieKey = sessionOptions?.tokenCookieKey ?? DEFAULT_SESSION_TOKEN_COOKIE_KEY;
|
|
109
|
+
this.csrfCookieKey = sessionOptions?.csrfCookieKey ?? DEFAULT_SESSION_CSRF_COOKIE_KEY;
|
|
110
|
+
this.sessionCookieOptions = sessionOptions?.cookieOptions ?? {};
|
|
111
|
+
const memorySessionStore = sessionOptions && !sessionOptions.store ? new LambderMemorySessionStore() : null;
|
|
112
|
+
this.sessionStore = memorySessionStore;
|
|
113
|
+
const rateLimits = options.rateLimits;
|
|
114
|
+
const memoryLimiter = rateLimits && !rateLimits.limiter ? new LambderMemoryRateLimiter() : null;
|
|
115
|
+
this.rateLimiter = memoryLimiter;
|
|
116
|
+
const idempotencyOptions = options.idempotency === true ? {} : options.idempotency || null;
|
|
117
|
+
const memoryIdempotency = idempotencyOptions && !idempotencyOptions.store ? new LambderMemoryIdempotencyStore() : null;
|
|
118
|
+
this.idempotencyStore = memoryIdempotency;
|
|
119
|
+
this.pipeline = new LambderApiPipeline({
|
|
120
|
+
apiVersion: this.apiVersion,
|
|
121
|
+
maxRequestPayloadBytes: options.maxRequestPayloadBytes,
|
|
122
|
+
sessions: sessionOptions
|
|
123
|
+
? {
|
|
124
|
+
manager: new LambderSessionManager({
|
|
125
|
+
store: sessionOptions.store ?? memorySessionStore,
|
|
126
|
+
sessionSalt: sessionOptions.sessionSalt ?? "lambder-mock",
|
|
127
|
+
enableSlidingExpiration: sessionOptions.enableSlidingExpiration,
|
|
128
|
+
slidingWriteIntervalSeconds: sessionOptions.slidingWriteIntervalSeconds,
|
|
129
|
+
dataRefresh: sessionOptions.dataRefresh,
|
|
130
|
+
// A plain-http page (device testing on a LAN) has no
|
|
131
|
+
// crypto.subtle, and a memory-only store is nothing
|
|
132
|
+
// anyone can leak, so hashing there protects nothing.
|
|
133
|
+
// Keyed on the store's own isMemoryOnly rather than on
|
|
134
|
+
// "did this runtime create it": an app that passes its
|
|
135
|
+
// own LambderMemorySessionStore got WebCrypto and threw
|
|
136
|
+
// on the first session call where the default path
|
|
137
|
+
// degrades, and a store that outlives the process still
|
|
138
|
+
// gets real hashing, which is what the declaration is
|
|
139
|
+
// there to say.
|
|
140
|
+
crypto: sessionOptions.crypto
|
|
141
|
+
?? (!isWebCryptoAvailable() && (sessionOptions.store?.isMemoryOnly ?? true) ? new LambderPlainSessionCrypto() : undefined),
|
|
142
|
+
}),
|
|
143
|
+
tokenCookieKey: this.tokenCookieKey,
|
|
144
|
+
csrfCookieKey: this.csrfCookieKey,
|
|
145
|
+
cookieOptions: sessionOptions.cookieOptions,
|
|
146
|
+
}
|
|
147
|
+
: undefined,
|
|
148
|
+
rateLimits: rateLimits
|
|
149
|
+
? { limiter: rateLimits.limiter ?? memoryLimiter, policies: rateLimits.policies, failOpen: rateLimits.failOpen }
|
|
150
|
+
: undefined,
|
|
151
|
+
guards: options.guards,
|
|
152
|
+
idempotency: idempotencyOptions
|
|
153
|
+
? {
|
|
154
|
+
store: idempotencyOptions.store ?? memoryIdempotency,
|
|
155
|
+
defaultTtlSeconds: idempotencyOptions.defaultTtlSeconds,
|
|
156
|
+
defaultPendingTtlSeconds: idempotencyOptions.defaultPendingTtlSeconds,
|
|
157
|
+
failOpen: idempotencyOptions.failOpen,
|
|
158
|
+
// The engine's own option is typed for the base call
|
|
159
|
+
// context, being the one thing every adapter shares; what
|
|
160
|
+
// it actually hands the function is the context of the
|
|
161
|
+
// adapter running it, which here is the mock's. So the
|
|
162
|
+
// option is declared for the mock context, where a reader
|
|
163
|
+
// can use ctx.request, and widened at the one place that
|
|
164
|
+
// knows both sides.
|
|
165
|
+
callerIdentity: idempotencyOptions.callerIdentity,
|
|
166
|
+
}
|
|
167
|
+
: undefined,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
// -----------------------------------------------------------------------
|
|
171
|
+
// Registry
|
|
172
|
+
// -----------------------------------------------------------------------
|
|
173
|
+
/**
|
|
174
|
+
* The registration-time checks every entry goes through, mocked or not:
|
|
175
|
+
* the ones the server runs on a definition, and the mock's own "a session
|
|
176
|
+
* endpoint needs the sessions option".
|
|
177
|
+
*
|
|
178
|
+
* One place, so no registration path can skip either check. A session
|
|
179
|
+
* endpoint on a mock without sessions would otherwise register silently
|
|
180
|
+
* and answer the first call with a 500 from inside the pipeline, naming
|
|
181
|
+
* the SERVER's option name, for a mistake whose fix is one option at
|
|
182
|
+
* create().
|
|
183
|
+
*/
|
|
184
|
+
assertEntryRegistration(definition) {
|
|
185
|
+
this.pipeline.assertRegistration(definition);
|
|
186
|
+
if (definition.mode === "session" && !this.pipeline.hasSessions) {
|
|
187
|
+
throw new Error(`LambderMockApp: session endpoint "${definition.name}" needs the sessions option at creation.`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
buildEntry(name, mode, input) {
|
|
191
|
+
const options = (typeof input === "function" ? { handler: input } : input);
|
|
192
|
+
const definition = {
|
|
193
|
+
name, mode,
|
|
194
|
+
guards: options.guards,
|
|
195
|
+
rateLimit: options.rateLimit,
|
|
196
|
+
idempotency: options.idempotency,
|
|
197
|
+
// No cast: the entry's schema is a z.ZodType, the same type the
|
|
198
|
+
// definition holds. A structural { safeParse } here would let a
|
|
199
|
+
// validator that is not a zod schema reach the 422 body as
|
|
200
|
+
// `zodError: { name: undefined, message: undefined, issues:
|
|
201
|
+
// undefined }`, a refusal a client cannot read.
|
|
202
|
+
input: options.input,
|
|
203
|
+
};
|
|
204
|
+
this.assertEntryRegistration(definition);
|
|
205
|
+
const handler = options.handler;
|
|
206
|
+
return { name, mode, definition, handler: async (ctx) => await handler(ctx), notMockedReason: null };
|
|
207
|
+
}
|
|
208
|
+
/** A mock for a public endpoint: a handler, or the handler with the endpoint's declarations restated. */
|
|
209
|
+
publicApi(name, entry) {
|
|
210
|
+
return this.buildEntry(name, "public", entry);
|
|
211
|
+
}
|
|
212
|
+
/** A mock for a session endpoint: the pipeline fetches the session before the handler runs, and refuses without one. */
|
|
213
|
+
sessionApi(name, entry) {
|
|
214
|
+
return this.buildEntry(name, "session", entry);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* A public endpoint deliberately left without a mock; a call answers the
|
|
218
|
+
* notMocked refusal carrying the reason.
|
|
219
|
+
*
|
|
220
|
+
* Public and session have separate builders for the same reason publicApi
|
|
221
|
+
* and sessionApi do: the refusal runs through the pipeline so that the
|
|
222
|
+
* steps BEFORE dispatch still happen, and the session read is one of them.
|
|
223
|
+
* Declaring every not-mocked endpoint public switched that step off, so a
|
|
224
|
+
* session endpoint with no session answered "not mocked" where the server
|
|
225
|
+
* answers sessionExpired, and the mode on its events and call log was
|
|
226
|
+
* wrong too. The mode cannot be recovered at runtime, because the contract
|
|
227
|
+
* is a type, so the builder is where it has to be said.
|
|
228
|
+
*/
|
|
229
|
+
notMocked(name, reason) {
|
|
230
|
+
return this.buildNotMockedEntry(name, "public", reason);
|
|
231
|
+
}
|
|
232
|
+
/** A session endpoint deliberately left without a mock: the session is still read, and refused before the notMocked refusal. */
|
|
233
|
+
sessionNotMocked(name, reason) {
|
|
234
|
+
return this.buildNotMockedEntry(name, "session", reason);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* "Everything I did not register is not mocked, for this reason", as an
|
|
238
|
+
* argument to the same register() call:
|
|
239
|
+
*
|
|
240
|
+
* ```ts
|
|
241
|
+
* mockApp.register(userMocks, billingMocks, mockApp.restNotMocked("not mocked yet"));
|
|
242
|
+
* ```
|
|
243
|
+
*
|
|
244
|
+
* What it buys is adoption over a contract the mocks do not cover yet:
|
|
245
|
+
* register() stays exhaustive by construction, and the endpoints nothing
|
|
246
|
+
* claims answer the notMocked refusal carrying this reason instead of
|
|
247
|
+
* apiNotFound, so a screen that reaches one says "not mocked yet" rather
|
|
248
|
+
* than "unknown error". Strays and duplicates in the explicit slices are
|
|
249
|
+
* refused exactly as they are without it, and an entry registered later
|
|
250
|
+
* (registerPartial, or a second register) takes the endpoint back from the
|
|
251
|
+
* rest.
|
|
252
|
+
*
|
|
253
|
+
* The one thing it cannot do is the session read. A call it answers is
|
|
254
|
+
* processed as a public endpoint: the protocol's pre-pass still runs, so a
|
|
255
|
+
* stale client still hears versionExpired, but the mode of a name nothing
|
|
256
|
+
* registered is not knowable at runtime, the contract being a type. So a
|
|
257
|
+
* signed-out call to an unmocked session endpoint is answered "not mocked"
|
|
258
|
+
* where the server answers sessionExpired, and the endpoint whose
|
|
259
|
+
* signed-out path a test cares about is the one to declare with
|
|
260
|
+
* sessionNotMocked instead.
|
|
261
|
+
*/
|
|
262
|
+
restNotMocked(reason) {
|
|
263
|
+
return { restNotMockedReason: reason };
|
|
264
|
+
}
|
|
265
|
+
buildNotMockedEntry(name, mode, reason) {
|
|
266
|
+
const definition = { name, mode };
|
|
267
|
+
this.assertEntryRegistration(definition);
|
|
268
|
+
return { name, mode, definition, handler: null, notMockedReason: reason };
|
|
269
|
+
}
|
|
270
|
+
/** The entries of one module as a slice, keyed by name. Two entries for one endpoint is an error here. */
|
|
271
|
+
apiSlice(...entries) {
|
|
272
|
+
const slice = {};
|
|
273
|
+
for (const entry of entries) {
|
|
274
|
+
if (slice[entry.name])
|
|
275
|
+
throw new Error(`LambderMockApp: endpoint "${entry.name}" appears twice in one slice.`);
|
|
276
|
+
slice[entry.name] = entry;
|
|
277
|
+
}
|
|
278
|
+
return slice;
|
|
279
|
+
}
|
|
280
|
+
addSlices(slices) {
|
|
281
|
+
this.registry.addSlices(slices);
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Registers the whole contract: every endpoint in exactly one slice, or
|
|
285
|
+
* in the reach of a restNotMocked entry passed beside them.
|
|
286
|
+
* Completeness, strays and overlap are checked by the compiler against
|
|
287
|
+
* the contract type; overlap and key-to-name agreement are checked again
|
|
288
|
+
* at runtime for slices built dynamically, and a second rest entry is
|
|
289
|
+
* refused there the way a duplicate name is.
|
|
290
|
+
*/
|
|
291
|
+
register(...slices) {
|
|
292
|
+
this.addSlices(slices);
|
|
293
|
+
return this;
|
|
294
|
+
}
|
|
295
|
+
/** Registers some endpoints, for a test that wants three and not three hundred. Overlap is still an error. */
|
|
296
|
+
registerPartial(...slices) {
|
|
297
|
+
this.addSlices(slices);
|
|
298
|
+
return this;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Replaces one endpoint's handler until restored: returns its own undo,
|
|
302
|
+
* which a test scopes with try/finally. The entry's declarations (mode,
|
|
303
|
+
* guards, rate limit, idempotency) stay as registered; only the handler
|
|
304
|
+
* changes.
|
|
305
|
+
*
|
|
306
|
+
* Overrides nest. A second override over the same endpoint stands on the
|
|
307
|
+
* first, and restoring it uncovers the first rather than the registry, so
|
|
308
|
+
* an override one `it` scoped cannot drop the one a describe put in place
|
|
309
|
+
* around it.
|
|
310
|
+
*/
|
|
311
|
+
override(name, handler) {
|
|
312
|
+
const base = this.registry.registered(name);
|
|
313
|
+
// A name with no entry has no declarations to keep, and inventing a
|
|
314
|
+
// public one would answer a session endpoint with no session, no
|
|
315
|
+
// guards and no rate limit: a test would read that as a pass for a
|
|
316
|
+
// call the server refuses. Register it first, then override it.
|
|
317
|
+
if (!base) {
|
|
318
|
+
throw new Error(`LambderMockApp: override("${name}") has nothing to override. Register the endpoint first (register or registerPartial), then override its handler.`);
|
|
319
|
+
}
|
|
320
|
+
const entry = { ...base, handler: async (ctx) => await handler(ctx), notMockedReason: null };
|
|
321
|
+
return this.registry.pushOverride(name, entry);
|
|
322
|
+
}
|
|
323
|
+
/** Puts every overridden handler back, however deeply they were stacked. */
|
|
324
|
+
restoreOverrides() {
|
|
325
|
+
this.registry.restoreOverrides();
|
|
326
|
+
}
|
|
327
|
+
/** The registered endpoint names. */
|
|
328
|
+
get registeredNames() {
|
|
329
|
+
return this.registry.names;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Whether a call to this name would be answered from the registry, which
|
|
333
|
+
* is what an adapter asks before passing one on.
|
|
334
|
+
*
|
|
335
|
+
* True for every name once a rest entry is registered, because the rest
|
|
336
|
+
* entry is what answers the names nothing else claimed. That is what makes
|
|
337
|
+
* a rest entry and the MSW adapter's `onUnmocked: "passthrough"`
|
|
338
|
+
* alternatives rather than layers: with one registered, the runtime
|
|
339
|
+
* answers everything itself and nothing is handed on to the network.
|
|
340
|
+
*/
|
|
341
|
+
hasRegisteredEntry(apiName) {
|
|
342
|
+
return this.entryFor(apiName) !== null || this.registry.restNotMockedReason !== null;
|
|
343
|
+
}
|
|
344
|
+
entryFor(apiName) {
|
|
345
|
+
return this.registry.entryFor(apiName);
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* The entry that answers a name nothing registered, when register() was
|
|
349
|
+
* given a rest entry: the notMocked refusal carrying its reason, run
|
|
350
|
+
* through the pipeline as a public endpoint.
|
|
351
|
+
*
|
|
352
|
+
* Public because the mode of an unregistered name cannot be recovered at
|
|
353
|
+
* runtime, the contract being a type. Everything that precedes dispatch
|
|
354
|
+
* still runs (the version gate, the payload restore); the session read is
|
|
355
|
+
* the one step this answer cannot have, which is the fidelity limit
|
|
356
|
+
* restNotMocked documents.
|
|
357
|
+
*/
|
|
358
|
+
restNotMockedEntry(apiName) {
|
|
359
|
+
const reason = this.registry.restNotMockedReason;
|
|
360
|
+
if (reason === null)
|
|
361
|
+
return null;
|
|
362
|
+
return { name: apiName, mode: "public", definition: { name: apiName, mode: "public" }, handler: null, notMockedReason: reason };
|
|
363
|
+
}
|
|
364
|
+
// -----------------------------------------------------------------------
|
|
365
|
+
// Control surface
|
|
366
|
+
// -----------------------------------------------------------------------
|
|
367
|
+
/** The next call to the endpoint fails this way; several calls queue in order. */
|
|
368
|
+
failNext(apiName, failure) {
|
|
369
|
+
this.failures.failNext(apiName, failure);
|
|
370
|
+
}
|
|
371
|
+
/** Every call to the endpoint fails this way until cleared with null. */
|
|
372
|
+
setFailure(apiName, failure) {
|
|
373
|
+
this.failures.setFailure(apiName, failure);
|
|
374
|
+
}
|
|
375
|
+
/** Every call rejects at the transport, as with no network at all. */
|
|
376
|
+
setOffline(offline) {
|
|
377
|
+
this.failures.setOffline(offline);
|
|
378
|
+
}
|
|
379
|
+
setLatency(latency) {
|
|
380
|
+
this.failures.setLatency(latency);
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Rewinds the runtime: sessions, rate-limit counters, replay records,
|
|
384
|
+
* overrides, injected failures, the offline switch, the configured
|
|
385
|
+
* latency, the call log and its numbering, the cookies its own transports
|
|
386
|
+
* hold, then onReset, so the app rewinds its own data too.
|
|
387
|
+
*
|
|
388
|
+
* The cookies matter as much as the sessions do: emptying the session
|
|
389
|
+
* store while a jar still holds the token for one of them leaves the next
|
|
390
|
+
* call carrying a session that no longer exists, which reads as signed in
|
|
391
|
+
* until the answer says sessionExpired. So every jar transport() built
|
|
392
|
+
* for itself is emptied, and the cookies a "document" transport mirrored
|
|
393
|
+
* are expired again.
|
|
394
|
+
*
|
|
395
|
+
* The registry survives, being what the runtime was configured with
|
|
396
|
+
* rather than what it accumulated. Subscriptions survive too, because
|
|
397
|
+
* they are how a test watches the runtime rather than state it is
|
|
398
|
+
* testing; a listener muted for throwing is unmuted, so one bad call does
|
|
399
|
+
* not silence it for the rest of the run. A session store or a cookie jar
|
|
400
|
+
* the app supplied itself survives: the runtime did not create it and
|
|
401
|
+
* does not know what else holds it.
|
|
402
|
+
*/
|
|
403
|
+
reset() {
|
|
404
|
+
this.sessionStore?.reset();
|
|
405
|
+
this.rateLimiter?.reset();
|
|
406
|
+
this.idempotencyStore?.reset();
|
|
407
|
+
this.registry.restoreOverrides();
|
|
408
|
+
this.failures.reset();
|
|
409
|
+
this.recorder.reset();
|
|
410
|
+
this.browserCookies.reset();
|
|
411
|
+
this.onReset?.();
|
|
412
|
+
}
|
|
413
|
+
// -----------------------------------------------------------------------
|
|
414
|
+
// Sessions
|
|
415
|
+
// -----------------------------------------------------------------------
|
|
416
|
+
/**
|
|
417
|
+
* The four session members refuse in the mock's own words, naming the
|
|
418
|
+
* option a mock is created with.
|
|
419
|
+
*
|
|
420
|
+
* The pipeline's guard says "Configure the session option at creation",
|
|
421
|
+
* which is the SERVER's option name: the mock's is `sessions`, and a
|
|
422
|
+
* reader who goes looking for `session` on create() does not find it.
|
|
423
|
+
* The registration path was fixed for exactly this one method over.
|
|
424
|
+
*/
|
|
425
|
+
assertSessionsConfigured(member) {
|
|
426
|
+
if (!this.pipeline.hasSessions)
|
|
427
|
+
throw new Error(`LambderMockApp: ${member} needs the sessions option at creation.`);
|
|
428
|
+
}
|
|
429
|
+
/** The session manager, for tests that inspect or manipulate sessions directly. Throws when sessions are off. */
|
|
430
|
+
get sessionManager() {
|
|
431
|
+
this.assertSessionsConfigured("sessionManager");
|
|
432
|
+
return this.pipeline.sessionManager;
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Starts a session without a login endpoint: creates it through the
|
|
436
|
+
* session controller, the way a login handler does, plants its cookies
|
|
437
|
+
* into the jar when one is given, so the jar's transport is signed in
|
|
438
|
+
* from its next call, and mirrors the readable ones into document.cookie
|
|
439
|
+
* the way an answer's cookies are. Returns the raw tokens too.
|
|
440
|
+
*/
|
|
441
|
+
async signIn(sessionKey, data, options = {}) {
|
|
442
|
+
this.assertSessionsConfigured("signIn()");
|
|
443
|
+
// The app's one cookie host unless this call names another: planted
|
|
444
|
+
// under a host the transport does not read them back at, the cookies
|
|
445
|
+
// are simply never sent, and every session call answers sessionExpired
|
|
446
|
+
// with a full jar.
|
|
447
|
+
const host = options.host ?? this.cookieHost;
|
|
448
|
+
const ctx = createApiCallContext();
|
|
449
|
+
const controller = this.pipeline.sessionController(ctx, { host, cookies: {}, csrfToken: null });
|
|
450
|
+
const created = await controller.issueSession(sessionKey, data, options.ttlSeconds ?? this.sessionTtlSeconds);
|
|
451
|
+
const headers = {};
|
|
452
|
+
ctx.responseHeaders.applyInto(headers);
|
|
453
|
+
const setCookies = getAnswerHeader(headers, "Set-Cookie") ?? [];
|
|
454
|
+
// The host these cookies came from, which is the same one the
|
|
455
|
+
// controller wrote them for. A jar checks every Domain against the
|
|
456
|
+
// sending host and refuses one it cannot check, so planting them
|
|
457
|
+
// unscoped dropped the session cookie of any app that configures a
|
|
458
|
+
// cookie domain, silently.
|
|
459
|
+
options.jar?.storeSetCookies(setCookies, { host });
|
|
460
|
+
// Through the same mirror every other cookie writer uses. Behind the
|
|
461
|
+
// MSW adapter the jar is not where a page reads its CSRF token: the
|
|
462
|
+
// browser caller reads document.cookie and posts what it finds, so a
|
|
463
|
+
// signIn that only filled a jar left the token empty and every one of
|
|
464
|
+
// the session endpoints answered sessionExpired.
|
|
465
|
+
this.browserCookies.mirrorSetCookies(setCookies);
|
|
466
|
+
return created;
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Ends every session of the subject ("log this subject out everywhere")
|
|
470
|
+
* and clears what signIn planted: the cookies in the jar given, and the
|
|
471
|
+
* copies in document.cookie.
|
|
472
|
+
*
|
|
473
|
+
* Symmetric on purpose, the way reset() is. The records alone leave the
|
|
474
|
+
* jar and the page carrying a token for a session that no longer exists,
|
|
475
|
+
* which reads as signed in until an answer says otherwise.
|
|
476
|
+
*/
|
|
477
|
+
async signOut(sessionKey, options = {}) {
|
|
478
|
+
this.assertSessionsConfigured("signOut()");
|
|
479
|
+
await this.pipeline.sessionManager.deleteSessionAllByKey(sessionKey);
|
|
480
|
+
const host = options.host ?? this.cookieHost;
|
|
481
|
+
// The scope signIn planted under: a deletion only reaches a cookie
|
|
482
|
+
// carrying the same Domain and Path, so it is built from the app's own
|
|
483
|
+
// cookie options rather than from defaults.
|
|
484
|
+
const cleared = [
|
|
485
|
+
serializeClearCookie(this.tokenCookieKey, { ...this.sessionCookieOptions, httpOnly: true }, host),
|
|
486
|
+
serializeClearCookie(this.csrfCookieKey, this.sessionCookieOptions, host),
|
|
487
|
+
];
|
|
488
|
+
options.jar?.storeSetCookies(cleared, { host });
|
|
489
|
+
this.browserCookies.mirrorSetCookies(cleared);
|
|
490
|
+
}
|
|
491
|
+
/** Marks the subject's session data stale, so the next read renews it through dataRefresh. */
|
|
492
|
+
async expireSessionData(sessionKey) {
|
|
493
|
+
this.assertSessionsConfigured("expireSessionData()");
|
|
494
|
+
await this.pipeline.sessionManager.expireSessionDataAllByKey(sessionKey);
|
|
495
|
+
}
|
|
496
|
+
// -----------------------------------------------------------------------
|
|
497
|
+
// Observation
|
|
498
|
+
// -----------------------------------------------------------------------
|
|
499
|
+
/**
|
|
500
|
+
* Listens to every call, both phases. Keyed, so a hot-reloaded module
|
|
501
|
+
* replaces its own listener instead of stacking a duplicate. Returns the
|
|
502
|
+
* unsubscribe.
|
|
503
|
+
*/
|
|
504
|
+
subscribe(key, listener) {
|
|
505
|
+
return this.recorder.subscribe(key, listener);
|
|
506
|
+
}
|
|
507
|
+
/** The completed calls, oldest first, bounded by callLogSize. */
|
|
508
|
+
get calls() {
|
|
509
|
+
return this.recorder.calls;
|
|
510
|
+
}
|
|
511
|
+
emit(event) {
|
|
512
|
+
this.recorder.emit(event);
|
|
513
|
+
}
|
|
514
|
+
// -----------------------------------------------------------------------
|
|
515
|
+
// Handling a call
|
|
516
|
+
// -----------------------------------------------------------------------
|
|
517
|
+
/** The context one call runs on: the core's call context plus what mock guards and handlers see. */
|
|
518
|
+
createContext(request) {
|
|
519
|
+
const pipeline = this.pipeline;
|
|
520
|
+
const base = createApiCallContext();
|
|
521
|
+
const ctx = Object.assign(base, {
|
|
522
|
+
apiName: request.apiName,
|
|
523
|
+
request,
|
|
524
|
+
signal: request.signal ?? new AbortController().signal,
|
|
525
|
+
envelope: {},
|
|
526
|
+
payload: request.payload,
|
|
527
|
+
guardInputs: request.guardInputs,
|
|
528
|
+
// The key as a handler can use it. A non-string is not a key: the
|
|
529
|
+
// engine refuses one with a 400 on every endpoint that declares
|
|
530
|
+
// idempotency, and on one that does not it is a value nothing
|
|
531
|
+
// reads, so handing it over typed as a string would be the lie.
|
|
532
|
+
idempotencyKey: typeof request.idempotencyKey === "string" ? request.idempotencyKey : undefined,
|
|
533
|
+
sessions: undefined,
|
|
534
|
+
});
|
|
535
|
+
// The controller reads and writes this very context, so it is built
|
|
536
|
+
// after it. Without sessions configured, touching it says why.
|
|
537
|
+
Object.defineProperty(ctx, "sessions", {
|
|
538
|
+
enumerable: true,
|
|
539
|
+
get() {
|
|
540
|
+
if (!pipeline.hasSessions)
|
|
541
|
+
throw new Error(`LambderMockApp: ctx.sessions on "${request.apiName}" needs the sessions option at creation.`);
|
|
542
|
+
return pipeline.sessionController(ctx, LambderApiPipeline.sessionInfoOf(request));
|
|
543
|
+
},
|
|
544
|
+
});
|
|
545
|
+
return ctx;
|
|
546
|
+
}
|
|
547
|
+
/** What a call looks like on the way in, for the runtime's own calls and for one an adapter passes on. */
|
|
548
|
+
requestEvent(id, request, mode, at) {
|
|
549
|
+
return {
|
|
550
|
+
phase: "request", id, apiName: request.apiName, mode,
|
|
551
|
+
payload: request.payload, guardInputs: request.guardInputs, idempotencyKey: request.idempotencyKey,
|
|
552
|
+
version: request.version, headers: request.headers,
|
|
553
|
+
hasSessionCookie: (request.cookies[this.tokenCookieKey]?.length ?? 0) > 0,
|
|
554
|
+
at,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Records a call an adapter handed on instead of answering: the MSW
|
|
559
|
+
* adapter's passthrough. Without it a name the registry does not know
|
|
560
|
+
* leaves no trace at all, and a mistyped endpoint reaches the real
|
|
561
|
+
* network with nothing in the call log or on the subscription to say so,
|
|
562
|
+
* which is the one failure the log exists to make visible.
|
|
563
|
+
*/
|
|
564
|
+
notePassthrough(request) {
|
|
565
|
+
const facts = this.callFacts(this.recorder.nextCallId(), request, null);
|
|
566
|
+
this.emit(this.requestEvent(facts.id, request, null, facts.startedAt));
|
|
567
|
+
this.recorder.settle(facts, { answer: null, outcome: "passthrough", guardsRun: [] });
|
|
568
|
+
}
|
|
569
|
+
/** What every record of one call repeats (see LambderMockCallFacts). */
|
|
570
|
+
callFacts(id, request, mode) {
|
|
571
|
+
return { id, apiName: request.apiName, mode, startedAt: Date.now(), request };
|
|
572
|
+
}
|
|
573
|
+
/** One call from a parsed request to its answer, events included. The entry point every transport and adapter shares. */
|
|
574
|
+
async handleRequest(request) {
|
|
575
|
+
const id = this.recorder.nextCallId();
|
|
576
|
+
const registered = this.entryFor(request.apiName);
|
|
577
|
+
// The rest entry answers whatever nothing registered, when register()
|
|
578
|
+
// was given one. The mode reported stays the registered entry's, so it
|
|
579
|
+
// is null here exactly as it is for a name the registry does not know:
|
|
580
|
+
// the rest answer is processed as public, which is a property of the
|
|
581
|
+
// answer rather than a claim about the endpoint.
|
|
582
|
+
const entry = registered ?? this.restNotMockedEntry(request.apiName);
|
|
583
|
+
const mode = registered?.mode ?? null;
|
|
584
|
+
const facts = this.callFacts(id, request, mode);
|
|
585
|
+
const startedAt = facts.startedAt;
|
|
586
|
+
const ctx = this.createContext(request);
|
|
587
|
+
// The pipeline's own trace, handed in rather than read off what run()
|
|
588
|
+
// returns: a crash unwinds past the return, and the guards that ran
|
|
589
|
+
// before it are exactly what a developer reading the call log is
|
|
590
|
+
// looking for. Written as the call goes, so it survives the throw.
|
|
591
|
+
const trace = { guardsRun: [], replayed: false };
|
|
592
|
+
let answer;
|
|
593
|
+
let outcome;
|
|
594
|
+
let error;
|
|
595
|
+
try {
|
|
596
|
+
// The protocol's pre-pass, run before the name is resolved, which
|
|
597
|
+
// is where the server runs it. Two things depended on it: an
|
|
598
|
+
// unknown name reached the notFound refusal without the version
|
|
599
|
+
// gate or the payload restore, so a stale client or a malformed
|
|
600
|
+
// compressed payload was answered differently here than on the
|
|
601
|
+
// server; and the request event carried the wire fields instead of
|
|
602
|
+
// the payload, so a dev panel watching calls in flight showed
|
|
603
|
+
// nothing for exactly the compressed calls someone opens a panel
|
|
604
|
+
// for. run() calls prepare again, which is safe by construction.
|
|
605
|
+
const prepared = await this.pipeline.prepare(request);
|
|
606
|
+
this.emit(this.requestEvent(id, request, mode, startedAt));
|
|
607
|
+
await this.failures.wait(this.failures.latencyFor(request.apiName), request.signal);
|
|
608
|
+
if (this.failures.offline)
|
|
609
|
+
throw new LambderMockTransportError("offline");
|
|
610
|
+
// After the wait and the offline check, both of which end the call
|
|
611
|
+
// before it reaches a handler: taking the failure first spent a
|
|
612
|
+
// queued failNext on a call that never got to be failed by it, and
|
|
613
|
+
// the next call, the one the test was arranging for, then answered
|
|
614
|
+
// normally.
|
|
615
|
+
const failure = this.failures.take(request.apiName);
|
|
616
|
+
if (failure) {
|
|
617
|
+
answer = await this.failures.answerFor(failure, request);
|
|
618
|
+
outcome = "injected";
|
|
619
|
+
}
|
|
620
|
+
else if (prepared) {
|
|
621
|
+
answer = prepared;
|
|
622
|
+
}
|
|
623
|
+
else if (!entry) {
|
|
624
|
+
answer = this.pipeline.answerUnknownApi(request, ctx);
|
|
625
|
+
outcome = "unknownApi";
|
|
626
|
+
}
|
|
627
|
+
else {
|
|
628
|
+
const handler = entry.handler;
|
|
629
|
+
const result = await this.pipeline.run(request, ctx, entry.definition, handler
|
|
630
|
+
? async (callCtx) => {
|
|
631
|
+
// The payload the handler sees is the restored one.
|
|
632
|
+
callCtx.payload = request.payload;
|
|
633
|
+
const payload = await handler(callCtx);
|
|
634
|
+
return envelopeAnswer(buildApiEnvelope(this.apiVersion, payload === undefined ? null : payload, {
|
|
635
|
+
message: callCtx.envelope.message,
|
|
636
|
+
logList: callCtx.logList,
|
|
637
|
+
}));
|
|
638
|
+
}
|
|
639
|
+
// A notMocked entry refuses where a handler would run, not
|
|
640
|
+
// ahead of the pipeline: answering it directly skipped the
|
|
641
|
+
// steps that precede dispatch, so a stale client heard
|
|
642
|
+
// "not mocked" from a mock the server would have answered
|
|
643
|
+
// versionExpired to, and a compressed payload never reached
|
|
644
|
+
// the events at all.
|
|
645
|
+
: async () => {
|
|
646
|
+
throw new LambderApiRefusal(`Not mocked: ${entry.notMockedReason}`, {
|
|
647
|
+
errorMessage: { type: "warning", code: LAMBDER_REFUSAL_CODES.notMocked, content: `"${request.apiName}" is not mocked: ${entry.notMockedReason}` },
|
|
648
|
+
});
|
|
649
|
+
}, trace);
|
|
650
|
+
answer = result.answer;
|
|
651
|
+
if (result.replayed)
|
|
652
|
+
outcome = "replayed";
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
catch (err) {
|
|
656
|
+
if (err instanceof LambderMockTransportError) {
|
|
657
|
+
this.recorder.settle(facts, { answer: null, outcome: "injected", guardsRun: trace.guardsRun, error: err });
|
|
658
|
+
throw err;
|
|
659
|
+
}
|
|
660
|
+
error = coerceToError(err);
|
|
661
|
+
// A mock runtime is a development tool: the point of a handler
|
|
662
|
+
// that threw is the message it threw, and replacing it with the
|
|
663
|
+
// server's wording sends a developer looking through a call log
|
|
664
|
+
// for what could have been on the screen. Apps that want the
|
|
665
|
+
// production shape turn it off.
|
|
666
|
+
answer = this.revealHandlerErrors
|
|
667
|
+
? envelopeAnswer(buildApiEnvelope(this.apiVersion, null, { errorMessage: error.message }), { statusCode: 500 })
|
|
668
|
+
: crashAnswer(this.apiVersion);
|
|
669
|
+
outcome = "crash";
|
|
670
|
+
}
|
|
671
|
+
// Every header written during the call belongs on the answer, whichever
|
|
672
|
+
// way the call ended. The pipeline does this for the answers it
|
|
673
|
+
// produces itself, but a crash unwinds past it and an injected failure
|
|
674
|
+
// never reaches it, and those are the two that hurt most in
|
|
675
|
+
// development: a handler that created a session and then threw would
|
|
676
|
+
// otherwise leave the jar with no cookie and nothing to explain it.
|
|
677
|
+
// applyInto is idempotent, so the answers the pipeline already handled
|
|
678
|
+
// are unaffected.
|
|
679
|
+
ctx.responseHeaders.applyInto(answer.headers);
|
|
680
|
+
this.recorder.settle(facts, { answer, guardsRun: trace.guardsRun, ...(outcome ? { outcome } : {}), ...(error ? { error } : {}) });
|
|
681
|
+
return answer;
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* A transport request as the core's request: the envelope read the way the
|
|
685
|
+
* server reads it. Public because the adapters call it, which is what
|
|
686
|
+
* keeps them from each reading a transport request their own way.
|
|
687
|
+
*/
|
|
688
|
+
requestFromTransport(transportRequest) {
|
|
689
|
+
const info = {
|
|
690
|
+
headers: lowercaseHeaderNames(transportRequest.headers),
|
|
691
|
+
cookies: cookieValuesByName(transportRequest.cookies ?? []),
|
|
692
|
+
// One textual form per address, as the server's own context
|
|
693
|
+
// resolves it: a `per: "ip"` limit keyed on whatever spelling a
|
|
694
|
+
// caller wrote is a limit that does not limit, and a test written
|
|
695
|
+
// over it proves less than it looks like it does.
|
|
696
|
+
ip: normalizeClientIp(transportRequest.clientIp ?? this.defaultClientIp),
|
|
697
|
+
host: transportRequest.siteHost || this.cookieHost,
|
|
698
|
+
...(transportRequest.signal ? { signal: transportRequest.signal } : {}),
|
|
699
|
+
};
|
|
700
|
+
const request = readApiEnvelope(buildTransportEnvelope(transportRequest), info);
|
|
701
|
+
if (!request)
|
|
702
|
+
throw new Error("LambderMockApp: the transport request names no api.");
|
|
703
|
+
return request;
|
|
704
|
+
}
|
|
705
|
+
/** One call from a transport request to its answer: what the mock transport and the adapters call. */
|
|
706
|
+
async handle(transportRequest) {
|
|
707
|
+
return await this.handleRequest(this.requestFromTransport(transportRequest));
|
|
708
|
+
}
|
|
709
|
+
// -----------------------------------------------------------------------
|
|
710
|
+
// Transports
|
|
711
|
+
// -----------------------------------------------------------------------
|
|
712
|
+
/**
|
|
713
|
+
* The direct transport: a caller's request into handle(), the answer
|
|
714
|
+
* back in the form the caller reads, cookies carried by a jar the way
|
|
715
|
+
* a browser carries them. Each transport gets its own jar unless one is
|
|
716
|
+
* given, so two transports hold two sessions; the jar is on the returned
|
|
717
|
+
* transport as `cookieJar`, so a test can read or clear the one it did
|
|
718
|
+
* not create itself, and reset() empties it.
|
|
719
|
+
*/
|
|
720
|
+
transport(options = {}) {
|
|
721
|
+
const clientIp = options.clientIp ?? this.defaultClientIp;
|
|
722
|
+
const direct = async (request) => toHttpAnswer(await this.handle({ ...request, clientIp: request.clientIp ?? clientIp }));
|
|
723
|
+
const cookies = options.cookies ?? "memory";
|
|
724
|
+
if (cookies === false)
|
|
725
|
+
return transportCarrying(direct, null);
|
|
726
|
+
const jar = cookies instanceof LambderCookieJar ? cookies : new LambderCookieJar();
|
|
727
|
+
// A jar this runtime built is this runtime's to empty on reset; one
|
|
728
|
+
// the caller passed is the caller's, the way an app-supplied session
|
|
729
|
+
// store is.
|
|
730
|
+
if (jar !== cookies)
|
|
731
|
+
this.browserCookies.adoptJar(jar);
|
|
732
|
+
// The runtime's one cookie host, so the jar scopes what it sends the
|
|
733
|
+
// way the browser this transport stands in for would. Without it the
|
|
734
|
+
// scope was whatever host the caller happened to name, which is the
|
|
735
|
+
// page's, and cookies signIn planted went unsent.
|
|
736
|
+
const withJar = lambderCookieJarTransport(direct, { jar, csrfCookieKey: this.csrfCookieKey, host: this.cookieHost });
|
|
737
|
+
if (cookies !== "document")
|
|
738
|
+
return transportCarrying(withJar, jar);
|
|
739
|
+
// The page's own cookie storage sees the non-HttpOnly cookies (the
|
|
740
|
+
// CSRF token), so the caller's cookie read and clear paths run for
|
|
741
|
+
// real; the HttpOnly session cookie stays in the jar, as a browser
|
|
742
|
+
// would keep it out of document.cookie.
|
|
743
|
+
return transportCarrying(async (request) => {
|
|
744
|
+
const answer = await withJar(request);
|
|
745
|
+
this.mirrorCookiesIntoDocument(answer.setCookies ?? []);
|
|
746
|
+
return answer;
|
|
747
|
+
}, jar);
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Mirrors an answer's non-HttpOnly cookies into document.cookie and
|
|
751
|
+
* remembers them, so reset() expires them again. The direct transport's
|
|
752
|
+
* "document" mode and the MSW adapter both come through here: one
|
|
753
|
+
* implementation of the mirror, one record of what was planted.
|
|
754
|
+
*/
|
|
755
|
+
mirrorCookiesIntoDocument(setCookies) {
|
|
756
|
+
this.browserCookies.mirrorSetCookies(setCookies);
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Takes a jar an adapter built for itself as the runtime's own, so reset()
|
|
760
|
+
* empties it with the rest. The MSW adapter's jar holds the session
|
|
761
|
+
* cookies of calls that never touch transport(), and a reset that leaves
|
|
762
|
+
* it full is the same stale-session bug: the store is empty and the next
|
|
763
|
+
* request still carries a token for one of its sessions.
|
|
764
|
+
*/
|
|
765
|
+
adoptCookieJar(jar) {
|
|
766
|
+
this.browserCookies.adoptJar(jar);
|
|
767
|
+
}
|
|
768
|
+
/** caller.setTransport(mockApp.transport(options)); returns the transport, its jar on it. */
|
|
769
|
+
attach(caller, options = {}) {
|
|
770
|
+
const transport = this.transport(options);
|
|
771
|
+
caller.setTransport(transport);
|
|
772
|
+
return transport;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Fixes the contract and session data types, then hands out the guard
|
|
777
|
+
* builder bound to the mock's contexts and the create() that infers
|
|
778
|
+
* everything else (the guard map, the rate-limit policies) from its options.
|
|
779
|
+
* Curried for the same reason initLambder is: TypeScript type arguments are
|
|
780
|
+
* all-or-nothing per call.
|
|
781
|
+
*
|
|
782
|
+
* ```ts
|
|
783
|
+
* const mock = initLambderMock<ApiContractType, SessionData>();
|
|
784
|
+
* const mockApp = mock.create({
|
|
785
|
+
* apiVersion: "1.4.0",
|
|
786
|
+
* sessions: true,
|
|
787
|
+
* guards: { tenant: mock.guard({ guardInput: z.object({ tenantId: z.uuid() }), session: true, handler: ... }) },
|
|
788
|
+
* });
|
|
789
|
+
* ```
|
|
790
|
+
*/
|
|
791
|
+
export const initLambderMock = () => ({
|
|
792
|
+
/** Builds a mock guard: the server guard's shape, the handler seeing the mock's contexts. */
|
|
793
|
+
guard: lambderGuardBuilder(),
|
|
794
|
+
/**
|
|
795
|
+
* Builds a mock rate-limit key, the counterpart of `guard`. Bound to the
|
|
796
|
+
* mock's own call context, because the server's lambderRateLimitKey() is
|
|
797
|
+
* bound to the render context and a handler written with it compiles here
|
|
798
|
+
* while reading fields the mock context does not have.
|
|
799
|
+
*/
|
|
800
|
+
rateLimitKey: lambderRateLimitKeyBuilder(),
|
|
801
|
+
/**
|
|
802
|
+
* The mock app, with the guard map and the rate-limit policies inferred
|
|
803
|
+
* from the options.
|
|
804
|
+
*
|
|
805
|
+
* `const` on each of them is what pins a restatement to the contract, and
|
|
806
|
+
* it has one cost: inferring a generic from an object literal switches
|
|
807
|
+
* excess-property checking off for the whole literal, nested objects
|
|
808
|
+
* included, so a typo inside `rateLimits.policies` or `idempotency`
|
|
809
|
+
* compiled and was dropped in silence. `I` exists for the same reason `P`
|
|
810
|
+
* does, and LambderMockSurplusKeys puts the error back on the key.
|
|
811
|
+
*/
|
|
812
|
+
create(options) {
|
|
813
|
+
return new LambderMockApp(options);
|
|
814
|
+
},
|
|
815
|
+
});
|