@reventlessdev/rescript-pulumi-aws 3.0.0-alpha.3 → 3.0.0-alpha.4

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.
@@ -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: {},
@@ -210,16 +210,17 @@ describe('listAllItemsConnection', () => {
210
210
  })
211
211
 
212
212
  test('final page (nextToken null) closes the connection', () => {
213
+ const after = util.base64Encode(JSON.stringify({ t: 'TOK1', n: -1 }))
213
214
  const r = response(makeCtx({
214
- args: { after: 'x' },
215
+ args: { after },
215
216
  result: { items: [{ id: 'z' }], nextToken: null },
216
217
  }))
217
218
  expect(r.pageInfo.hasNextPage).toBe(false)
218
219
  expect(r.pageInfo.hasPreviousPage).toBe(true)
219
220
  })
220
221
 
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.
222
+ // A filtered Scan can return an empty page while nextToken is still set. The
223
+ // boundary cursor must let the client resume instead of restarting page 1.
223
224
  test('empty-but-continuable page yields a resumable boundary cursor', () => {
224
225
  const empty = response(makeCtx({
225
226
  args: {},
@@ -238,7 +239,7 @@ describe('listAllItemsConnection', () => {
238
239
  expect(r.pageInfo.hasNextPage).toBe(false)
239
240
  })
240
241
 
241
- // Fix 2: Scan cannot page backward; last/before must error, not mislead.
242
+ // Scan cannot page backward; last/before must error, not mislead.
242
243
  test('request rejects backward pagination (before)', () => {
243
244
  expect(() => request(makeCtx({ args: { before: 'x' } }))).toThrow(
244
245
  'Backward pagination',
@@ -248,6 +249,95 @@ describe('listAllItemsConnection', () => {
248
249
  test('request rejects backward pagination (last)', () => {
249
250
  expect(() => request(makeCtx({ args: { last: 5 } }))).toThrow('Backward pagination')
250
251
  })
252
+
253
+ // Cursors minted before the read window existed named the following window.
254
+ test('a pre-window { token, index } cursor still resumes', () => {
255
+ const legacy = util.base64Encode(JSON.stringify({ token: 'TOK9', index: 3 }))
256
+ const req = request(makeCtx({ args: { after: legacy } }))
257
+ expect(req.nextToken).toBe('TOK9')
258
+ const r = response(makeCtx({
259
+ args: { after: legacy },
260
+ result: { items: [{ id: 'a' }], nextToken: null },
261
+ }))
262
+ expect(r.edges).toHaveLength(1)
263
+ })
264
+ })
265
+
266
+ // The defect a selective filter turns into a visibly broken list: DynamoDB
267
+ // applies Limit before the FilterExpression, so reading `first` rows and
268
+ // returning the survivors spreads one matching row over several pages, most of
269
+ // them blank under a live Next button.
270
+ describe('reads a window, serves a page', () => {
271
+ const { request, response } = evalResolver(
272
+ F.listAllItemsConnection('name', [], [], [], undefined, 'customerId', ['Admin']),
273
+ )
274
+
275
+ test('an unfiltered read examines exactly the page it serves', () => {
276
+ const req = request(makeCtx({ args: { first: 25 }, identity: null }))
277
+ expect(req.filter).toBeUndefined()
278
+ expect(req.limit).toBe(25)
279
+ })
280
+
281
+ test('a filtered read examines a wider window than the page', () => {
282
+ const req = request(makeCtx({ args: { first: 25 } }))
283
+ expect(req.filter.expression).toBe('#owner = :owner')
284
+ expect(req.limit).toBe(1000)
285
+ })
286
+
287
+ test('a caller asking for more than the window gets what it asked for', () => {
288
+ expect(request(makeCtx({ args: { first: 5000 } })).limit).toBe(5000)
289
+ })
290
+
291
+ // The user-visible bug: one matching row must be one page, not three.
292
+ test('the matches of an exhausted window are a single closed page', () => {
293
+ const r = response(makeCtx({
294
+ args: { first: 50 },
295
+ result: { items: [{ id: 'mine' }], nextToken: null },
296
+ }))
297
+ expect(r.edges).toHaveLength(1)
298
+ expect(r.pageInfo.hasNextPage).toBe(false)
299
+ })
300
+
301
+ test('a window holding more matches than the page pages within itself', () => {
302
+ const items = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }, { id: 'e' }]
303
+ const page1 = response(makeCtx({
304
+ args: { first: 2 },
305
+ result: { items, nextToken: 'W2' },
306
+ }))
307
+ expect(page1.edges.map(e => e.node.id)).toEqual(['a', 'b'])
308
+ expect(page1.pageInfo.hasNextPage).toBe(true)
309
+
310
+ // Same window, resumed past the rows already served — not the next window.
311
+ const req2 = request(makeCtx({ args: { first: 2, after: page1.pageInfo.endCursor } }))
312
+ expect(req2.nextToken).toBeNull()
313
+ const page2 = response(makeCtx({
314
+ args: { first: 2, after: page1.pageInfo.endCursor },
315
+ result: { items, nextToken: 'W2' },
316
+ }))
317
+ expect(page2.edges.map(e => e.node.id)).toEqual(['c', 'd'])
318
+
319
+ // The row that closes the window hands the next request the window after it.
320
+ const page3 = response(makeCtx({
321
+ args: { first: 2, after: page2.pageInfo.endCursor },
322
+ result: { items, nextToken: 'W2' },
323
+ }))
324
+ expect(page3.edges.map(e => e.node.id)).toEqual(['e'])
325
+ expect(page3.pageInfo.hasNextPage).toBe(true)
326
+ expect(
327
+ request(makeCtx({ args: { first: 2, after: page3.pageInfo.endCursor } })).nextToken,
328
+ ).toBe('W2')
329
+ })
330
+
331
+ // Without this the final row of a window resumes into a window with nothing
332
+ // left in it, which is the blank page again one step later.
333
+ test('the last row of a page that closes its window names the next window', () => {
334
+ const r = response(makeCtx({
335
+ args: { first: 2 },
336
+ result: { items: [{ id: 'a' }, { id: 'b' }], nextToken: 'W2' },
337
+ }))
338
+ expect(JSON.parse(util.base64Decode(r.edges[1].cursor))).toEqual({ t: 'W2', n: -1 })
339
+ expect(JSON.parse(util.base64Decode(r.edges[0].cursor))).toEqual({ t: null, n: 0 })
340
+ })
251
341
  })
252
342
 
253
343
  describe('with filterFields (per-field Eq)', () => {
@@ -669,11 +759,9 @@ describe('authorizeIndexedAccess', () => {
669
759
  // ---------------------------------------------------------------------------
670
760
  // listAllItemsConnection — @owner scoping
671
761
  // ---------------------------------------------------------------------------
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.
762
+ // The one read path with no Lambda in it the predicate is generated JS that
763
+ // AppSync runs directly, sharing no code with the paths the ReScript suites
764
+ // cover. Evaluating the emitted source is the strongest check short of a deploy.
677
765
  describe('listAllItemsConnection — owner scoping', () => {
678
766
  // (labelField, filterFields, rangeFields, sortFields, requireAttribute,
679
767
  // ownerField, elevatedGroups)
@@ -757,16 +845,204 @@ describe('listAllItemsConnection — owner scoping', () => {
757
845
  const r = request(makeCtx({ args: {}, identity: asUser('ops-1', ['Admin']) }))
758
846
  expect(r.filter.expressionValues[':owner']).toEqual({ S: 'ops-1' })
759
847
  })
848
+
849
+ // ── The same identities, once the scope has an index to read ──────────────
850
+ //
851
+ // The predicate moves from the FilterExpression to a key condition, so who is
852
+ // narrowed and who is not now selects a different PHYSICAL READ. The defect
853
+ // class this guards against was invisible precisely because each path was
854
+ // individually correct — so the identity table runs over both, rather than a
855
+ // parallel suite growing beside it.
856
+ //
857
+ // (labelField, filterFields, rangeFields, sortFields, requireAttribute,
858
+ // ownerField, elevatedGroups, retiredField, retiredValues,
859
+ // ownerIndex, ownerIndexSortField)
860
+ const indexed = () =>
861
+ evalResolver(
862
+ F.listAllItemsConnection(
863
+ 'name', ['status'], [], [], undefined, 'customerId', ['Admin'],
864
+ undefined, undefined, '_owner', 'id',
865
+ ),
866
+ )
867
+
868
+ const identities = [
869
+ ['a plain caller', asUser('cust-a'), 'Query'],
870
+ ['a caller in an elevated group', asUser('ops-1', ['Admin']), 'Scan'],
871
+ ['an IAM-shaped identity with no sub', { userArn: 'arn:aws:sts::1:x', username: 'Ing' }, 'Scan'],
872
+ ['a wholly absent identity', null, 'Scan'],
873
+ ]
874
+
875
+ test.each(identities)('%s reads through the operation its scope implies', (_, identity, op) => {
876
+ const { request } = indexed()
877
+ expect(request(makeCtx({ args: {}, identity })).operation).toBe(op)
878
+ })
879
+
880
+ test('a scoped caller is keyed on their own id, not filtered on it', () => {
881
+ const { request } = indexed()
882
+ const r = request(makeCtx({ args: {}, identity: asUser('cust-a') }))
883
+ expect(r.index).toBe('_owner')
884
+ expect(r.query.expression).toBe('#owner = :owner')
885
+ expect(r.query.expressionNames['#owner']).toBe('customerId')
886
+ expect(r.query.expressionValues[':owner']).toEqual({ S: 'cust-a' })
887
+ // The whole point: no owner clause survives anywhere in the filter. A copy
888
+ // left there would be a second, redundant predicate — and an expressionName
889
+ // the filter no longer references is a ValidationException.
890
+ expect(r.filter).toBeUndefined()
891
+ })
892
+
893
+ // The elevated read is the one that must not have changed: it is the same
894
+ // request the unindexed resolver builds, argument for argument.
895
+ test.each([
896
+ ['an elevated caller', asUser('ops-1', ['Admin'])],
897
+ ['an IAM service caller', { userArn: 'arn:aws:sts::1:x', username: 'Ing' }],
898
+ ])("%s's read is unchanged by the index existing", (_, identity) => {
899
+ const before = evalResolver(
900
+ F.listAllItemsConnection('name', ['status'], [], [], undefined, 'customerId', ['Admin']),
901
+ ).request(makeCtx({ args: { first: 20, filter: { statusEq: 'Placed' } }, identity }))
902
+ const after = indexed().request(
903
+ makeCtx({ args: { first: 20, filter: { statusEq: 'Placed' } }, identity }),
904
+ )
905
+ expect(after).toEqual(before)
906
+ })
907
+
908
+ // The read window exists because a FilterExpression cuts rows AFTER `Limit`.
909
+ // A key condition does not, so a scoped caller with no filter of their own
910
+ // examines exactly the page they asked for — the arithmetic the window was
911
+ // hiding, gone rather than papered over.
912
+ test('a scoped caller with no filter examines exactly the page', () => {
913
+ const { request } = indexed()
914
+ expect(request(makeCtx({ args: { first: 25 }, identity: asUser('cust-a') })).limit).toBe(25)
915
+ })
916
+
917
+ test("a scoped caller's own filter still widens the window", () => {
918
+ const { request } = indexed()
919
+ const r = request(
920
+ makeCtx({ args: { first: 25, filter: { statusEq: 'Placed' } }, identity: asUser('cust-a') }),
921
+ )
922
+ expect(r.limit).toBe(1000)
923
+ expect(r.filter.expression).toBe('#status = :statusEq')
924
+ })
925
+
926
+ // Both branches are reachable by the SAME caller across requests — an active
927
+ // role switch mid-pagination flips `_exempt`. A `nextToken` continues the
928
+ // operation that minted it, so replaying one on the other branch would answer
929
+ // a different question in silence.
930
+ describe('the cursor path tag', () => {
931
+ const { request, response } = indexed()
932
+ const pageFor = identity =>
933
+ response(makeCtx({
934
+ args: {},
935
+ identity,
936
+ result: { items: [{ id: 'a' }], nextToken: 'TOK' },
937
+ })).pageInfo.endCursor
938
+
939
+ test('a Scan cursor replayed on the Query branch is refused', () => {
940
+ const scanCursor = pageFor(asUser('ops-1', ['Admin']))
941
+ expect(() =>
942
+ request(makeCtx({ args: { after: scanCursor }, identity: asUser('cust-a') })),
943
+ ).toThrow('different read of this list')
944
+ })
945
+
946
+ test('a Query cursor replayed on the Scan branch is refused', () => {
947
+ const queryCursor = pageFor(asUser('cust-a'))
948
+ expect(() =>
949
+ request(makeCtx({ args: { after: queryCursor }, identity: asUser('ops-1', ['Admin']) })),
950
+ ).toThrow('different read of this list')
951
+ })
952
+
953
+ test('each branch resumes its own cursor', () => {
954
+ expect(
955
+ request(makeCtx({ args: { after: pageFor(asUser('cust-a')) }, identity: asUser('cust-a') }))
956
+ .nextToken,
957
+ ).toBe('TOK')
958
+ const ops = asUser('ops-1', ['Admin'])
959
+ expect(request(makeCtx({ args: { after: pageFor(ops) }, identity: ops })).nextToken).toBe('TOK')
960
+ })
961
+
962
+ // Cursors minted before the tag existed all came off a Scan, and the callers
963
+ // holding one are the exempt callers whose read did not change.
964
+ test('an untagged cursor still resumes the Scan branch', () => {
965
+ const legacy = util.base64Encode(JSON.stringify({ t: 'TOK9', n: 2 }))
966
+ const ops = asUser('ops-1', ['Admin'])
967
+ expect(request(makeCtx({ args: { after: legacy }, identity: ops })).nextToken).toBe('TOK9')
968
+ })
969
+ })
970
+
971
+ // Ordering on the index's own sort key is `ScanIndexForward`'s job across the
972
+ // caller's whole partition. Re-sorting the page in JS afterwards is the one
973
+ // way to BREAK that order — a sort over a page is not a sort over the rows.
974
+ describe('orderBy on the index sort key', () => {
975
+ const sorted = () =>
976
+ evalResolver(
977
+ F.listAllItemsConnection(
978
+ 'name', [], [], ['id', 'placedAt'], undefined, 'customerId', ['Admin'],
979
+ undefined, undefined, '_owner', 'id',
980
+ ),
981
+ )
982
+ const items = [{ id: 'c' }, { id: 'a' }, { id: 'b' }]
983
+ const scoped = asUser('cust-a')
984
+
985
+ test('DESC flips scanIndexForward instead of reversing a page', () => {
986
+ const { request } = sorted()
987
+ const args = { orderBy: { field: 'id', direction: 'DESC' } }
988
+ expect(request(makeCtx({ args, identity: scoped })).scanIndexForward).toBe(false)
989
+ expect(
990
+ request(makeCtx({ args: { orderBy: { field: 'id', direction: 'ASC' } }, identity: scoped }))
991
+ .scanIndexForward,
992
+ ).toBe(true)
993
+ })
994
+
995
+ test('the page DynamoDB ordered is handed back untouched', () => {
996
+ const { response } = sorted()
997
+ const r = response(makeCtx({
998
+ args: { orderBy: { field: 'id', direction: 'ASC' } },
999
+ identity: scoped,
1000
+ result: { items, nextToken: null },
1001
+ }))
1002
+ expect(r.edges.map(e => e.node.id)).toEqual(['c', 'a', 'b'])
1003
+ })
1004
+
1005
+ test('any other sort field still sorts in JS', () => {
1006
+ const { response } = sorted()
1007
+ const r = response(makeCtx({
1008
+ args: { orderBy: { field: 'placedAt', direction: 'ASC' } },
1009
+ identity: scoped,
1010
+ result: { items: [{ placedAt: 'z' }, { placedAt: 'a' }], nextToken: null },
1011
+ }))
1012
+ expect(r.edges.map(e => e.node.placedAt)).toEqual(['a', 'z'])
1013
+ })
1014
+
1015
+ // The exempt caller Scans, which has no ordering of its own, so the per-page
1016
+ // sort is still the only order they get.
1017
+ test('an exempt caller keeps the per-page sort on the same field', () => {
1018
+ const { response } = sorted()
1019
+ const r = response(makeCtx({
1020
+ args: { orderBy: { field: 'id', direction: 'ASC' } },
1021
+ identity: asUser('ops-1', ['Admin']),
1022
+ result: { items, nextToken: null },
1023
+ }))
1024
+ expect(r.edges.map(e => e.node.id)).toEqual(['a', 'b', 'c'])
1025
+ })
1026
+ })
1027
+
1028
+ // Backward paging stays refused on both branches: a Query can walk backward,
1029
+ // but the `{t, n}` cursor is DynamoDB's forward-only continuation token, and a
1030
+ // door must not advertise what its cursor cannot honour.
1031
+ test.each([['scoped', asUser('cust-a')], ['exempt', asUser('ops-1', ['Admin'])]])(
1032
+ 'a %s caller is still refused backward paging',
1033
+ (_, identity) => {
1034
+ const { request } = indexed()
1035
+ expect(() => request(makeCtx({ args: { last: 5 }, identity }))).toThrow('Backward pagination')
1036
+ },
1037
+ )
760
1038
  })
761
1039
 
762
1040
  // ---------------------------------------------------------------------------
763
1041
  // By-key reads — owner scoping
764
1042
  // ---------------------------------------------------------------------------
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.
1043
+ // The list resolver carried the predicate while these did not, so a caller
1044
+ // narrowed to their own rows could still read any row they could name. The guard
1045
+ // belongs in the response GetItem has no FilterExpression and answers null.
770
1046
  describe('getItemById / queryByIdSort — owner scoping', () => {
771
1047
  const asUser = (sub, groups = []) => ({
772
1048
  username: sub,