@reventlessdev/reventless-aws 3.0.0-alpha.305 → 3.0.0-alpha.307

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.
Files changed (67) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/package.json +8 -8
  3. package/src/Platform.res +1 -1
  4. package/src/adapter/Api/AppSync_Resolver_Native.res +1 -1
  5. package/src/adapter/Api/AppSync_Resolver_Retrying.res +2 -2
  6. package/src/adapter/Auth/Auth_ActiveRolePoolAttachment.res +69 -11
  7. package/src/adapter/Auth/Auth_ActiveRolePoolAttachment.res.mjs +19 -1
  8. package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res +2 -2
  9. package/src/adapter/DcbEventLog/DcbEventLogStorage_Postgres.res +1 -1
  10. package/src/adapter/EventLog/EventLogStorage_DynamoDb_Runtime.res +1 -1
  11. package/src/adapter/Postgres/PgChangeFeedRelay_Runtime.res +1 -1
  12. package/src/adapter/Postgres/PgProjectionFeed.res +1 -1
  13. package/src/adapter/QueryDb/PgQueryResolver_Lambda.res +64 -0
  14. package/src/adapter/QueryDb/PgQueryResolver_Lambda.res.mjs +54 -2
  15. package/src/adapter/QueryDb/QueryDbBackend.res +1 -1
  16. package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res +138 -46
  17. package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res.mjs +25 -21
  18. package/src/adapter/QueryDb/QueryDbResolvers_Lambda.res +19 -11
  19. package/src/adapter/QueryDb/QueryDbResolvers_Lambda.res.mjs +11 -10
  20. package/src/adapter/QueryEngine/QueryEngine_DynamoDb.res +1 -1
  21. package/src/adapter/Runtime/AggregateEntryPoint.mjs +1 -1
  22. package/src/adapter/Runtime/AggregateEntryPoint_Ops.res +1 -1
  23. package/src/adapter/Runtime/AutomationSliceEntryPoint.mjs +1 -1
  24. package/src/adapter/Runtime/AutomationSliceEntryPoint_Ops.res +1 -1
  25. package/src/adapter/Runtime/CounterEntryPoint.mjs +1 -1
  26. package/src/adapter/Runtime/CounterEntryPoint_Ops.res +1 -1
  27. package/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs +44 -6
  28. package/src/adapter/Runtime/DcbCommandTopicEntryPoint_Ops.res +1 -1
  29. package/src/adapter/Runtime/EventCollectorEntryPoint.mjs +1 -1
  30. package/src/adapter/Runtime/EventCollectorEntryPoint_Ops.res +1 -1
  31. package/src/adapter/Runtime/EventMapperEntryPoint.mjs +1 -1
  32. package/src/adapter/Runtime/EventMapperEntryPoint_Ops.res +1 -1
  33. package/src/adapter/Runtime/ExtensionPointEntryPoint.mjs +1 -1
  34. package/src/adapter/Runtime/ExtensionPointEntryPoint_Ops.res +1 -1
  35. package/src/adapter/Runtime/PgQueryResolverEntryPoint.mjs +1 -1
  36. package/src/adapter/Runtime/PgQueryResolverEntryPoint_Ops.res +7 -1
  37. package/src/adapter/Runtime/PgQueryResolverEntryPoint_Ops.res.mjs +2 -1
  38. package/src/adapter/Runtime/PluginExtensionPointEntryPoint.res +1 -1
  39. package/src/adapter/Runtime/ProjectionEntryPoint_Ops.res +1 -1
  40. package/src/adapter/Runtime/QueryDbEntryPoint_Ops.res +1 -1
  41. package/src/adapter/Runtime/ReadModelEntryPoint.mjs +1 -1
  42. package/src/adapter/Runtime/ReadModelEntryPoint_Ops.res +1 -1
  43. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +1 -1
  44. package/src/adapter/Runtime/SideEffectEntryPoint.mjs +1 -1
  45. package/src/adapter/Runtime/SideEffectEntryPoint_Ops.res +1 -1
  46. package/src/adapter/Runtime/StateViewSliceEntryPoint.mjs +1 -1
  47. package/src/adapter/Runtime/StateViewSliceEntryPoint_Ops.res +1 -1
  48. package/src/adapter/Runtime/StreamRoutedEntryPoint_Ops.res +1 -1
  49. package/src/adapter/Runtime/TaskBucketEntryPoint.mjs +1 -1
  50. package/src/adapter/Runtime/TaskBucketEntryPoint_Ops.res +1 -1
  51. package/src/util/Util_Bundle.res +1 -1
  52. package/src/util/Util_DynamoDbStream_Runtime.res +1 -1
  53. package/tests/AWS_TagsTest.res +2 -2
  54. package/tests/AppSync_RetirementNarrowingTest.res +272 -0
  55. package/tests/AppSync_RetirementNarrowingTest.res.mjs +193 -0
  56. package/tests/Auth_ActiveRolePoolAttachmentTest.res +140 -1
  57. package/tests/Auth_ActiveRolePoolAttachmentTest.res.mjs +117 -2
  58. package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res +2 -2
  59. package/tests/EventLogStorage_DynamoDb_RuntimeTest.res +1 -1
  60. package/tests/PgQueryResolver_LambdaTest.res +2 -0
  61. package/tests/PgQueryResolver_LambdaTest.res.mjs +34 -30
  62. package/tests/QueryDbResolvers_AppSyncTest.res +2 -2
  63. package/tests/Util_BundleEsmLoaderTest.res +1 -1
  64. package/tests/Util_BundleRuntimeExtensionTest.res +1 -1
  65. package/tests/integration/AggregateEntryPoint_IntegrationTest.res +1 -1
  66. package/tests/integration/DcbCommandTopicEntryPoint_IntegrationTest.res +1 -1
  67. package/tests/integration/DcbEventLogStorage_DynamoDb_IntegrationTest.res +2 -2
