@kensio/yulin-aws-simulation 1.17.0 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +4 -4
- package/package.json +1 -1
- package/skills/yulin-aws-simulation/SKILL.md +105 -88
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "yulin-aws-simulation",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.18.0",
|
|
5
5
|
"description": "How to test AWS code well with the @kensio/yulin in-process simulator.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Kensio Software",
|
package/README.md
CHANGED
|
@@ -38,10 +38,10 @@ Unzip it into `.agents/skills/` and it is installed.
|
|
|
38
38
|
|
|
39
39
|
## What it covers
|
|
40
40
|
|
|
41
|
-
**Deploy your real synthesized
|
|
42
|
-
`deployTemplateFile({ templatePath, stackName })`. A construct change that breaks the system
|
|
43
|
-
the test. Do not hand-roll a wrapper that reads the file and calls `deployTemplate`: the file
|
|
44
|
-
is how Yulin finds the cloud assembly beside it. A wrapper loses staged CDK assets. `transform`
|
|
41
|
+
**Deploy your real synthesized templates.** Deploy the JSON CDK produced, one template or several,
|
|
42
|
+
with `deployTemplateFile({ templatePath, stackName })`. A construct change that breaks the system
|
|
43
|
+
breaks the test. Do not hand-roll a wrapper that reads the file and calls `deployTemplate`: the file
|
|
44
|
+
path is how Yulin finds the cloud assembly beside it. A wrapper loses staged CDK assets. `transform`
|
|
45
45
|
handles what a simulation cannot resolve, such as an ARN carrying a real account or a hosted zone ID
|
|
46
46
|
from a CDK lookup, and `watch` re-applies the file on change for dev servers. `deployCdkOut` deploys
|
|
47
47
|
a whole cloud assembly, each Stack into the region its own environment names.
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: yulin-aws-simulation
|
|
3
|
-
description: Use the @kensio/yulin in-process AWS simulator well when testing AWS code, using it directly rather than building a harness around it, driving tests, local dev and production from
|
|
3
|
+
description: Use the @kensio/yulin in-process AWS simulator well when testing AWS code, using it directly rather than building a harness around it, driving tests, local dev and production from the templates a CDK app already synthesizes, deploying one of them or a whole cdk.out cloud assembly, binding real handlers to the functions a template declares, intercepting SDK clients with SimSdk, driving HTTP requests into the simulation with SimAwsHttp, controlling simulated time, reading back the names CloudFormation generates, matching service errors by name, and handling properties Yulin refuses to simulate. Use when writing or reviewing tests that touch AWS, when replacing aws-sdk-client-mock or hand-rolled AWS stubs, when a CDK stack needs testing, when a CloudFront Distribution, its DNS records or its certificate need testing, when test setup around Yulin is growing helper classes or wrapper functions, and when Yulin refuses a template property or an SDK command.
|
|
4
4
|
license: Apache-2.0
|
|
5
5
|
metadata:
|
|
6
|
-
version: "1.
|
|
6
|
+
version: "1.18.0"
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# Testing with Yulin
|
|
@@ -32,15 +32,16 @@ side effects, no network, no cleanup and no awaiting. Service accessors take the
|
|
|
32
32
|
objects the SDK does. `using simSdk = new SimSdk()` is the teardown, `deployTemplateFile` is the
|
|
33
33
|
environment, and a simulation per test is close to free.
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
second product.
|
|
35
|
+
A test constructs, deploys if a template is involved, intercepts, exercises, then reads the
|
|
36
|
+
simulation back. A wrapper around any of those steps hides what the reader needs to see. A repeated
|
|
37
|
+
sequence can be a small function in the same file returning the simulation objects themselves. Once
|
|
38
|
+
it wants a class, an options interface or a directory, it has become a second product.
|
|
40
39
|
|
|
41
|
-
## One
|
|
40
|
+
## One CDK app behind the tests, local dev and production
|
|
42
41
|
|
|
43
|
-
Describe the infrastructure once in CDK and let the synthesized output drive all three
|
|
42
|
+
Describe the infrastructure once in CDK and let the synthesized output drive all three. Deploy the
|
|
43
|
+
template files `cdk synth` wrote, one of them or all of them, and talk to the simulation the way
|
|
44
|
+
production talks to AWS.
|
|
44
45
|
|
|
45
46
|
```typescript
|
|
46
47
|
const stack = await simAws.region("eu-west-2").cloudFormation().deployTemplateFile({
|
|
@@ -51,10 +52,9 @@ const stack = await simAws.region("eu-west-2").cloudFormation().deployTemplateFi
|
|
|
51
52
|
await stack.waitForDeployComplete();
|
|
52
53
|
```
|
|
53
54
|
|
|
54
|
-
A
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
only a test needs it.
|
|
55
|
+
A hand-written template only tests itself, and a dev environment building its own buckets is a third
|
|
56
|
+
description whose drift shows up as a bug reproducing in one place of the three. Infrastructure
|
|
57
|
+
belongs in the CDK app even when only a test needs it.
|
|
58
58
|
|
|
59
59
|
**Do not hand-roll a wrapper that reads the file and calls `deployTemplate`.** `deployTemplateFile`
|
|
60
60
|
locates the cloud assembly beside the file, and that is how staged CDK assets resolve. A wrapper
|
|
@@ -76,14 +76,32 @@ await simAws.cloudFormation().deployTemplateFile({
|
|
|
76
76
|
});
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
-
|
|
80
|
-
had something to watch. The watched file is the one CDK wrote, and the adaptation re-applies on
|
|
81
|
-
every read.
|
|
79
|
+
The watched file is the one CDK wrote, and the adaptation re-applies on every read.
|
|
82
80
|
|
|
83
81
|
`stack.output("SiteBucketName")` answers a resolved Output narrowed to a string, throwing on one the
|
|
84
|
-
template never declared. Do not hand-roll that reader.
|
|
85
|
-
|
|
86
|
-
|
|
82
|
+
template never declared. Do not hand-roll that reader. A failed `cdk synth` leaves the previous
|
|
83
|
+
template in `cdk.out` with the tests still passing against it, so check the synthesized JSON changed
|
|
84
|
+
before concluding anything from a construct change.
|
|
85
|
+
|
|
86
|
+
### Read a generated name back, never write one out
|
|
87
|
+
|
|
88
|
+
A Resource whose template leaves its name out is named `<stack name>-<logical ID>-<tail>`, as an
|
|
89
|
+
account names one. The tail is twelve lowercase hex characters derived from the other two parts,
|
|
90
|
+
standing in for the twelve random ones real CloudFormation appends. Where the two parts overrun the
|
|
91
|
+
service's limit, thirteen characters of it go to the tail and its hyphen and the rest is shared
|
|
92
|
+
between them (a 64 character limit leaves 25 each). The trimmed stack name is what an IAM policy
|
|
93
|
+
scoped by resource prefix matches, and a deploy Role allowed an action on `MyVeryLongStackName-*` is
|
|
94
|
+
refused here as an account refuses it.
|
|
95
|
+
|
|
96
|
+
The same template under the same stack name generates the same name every time. That is the one
|
|
97
|
+
property tempting a test to write the name out. Read it back instead, from a `Ref`, from an
|
|
98
|
+
`Fn::GetAtt` attribute or from the accessor of the service holding it.
|
|
99
|
+
[Names CloudFormation generates](https://yulinsim.dev/services/cloudformation/#names-cloudformation-generates)
|
|
100
|
+
covers the tail and the trimming.
|
|
101
|
+
|
|
102
|
+
An ECS container binding has no way round this. The family CloudFormation generates for an unnamed
|
|
103
|
+
task definition ends in a tail only a deployment reveals. Name the task definition Resource with the
|
|
104
|
+
`logicalId` binding form.
|
|
87
105
|
|
|
88
106
|
### Register what the app looks up, deploy what the app creates
|
|
89
107
|
|
|
@@ -93,12 +111,11 @@ simulated resource carrying it comes from. Yulin stands one up at a chosen id wi
|
|
|
93
111
|
`simAws.acm().registerCertificate({ arn, domainName })`,
|
|
94
112
|
`simAws.cognitoIdentityProvider().registerUserPool({ id, name })` and `registerUserPoolClient`.
|
|
95
113
|
|
|
96
|
-
A registration creates a resource in place of the app creating one
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
deployed resource cannot be given the id its template names.
|
|
114
|
+
A registration creates a resource in place of the app creating one. It suits `HostedZone.fromLookup`
|
|
115
|
+
or a certificate issued by hand outside the app. A resource some stack in the same app creates wants
|
|
116
|
+
deploying instead, since a registration takes its configuration from somewhere other than the
|
|
117
|
+
deployed template. Substitute in a `transform` only where a deployed resource cannot be given the id
|
|
118
|
+
its template names.
|
|
102
119
|
|
|
103
120
|
Route 53 needs least of this. An `AWS::Route53::RecordSet` naming a hosted zone id no zone holds
|
|
104
121
|
gets one registered under that id as the record is created, taking its name from the records naming
|
|
@@ -128,9 +145,9 @@ const stacks = await simAws.cloudFormation().deployCdkOut({
|
|
|
128
145
|
`stackNames` picks part of an assembly, which most apps need, since most also synthesize a
|
|
129
146
|
deployment pipeline. `stackOptions` carries the `bindings`, `parameters` and `transform` that
|
|
130
147
|
`deployTemplateFile` takes for one template, keyed the same way. Its transform is handed the Stacks
|
|
131
|
-
the same call
|
|
132
|
-
|
|
133
|
-
|
|
148
|
+
the same call already deployed, so a Stack consuming a sibling's value stays inside one call. Two
|
|
149
|
+
Stacks passing a plain string between them declare no dependency for the manifest to carry, and
|
|
150
|
+
their order in `stackNames` is what puts the value there in time.
|
|
134
151
|
|
|
135
152
|
## Intercept real SDK clients, never hand-roll stubs
|
|
136
153
|
|
|
@@ -166,14 +183,10 @@ reachable as `simSdk.simAws`, and `new SimSdk({ simAws })` shares an existing on
|
|
|
166
183
|
|
|
167
184
|
A fake S3 client stubbing `send` with canned `ListObjectsV2` pages, asserted on through the
|
|
168
185
|
continuation tokens it recorded, passes for code that built its command without a `Bucket`.
|
|
169
|
-
Simulated S3
|
|
170
|
-
all apply, `IsTruncated` and `NextContinuationToken` come back as the service sends them, and
|
|
186
|
+
Simulated S3 paginates for real, down to the ETag a real multipart upload produces, and
|
|
171
187
|
`configureMaxKeysPerPage` lowers the page size so that a bucket of two objects makes a caller walk a
|
|
172
|
-
continuation.
|
|
173
|
-
|
|
174
|
-
The residue is small. A couple of answers the service never sends (a truncated page naming no
|
|
175
|
-
continuation token) can only come from a fake, and a test reaching for one should say so in a
|
|
176
|
-
comment.
|
|
188
|
+
continuation. The residue is small, and a test reaching for a fake to get an answer the service
|
|
189
|
+
never sends should say so in a comment.
|
|
177
190
|
|
|
178
191
|
## Freeze the clock and advance it deliberately
|
|
179
192
|
|
|
@@ -189,13 +202,13 @@ const simAws = new SimAws({
|
|
|
189
202
|
await simAws.clock().advanceBy({ minutes: 20 });
|
|
190
203
|
```
|
|
191
204
|
|
|
192
|
-
Time-dependent behaviour then becomes something a test asserts on in microseconds
|
|
193
|
-
the simulation keys off that clock. EventBridge rules and Scheduler schedules fire only when time
|
|
194
|
-
advanced past them, DynamoDB items pass their TTL, Secrets Manager deletions come due,
|
|
195
|
-
sessions expire, Lambda event source mappings re-poll, and inside a simulated Lambda
|
|
196
|
-
and `new Date()` report simulated time.
|
|
197
|
-
unnecessary here, since advancing this one exercises the real expiry rules of the services around
|
|
198
|
-
as well as the code's own arithmetic.
|
|
205
|
+
Time-dependent behaviour then becomes something a test asserts on in microseconds, and a good deal
|
|
206
|
+
of the simulation keys off that clock. EventBridge rules and Scheduler schedules fire only when time
|
|
207
|
+
is advanced past them, DynamoDB items pass their TTL, Secrets Manager deletions come due,
|
|
208
|
+
`AssumeRole` sessions expire, Lambda event source mappings re-poll, and inside a simulated Lambda
|
|
209
|
+
`Date.now()` and `new Date()` report simulated time. The clock stub `isolated-testing-style` allows
|
|
210
|
+
is unnecessary here, since advancing this one exercises the real expiry rules of the services around
|
|
211
|
+
it as well as the code's own arithmetic.
|
|
199
212
|
|
|
200
213
|
`simAws.clock().resume()` tracks the underlying clock and `simAws.clock().isFrozen` reports the
|
|
201
214
|
mode. Running mode suits a local dev server, and a test usually wants an advance.
|
|
@@ -213,11 +226,11 @@ A call-count assertion holds only for today's implementation. A state assertion
|
|
|
213
226
|
handler is rewritten, and it fails if the call was made in a way the real service would have
|
|
214
227
|
rejected.
|
|
215
228
|
|
|
216
|
-
The accessors sit on more than one scope
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
229
|
+
The accessors sit on more than one scope. `simAws.region(name)` carries some of the services and
|
|
230
|
+
`simAws.region(name).account()` carries all of them (`logs()` among the account-only ones), so look
|
|
231
|
+
on the other scope before concluding a service is missing. Each also takes a plain
|
|
232
|
+
`{ input: { ... } }` in place of a Command object. An assertion can then read a service back without
|
|
233
|
+
adding an `@aws-sdk/client-*` package the production code has no use for.
|
|
221
234
|
|
|
222
235
|
## Drive requests into the simulation
|
|
223
236
|
|
|
@@ -238,11 +251,11 @@ A hostname simulated Route 53 answers for is requested by its own name, with no
|
|
|
238
251
|
`localUrl(...)` adapting (an `https` URL works with no certificate set up for it). That one request
|
|
239
252
|
resolves the hostname, finds the Distribution its alias records point at, and runs the deployed
|
|
240
253
|
CloudFront Function at viewer-request. The certificate, the Hosted Zone records, the Distribution's
|
|
241
|
-
aliases and the function are covered together
|
|
242
|
-
every Route 53 record missing.
|
|
254
|
+
aliases and the function are covered together, where a template assertion over the same stack passes
|
|
255
|
+
with every Route 53 record missing.
|
|
243
256
|
|
|
244
|
-
Reach for `serveSimAws` when the request comes from outside the process
|
|
245
|
-
|
|
257
|
+
Reach for `serveSimAws` when the request comes from outside the process (a browser, `curl`, an SDK
|
|
258
|
+
client pointed at a local endpoint). Both go through the same routing and service code, and
|
|
246
259
|
`SimAwsHttp` leaves parallel test files no port to collide over. See
|
|
247
260
|
[the serving docs](https://yulinsim.dev/serve/) for the API.
|
|
248
261
|
|
|
@@ -257,9 +270,9 @@ if (error instanceof Error && error.name === "ResourceNotFoundException") { ...
|
|
|
257
270
|
```
|
|
258
271
|
|
|
259
272
|
The SDK exports exception classes, which invites the `instanceof` check. It holds only while exactly
|
|
260
|
-
one copy of the SDK package is in play
|
|
261
|
-
raising its own classes
|
|
262
|
-
error names and SDK-shaped `$metadata` without being instances of the SDK classes. Fix it in
|
|
273
|
+
one copy of the SDK package is in play, and two copies in the module graph, a bundler, or a
|
|
274
|
+
simulator raising its own classes all stop it matching silently. Yulin's errors carry the service's
|
|
275
|
+
real error names and SDK-shaped `$metadata` without being instances of the SDK classes. Fix it in
|
|
263
276
|
production code, where a version skew between two `@aws-sdk/client-*` packages breaks `instanceof`
|
|
264
277
|
too. `name` is what the wire carries, and is right in both places.
|
|
265
278
|
|
|
@@ -283,13 +296,12 @@ workaround has to be maintained in every project that hits the same gap.
|
|
|
283
296
|
|
|
284
297
|
The asymmetry matters more than the volume. A simulator staying silent about something costs little,
|
|
285
298
|
leaving that behaviour uncovered where it already was. A simulator saying 200 where production says
|
|
286
|
-
403
|
|
287
|
-
false
|
|
288
|
-
|
|
289
|
-
it is forcing structural duplication.
|
|
299
|
+
403 is the opposite of what it is for. So report a false pass with what production does and what the
|
|
300
|
+
simulation did, and a false refusal with the property and the template that carries it. Raise a gap
|
|
301
|
+
costing nothing but convenience as well, once it is forcing structural duplication.
|
|
290
302
|
|
|
291
303
|
A workaround kept while the issue is open wants a comment naming that issue and a revisit when it
|
|
292
|
-
closes. Re-read
|
|
304
|
+
closes. Re-read those claims on each upgrade. They are the ones nothing tests.
|
|
293
305
|
|
|
294
306
|
## Deploy expensive context once per test file
|
|
295
307
|
|
|
@@ -314,16 +326,18 @@ it("stores an upload", async () => {
|
|
|
314
326
|
});
|
|
315
327
|
```
|
|
316
328
|
|
|
317
|
-
A template deployment is the only thing usually worth hoisting
|
|
329
|
+
A template deployment is the only thing usually worth hoisting. In `beforeEach` it pays for the
|
|
318
330
|
whole stack once per test for no isolation you did not already have. The `SimSdk`, a seeded row and
|
|
319
|
-
a bucket key belong inside the test that needs them
|
|
320
|
-
do not all want the same state is the
|
|
331
|
+
a bucket key belong inside the test that needs them, and a `beforeEach` assembling state for tests
|
|
332
|
+
that do not all want the same state is the harness this skill opens by arguing against.
|
|
321
333
|
|
|
322
|
-
##
|
|
334
|
+
## Bind a handler and run it as a real simulated Lambda
|
|
323
335
|
|
|
324
|
-
|
|
325
|
-
function at deploy time
|
|
326
|
-
`arn`, `cdkPath` or `imageRepository
|
|
336
|
+
`bindings` is how to run your own code inside the simulation, for an `AWS::CloudFront::Function` as
|
|
337
|
+
much as for a Lambda function. Bind an in-process handler at deploy time, targeting a Lambda
|
|
338
|
+
function by `logicalId`, `functionName`, `arn`, `cdkPath` or `imageRepository`, and a CloudFront
|
|
339
|
+
Function by `logicalId`, `functionName` or `arn`. On a CloudFront Function it is what covers source
|
|
340
|
+
`cdk synth` embedded or transformed.
|
|
327
341
|
|
|
328
342
|
```typescript
|
|
329
343
|
await simAws.cloudFormation().deployTemplateFile({
|
|
@@ -358,10 +372,10 @@ accessors. A recording logger was the last reason standing, and from 1.17.1 a bo
|
|
|
358
372
|
is recorded into its log group, read back at `/aws/lambda/<function name>` through
|
|
359
373
|
`FilterLogEvents`.
|
|
360
374
|
|
|
361
|
-
The console and the process standard streams are
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
375
|
+
The console and the process standard streams are bridged for the length of an invocation, as
|
|
376
|
+
`process.env` and `Date` are. A logging library building its own `Console` over those streams at
|
|
377
|
+
module scope is recorded too, Powertools' `Logger` included, both its JSON log line and its EMF
|
|
378
|
+
metric document.
|
|
365
379
|
|
|
366
380
|
So when a test builds the application's own graph, ask what it cannot get through an invocation.
|
|
367
381
|
Expect the answer to be nothing.
|
|
@@ -370,27 +384,30 @@ Expect the answer to be nothing.
|
|
|
370
384
|
|
|
371
385
|
A bound handler gets the function's declared environment variables with nothing stubbed.
|
|
372
386
|
`SimProcessEnvironment` holds a run's variables in an `AsyncLocalStorage` store and resolves
|
|
373
|
-
`process.env` to it for the length of the run, with concurrent runs each seeing their own.
|
|
374
|
-
|
|
387
|
+
`process.env` to it for the length of the run, with concurrent runs each seeing their own. What it
|
|
388
|
+
cannot reach is a read that already happened. A handler module doing
|
|
375
389
|
`const TABLE = process.env.TABLE_NAME` at module scope is evaluated when the test file imports it,
|
|
376
390
|
long before any run, and captures the host value.
|
|
377
391
|
|
|
378
392
|
So read the environment inside the handler body, memoising there where a warm container should build
|
|
379
|
-
its clients once. The substituted `Date` works the same way
|
|
380
|
-
is the sign of a handler reading too early. `SimLambdaEnvironmentConflicts` warns about
|
|
381
|
-
only where the host value and the declared value differ, and a suite that stubs the right
|
|
382
|
-
stays quiet and never learns.
|
|
393
|
+
its clients once. The substituted `Date` works the same way, and a `vi.stubEnv` around a bound
|
|
394
|
+
handler is the sign of a handler reading too early. `SimLambdaEnvironmentConflicts` warns about
|
|
395
|
+
this, but only where the host value and the declared value differ, and a suite that stubs the right
|
|
396
|
+
values stays quiet and never learns.
|
|
383
397
|
|
|
384
|
-
###
|
|
398
|
+
### Keep the zip path for a case about the artefact
|
|
385
399
|
|
|
386
|
-
Deploying without `bindings` runs the bundle `cdk synth` produced.
|
|
387
|
-
the cloud assembly's assets into the staging bucket in simulated S3,
|
|
388
|
-
as CommonJS in a vm sandbox with its own `process.env`, `Date` and
|
|
389
|
-
module-scope problem above never arises. Both paths authorise through the
|
|
390
|
-
same policy mutation fails a zip-path test exactly as it fails a bound one.
|
|
400
|
+
Deploying a Lambda function without `bindings` runs the bundle `cdk synth` produced.
|
|
401
|
+
`deployTemplateFile` publishes the cloud assembly's assets into the staging bucket in simulated S3,
|
|
402
|
+
and the modules are evaluated as CommonJS in a vm sandbox with its own `process.env`, `Date` and
|
|
403
|
+
HTTP clients, where the module-scope problem above never arises. Both paths authorise through the
|
|
404
|
+
execution role, and the same policy mutation fails a zip-path test exactly as it fails a bound one.
|
|
391
405
|
|
|
392
|
-
|
|
393
|
-
|
|
406
|
+
What that buys is the artefact that deploys, its imports and its bundling included, and a case about
|
|
407
|
+
the bundle itself is the case to spend it on. Everywhere else it is the fallback. Every run waits on
|
|
408
|
+
the build, a stale `cdk.out` runs yesterday's handler, and the vm runtime loads CommonJS as the real
|
|
409
|
+
`nodejs` runtimes do, leaving a `NodejsFunction` synthesized with `format: OutputFormat.ESM` refused
|
|
410
|
+
at cold start.
|
|
394
411
|
|
|
395
412
|
### Outbound HTTP is answered by the simulation
|
|
396
413
|
|
|
@@ -398,7 +415,7 @@ From 1.16.2, a simulated Lambda's `fetch` and its `node:http` and `node:https` a
|
|
|
398
415
|
simulation for every hostname simulated Route 53 resolves, through the same in-process entry point a
|
|
399
416
|
request arriving on localhost uses. A Cognito user pool domain, an HTTP API and a load balancer are
|
|
400
417
|
all answered without the test knowing which of them it asked, and everything else reaches the
|
|
401
|
-
network as
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
418
|
+
network as addressed. That is what makes an OAuth authorization code exchange testable, since it
|
|
419
|
+
lives only at the pool domain's hosted `/oauth2/token` endpoint with no SDK operation behind it, and
|
|
420
|
+
it lets `CognitoJwtVerifier` fetch a simulated pool's JWKS from inside a handler with no cache
|
|
421
|
+
primed.
|