@reventlessdev/reventless-aws 3.0.0-alpha.318 → 3.0.0-alpha.320

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 (40) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/package.json +11 -7
  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/Runtime/AutomationSliceEntryPoint_Ops.res +6 -3
  22. package/src/util/Util_AwsError.res +56 -0
  23. package/src/util/Util_AwsError.res.mjs +59 -0
  24. package/src/util/Util_ShellConfig.res +28 -0
  25. package/src/util/Util_ShellConfig.res.mjs +22 -0
  26. package/tests/Auth_ActiveRolePoolAttachmentTest.res +175 -0
  27. package/tests/Auth_ActiveRolePoolAttachmentTest.res.mjs +136 -0
  28. package/tests/Auth_ActiveRoleStoreTest.res +65 -0
  29. package/tests/Auth_ActiveRoleStoreTest.res.mjs +48 -0
  30. package/tests/Auth_ActiveRoleStore_SchemaTest.res +98 -0
  31. package/tests/Auth_ActiveRoleStore_SchemaTest.res.mjs +97 -0
  32. package/tests/AwsErrorFixtures.mjs +16 -0
  33. package/tests/PgQueryResolver_LambdaTest.res +166 -0
  34. package/tests/PgQueryResolver_LambdaTest.res.mjs +196 -0
  35. package/tests/ProvisionIdentityTest.res +109 -0
  36. package/tests/ProvisionIdentityTest.res.mjs +132 -0
  37. package/tests/Util_AwsErrorTest.res +127 -0
  38. package/tests/Util_AwsErrorTest.res.mjs +76 -0
  39. package/tests/Util_ShellConfigTest.res +51 -0
  40. package/tests/Util_ShellConfigTest.res.mjs +42 -0
