@reventlessdev/rescript-pulumi-aws 3.0.0-alpha.0 → 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.
- package/CHANGELOG.md +84 -0
- package/package.json +4 -4
- package/src/AppSync/AppSync_Resolver_Functions.res +459 -268
- package/src/AppSync/AppSync_Resolver_Functions.res.mjs +234 -112
- package/src/Cloudwatch/Cloudwatch_EventRule.res +4 -0
- package/src/Cloudwatch/Cloudwatch_EventTarget.res +11 -0
- package/src/DynamoDb/DynamoDb_Table.res +26 -1
- package/src/DynamoDb/DynamoDb_Table.res.mjs +8 -1
- package/src/example/AlarmMailExample.res +34 -0
- package/src/example/AlarmMailExample.res.mjs +44 -0
- package/tests/AppSync_Resolver_FunctionsTest.mjs +368 -26
|
@@ -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 —
|
|
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
|
-
//
|
|
197
|
-
|
|
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
|
-
//
|
|
201
|
-
//
|
|
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
|
|
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(
|
|
224
|
+
expect(r.pageInfo.hasPreviousPage).toBe(false)
|
|
219
225
|
})
|
|
220
226
|
|
|
221
|
-
//
|
|
222
|
-
//
|
|
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
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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
|
-
//
|
|
673
|
-
//
|
|
674
|
-
//
|
|
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
|
|
766
|
-
// narrowed to their own rows could still read any row they could name.
|
|
767
|
-
//
|
|
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,
|