@reventlessdev/reventless-aws 3.0.0-alpha.319 → 3.0.0-alpha.321

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/package.json +12 -8
  3. package/rescript.json +4 -0
  4. package/run-provision-identity.mjs +3 -0
  5. package/scripts/ProvisionIdentity.res +303 -0
  6. package/scripts/ProvisionIdentity.res.mjs +376 -0
  7. package/src/Platform.res +54 -30
  8. package/src/Platform.res.mjs +34 -32
  9. package/src/Platform_Stack.res +102 -19
  10. package/src/Platform_Stack.res.mjs +43 -7
  11. package/src/adapter/Auth/Auth_ActiveRolePoolAttachment.res +267 -32
  12. package/src/adapter/Auth/Auth_ActiveRolePoolAttachment.res.mjs +124 -14
  13. package/src/adapter/Auth/Auth_ActiveRoleStore.res +93 -5
  14. package/src/adapter/Auth/Auth_ActiveRoleStore.res.mjs +53 -4
  15. package/src/adapter/Auth/Auth_ActiveRoleStore_Ops.res +50 -4
  16. package/src/adapter/Auth/Auth_ActiveRoleStore_Ops.res.mjs +24 -3
  17. package/src/adapter/Auth/Auth_ActiveRoleStore_Schema.res +92 -0
  18. package/src/adapter/Auth/Auth_ActiveRoleStore_Schema.res.mjs +49 -0
  19. package/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res +21 -7
  20. package/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res.mjs +15 -7
  21. package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res +34 -18
  22. package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res.mjs +14 -4
  23. package/src/adapter/QueryDb/QueryDbStorage_DynamoDb.res +29 -14
  24. package/src/adapter/QueryDb/QueryDbStorage_DynamoDb.res.mjs +11 -1
  25. package/src/util/Util_AwsError.res +56 -0
  26. package/src/util/Util_AwsError.res.mjs +59 -0
  27. package/src/util/Util_ShellConfig.res +28 -0
  28. package/src/util/Util_ShellConfig.res.mjs +22 -0
  29. package/tests/AppSync_RetirementNarrowingTest.res +10 -12
  30. package/tests/AppSync_RetirementNarrowingTest.res.mjs +4 -2
  31. package/tests/Auth_ActiveRolePoolAttachmentTest.res +175 -0
  32. package/tests/Auth_ActiveRolePoolAttachmentTest.res.mjs +136 -0
  33. package/tests/Auth_ActiveRoleStoreTest.res +65 -0
  34. package/tests/Auth_ActiveRoleStoreTest.res.mjs +48 -0
  35. package/tests/Auth_ActiveRoleStore_SchemaTest.res +98 -0
  36. package/tests/Auth_ActiveRoleStore_SchemaTest.res.mjs +97 -0
  37. package/tests/AwsErrorFixtures.mjs +16 -0
  38. package/tests/ProvisionIdentityTest.res +109 -0
  39. package/tests/ProvisionIdentityTest.res.mjs +132 -0
  40. package/tests/QueryDbOwnerIndexTableTest.res +53 -0
  41. package/tests/QueryDbOwnerIndexTableTest.res.mjs +50 -0
  42. package/tests/QueryDbResolvers_AppSyncTest.res +19 -26
  43. package/tests/QueryDbResolvers_AppSyncTest.res.mjs +15 -17
  44. package/tests/Util_AwsErrorTest.res +127 -0
  45. package/tests/Util_AwsErrorTest.res.mjs +76 -0
  46. package/tests/Util_ShellConfigTest.res +51 -0
  47. package/tests/Util_ShellConfigTest.res.mjs +42 -0
@@ -1,11 +1,7 @@
1
- // The DynamoDB backend narrows retirement on every door the rule names, not
2
- // only on the list.
3
- //
4
- // These assert the *generated resolver source*, which is the only artifact that
5
- // exists before a deploy: the predicate runs inside AppSync's JS runtime, so a
6
- // unit test cannot execute it against a table, and what can be checked here is
7
- // that each door carries the guard, in the half of the template its operation
8
- // allows, and that a view declaring no retirement is untouched.
1
+ // The DynamoDB backend narrows retirement on every door the rule names, not only
2
+ // on the list. These assert the generated resolver source — the only artifact
3
+ // that exists before a deploy — for the guard, the half of the template its
4
+ // operation allows, and a view declaring no retirement staying untouched.
9
5
 
10
6
  open JestGlobals
11
7
 
