@reventlessdev/reventless-aws 3.0.0-alpha.292 → 3.0.0-alpha.293

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.
@@ -0,0 +1,201 @@
1
+ // Runtime handler for the Cognito **pre token generation** trigger — the AWS
2
+ // minting point for "acting as one of the roles you hold". See
3
+ // [docs/plans/active-role-narrows-the-token.md] §6.
4
+ //
5
+ // Compiled, type-checked and Pulumi-free so it ships as an EntryPoint module
6
+ // ([Auth_ActiveRoleTrigger.res] bundles it and attaches it to the user pool).
7
+ //
8
+ // The narrowing has to happen here rather than in our own enforcement code
9
+ // because `@authorize(AllowGroups([...]))` compiles to `@aws_auth`, which AppSync
10
+ // evaluates against `cognito:groups` **before any of our code executes**. A
11
+ // request header we invent could scope reads correctly and still leave every
12
+ // group-gated mutation callable — right about the data, wrong about the writes.
13
+ // Overriding the group claim is the one place every enforcement point already
14
+ // looks.
15
+ //
16
+ // **Version 1 of the trigger, deliberately.** Group override and added ID-token
17
+ // claims are `V1_0` capabilities; `V2_0`/`V3_0` buy access-token customisation
18
+ // this does not need and are gated behind the Essentials and Plus feature plans.
19
+ // A deployment on the Lite tier must not be excluded from acting as a role.
20
+
21
+ open AwsSdk
22
+
23
+ // ── Environment ─────────────────────────────────────────────────────────────
24
+
25
+ let getEnv = (k: string): option<string> =>
26
+ switch NodeProcess.env->Dict.get(k) {
27
+ | Some("") | None => None
28
+ | Some(v) => Some(v)
29
+ }
30
+
31
+ let tableName = (): string =>
32
+ switch getEnv("ACTIVE_ROLE_TABLE") {
33
+ | Some(t) => t
34
+ | None => JsError.throwWithMessage("ACTIVE_ROLE_TABLE is not configured")
35
+ }
36
+
37
+ // ── Cognito pre-token-generation event (V1_0) ───────────────────────────────
38
+
39
+ type userAttributes = {sub?: string}
40
+
41
+ /** The groups the pool says the user is really in, supplied by the pool itself.
42
+ This is why the subset check here needs no lookup and no IAM to perform one:
43
+ the authority arrives in the payload, and the check is a containment test
44
+ against it. */
45
+ type groupConfiguration = {
46
+ groupsToOverride?: array<string>,
47
+ iamRolesToOverride?: array<string>,
48
+ preferredRole?: Nullable.t<string>,
49
+ }
50
+
51
+ type triggerRequest = {
52
+ userAttributes?: userAttributes,
53
+ groupConfiguration?: groupConfiguration,
54
+ }
55
+
56
+ type groupOverrideDetails = {
57
+ groupsToOverride: array<string>,
58
+ iamRolesToOverride: array<string>,
59
+ preferredRole?: Nullable.t<string>,
60
+ }
61
+
62
+ type claimsOverrideDetails = {
63
+ claimsToAddOrOverride?: dict<string>,
64
+ groupOverrideDetails?: groupOverrideDetails,
65
+ }
66
+
67
+ type triggerResponse = {claimsOverrideDetails?: claimsOverrideDetails}
68
+
69
+ type event = {
70
+ request?: triggerRequest,
71
+ response?: triggerResponse,
72
+ userName?: string,
73
+ }
74
+
75
+ // ── The decision ────────────────────────────────────────────────────────────
76
+
77
+ type decision =
78
+ /** No stored preference — mint exactly the token that would have been minted
79
+ before any of this existed. This is the path every existing caller takes. */
80
+ | Unchanged
81
+ /** The stored role is one the pool says the caller holds. */
82
+ | Narrow({role: string, membership: array<string>})
83
+ /** The row outlived the membership that justified it.
84
+
85
+ Resolved by minting the full set rather than narrowing to a group the pool no
86
+ longer grants — but *decided* here rather than falling out of the code,
87
+ because the alternative reading is a token scoped to a role the caller has
88
+ lost. Unlike the write door, this path has no client to refuse: the trigger
89
+ meets a stale row on an ordinary refresh with nobody asking for anything. It
90
+ says so on the token instead, so a caller whose chosen role silently stopped
91
+ applying has something to read. */
92
+ | Stale({role: string, membership: array<string>})
93
+
94
+ let decide = (~membership: array<string>, ~storedRole: option<string>): decision =>
95
+ switch storedRole {
96
+ | None | Some("") => Unchanged
97
+ | Some(role) =>
98
+ membership->Array.includes(role) ? Narrow({role, membership}) : Stale({role, membership})
99
+ }
100
+
101
+ /**
102
+ Turn a decision into the trigger's response.
103
+
104
+ 🚨 **`groupOverrideDetails` replaces the whole group configuration.** Supplying it
105
+ with only `groupsToOverride` drops the caller's `iamRolesToOverride` and
106
+ `preferredRole` — the same reset-by-omission hazard `UpdateUserPool` has, and just
107
+ as quiet. Both are echoed from the incoming configuration so the override changes
108
+ exactly one thing.
109
+
110
+ `Unchanged` returns the event untouched rather than an empty override: an empty
111
+ `claimsOverrideDetails` is not the same as none, and the regression line for this
112
+ whole feature is that a caller with no stored role gets byte-identical output.
113
+ */
114
+ let respond = (~event: event, ~decision: decision): event => {
115
+ let incoming = event.request->Option.flatMap(r => r.groupConfiguration)
116
+ let iamRolesToOverride = incoming->Option.flatMap(g => g.iamRolesToOverride)->Option.getOr([])
117
+ let preferredRole = incoming->Option.flatMap(g => g.preferredRole)
118
+
119
+ let overrideWith = (~groups: array<string>, ~claims: dict<string>) => {
120
+ ...event,
121
+ response: {
122
+ claimsOverrideDetails: {
123
+ claimsToAddOrOverride: claims,
124
+ groupOverrideDetails: {
125
+ groupsToOverride: groups,
126
+ iamRolesToOverride,
127
+ preferredRole: ?preferredRole,
128
+ },
129
+ },
130
+ },
131
+ }
132
+
133
+ switch decision {
134
+ | Unchanged => event
135
+ | Narrow({role, membership}) =>
136
+ overrideWith(
137
+ ~groups=[role],
138
+ ~claims=Dict.fromArray([
139
+ (ReventlessCore.Auth_ActiveRole.activeRoleClaim, role),
140
+ (ReventlessCore.Auth_ActiveRole.availableRolesClaim, membership->Array.join(",")),
141
+ ]),
142
+ )
143
+ | Stale({role, membership}) =>
144
+ // The full set, as the pool granted it — plus the marker that explains why the
145
+ // stored choice did not apply.
146
+ overrideWith(
147
+ ~groups=membership,
148
+ ~claims=Dict.fromArray([
149
+ (ReventlessCore.Auth_ActiveRole.staleRoleClaim, role),
150
+ (ReventlessCore.Auth_ActiveRole.availableRolesClaim, membership->Array.join(",")),
151
+ ]),
152
+ )
153
+ }
154
+ }
155
+
156
+ // ── Stored preference ───────────────────────────────────────────────────────
157
+
158
+ /**
159
+ The role this subject last chose, or `None` if they never chose one or cleared it.
160
+
161
+ A read failure resolves to `None`, not to an error. This trigger sits in the
162
+ critical path of every token Cognito mints for this pool: a throw here fails the
163
+ sign-in outright, and failing a login because a *preference* could not be read
164
+ trades a working session for a cosmetic one. The caller lands on full membership —
165
+ their existing privileges, not more — which is the safe direction to fail.
166
+ */
167
+ let storedRoleFor = async (~sub: string, ~table: string): option<string> =>
168
+ try {
169
+ let out = await DynamoDb_DocumentClient.GetCommand.make({
170
+ tableName: table,
171
+ key: Dict.fromArray([("id", JSON.Encode.string(sub))]),
172
+ })->DynamoDb_DocumentClient.GetCommand.send
173
+ out.item
174
+ ->Option.flatMap(JSON.Decode.object)
175
+ ->Option.flatMap(o => o->Dict.get("activeRole"))
176
+ ->Option.flatMap(JSON.Decode.string)
177
+ } catch {
178
+ | _ => None
179
+ }
180
+
181
+ // ── Handler ─────────────────────────────────────────────────────────────────
182
+
183
+ let handler = async (event: event): event => {
184
+ let membership =
185
+ event.request
186
+ ->Option.flatMap(r => r.groupConfiguration)
187
+ ->Option.flatMap(g => g.groupsToOverride)
188
+ ->Option.getOr([])
189
+
190
+ let sub =
191
+ event.request->Option.flatMap(r => r.userAttributes)->Option.flatMap(u => u.sub)->Option.getOr("")
192
+
193
+ // No subject means no row to look up. Returning the event untouched keeps the
194
+ // sign-in working on exactly the membership the pool granted.
195
+ if sub == "" {
196
+ event
197
+ } else {
198
+ let storedRole = await storedRoleFor(~sub, ~table=tableName())
199
+ respond(~event, ~decision=decide(~membership, ~storedRole))
200
+ }
201
+ }
@@ -0,0 +1,126 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
6
+ import * as LibDynamodb from "@aws-sdk/lib-dynamodb";
7
+ import * as Auth_ActiveRole$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Auth/Auth_ActiveRole.res.mjs";
8
+ import * as DynamoDb_DocumentClient$AwsSdk from "@reventlessdev/rescript-aws-sdk/src/DynamoDb_DocumentClient.res.mjs";
9
+
10
+ function getEnv(k) {
11
+ let v = process.env[k];
12
+ if (v !== undefined && v !== "") {
13
+ return v;
14
+ }
15
+ }
16
+
17
+ function tableName() {
18
+ let t = getEnv("ACTIVE_ROLE_TABLE");
19
+ if (t !== undefined) {
20
+ return t;
21
+ } else {
22
+ return Stdlib_JsError.throwWithMessage("ACTIVE_ROLE_TABLE is not configured");
23
+ }
24
+ }
25
+
26
+ function decide(membership, storedRole) {
27
+ if (storedRole !== undefined && storedRole !== "") {
28
+ if (membership.includes(storedRole)) {
29
+ return {
30
+ TAG: "Narrow",
31
+ role: storedRole,
32
+ membership: membership
33
+ };
34
+ } else {
35
+ return {
36
+ TAG: "Stale",
37
+ role: storedRole,
38
+ membership: membership
39
+ };
40
+ }
41
+ } else {
42
+ return "Unchanged";
43
+ }
44
+ }
45
+
46
+ function respond(event, decision) {
47
+ let incoming = Stdlib_Option.flatMap(event.request, r => r.groupConfiguration);
48
+ let iamRolesToOverride = Stdlib_Option.getOr(Stdlib_Option.flatMap(incoming, g => g.iamRolesToOverride), []);
49
+ let preferredRole = Stdlib_Option.flatMap(incoming, g => g.preferredRole);
50
+ let overrideWith = (groups, claims) => {
51
+ let newrecord = {...event};
52
+ newrecord.response = {
53
+ claimsOverrideDetails: {
54
+ claimsToAddOrOverride: claims,
55
+ groupOverrideDetails: {
56
+ groupsToOverride: groups,
57
+ iamRolesToOverride: iamRolesToOverride,
58
+ preferredRole: preferredRole
59
+ }
60
+ }
61
+ };
62
+ return newrecord;
63
+ };
64
+ if (typeof decision !== "object") {
65
+ return event;
66
+ }
67
+ if (decision.TAG === "Narrow") {
68
+ let role = decision.role;
69
+ return overrideWith([role], Object.fromEntries([
70
+ [
71
+ Auth_ActiveRole$ReventlessCore.activeRoleClaim,
72
+ role
73
+ ],
74
+ [
75
+ Auth_ActiveRole$ReventlessCore.availableRolesClaim,
76
+ decision.membership.join(",")
77
+ ]
78
+ ]));
79
+ }
80
+ let membership = decision.membership;
81
+ return overrideWith(membership, Object.fromEntries([
82
+ [
83
+ Auth_ActiveRole$ReventlessCore.staleRoleClaim,
84
+ decision.role
85
+ ],
86
+ [
87
+ Auth_ActiveRole$ReventlessCore.availableRolesClaim,
88
+ membership.join(",")
89
+ ]
90
+ ]));
91
+ }
92
+
93
+ async function storedRoleFor(sub, table) {
94
+ try {
95
+ let out = await DynamoDb_DocumentClient$AwsSdk.GetCommand.send(new LibDynamodb.GetCommand({
96
+ TableName: table,
97
+ Key: Object.fromEntries([[
98
+ "id",
99
+ sub
100
+ ]])
101
+ }));
102
+ return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(out.Item, Stdlib_JSON.Decode.object), o => o["activeRole"]), Stdlib_JSON.Decode.string);
103
+ } catch (exn) {
104
+ return;
105
+ }
106
+ }
107
+
108
+ async function handler(event) {
109
+ let membership = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(event.request, r => r.groupConfiguration), g => g.groupsToOverride), []);
110
+ let sub = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(event.request, r => r.userAttributes), u => u.sub), "");
111
+ if (sub === "") {
112
+ return event;
113
+ }
114
+ let storedRole = await storedRoleFor(sub, tableName());
115
+ return respond(event, decide(membership, storedRole));
116
+ }
117
+
118
+ export {
119
+ getEnv,
120
+ tableName,
121
+ decide,
122
+ respond,
123
+ storedRoleFor,
124
+ handler,
125
+ }
126
+ /* @aws-sdk/lib-dynamodb Not a pure module */
@@ -0,0 +1,211 @@
1
+ open JestGlobals
2
+
3
+ // 🚨 The test §6 of the plan asks for, and the one that matters most in this
4
+ // feature: `UpdateUserPool` requires "a value for all parameters that you don't
5
+ // want set to a default value". An attach that sends only `LambdaConfig`
6
+ // silently returns every other setting on the pool to its default — on a pool
7
+ // the framework did not create and whose configuration it never described.
8
+ //
9
+ // So the assertions below are all the same assertion: a pool carrying
10
+ // non-default settings still carries them afterwards. The merge is a pure
11
+ // function precisely so this is checkable against a pool the test did not
12
+ // create, which is the case the merge exists for.
13
+
14
+ module Attachment = Auth_ActiveRolePoolAttachment
15
+
16
+ let str = JSON.Encode.string
17
+
18
+ // A pool that is nothing like a fresh one: MFA on, a deletion guard, a custom
19
+ // verification message, two triggers already attached, tags, and a paid tier.
20
+ //
21
+ // The key set mirrors what `DescribeUserPool` actually returns for a live pool,
22
+ // including `IssuerConfiguration` / `KeyConfiguration` — informational fields
23
+ // that are *not* members of `UpdateUserPool`. The merge carries them and the SDK
24
+ // drops them during serialisation, which is why they are harmless; they are here
25
+ // so a future edit that starts filtering on this list has a real shape to filter.
26
+ let describedPool = () =>
27
+ Dict.fromArray([
28
+ ("Id", str("eu-west-1_abc123")),
29
+ ("Name", str("CustomerPool")),
30
+ ("Arn", str("arn:aws:cognito-idp:eu-west-1:1:userpool/eu-west-1_abc123")),
31
+ ("Status", str("Enabled")),
32
+ ("CreationDate", str("2020-01-01T00:00:00Z")),
33
+ ("LastModifiedDate", str("2024-01-01T00:00:00Z")),
34
+ ("SchemaAttributes", JSON.Encode.array([str("email")])),
35
+ ("UsernameAttributes", JSON.Encode.array([str("email")])),
36
+ ("EstimatedNumberOfUsers", JSON.Encode.int(4200)),
37
+ ("IssuerConfiguration", Dict.fromArray([("IssuerUri", str("https://x"))])->JSON.Encode.object),
38
+ ("KeyConfiguration", Dict.fromArray([("KmsKeyId", str("k-1"))])->JSON.Encode.object),
39
+ // A paid tier silently reverting to Lite would be a billing- and
40
+ // capability-level regression, and one nothing in the deploy would report.
41
+ ("UserPoolTier", str("PLUS")),
42
+ ("MfaConfiguration", str("ON")),
43
+ ("DeletionProtection", str("ACTIVE")),
44
+ ("EmailVerificationMessage", str("Your code is {####}, do not share it")),
45
+ (
46
+ "Policies",
47
+ Dict.fromArray([
48
+ (
49
+ "PasswordPolicy",
50
+ Dict.fromArray([("MinimumLength", JSON.Encode.int(24))])->JSON.Encode.object,
51
+ ),
52
+ ])->JSON.Encode.object,
53
+ ),
54
+ (
55
+ "UserPoolTags",
56
+ Dict.fromArray([("CostCentre", str("identity"))])->JSON.Encode.object,
57
+ ),
58
+ (
59
+ "LambdaConfig",
60
+ Dict.fromArray([
61
+ ("PreSignUp", str("arn:aws:lambda:eu-west-1:1:function:TheirPreSignUp")),
62
+ ("CustomMessage", str("arn:aws:lambda:eu-west-1:1:function:TheirCustomMessage")),
63
+ ])->JSON.Encode.object,
64
+ ),
65
+ ])
66
+
67
+ let merged = (~preTokenGenerationArn) =>
68
+ Attachment.mergedUpdateInput(
69
+ ~described=describedPool(),
70
+ ~userPoolId="eu-west-1_abc123",
71
+ ~preTokenGenerationArn,
72
+ )
73
+
74
+ let triggerArn = "arn:aws:lambda:eu-west-1:1:function:ActiveRoleTrigger"
75
+
76
+ let lambdaConfigOf = input =>
77
+ input->Dict.get("LambdaConfig")->Option.flatMap(JSON.Decode.object)
78
+
79
+ describe("Auth_ActiveRolePoolAttachment.mergedUpdateInput — settings survive the attach", () => {
80
+ let input = merged(~preTokenGenerationArn=Some(triggerArn))
81
+
82
+ testSync("MFA stays on rather than defaulting off", () =>
83
+ expect(input->Dict.get("MfaConfiguration"))->toEqual(Some(str("ON")))
84
+ )
85
+
86
+ testSync("deletion protection stays active", () =>
87
+ expect(input->Dict.get("DeletionProtection"))->toEqual(Some(str("ACTIVE")))
88
+ )
89
+
90
+ testSync("a customised verification message is not reset", () =>
91
+ expect(input->Dict.get("EmailVerificationMessage"))->toEqual(
92
+ Some(str("Your code is {####}, do not share it")),
93
+ )
94
+ )
95
+
96
+ testSync("the password policy travels whole", () =>
97
+ expect(
98
+ input
99
+ ->Dict.get("Policies")
100
+ ->Option.flatMap(JSON.Decode.object)
101
+ ->Option.flatMap(p => p->Dict.get("PasswordPolicy"))
102
+ ->Option.flatMap(JSON.Decode.object)
103
+ ->Option.flatMap(p => p->Dict.get("MinimumLength")),
104
+ )->toEqual(Some(JSON.Encode.int(24)))
105
+ )
106
+
107
+ testSync("tags are not dropped", () =>
108
+ expect(
109
+ input
110
+ ->Dict.get("UserPoolTags")
111
+ ->Option.flatMap(JSON.Decode.object)
112
+ ->Option.flatMap(t => t->Dict.get("CostCentre")),
113
+ )->toEqual(Some(str("identity")))
114
+ )
115
+
116
+ testSync("a paid tier is not reverted to Lite", () =>
117
+ expect(input->Dict.get("UserPoolTier"))->toEqual(Some(str("PLUS")))
118
+ )
119
+
120
+ // The reset hazard one level down: replacing the whole `LambdaConfig` with a
121
+ // single-key object would silently detach every trigger the customer had.
122
+ testSync("the pool's existing triggers stay attached", () =>
123
+ expect((
124
+ lambdaConfigOf(input)->Option.flatMap(c => c->Dict.get("PreSignUp")),
125
+ lambdaConfigOf(input)->Option.flatMap(c => c->Dict.get("CustomMessage")),
126
+ ))->toEqual((
127
+ Some(str("arn:aws:lambda:eu-west-1:1:function:TheirPreSignUp")),
128
+ Some(str("arn:aws:lambda:eu-west-1:1:function:TheirCustomMessage")),
129
+ ))
130
+ )
131
+
132
+ testSync("and ours is added beside them", () =>
133
+ expect(lambdaConfigOf(input)->Option.flatMap(c => c->Dict.get("PreTokenGeneration")))->toEqual(
134
+ Some(str(triggerArn)),
135
+ )
136
+ )
137
+ })
138
+
139
+ describe("Auth_ActiveRolePoolAttachment.mergedUpdateInput — shaping for the API", () => {
140
+ let input = merged(~preTokenGenerationArn=Some(triggerArn))
141
+
142
+ testSync("UserPoolId names the pool being updated", () =>
143
+ expect(input->Dict.get("UserPoolId"))->toEqual(Some(str("eu-west-1_abc123")))
144
+ )
145
+
146
+ // DescribeUserPool returns `Name`; UpdateUserPool takes `PoolName`. Sending
147
+ // `Name` is rejected, and omitting the rename would rename the pool by default.
148
+ testSync("Name is renamed to PoolName rather than dropped", () =>
149
+ expect((input->Dict.get("PoolName"), input->Dict.get("Name")))->toEqual((
150
+ Some(str("CustomerPool")),
151
+ None,
152
+ ))
153
+ )
154
+
155
+ testSync("read-only fields UpdateUserPool rejects are not sent", () =>
156
+ expect(
157
+ ["Id", "Arn", "Status", "CreationDate", "LastModifiedDate", "SchemaAttributes",
158
+ "UsernameAttributes", "EstimatedNumberOfUsers"]->Array.filter(k =>
159
+ input->Dict.get(k)->Option.isSome
160
+ ),
161
+ )->toEqual([])
162
+ )
163
+ })
164
+
165
+ describe("Auth_ActiveRolePoolAttachment.mergedUpdateInput — detaching", () => {
166
+ let input = merged(~preTokenGenerationArn=None)
167
+
168
+ // Destroy has to detach, or the pool is left pointing at a deleted function and
169
+ // every sign-in fails — on a pool nothing in this deployment would ever fix.
170
+ testSync("clearing removes our trigger", () =>
171
+ expect(lambdaConfigOf(input)->Option.flatMap(c => c->Dict.get("PreTokenGeneration")))->toEqual(
172
+ None,
173
+ )
174
+ )
175
+
176
+ testSync("clearing leaves the customer's own triggers alone", () =>
177
+ expect(lambdaConfigOf(input)->Option.flatMap(c => c->Dict.get("PreSignUp")))->toEqual(
178
+ Some(str("arn:aws:lambda:eu-west-1:1:function:TheirPreSignUp")),
179
+ )
180
+ )
181
+
182
+ testSync("clearing still sends the rest of the pool back whole", () =>
183
+ expect(input->Dict.get("MfaConfiguration"))->toEqual(Some(str("ON")))
184
+ )
185
+ })
186
+
187
+ describe("Auth_ActiveRolePoolAttachment.mergedUpdateInput — a pool with nothing set", () => {
188
+ testSync("a pool with no LambdaConfig gains one holding only our trigger", () => {
189
+ let input = Attachment.mergedUpdateInput(
190
+ ~described=Dict.fromArray([("Name", str("Bare"))]),
191
+ ~userPoolId="eu-west-1_bare",
192
+ ~preTokenGenerationArn=Some(triggerArn),
193
+ )
194
+ expect(lambdaConfigOf(input))->toEqual(
195
+ Some(Dict.fromArray([("PreTokenGeneration", str(triggerArn))])),
196
+ )
197
+ })
198
+ })
199
+
200
+ describe("Auth_ActiveRolePoolAttachment.attachedTrigger", () => {
201
+ testSync("reports the trigger a described pool carries", () =>
202
+ expect(Attachment.attachedTrigger(~described=merged(~preTokenGenerationArn=Some(triggerArn))))
203
+ ->toEqual(Some(triggerArn))
204
+ )
205
+
206
+ testSync("reports none when the pool carries no trigger", () =>
207
+ expect(Attachment.attachedTrigger(~described=Dict.fromArray([("Name", str("Bare"))])))->toEqual(
208
+ None,
209
+ )
210
+ )
211
+ })