@@ -0,0 +1,272 @@
1
+ // The DynamoDB backend narrows retirement on every door the rule names, not
2
+ // only on the list.
3
+ //
4
+ // These assert the *generated resolver source*, which is the only artifact that
5
+ // exists before a deploy: the predicate runs inside AppSync's JS runtime, so a
6
+ // unit test cannot execute it against a table, and what can be checked here is
7
+ // that each door carries the guard, in the half of the template its operation
8
+ // allows, and that a view declaring no retirement is untouched.
9
+
10
+ open JestGlobals
11
+
12
+ module F = AppSync_Resolver_Retrying.Functions
13
+
14
+ // The templates are `Pulumi.Input.t<string>` — a string at rest, wrapped for the
15
+ // provider. Read it back as one so the assertions can be about text.
16
+ external asString: Pulumi.Input.t<string> => string = "%identity"
17
+
18
+ let elevated = ["Admin"]
19
+
20
+ // A product view: retired by two states of its lifecycle, unowned.
21
+ let shelf = (~includeOwner: bool) =>
22
+ F.getItemById(
23
+ ~ownerField=?includeOwner ? Some("customerId") : None,
24
+ ~elevatedGroups=elevated,
25
+ ~retiredField="shelfStatus",
26
+ ~retiredValues=["Archived", "Discontinued"],
27
+ )->asString
28
+
29
+ describe("the single-entity door", () => {
30
+ testSync("withholds a retired row until an exempt caller asks", () => {
31
+ let code = shelf(~includeOwner=false)
32
+ expect(code->String.includes("const _wantsRetired = _exempt && ctx.args.includeRetired === true"))
33
+ ->Expect.toBe(true)
34
+ expect(code->String.includes("_live(ctx.result) ? ctx.result : null"))->Expect.toBe(true)
35
+ })
36
+
37
+ // Absent attribute keeps the row: a row written before the annotation existed
38
+ // is not retired, which is what stops a view emptying the day it lands.
39
+ testSync("reads a missing attribute as not retired", () =>
40
+ expect(shelf(~includeOwner=false)->String.includes("row['shelfStatus'] == null"))->Expect.toBe(true)
41
+ )
42
+
43
+ testSync("tests membership of the retiring states, not equality with one", () =>
44
+ expect(
45
+ shelf(~includeOwner=false)->String.includes(
46
+ "['Archived', 'Discontinued'].indexOf(row['shelfStatus']) >= 0",
47
+ ),
48
+ )->Expect.toBe(true)
49
+ )
50
+
51
+ // Two `const _exempt` in one function body is a syntax error, so the owner
52
+ // preamble's copy is the only one when both guards are present.
53
+ testSync("declares the exemption test once when owner scoping is also on", () => {
54
+ let code = shelf(~includeOwner=true)
55
+ let occurrences =
56
+ code->String.split("const _exempt =")->Array.length - 1
57
+ expect((occurrences, code->String.includes("_owns(ctx.result) && _live(ctx.result)")))
58
+ ->Expect.toEqual((1, true))
59
+ })
60
+
61
+ testSync("is unchanged for a view that declares no retirement", () => {
62
+ let code = F.getItemById(~elevatedGroups=elevated)->asString
63
+ expect((code->String.includes("_wantsRetired"), code->String.includes("includeRetired")))
64
+ ->Expect.toEqual((false, false))
65
+ })
66
+ })
67
+
68
+ describe("the by-ids door", () => {
69
+ let code = F.batchGetItemsByIds(
70
+ ~retiredField="shelfStatus",
71
+ ~retiredValues=["Archived"],
72
+ ~elevatedGroups=elevated,
73
+ )("ProductsTable")
74
+
75
+ // BatchGetItem has no FilterExpression, so the predicate is a filter over what
76
+ // came back — beside the null-drop the non-null list type already needs.
77
+ testSync("drops retired rows from the returned array", () =>
78
+ expect(code->String.includes("item !== null && _live(item)"))->Expect.toBe(true)
79
+ )
80
+
81
+ testSync("still names the table it batches against", () =>
82
+ expect(code->String.includes("ctx.result?.data?.['ProductsTable']"))->Expect.toBe(true)
83
+ )
84
+
85
+ testSync("is unchanged for a view that declares no retirement", () =>
86
+ expect(F.batchGetItemsByIds()("ProductsTable")->String.includes("_live"))
87
+ ->Expect.toBe(false)
88
+ )
89
+ })
90
+
91
+ describe("the by-index door", () => {
92
+ let code = F.queryByIndexFiltered(
93
+ ~index="byCategory",
94
+ ~idField="categoryId",
95
+ ~retiredField="shelfStatus",
96
+ ~retiredValues=["Archived", "Discontinued"],
97
+ ~elevatedGroups=elevated,
98
+ )->asString
99
+
100
+ // A Query takes a FilterExpression, so the predicate is pushed into the read.
101
+ // Narrowing after it would hand back fewer rows than `limit` asked for and say
102
+ // nothing about why.
103
+ testSync("pushes the predicate into the FilterExpression", () => {
104
+ expect(code->String.includes("names['#retired'] = 'shelfStatus'"))->Expect.toBe(true)
105
+ expect(
106
+ code->String.includes(
107
+ "attribute_not_exists(#retired) OR (#retired <> :retiredValue0 AND #retired <> :retiredValue1)",
108
+ ),
109
+ )->Expect.toBe(true)
110
+ })
111
+
112
+ // It composes with the caller's own filter arguments rather than replacing them.
113
+ testSync("ANDs onto an expression the caller may already have built", () =>
114
+ expect(code->String.includes("if (expression) expression += ' AND '"))->Expect.toBe(true)
115
+ )
116
+
117
+ testSync("is unchanged for a view that declares no retirement", () =>
118
+ expect(
119
+ F.queryByIndexFiltered(~index="byCategory", ~idField="categoryId")
120
+ ->asString
121
+ ->String.includes("#retired"),
122
+ )->Expect.toBe(false)
123
+ )
124
+ })
125
+
126
+ describe("the boolean form", () => {
127
+ testSync("tests the flag rather than a set of states", () => {
128
+ let code =
129
+ F.getItemById(~elevatedGroups=elevated, ~retiredField="archived")->asString
130
+ expect((
131
+ code->String.includes("row['archived'] === true"),
132
+ code->String.includes("indexOf(row["),
133
+ ))->Expect.toEqual((true, false))
134
+ })
135
+ })
136
+
137
+ // ── Ownership, on the two doors that never had it ────────────────────────────
138
+
139
+ describe("the by-ids door's owner scoping", () => {
140
+ let code = F.batchGetItemsByIds(
141
+ ~ownerField="customerId",
142
+ ~retiredField="shelfStatus",
143
+ ~retiredValues=["Archived"],
144
+ ~elevatedGroups=elevated,
145
+ )("Orders")
146
+
147
+ // Dropped from the array rather than refused, for the reason the single-key
148
+ // door answers null: telling "not yours" apart from "not there" makes the door
149
+ // an oracle for which ids exist.
150
+ testSync("drops a row the caller does not own", () =>
151
+ expect(code->String.includes("item !== null && _owns(item) && _live(item)"))->Expect.toBe(true)
152
+ )
153
+
154
+ testSync("declares the exemption test once, shared with the retirement guard", () =>
155
+ expect(code->String.split("const _exempt =")->Array.length - 1)->Expect.toBe(1)
156
+ )
157
+
158
+ testSync("is unchanged for a view with neither rule", () => {
159
+ let plain = F.batchGetItemsByIds()("Orders")
160
+ expect((plain->String.includes("_owns"), plain->String.includes("_live")))
161
+ ->Expect.toEqual((false, false))
162
+ })
163
+ })
164
+
165
+ describe("the by-index door's owner scoping", () => {
166
+ let code = F.queryByIndexFiltered(
167
+ ~index="byCategory",
168
+ ~idField="categoryId",
169
+ ~ownerField="customerId",
170
+ ~elevatedGroups=elevated,
171
+ )->asString
172
+
173
+ testSync("pushes the owner predicate into the read", () =>
174
+ expect(code->String.includes("names['#owner'] = 'customerId'"))->Expect.toBe(true)
175
+ )
176
+
177
+ // The predicate must not be readable off the arguments: a rule about what the
178
+ // caller may see cannot arrive on a channel the caller controls.
179
+ testSync("reads the owner from the identity, never from ctx.args", () =>
180
+ expect(code->String.includes("values[':owner'] = _osub"))->Expect.toBe(true)
181
+ )
182
+
183
+ testSync("exempts an elevated caller and an IAM service call", () =>
184
+ expect(
185
+ code->String.includes("if (!(_osub == null || _ogroups.some(g => _oelevated.indexOf(g) >= 0)))"),
186
+ )->Expect.toBe(true)
187
+ )
188
+
189
+ // The generic argument loop turns an unrecognised arg into a `contains` filter.
190
+ // `includeRetired` is a request to lift a restriction, and filtering on it
191
+ // would match no row at all.
192
+ testSync("never treats includeRetired as a column to match on", () =>
193
+ expect(code->String.includes("key === 'includeRetired'"))->Expect.toBe(true)
194
+ )
195
+ })
196
+
197
+ // The by-index door was, until this landed, unreachable rather than merely
198
+ // un-widenable: the SDL declared `id` while the template below read the index
199
+ // key, and the template answered with DynamoDB's `{items, nextToken}` against a
200
+ // field that promised a Connection. These assert the two halves that were wrong,
201
+ // beside the paging arguments the field actually offers.
202
+ describe("the by-index door answers the field it is attached to", () => {
203
+ let code =
204
+ F.queryByIndexFiltered(~index="byCategory", ~idField="categoryId")->asString
205
+
206
+ testSync("keys the read on the index column the SDL offers", () =>
207
+ expect(code->String.includes("util.dynamodb.toDynamoDB(args.categoryId)"))->Expect.toBe(true)
208
+ )
209
+
210
+ testSync("returns a Relay connection, not the raw DynamoDB result", () =>
211
+ expect((
212
+ code->String.includes("edges,"),
213
+ code->String.includes("hasNextPage: !!next"),
214
+ code->String.includes("return ctx.result;"),
215
+ ))->Expect.toEqual((true, true, false))
216
+ )
217
+
218
+ // `first`/`after` are what the field declares; `limit`/`nextToken` were what
219
+ // the template read, and nothing translated between the two.
220
+ testSync("pages on the Relay arguments the field declares", () =>
221
+ expect((
222
+ code->String.includes("limit: (args.first ?? 50)"),
223
+ code->String.includes("util.base64Decode(args.after)"),
224
+ ))->Expect.toEqual((true, true))
225
+ )
226
+
227
+ // Every declared argument has a job other than matching a column, so each one
228
+ // reaching the generic filter loop would filter on an attribute no row has.
229
+ testSync("keeps every paging argument out of the filter loop", () =>
230
+ expect(
231
+ ["first", "after", "last", "before", "includeRetired"]->Array.every(arg =>
232
+ code->String.includes(`key === '${arg}'`)
233
+ ),
234
+ )->Expect.toBe(true)
235
+ )
236
+ })
237
+
238
+ // Backward paging is refused rather than ignored. The cursor is DynamoDB's own
239
+ // continuation token, which walks one way, so `last: 2` could only ever be
240
+ // answered with the first two rows — a different question, answered silently.
241
+ // `listAllItemsConnection` set this rule; the local backend refuses with the
242
+ // same sentence, so the door reads the same either side of a deploy.
243
+ describe("the by-index door's backward paging", () => {
244
+ let refusal = "Backward pagination (last/before) is not supported on by-index connections"
245
+
246
+ testSync("refuses last/before on the plain index read", () =>
247
+ expect(
248
+ F.queryByIndexFiltered(~index="byCategory", ~idField="categoryId")
249
+ ->asString
250
+ ->String.includes(refusal),
251
+ )->Expect.toBe(true)
252
+ )
253
+
254
+ testSync("refuses them on the sort-key index read too", () =>
255
+ expect(
256
+ F.queryByIndexSortFiltered(~index="byCustomer", ~idField="customerId", ~sortField="placedAt")
257
+ ->asString
258
+ ->String.includes(refusal),
259
+ )->Expect.toBe(true)
260
+ )
261
+
262
+ // Before the key condition is built, so a refused request never reaches the
263
+ // read — and `util.error` is what AppSync returns to the caller as the field
264
+ // error, rather than an empty page that looks like an answer.
265
+ testSync("refuses before doing any work, and says so to the caller", () => {
266
+ let code = F.queryByIndexFiltered(~index="byCategory", ~idField="categoryId")->asString
267
+ let guardAt = code->String.indexOf("args.before != null")
268
+ let queryAt = code->String.indexOf("expressionValues")
269
+ expect((guardAt >= 0 && guardAt < queryAt, code->String.includes("'UnsupportedPagination'")))
270
+ ->Expect.toEqual((true, true))
271
+ })
272
+ })
@@ -0,0 +1,193 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as AppSync_Resolver_Functions$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/AppSync/AppSync_Resolver_Functions.res.mjs";
4
+
5
+ let elevated = ["Admin"];
6
+
7
+ function shelf(includeOwner) {
8
+ return AppSync_Resolver_Functions$PulumiAws.getItemById(includeOwner ? "customerId" : undefined, elevated, "shelfStatus", [
9
+ "Archived",
10
+ "Discontinued"
11
+ ]);
12
+ }
13
+
14
+ globalThis.describe("the single-entity door", () => {
15
+ globalThis.test("withholds a retired row until an exempt caller asks", () => {
16
+ let code = shelf(false);
17
+ globalThis.expect(code.includes("const _wantsRetired = _exempt && ctx.args.includeRetired === true")).toBe(true);
18
+ globalThis.expect(code.includes("_live(ctx.result) ? ctx.result : null")).toBe(true);
19
+ });
20
+ globalThis.test("reads a missing attribute as not retired", () => {
21
+ globalThis.expect(shelf(false).includes("row['shelfStatus'] == null")).toBe(true);
22
+ });
23
+ globalThis.test("tests membership of the retiring states, not equality with one", () => {
24
+ globalThis.expect(shelf(false).includes("['Archived', 'Discontinued'].indexOf(row['shelfStatus']) >= 0")).toBe(true);
25
+ });
26
+ globalThis.test("declares the exemption test once when owner scoping is also on", () => {
27
+ let code = shelf(true);
28
+ let occurrences = code.split("const _exempt =").length - 1 | 0;
29
+ globalThis.expect([
30
+ occurrences,
31
+ code.includes("_owns(ctx.result) && _live(ctx.result)")
32
+ ]).toEqual([
33
+ 1,
34
+ true
35
+ ]);
36
+ });
37
+ globalThis.test("is unchanged for a view that declares no retirement", () => {
38
+ let code = AppSync_Resolver_Functions$PulumiAws.getItemById(undefined, elevated, undefined, undefined);
39
+ globalThis.expect([
40
+ code.includes("_wantsRetired"),
41
+ code.includes("includeRetired")
42
+ ]).toEqual([
43
+ false,
44
+ false
45
+ ]);
46
+ });
47
+ });
48
+
49
+ globalThis.describe("the by-ids door", () => {
50
+ let code = AppSync_Resolver_Functions$PulumiAws.batchGetItemsByIds(undefined, "shelfStatus", ["Archived"], elevated)("ProductsTable");
51
+ globalThis.test("drops retired rows from the returned array", () => {
52
+ globalThis.expect(code.includes("item !== null && _live(item)")).toBe(true);
53
+ });
54
+ globalThis.test("still names the table it batches against", () => {
55
+ globalThis.expect(code.includes("ctx.result?.data?.['ProductsTable']")).toBe(true);
56
+ });
57
+ globalThis.test("is unchanged for a view that declares no retirement", () => {
58
+ globalThis.expect(AppSync_Resolver_Functions$PulumiAws.batchGetItemsByIds(undefined, undefined, undefined, undefined)("ProductsTable").includes("_live")).toBe(false);
59
+ });
60
+ });
61
+
62
+ globalThis.describe("the by-index door", () => {
63
+ let code = AppSync_Resolver_Functions$PulumiAws.queryByIndexFiltered("byCategory", "categoryId", undefined, "shelfStatus", [
64
+ "Archived",
65
+ "Discontinued"
66
+ ], elevated);
67
+ globalThis.test("pushes the predicate into the FilterExpression", () => {
68
+ globalThis.expect(code.includes("names['#retired'] = 'shelfStatus'")).toBe(true);
69
+ globalThis.expect(code.includes("attribute_not_exists(#retired) OR (#retired <> :retiredValue0 AND #retired <> :retiredValue1)")).toBe(true);
70
+ });
71
+ globalThis.test("ANDs onto an expression the caller may already have built", () => {
72
+ globalThis.expect(code.includes("if (expression) expression += ' AND '")).toBe(true);
73
+ });
74
+ globalThis.test("is unchanged for a view that declares no retirement", () => {
75
+ globalThis.expect(AppSync_Resolver_Functions$PulumiAws.queryByIndexFiltered("byCategory", "categoryId", undefined, undefined, undefined, undefined).includes("#retired")).toBe(false);
76
+ });
77
+ });
78
+
79
+ globalThis.describe("the boolean form", () => {
80
+ globalThis.test("tests the flag rather than a set of states", () => {
81
+ let code = AppSync_Resolver_Functions$PulumiAws.getItemById(undefined, elevated, "archived", undefined);
82
+ globalThis.expect([
83
+ code.includes("row['archived'] === true"),
84
+ code.includes("indexOf(row[")
85
+ ]).toEqual([
86
+ true,
87
+ false
88
+ ]);
89
+ });
90
+ });
91
+
92
+ globalThis.describe("the by-ids door's owner scoping", () => {
93
+ let code = AppSync_Resolver_Functions$PulumiAws.batchGetItemsByIds("customerId", "shelfStatus", ["Archived"], elevated)("Orders");
94
+ globalThis.test("drops a row the caller does not own", () => {
95
+ globalThis.expect(code.includes("item !== null && _owns(item) && _live(item)")).toBe(true);
96
+ });
97
+ globalThis.test("declares the exemption test once, shared with the retirement guard", () => {
98
+ globalThis.expect(code.split("const _exempt =").length - 1 | 0).toBe(1);
99
+ });
100
+ globalThis.test("is unchanged for a view with neither rule", () => {
101
+ let plain = AppSync_Resolver_Functions$PulumiAws.batchGetItemsByIds(undefined, undefined, undefined, undefined)("Orders");
102
+ globalThis.expect([
103
+ plain.includes("_owns"),
104
+ plain.includes("_live")
105
+ ]).toEqual([
106
+ false,
107
+ false
108
+ ]);
109
+ });
110
+ });
111
+
112
+ globalThis.describe("the by-index door's owner scoping", () => {
113
+ let code = AppSync_Resolver_Functions$PulumiAws.queryByIndexFiltered("byCategory", "categoryId", "customerId", undefined, undefined, elevated);
114
+ globalThis.test("pushes the owner predicate into the read", () => {
115
+ globalThis.expect(code.includes("names['#owner'] = 'customerId'")).toBe(true);
116
+ });
117
+ globalThis.test("reads the owner from the identity, never from ctx.args", () => {
118
+ globalThis.expect(code.includes("values[':owner'] = _osub")).toBe(true);
119
+ });
120
+ globalThis.test("exempts an elevated caller and an IAM service call", () => {
121
+ globalThis.expect(code.includes("if (!(_osub == null || _ogroups.some(g => _oelevated.indexOf(g) >= 0)))")).toBe(true);
122
+ });
123
+ globalThis.test("never treats includeRetired as a column to match on", () => {
124
+ globalThis.expect(code.includes("key === 'includeRetired'")).toBe(true);
125
+ });
126
+ });
127
+
128
+ globalThis.describe("the by-index door answers the field it is attached to", () => {
129
+ let code = AppSync_Resolver_Functions$PulumiAws.queryByIndexFiltered("byCategory", "categoryId", undefined, undefined, undefined, undefined);
130
+ globalThis.test("keys the read on the index column the SDL offers", () => {
131
+ globalThis.expect(code.includes("util.dynamodb.toDynamoDB(args.categoryId)")).toBe(true);
132
+ });
133
+ globalThis.test("returns a Relay connection, not the raw DynamoDB result", () => {
134
+ globalThis.expect([
135
+ code.includes("edges,"),
136
+ code.includes("hasNextPage: !!next"),
137
+ code.includes("return ctx.result;")
138
+ ]).toEqual([
139
+ true,
140
+ true,
141
+ false
142
+ ]);
143
+ });
144
+ globalThis.test("pages on the Relay arguments the field declares", () => {
145
+ globalThis.expect([
146
+ code.includes("limit: (args.first ?? 50)"),
147
+ code.includes("util.base64Decode(args.after)")
148
+ ]).toEqual([
149
+ true,
150
+ true
151
+ ]);
152
+ });
153
+ globalThis.test("keeps every paging argument out of the filter loop", () => {
154
+ globalThis.expect([
155
+ "first",
156
+ "after",
157
+ "last",
158
+ "before",
159
+ "includeRetired"
160
+ ].every(arg => code.includes(`key === '` + arg + `'`))).toBe(true);
161
+ });
162
+ });
163
+
164
+ globalThis.describe("the by-index door's backward paging", () => {
165
+ let refusal = "Backward pagination (last/before) is not supported on by-index connections";
166
+ globalThis.test("refuses last/before on the plain index read", () => {
167
+ globalThis.expect(AppSync_Resolver_Functions$PulumiAws.queryByIndexFiltered("byCategory", "categoryId", undefined, undefined, undefined, undefined).includes(refusal)).toBe(true);
168
+ });
169
+ globalThis.test("refuses them on the sort-key index read too", () => {
170
+ globalThis.expect(AppSync_Resolver_Functions$PulumiAws.queryByIndexSortFiltered("byCustomer", "customerId", undefined, "placedAt", undefined, undefined, undefined).includes(refusal)).toBe(true);
171
+ });
172
+ globalThis.test("refuses before doing any work, and says so to the caller", () => {
173
+ let code = AppSync_Resolver_Functions$PulumiAws.queryByIndexFiltered("byCategory", "categoryId", undefined, undefined, undefined, undefined);
174
+ let guardAt = code.indexOf("args.before != null");
175
+ let queryAt = code.indexOf("expressionValues");
176
+ globalThis.expect([
177
+ guardAt >= 0 && guardAt < queryAt,
178
+ code.includes("'UnsupportedPagination'")
179
+ ]).toEqual([
180
+ true,
181
+ true
182
+ ]);
183
+ });
184
+ });
185
+
186
+ let F;
187
+
188
+ export {
189
+ F,
190
+ elevated,
191
+ shelf,
192
+ }
193
+ /* Not a pure module */
@@ -76,6 +76,11 @@ let triggerArn = "arn:aws:lambda:eu-west-1:1:function:ActiveRoleTrigger"
76
76
  let lambdaConfigOf = input =>