@@ -210,18 +206,20 @@ describe("the by-index door answers the field it is attached to", () => {
210
206
  testSync("returns a Relay connection, not the raw DynamoDB result", () =>
211
207
  expect((
212
208
  code->String.includes("edges,"),
213
- code->String.includes("hasNextPage: !!next"),
209
+ code->String.includes("hasNextPage: _more || !!_next"),
214
210
  code->String.includes("return ctx.result;"),
215
211
  ))->Expect.toEqual((true, true, false))
216
212
  )
217
213
 
218
214
  // `first`/`after` are what the field declares; `limit`/`nextToken` were what
219
- // the template read, and nothing translated between the two.
215
+ // the template read, and nothing translated between the two. `limit` counts
216
+ // rows examined, so a filtered read looks wider than the page it serves.
220
217
  testSync("pages on the Relay arguments the field declares", () =>
221
218
  expect((
222
- code->String.includes("limit: (args.first ?? 50)"),
219
+ code->String.includes("const _first = args.first ?? 50;"),
220
+ code->String.includes("expression ? (_first > 1000 ? _first : 1000)"),
223
221
  code->String.includes("util.base64Decode(args.after)"),
224
- ))->Expect.toEqual((true, true))
222
+ ))->Expect.toEqual((true, true, true))
225
223
  )
226
224
 
227
225
  // Every declared argument has a job other than matching a column, so each one
