@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.
- package/CHANGELOG.md +35 -0
- package/package.json +11 -7
- package/rescript.json +4 -0
- package/run-provision-identity.mjs +3 -0
- package/scripts/ProvisionIdentity.res +303 -0
- package/scripts/ProvisionIdentity.res.mjs +376 -0
- package/src/Platform.res +54 -30
- package/src/Platform.res.mjs +34 -32
- package/src/Platform_Stack.res +102 -19
- package/src/Platform_Stack.res.mjs +43 -7
- package/src/adapter/Auth/Auth_ActiveRolePoolAttachment.res +267 -32
- package/src/adapter/Auth/Auth_ActiveRolePoolAttachment.res.mjs +124 -14
- package/src/adapter/Auth/Auth_ActiveRoleStore.res +93 -5
- package/src/adapter/Auth/Auth_ActiveRoleStore.res.mjs +53 -4
- package/src/adapter/Auth/Auth_ActiveRoleStore_Ops.res +50 -4
- package/src/adapter/Auth/Auth_ActiveRoleStore_Ops.res.mjs +24 -3
- package/src/adapter/Auth/Auth_ActiveRoleStore_Schema.res +92 -0
- package/src/adapter/Auth/Auth_ActiveRoleStore_Schema.res.mjs +49 -0
- package/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res +21 -7
- package/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res.mjs +15 -7
- package/src/adapter/Runtime/AutomationSliceEntryPoint_Ops.res +6 -3
- package/src/util/Util_AwsError.res +56 -0
- package/src/util/Util_AwsError.res.mjs +59 -0
- package/src/util/Util_ShellConfig.res +28 -0
- package/src/util/Util_ShellConfig.res.mjs +22 -0
- package/tests/Auth_ActiveRolePoolAttachmentTest.res +175 -0
- package/tests/Auth_ActiveRolePoolAttachmentTest.res.mjs +136 -0
- package/tests/Auth_ActiveRoleStoreTest.res +65 -0
- package/tests/Auth_ActiveRoleStoreTest.res.mjs +48 -0
- package/tests/Auth_ActiveRoleStore_SchemaTest.res +98 -0
- package/tests/Auth_ActiveRoleStore_SchemaTest.res.mjs +97 -0
- package/tests/AwsErrorFixtures.mjs +16 -0
- package/tests/PgQueryResolver_LambdaTest.res +166 -0
- package/tests/PgQueryResolver_LambdaTest.res.mjs +196 -0
- package/tests/ProvisionIdentityTest.res +109 -0
- package/tests/ProvisionIdentityTest.res.mjs +132 -0
- package/tests/Util_AwsErrorTest.res +127 -0
- package/tests/Util_AwsErrorTest.res.mjs +76 -0
- package/tests/Util_ShellConfigTest.res +51 -0
- 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
|
+
})
|