@reventlessdev/rescript-pulumi-aws 3.0.0-alpha.1 → 3.0.0-alpha.10

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,44 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Aws from "@pulumi/aws";
4
+ import * as Cloudwatch_EventTarget$PulumiAws from "../Cloudwatch/Cloudwatch_EventTarget.res.mjs";
5
+
6
+ let alarmMailTopic = new (Aws.sns.Topic)("example-alarm-mail");
7
+
8
+ let alarmStateChanges = new (Aws.cloudwatch.EventRule)("example-alarm-state-changes", {
9
+ description: "CloudWatch alarm state changes, ALARM and OK only",
10
+ eventPattern: `{"source":["aws.cloudwatch"],"detail-type":["CloudWatch Alarm State Change"],"detail":{"state":{"value":["ALARM","OK"]}}}`
11
+ });
12
+
13
+ let mailBody_inputPaths = Object.fromEntries([
14
+ [
15
+ "name",
16
+ "$.detail.alarmName"
17
+ ],
18
+ [
19
+ "state",
20
+ "$.detail.state.value"
21
+ ],
22
+ [
23
+ "reason",
24
+ "$.detail.state.reason"
25
+ ]
26
+ ]);
27
+
28
+ let mailBody = {
29
+ inputPaths: mailBody_inputPaths,
30
+ inputTemplate: `"<state>: <name> — <reason>"`
31
+ };
32
+
33
+ new (Aws.cloudwatch.EventTarget)("example-alarm-mail-target", {
34
+ rule: Cloudwatch_EventTarget$PulumiAws.Rule.ofEventRule(alarmStateChanges),
35
+ arn: alarmMailTopic.arn,
36
+ inputTransformer: mailBody
37
+ });
38
+
39
+ export {
40
+ alarmMailTopic,
41
+ alarmStateChanges,
42
+ mailBody,
43
+ }
44
+ /* alarmMailTopic Not a pure module */
@@ -148,7 +148,7 @@ describe('listAllItems', () => {
148
148
  })
149
149
 
150
150
  // ---------------------------------------------------------------------------
151
- // listAllItemsConnection — Phase 3 server-side filter/sort
151
+ // listAllItemsConnection — server-side filter/sort
152
152
  // ---------------------------------------------------------------------------