@@ -133,7 +133,7 @@ globalThis.describe("the by-index door answers the field it is attached to", ()
133
133
  globalThis.test("returns a Relay connection, not the raw DynamoDB result", () => {
134
134
  globalThis.expect([
135
135
  code.includes("edges,"),
136
- code.includes("hasNextPage: !!next"),
136
+ code.includes("hasNextPage: _more || !!_next"),
137
137
  code.includes("return ctx.result;")
138
138
  ]).toEqual([
139
139
  true,
@@ -143,9 +143,11 @@ globalThis.describe("the by-index door answers the field it is attached to", ()
143
143
  });
144
144
  globalThis.test("pages on the Relay arguments the field declares", () => {
145
145
  globalThis.expect([
146
- code.includes("limit: (args.first ?? 50)"),
146
+ code.includes("const _first = args.first ?? 50;"),
147
+ code.includes("expression ? (_first > 1000 ? _first : 1000)"),
147
148
  code.includes("util.base64Decode(args.after)")
148
149
  ]).toEqual([
150
+ true,
149
151
  true,
150
152
  true
151
153
  ]);
@@ -421,4 +421,179 @@ describe("Auth_ActiveRolePoolAttachment.probeEvent", () => {
421
421
  ->Option.flatMap(JSON.Decode.array),
422
422
  )->toEqual(Some([]))
423
423
  )
424
+
425
+ // 🚨 The handler keys its row on (subject, app client), and takes the
426
+ // "nothing to look up" branch when either is missing. A probe without a
427
+ // `callerContext` would return healthy without the handler ever reaching the
428
+ // store — proving less than the check appears to prove.
429
+ testSync("carries the caller context the handler keys its read on", () =>
430
+ expect(
431
+ event
432
+ ->Option.flatMap(o => o->Dict.get("callerContext"))
433
+ ->Option.flatMap(JSON.Decode.object)
434
+ ->Option.flatMap(c => c->Dict.get("clientId"))
435
+ ->Option.flatMap(JSON.Decode.string)
436
+ ->Option.isSome,
437
+ )->toBe(true)
438
+ )
439
+
440
+ // Both halves of the key must miss every real row, not just the subject.
441
+ testSync("the probe's subject and client cannot collide with a real row", () =>
442
+ expect(
443
+ Attachment.probeSubject == Attachment.probeClientId,
444
+ )->toBe(false)
445
+ )
446
+ })
447
+
448
+ // 🚨 Whose slot is it. Cognito allows a pool exactly one pre-token-generation
449
+ // trigger, so an unconditional attach is last-writer-wins — it replaces a BYO
450
+ // customer's own trigger silently, and it lets two platform stacks each read a
451
+ // store the other never writes. The describe this resource already performs is
452
+ // read for what is attached, and a slot held by anything else fails the deploy.
453
+ describe("Auth_ActiveRolePoolAttachment.attachedTriggerArn", () => {
454
+ let theirArn = "arn:aws:lambda:eu-west-1:1:function:TheirOwnPreToken"
455
+
456
+ let withConfig = (~version) =>
457
+ Dict.fromArray([
458
+ (
459
+ "LambdaConfig",
460
+ Dict.fromArray([
461
+ (
462
+ "PreTokenGenerationConfig",
463
+ Dict.fromArray([
464
+ ("LambdaArn", str(theirArn)),
465
+ ("LambdaVersion", str(version)),
466
+ ])->JSON.Encode.object,
467
+ ),
468
+ ])->JSON.Encode.object,
469
+ ),
470
+ ])
471
+
472
+ testSync("an empty pool holds nothing", () =>
473
+ expect(Attachment.attachedTriggerArn(~described=Dict.fromArray([("Name", str("Bare"))])))
474
+ ->toEqual(None)
475
+ )
476
+
477
+ testSync("reports the trigger a pool carries", () =>
478
+ expect(Attachment.attachedTriggerArn(~described=withConfig(~version="V1_0")))->toEqual(
479
+ Some(theirArn),
480
+ )
481
+ )
482
+
483
+ // 🚨 The distinction from `attachedTrigger`, and the reason both exist. A
484
+ // foreign trigger pinned at a version we do not implement is very much
485
+ // attached; a version-aware read calls it absent, and this resource would then
486
+ // quietly replace the very trigger it is meant to refuse.
487
+ testSync("a foreign trigger at another version is still occupying the slot", () =>
488
+ expect((
489
+ Attachment.attachedTriggerArn(~described=withConfig(~version="V2_0")),
490
+ Attachment.attachedTrigger(~described=withConfig(~version="V2_0")),
491
+ ))->toEqual((Some(theirArn), None))
492
+ )
493
+
494
+ testSync("falls back to the legacy field on a pool carrying only that", () =>
495
+ expect(
496
+ Attachment.attachedTriggerArn(
497
+ ~described=Dict.fromArray([
498
+ (
499
+ "LambdaConfig",
500
+ Dict.fromArray([("PreTokenGeneration", str(theirArn))])->JSON.Encode.object,
501
+ ),
502
+ ]),
503
+ ),
504
+ )->toEqual(Some(theirArn))
505
+ )
506
+ })
507
+
508
+ describe("Auth_ActiveRolePoolAttachment.classifySlot", () => {
509
+ let ours = "arn:aws:lambda:eu-west-1:1:function:OurTrigger"
510
+ let theirs = "arn:aws:lambda:eu-west-1:1:function:OtherStackTrigger"
511
+ let store = "ReventlessActiveRoleStore-eu-west-1_x"
512
+
513
+ let classify = (~attachedArn, ~attachedStore) =>
514
+ Attachment.classifySlot(~attachedArn, ~ourArn=ours, ~ourStore=store, ~attachedStore)
515
+
516
+ testSync("an empty slot is free to take", () =>
517
+ expect(classify(~attachedArn=None, ~attachedStore=None))->toEqual(Attachment.Vacant)
518
+ )
519
+
520
+ // A pool can carry an empty string where a trigger was detached out of band.
521
+ testSync("an empty ARN is an empty slot, not a foreign trigger", () =>
522
+ expect(classify(~attachedArn=Some(""), ~attachedStore=None))->toEqual(Attachment.Vacant)
523
+ )
524
+
525
+ testSync("our own trigger is ours to re-attach", () =>
526
+ expect(classify(~attachedArn=Some(ours), ~attachedStore=None))->toEqual(Attachment.Ours)
527
+ )
528
+
529
+ // 🚨 The case the shared pool exists for: two platform stacks running identical
530
+ // code over identical rows. Whichever holds the slot serves both, so this is
531
+ // not a conflict and must not fail a deploy.
532
+ testSync("another deployment's trigger on the same store serves both", () =>
533
+ expect(classify(~attachedArn=Some(theirs), ~attachedStore=Some(store)))->toEqual(
534
+ Attachment.SharedWith(theirs),
535
+ )
536
+ )
537
+
538
+ // 🚨 The original defect, caught at the only moment anything can see both
539
+ // halves. Unreachable by configuration now that the store is derived — reachable
540
+ // by version skew, while an older release is still on its stack-scoped table.
541
+ testSync("another deployment's trigger on a different store is the defect", () =>
542
+ expect(classify(~attachedArn=Some(theirs), ~attachedStore=Some("ActiveRoleStore-829c96f")))
543
+ ->toEqual(Attachment.DifferentStore({arn: theirs, theirStore: "ActiveRoleStore-829c96f"}))
544
+ )
545
+
546
+ // Reading the store rather than matching a name is what makes this a check on
547
+ // the invariant: a function with no ACTIVE_ROLE_TABLE is not one of ours, and
548
+ // neither is one we could not read.
549
+ testSync("a trigger with no store of ours is foreign", () =>
550
+ expect(classify(~attachedArn=Some(theirs), ~attachedStore=None))->toEqual(
551
+ Attachment.Foreign(theirs),
552
+ )
553
+ )
554
+ })
555
+
556
+ describe("Auth_ActiveRolePoolAttachment.refusalFor", () => {
557
+ let store = "ReventlessActiveRoleStore-eu-west-1_x"
558
+ let refusal = slot => Attachment.refusalFor(~slot, ~userPoolId="eu-west-1_x", ~ourStore=store)
559
+
560
+ testSync("the three attachable slots produce no refusal", () =>
561
+ expect((
562
+ refusal(Attachment.Vacant),
563
+ refusal(Attachment.Ours),
564
+ refusal(Attachment.SharedWith("arn:aws:lambda:eu-west-1:1:function:Other")),
565
+ ))->toEqual((None, None, None))
566
+ )
567
+
568
+ // Both stores named, because an operator cannot act on this without knowing
569
+ // which two are in disagreement.
570
+ testSync("a disagreeing store is refused, naming both stores", () => {
571
+ let message =
572
+ refusal(
573
+ Attachment.DifferentStore({
574
+ arn: "arn:aws:lambda:eu-west-1:1:function:Other",
575
+ theirStore: "ActiveRoleStore-829c96f",
576
+ }),
577
+ )->Option.getOr("")
578
+ expect((
579
+ message->String.includes(store),
580
+ message->String.includes("ActiveRoleStore-829c96f"),
581
+ message->String.includes("eu-west-1_x"),
582
+ ))->toEqual((true, true, true))
583
+ })
584
+
585
+ // 🚨 The trade this file already makes for its denylist, applied to the trigger
586
+ // slot: a customer's own claims-enrichment trigger is replaced silently today.
587
+ // Given "a customer's pool quietly loses a trigger" and "the deploy fails
588
+ // naming what is in the way", the second is the one to design for.
589
+ testSync("a foreign trigger is refused, naming what is attached", () => {
590
+ let arn = "arn:aws:lambda:eu-west-1:1:function:TheirClaimsEnrichment"
591
+ let message = refusal(Attachment.Foreign(arn))->Option.getOr("")
592
+ expect((
593
+ message->String.includes(arn),
594
+ // The likeliest cause of a false Foreign is a missing permission, so the
595
+ // sentence has to name it or an operator debugs the wrong thing.
596
+ message->String.includes("lambda:GetFunctionConfiguration"),
597
+ ))->toEqual((true, true))
598
+ })
424
599
  })
@@ -362,6 +362,142 @@ globalThis.describe("Auth_ActiveRolePoolAttachment.probeEvent", () => {
362
362
  globalThis.test("presents no groups to narrow", () => {
363
363
  globalThis.expect(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(event, o => o["request"]), Stdlib_JSON.Decode.object), r => r["groupConfiguration"]), Stdlib_JSON.Decode.object), g => g["groupsToOverride"]), Stdlib_JSON.Decode.array)).toEqual([]);
364
364
  });
365
+ globalThis.test("carries the caller context the handler keys its read on", () => {
366
+ globalThis.expect(Stdlib_Option.isSome(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(event, o => o["callerContext"]), Stdlib_JSON.Decode.object), c => c["clientId"]), Stdlib_JSON.Decode.string))).toBe(true);
367
+ });
368
+ globalThis.test("the probe's subject and client cannot collide with a real row", () => {
369
+ globalThis.expect(Auth_ActiveRolePoolAttachment$ReventlessAws.probeSubject === Auth_ActiveRolePoolAttachment$ReventlessAws.probeClientId).toBe(false);
370
+ });
371
+ });
372
+
373
+ globalThis.describe("Auth_ActiveRolePoolAttachment.attachedTriggerArn", () => {
374
+ let theirArn = "arn:aws:lambda:eu-west-1:1:function:TheirOwnPreToken";
375
+ let withConfig = version => Object.fromEntries([[
376
+ "LambdaConfig",
377
+ Object.fromEntries([[
378
+ "PreTokenGenerationConfig",
379
+ Object.fromEntries([
380
+ [
381
+ "LambdaArn",
382
+ theirArn
383
+ ],
384
+ [
385
+ "LambdaVersion",
386
+ version
387
+ ]
388
+ ])
389
+ ]])
390
+ ]]);
391
+ globalThis.test("an empty pool holds nothing", () => {
392
+ globalThis.expect(Auth_ActiveRolePoolAttachment$ReventlessAws.attachedTriggerArn(Object.fromEntries([[
393
+ "Name",
394
+ "Bare"
395
+ ]]))).toEqual(undefined);
396
+ });
397
+ globalThis.test("reports the trigger a pool carries", () => {
398
+ globalThis.expect(Auth_ActiveRolePoolAttachment$ReventlessAws.attachedTriggerArn(withConfig("V1_0"))).toEqual(theirArn);
399
+ });
400
+ globalThis.test("a foreign trigger at another version is still occupying the slot", () => {
401
+ globalThis.expect([
402
+ Auth_ActiveRolePoolAttachment$ReventlessAws.attachedTriggerArn(withConfig("V2_0")),
403
+ Auth_ActiveRolePoolAttachment$ReventlessAws.attachedTrigger(withConfig("V2_0"))
404
+ ]).toEqual([
405
+ theirArn,
406
+ undefined
407
+ ]);
408
+ });
409
+ globalThis.test("falls back to the legacy field on a pool carrying only that", () => {
410
+ globalThis.expect(Auth_ActiveRolePoolAttachment$ReventlessAws.attachedTriggerArn(Object.fromEntries([[
411
+ "LambdaConfig",
412
+ Object.fromEntries([[
413
+ "PreTokenGeneration",
414
+ theirArn
415
+ ]])
416
+ ]]))).toEqual(theirArn);
417
+ });
418
+ });
419
+
420
+ globalThis.describe("Auth_ActiveRolePoolAttachment.classifySlot", () => {
421
+ let ours = "arn:aws:lambda:eu-west-1:1:function:OurTrigger";
422
+ let theirs = "arn:aws:lambda:eu-west-1:1:function:OtherStackTrigger";
423
+ let store = "ReventlessActiveRoleStore-eu-west-1_x";
424
+ let classify = (attachedArn, attachedStore) => Auth_ActiveRolePoolAttachment$ReventlessAws.classifySlot(attachedArn, ours, store, attachedStore);
425
+ globalThis.test("an empty slot is free to take", () => {
426
+ globalThis.expect(classify(undefined, undefined)).toEqual("Vacant");
427
+ });
428
+ globalThis.test("an empty ARN is an empty slot, not a foreign trigger", () => {
429
+ globalThis.expect(classify("", undefined)).toEqual("Vacant");
430
+ });
431
+ globalThis.test("our own trigger is ours to re-attach", () => {
432
+ globalThis.expect(classify(ours, undefined)).toEqual("Ours");
433
+ });
434
+ globalThis.test("another deployment's trigger on the same store serves both", () => {
435
+ globalThis.expect(classify(theirs, store)).toEqual({
436
+ TAG: "SharedWith",
437
+ _0: theirs
438
+ });
439
+ });
440
+ globalThis.test("another deployment's trigger on a different store is the defect", () => {
441
+ globalThis.expect(classify(theirs, "ActiveRoleStore-829c96f")).toEqual({
442
+ TAG: "DifferentStore",
443
+ arn: theirs,
444
+ theirStore: "ActiveRoleStore-829c96f"
445
+ });
446
+ });
447
+ globalThis.test("a trigger with no store of ours is foreign", () => {
448
+ globalThis.expect(classify(theirs, undefined)).toEqual({
449
+ TAG: "Foreign",
450
+ _0: theirs
451
+ });
452
+ });
453
+ });
454
+
455
+ globalThis.describe("Auth_ActiveRolePoolAttachment.refusalFor", () => {
456
+ let store = "ReventlessActiveRoleStore-eu-west-1_x";
457
+ globalThis.test("the three attachable slots produce no refusal", () => {
458
+ globalThis.expect([
459
+ Auth_ActiveRolePoolAttachment$ReventlessAws.refusalFor("Vacant", "eu-west-1_x", store),
460
+ Auth_ActiveRolePoolAttachment$ReventlessAws.refusalFor("Ours", "eu-west-1_x", store),
461
+ Auth_ActiveRolePoolAttachment$ReventlessAws.refusalFor({
462
+ TAG: "SharedWith",
463
+ _0: "arn:aws:lambda:eu-west-1:1:function:Other"
464
+ }, "eu-west-1_x", store)
465
+ ]).toEqual([
466
+ undefined,
467
+ undefined,
468
+ undefined
469
+ ]);
470
+ });
471
+ globalThis.test("a disagreeing store is refused, naming both stores", () => {
472
+ let message = Stdlib_Option.getOr(Auth_ActiveRolePoolAttachment$ReventlessAws.refusalFor({
473
+ TAG: "DifferentStore",
474
+ arn: "arn:aws:lambda:eu-west-1:1:function:Other",
475
+ theirStore: "ActiveRoleStore-829c96f"
476
+ }, "eu-west-1_x", store), "");
477
+ globalThis.expect([
478
+ message.includes(store),
479
+ message.includes("ActiveRoleStore-829c96f"),
480
+ message.includes("eu-west-1_x")
481
+ ]).toEqual([
482
+ true,
483
+ true,
484
+ true
485
+ ]);
486
+ });
487
+ globalThis.test("a foreign trigger is refused, naming what is attached", () => {
488
+ let arn = "arn:aws:lambda:eu-west-1:1:function:TheirClaimsEnrichment";
489
+ let message = Stdlib_Option.getOr(Auth_ActiveRolePoolAttachment$ReventlessAws.refusalFor({
490
+ TAG: "Foreign",
491
+ _0: arn
492
+ }, "eu-west-1_x", store), "");
493
+ globalThis.expect([
494
+ message.includes(arn),
495
+ message.includes("lambda:GetFunctionConfiguration")
496
+ ]).toEqual([
497
+ true,
498
+ true
499
+ ]);
500
+ });
365
501
  });
