lambder 14.1.1 → 15.0.2
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 +429 -0
- package/README.md +14 -17
- package/dist/api/LambderApiDeclarations.d.ts +11 -5
- package/dist/api/LambderApiDefinition.d.ts +2 -1
- package/dist/api/LambderApiEnvelope.d.ts +2 -2
- package/dist/api/LambderApiEnvelope.js +8 -2
- package/dist/api/LambderApiGuards.d.ts +44 -5
- package/dist/api/LambderApiGuards.js +27 -0
- package/dist/api/LambderApiIdempotency.d.ts +14 -7
- package/dist/api/LambderApiIdempotency.js +39 -27
- package/dist/api/LambderApiPipeline.d.ts +25 -11
- package/dist/api/LambderApiPipeline.js +34 -17
- package/dist/api/LambderApiRateLimits.d.ts +28 -21
- package/dist/api/LambderApiRateLimits.js +55 -35
- package/dist/api/LambderApiRefusals.d.ts +10 -4
- package/dist/api/LambderApiRefusals.js +31 -5
- package/dist/api/LambderApiRequest.d.ts +21 -1
- package/dist/api/LambderApiRequest.js +31 -2
- package/dist/build/checkApiRefusals.d.ts +8 -3
- package/dist/build/checkApiRefusals.js +80 -29
- package/dist/build/contractPrinterProcess.d.ts +11 -0
- package/dist/build/contractPrinterProcess.js +17 -0
- package/dist/build/generateApiFiles.d.ts +26 -5
- package/dist/build/generateApiFiles.js +68 -7
- package/dist/build/writeApiSchemas.d.ts +109 -0
- package/dist/build/writeApiSchemas.js +308 -0
- package/dist/build.d.ts +3 -1
- package/dist/build.js +2 -0
- package/dist/client/LambderCaller.d.ts +25 -11
- package/dist/client/LambderCaller.js +57 -49
- package/dist/client/LambderUploadRunner.d.ts +55 -27
- package/dist/client/LambderUploadRunner.js +89 -34
- package/dist/client.d.ts +6 -6
- package/dist/client.js +2 -2
- package/dist/core/Lambder.d.ts +31 -21
- package/dist/core/Lambder.js +79 -23
- package/dist/core/LambderCallSummary.d.ts +1 -1
- package/dist/core/LambderContext.d.ts +59 -7
- package/dist/core/LambderContext.js +31 -15
- package/dist/core/LambderCrashHandling.d.ts +5 -0
- package/dist/core/LambderCrashHandling.js +11 -1
- package/dist/core/LambderCreateOptions.d.ts +28 -17
- package/dist/core/LambderCreateOptions.js +14 -7
- package/dist/core/LambderResolver.d.ts +3 -2
- package/dist/core/LambderResponseBuilder.d.ts +20 -9
- package/dist/core/LambderResponseBuilder.js +39 -8
- package/dist/core/LambderTemplatingEngine.d.ts +24 -6
- package/dist/core/LambderTemplatingEngine.js +23 -3
- package/dist/index.d.ts +15 -12
- package/dist/index.js +7 -5
- package/dist/invoke/LambderInvokeCaller.d.ts +25 -4
- package/dist/invoke/LambderInvokeCaller.js +47 -9
- package/dist/invoke/LambderInvokeOutcome.d.ts +7 -6
- package/dist/mock/LambderMockAdapterDoor.d.ts +40 -0
- package/dist/mock/LambderMockAdapterDoor.js +10 -0
- package/dist/mock/LambderMockApiSchemas.d.ts +28 -0
- package/dist/mock/LambderMockApiSchemas.js +150 -0
- package/dist/mock/LambderMockApp.d.ts +56 -48
- package/dist/mock/LambderMockApp.js +107 -62
- package/dist/mock/LambderMockBrowserCookies.js +18 -5
- package/dist/mock/LambderMockCreateOptions.d.ts +51 -4
- package/dist/mock/LambderMockFailureInjector.d.ts +12 -3
- package/dist/mock/LambderMockFailureInjector.js +12 -8
- package/dist/mock/LambderMockTypes.d.ts +8 -4
- package/dist/mock/lambderMockInvokeTransport.js +2 -9
- package/dist/mock/lambderMockMswHandler.d.ts +19 -13
- package/dist/mock/lambderMockMswHandler.js +25 -22
- package/dist/mock.d.ts +1 -0
- package/dist/secrets/LambderOneShotSecrets.d.ts +1 -1
- package/dist/secrets/LambderOneShotSecrets.js +6 -36
- package/dist/secrets/LambderPasswordHasher.d.ts +56 -0
- package/dist/secrets/LambderPasswordHasher.js +168 -0
- package/dist/session/LambderSessionController.d.ts +37 -4
- package/dist/session/LambderSessionController.js +36 -6
- package/dist/session/LambderSessionManager.d.ts +33 -3
- package/dist/session/LambderSessionManager.js +55 -16
- package/dist/shared/LambderHtml.d.ts +23 -4
- package/dist/shared/LambderHtml.js +17 -5
- package/dist/shared/LambderI18n.d.ts +106 -16
- package/dist/shared/LambderI18n.js +259 -74
- package/dist/shared/contracts/LambderCache.d.ts +14 -1
- package/dist/shared/contracts/LambderRateLimiter.d.ts +5 -6
- package/dist/shared/transport/LambderApiTransport.d.ts +3 -2
- package/dist/shared/util/LambderBackoffTimer.d.ts +24 -13
- package/dist/shared/util/LambderBackoffTimer.js +40 -12
- package/dist/shared/util/LambderCallAbort.d.ts +22 -6
- package/dist/shared/util/LambderCallAbort.js +37 -9
- package/dist/shared/util/LambderErrorReportMark.d.ts +4 -0
- package/dist/shared/util/LambderErrorReportMark.js +22 -0
- package/dist/shared/util/LambderKeyFieldDigest.d.ts +46 -0
- package/dist/shared/util/LambderKeyFieldDigest.js +75 -0
- package/dist/shared/util/LambderSignedClaims.d.ts +19 -0
- package/dist/shared/util/LambderSignedClaims.js +54 -0
- package/dist/shared/util/LambderSwappableInstances.d.ts +29 -0
- package/dist/shared/util/LambderSwappableInstances.js +54 -0
- package/dist/shared/util/LambderTestingDoors.d.ts +6 -2
- package/dist/shared/util/LambderTestingDoors.js +6 -2
- package/dist/shared/util/LambderTextDigest.d.ts +5 -5
- package/dist/shared/util/LambderTextDigest.js +5 -5
- package/dist/shared/wire/LambderApiContract.d.ts +1 -1
- package/dist/shared/wire/LambderApiOptionEntries.d.ts +9 -1
- package/dist/shared/wire/LambderApiOptionEntries.js +15 -0
- package/dist/shared/wire/LambderApiOptionValues.d.ts +3 -2
- package/dist/shared/wire/LambderApiOutcome.d.ts +37 -19
- package/dist/shared/wire/LambderApiOutcome.js +1 -1
- package/dist/shared/wire/LambderApiRefusal.d.ts +39 -12
- package/dist/shared/wire/LambderApiRefusal.js +3 -3
- package/dist/shared/wire/LambderApiSchemaEntries.d.ts +21 -0
- package/dist/shared/wire/LambderApiSchemaEntries.js +18 -0
- package/dist/shared/wire/LambderCallOptions.d.ts +3 -3
- package/dist/shared/wire/LambderCallOutcome.d.ts +2 -1
- package/dist/shared/wire/LambderCallOutcome.js +3 -2
- package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +5 -3
- package/dist/shared/wire/LambderIdempotencyKeyScope.js +5 -3
- package/dist/shared/wire/LambderInvokeApiId.d.ts +3 -3
- package/dist/shared/wire/LambderInvokeApiId.js +3 -3
- package/dist/shared/wire/LambderOutcomeAssertions.d.ts +7 -6
- package/dist/shared/wire/LambderOutcomeAssertions.js +1 -1
- package/dist/stores/LambderCacheValues.d.ts +2 -5
- package/dist/stores/LambderDdbCache.d.ts +15 -9
- package/dist/stores/LambderDdbCache.js +25 -6
- package/dist/stores/LambderDdbIdempotencyStore.d.ts +6 -4
- package/dist/stores/LambderDdbIdempotencyStore.js +32 -52
- package/dist/stores/LambderDdbOneShotSecretStore.d.ts +11 -0
- package/dist/stores/LambderDdbOneShotSecretStore.js +17 -5
- package/dist/stores/LambderDdbSdk.d.ts +59 -29
- package/dist/stores/LambderDdbSdk.js +116 -44
- package/dist/stores/LambderDdbSessionStore.d.ts +26 -11
- package/dist/stores/LambderDdbSessionStore.js +133 -68
- package/dist/stores/LambderMemoryCache.d.ts +2 -2
- package/dist/stores/LambderS3FileSource.d.ts +3 -2
- package/dist/stores/LambderS3FileSource.js +11 -13
- package/dist/stores/LambderS3UploadBucket.d.ts +12 -0
- package/dist/stores/LambderS3UploadBucket.js +23 -0
- package/dist/stores/LambderStorageBackedCache.d.ts +2 -2
- package/dist/stores/LambderStoredText.d.ts +26 -0
- package/dist/stores/LambderStoredText.js +25 -0
- package/dist/testing/LambderConformanceRunner.d.ts +9 -2
- package/dist/testing/LambderConformanceRunner.js +9 -2
- package/dist/testing/LambderTestApp.d.ts +65 -7
- package/dist/testing/LambderTestApp.js +101 -6
- package/dist/testing/LambderTestVisitor.d.ts +43 -12
- package/dist/testing/LambderTestVisitor.js +35 -11
- package/dist/testing/bootLambdaPackage.d.ts +105 -0
- package/dist/testing/bootLambdaPackage.js +265 -0
- package/dist/testing/lambdaPackageBootProcess.d.ts +39 -0
- package/dist/testing/lambdaPackageBootProcess.js +130 -0
- package/dist/testing/lambderCacheConformance.d.ts +26 -0
- package/dist/testing/lambderCacheConformance.js +287 -0
- package/dist/testing/lambderCacheStorageConformance.d.ts +25 -0
- package/dist/testing/lambderCacheStorageConformance.js +159 -0
- package/dist/testing/lambderSessionStoreConformance.d.ts +3 -3
- package/dist/testing/lambderSessionStoreConformance.js +3 -3
- package/dist/testing.d.ts +7 -1
- package/dist/testing.js +5 -0
- package/package.json +1 -6
- package/dist/shared/util/boundKeyField.d.ts +0 -20
- package/dist/shared/util/boundKeyField.js +0 -34
|
@@ -20,6 +20,8 @@ import { LambderMockFailureInjector, LambderMockTransportError } from "./Lambder
|
|
|
20
20
|
import { LambderMockCallRecorder } from "./LambderMockCallRecorder.js";
|
|
21
21
|
import { LambderMockEntryRegistry } from "./LambderMockEntryRegistry.js";
|
|
22
22
|
import { LambderMockBrowserCookies } from "./LambderMockBrowserCookies.js";
|
|
23
|
+
import { LambderMockApiSchemas } from "./LambderMockApiSchemas.js";
|
|
24
|
+
import { LAMBDER_MOCK_ADAPTER_DOOR } from "./LambderMockAdapterDoor.js";
|
|
23
25
|
import { LambderMemoryRateLimiter } from "../stores/LambderMemoryRateLimiter.js";
|
|
24
26
|
import { LambderMemoryIdempotencyStore } from "../stores/LambderMemoryIdempotencyStore.js";
|
|
25
27
|
import { LambderMemorySessionStore } from "../stores/LambderMemorySessionStore.js";
|
|
@@ -33,6 +35,8 @@ import { DEFAULT_SESSION_CSRF_COOKIE_KEY, DEFAULT_SESSION_TOKEN_COOKIE_KEY } fro
|
|
|
33
35
|
const transportCarrying = (transport, jar) => Object.assign(transport, { cookieJar: jar });
|
|
34
36
|
const DEFAULT_CALL_LOG_SIZE = 200;
|
|
35
37
|
const DEFAULT_SESSION_TTL_SECONDS = 30 * 24 * 60 * 60;
|
|
38
|
+
/** The salt the mock's sessions are keyed with, and so its policy keys' digest, when the app names none. */
|
|
39
|
+
const DEFAULT_MOCK_SESSION_SALT = "lambder-mock";
|
|
36
40
|
/**
|
|
37
41
|
* The host the runtime's cookies belong to when the app names none: the page
|
|
38
42
|
* the mock is running in, or "localhost" where there is no page (a Node test).
|
|
@@ -73,6 +77,29 @@ export class LambderMockApp {
|
|
|
73
77
|
defaultClientIp;
|
|
74
78
|
/** The host this runtime's cookies belong to (see the cookieHost option). */
|
|
75
79
|
cookieHost;
|
|
80
|
+
/**
|
|
81
|
+
* The page's cookie jar: where the browser this runtime serves keeps the
|
|
82
|
+
* cookies no page script can read, the HttpOnly session cookie among
|
|
83
|
+
* them. signIn and signOut plant into it and clear it when they are given
|
|
84
|
+
* no jar, and the MSW adapter carries its calls' cookies in it when it is
|
|
85
|
+
* given none, so the session signIn starts is the one the page's next
|
|
86
|
+
* call through the worker sends. The runtime's own: reset() empties it.
|
|
87
|
+
*/
|
|
88
|
+
pageCookieJar = new LambderCookieJar();
|
|
89
|
+
/**
|
|
90
|
+
* What the MSW adapter asks of the runtime and tells it beyond handing it
|
|
91
|
+
* a call, behind a key no entry point exports (see
|
|
92
|
+
* LAMBDER_MOCK_ADAPTER_DOOR): the adapter's bookkeeping, not the app's.
|
|
93
|
+
*/
|
|
94
|
+
[LAMBDER_MOCK_ADAPTER_DOOR] = {
|
|
95
|
+
hasRegisteredEntry: (apiName) => this.entryFor(apiName) !== null || this.registry.restNotMockedReason !== null,
|
|
96
|
+
notePassthrough: (request) => {
|
|
97
|
+
const facts = this.callFacts(this.recorder.nextCallId(), request, null);
|
|
98
|
+
this.emit(this.requestEvent(facts.id, request, null, facts.startedAt));
|
|
99
|
+
this.recorder.settle(facts, { answer: null, outcome: "passthrough", guardsRun: [] });
|
|
100
|
+
},
|
|
101
|
+
mirrorCookiesIntoDocument: (setCookies) => this.browserCookies.mirrorSetCookies(setCookies),
|
|
102
|
+
};
|
|
76
103
|
/**
|
|
77
104
|
* The API core, every protocol step of it. Private: the mock's surface is
|
|
78
105
|
* the app, and a consumer reaching past it would be configuring the
|
|
@@ -93,6 +120,8 @@ export class LambderMockApp {
|
|
|
93
120
|
registry = new LambderMockEntryRegistry();
|
|
94
121
|
/** The server's declared options per API, when create() was given the generated table; the entries' declarations come from here. */
|
|
95
122
|
apiOptions;
|
|
123
|
+
/** The server's schemas, when create() was given the generated table: every entry's input is validated, and every answer parsed, through them. */
|
|
124
|
+
apiSchemas;
|
|
96
125
|
/** The mock's own guards as create() was given them: which of them need a session, for an entry's mode without the apiOptions table. */
|
|
97
126
|
mockGuards;
|
|
98
127
|
/** The server's guard declarations, when create() was given the generated table: the refusal codes a declared guard adds to an entry. */
|
|
@@ -108,8 +137,24 @@ export class LambderMockApp {
|
|
|
108
137
|
declared = null) {
|
|
109
138
|
this.apiVersion = options.apiVersion ?? null;
|
|
110
139
|
this.apiOptions = options.apiOptions ?? null;
|
|
140
|
+
this.apiSchemas = options.apiSchemas ? new LambderMockApiSchemas(options.apiSchemas) : null;
|
|
111
141
|
this.mockGuards = options.guards ?? null;
|
|
112
142
|
this.guardDeclarations = options.guardDeclarations ?? null;
|
|
143
|
+
// A mock guard stands in for the server's guard of its name, so it
|
|
144
|
+
// declares the codes that guard declares: its own refusals type its
|
|
145
|
+
// ctx.refuse and are what checkApiRefusals holds it to. The compiler
|
|
146
|
+
// compares literal lists at the guards option; this catches a list
|
|
147
|
+
// typed as any string and a caller the compiler did not see.
|
|
148
|
+
for (const [name, guard] of Object.entries(options.guards ?? {})) {
|
|
149
|
+
if (!this.guardDeclarations || !Object.prototype.hasOwnProperty.call(this.guardDeclarations, name))
|
|
150
|
+
continue;
|
|
151
|
+
const own = new Set(Array.isArray(guard?.refusals) ? guard.refusals : []);
|
|
152
|
+
const declared = new Set(this.guardDeclarations[name].refusals ?? []);
|
|
153
|
+
if (own.size !== declared.size || [...declared].some((code) => !own.has(code))) {
|
|
154
|
+
const listed = (codes) => codes.size ? [...codes].map((code) => `"${code}"`).join(", ") : "none";
|
|
155
|
+
throw new Error(`LambderMockApp: the mock guard "${name}" declares the refusals ${listed(own)}, and the server's guard of that name declares ${listed(declared)} in the guardDeclarations table. A mock guard declares exactly the server guard's codes: regenerate the table, or give the mock guard refusals: [${[...declared].map((code) => `"${code}"`).join(", ")}].`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
113
158
|
this.refusalVocabulary = declared?.vocabulary ?? null;
|
|
114
159
|
this.requireRefusalCodes = declared?.requireCodes ?? false;
|
|
115
160
|
if (this.requireRefusalCodes && !this.apiOptions) {
|
|
@@ -117,6 +162,7 @@ export class LambderMockApp {
|
|
|
117
162
|
}
|
|
118
163
|
this.failures = new LambderMockFailureInjector({ apiVersion: this.apiVersion, latency: options.latency ?? 0 });
|
|
119
164
|
this.recorder = new LambderMockCallRecorder({ callLogSize: options.callLogSize ?? DEFAULT_CALL_LOG_SIZE });
|
|
165
|
+
this.browserCookies.adoptJar(this.pageCookieJar);
|
|
120
166
|
// The loopback address when nothing names a client, as for a request
|
|
121
167
|
// from the page itself (the in-process handler transport's default
|
|
122
168
|
// too). Normalized once here because the direct transport, the MSW
|
|
@@ -160,7 +206,7 @@ export class LambderMockApp {
|
|
|
160
206
|
? {
|
|
161
207
|
manager: new LambderSessionManager({
|
|
162
208
|
store: sessionOptions.store ?? memorySessionStore,
|
|
163
|
-
sessionSalt: sessionOptions.sessionSalt ??
|
|
209
|
+
sessionSalt: sessionOptions.sessionSalt ?? DEFAULT_MOCK_SESSION_SALT,
|
|
164
210
|
enableSlidingExpiration: sessionOptions.enableSlidingExpiration,
|
|
165
211
|
slidingWriteIntervalSeconds: sessionOptions.slidingWriteIntervalSeconds,
|
|
166
212
|
// As given: the manager refuses dataRefresh without
|
|
@@ -202,6 +248,9 @@ export class LambderMockApp {
|
|
|
202
248
|
callerIdentity: idempotencyOptions.callerIdentity,
|
|
203
249
|
}
|
|
204
250
|
: undefined,
|
|
251
|
+
// As on the server: with sessions on, their salt keys the digest
|
|
252
|
+
// of the caller's fields in rate-limit and idempotency keys.
|
|
253
|
+
atRestSecret: sessionOptions ? sessionOptions.sessionSalt ?? DEFAULT_MOCK_SESSION_SALT : undefined,
|
|
205
254
|
});
|
|
206
255
|
}
|
|
207
256
|
// -----------------------------------------------------------------------
|
|
@@ -291,6 +340,18 @@ export class LambderMockApp {
|
|
|
291
340
|
return null;
|
|
292
341
|
return this.apiOptions[name].mode;
|
|
293
342
|
}
|
|
343
|
+
/**
|
|
344
|
+
* The schema an entry's input is validated with: the server's, from the
|
|
345
|
+
* apiSchemas table when create() was given it, then the entry's own where
|
|
346
|
+
* it restates one, run on what the server's leaves (the payload stripped
|
|
347
|
+
* and defaulted as on the server). Either alone where only one is given.
|
|
348
|
+
*/
|
|
349
|
+
entryInputOf(name, own) {
|
|
350
|
+
if (!this.apiSchemas)
|
|
351
|
+
return own;
|
|
352
|
+
const server = this.apiSchemas.inputOf(name);
|
|
353
|
+
return own ? server.pipe(own) : server;
|
|
354
|
+
}
|
|
294
355
|
buildEntry(name, input) {
|
|
295
356
|
const options = (typeof input === "function" ? { handler: input } : input);
|
|
296
357
|
const declared = this.declaredOptionsOf(name);
|
|
@@ -316,7 +377,7 @@ export class LambderMockApp {
|
|
|
316
377
|
// validator that is not a zod schema reach the 422 body as
|
|
317
378
|
// `zodError: { name: undefined, message: undefined, issues:
|
|
318
379
|
// undefined }`, a refusal a client cannot read.
|
|
319
|
-
input: options.input,
|
|
380
|
+
input: this.entryInputOf(name, options.input),
|
|
320
381
|
};
|
|
321
382
|
this.assertEntryRegistration(definition);
|
|
322
383
|
const handler = options.handler;
|
|
@@ -441,18 +502,6 @@ export class LambderMockApp {
|
|
|
441
502
|
get registeredNames() {
|
|
442
503
|
return this.registry.names;
|
|
443
504
|
}
|
|
444
|
-
/**
|
|
445
|
-
* Whether a call to this name would be answered from the registry, which
|
|
446
|
-
* is what an adapter asks before passing one on.
|
|
447
|
-
*
|
|
448
|
-
* True for every name once a rest entry is registered, since it answers
|
|
449
|
-
* whatever nothing else claimed. That makes a rest entry and the MSW
|
|
450
|
-
* adapter's `onUnmocked: "passthrough"` alternatives rather than layers:
|
|
451
|
-
* with one registered, nothing is handed on to the network.
|
|
452
|
-
*/
|
|
453
|
-
hasRegisteredEntry(apiName) {
|
|
454
|
-
return this.entryFor(apiName) !== null || this.registry.restNotMockedReason !== null;
|
|
455
|
-
}
|
|
456
505
|
entryFor(apiName) {
|
|
457
506
|
return this.registry.entryFor(apiName);
|
|
458
507
|
}
|
|
@@ -475,7 +524,7 @@ export class LambderMockApp {
|
|
|
475
524
|
// -----------------------------------------------------------------------
|
|
476
525
|
// Control surface
|
|
477
526
|
// -----------------------------------------------------------------------
|
|
478
|
-
/** The next call to the endpoint fails this way; several calls queue in order. An injected refusal names one of the endpoint's declared codes, its data as a handler raises it (see LambderMockRefusalsOf), and is checked and sent as a real one is. */
|
|
527
|
+
/** The next call to the endpoint fails this way; several calls queue in order. An injected refusal names one of the endpoint's declared codes, its data as a handler raises it (see LambderMockRefusalsOf), and is checked and sent as a real one is. An injected sessionExpired, or a refusal whose code is declared so, ends the session the call carries, as the server's does. */
|
|
479
528
|
failNext(apiName, failure) {
|
|
480
529
|
this.failures.failNext(apiName, failure);
|
|
481
530
|
}
|
|
@@ -493,13 +542,14 @@ export class LambderMockApp {
|
|
|
493
542
|
/**
|
|
494
543
|
* Rewinds the runtime: sessions, rate-limit counters, replay records,
|
|
495
544
|
* overrides, injected failures, the offline switch, the configured
|
|
496
|
-
* latency, the call log and its numbering, the cookies its own
|
|
497
|
-
*
|
|
545
|
+
* latency, the call log and its numbering, the cookies its own jars hold,
|
|
546
|
+
* then onReset, so the app rewinds its own data too.
|
|
498
547
|
*
|
|
499
548
|
* The cookies matter as much as the sessions: a jar still holding the
|
|
500
549
|
* token of an emptied store's session reads as signed in until an answer
|
|
501
|
-
* says sessionExpired. So every jar transport() built
|
|
502
|
-
* emptied, and the cookies
|
|
550
|
+
* says sessionExpired. So the page's jar and every jar transport() built
|
|
551
|
+
* for itself are emptied, and the cookies mirrored into document.cookie
|
|
552
|
+
* are expired at the scope each was set at.
|
|
503
553
|
*
|
|
504
554
|
* The registry survives, being configuration rather than accumulated
|
|
505
555
|
* state. Subscriptions survive too, being how a test watches the runtime;
|
|
@@ -539,9 +589,10 @@ export class LambderMockApp {
|
|
|
539
589
|
/**
|
|
540
590
|
* Starts a session without a login endpoint: creates it through the
|
|
541
591
|
* session controller, the way a login handler does, plants its cookies
|
|
542
|
-
* into the jar
|
|
543
|
-
* from its next call, and
|
|
544
|
-
* the
|
|
592
|
+
* into the jar given, or the page's (pageCookieJar) when none is, so that
|
|
593
|
+
* jar's transport or the MSW adapter is signed in from its next call, and
|
|
594
|
+
* mirrors the readable ones into document.cookie the way an answer's
|
|
595
|
+
* cookies are. Returns the raw tokens too.
|
|
545
596
|
*/
|
|
546
597
|
async signIn(sessionKey, data, options = {}) {
|
|
547
598
|
this.assertSessionsConfigured("signIn()");
|
|
@@ -560,7 +611,7 @@ export class LambderMockApp {
|
|
|
560
611
|
// every Domain against the sending host and refuses one it cannot
|
|
561
612
|
// check, so unscoped, the session cookie of an app that configures a
|
|
562
613
|
// cookie domain would be dropped silently.
|
|
563
|
-
options.jar
|
|
614
|
+
(options.jar ?? this.pageCookieJar).storeSetCookies(setCookies, { host });
|
|
564
615
|
// Through the same mirror every other cookie writer uses. Behind the
|
|
565
616
|
// MSW adapter the page's caller reads its CSRF token from
|
|
566
617
|
// document.cookie, not the jar, so a signIn that only filled a jar
|
|
@@ -571,8 +622,8 @@ export class LambderMockApp {
|
|
|
571
622
|
}
|
|
572
623
|
/**
|
|
573
624
|
* Ends every session of the subject ("log this subject out everywhere")
|
|
574
|
-
* and clears what signIn planted: the cookies in the jar given,
|
|
575
|
-
* copies in document.cookie.
|
|
625
|
+
* and clears what signIn planted: the cookies in the jar given, or the
|
|
626
|
+
* page's when none is, and the copies in document.cookie.
|
|
576
627
|
*
|
|
577
628
|
* Symmetric on purpose, the way reset() is. The records alone leave the
|
|
578
629
|
* jar and the page carrying a token for a session that no longer exists,
|
|
@@ -589,7 +640,7 @@ export class LambderMockApp {
|
|
|
589
640
|
serializeClearCookie(this.tokenCookieKey, { ...this.sessionCookieOptions, httpOnly: true }, host),
|
|
590
641
|
serializeClearCookie(this.csrfCookieKey, this.sessionCookieOptions, host),
|
|
591
642
|
];
|
|
592
|
-
options.jar
|
|
643
|
+
(options.jar ?? this.pageCookieJar).storeSetCookies(cleared, { host });
|
|
593
644
|
this.browserCookies.mirrorSetCookies(cleared);
|
|
594
645
|
}
|
|
595
646
|
/** Marks the subject's session data stale, so the next read renews it through dataRefresh. */
|
|
@@ -672,18 +723,6 @@ export class LambderMockApp {
|
|
|
672
723
|
at,
|
|
673
724
|
};
|
|
674
725
|
}
|
|
675
|
-
/**
|
|
676
|
-
* Records a call an adapter handed on instead of answering: the MSW
|
|
677
|
-
* adapter's passthrough. Without it a name the registry does not know
|
|
678
|
-
* leaves no trace at all, and a mistyped endpoint reaches the real
|
|
679
|
-
* network with nothing in the call log or on the subscription to say so,
|
|
680
|
-
* which is the one failure the log exists to make visible.
|
|
681
|
-
*/
|
|
682
|
-
notePassthrough(request) {
|
|
683
|
-
const facts = this.callFacts(this.recorder.nextCallId(), request, null);
|
|
684
|
-
this.emit(this.requestEvent(facts.id, request, null, facts.startedAt));
|
|
685
|
-
this.recorder.settle(facts, { answer: null, outcome: "passthrough", guardsRun: [] });
|
|
686
|
-
}
|
|
687
726
|
/** What every record of one call repeats (see LambderMockCallFacts). */
|
|
688
727
|
callFacts(id, request, mode) {
|
|
689
728
|
return { id, apiName: request.apiName, mode, startedAt: Date.now(), request };
|
|
@@ -732,7 +771,21 @@ export class LambderMockApp {
|
|
|
732
771
|
// normally.
|
|
733
772
|
const failure = this.failures.take(request.apiName);
|
|
734
773
|
if (failure) {
|
|
735
|
-
|
|
774
|
+
const injected = await this.failures.answerFor(failure, request, entry?.definition.refusals);
|
|
775
|
+
// An answer saying the session is over ends the session the
|
|
776
|
+
// call carries, as on the server: there sessionExpired means
|
|
777
|
+
// the read found none, and a refusal flagged so deletes it.
|
|
778
|
+
// Left alive, the caller's next call would be signed in.
|
|
779
|
+
// The cookies stay, as there.
|
|
780
|
+
if (injected.endsSession && this.pipeline.hasSessions) {
|
|
781
|
+
const manager = this.pipeline.sessionManager;
|
|
782
|
+
for (const token of request.cookies[this.tokenCookieKey] ?? []) {
|
|
783
|
+
const session = await manager.lookupSession(token);
|
|
784
|
+
if (session)
|
|
785
|
+
await manager.deleteSession(session);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
answer = injected.answer;
|
|
736
789
|
outcome = "injected";
|
|
737
790
|
}
|
|
738
791
|
else if (prepared) {
|
|
@@ -748,7 +801,11 @@ export class LambderMockApp {
|
|
|
748
801
|
? async (callCtx) => {
|
|
749
802
|
// The payload the handler sees is the restored one.
|
|
750
803
|
callCtx.payload = request.payload;
|
|
751
|
-
const
|
|
804
|
+
const returned = await handler(callCtx);
|
|
805
|
+
// Through the server's output schema when create() was
|
|
806
|
+
// given the table, as the server sends every answer:
|
|
807
|
+
// what it does not declare dropped, its defaults filled.
|
|
808
|
+
const payload = this.apiSchemas ? this.apiSchemas.parseOutput(request.apiName, returned) : returned;
|
|
752
809
|
// The server's rule, where the server applies it: an
|
|
753
810
|
// answer is an object or an array, and a handler that
|
|
754
811
|
// returns anything else has broken its contract.
|
|
@@ -870,29 +927,10 @@ export class LambderMockApp {
|
|
|
870
927
|
// would keep it out of document.cookie.
|
|
871
928
|
return transportCarrying(async (request) => {
|
|
872
929
|
const answer = await withJar(request);
|
|
873
|
-
this.
|
|
930
|
+
this.browserCookies.mirrorSetCookies(answer.setCookies ?? []);
|
|
874
931
|
return answer;
|
|
875
932
|
}, jar);
|
|
876
933
|
}
|
|
877
|
-
/**
|
|
878
|
-
* Mirrors an answer's non-HttpOnly cookies into document.cookie and
|
|
879
|
-
* remembers them, so reset() expires them again. The direct transport's
|
|
880
|
-
* "document" mode and the MSW adapter both come through here: one
|
|
881
|
-
* implementation of the mirror, one record of what was planted.
|
|
882
|
-
*/
|
|
883
|
-
mirrorCookiesIntoDocument(setCookies) {
|
|
884
|
-
this.browserCookies.mirrorSetCookies(setCookies);
|
|
885
|
-
}
|
|
886
|
-
/**
|
|
887
|
-
* Takes a jar an adapter built for itself as the runtime's own, so reset()
|
|
888
|
-
* empties it with the rest. The MSW adapter's jar holds the session
|
|
889
|
-
* cookies of calls that never touch transport(); left full after a reset,
|
|
890
|
-
* the next request would carry a token for a session the emptied store no
|
|
891
|
-
* longer has.
|
|
892
|
-
*/
|
|
893
|
-
adoptCookieJar(jar) {
|
|
894
|
-
this.browserCookies.adoptJar(jar);
|
|
895
|
-
}
|
|
896
934
|
/** caller.setTransport(mockApp.transport(options)); returns the transport, its jar on it. */
|
|
897
935
|
attach(caller, options = {}) {
|
|
898
936
|
const transport = this.transport(options);
|
|
@@ -918,8 +956,15 @@ export class LambderMockApp {
|
|
|
918
956
|
*/
|
|
919
957
|
/** The mock init bound to one vocabulary (or none): the builders, refuse and create() that share it. */
|
|
920
958
|
const lambderMockInitOf = (declared) => ({
|
|
921
|
-
/**
|
|
922
|
-
|
|
959
|
+
/**
|
|
960
|
+
* Builds a mock guard: the server guard's shape, the handler seeing the
|
|
961
|
+
* mock's contexts. Bound to the vocabulary the mock declared, as the
|
|
962
|
+
* server init's builder is to the server's: its ctx.refuse is typed to the
|
|
963
|
+
* guard's own `refusals`, data in the schema's input form, and a code the
|
|
964
|
+
* vocabulary does not hold is refused as the guard is built. A mock that
|
|
965
|
+
* declared none checks nothing here.
|
|
966
|
+
*/
|
|
967
|
+
guard: lambderGuardBuilder(declared ? declared.vocabulary : undefined),
|
|
923
968
|
/**
|
|
924
969
|
* Builds a mock rate-limit key, the counterpart of `guard`. Bound to the
|
|
925
970
|
* mock's own call context, because the server's lambderRateLimitKey() is
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parseSetCookie } from "../shared/transport/LambderCookieJar.js";
|
|
2
|
+
import { serializeClearCookie } from "../shared/wire/LambderCookie.js";
|
|
2
3
|
/**
|
|
3
4
|
* Where the runtime's cookies live outside its own answers: the jars it built
|
|
4
5
|
* for itself, and the copies it planted in the page's cookie storage. A
|
|
@@ -42,13 +43,12 @@ export class LambderMockBrowserCookies {
|
|
|
42
43
|
mirrorSetCookies(setCookies) {
|
|
43
44
|
if (typeof document === "undefined")
|
|
44
45
|
return;
|
|
45
|
-
const secureContext = typeof globalThis.isSecureContext === "boolean" ? globalThis.isSecureContext : true;
|
|
46
46
|
for (const header of setCookies) {
|
|
47
47
|
const cookie = parseSetCookie(header, Date.now());
|
|
48
48
|
if (!cookie || cookie.httpOnly)
|
|
49
49
|
continue;
|
|
50
|
-
|
|
51
|
-
this.mirroredCookies.set(`${cookie.name}|${cookie.path}`, { name: cookie.name, path: cookie.path });
|
|
50
|
+
writeDocumentCookie(header);
|
|
51
|
+
this.mirroredCookies.set(`${cookie.name}|${cookie.domain ?? ""}|${cookie.path}`, { name: cookie.name, domain: cookie.domain, path: cookie.path });
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
/**
|
|
@@ -64,9 +64,22 @@ export class LambderMockBrowserCookies {
|
|
|
64
64
|
for (const jar of this.ownedJars)
|
|
65
65
|
jar.clear();
|
|
66
66
|
if (typeof document !== "undefined") {
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
// At the Domain and Path each was set with: a deletion reaches
|
|
68
|
+
// only the cookie of the same name at the same scope, so one
|
|
69
|
+
// planted under the app's cookie domain would outlive a deletion
|
|
70
|
+
// naming the path alone.
|
|
71
|
+
for (const { name, domain, path } of this.mirroredCookies.values())
|
|
72
|
+
writeDocumentCookie(serializeClearCookie(name, { domain, path }));
|
|
69
73
|
}
|
|
70
74
|
this.mirroredCookies.clear();
|
|
71
75
|
}
|
|
72
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* One write into document.cookie, `Secure` dropped where the page is not a
|
|
79
|
+
* secure context: the browser refuses such a write there, and plain http on
|
|
80
|
+
* a LAN address has to keep working.
|
|
81
|
+
*/
|
|
82
|
+
const writeDocumentCookie = (header) => {
|
|
83
|
+
const secureContext = typeof globalThis.isSecureContext === "boolean" ? globalThis.isSecureContext : true;
|
|
84
|
+
document.cookie = secureContext ? header : header.replace(/;\s*Secure\b/i, "");
|
|
85
|
+
};
|
|
@@ -6,6 +6,7 @@ import type { MaybePromise } from "../shared/util/LambderTypeUtilities.js";
|
|
|
6
6
|
import type { LambderContractGuardNames, LambderContractIdempotencyOf, LambderContractKeysWithMode, LambderContractMode, LambderContractRateLimitNames, LambderContractRateLimitOf } from "../shared/wire/LambderApiContract.js";
|
|
7
7
|
import type { LambderApiGuard } from "../api/LambderApiGuards.js";
|
|
8
8
|
import type { LambderApiOptionEntry, LambderGuardDeclarationEntry } from "../shared/wire/LambderApiOptionEntries.js";
|
|
9
|
+
import type { LambderApiSchemaEntry } from "../shared/wire/LambderApiSchemaEntries.js";
|
|
9
10
|
import type { LambderApiRateLimitPolicyConfig } from "../api/LambderApiRateLimits.js";
|
|
10
11
|
import type { LambderApiRequest } from "../api/LambderApiRequest.js";
|
|
11
12
|
import type { LambderApiTransport } from "../shared/transport/LambderApiTransport.js";
|
|
@@ -141,9 +142,28 @@ export type LambderMockGuardShapeOf<D> = (D extends {
|
|
|
141
142
|
} : {
|
|
142
143
|
session?: false | undefined;
|
|
143
144
|
});
|
|
144
|
-
/**
|
|
145
|
+
/** The refusal codes a guard or a guard declaration names, as a union: never for one that names none, string for a list typed no narrower. */
|
|
146
|
+
type LambderMockRefusalCodesIn<T> = T extends {
|
|
147
|
+
refusals?: infer R;
|
|
148
|
+
} ? (NonNullable<R> extends readonly (infer N extends string)[] ? N : never) : never;
|
|
149
|
+
/**
|
|
150
|
+
* A mock guard's own `refusals` held to the codes the server's guard of its
|
|
151
|
+
* name declares in the generated table: the same codes, in any order. They
|
|
152
|
+
* type the mock guard's ctx.refuse and are what checkApiRefusals holds the
|
|
153
|
+
* guard to, so a mock guard declaring other codes than the server's would be
|
|
154
|
+
* checked against a guard that does not exist. A list typed as any string,
|
|
155
|
+
* on either side, says nothing to compare; create() compares it.
|
|
156
|
+
*/
|
|
157
|
+
type LambderMockGuardRefusalsAgree<TGuard, TDeclaration> = string extends LambderMockRefusalCodesIn<TGuard> | LambderMockRefusalCodesIn<TDeclaration> ? unknown : [LambderMockRefusalCodesIn<TGuard>] extends [LambderMockRefusalCodesIn<TDeclaration>] ? ([LambderMockRefusalCodesIn<TDeclaration>] extends [LambderMockRefusalCodesIn<TGuard>] ? unknown : LambderMockGuardRefusalsMismatch<TDeclaration>) : LambderMockGuardRefusalsMismatch<TDeclaration>;
|
|
158
|
+
/** The error a disagreeing guard meets: the property name is the message, and its value the codes the server's guard declares. */
|
|
159
|
+
type LambderMockGuardRefusalsMismatch<TDeclaration> = {
|
|
160
|
+
readonly "lambder: this mock guard's refusals are not the codes the server's guard of its name declares in guardDeclarations; declare exactly these": TDeclaration extends {
|
|
161
|
+
refusals: infer R;
|
|
162
|
+
} ? R : readonly [];
|
|
163
|
+
};
|
|
164
|
+
/** Each mock guard the declarations know held to its declared shape and refusal codes; a guard the table does not have is free. */
|
|
145
165
|
type LambderMockGuardsAgree<G, D> = {
|
|
146
|
-
[N in keyof G & keyof D]: G[N] extends LambderMockGuardShapeOf<D[N]> ? unknown : LambderMockGuardShapeOf<D[N]>;
|
|
166
|
+
[N in keyof G & keyof D]: (G[N] extends LambderMockGuardShapeOf<D[N]> ? unknown : LambderMockGuardShapeOf<D[N]>) & LambderMockGuardRefusalsAgree<G[N], D[N]>;
|
|
147
167
|
};
|
|
148
168
|
/**
|
|
149
169
|
* The guards option: required whenever the contract declares any guard name,
|
|
@@ -211,12 +231,23 @@ export type LambderMockApiOptionsCover<C> = {
|
|
|
211
231
|
mode: LambderContractMode<C, K>;
|
|
212
232
|
};
|
|
213
233
|
};
|
|
234
|
+
/**
|
|
235
|
+
* What the generated `apiSchemas` table has to hold: an entry for every
|
|
236
|
+
* endpoint the contract declares. A table generated before an endpoint was
|
|
237
|
+
* added is a compile error at the option rather than a throw when that
|
|
238
|
+
* endpoint's entry registers.
|
|
239
|
+
*/
|
|
240
|
+
export type LambderMockApiSchemasCover<C> = {
|
|
241
|
+
readonly [K in keyof C & string]: LambderApiSchemaEntry;
|
|
242
|
+
};
|
|
214
243
|
export type LambderMockAppOptions<C, S, G, P extends LambderMockRateLimitPolicies<S> = LambderMockRateLimitPolicies<S>, I extends boolean | LambderMockIdempotencyOptions<S> = boolean | LambderMockIdempotencyOptions<S>, D extends Record<string, LambderGuardDeclarationEntry> = {}, A extends Record<string, LambderApiOptionEntry> | undefined = undefined> = LambderMockGuardsOption<C, S, G, D> & LambderMockSessionsOption<C, S> & LambderMockIdempotencyOption<C, S, I> & LambderMockRateLimitsOption<C, S, P> & {
|
|
215
244
|
/**
|
|
216
245
|
* The server's guard declarations, as the generated options module
|
|
217
246
|
* exports them (`guardDeclarations`). Given, every mock guard of a name
|
|
218
|
-
* the table has is held to its input mode
|
|
219
|
-
* the `guards` option (see LambderMockGuardShapeOf)
|
|
247
|
+
* the table has is held to its input mode, session requirement and
|
|
248
|
+
* refusal codes at the `guards` option (see LambderMockGuardShapeOf), and
|
|
249
|
+
* create() compares the refusal codes again, for a list typed no narrower
|
|
250
|
+
* than strings or a caller the compiler did not see. Nothing runs on it.
|
|
220
251
|
*/
|
|
221
252
|
guardDeclarations?: D;
|
|
222
253
|
/**
|
|
@@ -230,6 +261,22 @@ export type LambderMockAppOptions<C, S, G, P extends LambderMockRateLimitPolicie
|
|
|
230
261
|
* contract (see LambderMockApiOptionsCover).
|
|
231
262
|
*/
|
|
232
263
|
apiOptions?: A & LambderMockApiOptionsCover<C>;
|
|
264
|
+
/**
|
|
265
|
+
* The server's input and output schemas per API, as the generated schemas
|
|
266
|
+
* module exports them (`apiSchemas`, written by writeApiSchemas). Given,
|
|
267
|
+
* the input of every call a handler answers is validated against the
|
|
268
|
+
* server's input schema and refused as the server refuses it (422, or
|
|
269
|
+
* what onInvalidInput answers), the handler reads the payload as the
|
|
270
|
+
* server's parse leaves it, and every answer is parsed through the
|
|
271
|
+
* server's output schema, which drops what it does not declare and fills
|
|
272
|
+
* its defaults. An entry's own `input` schema still applies, to what the
|
|
273
|
+
* server's leaves. Each schema is rebuilt on the first call that needs
|
|
274
|
+
* it. Refinements and transforms are not in the table (writeApiSchemas
|
|
275
|
+
* lists them). The module names every endpoint: import it from the mock's
|
|
276
|
+
* setup alone, which a production build never loads. The table has to
|
|
277
|
+
* cover the contract (see LambderMockApiSchemasCover).
|
|
278
|
+
*/
|
|
279
|
+
apiSchemas?: LambderMockApiSchemasCover<C>;
|
|
233
280
|
/** Stamped on every answer's envelope as apiVersion, as the server's option is. */
|
|
234
281
|
apiVersion?: string;
|
|
235
282
|
/** The version floor, as on the server: a call naming a lower `version` answers versionExpired whatever its signature says. */
|
|
@@ -2,10 +2,16 @@ import { type LambderEndpointRefusals } from "../api/LambderApiRefusals.js";
|
|
|
2
2
|
import type { LambderApiAnswer } from "../api/LambderApiAnswer.js";
|
|
3
3
|
import type { LambderApiRequest } from "../api/LambderApiRequest.js";
|
|
4
4
|
import type { LambderMockFailure, LambderMockFailureReason, LambderMockLatency } from "./LambderMockTypes.js";
|
|
5
|
+
/** What an injected failure answers, and whether that answer ends the session the call carries. */
|
|
6
|
+
type LambderMockInjectionResult = {
|
|
7
|
+
answer: LambderApiAnswer;
|
|
8
|
+
endsSession: boolean;
|
|
9
|
+
};
|
|
5
10
|
/**
|
|
6
11
|
* A failure the transport reports rather than an answer the caller reads:
|
|
7
12
|
* an injected network failure or timeout. The caller maps a rejected
|
|
8
|
-
* transport to `network`, or `timeout` when
|
|
13
|
+
* transport to `network`, or to `timeout` or `aborted` when the call's own
|
|
14
|
+
* timeout or signal had aborted it.
|
|
9
15
|
*/
|
|
10
16
|
export declare class LambderMockTransportError extends Error {
|
|
11
17
|
readonly reason: "network" | "timeout" | "offline";
|
|
@@ -56,9 +62,12 @@ export declare class LambderMockFailureInjector {
|
|
|
56
62
|
* never reach the caller as answers. An injected refusal is checked
|
|
57
63
|
* against the endpoint's declared codes (`endpoint`), as the pipeline
|
|
58
64
|
* checks a real one, so a test cannot inject what the server could never
|
|
59
|
-
* send.
|
|
65
|
+
* send. `endsSession` says the answer tells the caller its session is
|
|
66
|
+
* over (sessionExpired, or a refusal whose code is declared so), which
|
|
67
|
+
* the runtime then makes true.
|
|
60
68
|
*/
|
|
61
|
-
answerFor(failure: LambderMockFailure, request: LambderApiRequest, endpoint: LambderEndpointRefusals | undefined): Promise<
|
|
69
|
+
answerFor(failure: LambderMockFailure, request: LambderApiRequest, endpoint: LambderEndpointRefusals | undefined): Promise<LambderMockInjectionResult>;
|
|
62
70
|
/** Clears every injected failure and puts the configured latency back. */
|
|
63
71
|
reset(): void;
|
|
64
72
|
}
|
|
73
|
+
export {};
|
|
@@ -8,7 +8,8 @@ const toRefusalMessage = (message, fallback) => typeof message === "string" ? {
|
|
|
8
8
|
/**
|
|
9
9
|
* A failure the transport reports rather than an answer the caller reads:
|
|
10
10
|
* an injected network failure or timeout. The caller maps a rejected
|
|
11
|
-
* transport to `network`, or `timeout` when
|
|
11
|
+
* transport to `network`, or to `timeout` or `aborted` when the call's own
|
|
12
|
+
* timeout or signal had aborted it.
|
|
12
13
|
*/
|
|
13
14
|
export class LambderMockTransportError extends Error {
|
|
14
15
|
reason;
|
|
@@ -112,16 +113,19 @@ export class LambderMockFailureInjector {
|
|
|
112
113
|
* never reach the caller as answers. An injected refusal is checked
|
|
113
114
|
* against the endpoint's declared codes (`endpoint`), as the pipeline
|
|
114
115
|
* checks a real one, so a test cannot inject what the server could never
|
|
115
|
-
* send.
|
|
116
|
+
* send. `endsSession` says the answer tells the caller its session is
|
|
117
|
+
* over (sessionExpired, or a refusal whose code is declared so), which
|
|
118
|
+
* the runtime then makes true.
|
|
116
119
|
*/
|
|
117
120
|
async answerFor(failure, request, endpoint) {
|
|
118
121
|
switch (failure.reason) {
|
|
119
122
|
case "network": throw new LambderMockTransportError("network");
|
|
120
123
|
case "timeout": return await this.waitForAbort(request.signal);
|
|
121
|
-
case "server": return crashAnswer(this.apiVersion);
|
|
124
|
+
case "server": return { answer: crashAnswer(this.apiVersion), endsSession: false };
|
|
122
125
|
case "refusal": {
|
|
123
126
|
const message = toRefusalMessage(failure.message, "Injected refusal.");
|
|
124
|
-
|
|
127
|
+
const checked = checkedRefusal(request.apiName, endpoint, new LambderApiRefusal(message.content, { refusal: message, statusCode: failure.statusCode }));
|
|
128
|
+
return { answer: refusalAnswer(checked, this.apiVersion), endsSession: checked.sessionExpired === true };
|
|
125
129
|
}
|
|
126
130
|
case "notAuthorized": {
|
|
127
131
|
const message = toRefusalMessage(failure.message, "Injected authorization refusal.");
|
|
@@ -132,11 +136,11 @@ export class LambderMockFailureInjector {
|
|
|
132
136
|
if (!checked.notAuthorized) {
|
|
133
137
|
throw new Error(`LambderMockApp: the injected notAuthorized failure names the code "${String(message.code)}", which is not declared notAuthorized. Inject it as a refusal, or declare the flag on the code.`);
|
|
134
138
|
}
|
|
135
|
-
return refusalAnswer(checked, this.apiVersion);
|
|
139
|
+
return { answer: refusalAnswer(checked, this.apiVersion), endsSession: checked.sessionExpired === true };
|
|
136
140
|
}
|
|
137
|
-
case "sessionExpired": return sessionExpiredAnswer(this.apiVersion);
|
|
138
|
-
case "versionExpired": return versionExpiredAnswer(this.apiVersion);
|
|
139
|
-
case "rateLimited": return refusalAnswer(rateLimitRefusal("Injected rate limit.", failure.retryAfterSeconds ?? 30, failure.message), this.apiVersion);
|
|
141
|
+
case "sessionExpired": return { answer: sessionExpiredAnswer(this.apiVersion), endsSession: true };
|
|
142
|
+
case "versionExpired": return { answer: versionExpiredAnswer(this.apiVersion), endsSession: false };
|
|
143
|
+
case "rateLimited": return { answer: refusalAnswer(rateLimitRefusal("Injected rate limit.", { policy: failure.policy ?? "injected", retryAfterSeconds: failure.retryAfterSeconds ?? 30 }, failure.message), this.apiVersion), endsSession: false };
|
|
140
144
|
}
|
|
141
145
|
}
|
|
142
146
|
/** Clears every injected failure and puts the configured latency back. */
|
|
@@ -250,9 +250,10 @@ export type LambderMockEntryOptions<C, K extends keyof C, S, G, TInputSchema ext
|
|
|
250
250
|
* 422 exactly as the server would. Optional, and the mock's own: the
|
|
251
251
|
* contract is type-only, and importing the server's schemas would put the
|
|
252
252
|
* whole endpoint surface into the browser bundle. Restate it for endpoints
|
|
253
|
-
* whose rejection path a test exercises; without it
|
|
254
|
-
*
|
|
255
|
-
*
|
|
253
|
+
* whose rejection path a test exercises; without it, or the generated
|
|
254
|
+
* `apiSchemas` table given to create() (after which this one runs), a bad
|
|
255
|
+
* payload reaches the handler. What it parses to is pinned to the
|
|
256
|
+
* contract's input (see LambderMockInputPin).
|
|
256
257
|
*/
|
|
257
258
|
input?: TInputSchema & LambderMockInputPin<C, K, TInputSchema>;
|
|
258
259
|
handler: LambderMockHandler<C, K, S, G, TVocabulary, TCodesRequired>;
|
|
@@ -377,7 +378,9 @@ export type LambderMockLatency = number | {
|
|
|
377
378
|
* external signal, or for ever, which is what a timeout is). TMessage is the
|
|
378
379
|
* endpoint's refusal message, so an injected refusal names one of its
|
|
379
380
|
* declared codes with that code's data, and is checked against the
|
|
380
|
-
* declaration as a real one is.
|
|
381
|
+
* declaration as a real one is. An injected rate limit names the policy its
|
|
382
|
+
* data carries (`policy`, default "injected"), so a client's per-policy
|
|
383
|
+
* wording can be tested.
|
|
381
384
|
*/
|
|
382
385
|
export type LambderMockFailure<TMessage extends LambderUncheckedRefusalMessage = LambderUncheckedRefusalMessage> = {
|
|
383
386
|
reason: "network";
|
|
@@ -398,6 +401,7 @@ export type LambderMockFailure<TMessage extends LambderUncheckedRefusalMessage =
|
|
|
398
401
|
reason: "versionExpired";
|
|
399
402
|
} | {
|
|
400
403
|
reason: "rateLimited";
|
|
404
|
+
policy?: string;
|
|
401
405
|
retryAfterSeconds?: number;
|
|
402
406
|
message?: LambderRateLimitMessage;
|
|
403
407
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { apiNameOfCallPath } from "../shared/wire/LambderApiNames.js";
|
|
2
2
|
import { DEFAULT_API_PATH } from "../shared/wire/LambderDefaultApiPath.js";
|
|
3
|
-
import {
|
|
3
|
+
import { readApiEnvelopeText, cookieValuesByName, isApiCallContentType, lowercaseHeaderNames } from "../api/LambderApiRequest.js";
|
|
4
4
|
import { getAnswerHeader } from "../shared/wire/LambderAnswerHeaders.js";
|
|
5
5
|
import { base64ToText } from "../shared/util/LambderBase64.js";
|
|
6
6
|
import { normalizeClientIp } from "../shared/util/LambderClientIp.js";
|
|
@@ -15,13 +15,6 @@ export const lambderMockInvokeTransport = (mockApp, options = {}) => async (even
|
|
|
15
15
|
const apiPath = options.apiPath ?? DEFAULT_API_PATH;
|
|
16
16
|
const rawBody = event.body ?? "";
|
|
17
17
|
const body = event.isBase64Encoded ? base64ToText(rawBody) : rawBody;
|
|
18
|
-
let post = {};
|
|
19
|
-
try {
|
|
20
|
-
post = JSON.parse(body || "{}") ?? {};
|
|
21
|
-
}
|
|
22
|
-
catch {
|
|
23
|
-
post = {};
|
|
24
|
-
}
|
|
25
18
|
const headers = lowercaseHeaderNames(event.headers);
|
|
26
19
|
const cookies = cookieValuesByName(event.cookies ?? []);
|
|
27
20
|
// A JSON POST to `{apiPath}/{group}/{action}` is a call, as the server's
|
|
@@ -33,7 +26,7 @@ export const lambderMockInvokeTransport = (mockApp, options = {}) => async (even
|
|
|
33
26
|
const apiName = event.requestContext?.http?.method === "POST" && isApiCallContentType(headers)
|
|
34
27
|
? apiNameOfCallPath(apiPath, event.rawPath ?? "")
|
|
35
28
|
: null;
|
|
36
|
-
const request = apiName !== null &&
|
|
29
|
+
const request = apiName !== null && readApiEnvelopeText(body, {
|
|
37
30
|
headers, cookies,
|
|
38
31
|
ip: normalizeClientIp(event.requestContext?.http?.sourceIp ?? ""),
|
|
39
32
|
host: headers.host || event.requestContext?.domainName || "lambder-invoke",
|