153
153
  describe('listAllItemsConnection', () => {
154
154
  describe('default (no capability args)', () => {
@@ -193,13 +193,13 @@ describe('listAllItemsConnection', () => {
193
193
  expect(r.edges[0].node).toEqual({ id: 'a' })
194
194
  expect(r.pageInfo.hasNextPage).toBe(true)
195
195
  expect(r.pageInfo.hasPreviousPage).toBe(false)
196
- // Fix 1: the cursor encodes the real nextToken, not a positional index.
197
- expect(JSON.parse(util.base64Decode(r.pageInfo.endCursor)).token).toBe('next')
196
+ // The cursor encodes a real read window, not a positional index. The last row
197
+ // closed this one, so it names the window that follows.
198
+ expect(JSON.parse(util.base64Decode(r.pageInfo.endCursor)).t).toBe('next')
198
199
  })
199
200
 
200
- // Fix 1 (docs/plans/done/aws-scan-connection-cursor-roundtrip.md): the cursor the
201
- // response emits must decode back to the exact DynamoDB nextToken the request feeds
202
- // to the next Scan. This is the round-trip that failed before the fix.
201
+ // The cursor the response emits must decode back to the DynamoDB nextToken the
202
+ // request feeds to the next Scan.
203
203
  test('endCursor round-trips: response endCursor → request nextToken', () => {
204
204
  const page1 = response(makeCtx({
205
205
  args: {},
@@ -209,17 +209,23 @@ describe('listAllItemsConnection', () => {
209
209
  expect(req2.nextToken).toBe('TOK1')
210
210
  })
211
211
 
212
+ // `hasPreviousPage` reports what this door can SERVE, not what exists. This
213
+ // cursor opens a new window at position 0, so the page before it lies in the
214
+ // previous window — real, and unreachable, because a continuation token
215
+ // cannot name the one before it. Claiming it is what drew a Prev button that
216
+ // always errored.
212
217
  test('final page (nextToken null) closes the connection', () => {
218
+ const after = util.base64Encode(JSON.stringify({ t: 'TOK1', n: -1 }))
213
219
  const r = response(makeCtx({
214
- args: { after: 'x' },
220
+ args: { after },
215
221
  result: { items: [{ id: 'z' }], nextToken: null },
216
222
  }))
217
223
  expect(r.pageInfo.hasNextPage).toBe(false)
218
- expect(r.pageInfo.hasPreviousPage).toBe(true)
224
+ expect(r.pageInfo.hasPreviousPage).toBe(false)
219
225
  })
220
226
 
221
- // Fix 3: a filtered Scan can return an empty page while nextToken is still set.
222
- // The boundary cursor must let the client resume instead of restarting page 1.
227
+ // A filtered Scan can return an empty page while nextToken is still set. The
228
+ // boundary cursor must let the client resume instead of restarting page 1.
223
229
  test('empty-but-continuable page yields a resumable boundary cursor', () => {
224
230
  const empty = response(makeCtx({
225
231
  args: {},
@@ -238,15 +244,144 @@ describe('listAllItemsConnection', () => {
238
244
  expect(r.pageInfo.hasNextPage).toBe(false)
239
245
  })
240
246
 
241
- // Fix 2: Scan cannot page backward; last/before must error, not mislead.
242
- test('request rejects backward pagination (before)', () => {
243
- expect(() => request(makeCtx({ args: { before: 'x' } }))).toThrow(
244
- 'Backward pagination',
247
+ // `before` is served by re-reading the window the cursor names and cutting
248
+ // the page that ends at it. `last` is not: the last N rows of the list needs
249
+ // the END of the list, which a forward-only cursor cannot reach.
250
+ test('request rejects `last`, which needs the end of the list', () => {
251
+ expect(() => request(makeCtx({ args: { last: 5 } }))).toThrow('last is not supported')
252
+ })
253
+
254
+ test('a backward page is the page that precedes it, exactly', () => {
255
+ const items = Array.from({ length: 25 }, (_, i) => ({ id: 'r' + i }))
256
+ const page1 = response(makeCtx({ args: { first: 10 }, result: { items, nextToken: null } }))
257
+ const fwd = { args: { first: 10, after: page1.pageInfo.endCursor } }
258
+ const page2 = response(makeCtx({ ...fwd, result: { items, nextToken: null } }))
259
+ expect(page2.edges.map(e => e.node.id)).toEqual(items.slice(10, 20).map(i => i.id))
260
+
261
+ const back = { args: { first: 10, before: page2.pageInfo.startCursor } }
262
+ const prev = response(makeCtx({ ...back, result: { items, nextToken: null } }))
263
+ expect(prev.edges.map(e => e.node.id)).toEqual(page1.edges.map(e => e.node.id))
264
+ // Cut from ahead, so a next page provably exists — the one we came from.
265
+ expect(prev.pageInfo.hasNextPage).toBe(true)
266
+ expect(prev.pageInfo.hasPreviousPage).toBe(false)
267
+ })
268
+
269
+ // The read must reach `_upTo` rows into the window, not `first + from`, or the
270
+ // backward slice comes back with its tail missing.
271
+ test('a backward read examines the window as deep as the page it cuts', () => {
272
+ const before = util.base64Encode(JSON.stringify({ t: null, n: 30 }))
273
+ expect(request(makeCtx({ args: { first: 10, before } })).limit).toBe(30)
274
+ })
275
+
276
+ test('the window a backward cursor names is the one re-read', () => {
277
+ const before = util.base64Encode(JSON.stringify({ t: 'W7', n: 30 }))
278
+ expect(request(makeCtx({ args: { first: 10, before } })).nextToken).toBe('W7')
279
+ })
280
+
281
+ // The one page still out of reach, and the only case left refused.
282
+ test('a previous page in an earlier window is refused, and says which limit', () => {
283
+ const before = util.base64Encode(JSON.stringify({ t: 'W2', n: 0 }))
284
+ expect(() => request(makeCtx({ args: { first: 10, before } }))).toThrow(
285
+ 'earlier read window',
245
286
  )
246
287
  })
247
288
 
248
- test('request rejects backward pagination (last)', () => {
249
- expect(() => request(makeCtx({ args: { last: 5 } }))).toThrow('Backward pagination')
289
+ // At the very start there is no earlier window to be unable to name, so a
290
+ // short backward page is served rather than refused.
291
+ test('a backward page at the start of the first window is served, not refused', () => {
292
+ const items = Array.from({ length: 10 }, (_, i) => ({ id: 'r' + i }))
293
+ const before = util.base64Encode(JSON.stringify({ t: null, n: 3 }))
294
+ const r = response(makeCtx({ args: { first: 10, before }, result: { items, nextToken: null } }))
295
+ expect(r.edges.map(e => e.node.id)).toEqual(['r0', 'r1', 'r2'])
296
+ })
297
+
298
+ // Cursors minted before the read window existed named the following window.
299
+ test('a pre-window { token, index } cursor still resumes', () => {
300
+ const legacy = util.base64Encode(JSON.stringify({ token: 'TOK9', index: 3 }))
301
+ const req = request(makeCtx({ args: { after: legacy } }))
302
+ expect(req.nextToken).toBe('TOK9')
303
+ const r = response(makeCtx({
304
+ args: { after: legacy },
305
+ result: { items: [{ id: 'a' }], nextToken: null },
306
+ }))
307
+ expect(r.edges).toHaveLength(1)
308
+ })
309
+ })
310
+
311
+ // The defect a selective filter turns into a visibly broken list: DynamoDB
312
+ // applies Limit before the FilterExpression, so reading `first` rows and
313
+ // returning the survivors spreads one matching row over several pages, most of
314
+ // them blank under a live Next button.
315
+ describe('reads a window, serves a page', () => {
316
+ const { request, response } = evalResolver(
317
+ F.listAllItemsConnection('name', [], [], [], undefined, 'customerId', ['Admin']),
318
+ )
319
+
320
+ test('an unfiltered read examines exactly the page it serves', () => {
321
+ const req = request(makeCtx({ args: { first: 25 }, identity: null }))
322
+ expect(req.filter).toBeUndefined()
323
+ expect(req.limit).toBe(25)
324
+ })
325
+
326
+ test('a filtered read examines a wider window than the page', () => {
327
+ const req = request(makeCtx({ args: { first: 25 } }))
328
+ expect(req.filter.expression).toBe('#owner = :owner')
329
+ expect(req.limit).toBe(1000)
330
+ })
331
+
332
+ test('a caller asking for more than the window gets what it asked for', () => {
333
+ expect(request(makeCtx({ args: { first: 5000 } })).limit).toBe(5000)
334
+ })
335
+
336
+ // The user-visible bug: one matching row must be one page, not three.
337
+ test('the matches of an exhausted window are a single closed page', () => {
338
+ const r = response(makeCtx({
339
+ args: { first: 50 },
340
+ result: { items: [{ id: 'mine' }], nextToken: null },
341
+ }))
342
+ expect(r.edges).toHaveLength(1)
343
+ expect(r.pageInfo.hasNextPage).toBe(false)
344
+ })
345
+
346
+ test('a window holding more matches than the page pages within itself', () => {
347
+ const items = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }, { id: 'e' }]
348
+ const page1 = response(makeCtx({
349
+ args: { first: 2 },
350
+ result: { items, nextToken: 'W2' },
351
+ }))
352
+ expect(page1.edges.map(e => e.node.id)).toEqual(['a', 'b'])
353
+ expect(page1.pageInfo.hasNextPage).toBe(true)
354
+
355
+ // Same window, resumed past the rows already served — not the next window.
356
+ const req2 = request(makeCtx({ args: { first: 2, after: page1.pageInfo.endCursor } }))
357
+ expect(req2.nextToken).toBeNull()
358
+ const page2 = response(makeCtx({
359
+ args: { first: 2, after: page1.pageInfo.endCursor },
360
+ result: { items, nextToken: 'W2' },
361
+ }))
362
+ expect(page2.edges.map(e => e.node.id)).toEqual(['c', 'd'])
363
+
364
+ // The row that closes the window hands the next request the window after it.
365
+ const page3 = response(makeCtx({
366
+ args: { first: 2, after: page2.pageInfo.endCursor },
367
+ result: { items, nextToken: 'W2' },
368
+ }))
369
+ expect(page3.edges.map(e => e.node.id)).toEqual(['e'])
370
+ expect(page3.pageInfo.hasNextPage).toBe(true)
371
+ expect(
372
+ request(makeCtx({ args: { first: 2, after: page3.pageInfo.endCursor } })).nextToken,
373
+ ).toBe('W2')
374
+ })
375
+
376
+ // Without this the final row of a window resumes into a window with nothing
377
+ // left in it, which is the blank page again one step later.
378
+ test('the last row of a page that closes its window names the next window', () => {
379
+ const r = response(makeCtx({
380
+ args: { first: 2 },
381
+ result: { items: [{ id: 'a' }, { id: 'b' }], nextToken: 'W2' },
382
+ }))
383
+ expect(JSON.parse(util.base64Decode(r.edges[1].cursor))).toEqual({ t: 'W2', n: -1 })
384
+ expect(JSON.parse(util.base64Decode(r.edges[0].cursor))).toEqual({ t: null, n: 0 })
250
385
  })
251
386
  })
252
387
 
@@ -669,11 +804,9 @@ describe('authorizeIndexedAccess', () => {
669
804
  // ---------------------------------------------------------------------------
670
805
  // listAllItemsConnection — @owner scoping
671
806
  // ---------------------------------------------------------------------------
672
- // This is the one read path with no Lambda in it: the predicate lives in
673
- // generated JS that AppSync runs directly, so it shares no code with the three
674
- // paths the ReScript suites cover. Evaluating the emitted source here is the
675
- // strongest check available short of a deployed stack, and the only one that
676
- // can catch an APPSYNC_JS-hostile construct or a mis-ordered branch.
807
+ // The one read path with no Lambda in it the predicate is generated JS that
808
+ // AppSync runs directly, sharing no code with the paths the ReScript suites
809
+ // cover. Evaluating the emitted source is the strongest check short of a deploy.
677
810
  describe('listAllItemsConnection — owner scoping', () => {
678
811
  // (labelField, filterFields, rangeFields, sortFields, requireAttribute,
679
812
  // ownerField, elevatedGroups)
@@ -757,16 +890,225 @@ describe('listAllItemsConnection — owner scoping', () => {
757
890
  const r = request(makeCtx({ args: {}, identity: asUser('ops-1', ['Admin']) }))
758
891
  expect(r.filter.expressionValues[':owner']).toEqual({ S: 'ops-1' })
759
892
  })
893
+
894
+ // ── The same identities, once the scope has an index to read ──────────────
895
+ //
896
+ // The predicate moves from the FilterExpression to a key condition, so who is
897
+ // narrowed and who is not now selects a different PHYSICAL READ. The defect
898
+ // class this guards against was invisible precisely because each path was
899
+ // individually correct — so the identity table runs over both, rather than a
900
+ // parallel suite growing beside it.
901
+ //
902
+ // (labelField, filterFields, rangeFields, sortFields, requireAttribute,
903
+ // ownerField, elevatedGroups, retiredField, retiredValues,
904
+ // ownerIndex, ownerIndexSortField)
905
+ const indexed = () =>
906
+ evalResolver(
907
+ F.listAllItemsConnection(
908
+ 'name', ['status'], [], [], undefined, 'customerId', ['Admin'],
909
+ undefined, undefined, '_owner', 'id',
910
+ ),
911
+ )
912
+
913
+ const identities = [
914
+ ['a plain caller', asUser('cust-a'), 'Query'],
915
+ ['a caller in an elevated group', asUser('ops-1', ['Admin']), 'Scan'],
916
+ ['an IAM-shaped identity with no sub', { userArn: 'arn:aws:sts::1:x', username: 'Ing' }, 'Scan'],
917
+ ['a wholly absent identity', null, 'Scan'],
918
+ ]
919
+
920
+ test.each(identities)('%s reads through the operation its scope implies', (_, identity, op) => {
921
+ const { request } = indexed()
922
+ expect(request(makeCtx({ args: {}, identity })).operation).toBe(op)
923
+ })
924
+
925
+ test('a scoped caller is keyed on their own id, not filtered on it', () => {
926
+ const { request } = indexed()
927
+ const r = request(makeCtx({ args: {}, identity: asUser('cust-a') }))
928
+ expect(r.index).toBe('_owner')
929
+ expect(r.query.expression).toBe('#owner = :owner')
930
+ expect(r.query.expressionNames['#owner']).toBe('customerId')
931
+ expect(r.query.expressionValues[':owner']).toEqual({ S: 'cust-a' })
932
+ // The whole point: no owner clause survives anywhere in the filter. A copy
933
+ // left there would be a second, redundant predicate — and an expressionName
934
+ // the filter no longer references is a ValidationException.
935
+ expect(r.filter).toBeUndefined()
936
+ })
937
+
938
+ // The elevated read is the one that must not have changed: it is the same
939
+ // request the unindexed resolver builds, argument for argument.
940
+ test.each([
941
+ ['an elevated caller', asUser('ops-1', ['Admin'])],
942
+ ['an IAM service caller', { userArn: 'arn:aws:sts::1:x', username: 'Ing' }],
943
+ ])("%s's read is unchanged by the index existing", (_, identity) => {
944
+ const before = evalResolver(
945
+ F.listAllItemsConnection('name', ['status'], [], [], undefined, 'customerId', ['Admin']),
946
+ ).request(makeCtx({ args: { first: 20, filter: { statusEq: 'Placed' } }, identity }))
947
+ const after = indexed().request(
948
+ makeCtx({ args: { first: 20, filter: { statusEq: 'Placed' } }, identity }),
949
+ )
950
+ expect(after).toEqual(before)
951
+ })
952
+
953
+ // The read window exists because a FilterExpression cuts rows AFTER `Limit`.
954
+ // A key condition does not, so a scoped caller with no filter of their own
955
+ // examines exactly the page they asked for — the arithmetic the window was
956
+ // hiding, gone rather than papered over.
957
+ test('a scoped caller with no filter examines exactly the page', () => {
958
+ const { request } = indexed()
959
+ expect(request(makeCtx({ args: { first: 25 }, identity: asUser('cust-a') })).limit).toBe(25)
960
+ })
961
+
962
+ test("a scoped caller's own filter still widens the window", () => {
963
+ const { request } = indexed()
964
+ const r = request(
965
+ makeCtx({ args: { first: 25, filter: { statusEq: 'Placed' } }, identity: asUser('cust-a') }),
966
+ )
967
+ expect(r.limit).toBe(1000)
968
+ expect(r.filter.expression).toBe('#status = :statusEq')
969
+ })
970
+
971
+ // Both branches are reachable by the SAME caller across requests — an active
972
+ // role switch mid-pagination flips `_exempt`. A `nextToken` continues the
973
+ // operation that minted it, so replaying one on the other branch would answer
974
+ // a different question in silence.
975
+ describe('the cursor path tag', () => {
976
+ const { request, response } = indexed()
977
+ const pageFor = identity =>
978
+ response(makeCtx({
979
+ args: {},
980
+ identity,
981
+ result: { items: [{ id: 'a' }], nextToken: 'TOK' },
982
+ })).pageInfo.endCursor
983
+
984
+ test('a Scan cursor replayed on the Query branch is refused', () => {
985
+ const scanCursor = pageFor(asUser('ops-1', ['Admin']))
986
+ expect(() =>
987
+ request(makeCtx({ args: { after: scanCursor }, identity: asUser('cust-a') })),
988
+ ).toThrow('different read of this list')
989
+ })
990
+
991
+ test('a Query cursor replayed on the Scan branch is refused', () => {
992
+ const queryCursor = pageFor(asUser('cust-a'))
993
+ expect(() =>
994
+ request(makeCtx({ args: { after: queryCursor }, identity: asUser('ops-1', ['Admin']) })),
995
+ ).toThrow('different read of this list')
996
+ })
997
+
998
+ test('each branch resumes its own cursor', () => {
999
+ expect(
1000
+ request(makeCtx({ args: { after: pageFor(asUser('cust-a')) }, identity: asUser('cust-a') }))
1001
+ .nextToken,
1002
+ ).toBe('TOK')
1003
+ const ops = asUser('ops-1', ['Admin'])
1004
+ expect(request(makeCtx({ args: { after: pageFor(ops) }, identity: ops })).nextToken).toBe('TOK')
1005
+ })
1006
+
1007
+ // Cursors minted before the tag existed all came off a Scan, and the callers
1008
+ // holding one are the exempt callers whose read did not change.
1009
+ test('an untagged cursor still resumes the Scan branch', () => {
1010
+ const legacy = util.base64Encode(JSON.stringify({ t: 'TOK9', n: 2 }))
1011
+ const ops = asUser('ops-1', ['Admin'])
1012
+ expect(request(makeCtx({ args: { after: legacy }, identity: ops })).nextToken).toBe('TOK9')
1013
+ })
1014
+ })
1015
+
1016
+ // Ordering on the index's own sort key is `ScanIndexForward`'s job across the
1017
+ // caller's whole partition. Re-sorting the page in JS afterwards is the one
1018
+ // way to BREAK that order — a sort over a page is not a sort over the rows.
1019
+ describe('orderBy on the index sort key', () => {
1020
+ const sorted = () =>
1021
+ evalResolver(
1022
+ F.listAllItemsConnection(
1023
+ 'name', [], [], ['id', 'placedAt'], undefined, 'customerId', ['Admin'],
1024
+ undefined, undefined, '_owner', 'id',
1025
+ ),
1026
+ )
1027
+ const items = [{ id: 'c' }, { id: 'a' }, { id: 'b' }]
1028
+ const scoped = asUser('cust-a')
1029
+
1030
+ test('DESC flips scanIndexForward instead of reversing a page', () => {
1031
+ const { request } = sorted()
1032
+ const args = { orderBy: { field: 'id', direction: 'DESC' } }
1033
+ expect(request(makeCtx({ args, identity: scoped })).scanIndexForward).toBe(false)
1034
+ expect(
1035
+ request(makeCtx({ args: { orderBy: { field: 'id', direction: 'ASC' } }, identity: scoped }))
1036
+ .scanIndexForward,
1037
+ ).toBe(true)
1038
+ })
1039
+
1040
+ test('the page DynamoDB ordered is handed back untouched', () => {
1041
+ const { response } = sorted()
1042
+ const r = response(makeCtx({
1043
+ args: { orderBy: { field: 'id', direction: 'ASC' } },
1044
+ identity: scoped,
1045
+ result: { items, nextToken: null },
1046
+ }))
1047
+ expect(r.edges.map(e => e.node.id)).toEqual(['c', 'a', 'b'])
1048
+ })
1049
+
1050
+ test('any other sort field still sorts in JS', () => {
1051
+ const { response } = sorted()
1052
+ const r = response(makeCtx({
1053
+ args: { orderBy: { field: 'placedAt', direction: 'ASC' } },
1054
+ identity: scoped,
1055
+ result: { items: [{ placedAt: 'z' }, { placedAt: 'a' }], nextToken: null },
1056
+ }))
1057
+ expect(r.edges.map(e => e.node.placedAt)).toEqual(['a', 'z'])
1058
+ })
1059
+
1060
+ // The exempt caller Scans, which has no ordering of its own, so the per-page
1061
+ // sort is still the only order they get.
1062
+ test('an exempt caller keeps the per-page sort on the same field', () => {
1063
+ const { response } = sorted()
1064
+ const r = response(makeCtx({
1065
+ args: { orderBy: { field: 'id', direction: 'ASC' } },
1066
+ identity: asUser('ops-1', ['Admin']),
1067
+ result: { items, nextToken: null },
1068
+ }))
1069
+ expect(r.edges.map(e => e.node.id)).toEqual(['a', 'b', 'c'])
1070
+ })
1071
+ })
1072
+
1073
+ // Backward paging works the same way on both branches, and for the same reason:
1074
+ // it re-reads the window the cursor names and cuts the page ending at it, which
1075
+ // is operation-agnostic. `last` stays refused on both — it needs the end of the
1076
+ // list, which neither a Scan nor this Query's cursor can reach.
1077
+ test.each([['scoped', asUser('cust-a')], ['exempt', asUser('ops-1', ['Admin'])]])(
1078
+ 'a %s caller is still refused `last`',
1079
+ (_, identity) => {
1080
+ const { request } = indexed()
1081
+ expect(() => request(makeCtx({ args: { last: 5 }, identity }))).toThrow(
1082
+ 'last is not supported',
1083
+ )
1084
+ },
1085
+ )
1086
+
1087
+ // The path tag has to survive the direction change: a backward cursor still
1088
+ // names the read that minted it, and replaying it on the other branch is the
1089
+ // same mistake as replaying a forward one.
1090
+ test('a backward cursor is path-checked like a forward one', () => {
1091
+ const { request, response } = indexed()
1092
+ const scopedPage = response(makeCtx({
1093
+ args: { first: 10 },
1094
+ identity: asUser('cust-a'),
1095
+ result: { items: Array.from({ length: 10 }, (_, i) => ({ id: 'r' + i })), nextToken: null },
1096
+ }))
1097
+ expect(() =>
1098
+ request(makeCtx({
1099
+ args: { first: 10, before: scopedPage.pageInfo.endCursor },
1100
+ identity: asUser('ops-1', ['Admin']),
1101
+ })),
1102
+ ).toThrow('different read of this list')
1103
+ })
760
1104
  })
761
1105
 
762
1106
  // ---------------------------------------------------------------------------
763
1107
  // By-key reads — owner scoping
764
1108
  // ---------------------------------------------------------------------------
765
- // The list resolver above carried the predicate while these did not, so a caller
766
- // narrowed to their own rows could still read any row they could name. These
767
- // cases pin the guard in the place it has to live: the response, since a GetItem
768
- // has no FilterExpression, and null rather than an error, which is what the
769
- // in-process platform answers and what does not confirm the row exists.
1109
+ // The list resolver carried the predicate while these did not, so a caller
1110
+ // narrowed to their own rows could still read any row they could name. The guard
1111
+ // belongs in the response GetItem has no FilterExpression and answers null.
770
1112
  describe('getItemById / queryByIdSort — owner scoping', () => {
771
1113
  const asUser = (sub, groups = []) => ({
772
1114
  username: sub,