366
502
 
367
503
  let Attachment;
@@ -72,3 +72,68 @@ describe("Auth_ActiveRoleStore_Ops.cognitoLookupName", () => {
72
72
  expect(Ops.cognitoLookupName(~identity={}))->toEqual(None)
73
73
  )
74
74
  })
75
+
76
+ // Which store this deployment reads and writes. Two cases and no third: a stack
77
+ // that creates its own provider owns everything attached to it, and a stack given
78
+ // a provider owns none of it. See
79
+ // [docs/plans/active-role-store-scoped-to-the-pool.md].
80
+ describe("Auth_ActiveRoleStore.chooseStore", () => {
81
+ testSync("a stack that creates its own provider keeps its own table", () =>
82
+ expect(Auth_ActiveRoleStore.chooseStore(~identityProviderId=None))->toEqual(
83
+ Auth_ActiveRoleStore.StackScoped,
84
+ )
85
+ )
86
+
87
+ testSync("a stack given a provider reads that provider's derived store", () =>
88
+ expect(Auth_ActiveRoleStore.chooseStore(~identityProviderId=Some("eu-west-1_CQTwafSeX")))
89
+ ->toEqual(
90
+ Auth_ActiveRoleStore.ProviderScoped("ReventlessActiveRoleStore-eu-west-1_CQTwafSeX"),
91
+ )
92
+ )
93
+
94
+ // 🚨 The defect, expressed as the property that now prevents it. Two platform
95
+ // stacks on one provider previously deployed a table each, so the pool's single
96
+ // trigger read rows the other's resolver wrote — every switch succeeding and
97
+ // doing nothing. There is no configuration either could carry that would make
98
+ // them disagree.
99
+ testSync("two deployments on one provider cannot choose different stores", () =>
100
+ expect(Auth_ActiveRoleStore.chooseStore(~identityProviderId=Some("eu-west-1_Shared")))
101
+ ->toEqual(Auth_ActiveRoleStore.chooseStore(~identityProviderId=Some("eu-west-1_Shared")))
102
+ )
103
+ })
104
+
105
+ // The second half of the row key. Every other absent field in this handler has a
106
+ // defensible default; this one does not, and the test that matters is the one
107
+ // asserting it refuses rather than substituting something.
108
+ describe("Auth_ActiveRoleStore_Ops.appClientId", () => {
109
+ testSync("the app client the authorizer resolved is the one used", () =>
110
+ expect(Ops.appClientId(~identity={sub: "sub-1", clientId: Value("7cl13nt")}))->toEqual(
111
+ Some("7cl13nt"),
112
+ )
113
+ )
114
+
115
+ // 🚨 The trigger keys its read on the client id Cognito hands it, so a write
116
+ // under any substitute — a constant, the subject, the empty string — is a row
117
+ // the trigger never finds. Refusing is the only answer that does not recreate
118
+ // the defect one level down.
119
+ testSync("an absent client id yields nothing to key a row on", () =>
120
+ expect(Ops.appClientId(~identity={sub: "sub-1"}))->toEqual(None)
121
+ )
122
+
123
+ // The resolver sends `appClientId(id)`, which returns null when the claims are
124
+ // not there to read — so null is the shape that actually arrives, not a missing
125
+ // field.
126
+ testSync("an explicitly null client id is not a client id", () =>
127
+ expect(Ops.appClientId(~identity={sub: "sub-1", clientId: Null}))->toEqual(None)
128
+ )
129
+
130
+ testSync("a blank client id is not a client id either", () =>
131
+ expect(Ops.appClientId(~identity={sub: "sub-1", clientId: Value(" ")}))->toEqual(None)
132
+ )
133
+
134
+ // The subject is deliberately not a fallback: it would key every platform's row
135
+ // identically and undo the per-platform narrowing the pair key exists for.
136
+ testSync("the subject is never substituted for the app client", () =>
137
+ expect(Ops.appClientId(~identity={sub: "sub-1", clientId: Null}))->toEqual(None)
138
+ )
139
+ })
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
4
  import * as Auth_ActiveRole$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Auth/Auth_ActiveRole.res.mjs";
