@reventlessdev/reventless-aws 3.0.0-alpha.345 → 3.0.0-alpha.347
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 +54 -0
- package/package.json +19 -12
- package/rescript.json +2 -1
- package/run-bake-manifest.mjs +3 -0
- package/run-deploy-app.mjs +3 -0
- package/run-provision-accounts.mjs +3 -0
- package/run-provision-admin.mjs +3 -0
- package/scripts/BakeManifest.res +395 -0
- package/scripts/BakeManifest.res.mjs +487 -0
- package/scripts/DeployApp.res +708 -0
- package/scripts/DeployApp.res.mjs +1015 -0
- package/scripts/DeployManifest.res +84 -0
- package/scripts/DeployManifest.res.mjs +112 -0
- package/scripts/ProvisionAccounts.res +339 -0
- package/scripts/ProvisionAccounts.res.mjs +351 -0
- package/scripts/ProvisionAdmin.res +275 -0
- package/scripts/ProvisionAdmin.res.mjs +289 -0
- package/scripts/ProvisionCognito.res +149 -0
- package/scripts/ProvisionCognito.res.mjs +98 -0
- package/scripts/ProvisionIdentity.res +76 -11
- package/scripts/ProvisionIdentity.res.mjs +72 -7
- package/scripts/ProvisionProvider.res +104 -0
- package/scripts/ProvisionProvider.res.mjs +123 -0
- package/scripts/PulumiCli.res +43 -0
- package/scripts/PulumiCli.res.mjs +106 -0
- package/src/Platform.res +51 -4
- package/src/Platform.res.mjs +32 -14
- package/src/Platform_Stack.res +119 -9
- package/src/Platform_Stack.res.mjs +42 -4
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res +1 -5
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res.mjs +1 -1
- package/src/adapter/Api/Platform_UIFragments_Lambda.res +1 -5
- package/src/adapter/Api/Platform_UIFragments_Lambda.res.mjs +1 -2
- package/src/adapter/Auth/Auth_ActiveRoleStore.res +1 -5
- package/src/adapter/Auth/Auth_ActiveRoleStore.res.mjs +1 -2
- package/src/adapter/Auth/Auth_ActiveRoleTrigger.res +1 -5
- package/src/adapter/Auth/Auth_ActiveRoleTrigger.res.mjs +1 -2
- package/src/adapter/Auth/Auth_LoginIdentifier.res +66 -0
- package/src/adapter/Auth/Auth_LoginIdentifier.res.mjs +70 -0
- package/src/adapter/Auth/Auth_SignUpMode.res +78 -0
- package/src/adapter/Auth/Auth_SignUpMode.res.mjs +56 -0
- package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res +52 -31
- package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res.mjs +75 -44
- package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync.res +1 -5
- package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync.res.mjs +1 -2
- package/src/adapter/Geocoder/Geocoder_AwsLocation_Resolver.res +1 -5
- package/src/adapter/Geocoder/Geocoder_AwsLocation_Resolver.res.mjs +1 -2
- package/src/adapter/Postgres/PgChangeFeedRelay_Runtime.res +5 -4
- package/src/adapter/Postgres/PgChangeFeedRelay_Runtime.res.mjs +1 -1
- package/src/adapter/Postgres/PgMigration_Builder.res +1 -1
- package/src/adapter/Postgres/PgMigration_Builder.res.mjs +1 -2
- package/src/adapter/Runtime/AutomationSliceEntryPoint_Ops.res +5 -0
- package/src/adapter/Runtime/AutomationSliceEntryPoint_Ops.res.mjs +5 -1
- package/src/adapter/Runtime/CommandGeneratorEntryPoint_Ops.res +2 -0
- package/src/adapter/Runtime/CommandGeneratorEntryPoint_Ops.res.mjs +2 -2
- package/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs +15 -6
- package/src/adapter/Runtime/DcbCommandTopicEntryPoint_Ops.res +51 -30
- package/src/adapter/Runtime/DcbCommandTopicEntryPoint_Ops.res.mjs +31 -14
- package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +1 -5
- package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs +1 -1
- package/src/adapter/StateTopic/StateTopic_AppSync.res +1 -5
- package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +1 -1
- package/src/adapter/Task/TaskBucket_S3.res +1 -3
- package/src/adapter/Task/TaskBucket_S3.res.mjs +1 -1
- package/src/adapter/Upload/Upload_Claim_S3.res +1 -5
- package/src/adapter/Upload/Upload_Claim_S3.res.mjs +1 -1
- package/src/adapter/Upload/Upload_Presign_S3.res +1 -5
- package/src/adapter/Upload/Upload_Presign_S3.res.mjs +1 -2
- package/src/capability/Capability_Geocoding_AwsLocation.res +5 -1
- package/src/capability/Capability_Geocoding_AwsLocation.res.mjs +7 -1
- package/src/components/Api/AppSync_Adapter.res +56 -72
- package/src/components/Api/AppSync_Adapter.res.mjs +26 -60
- package/src/components/Api/AppSync_SdlDecorate.res +82 -1
- package/src/components/Api/AppSync_SdlDecorate.res.mjs +45 -0
- package/src/plugin/cloner/ClonerRunner_Fargate.res +1 -5
- package/src/plugin/cloner/ClonerRunner_Fargate.res.mjs +1 -1
- package/src/plugin/stack/Plugin_Stack.res +4 -0
- package/src/plugin/stack/Plugin_Stack.res.mjs +2 -0
- package/src/util/Util_DeadLetterQueue.res +1 -5
- package/src/util/Util_DeadLetterQueue.res.mjs +1 -2
- package/src/util/Util_StoreLayout.res +15 -2
- package/src/util/Util_StoreLayout.res.mjs +11 -3
- package/tests/AppSync_AdapterTest.res +83 -0
- package/tests/AppSync_AdapterTest.res.mjs +69 -0
- package/tests/Auth_LoginIdentifierTest.res +51 -0
- package/tests/Auth_LoginIdentifierTest.res.mjs +51 -0
- package/tests/Auth_SignUpModeTest.res +56 -0
- package/tests/Auth_SignUpModeTest.res.mjs +53 -0
- package/tests/BakeManifestTest.res +204 -0
- package/tests/BakeManifestTest.res.mjs +259 -0
- package/tests/DcbColdStartPartitionTest.res +32 -0
- package/tests/DcbColdStartPartitionTest.res.mjs +33 -0
- package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res +88 -0
- package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res.mjs +112 -0
- package/tests/DcbUnresolvedPartitionSlice.res +28 -0
- package/tests/DcbUnresolvedPartitionSlice.res.mjs +38 -0
- package/tests/DeployAppTest.res +90 -0
- package/tests/DeployAppTest.res.mjs +133 -0
- package/tests/LambdaLayerLookupTest.res +104 -0
- package/tests/LambdaLayerLookupTest.res.mjs +117 -0
- package/tests/ProvisionAccountsTest.res +168 -0
- package/tests/ProvisionAccountsTest.res.mjs +184 -0
- package/tests/ProvisionAdminTest.res +55 -0
- package/tests/ProvisionAdminTest.res.mjs +66 -0
- package/tests/ProvisionIdentityTest.res +89 -3
- package/tests/ProvisionIdentityTest.res.mjs +66 -1
- package/tests/Util_OwnerScopeEnvTest.res +25 -0
- package/tests/Util_OwnerScopeEnvTest.res.mjs +13 -0
- package/tests/Util_StoreLayoutTest.res +6 -0
- package/tests/Util_StoreLayoutTest.res.mjs +7 -4
- package/tests/dcbColdStartPartition.mjs +66 -0
- package/tests/setup/layerArn.cjs +5 -0
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import * as Stdlib_Int from "@rescript/runtime/lib/es6/Stdlib_Int.js";
|
|
4
4
|
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
5
5
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
6
|
+
import * as Pulumi from "@pulumi/pulumi";
|
|
7
|
+
import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
|
|
6
8
|
import * as StoreLayout$ReventlessCore from "@reventlessdev/reventless-core/src/util/StoreLayout.res.mjs";
|
|
7
9
|
|
|
8
10
|
let defaultEphemeralPrefixes = ["pr-"];
|
|
@@ -15,15 +17,20 @@ function layoutFor(stack, prodStacks) {
|
|
|
15
17
|
}
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
function protectionFor(stack, ephemeralPrefixesOpt) {
|
|
20
|
+
function protectionFor(stack, disposableOpt, ephemeralPrefixesOpt) {
|
|
21
|
+
let disposable = disposableOpt !== undefined ? disposableOpt : false;
|
|
19
22
|
let ephemeralPrefixes = ephemeralPrefixesOpt !== undefined ? ephemeralPrefixesOpt : defaultEphemeralPrefixes;
|
|
20
|
-
if (ephemeralPrefixes.some(p => stack.startsWith(p))) {
|
|
23
|
+
if (disposable || ephemeralPrefixes.some(p => stack.startsWith(p))) {
|
|
21
24
|
return "Unprotected";
|
|
22
25
|
} else {
|
|
23
26
|
return "Protected";
|
|
24
27
|
}
|
|
25
28
|
}
|
|
26
29
|
|
|
30
|
+
function protectionOfDeployedStack() {
|
|
31
|
+
return protectionFor(Pulumi.getStack(), Primitive_object.equal(new Pulumi.Config("reventless").get("disposable"), "true"), undefined);
|
|
32
|
+
}
|
|
33
|
+
|
|
27
34
|
function bucketNameFor(layout, stack, plugin, store) {
|
|
28
35
|
if (layout === "PerStore") {
|
|
29
36
|
return plugin + `-` + store;
|
|
@@ -84,10 +91,11 @@ export {
|
|
|
84
91
|
defaultEphemeralPrefixes,
|
|
85
92
|
layoutFor,
|
|
86
93
|
protectionFor,
|
|
94
|
+
protectionOfDeployedStack,
|
|
87
95
|
bucketNameFor,
|
|
88
96
|
keyPrefixFor,
|
|
89
97
|
servingFor,
|
|
90
98
|
coverageFor,
|
|
91
99
|
pendingExpiryFor,
|
|
92
100
|
}
|
|
93
|
-
/*
|
|
101
|
+
/* @pulumi/pulumi Not a pure module */
|
|
@@ -276,6 +276,89 @@ describe("AppSync_Adapter.injectAwsAuth — Stage E2 permission lifting", () =>
|
|
|
276
276
|
}
|
|
277
277
|
})
|
|
278
278
|
|
|
279
|
+
// ── AllowAnonymous is refused, not silently downgraded ────────────────────
|
|
280
|
+
//
|
|
281
|
+
// The defect these cover: `AllowAnonymous` shared `AllowAuthenticated`'s arm
|
|
282
|
+
// and emitted the same group-less directive, which on this Cognito-primary
|
|
283
|
+
// API means *any authenticated caller* — the inverse of the declaration. It
|
|
284
|
+
// deployed green, passed `assertGateable`, and worked on the local platform,
|
|
285
|
+
// whose resolvers call `Authorization.isAllowed` and honour the rule. It
|
|
286
|
+
// failed closed, so no deployment ever reported it.
|
|
287
|
+
|
|
288
|
+
let refuses = (run: unit => 'a): bool =>
|
|
289
|
+
switch run() {
|
|
290
|
+
| _ => false
|
|
291
|
+
| exception _ => true
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
let messageOf = (run: unit => 'a): string =>
|
|
295
|
+
switch run() {
|
|
296
|
+
| _ => ""
|
|
297
|
+
| exception JsExn(err) => JsExn.message(err)->Option.getOr("")
|
|
298
|
+
| exception _ => ""
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
let anonymousMutation = (~fieldNames) => {
|
|
302
|
+
let fp =
|
|
303
|
+
fieldNames
|
|
304
|
+
->Array.map(n => (n, Reventless.Authorization.AllowAnonymous))
|
|
305
|
+
->Dict.fromArray
|
|
306
|
+
let entry = mutationEntry(~fieldNames, ~fieldPermissions=fp)
|
|
307
|
+
let frag = makeFragment(fieldNames->Array.map(n => `${n}(id: ID!): String`), [])
|
|
308
|
+
() => AppSync_Adapter.injectAwsAuth(frag, ~mutationEntries=[entry], ~queryEntries=[])
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
testSync("AllowAnonymous on a mutation fails the deploy", () => {
|
|
312
|
+
expect(refuses(anonymousMutation(~fieldNames=["p_Register"])))->toBe(true)
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
// The whole point of the change: two distinct permissions must not produce
|
|
316
|
+
// one outcome. Asserting only "it throws" would still pass if a later edit
|
|
317
|
+
// re-collapsed the arms and dropped the refusal, so pin the contrast.
|
|
318
|
+
testSync("AllowAnonymous and AllowAuthenticated do not behave alike", () => {
|
|
319
|
+
let authenticated = {
|
|
320
|
+
let fp = Dict.fromArray([("p_Register", Reventless.Authorization.AllowAuthenticated)])
|
|
321
|
+
let entry = mutationEntry(~fieldNames=["p_Register"], ~fieldPermissions=fp)
|
|
322
|
+
let frag = makeFragment(["p_Register(id: ID!): String"], [])
|
|
323
|
+
() => AppSync_Adapter.injectAwsAuth(frag, ~mutationEntries=[entry], ~queryEntries=[])
|
|
324
|
+
}
|
|
325
|
+
expect(refuses(authenticated))->toBe(false)
|
|
326
|
+
expect(refuses(anonymousMutation(~fieldNames=["p_Register"])))->toBe(true)
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
testSync("the refusal names the offending field, so it can be found", () => {
|
|
330
|
+
let msg = messageOf(anonymousMutation(~fieldNames=["p_Register"]))
|
|
331
|
+
expect(msg)->toContain("p_Register")
|
|
332
|
+
expect(msg)->toContain("AllowAnonymous")
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
// One deploy should report every offending field. Reporting the first one
|
|
336
|
+
// turns a batch of them into as many failed deploys as there are fields.
|
|
337
|
+
testSync("every offending field is reported, not just the first", () => {
|
|
338
|
+
let msg = messageOf(anonymousMutation(~fieldNames=["p_Register", "p_Confirm"]))
|
|
339
|
+
expect(msg)->toContain("p_Register")
|
|
340
|
+
expect(msg)->toContain("p_Confirm")
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
testSync("AllowAnonymous on a query names BOTH derived field names", () => {
|
|
344
|
+
let entry = queryEntry(
|
|
345
|
+
~single="p_Item",
|
|
346
|
+
~list="p_Items",
|
|
347
|
+
~permission=Some(Reventless.Authorization.AllowAnonymous),
|
|
348
|
+
)
|
|
349
|
+
let frag = makeFragment(
|
|
350
|
+
[],
|
|
351
|
+
["p_Item(id: ID!): Item", "p_Items(first: Int, after: String): ItemConnection!"],
|
|
352
|
+
)
|
|
353
|
+
let run = () => AppSync_Adapter.injectAwsAuth(frag, ~mutationEntries=[], ~queryEntries=[entry])
|
|
354
|
+
expect(refuses(run))->toBe(true)
|
|
355
|
+
let msg = messageOf(run)
|
|
356
|
+
// The spec declares one permission and the generator emits two fields from
|
|
357
|
+
// it; naming one would send the author hunting a declaration that is not there.
|
|
358
|
+
expect(msg)->toContain("p_Item")
|
|
359
|
+
expect(msg)->toContain("p_Items")
|
|
360
|
+
})
|
|
361
|
+
|
|
279
362
|
testSync("Spec-level permission wins over legacy authorization field", () => {
|
|
280
363
|
// Legacy {tableName, group} says "Admin"; spec-level says "Manager".
|
|
281
364
|
// Spec-level must win.
|
|
@@ -245,6 +245,75 @@ globalThis.describe("AppSync_Adapter.injectAwsAuth — Stage E2 permission lifti
|
|
|
245
245
|
return Stdlib_JsError.throwWithMessage("missing query fields");
|
|
246
246
|
}
|
|
247
247
|
});
|
|
248
|
+
let refuses = run => {
|
|
249
|
+
try {
|
|
250
|
+
run();
|
|
251
|
+
return false;
|
|
252
|
+
} catch (exn) {
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
let messageOf = run => {
|
|
257
|
+
try {
|
|
258
|
+
run();
|
|
259
|
+
return "";
|
|
260
|
+
} catch (raw_err) {
|
|
261
|
+
let err = Primitive_exceptions.internalToException(raw_err);
|
|
262
|
+
if (err.RE_EXN_ID === "JsExn") {
|
|
263
|
+
return Stdlib_Option.getOr(Stdlib_JsExn.message(err._1), "");
|
|
264
|
+
} else {
|
|
265
|
+
return "";
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
let anonymousMutation = fieldNames => {
|
|
270
|
+
let fp = Object.fromEntries(fieldNames.map(n => [
|
|
271
|
+
n,
|
|
272
|
+
"AllowAnonymous"
|
|
273
|
+
]));
|
|
274
|
+
let entry = mutationEntry(fieldNames, fp);
|
|
275
|
+
let frag = makeFragment(fieldNames.map(n => n + `(id: ID!): String`), []);
|
|
276
|
+
return () => AppSync_Adapter$ReventlessAws.injectAwsAuth(frag, [entry], []);
|
|
277
|
+
};
|
|
278
|
+
globalThis.test("AllowAnonymous on a mutation fails the deploy", () => {
|
|
279
|
+
globalThis.expect(refuses(anonymousMutation(["p_Register"]))).toBe(true);
|
|
280
|
+
});
|
|
281
|
+
globalThis.test("AllowAnonymous and AllowAuthenticated do not behave alike", () => {
|
|
282
|
+
let fp = Object.fromEntries([[
|
|
283
|
+
"p_Register",
|
|
284
|
+
"AllowAuthenticated"
|
|
285
|
+
]]);
|
|
286
|
+
let entry = mutationEntry(["p_Register"], fp);
|
|
287
|
+
let frag = makeFragment(["p_Register(id: ID!): String"], []);
|
|
288
|
+
let authenticated = () => AppSync_Adapter$ReventlessAws.injectAwsAuth(frag, [entry], []);
|
|
289
|
+
globalThis.expect(refuses(authenticated)).toBe(false);
|
|
290
|
+
globalThis.expect(refuses(anonymousMutation(["p_Register"]))).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
globalThis.test("the refusal names the offending field, so it can be found", () => {
|
|
293
|
+
let msg = messageOf(anonymousMutation(["p_Register"]));
|
|
294
|
+
globalThis.expect(msg).toContain("p_Register");
|
|
295
|
+
globalThis.expect(msg).toContain("AllowAnonymous");
|
|
296
|
+
});
|
|
297
|
+
globalThis.test("every offending field is reported, not just the first", () => {
|
|
298
|
+
let msg = messageOf(anonymousMutation([
|
|
299
|
+
"p_Register",
|
|
300
|
+
"p_Confirm"
|
|
301
|
+
]));
|
|
302
|
+
globalThis.expect(msg).toContain("p_Register");
|
|
303
|
+
globalThis.expect(msg).toContain("p_Confirm");
|
|
304
|
+
});
|
|
305
|
+
globalThis.test("AllowAnonymous on a query names BOTH derived field names", () => {
|
|
306
|
+
let entry = queryEntry("p_Item", "p_Items", "AllowAnonymous");
|
|
307
|
+
let frag = makeFragment([], [
|
|
308
|
+
"p_Item(id: ID!): Item",
|
|
309
|
+
"p_Items(first: Int, after: String): ItemConnection!"
|
|
310
|
+
]);
|
|
311
|
+
let run = () => AppSync_Adapter$ReventlessAws.injectAwsAuth(frag, [], [entry]);
|
|
312
|
+
globalThis.expect(refuses(run)).toBe(true);
|
|
313
|
+
let msg = messageOf(run);
|
|
314
|
+
globalThis.expect(msg).toContain("p_Item");
|
|
315
|
+
globalThis.expect(msg).toContain("p_Items");
|
|
316
|
+
});
|
|
248
317
|
globalThis.test("Spec-level permission wins over legacy authorization field", () => {
|
|
249
318
|
let fp = Object.fromEntries([[
|
|
250
319
|
"p_X",
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
open JestGlobals
|
|
2
|
+
|
|
3
|
+
// The sign-in attribute is the one pool setting no later deploy can correct, so
|
|
4
|
+
// what these cover is the parse: absent must mean today's behaviour, and a
|
|
5
|
+
// spelling this build does not know must refuse rather than default.
|
|
6
|
+
|
|
7
|
+
module LoginIdentifier = Auth_LoginIdentifier
|
|
8
|
+
|
|
9
|
+
describe("Auth_LoginIdentifier.parse", () => {
|
|
10
|
+
testSync("absent is email, so an existing stack redeploys unchanged", () =>
|
|
11
|
+
expect(LoginIdentifier.parse(None))->toEqual(Ok(LoginIdentifier.Email))
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
testSync("every spelling round-trips through its config name", () =>
|
|
15
|
+
expect(
|
|
16
|
+
LoginIdentifier.all->Array.map(i => LoginIdentifier.parse(Some(LoginIdentifier.toString(i)))),
|
|
17
|
+
)->toEqual(LoginIdentifier.all->Array.map(i => Ok(i)))
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
// 🚨 A typo that defaulted would create a pool signing in on the wrong
|
|
21
|
+
// attribute, and correcting that means replacing the pool and re-registering
|
|
22
|
+
// every account in it.
|
|
23
|
+
testSync("an unrecognised spelling refuses", () =>
|
|
24
|
+
expect(LoginIdentifier.parse(Some("e-mail"))->Result.isError)->toBe(true)
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
testSync("the refusal names what was accepted instead", () =>
|
|
28
|
+
expect(
|
|
29
|
+
switch LoginIdentifier.parse(Some("e-mail")) {
|
|
30
|
+
| Error(message) => message->String.includes("emailOrPhone")
|
|
31
|
+
| Ok(_) => false
|
|
32
|
+
},
|
|
33
|
+
)->toBe(true)
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
// Case matters: Cognito's own attribute names are lowercase, and accepting
|
|
37
|
+
// "Email" here would leave two spellings meaning one pool.
|
|
38
|
+
testSync("a differently-cased spelling is not silently accepted", () =>
|
|
39
|
+
expect(LoginIdentifier.parse(Some("Email"))->Result.isError)->toBe(true)
|
|
40
|
+
)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe("Auth_LoginIdentifier.usernameAttributes", () => {
|
|
44
|
+
testSync("maps onto the attribute names Cognito takes at creation", () =>
|
|
45
|
+
expect(LoginIdentifier.all->Array.map(LoginIdentifier.usernameAttributes))->toEqual([
|
|
46
|
+
["email"],
|
|
47
|
+
["phone_number"],
|
|
48
|
+
["email", "phone_number"],
|
|
49
|
+
])
|
|
50
|
+
)
|
|
51
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
|
|
4
|
+
import * as Auth_LoginIdentifier$ReventlessAws from "../src/adapter/Auth/Auth_LoginIdentifier.res.mjs";
|
|
5
|
+
|
|
6
|
+
globalThis.describe("Auth_LoginIdentifier.parse", () => {
|
|
7
|
+
globalThis.test("absent is email, so an existing stack redeploys unchanged", () => {
|
|
8
|
+
globalThis.expect(Auth_LoginIdentifier$ReventlessAws.parse(undefined)).toEqual({
|
|
9
|
+
TAG: "Ok",
|
|
10
|
+
_0: "Email"
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
globalThis.test("every spelling round-trips through its config name", () => {
|
|
14
|
+
globalThis.expect(Auth_LoginIdentifier$ReventlessAws.all.map(i => Auth_LoginIdentifier$ReventlessAws.parse(Auth_LoginIdentifier$ReventlessAws.toString(i)))).toEqual(Auth_LoginIdentifier$ReventlessAws.all.map(i => ({
|
|
15
|
+
TAG: "Ok",
|
|
16
|
+
_0: i
|
|
17
|
+
})));
|
|
18
|
+
});
|
|
19
|
+
globalThis.test("an unrecognised spelling refuses", () => {
|
|
20
|
+
globalThis.expect(Stdlib_Result.isError(Auth_LoginIdentifier$ReventlessAws.parse("e-mail"))).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
globalThis.test("the refusal names what was accepted instead", () => {
|
|
23
|
+
let message = Auth_LoginIdentifier$ReventlessAws.parse("e-mail");
|
|
24
|
+
let tmp;
|
|
25
|
+
tmp = message.TAG === "Ok" ? false : message._0.includes("emailOrPhone");
|
|
26
|
+
globalThis.expect(tmp).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
globalThis.test("a differently-cased spelling is not silently accepted", () => {
|
|
29
|
+
globalThis.expect(Stdlib_Result.isError(Auth_LoginIdentifier$ReventlessAws.parse("Email"))).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
globalThis.describe("Auth_LoginIdentifier.usernameAttributes", () => {
|
|
34
|
+
globalThis.test("maps onto the attribute names Cognito takes at creation", () => {
|
|
35
|
+
globalThis.expect(Auth_LoginIdentifier$ReventlessAws.all.map(Auth_LoginIdentifier$ReventlessAws.usernameAttributes)).toEqual([
|
|
36
|
+
["email"],
|
|
37
|
+
["phone_number"],
|
|
38
|
+
[
|
|
39
|
+
"email",
|
|
40
|
+
"phone_number"
|
|
41
|
+
]
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
let LoginIdentifier;
|
|
47
|
+
|
|
48
|
+
export {
|
|
49
|
+
LoginIdentifier,
|
|
50
|
+
}
|
|
51
|
+
/* Not a pure module */
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
open JestGlobals
|
|
2
|
+
|
|
3
|
+
// Sign-up mode is correctable on a live pool, unlike the sign-in attribute, so
|
|
4
|
+
// what these cover is not permanence but silence: absent must mean today's
|
|
5
|
+
// behaviour, an unknown spelling must refuse rather than quietly close the pool,
|
|
6
|
+
// and the mapping onto Cognito's inverted field must not be readable backwards.
|
|
7
|
+
|
|
8
|
+
module SignUpMode = Auth_SignUpMode
|
|
9
|
+
|
|
10
|
+
describe("Auth_SignUpMode.parse", () => {
|
|
11
|
+
testSync("absent is adminOnly, so an existing stack redeploys unchanged", () =>
|
|
12
|
+
expect(SignUpMode.parse(None))->toEqual(Ok(SignUpMode.AdminOnly))
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
testSync("every spelling round-trips through its config name", () =>
|
|
16
|
+
expect(SignUpMode.all->Array.map(m => SignUpMode.parse(Some(SignUpMode.toString(m)))))->toEqual(
|
|
17
|
+
SignUpMode.all->Array.map(m => Ok(m)),
|
|
18
|
+
)
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
// 🚨 Defaulting past a typo fails CLOSED, which is safe and invisible: the
|
|
22
|
+
// deploy is green, the config reads as though registration is open, and the
|
|
23
|
+
// only symptom is every registration being refused — far from the cause.
|
|
24
|
+
testSync("an unrecognised spelling refuses", () =>
|
|
25
|
+
expect(SignUpMode.parse(Some("self-service"))->Result.isError)->toBe(true)
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
testSync("the refusal names what was accepted instead", () =>
|
|
29
|
+
expect(
|
|
30
|
+
switch SignUpMode.parse(Some("self-service")) {
|
|
31
|
+
| Error(message) => message->String.includes("selfService")
|
|
32
|
+
| Ok(_) => false
|
|
33
|
+
},
|
|
34
|
+
)->toBe(true)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
testSync("a differently-cased spelling is not silently accepted", () =>
|
|
38
|
+
expect(SignUpMode.parse(Some("SelfService"))->Result.isError)->toBe(true)
|
|
39
|
+
)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe("Auth_SignUpMode.allowAdminCreateUserOnly", () => {
|
|
43
|
+
// Cognito asks the opposite of what this type asks. Pinning both arms is the
|
|
44
|
+
// point: an inverted mapping would open a pool meant to stay closed, and on a
|
|
45
|
+
// shared pool it would open it for every stack using it.
|
|
46
|
+
testSync("inverts the question Cognito's field asks", () =>
|
|
47
|
+
expect((
|
|
48
|
+
SignUpMode.AdminOnly->SignUpMode.allowAdminCreateUserOnly,
|
|
49
|
+
SignUpMode.SelfService->SignUpMode.allowAdminCreateUserOnly,
|
|
50
|
+
))->toEqual((true, false))
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
testSync("the default leaves the pool admin-create-only", () =>
|
|
54
|
+
expect(SignUpMode.default->SignUpMode.allowAdminCreateUserOnly)->toBe(true)
|
|
55
|
+
)
|
|
56
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
|
|
4
|
+
import * as Auth_SignUpMode$ReventlessAws from "../src/adapter/Auth/Auth_SignUpMode.res.mjs";
|
|
5
|
+
|
|
6
|
+
globalThis.describe("Auth_SignUpMode.parse", () => {
|
|
7
|
+
globalThis.test("absent is adminOnly, so an existing stack redeploys unchanged", () => {
|
|
8
|
+
globalThis.expect(Auth_SignUpMode$ReventlessAws.parse(undefined)).toEqual({
|
|
9
|
+
TAG: "Ok",
|
|
10
|
+
_0: "AdminOnly"
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
globalThis.test("every spelling round-trips through its config name", () => {
|
|
14
|
+
globalThis.expect(Auth_SignUpMode$ReventlessAws.all.map(m => Auth_SignUpMode$ReventlessAws.parse(Auth_SignUpMode$ReventlessAws.toString(m)))).toEqual(Auth_SignUpMode$ReventlessAws.all.map(m => ({
|
|
15
|
+
TAG: "Ok",
|
|
16
|
+
_0: m
|
|
17
|
+
})));
|
|
18
|
+
});
|
|
19
|
+
globalThis.test("an unrecognised spelling refuses", () => {
|
|
20
|
+
globalThis.expect(Stdlib_Result.isError(Auth_SignUpMode$ReventlessAws.parse("self-service"))).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
globalThis.test("the refusal names what was accepted instead", () => {
|
|
23
|
+
let message = Auth_SignUpMode$ReventlessAws.parse("self-service");
|
|
24
|
+
let tmp;
|
|
25
|
+
tmp = message.TAG === "Ok" ? false : message._0.includes("selfService");
|
|
26
|
+
globalThis.expect(tmp).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
globalThis.test("a differently-cased spelling is not silently accepted", () => {
|
|
29
|
+
globalThis.expect(Stdlib_Result.isError(Auth_SignUpMode$ReventlessAws.parse("SelfService"))).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
globalThis.describe("Auth_SignUpMode.allowAdminCreateUserOnly", () => {
|
|
34
|
+
globalThis.test("inverts the question Cognito's field asks", () => {
|
|
35
|
+
globalThis.expect([
|
|
36
|
+
Auth_SignUpMode$ReventlessAws.allowAdminCreateUserOnly("AdminOnly"),
|
|
37
|
+
Auth_SignUpMode$ReventlessAws.allowAdminCreateUserOnly("SelfService")
|
|
38
|
+
]).toEqual([
|
|
39
|
+
true,
|
|
40
|
+
false
|
|
41
|
+
]);
|
|
42
|
+
});
|
|
43
|
+
globalThis.test("the default leaves the pool admin-create-only", () => {
|
|
44
|
+
globalThis.expect(Auth_SignUpMode$ReventlessAws.allowAdminCreateUserOnly(Auth_SignUpMode$ReventlessAws.default)).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
let SignUpMode;
|
|
49
|
+
|
|
50
|
+
export {
|
|
51
|
+
SignUpMode,
|
|
52
|
+
}
|
|
53
|
+
/* Not a pure module */
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
open JestGlobals
|
|
2
|
+
|
|
3
|
+
// What the command decides without AWS: which stacks it reads, what it asks the
|
|
4
|
+
// bake function, how it reads the answer, and when it stops asking.
|
|
5
|
+
|
|
6
|
+
module Bake = BakeManifest
|
|
7
|
+
|
|
8
|
+
let json = text => JSON.parseOrThrow(text)
|
|
9
|
+
|
|
10
|
+
describe("DeployManifest", () => {
|
|
11
|
+
let text = `
|
|
12
|
+
region: eu-west-1
|
|
13
|
+
platform:
|
|
14
|
+
path: platform-aws
|
|
15
|
+
name: shop-platform
|
|
16
|
+
stack-defaults:
|
|
17
|
+
platform:messagingEmailProvider: log
|
|
18
|
+
plugins:
|
|
19
|
+
- name: catalog
|
|
20
|
+
path: catalog-aws
|
|
21
|
+
depends-on: []
|
|
22
|
+
- name: ordering
|
|
23
|
+
path: ordering-aws
|
|
24
|
+
`
|
|
25
|
+
|
|
26
|
+
testSync("resolves every folder against the manifest's own folder", () => {
|
|
27
|
+
let resolved =
|
|
28
|
+
DeployManifest.parseString(text)->Result.map(
|
|
29
|
+
m => m->DeployManifest.resolve(~file="/repo/shop/deploy-manifest.yaml"),
|
|
30
|
+
)
|
|
31
|
+
expect(resolved)->toEqual(
|
|
32
|
+
Ok({
|
|
33
|
+
DeployManifest.file: "/repo/shop/deploy-manifest.yaml",
|
|
34
|
+
region: Some("eu-west-1"),
|
|
35
|
+
platform: {
|
|
36
|
+
name: "shop-platform",
|
|
37
|
+
dir: "/repo/shop/platform-aws",
|
|
38
|
+
stackDefaults: Dict.fromArray([("platform:messagingEmailProvider", "log")]),
|
|
39
|
+
},
|
|
40
|
+
plugins: [
|
|
41
|
+
{name: "catalog", dir: "/repo/shop/catalog-aws", stackDefaults: Dict.make()},
|
|
42
|
+
{name: "ordering", dir: "/repo/shop/ordering-aws", stackDefaults: Dict.make()},
|
|
43
|
+
],
|
|
44
|
+
}),
|
|
45
|
+
)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
testSync("refuses a manifest without a platform", () =>
|
|
49
|
+
expect(DeployManifest.parseString("plugins: []")->Result.isError)->toBe(true)
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe("BakeManifest.parseArgs", () => {
|
|
54
|
+
testSync("reads the stack, the manifest and the deploy's start", () =>
|
|
55
|
+
expect(
|
|
56
|
+
Bake.parseArgs([
|
|
57
|
+
"--stack",
|
|
58
|
+
"alpha",
|
|
59
|
+
"--manifest",
|
|
60
|
+
"m.yaml",
|
|
61
|
+
"--since",
|
|
62
|
+
"2026-09-16T10:00:00Z",
|
|
63
|
+
]),
|
|
64
|
+
)->toEqual(
|
|
65
|
+
Ok({
|
|
66
|
+
Bake.manifest: Some("m.yaml"),
|
|
67
|
+
stack: Some("alpha"),
|
|
68
|
+
since: Some("2026-09-16T10:00:00Z"),
|
|
69
|
+
help: false,
|
|
70
|
+
}),
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
// CI passes the start through an output that is empty on a hand-run workflow.
|
|
75
|
+
testSync("an empty --since is no instant", () =>
|
|
76
|
+
expect(Bake.parseArgs(["--since", ""])->Result.map(a => a.since))->toEqual(Ok(None))
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
testSync("an unknown flag refuses", () =>
|
|
80
|
+
expect(Bake.parseArgs(["--stak", "alpha"]))->toEqual(Error(`unknown argument "--stak"`))
|
|
81
|
+
)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe("BakeManifest reads the stacks", () => {
|
|
85
|
+
testSync("a platform without a bake function declares no bake", () =>
|
|
86
|
+
expect(
|
|
87
|
+
Bake.targetOf(
|
|
88
|
+
json(`{"bakedManifestBucket": "b", "bakedManifestFunction": ""}`)
|
|
89
|
+
->JSON.Decode.object
|
|
90
|
+
->Option.getOr(Dict.make()),
|
|
91
|
+
),
|
|
92
|
+
)->toEqual(None)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
testSync("a platform with one names the function, bucket and key", () =>
|
|
96
|
+
expect(
|
|
97
|
+
Bake.targetOf(
|
|
98
|
+
json(`{"bakedManifestFunction": "fn", "bakedManifestBucket": "b", "bakedManifestKey": "manifest.json"}`)
|
|
99
|
+
->JSON.Decode.object
|
|
100
|
+
->Option.getOr(Dict.make()),
|
|
101
|
+
),
|
|
102
|
+
)->toEqual(Some({Bake.functionName: "fn", bucket: "b", key: "manifest.json"}))
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
testSync("a plugin stack's structure key is read from pluginStructureRef", () =>
|
|
106
|
+
expect(
|
|
107
|
+
Bake.structureRefOf(
|
|
108
|
+
json(`{"pluginStructureRef": {"plugin": "Catalog", "key": "sha256/abc"}}`)
|
|
109
|
+
->JSON.Decode.object
|
|
110
|
+
->Option.getOr(Dict.make()),
|
|
111
|
+
),
|
|
112
|
+
)->toEqual(Some(("Catalog", "sha256/abc")))
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
testSync("the payload carries the deploy's start only when there is one", () => {
|
|
116
|
+
let target = {Bake.functionName: "fn", bucket: "b", key: "k"}
|
|
117
|
+
let expect_ = Dict.fromArray([("Catalog", "sha256/abc")])
|
|
118
|
+
expect(
|
|
119
|
+
Bake.payload(~target, ~expect=expect_, ~since=None)->JSON.stringify,
|
|
120
|
+
)->toBe(`{"bake":true,"bucket":"b","key":"k","expect":{"Catalog":"sha256/abc"}}`)
|
|
121
|
+
expect(
|
|
122
|
+
Bake.payload(~target, ~expect=expect_, ~since=Some("2026-09-16T10:00:00Z"))->JSON.stringify,
|
|
123
|
+
)->toBe(`{"bake":true,"bucket":"b","key":"k","expect":{"Catalog":"sha256/abc"},"since":"2026-09-16T10:00:00Z"}`)
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
let pending = state =>
|
|
128
|
+
json(
|
|
129
|
+
`[{"baked": false, "pending": ["Catalog"], "registrations": [
|
|
130
|
+
{"plugin": "Catalog", "state": "${state}", "expected": "sha256/new", "found": "sha256/old", "writtenAt": null}
|
|
131
|
+
]}]`,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
let baked = json(`[
|
|
135
|
+
{"baked": true, "bucket": "b", "key": "manifest.json", "plugins": 2, "bytes": 512},
|
|
136
|
+
{"summary": true, "plugins": 1, "registered": 1, "unchanged": 0, "matched": 0, "registrations": [
|
|
137
|
+
{"plugin": "Catalog", "state": "registered", "expected": "sha256/new", "found": "sha256/new", "writtenAt": "2026-09-16T10:01:00Z"}
|
|
138
|
+
]}
|
|
139
|
+
]`)
|
|
140
|
+
|
|
141
|
+
describe("BakeManifest.readAnswer", () => {
|
|
142
|
+
testSync("a written manifest reports its files and what convergence proved", () =>
|
|
143
|
+
switch Bake.readAnswer(baked) {
|
|
144
|
+
| Ok(Baked({written, summary})) =>
|
|
145
|
+
expect((
|
|
146
|
+
written->Array.map(w => (w.key, w.bytes)),
|
|
147
|
+
summary->Option.map(s => s.registered),
|
|
148
|
+
))->toEqual(([("manifest.json", 512)], Some(1)))
|
|
149
|
+
| _ => expect("not baked")->toBe("baked")
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
testSync("a pending answer names the plugins not yet arrived", () =>
|
|
154
|
+
switch Bake.readAnswer(pending("behind")) {
|
|
155
|
+
| Ok(Pending({pending})) => expect(pending)->toEqual(["Catalog"])
|
|
156
|
+
| _ => expect("not pending")->toBe("pending")
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
testSync("the report line carries both keys and the row's date", () =>
|
|
161
|
+
expect(
|
|
162
|
+
Bake.registrationLine({
|
|
163
|
+
plugin: "Catalog",
|
|
164
|
+
state: "missing",
|
|
165
|
+
expected: "sha256/new",
|
|
166
|
+
found: None,
|
|
167
|
+
writtenAt: None,
|
|
168
|
+
}),
|
|
169
|
+
)->toBe(" Catalog: missing — expected sha256/new, row holds no row (written never)")
|
|
170
|
+
)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
describe("BakeManifest.converge", () => {
|
|
174
|
+
let run = async (answers: array<JSON.t>, ~attempts) => {
|
|
175
|
+
let asked = ref(0)
|
|
176
|
+
let invoke = async () => {
|
|
177
|
+
let answer = answers->Array.getUnsafe(Math.Int.min(asked.contents, answers->Array.length - 1))
|
|
178
|
+
asked := asked.contents + 1
|
|
179
|
+
Ok(answer)
|
|
180
|
+
}
|
|
181
|
+
let outcome = await Bake.converge(~invoke, ~sleep=async () => (), ~log=_ => (), ~attempts)
|
|
182
|
+
(outcome->Result.isOk, asked.contents)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
test("asks again while a plugin is behind, then succeeds", async () =>
|
|
186
|
+
expect(await run([pending("behind"), pending("behind"), baked], ~attempts=5))->toEqual((
|
|
187
|
+
true,
|
|
188
|
+
3,
|
|
189
|
+
))
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
// A first deploy has no row for any plugin until its registration lands.
|
|
193
|
+
test("waits for a missing registration", async () =>
|
|
194
|
+
expect(await run([pending("missing"), baked], ~attempts=5))->toEqual((true, 2))
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
test("stops at once on a registration that diverged", async () =>
|
|
198
|
+
expect(await run([pending("diverged"), baked], ~attempts=5))->toEqual((false, 1))
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
test("gives up when the attempts run out", async () =>
|
|
202
|
+
expect(await run([pending("behind")], ~attempts=3))->toEqual((false, 3))
|
|
203
|
+
)
|
|
204
|
+
})
|