@reventlessdev/reventless-aws 3.0.0-alpha.345 → 3.0.0-alpha.346
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 +28 -0
- package/package.json +12 -9
- package/run-provision-accounts.mjs +3 -0
- package/run-provision-admin.mjs +3 -0
- package/scripts/ProvisionAccounts.res +325 -0
- package/scripts/ProvisionAccounts.res.mjs +337 -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/src/Platform.res +32 -2
- package/src/Platform.res.mjs +20 -10
- package/src/Platform_Stack.res +119 -9
- package/src/Platform_Stack.res.mjs +42 -4
- 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/Runtime/AutomationSliceEntryPoint_Ops.res +5 -0
- package/src/adapter/Runtime/AutomationSliceEntryPoint_Ops.res.mjs +5 -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 +80 -1
- package/src/components/Api/AppSync_SdlDecorate.res.mjs +45 -0
- 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/ProvisionAccountsTest.res +142 -0
- package/tests/ProvisionAccountsTest.res.mjs +153 -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
|
@@ -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,142 @@
|
|
|
1
|
+
open JestGlobals
|
|
2
|
+
|
|
3
|
+
// What can be decided before an AWS call is made: which pool and which manifest
|
|
4
|
+
// the run is about, and which usernames a pool could never authenticate.
|
|
5
|
+
//
|
|
6
|
+
// The applying itself is not covered here — it is four SDK calls in a loop. What
|
|
7
|
+
// is covered is the preflight, because its whole purpose is to fail *before*
|
|
8
|
+
// half a cast exists, and the argument parsing, because getting it wrong
|
|
9
|
+
// provisions the right accounts into the wrong deployment.
|
|
10
|
+
//
|
|
11
|
+
// The manifest's own rules — what a second run does to a password already in the
|
|
12
|
+
// file — live with the format, in `spec`'s `AccountsManifestTest`.
|
|
13
|
+
|
|
14
|
+
module Provision = ProvisionAccounts
|
|
15
|
+
|
|
16
|
+
describe("ProvisionAccounts.parseArgs", () => {
|
|
17
|
+
testSync("no arguments leaves every choice unanswered", () =>
|
|
18
|
+
expect(Provision.parseArgs([]))->toEqual(
|
|
19
|
+
Ok({Provision.providerId: None, stack: None, file: None, help: false}),
|
|
20
|
+
)
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
testSync("a pool and a manifest parse", () =>
|
|
24
|
+
expect(
|
|
25
|
+
Provision.parseArgs(["--provider-id", "eu-west-1_AbCdEfGhI", "--file", "cast.yaml"]),
|
|
26
|
+
)->toEqual(
|
|
27
|
+
Ok({
|
|
28
|
+
Provision.providerId: Some("eu-west-1_AbCdEfGhI"),
|
|
29
|
+
stack: None,
|
|
30
|
+
file: Some("cast.yaml"),
|
|
31
|
+
help: false,
|
|
32
|
+
}),
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
testSync("--stack picks the deployment to read the pool id from", () =>
|
|
37
|
+
expect(Provision.parseArgs(["--stack", "beta"]))->toEqual(
|
|
38
|
+
Ok({Provision.providerId: None, stack: Some("beta"), file: None, help: false}),
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
// The reason unknown flags refuse rather than being skipped, and it lands
|
|
43
|
+
// harder here than on the first administrator: a typo'd `--provider-id` falls
|
|
44
|
+
// through to the environment and creates the whole cast in a different pool
|
|
45
|
+
// than the operator named.
|
|
46
|
+
testSync("an unknown flag refuses rather than being ignored", () =>
|
|
47
|
+
expect(Provision.parseArgs(["--pool", "eu-west-1_AbCdEfGhI"]))->toEqual(
|
|
48
|
+
Error(`unknown argument "--pool"`),
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
testSync("a flag with no value refuses", () =>
|
|
53
|
+
expect(Provision.parseArgs(["--provider-id"]))->toEqual(Error("--provider-id needs a value"))
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
testSync("-h asks for the usage", () =>
|
|
57
|
+
expect(Provision.parseArgs(["-h"]))->toEqual(
|
|
58
|
+
Ok({Provision.providerId: None, stack: None, file: None, help: true}),
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// Which pool a run writes to, which is the one decision that is expensive to get
|
|
64
|
+
// wrong: provisioning a cast into the wrong deployment succeeds, and leaves
|
|
65
|
+
// working accounts somewhere nobody is looking.
|
|
66
|
+
//
|
|
67
|
+
// The stack fallback is not exercised here — it shells out to `pulumi`, and a
|
|
68
|
+
// test that did would depend on a logged-in CLI and a deployed stack. What is
|
|
69
|
+
// covered is the precedence above it, and that every answer says where it came
|
|
70
|
+
// from, which is what makes a wrong-pool run visible rather than silent.
|
|
71
|
+
describe("ProvisionProvider.resolve", () => {
|
|
72
|
+
let withoutEnv = fn => {
|
|
73
|
+
let saved = NodeProcess.env->Dict.get(ProvisionProvider.envKey)
|
|
74
|
+
NodeProcess.env->Dict.delete(ProvisionProvider.envKey)
|
|
75
|
+
let outcome = fn()
|
|
76
|
+
switch saved {
|
|
77
|
+
| Some(value) => NodeProcess.env->Dict.set(ProvisionProvider.envKey, value)
|
|
78
|
+
| None => ()
|
|
79
|
+
}
|
|
80
|
+
outcome
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
testSync("an explicit id wins, and says so", () =>
|
|
84
|
+
expect(
|
|
85
|
+
withoutEnv(
|
|
86
|
+
() => ProvisionProvider.resolve(~given=Some("eu-west-1_Explicit"), ~stack=Some("alpha")),
|
|
87
|
+
),
|
|
88
|
+
)->toEqual(Ok(("eu-west-1_Explicit", ProvisionProvider.Flag)))
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
// The flag beats the variable rather than the other way round: a CI shell that
|
|
92
|
+
// exports one for every stack must still be overridable for a single run.
|
|
93
|
+
testSync("an explicit id beats the environment", () => {
|
|
94
|
+
NodeProcess.env->Dict.set(ProvisionProvider.envKey, "eu-west-1_FromEnv")
|
|
95
|
+
let resolved = ProvisionProvider.resolve(~given=Some("eu-west-1_Explicit"), ~stack=None)
|
|
96
|
+
NodeProcess.env->Dict.delete(ProvisionProvider.envKey)
|
|
97
|
+
expect(resolved)->toEqual(Ok(("eu-west-1_Explicit", ProvisionProvider.Flag)))
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
testSync("the environment answers when no flag does", () => {
|
|
101
|
+
NodeProcess.env->Dict.set(ProvisionProvider.envKey, "eu-west-1_FromEnv")
|
|
102
|
+
let resolved = ProvisionProvider.resolve(~given=None, ~stack=None)
|
|
103
|
+
NodeProcess.env->Dict.delete(ProvisionProvider.envKey)
|
|
104
|
+
expect(resolved)->toEqual(Ok(("eu-west-1_FromEnv", ProvisionProvider.Environment)))
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// An empty variable is not an answer. Exporting it unset is the ordinary shape
|
|
108
|
+
// of a CI script whose earlier step did not run, and reading "" as the pool id
|
|
109
|
+
// would fail much later with a message about Cognito rather than about setup.
|
|
110
|
+
testSync("an empty environment variable is not an answer", () =>
|
|
111
|
+
expect({
|
|
112
|
+
NodeProcess.env->Dict.set(ProvisionProvider.envKey, " ")
|
|
113
|
+
let resolved = ProvisionProvider.resolve(~given=None, ~stack=Some("no-such-stack-here"))
|
|
114
|
+
NodeProcess.env->Dict.delete(ProvisionProvider.envKey)
|
|
115
|
+
resolved->Result.isError
|
|
116
|
+
})->toBe(true)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
testSync("each source describes itself", () => {
|
|
120
|
+
expect(ProvisionProvider.describe(Flag))->toBe("--provider-id")
|
|
121
|
+
expect(ProvisionProvider.describe(Environment))->toBe("REVENTLESS_IDENTITY_PROVIDER_ID")
|
|
122
|
+
expect(ProvisionProvider.describe(Stack("beta")))->toBe("stack beta")
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// A manifest usually names plain usernames — `shopper`, `merch` — and a pool
|
|
127
|
+
// created with `UsernameAttributes: [email]` will not take them. The accounts it
|
|
128
|
+
// makes instead are correctly created and cannot authenticate, which is a symptom
|
|
129
|
+
// that arrives at a login screen far from the run that caused it.
|
|
130
|
+
describe("ProvisionAccounts.looksLikeEmail", () => {
|
|
131
|
+
testSync("an address is one", () =>
|
|
132
|
+
expect(Provision.looksLikeEmail("me@example.com"))->toBe(true)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
testSync("a demo username is not", () => expect(Provision.looksLikeEmail("shopper"))->toBe(false))
|
|
136
|
+
|
|
137
|
+
// Not a validator — Cognito owns that. It answers one question: would this pool
|
|
138
|
+
// accept this string as a sign-in name. A local part with no domain would not.
|
|
139
|
+
testSync("an @ with no domain dot is not", () =>
|
|
140
|
+
expect(Provision.looksLikeEmail("admin@localhost"))->toBe(false)
|
|
141
|
+
)
|
|
142
|
+
})
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
|
|
4
|
+
import * as Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
|
|
5
|
+
import * as ProvisionAccounts$ReventlessAws from "../scripts/ProvisionAccounts.res.mjs";
|
|
6
|
+
import * as ProvisionProvider$ReventlessAws from "../scripts/ProvisionProvider.res.mjs";
|
|
7
|
+
|
|
8
|
+
globalThis.describe("ProvisionAccounts.parseArgs", () => {
|
|
9
|
+
globalThis.test("no arguments leaves every choice unanswered", () => {
|
|
10
|
+
globalThis.expect(ProvisionAccounts$ReventlessAws.parseArgs([])).toEqual({
|
|
11
|
+
TAG: "Ok",
|
|
12
|
+
_0: {
|
|
13
|
+
providerId: undefined,
|
|
14
|
+
stack: undefined,
|
|
15
|
+
file: undefined,
|
|
16
|
+
help: false
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
globalThis.test("a pool and a manifest parse", () => {
|
|
21
|
+
globalThis.expect(ProvisionAccounts$ReventlessAws.parseArgs([
|
|
22
|
+
"--provider-id",
|
|
23
|
+
"eu-west-1_AbCdEfGhI",
|
|
24
|
+
"--file",
|
|
25
|
+
"cast.yaml"
|
|
26
|
+
])).toEqual({
|
|
27
|
+
TAG: "Ok",
|
|
28
|
+
_0: {
|
|
29
|
+
providerId: "eu-west-1_AbCdEfGhI",
|
|
30
|
+
stack: undefined,
|
|
31
|
+
file: "cast.yaml",
|
|
32
|
+
help: false
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
globalThis.test("--stack picks the deployment to read the pool id from", () => {
|
|
37
|
+
globalThis.expect(ProvisionAccounts$ReventlessAws.parseArgs([
|
|
38
|
+
"--stack",
|
|
39
|
+
"beta"
|
|
40
|
+
])).toEqual({
|
|
41
|
+
TAG: "Ok",
|
|
42
|
+
_0: {
|
|
43
|
+
providerId: undefined,
|
|
44
|
+
stack: "beta",
|
|
45
|
+
file: undefined,
|
|
46
|
+
help: false
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
globalThis.test("an unknown flag refuses rather than being ignored", () => {
|
|
51
|
+
globalThis.expect(ProvisionAccounts$ReventlessAws.parseArgs([
|
|
52
|
+
"--pool",
|
|
53
|
+
"eu-west-1_AbCdEfGhI"
|
|
54
|
+
])).toEqual({
|
|
55
|
+
TAG: "Error",
|
|
56
|
+
_0: `unknown argument "--pool"`
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
globalThis.test("a flag with no value refuses", () => {
|
|
60
|
+
globalThis.expect(ProvisionAccounts$ReventlessAws.parseArgs(["--provider-id"])).toEqual({
|
|
61
|
+
TAG: "Error",
|
|
62
|
+
_0: "--provider-id needs a value"
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
globalThis.test("-h asks for the usage", () => {
|
|
66
|
+
globalThis.expect(ProvisionAccounts$ReventlessAws.parseArgs(["-h"])).toEqual({
|
|
67
|
+
TAG: "Ok",
|
|
68
|
+
_0: {
|
|
69
|
+
providerId: undefined,
|
|
70
|
+
stack: undefined,
|
|
71
|
+
file: undefined,
|
|
72
|
+
help: true
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
globalThis.describe("ProvisionProvider.resolve", () => {
|
|
79
|
+
let withoutEnv = fn => {
|
|
80
|
+
let saved = process.env[ProvisionProvider$ReventlessAws.envKey];
|
|
81
|
+
Stdlib_Dict.$$delete(process.env, ProvisionProvider$ReventlessAws.envKey);
|
|
82
|
+
let outcome = fn();
|
|
83
|
+
if (saved !== undefined) {
|
|
84
|
+
process.env[ProvisionProvider$ReventlessAws.envKey] = saved;
|
|
85
|
+
}
|
|
86
|
+
return outcome;
|
|
87
|
+
};
|
|
88
|
+
globalThis.test("an explicit id wins, and says so", () => {
|
|
89
|
+
globalThis.expect(withoutEnv(() => ProvisionProvider$ReventlessAws.resolve("eu-west-1_Explicit", "alpha"))).toEqual({
|
|
90
|
+
TAG: "Ok",
|
|
91
|
+
_0: [
|
|
92
|
+
"eu-west-1_Explicit",
|
|
93
|
+
"Flag"
|
|
94
|
+
]
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
globalThis.test("an explicit id beats the environment", () => {
|
|
98
|
+
process.env[ProvisionProvider$ReventlessAws.envKey] = "eu-west-1_FromEnv";
|
|
99
|
+
let resolved = ProvisionProvider$ReventlessAws.resolve("eu-west-1_Explicit", undefined);
|
|
100
|
+
Stdlib_Dict.$$delete(process.env, ProvisionProvider$ReventlessAws.envKey);
|
|
101
|
+
globalThis.expect(resolved).toEqual({
|
|
102
|
+
TAG: "Ok",
|
|
103
|
+
_0: [
|
|
104
|
+
"eu-west-1_Explicit",
|
|
105
|
+
"Flag"
|
|
106
|
+
]
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
globalThis.test("the environment answers when no flag does", () => {
|
|
110
|
+
process.env[ProvisionProvider$ReventlessAws.envKey] = "eu-west-1_FromEnv";
|
|
111
|
+
let resolved = ProvisionProvider$ReventlessAws.resolve(undefined, undefined);
|
|
112
|
+
Stdlib_Dict.$$delete(process.env, ProvisionProvider$ReventlessAws.envKey);
|
|
113
|
+
globalThis.expect(resolved).toEqual({
|
|
114
|
+
TAG: "Ok",
|
|
115
|
+
_0: [
|
|
116
|
+
"eu-west-1_FromEnv",
|
|
117
|
+
"Environment"
|
|
118
|
+
]
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
globalThis.test("an empty environment variable is not an answer", () => {
|
|
122
|
+
process.env[ProvisionProvider$ReventlessAws.envKey] = " ";
|
|
123
|
+
let resolved = ProvisionProvider$ReventlessAws.resolve(undefined, "no-such-stack-here");
|
|
124
|
+
globalThis.expect((Stdlib_Dict.$$delete(process.env, ProvisionProvider$ReventlessAws.envKey), Stdlib_Result.isError(resolved))).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
globalThis.test("each source describes itself", () => {
|
|
127
|
+
globalThis.expect(ProvisionProvider$ReventlessAws.describe("Flag")).toBe("--provider-id");
|
|
128
|
+
globalThis.expect(ProvisionProvider$ReventlessAws.describe("Environment")).toBe("REVENTLESS_IDENTITY_PROVIDER_ID");
|
|
129
|
+
globalThis.expect(ProvisionProvider$ReventlessAws.describe({
|
|
130
|
+
TAG: "Stack",
|
|
131
|
+
_0: "beta"
|
|
132
|
+
})).toBe("stack beta");
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
globalThis.describe("ProvisionAccounts.looksLikeEmail", () => {
|
|
137
|
+
globalThis.test("an address is one", () => {
|
|
138
|
+
globalThis.expect(ProvisionAccounts$ReventlessAws.looksLikeEmail("me@example.com")).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
globalThis.test("a demo username is not", () => {
|
|
141
|
+
globalThis.expect(ProvisionAccounts$ReventlessAws.looksLikeEmail("shopper")).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
globalThis.test("an @ with no domain dot is not", () => {
|
|
144
|
+
globalThis.expect(ProvisionAccounts$ReventlessAws.looksLikeEmail("admin@localhost")).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
let Provision;
|
|
149
|
+
|
|
150
|
+
export {
|
|
151
|
+
Provision,
|
|
152
|
+
}
|
|
153
|
+
/* Not a pure module */
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
open JestGlobals
|
|
2
|
+
|
|
3
|
+
// The bootstrap script's decidable part: which pool and which person the run is
|
|
4
|
+
// about. Reachable at all only because the module defines `main` without calling
|
|
5
|
+
// it — `run-provision-admin.mjs` does that — which is the same reason its sibling
|
|
6
|
+
// is testable.
|
|
7
|
+
//
|
|
8
|
+
// The AWS calls themselves are not covered here; what is covered is everything
|
|
9
|
+
// that can be wrong *before* one is made. The credential this script hands out is
|
|
10
|
+
// no longer its own — the generator moved to `spec` so the manifest's
|
|
11
|
+
// platform-neutral half could reach it, and `Util_PasswordTest` holds the policy
|
|
12
|
+
// it has to satisfy.
|
|
13
|
+
|
|
14
|
+
module Provision = ProvisionAdmin
|
|
15
|
+
|
|
16
|
+
describe("ProvisionAdmin.parseArgs", () => {
|
|
17
|
+
testSync("both arguments parse", () =>
|
|
18
|
+
expect(
|
|
19
|
+
Provision.parseArgs(["--provider-id", "eu-west-1_AbCdEfGhI", "--email", "me@example.com"]),
|
|
20
|
+
)->toEqual(
|
|
21
|
+
Ok({
|
|
22
|
+
Provision.providerId: Some("eu-west-1_AbCdEfGhI"),
|
|
23
|
+
stack: None,
|
|
24
|
+
email: Some("me@example.com"),
|
|
25
|
+
help: false,
|
|
26
|
+
}),
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
testSync("no arguments leaves both unanswered", () =>
|
|
31
|
+
expect(Provision.parseArgs([]))->toEqual(
|
|
32
|
+
Ok({Provision.providerId: None, stack: None, email: None, help: false}),
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
// The reason unknown flags refuse rather than being skipped: a typo'd
|
|
37
|
+
// `--provider-id` would fall through to the environment and make the
|
|
38
|
+
// administrator in a different pool than the operator named — a successful run
|
|
39
|
+
// against the wrong deployment, which is worse than a failed one.
|
|
40
|
+
testSync("an unknown flag refuses rather than being ignored", () =>
|
|
41
|
+
expect(Provision.parseArgs(["--provider", "eu-west-1_AbCdEfGhI"]))->toEqual(
|
|
42
|
+
Error(`unknown argument "--provider"`),
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
testSync("a flag with no value refuses", () =>
|
|
47
|
+
expect(Provision.parseArgs(["--email"]))->toEqual(Error("--email needs a value"))
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
testSync("-h asks for the usage", () =>
|
|
51
|
+
expect(Provision.parseArgs(["-h"]))->toEqual(
|
|
52
|
+
Ok({Provision.providerId: None, stack: None, email: None, help: true}),
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
})
|