@@ -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
+ })
@@ -0,0 +1,97 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
+ import * as Auth_ActiveRoleStore_Schema$ReventlessAws from "../src/adapter/Auth/Auth_ActiveRoleStore_Schema.res.mjs";
5
+
6
+ globalThis.describe("Auth_ActiveRoleStore_Schema.derivedStoreName", () => {
7
+ globalThis.test("names the store after the provider whose trigger reads it", () => {
8
+ globalThis.expect(Auth_ActiveRoleStore_Schema$ReventlessAws.derivedStoreName("eu-west-1_CQTwafSeX")).toBe("ReventlessActiveRoleStore-eu-west-1_CQTwafSeX");
9
+ });
10
+ globalThis.test("two deployments on one provider derive the same store", () => {
11
+ globalThis.expect(Auth_ActiveRoleStore_Schema$ReventlessAws.derivedStoreName("eu-west-1_Shared")).toBe(Auth_ActiveRoleStore_Schema$ReventlessAws.derivedStoreName("eu-west-1_Shared"));
12
+ });
13
+ globalThis.test("different providers do not collide", () => {
14
+ globalThis.expect(Auth_ActiveRoleStore_Schema$ReventlessAws.derivedStoreName("eu-west-1_A") === Auth_ActiveRoleStore_Schema$ReventlessAws.derivedStoreName("eu-west-1_B")).toBe(false);
15
+ });
16
+ globalThis.test("the derived name is a legal DynamoDB table name", () => {
17
+ let name = Auth_ActiveRoleStore_Schema$ReventlessAws.derivedStoreName("eu-west-1_CQTwafSeX");
18
+ globalThis.expect([
19
+ new RegExp("^[A-Za-z0-9_.-]+$").test(name),
20
+ name.length >= 3 && name.length <= 255
21
+ ]).toEqual([
22
+ true,
23
+ true
24
+ ]);
25
+ });
26
+ });
27
+
28
+ globalThis.describe("Auth_ActiveRoleStore_Schema.keySchemaRefusal", () => {
29
+ let store = "ReventlessActiveRoleStore-eu-west-1_x";
30
+ globalThis.test("the expected pair is adopted without complaint", () => {
31
+ globalThis.expect(Auth_ActiveRoleStore_Schema$ReventlessAws.keySchemaRefusal(store, [
32
+ [
33
+ "id",
34
+ "HASH"
35
+ ],
36
+ [
37
+ "clientId",
38
+ "RANGE"
39
+ ]
40
+ ])).toEqual(undefined);
41
+ });
42
+ globalThis.test("order is not part of the comparison", () => {
43
+ globalThis.expect(Auth_ActiveRoleStore_Schema$ReventlessAws.keySchemaRefusal(store, [
44
+ [
45
+ "clientId",
46
+ "RANGE"
47
+ ],
48
+ [
49
+ "id",
50
+ "HASH"
51
+ ]
52
+ ])).toEqual(undefined);
53
+ });
54
+ globalThis.test("the pre-clientId store is refused rather than adopted", () => {
55
+ globalThis.expect(Stdlib_Option.isSome(Auth_ActiveRoleStore_Schema$ReventlessAws.keySchemaRefusal(store, [[
56
+ "id",
57
+ "HASH"
58
+ ]]))).toBe(true);
59
+ });
60
+ globalThis.test("the refusal names the table, what it found, and what is needed", () => {
61
+ let message = Stdlib_Option.getOr(Auth_ActiveRoleStore_Schema$ReventlessAws.keySchemaRefusal(store, [[
62
+ "id",
63
+ "HASH"
64
+ ]]), "");
65
+ globalThis.expect([
66
+ message.includes(store),
67
+ message.includes("id:HASH"),
68
+ message.includes("clientId:RANGE")
69
+ ]).toEqual([
70
+ true,
71
+ true,
72
+ true
73
+ ]);
74
+ });
75
+ globalThis.test("a table keyed on something else entirely is refused", () => {
76
+ globalThis.expect(Stdlib_Option.isSome(Auth_ActiveRoleStore_Schema$ReventlessAws.keySchemaRefusal(store, [
77
+ [
78
+ "pk",
79
+ "HASH"
80
+ ],
81
+ [
82
+ "sk",
83
+ "RANGE"
84
+ ]
85
+ ]))).toBe(true);
86
+ });
87
+ globalThis.test("an empty key schema is refused, not treated as a match", () => {
88
+ globalThis.expect(Stdlib_Option.isSome(Auth_ActiveRoleStore_Schema$ReventlessAws.keySchemaRefusal(store, []))).toBe(true);
89
+ });
90
+ });
91
+
92
+ let Schema;
93
+
94
+ export {
95
+ Schema,
96
+ }
97
+ /* Not a pure module */
@@ -0,0 +1,16 @@
1
+ // Throws the shape an AWS SDK v3 client actually throws, so a test catches it
2
+ // through the same path the real code does.
3
+ //
4
+ // It has to *throw* rather than return: ReScript's `exn` for a JS error is the
5
+ // wrapper its own catch builds, not the bare `Error` object. A fixture that
6
+ // returned one would be a different value than any real call produces, and a test
7
+ // built on it would prove nothing about the code that catches them.
8
+ //
9
+ // Here rather than in a `%raw` inside the test, per the repo convention that
10
+ // untyped reflection lives in a companion `.mjs` bound through `@module`.
11
+
12
+ export const throwAwsError = (name, message) => {
13
+ const error = new Error(message)
14
+ error.name = name
15
+ throw error
16
+ }
@@ -612,3 +612,169 @@ describe("owner scoping", () => {
612
612
  expect(r->ids)->toEqual(["p-1", "p-3"])
613
613
  })
614
614
  })