5
+ import * as Auth_ActiveRoleStore$ReventlessAws from "../src/adapter/Auth/Auth_ActiveRoleStore.res.mjs";
5
6
  import * as Auth_ActiveRoleStore_Ops$ReventlessAws from "../src/adapter/Auth/Auth_ActiveRoleStore_Ops.res.mjs";
6
7
 
7
8
  globalThis.describe("Auth_ActiveRoleStore_Ops.mayActAs — the conformance table", () => {
@@ -59,6 +60,53 @@ globalThis.describe("Auth_ActiveRoleStore_Ops.cognitoLookupName", () => {
59
60
  });
60
61
  });
61
62
 
63
+ globalThis.describe("Auth_ActiveRoleStore.chooseStore", () => {
64
+ globalThis.test("a stack that creates its own provider keeps its own table", () => {
65
+ globalThis.expect(Auth_ActiveRoleStore$ReventlessAws.chooseStore(undefined)).toEqual("StackScoped");
66
+ });
67
+ globalThis.test("a stack given a provider reads that provider's derived store", () => {
68
+ globalThis.expect(Auth_ActiveRoleStore$ReventlessAws.chooseStore("eu-west-1_CQTwafSeX")).toEqual({
69
+ TAG: "ProviderScoped",
70
+ _0: "ReventlessActiveRoleStore-eu-west-1_CQTwafSeX"
71
+ });
72
+ });
73
+ globalThis.test("two deployments on one provider cannot choose different stores", () => {
74
+ globalThis.expect(Auth_ActiveRoleStore$ReventlessAws.chooseStore("eu-west-1_Shared")).toEqual(Auth_ActiveRoleStore$ReventlessAws.chooseStore("eu-west-1_Shared"));
75
+ });
76
+ });
77
+
78
+ globalThis.describe("Auth_ActiveRoleStore_Ops.appClientId", () => {
79
+ globalThis.test("the app client the authorizer resolved is the one used", () => {
80
+ globalThis.expect(Auth_ActiveRoleStore_Ops$ReventlessAws.appClientId({
81
+ sub: "sub-1",
82
+ clientId: "7cl13nt"
83
+ })).toEqual("7cl13nt");
84
+ });
85
+ globalThis.test("an absent client id yields nothing to key a row on", () => {
86
+ globalThis.expect(Auth_ActiveRoleStore_Ops$ReventlessAws.appClientId({
87
+ sub: "sub-1"
88
+ })).toEqual(undefined);
89
+ });
90
+ globalThis.test("an explicitly null client id is not a client id", () => {
91
+ globalThis.expect(Auth_ActiveRoleStore_Ops$ReventlessAws.appClientId({
92
+ sub: "sub-1",
93
+ clientId: null
94
+ })).toEqual(undefined);
95
+ });
96
+ globalThis.test("a blank client id is not a client id either", () => {
97
+ globalThis.expect(Auth_ActiveRoleStore_Ops$ReventlessAws.appClientId({
98
+ sub: "sub-1",
99
+ clientId: " "
100
+ })).toEqual(undefined);
101
+ });
102
+ globalThis.test("the subject is never substituted for the app client", () => {
103
+ globalThis.expect(Auth_ActiveRoleStore_Ops$ReventlessAws.appClientId({
104
+ sub: "sub-1",
105
+ clientId: null
106
+ })).toEqual(undefined);
107
+ });
108
+ });
109
+
62
110
  let Ops;