77
77
  input->Dict.get("LambdaConfig")->Option.flatMap(JSON.Decode.object)
78
78
 
79
+ /** The `PreTokenGenerationConfig` an attach is expected to write: our ARN, at the
80
+ only version the trigger handler implements. */
81
+ let v1Config = arn =>
82
+ Dict.fromArray([("LambdaArn", str(arn)), ("LambdaVersion", str("V1_0"))])->JSON.Encode.object
83
+
79
84
  describe("Auth_ActiveRolePoolAttachment.mergedUpdateInput — settings survive the attach", () => {
80
85
  let input = merged(~preTokenGenerationArn=Some(triggerArn))
81
86
 
@@ -192,8 +197,105 @@ describe("Auth_ActiveRolePoolAttachment.mergedUpdateInput — a pool with nothin
192
197
  ~preTokenGenerationArn=Some(triggerArn),
193
198
  )
194
199
  expect(lambdaConfigOf(input))->toEqual(
195
- Some(Dict.fromArray([("PreTokenGeneration", str(triggerArn))])),
200
+ Some(
201
+ Dict.fromArray([
202
+ ("PreTokenGeneration", str(triggerArn)),
203
+ ("PreTokenGenerationConfig", v1Config(triggerArn)),
204
+ ]),
205
+ ),
206
+ )
207
+ })
208
+ })
209
+
210
+ // 🚨 The pool shape that took the deploy down. Cognito carries this one trigger
211
+ // in two fields, and `UpdateUserPool` rejects the call when both are present
212
+ // naming different functions. Nothing in this suite carried a
213
+ // `PreTokenGenerationConfig` before, which is exactly why it shipped.
214
+ describe("Auth_ActiveRolePoolAttachment.mergedUpdateInput — a pool already on the V2 field", () => {
215
+ let theirArn = "arn:aws:lambda:eu-west-1:1:function:TheirOwnPreToken"
216
+
217
+ let describedWithConfig = (~version) =>
218
+ Dict.fromArray([
219
+ ("Name", str("CustomerPool")),
220
+ (
221
+ "LambdaConfig",
222
+ Dict.fromArray([
223
+ ("PreSignUp", str("arn:aws:lambda:eu-west-1:1:function:TheirPreSignUp")),
224
+ ("PreTokenGeneration", str(theirArn)),
225
+ (
226
+ "PreTokenGenerationConfig",
227
+ Dict.fromArray([
228
+ ("LambdaArn", str(theirArn)),
229
+ ("LambdaVersion", str(version)),
230
+ ])->JSON.Encode.object,
231
+ ),
232
+ ])->JSON.Encode.object,
233
+ ),
234
+ ])
235
+
236
+ let attachOnto = (~version) =>
237
+ Attachment.mergedUpdateInput(
238
+ ~described=describedWithConfig(~version),
239
+ ~userPoolId="eu-west-1_abc123",
240
+ ~preTokenGenerationArn=Some(triggerArn),
241
+ )
242
+
243
+ // The property AWS enforces, asserted directly rather than through its error
244
+ // text: the two fields must name the same function.
245
+ testSync("both fields name our trigger, and the same one", () => {
246
+ let config = lambdaConfigOf(attachOnto(~version="V1_0"))
247
+ expect((
248
+ config->Option.flatMap(c => c->Dict.get("PreTokenGeneration")),
249
+ config
250
+ ->Option.flatMap(c => c->Dict.get("PreTokenGenerationConfig"))
251
+ ->Option.flatMap(JSON.Decode.object)
252
+ ->Option.flatMap(c => c->Dict.get("LambdaArn")),
253
+ ))->toEqual((Some(str(triggerArn)), Some(str(triggerArn))))
254
+ })
255
+
256
+ // A pool left on V2_0 does not fail — the handler answers in V1_0 shape and
257
+ // Cognito ignores it, so tokens mint un-narrowed and nothing reports it.
258
+ testSync("a pool that arrived on V2_0 is pinned back to the version we implement", () =>
259
+ expect(
260
+ lambdaConfigOf(attachOnto(~version="V2_0"))
261
+ ->Option.flatMap(c => c->Dict.get("PreTokenGenerationConfig"))
262
+ ->Option.flatMap(JSON.Decode.object)
263
+ ->Option.flatMap(c => c->Dict.get("LambdaVersion")),
264
+ )->toEqual(Some(str("V1_0")))
265
+ )
266
+
267
+ testSync("the customer's other triggers still survive", () =>
268
+ expect(
269
+ lambdaConfigOf(attachOnto(~version="V1_0"))->Option.flatMap(c => c->Dict.get("PreSignUp")),
270
+ )->toEqual(Some(str("arn:aws:lambda:eu-west-1:1:function:TheirPreSignUp")))
271
+ )
272
+
273
+ testSync("detaching removes both fields, not just the legacy one", () => {
274
+ let config = lambdaConfigOf(
275
+ Attachment.mergedUpdateInput(
276
+ ~described=describedWithConfig(~version="V1_0"),
277
+ ~userPoolId="eu-west-1_abc123",
278
+ ~preTokenGenerationArn=None,
279
+ ),
280
+ )
281
+ expect((
282
+ config->Option.flatMap(c => c->Dict.get("PreTokenGeneration")),
283
+ config->Option.flatMap(c => c->Dict.get("PreTokenGenerationConfig")),
284
+ ))->toEqual((None, None))
285
+ })
286
+
287
+ // The fixpoint property, and the one that would have caught this: AWS
288
+ // materialises whichever field the merge did not write, so the merge has to
289
+ // accept its own output unchanged or a later deploy fails with nothing altered
290
+ // but the service normalising its own record.
291
+ testSync("feeding the merge its own output back changes nothing", () => {
292
+ let once = attachOnto(~version="V1_0")
293
+ let twice = Attachment.mergedUpdateInput(
294
+ ~described=once,
295
+ ~userPoolId="eu-west-1_abc123",
296
+ ~preTokenGenerationArn=Some(triggerArn),
196
297
  )
298
+ expect(lambdaConfigOf(twice))->toEqual(lambdaConfigOf(once))
197
299
  })
198
300
  })