615
+
616
+ // ── nested union member types ───────────────────────────────────────────────
617
+ //
618
+ // The `__typename` a union member is resolved from is written into the stored
619
+ // row, above the DynamoDB/Postgres branch (`withUnionMemberTypes` wraps the
620
+ // result of `makeQueryDbOps`), and this resolver stamps only the *top level* of
621
+ // a `node` answer. So the Postgres door's whole claim is that it hands a nested
622
+ // union back exactly as stored — which no test made, and which no deploy can
623
+ // make either: nothing runs the Postgres backend, so this door cannot be called
624
+ // against a deployment the way the AppSync ones were.
625
+ //
626
+ // Losing the nested key is not visible as an error: the member resolves to null
627
+ // and the null takes its non-nullable parent, so rows leave the answer.
628
+
629
+ let geoRow = (id, status) => {
630
+ let geo = Dict.make()
631
+ geo->Dict.set("__typename", JSON.Encode.string("GeolocationLocated"))
632
+ geo->Dict.set("lat", JSON.Encode.float(48.2082))
633
+ geo->Dict.set("lng", JSON.Encode.float(16.3738))
634
+ let o = Dict.make()
635
+ o->Dict.set("id", JSON.Encode.string(id))
636
+ o->Dict.set("status", JSON.Encode.string(status))
637
+ o->Dict.set("name", JSON.Encode.string("Geo " ++ id))
638
+ o->Dict.set("owner", JSON.Encode.string("u-a"))
639
+ o->Dict.set("geolocation", JSON.Encode.object(geo))
640
+ JSON.Encode.object(o)
641
+ }
642
+
643
+ let geoStore = Dict.fromArray([("g-1", geoRow("g-1", "active")), ("g-2", geoRow("g-2", "active"))])
644
+ let geoItems = () => geoStore->Dict.valuesToArray
645
+
646
+ let geoConnection = (): JSON.t =>
647
+ JSON.Encode.object(
648
+ Dict.fromArray([
649
+ (
650
+ "edges",
651
+ JSON.Encode.array(
652
+ geoItems()->Array.map(item =>
653
+ JSON.Encode.object(
654
+ Dict.fromArray([("cursor", JSON.Encode.string("c")), ("node", item)]),
655
+ )
656
+ ),
657
+ ),
658
+ ),
659
+ ]),
660
+ )
661
+
662
+ let geoBinding = (): PgQueryResolver_Lambda.binding => {
663
+ ops: {
664
+ ...ops,
665
+ load: async id => Ok(geoStore->Dict.get(id)->Option.mapOr([], i => [i])),
666
+ },
667
+ pushdowns: {
668
+ indexLookup: async (~readModelName as _, _field, _value) => geoItems(),
669
+ byIds: async (~readModelName as _, ids) => ids->Array.filterMap(id => geoStore->Dict.get(id)),
670
+ listPage: async (
671
+ ~readModelName as _,
672
+ ~argsDict as _,
673
+ ~capability as _,
674
+ ~labelField as _,
675
+ ~ownerScope as _=?,
676
+ ~retiredScope as _=?,
677
+ ) => Some(geoConnection()),
678
+ itemsPage: async (
679
+ ~readModelName as _,
680
+ ~subIdField as _,
681
+ ~id as _,
682
+ ~argsDict as _,
683
+ ~ownerScope as _=?,
684
+ ~retiredScope as _=?,
685
+ ) => geoConnection(),
686
+ scanAll: async (~readModelName as _) => geoItems(),
687
+ },
688
+ indexes: [
689
+ {index: "byStatus", type_: "S", idField: "status", projectionType: Reventless.ReadModel.ALL},
690
+ ],
691
+ subIdField: Some("lineNo"),
692
+ capability,
693
+ labelField: "name",
694
+ includeIdParam: true,
695
+ authorization: Reventless.Authorization.AllowAnonymous,
696
+ ownerField: None,
697
+ retiredField: None,
698
+ retiredValues: None,
699
+ namedWhenRetired: false,
700
+ }
701
+
702
+ // The member type of the first row a door answers with, whatever shape it took.
703
+ let memberOf = (r: JSON.t): option<string> => {
704
+ let fromRow = row =>
705
+ row->field("geolocation")->Option.flatMap(g => g->str("__typename"))
706
+ switch r->JSON.Decode.array {
707
+ | Some(rows) => rows->Array.get(0)->Option.flatMap(fromRow)
708
+ | None =>
709
+ switch r->field("edges")->Option.flatMap(JSON.Decode.array) {
710
+ | Some(edges) => edges->Array.get(0)->Option.flatMap(e => e->field("node"))->Option.flatMap(fromRow)
711
+ | None => fromRow(r)
712
+ }
713
+ }
714
+ }
715
+
716
+ describe("PgQueryResolver — a nested union survives every door", () => {
717
+ let doors = [
718
+ ("getById", mkPayload(~kind="getById", ~args=objArgs([("id", JSON.Encode.string("g-1"))]), ())),
719
+ (
720
+ "byIds",
721
+ mkPayload(~kind="byIds", ~args=objArgs([("ids", JSON.Encode.array([JSON.Encode.string("g-1")]))]), ()),
722
+ ),
723
+ (
724
+ "index",
725
+ mkPayload(~kind="index", ~index="byStatus", ~args=objArgs([("byStatus", JSON.Encode.string("active"))]), ()),
726
+ ),
727
+ ("list", mkPayload(~kind="list", ())),
728
+ ("items", mkPayload(~kind="items", ~args=objArgs([("id", JSON.Encode.string("g-1"))]), ())),
729
+ // The cross-table doors answer with the *target's* rows, so a union on the
730
+ // target travels through a field on some other view's type.
731
+ (
732
+ "resolveOne",
733
+ {
734
+ readModelName: "Geos",
735
+ kind: "resolveOne",
736
+ target: "Geos",
737
+ source: objArgs([("ref", JSON.Encode.string("g-1"))]),
738
+ sourceIdField: "ref",
739
+ multi: false,
740
+ arguments: objArgs([]),
741
+ identity: Reventless.Identity.anonymous,
742
+ },
743
+ ),
744
+ (
745
+ "resolveMany",
746
+ {
747
+ readModelName: "Geos",
748
+ kind: "resolveMany",
749
+ target: "Geos",
750
+ source: objArgs([("refs", JSON.Encode.array([JSON.Encode.string("g-1")]))]),
751
+ sourceIdsField: "refs",
752
+ arguments: objArgs([]),
753
+ identity: Reventless.Identity.anonymous,
754
+ },
755
+ ),
756
+ ]
757
+
758
+ doors->Array.forEach(((label, payload)) =>
759
+ testPromise(label ++ " hands the member type back as stored", async () => {
760
+ let r = await PgQueryResolver_Lambda.dispatch(~binding=geoBinding(), ~payload)
761
+ expect(memberOf(r))->toEqual(Some("GeolocationLocated"))
762
+ })
763
+ )
764
+
765
+ // `node` is the one door that writes a `__typename` of its own, at the top
766
+ // level. It must not reach down and overwrite the member's.
767
+ testPromise("node stamps the top level without disturbing the member", async () => {
768
+ PgQueryResolver_Lambda.register(~readModelName="Geos", geoBinding())
769
+ PgQueryResolver_Lambda.registerNodeType(~typeName="Geo", ~readModelName="Geos")
770
+ let payload: PgQueryResolver_Lambda.payload = {
771
+ readModelName: "",
772
+ kind: "node",
773
+ arguments: objArgs([("id", JSON.Encode.string(btoa("Geo:g-1")))]),
774
+ identity: Reventless.Identity.anonymous,
775
+ }
776
+ let r = await PgQueryResolver_Lambda.handler(payload, Obj.magic(Nullable.null))
777
+ expect(r->str("__typename"))->toEqual(Some("Geo"))
778
+ expect(memberOf(r))->toEqual(Some("GeolocationLocated"))
779
+ })
780
+ })