63
111
 
64
112
  let Contract;
@@ -0,0 +1,98 @@
1
+ open JestGlobals
2
+
3
+ // The store's identity — its name and its key — is shared by four things that
4
+ // cannot see each other: the deploy, the write door, the pre-token trigger, and
5
+ // the provisioning script an operator runs by hand. Any two of them disagreeing
6
+ // produces one symptom, and it is the symptom this whole area exists to end: a
7
+ // row written where nothing looks for it, so a role switch reports success and
8
+ // changes nothing.
9
+ //
10
+ // These assert the contract itself rather than any one consumer's use of it.
11
+
12
+ module Schema = Auth_ActiveRoleStore_Schema
13
+
14
+ describe("Auth_ActiveRoleStore_Schema.derivedStoreName", () => {
15
+ testSync("names the store after the provider whose trigger reads it", () =>
16
+ expect(Schema.derivedStoreName(~identityProviderId="eu-west-1_CQTwafSeX"))->toBe(
17
+ "ReventlessActiveRoleStore-eu-west-1_CQTwafSeX",
18
+ )
19
+ )
20
+
21
+ // The property the whole design turns on: two platforms on one provider cannot
22
+ // name different stores, so the defect stops being something to detect and
23
+ // becomes something that cannot be expressed.
24
+ testSync("two deployments on one provider derive the same store", () =>
25
+ expect(Schema.derivedStoreName(~identityProviderId="eu-west-1_Shared"))->toBe(
26
+ Schema.derivedStoreName(~identityProviderId="eu-west-1_Shared"),
27
+ )
28
+ )
29
+
30
+ testSync("different providers do not collide", () =>
31
+ expect(
32
+ Schema.derivedStoreName(~identityProviderId="eu-west-1_A") ==
33
+ Schema.derivedStoreName(~identityProviderId="eu-west-1_B"),
34
+ )->toBe(false)
35
+ )
36
+
37
+ // A pool id carries an underscore and a hyphen; DynamoDB accepts both, and a
38
+ // name that needed escaping would make the script and the deploy disagree about
39
+ // what "the same name" is.
40
+ testSync("the derived name is a legal DynamoDB table name", () => {
41
+ let name = Schema.derivedStoreName(~identityProviderId="eu-west-1_CQTwafSeX")
42
+ expect((
43
+ RegExp.test(RegExp.fromString("^[A-Za-z0-9_.-]+$"), name),
44
+ name->String.length >= 3 && name->String.length <= 255,
45
+ ))->toEqual((true, true))
46
+ })
47
+ })
48
+
49
+ describe("Auth_ActiveRoleStore_Schema.keySchemaRefusal", () => {
50
+ let store = "ReventlessActiveRoleStore-eu-west-1_x"
51
+
52
+ testSync("the expected pair is adopted without complaint", () =>
53
+ expect(
54
+ Schema.keySchemaRefusal(~tableName=store, ~actual=[("id", "HASH"), ("clientId", "RANGE")]),
55
+ )->toEqual(None)
56
+ )
57
+
58
+ // DescribeTable promises no ordering, and a refusal that depended on one would
59
+ // reject a table that is perfectly correct.
60
+ testSync("order is not part of the comparison", () =>
61
+ expect(
62
+ Schema.keySchemaRefusal(~tableName=store, ~actual=[("clientId", "RANGE"), ("id", "HASH")]),
63
+ )->toEqual(None)
64
+ )
65
+
66
+ // 🚨 The upgrade case. A store keyed on the subject alone is what every
67
+ // deployment had before the active role became per-platform, and adopting it
68
+ // silently would write rows under a two-part key that nothing reads.
69
+ testSync("the pre-clientId store is refused rather than adopted", () =>
70
+ expect(
71
+ Schema.keySchemaRefusal(~tableName=store, ~actual=[("id", "HASH")])->Option.isSome,
72
+ )->toBe(true)
73
+ )
74
+
75
+ testSync("the refusal names the table, what it found, and what is needed", () => {
76
+ let message = Schema.keySchemaRefusal(~tableName=store, ~actual=[("id", "HASH")])->Option.getOr("")
77
+ expect((
78
+ message->String.includes(store),
79
+ message->String.includes("id:HASH"),
80
+ message->String.includes("clientId:RANGE"),
81
+ ))->toEqual((true, true, true))
82
+ })
83
+
84
+ testSync("a table keyed on something else entirely is refused", () =>
85
+ expect(
86
+ Schema.keySchemaRefusal(
87
+ ~tableName=store,
88
+ ~actual=[("pk", "HASH"), ("sk", "RANGE")],
89
+ )->Option.isSome,
90
+ )->toBe(true)
91
+ )
92
+
93
+ // Not a table anyone would build on purpose — but `DescribeTable`'s KeySchema is
94
+ // optional in the SDK's types, and the caller resolves an absent one to this.
95
+ testSync("an empty key schema is refused, not treated as a match", () =>
96
+ expect(Schema.keySchemaRefusal(~tableName=store, ~actual=[])->Option.isSome)->toBe(true)
97
+ )
98
+ })