199
301
 
@@ -208,6 +310,43 @@ describe("Auth_ActiveRolePoolAttachment.attachedTrigger", () => {
208
310
  None,
209
311
  )
210
312
  )
313
+
314
+ testSync("reads the V2 field on a pool that only carries that one", () =>
315
+ expect(
316
+ Attachment.attachedTrigger(
317
+ ~described=Dict.fromArray([
318
+ (
319
+ "LambdaConfig",
320
+ Dict.fromArray([("PreTokenGenerationConfig", v1Config(triggerArn))])->JSON.Encode.object,
321
+ ),
322
+ ]),
323
+ ),
324
+ )->toEqual(Some(triggerArn))
325
+ )
326
+
327
+ // Our own ARN, at a version the handler does not speak. Reporting it as
328
+ // attached would leave a pool minting un-narrowed tokens looking correct
329
+ // forever; reporting none makes it drift the next `up` repairs.
330
+ testSync("reports none for our trigger held at a version we do not implement", () =>
331
+ expect(
332
+ Attachment.attachedTrigger(
333
+ ~described=Dict.fromArray([
334
+ (
335
+ "LambdaConfig",
336
+ Dict.fromArray([
337
+ (
338
+ "PreTokenGenerationConfig",
339
+ Dict.fromArray([
340
+ ("LambdaArn", str(triggerArn)),
341
+ ("LambdaVersion", str("V2_0")),
342
+ ])->JSON.Encode.object,
343
+ ),
344
+ ])->JSON.Encode.object,
345
+ ),
346
+ ]),
347
+ ),
348
+ )->toEqual(None)
349
+ )
211
350
  })
212
351
 
213
352
  // The pre-attach check. Its whole job is to keep a trigger that cannot run off a