@reventlessdev/reventless-aws 3.0.0-alpha.319 → 3.0.0-alpha.321
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/package.json +12 -8
- 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/QueryDb/QueryDbResolvers_AppSync.res +34 -18
- package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res.mjs +14 -4
- package/src/adapter/QueryDb/QueryDbStorage_DynamoDb.res +29 -14
- package/src/adapter/QueryDb/QueryDbStorage_DynamoDb.res.mjs +11 -1
- 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/AppSync_RetirementNarrowingTest.res +10 -12
- package/tests/AppSync_RetirementNarrowingTest.res.mjs +4 -2
- 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/ProvisionIdentityTest.res +109 -0
- package/tests/ProvisionIdentityTest.res.mjs +132 -0
- package/tests/QueryDbOwnerIndexTableTest.res +53 -0
- package/tests/QueryDbOwnerIndexTableTest.res.mjs +50 -0
- package/tests/QueryDbResolvers_AppSyncTest.res +19 -26
- package/tests/QueryDbResolvers_AppSyncTest.res.mjs +15 -17
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
open JestGlobals
|
|
2
|
+
|
|
3
|
+
// Argument handling for the operator script. Testable at all only because the
|
|
4
|
+
// module no longer calls `main` at the top level — `run-provision-identity.mjs`
|
|
5
|
+
// does. While it ran itself on import, nothing in here could be reached, and
|
|
6
|
+
// that is how a guard that could never match shipped in the same file.
|
|
7
|
+
//
|
|
8
|
+
// What these cover is the one thing this script does that is hard to undo:
|
|
9
|
+
// deciding whether to *adopt* a user pool or *create* one.
|
|
10
|
+
|
|
11
|
+
module Provision = ProvisionIdentity
|
|
12
|
+
|
|
13
|
+
describe("ProvisionIdentity.parseArgs", () => {
|
|
14
|
+
testSync("no arguments means adopt-or-create the default name", () =>
|
|
15
|
+
expect(Provision.parseArgs([]))->toEqual(
|
|
16
|
+
Ok({
|
|
17
|
+
Provision.poolName: Provision.defaultPoolName,
|
|
18
|
+
providerId: None,
|
|
19
|
+
help: false,
|
|
20
|
+
}),
|
|
21
|
+
)
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
testSync("--provider-id names an existing pool", () =>
|
|
25
|
+
expect(Provision.parseArgs(["--provider-id", "eu-west-1_AbCdEfGhI"]))->toEqual(
|
|
26
|
+
Ok({
|
|
27
|
+
Provision.poolName: Provision.defaultPoolName,
|
|
28
|
+
providerId: Some("eu-west-1_AbCdEfGhI"),
|
|
29
|
+
help: false,
|
|
30
|
+
}),
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
testSync("--name selects which pool to adopt by name", () =>
|
|
35
|
+
expect(Provision.parseArgs(["--name", "examples-dev"]))->toEqual(
|
|
36
|
+
Ok({Provision.poolName: "examples-dev", providerId: None, help: false}),
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
testSync("both together are accepted, and the id wins at resolve time", () =>
|
|
41
|
+
expect(Provision.parseArgs(["--name", "ignored", "--provider-id", "eu-west-1_x"]))->toEqual(
|
|
42
|
+
Ok({Provision.poolName: "ignored", providerId: Some("eu-west-1_x"), help: false}),
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
// 🚨 The reason unknown flags are refused rather than skipped. A misspelled
|
|
47
|
+
// `--provider-id` that parsed as "absent" would silently take the *create* path
|
|
48
|
+
// and stand up a second user pool beside the one the operator meant to extend.
|
|
49
|
+
testSync("a misspelled flag is refused, not ignored", () =>
|
|
50
|
+
expect(Provision.parseArgs(["--provider_id", "eu-west-1_x"]))->toEqual(
|
|
51
|
+
Error(`unknown argument "--provider_id"`),
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
// Same hazard from the other direction: a flag whose value was left off must
|
|
56
|
+
// not read as "no id given".
|
|
57
|
+
testSync("a flag with no value is refused", () =>
|
|
58
|
+
expect(Provision.parseArgs(["--provider-id"]))->toEqual(
|
|
59
|
+
Error("--provider-id needs a value"),
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
testSync("--name with no value is refused too", () =>
|
|
64
|
+
expect(Provision.parseArgs(["--name"]))->toEqual(Error("--name needs a value"))
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
testSync("--help and -h both ask for the usage text", () =>
|
|
68
|
+
expect((
|
|
69
|
+
Provision.parseArgs(["--help"])->Result.map(a => a.help),
|
|
70
|
+
Provision.parseArgs(["-h"])->Result.map(a => a.help),
|
|
71
|
+
))->toEqual((Ok(true), Ok(true)))
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
// The first error stops the scan rather than the last one winning: an operator
|
|
75
|
+
// fixing what they are told about should not then meet a second surprise.
|
|
76
|
+
testSync("the first refusal is the one reported", () =>
|
|
77
|
+
expect(Provision.parseArgs(["--nope", "--also-nope"]))->toEqual(
|
|
78
|
+
Error(`unknown argument "--nope"`),
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
// The pool this script creates when it creates one. Asserted because a pool is
|
|
84
|
+
// the one thing here that outlives every stack — nothing in a `pulumi destroy`
|
|
85
|
+
// touches it — so its settings are not a detail an operator can revise cheaply
|
|
86
|
+
// once accounts exist in it.
|
|
87
|
+
describe("ProvisionIdentity.poolSettings", () => {
|
|
88
|
+
let settings = Provision.poolSettings(~poolName="MyIdentity")
|
|
89
|
+
|
|
90
|
+
testSync("carries the name it was asked for", () =>
|
|
91
|
+
expect(settings.poolName)->toBe("MyIdentity")
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
testSync("matches what auto mode declares: email sign-in, no MFA, admin-only", () =>
|
|
95
|
+
expect((
|
|
96
|
+
settings.usernameAttributes,
|
|
97
|
+
settings.mfaConfiguration,
|
|
98
|
+
settings.adminCreateUserConfig->Option.flatMap(c => c.allowAdminCreateUserOnly),
|
|
99
|
+
))->toEqual((Some(["email"]), Some("OFF"), Some(true)))
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
testSync("keeps the 12-character password policy", () =>
|
|
103
|
+
expect(
|
|
104
|
+
settings.policies
|
|
105
|
+
->Option.flatMap(p => p.passwordPolicy)
|
|
106
|
+
->Option.flatMap(p => p.minimumLength),
|
|
107
|
+
)->toEqual(Some(12))
|
|
108
|
+
)
|
|
109
|
+
})
|
|
@@ -0,0 +1,132 @@
|
|
|
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 Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
|
|
5
|
+
import * as ProvisionIdentity$ReventlessAws from "../scripts/ProvisionIdentity.res.mjs";
|
|
6
|
+
|
|
7
|
+
globalThis.describe("ProvisionIdentity.parseArgs", () => {
|
|
8
|
+
globalThis.test("no arguments means adopt-or-create the default name", () => {
|
|
9
|
+
globalThis.expect(ProvisionIdentity$ReventlessAws.parseArgs([])).toEqual({
|
|
10
|
+
TAG: "Ok",
|
|
11
|
+
_0: {
|
|
12
|
+
poolName: ProvisionIdentity$ReventlessAws.defaultPoolName,
|
|
13
|
+
providerId: undefined,
|
|
14
|
+
help: false
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
globalThis.test("--provider-id names an existing pool", () => {
|
|
19
|
+
globalThis.expect(ProvisionIdentity$ReventlessAws.parseArgs([
|
|
20
|
+
"--provider-id",
|
|
21
|
+
"eu-west-1_AbCdEfGhI"
|
|
22
|
+
])).toEqual({
|
|
23
|
+
TAG: "Ok",
|
|
24
|
+
_0: {
|
|
25
|
+
poolName: ProvisionIdentity$ReventlessAws.defaultPoolName,
|
|
26
|
+
providerId: "eu-west-1_AbCdEfGhI",
|
|
27
|
+
help: false
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
globalThis.test("--name selects which pool to adopt by name", () => {
|
|
32
|
+
globalThis.expect(ProvisionIdentity$ReventlessAws.parseArgs([
|
|
33
|
+
"--name",
|
|
34
|
+
"examples-dev"
|
|
35
|
+
])).toEqual({
|
|
36
|
+
TAG: "Ok",
|
|
37
|
+
_0: {
|
|
38
|
+
poolName: "examples-dev",
|
|
39
|
+
providerId: undefined,
|
|
40
|
+
help: false
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
globalThis.test("both together are accepted, and the id wins at resolve time", () => {
|
|
45
|
+
globalThis.expect(ProvisionIdentity$ReventlessAws.parseArgs([
|
|
46
|
+
"--name",
|
|
47
|
+
"ignored",
|
|
48
|
+
"--provider-id",
|
|
49
|
+
"eu-west-1_x"
|
|
50
|
+
])).toEqual({
|
|
51
|
+
TAG: "Ok",
|
|
52
|
+
_0: {
|
|
53
|
+
poolName: "ignored",
|
|
54
|
+
providerId: "eu-west-1_x",
|
|
55
|
+
help: false
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
globalThis.test("a misspelled flag is refused, not ignored", () => {
|
|
60
|
+
globalThis.expect(ProvisionIdentity$ReventlessAws.parseArgs([
|
|
61
|
+
"--provider_id",
|
|
62
|
+
"eu-west-1_x"
|
|
63
|
+
])).toEqual({
|
|
64
|
+
TAG: "Error",
|
|
65
|
+
_0: `unknown argument "--provider_id"`
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
globalThis.test("a flag with no value is refused", () => {
|
|
69
|
+
globalThis.expect(ProvisionIdentity$ReventlessAws.parseArgs(["--provider-id"])).toEqual({
|
|
70
|
+
TAG: "Error",
|
|
71
|
+
_0: "--provider-id needs a value"
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
globalThis.test("--name with no value is refused too", () => {
|
|
75
|
+
globalThis.expect(ProvisionIdentity$ReventlessAws.parseArgs(["--name"])).toEqual({
|
|
76
|
+
TAG: "Error",
|
|
77
|
+
_0: "--name needs a value"
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
globalThis.test("--help and -h both ask for the usage text", () => {
|
|
81
|
+
globalThis.expect([
|
|
82
|
+
Stdlib_Result.map(ProvisionIdentity$ReventlessAws.parseArgs(["--help"]), a => a.help),
|
|
83
|
+
Stdlib_Result.map(ProvisionIdentity$ReventlessAws.parseArgs(["-h"]), a => a.help)
|
|
84
|
+
]).toEqual([
|
|
85
|
+
{
|
|
86
|
+
TAG: "Ok",
|
|
87
|
+
_0: true
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
TAG: "Ok",
|
|
91
|
+
_0: true
|
|
92
|
+
}
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
95
|
+
globalThis.test("the first refusal is the one reported", () => {
|
|
96
|
+
globalThis.expect(ProvisionIdentity$ReventlessAws.parseArgs([
|
|
97
|
+
"--nope",
|
|
98
|
+
"--also-nope"
|
|
99
|
+
])).toEqual({
|
|
100
|
+
TAG: "Error",
|
|
101
|
+
_0: `unknown argument "--nope"`
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
globalThis.describe("ProvisionIdentity.poolSettings", () => {
|
|
107
|
+
let settings = ProvisionIdentity$ReventlessAws.poolSettings("MyIdentity");
|
|
108
|
+
globalThis.test("carries the name it was asked for", () => {
|
|
109
|
+
globalThis.expect(settings.PoolName).toBe("MyIdentity");
|
|
110
|
+
});
|
|
111
|
+
globalThis.test("matches what auto mode declares: email sign-in, no MFA, admin-only", () => {
|
|
112
|
+
globalThis.expect([
|
|
113
|
+
settings.UsernameAttributes,
|
|
114
|
+
settings.MfaConfiguration,
|
|
115
|
+
Stdlib_Option.flatMap(settings.AdminCreateUserConfig, c => c.AllowAdminCreateUserOnly)
|
|
116
|
+
]).toEqual([
|
|
117
|
+
["email"],
|
|
118
|
+
"OFF",
|
|
119
|
+
true
|
|
120
|
+
]);
|
|
121
|
+
});
|
|
122
|
+
globalThis.test("keeps the 12-character password policy", () => {
|
|
123
|
+
globalThis.expect(Stdlib_Option.flatMap(Stdlib_Option.flatMap(settings.Policies, p => p.PasswordPolicy), p => p.MinimumLength)).toEqual(12);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
let Provision;
|
|
128
|
+
|
|
129
|
+
export {
|
|
130
|
+
Provision,
|
|
131
|
+
}
|
|
132
|
+
/* Not a pure module */
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The table half of the index `@owner` derives. Both assertions are about the
|
|
2
|
+
// DynamoDB resource the deploy would submit, which is the only artifact that
|
|
3
|
+
// exists before one.
|
|
4
|
+
|
|
5
|
+
open JestGlobals
|
|
6
|
+
|
|
7
|
+
// `Pulumi.Input.make` is `%identity`, so the provider-facing shape can be read
|
|
8
|
+
// back as the ReScript value it wraps.
|
|
9
|
+
external unwrap: Pulumi.Input.t<'a> => 'a = "%identity"
|
|
10
|
+
|
|
11
|
+
let ownerIndex: Reventless.ReadModel.indexConfig = {
|
|
12
|
+
index: "_owner",
|
|
13
|
+
type_: "S",
|
|
14
|
+
idField: "customerId",
|
|
15
|
+
subIdField: "id",
|
|
16
|
+
projectionType: ALL,
|
|
17
|
+
derived: true,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("the derived owner index", () => {
|
|
21
|
+
// Not KEYS_ONLY and not INCLUDE: the list door pushes the caller's filter, the
|
|
22
|
+
// retirement predicate and `requireAttribute` down as FilterExpressions over
|
|
23
|
+
// arbitrary columns, and a DynamoDB filter on a GSI may only name projected
|
|
24
|
+
// attributes. A row read through a narrow projection also comes back missing
|
|
25
|
+
// fields, which resolves a non-null SDL field to null.
|
|
26
|
+
testSync("is provisioned projecting every attribute", () => {
|
|
27
|
+
let gsis = QueryDbStorage_DynamoDb.globalSecondaryIndexes([ownerIndex])->unwrap
|
|
28
|
+
expect(
|
|
29
|
+
gsis->Array.map(g => {
|
|
30
|
+
let g = g->unwrap
|
|
31
|
+
(g.name, g.hashKey, g.rangeKey, g.projectionType)
|
|
32
|
+
}),
|
|
33
|
+
)->toEqual([("_owner", "customerId", Some("id"), PulumiAws.DynamoDb.Table.ALL)])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// Pulumi rejects a table that defines the same attribute twice, and the derived
|
|
37
|
+
// index sorts on `id` — the table's own partition key — on any view with no
|
|
38
|
+
// `@subId`. Undeduped, adding `@owner` to such a view fails the deploy outright.
|
|
39
|
+
testSync("does not redeclare an attribute the table already has", () => {
|
|
40
|
+
let names = QueryDbStorage_DynamoDb.attributes(None, [ownerIndex])->Array.map(a => a.name)
|
|
41
|
+
expect(names)->toEqual(["id", "customerId"])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// The same collision one step out: an index that sorts on the table's sort key.
|
|
45
|
+
testSync("dedupes against the table's own sort key too", () => {
|
|
46
|
+
let names =
|
|
47
|
+
QueryDbStorage_DynamoDb.attributes(
|
|
48
|
+
Some("placedAt"),
|
|
49
|
+
[{...ownerIndex, subIdField: "placedAt"}],
|
|
50
|
+
)->Array.map(a => a.name)
|
|
51
|
+
expect(names)->toEqual(["id", "placedAt", "customerId"])
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as QueryDbStorage_DynamoDb$ReventlessAws from "../src/adapter/QueryDb/QueryDbStorage_DynamoDb.res.mjs";
|
|
4
|
+
|
|
5
|
+
let ownerIndex = {
|
|
6
|
+
index: "_owner",
|
|
7
|
+
type_: "S",
|
|
8
|
+
idField: "customerId",
|
|
9
|
+
subIdField: "id",
|
|
10
|
+
projectionType: "ALL",
|
|
11
|
+
derived: true
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
globalThis.describe("the derived owner index", () => {
|
|
15
|
+
globalThis.test("is provisioned projecting every attribute", () => {
|
|
16
|
+
let gsis = QueryDbStorage_DynamoDb$ReventlessAws.globalSecondaryIndexes([ownerIndex]);
|
|
17
|
+
globalThis.expect(gsis.map(g => [
|
|
18
|
+
g.name,
|
|
19
|
+
g.hashKey,
|
|
20
|
+
g.rangeKey,
|
|
21
|
+
g.projectionType
|
|
22
|
+
])).toEqual([[
|
|
23
|
+
"_owner",
|
|
24
|
+
"customerId",
|
|
25
|
+
"id",
|
|
26
|
+
"ALL"
|
|
27
|
+
]]);
|
|
28
|
+
});
|
|
29
|
+
globalThis.test("does not redeclare an attribute the table already has", () => {
|
|
30
|
+
let names = QueryDbStorage_DynamoDb$ReventlessAws.attributes(undefined, [ownerIndex]).map(a => a.name);
|
|
31
|
+
globalThis.expect(names).toEqual([
|
|
32
|
+
"id",
|
|
33
|
+
"customerId"
|
|
34
|
+
]);
|
|
35
|
+
});
|
|
36
|
+
globalThis.test("dedupes against the table's own sort key too", () => {
|
|
37
|
+
let newrecord = {...ownerIndex};
|
|
38
|
+
let names = QueryDbStorage_DynamoDb$ReventlessAws.attributes("placedAt", [(newrecord.subIdField = "placedAt", newrecord)]).map(a => a.name);
|
|
39
|
+
globalThis.expect(names).toEqual([
|
|
40
|
+
"id",
|
|
41
|
+
"placedAt",
|
|
42
|
+
"customerId"
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export {
|
|
48
|
+
ownerIndex,
|
|
49
|
+
}
|
|
50
|
+
/* Not a pure module */
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
open JestGlobals
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// The auto-generated Connection Scan must exclude them, or `name: String!` resolves
|
|
7
|
-
// to null and nulls the entire Platform_PluginConnection ("No data" on the page).
|
|
3
|
+
// The Plugins admin RM shares its table with bookkeeping rows (`deploy-schema:*`,
|
|
4
|
+
// `plugin-info:*`, `deploy-schema-hash:*`) carrying no `name`. The Connection Scan
|
|
5
|
+
// must exclude them, or `name: String!` nulls the whole connection.
|
|
8
6
|
|
|
9
7
|
// `Pulumi.Input.make` is `%identity`, so a generated resolver's `Pulumi.Input.t<string>`
|
|
10
8
|
// IS the underlying JS string — recover it with Obj.magic for assertion.
|
|
@@ -39,43 +37,38 @@ describe("AppSync_Resolver_Retrying.Functions.listAllItemsConnection", () => {
|
|
|
39
37
|
})
|
|
40
38
|
})
|
|
41
39
|
|
|
42
|
-
// Tripwires for
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
describe("listAllItemsConnection —
|
|
40
|
+
// Tripwires for the Scan connection's paging. The behaviour itself is exercised
|
|
41
|
+
// against the evaluated resolver in `rescript/pulumi-aws`; these guard the wiring
|
|
42
|
+
// this package is responsible for emitting.
|
|
43
|
+
describe("listAllItemsConnection — paging", () => {
|
|
46
44
|
let code = AppSync_Resolver_Retrying.Functions.listAllItemsConnection(~labelField="name")->codeOf
|
|
47
45
|
|
|
48
|
-
testSync("
|
|
49
|
-
expect(code->String.includes("const next = ctx.result?.nextToken ?? null;"))->toBe(true)
|
|
50
|
-
expect(
|
|
51
|
-
code->String.includes("util.base64Encode(JSON.stringify({ token: next, index: i }))"),
|
|
52
|
-
)->toBe(true)
|
|
53
|
-
expect(code->String.includes("hasNextPage: !!next,"))->toBe(true)
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
testSync("Fix 1: request decodes `after` back to the DynamoDB token", () => {
|
|
46
|
+
testSync("the cursor round-trips a read window, not a synthetic index", () => {
|
|
57
47
|
expect(code->String.includes("JSON.parse(util.base64Decode(ctx.args.after))"))->toBe(true)
|
|
58
|
-
expect(code->String.includes("nextToken:
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
testSync("Fix 1: the old synthetic-index cursor is gone", () => {
|
|
48
|
+
expect(code->String.includes("nextToken: _window,"))->toBe(true)
|
|
62
49
|
// The regression: `cursor: ctx.args.after ? ctx.args.after + '_' + i : '' + i`.
|
|
63
50
|
expect(code->String.includes("ctx.args.after + '_' + i"))->toBe(false)
|
|
64
51
|
})
|
|
65
52
|
|
|
66
|
-
testSync("
|
|
53
|
+
testSync("a filtered read examines more rows than it serves", () => {
|
|
54
|
+
expect(code->String.includes("parts.length > 0 ? (_first > 1000 ? _first : 1000)"))->toBe(true)
|
|
55
|
+
expect(code->String.includes("const _page = _rest.slice(0, _first);"))->toBe(true)
|
|
56
|
+
expect(code->String.includes("hasNextPage: _more || !!_next,"))->toBe(true)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
testSync("backward paging (last/before) is rejected, not silently mishandled", () => {
|
|
67
60
|
expect(code->String.includes("ctx.args.before != null || ctx.args.last != null"))->toBe(true)
|
|
68
61
|
expect(code->String.includes("UnsupportedPagination"))->toBe(true)
|
|
69
62
|
})
|
|
70
63
|
|
|
71
|
-
testSync("
|
|
64
|
+
testSync("a window emptied by the filter still yields a resumable boundary cursor", () => {
|
|
72
65
|
expect(
|
|
73
66
|
code->String.includes(
|
|
74
|
-
"const
|
|
67
|
+
"const _boundary = _next ? util.base64Encode(JSON.stringify({ t: _next, n: -1 })) : null;",
|
|
75
68
|
),
|
|
76
69
|
)->toBe(true)
|
|
77
70
|
expect(
|
|
78
|
-
code->String.includes("edges.length > 0 ? edges[edges.length - 1].cursor :
|
|
71
|
+
code->String.includes("edges.length > 0 ? edges[edges.length - 1].cursor : _boundary"),
|
|
79
72
|
)->toBe(true)
|
|
80
73
|
})
|
|
81
74
|
})
|
|
@@ -19,37 +19,35 @@ globalThis.describe("QueryDbResolvers_AppSync.internalRowRequiredAttr", () => {
|
|
|
19
19
|
|
|
20
20
|
globalThis.describe("AppSync_Resolver_Retrying.Functions.listAllItemsConnection", () => {
|
|
21
21
|
globalThis.test("with ~requireAttribute emits an attribute_exists filter that excludes internal rows", () => {
|
|
22
|
-
let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, "name", undefined, undefined, undefined, undefined);
|
|
22
|
+
let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, "name", undefined, undefined, undefined, undefined, undefined, undefined);
|
|
23
23
|
globalThis.expect(code.includes("attribute_exists(#name)")).toBe(true);
|
|
24
24
|
globalThis.expect(code.includes("names['#name'] = 'name'")).toBe(true);
|
|
25
25
|
});
|
|
26
26
|
globalThis.test("without ~requireAttribute emits no attribute_exists filter (default read models unchanged)", () => {
|
|
27
|
-
let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined);
|
|
27
|
+
let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined);
|
|
28
28
|
globalThis.expect(code.includes("attribute_exists")).toBe(false);
|
|
29
29
|
});
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
-
globalThis.describe("listAllItemsConnection —
|
|
33
|
-
let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined);
|
|
34
|
-
globalThis.test("
|
|
35
|
-
globalThis.expect(code.includes("const next = ctx.result?.nextToken ?? null;")).toBe(true);
|
|
36
|
-
globalThis.expect(code.includes("util.base64Encode(JSON.stringify({ token: next, index: i }))")).toBe(true);
|
|
37
|
-
globalThis.expect(code.includes("hasNextPage: !!next,")).toBe(true);
|
|
38
|
-
});
|
|
39
|
-
globalThis.test("Fix 1: request decodes `after` back to the DynamoDB token", () => {
|
|
32
|
+
globalThis.describe("listAllItemsConnection — paging", () => {
|
|
33
|
+
let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined);
|
|
34
|
+
globalThis.test("the cursor round-trips a read window, not a synthetic index", () => {
|
|
40
35
|
globalThis.expect(code.includes("JSON.parse(util.base64Decode(ctx.args.after))")).toBe(true);
|
|
41
|
-
globalThis.expect(code.includes("nextToken:
|
|
42
|
-
});
|
|
43
|
-
globalThis.test("Fix 1: the old synthetic-index cursor is gone", () => {
|
|
36
|
+
globalThis.expect(code.includes("nextToken: _window,")).toBe(true);
|
|
44
37
|
globalThis.expect(code.includes("ctx.args.after + '_' + i")).toBe(false);
|
|
45
38
|
});
|
|
46
|
-
globalThis.test("
|
|
39
|
+
globalThis.test("a filtered read examines more rows than it serves", () => {
|
|
40
|
+
globalThis.expect(code.includes("parts.length > 0 ? (_first > 1000 ? _first : 1000)")).toBe(true);
|
|
41
|
+
globalThis.expect(code.includes("const _page = _rest.slice(0, _first);")).toBe(true);
|
|
42
|
+
globalThis.expect(code.includes("hasNextPage: _more || !!_next,")).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
globalThis.test("backward paging (last/before) is rejected, not silently mishandled", () => {
|
|
47
45
|
globalThis.expect(code.includes("ctx.args.before != null || ctx.args.last != null")).toBe(true);
|
|
48
46
|
globalThis.expect(code.includes("UnsupportedPagination")).toBe(true);
|
|
49
47
|
});
|
|
50
|
-
globalThis.test("
|
|
51
|
-
globalThis.expect(code.includes("const
|
|
52
|
-
globalThis.expect(code.includes("edges.length > 0 ? edges[edges.length - 1].cursor :
|
|
48
|
+
globalThis.test("a window emptied by the filter still yields a resumable boundary cursor", () => {
|
|
49
|
+
globalThis.expect(code.includes("const _boundary = _next ? util.base64Encode(JSON.stringify({ t: _next, n: -1 })) : null;")).toBe(true);
|
|
50
|
+
globalThis.expect(code.includes("edges.length > 0 ? edges[edges.length - 1].cursor : _boundary")).toBe(true);
|
|
53
51
|
});
|
|
54
52
|
});
|
|
55
53
|
|