@reventlessdev/rescript-pulumi-aws 3.0.0-alpha.3 → 3.0.0-alpha.30
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 +224 -0
- package/package.json +8 -7
- package/rescript.json +2 -1
- package/src/AppSync/AppSync_ResolverRuntime.res +4 -5
- package/src/AppSync/AppSync_Resolver_Functions.res +524 -396
- package/src/AppSync/AppSync_Resolver_Functions.res.mjs +206 -130
- package/src/AppSync/AppSync_SourceApiAssociation.res +1 -3
- package/src/AwsNative/AppSync/AwsNative_AppSync_Api.res +2 -3
- package/src/AwsNative/AppSync/AwsNative_AppSync_ChannelNamespace.res +2 -6
- package/src/AwsNative/AppSync/AwsNative_AppSync_Resolver.res +2 -5
- package/src/Cloudwatch/Cloudwatch_EventRule.res +4 -0
- package/src/Cloudwatch/Cloudwatch_EventTarget.res +11 -0
- package/src/Cloudwatch/Cloudwatch_LogMetricFilter.res +0 -1
- package/src/Cloudwatch/Cloudwatch_MetricAlarm.res +0 -1
- package/src/Cognito/Cognito_UserPool.res +2 -4
- package/src/IAM/IAM.res +1 -1
- package/src/IAM/PolicyDocument.res +2 -2
- package/src/IAM/PolicyDocument.res.mjs +3 -3
- package/src/Kinesis/Kinesis_Stream.res +3 -6
- package/src/Lambda/FunctionUrl.res +5 -2
- package/src/Lambda/Lambda.res +138 -52
- package/src/Lambda/Lambda.res.mjs +152 -23
- package/src/Rds/Rds_Cluster.res +0 -1
- package/src/Rds/Rds_Instance.res +0 -1
- package/src/Rds/Rds_Proxy.res +1 -2
- package/src/S3/S3_BucketV2.res +1 -3
- package/src/Scheduler/Scheduler.res +4 -0
- package/src/Scheduler/Scheduler.res.mjs +9 -0
- package/src/Scheduler/Scheduler_ScheduleGroup.res +14 -0
- package/src/Scheduler/Scheduler_ScheduleGroup.res.mjs +2 -0
- package/src/example/AlarmMailExample.res +34 -0
- package/src/example/AlarmMailExample.res.mjs +44 -0
- package/src/example/CustomDomainExample.res +2 -3
- package/src/example/MergedApiExample.res +1 -3
- package/tests/AppSync_Resolver_FunctionsTest.mjs +421 -28
|
@@ -18,9 +18,8 @@ let cert = Acm.Certificate.make(
|
|
|
18
18
|
~opts={provider: usEast1},
|
|
19
19
|
)
|
|
20
20
|
|
|
21
|
-
let firstValidationOption =
|
|
22
|
-
opts->Array.getUnsafe(0)
|
|
23
|
-
)
|
|
21
|
+
let firstValidationOption =
|
|
22
|
+
cert.domainValidationOptions->Pulumi.Output.apply(opts => opts->Array.getUnsafe(0))
|
|
24
23
|
|
|
25
24
|
let validationRecord = Route53.Record.make(
|
|
26
25
|
~name="example-cert-validation",
|
|
@@ -8,9 +8,7 @@
|
|
|
8
8
|
let mergedExecRole = IAM.Role.make(
|
|
9
9
|
~name="example-merged-exec-role",
|
|
10
10
|
~args={
|
|
11
|
-
assumeRolePolicy: Pulumi.Input.make(
|
|
12
|
-
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"appsync.amazonaws.com"},"Action":"sts:AssumeRole"}]}`,
|
|
13
|
-
),
|
|
11
|
+
assumeRolePolicy: Pulumi.Input.make(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"appsync.amazonaws.com"},"Action":"sts:AssumeRole"}]}`),
|
|
14
12
|
},
|
|
15
13
|
)
|
|
16
14
|
|
|
@@ -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)', () => {
|
|
@@ -158,7 +158,7 @@ describe('listAllItemsConnection', () => {
|
|
|
158
158
|
const ctx = makeCtx({ args: {} })
|
|
159
159
|
const result = request(ctx)
|
|
160
160
|
expect(result.operation).toBe('Scan')
|
|
161
|
-
expect(result.limit).toBe(
|
|
161
|
+
expect(result.limit).toBe(51)
|
|
162
162
|
expect(result.nextToken).toBeNull()
|
|
163
163
|
expect(result.filter).toBeUndefined()
|
|
164
164
|
})
|
|
@@ -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,173 @@ 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
|
+
// One row past the page: it is what says a next page exists.
|
|
321
|
+
test('an unfiltered read examines one row past the page it serves', () => {
|
|
322
|
+
const req = request(makeCtx({ args: { first: 25 }, identity: null }))
|
|
323
|
+
expect(req.filter).toBeUndefined()
|
|
324
|
+
expect(req.limit).toBe(26)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
test('a filtered read examines a wider window than the page', () => {
|
|
328
|
+
const req = request(makeCtx({ args: { first: 25 } }))
|
|
329
|
+
expect(req.filter.expression).toBe('#owner = :owner')
|
|
330
|
+
expect(req.limit).toBe(1000)
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
test('a caller asking for more than the window gets what it asked for', () => {
|
|
334
|
+
expect(request(makeCtx({ args: { first: 5000 } })).limit).toBe(5001)
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
// The user-visible bug: one matching row must be one page, not three.
|
|
338
|
+
test('the matches of an exhausted window are a single closed page', () => {
|
|
339
|
+
const r = response(makeCtx({
|
|
340
|
+
args: { first: 50 },
|
|
341
|
+
result: { items: [{ id: 'mine' }], nextToken: null },
|
|
342
|
+
}))
|
|
343
|
+
expect(r.edges).toHaveLength(1)
|
|
344
|
+
expect(r.pageInfo.hasNextPage).toBe(false)
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
test('a window holding more matches than the page pages within itself', () => {
|
|
348
|
+
const items = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }, { id: 'e' }]
|
|
349
|
+
const page1 = response(makeCtx({
|
|
350
|
+
args: { first: 2 },
|
|
351
|
+
result: { items, nextToken: 'W2' },
|
|
352
|
+
}))
|
|
353
|
+
expect(page1.edges.map(e => e.node.id)).toEqual(['a', 'b'])
|
|
354
|
+
expect(page1.pageInfo.hasNextPage).toBe(true)
|
|
355
|
+
|
|
356
|
+
// Same window, resumed past the rows already served — not the next window.
|
|
357
|
+
const req2 = request(makeCtx({ args: { first: 2, after: page1.pageInfo.endCursor } }))
|
|
358
|
+
expect(req2.nextToken).toBeNull()
|
|
359
|
+
const page2 = response(makeCtx({
|
|
360
|
+
args: { first: 2, after: page1.pageInfo.endCursor },
|
|
361
|
+
result: { items, nextToken: 'W2' },
|
|
362
|
+
}))
|
|
363
|
+
expect(page2.edges.map(e => e.node.id)).toEqual(['c', 'd'])
|
|
364
|
+
|
|
365
|
+
// The row that closes the window hands the next request the window after it.
|
|
366
|
+
const page3 = response(makeCtx({
|
|
367
|
+
args: { first: 2, after: page2.pageInfo.endCursor },
|
|
368
|
+
result: { items, nextToken: 'W2' },
|
|
369
|
+
}))
|
|
370
|
+
expect(page3.edges.map(e => e.node.id)).toEqual(['e'])
|
|
371
|
+
expect(page3.pageInfo.hasNextPage).toBe(true)
|
|
372
|
+
expect(
|
|
373
|
+
request(makeCtx({ args: { first: 2, after: page3.pageInfo.endCursor } })).nextToken,
|
|
374
|
+
).toBe('W2')
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
// DynamoDB hands back a token whenever a read stops AT its limit, whether or
|
|
378
|
+
// not anything follows. A read of exactly the page therefore claimed a next
|
|
379
|
+
// page after the last full one, and it came back blank — found on a deployed
|
|
380
|
+
// list paged one row at a time. The mock pages a table the way DynamoDB does.
|
|
381
|
+
test('paging to the end serves no blank page, whatever the page size', () => {
|
|
382
|
+
const rows = ['a', 'b', 'c', 'd'].map(id => ({ id }))
|
|
383
|
+
const dynamo = req => {
|
|
384
|
+
const start = req.nextToken === null ? 0 : +req.nextToken
|
|
385
|
+
const items = rows.slice(start, start + req.limit)
|
|
386
|
+
const stoppedAtLimit = items.length === req.limit
|
|
387
|
+
return { items, nextToken: stoppedAtLimit ? String(start + req.limit) : null }
|
|
388
|
+
}
|
|
389
|
+
;[1, 2, 4].map(first => {
|
|
390
|
+
const pages = []
|
|
391
|
+
let after
|
|
392
|
+
for (let i = 0; i < 10; i++) {
|
|
393
|
+
const args = after === undefined ? { first } : { first, after }
|
|
394
|
+
const r = response(makeCtx({ args, result: dynamo(request(makeCtx({ args, identity: null }))) }))
|
|
395
|
+
pages.push(r.edges.map(e => e.node.id))
|
|
396
|
+
if (!r.pageInfo.hasNextPage) break
|
|
397
|
+
after = r.pageInfo.endCursor
|
|
398
|
+
}
|
|
399
|
+
expect(pages.flat()).toEqual(['a', 'b', 'c', 'd'])
|
|
400
|
+
expect(pages.every(p => p.length > 0)).toBe(true)
|
|
401
|
+
expect(pages).toHaveLength(Math.ceil(4 / first))
|
|
402
|
+
})
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
// Without this the final row of a window resumes into a window with nothing
|
|
406
|
+
// left in it, which is the blank page again one step later.
|
|
407
|
+
test('the last row of a page that closes its window names the next window', () => {
|
|
408
|
+
const r = response(makeCtx({
|
|
409
|
+
args: { first: 2 },
|
|
410
|
+
result: { items: [{ id: 'a' }, { id: 'b' }], nextToken: 'W2' },
|
|
411
|
+
}))
|
|
412
|
+
expect(JSON.parse(util.base64Decode(r.edges[1].cursor))).toEqual({ t: 'W2', n: -1 })
|
|
413
|
+
expect(JSON.parse(util.base64Decode(r.edges[0].cursor))).toEqual({ t: null, n: 0 })
|
|
250
414
|
})
|
|
251
415
|
})
|
|
252
416
|
|
|
@@ -452,7 +616,7 @@ describe('queryByIndexSortFiltered', () => {
|
|
|
452
616
|
expect(result.query.expression).toBe('#ownerId = :ownerId')
|
|
453
617
|
expect(result.filter).toBeUndefined()
|
|
454
618
|
expect(result.index).toBe('ownerId')
|
|
455
|
-
expect(result.limit).toBe(
|
|
619
|
+
expect(result.limit).toBe(51)
|
|
456
620
|
})
|
|
457
621
|
|
|
458
622
|
test('query includes sortField when present', () => {
|
|
@@ -622,6 +786,28 @@ describe('invokeInboundTranslation', () => {
|
|
|
622
786
|
expect(result.payload.fieldName).toBe('importProduct')
|
|
623
787
|
expect(result.payload.arguments).toEqual({ csv: 'data' })
|
|
624
788
|
})
|
|
789
|
+
|
|
790
|
+
// The Lambda checks each translated command against the caller's groups.
|
|
791
|
+
test('request carries the Cognito caller and its groups', () => {
|
|
792
|
+
const ctx = makeCtx({
|
|
793
|
+
identity: { username: 'alice', sub: 's1', sourceIp: [], claims: { 'cognito:groups': ['Merchandiser'] } },
|
|
794
|
+
})
|
|
795
|
+
const { payload } = request(ctx)
|
|
796
|
+
expect(payload.identity.userId).toBe('s1')
|
|
797
|
+
expect(payload.identity.groups).toEqual(['Merchandiser'])
|
|
798
|
+
expect(payload.identity.provider).toBe('Cognito')
|
|
799
|
+
})
|
|
800
|
+
|
|
801
|
+
test('request carries an IAM caller as IAM', () => {
|
|
802
|
+
const ctx = makeCtx({ identity: { userArn: 'arn:aws:iam::123:role/Sync', accountId: '123' } })
|
|
803
|
+
const { payload } = request(ctx)
|
|
804
|
+
expect(payload.identity.provider).toBe('IAM')
|
|
805
|
+
})
|
|
806
|
+
|
|
807
|
+
test('request emits null identity when ctx.identity is null', () => {
|
|
808
|
+
const { payload } = request(makeCtx({ identity: null }))
|
|
809
|
+
expect(payload.identity).toBeNull()
|
|
810
|
+
})
|
|
625
811
|
})
|
|
626
812
|
|
|
627
813
|
// ---------------------------------------------------------------------------
|
|
@@ -669,11 +855,9 @@ describe('authorizeIndexedAccess', () => {
|
|
|
669
855
|
// ---------------------------------------------------------------------------
|
|
670
856
|
// listAllItemsConnection — @owner scoping
|
|
671
857
|
// ---------------------------------------------------------------------------
|
|
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.
|
|
858
|
+
// The one read path with no Lambda in it — the predicate is generated JS that
|
|
859
|
+
// AppSync runs directly, sharing no code with the paths the ReScript suites
|
|
860
|
+
// cover. Evaluating the emitted source is the strongest check short of a deploy.
|
|
677
861
|
describe('listAllItemsConnection — owner scoping', () => {
|
|
678
862
|
// (labelField, filterFields, rangeFields, sortFields, requireAttribute,
|
|
679
863
|
// ownerField, elevatedGroups)
|
|
@@ -757,16 +941,225 @@ describe('listAllItemsConnection — owner scoping', () => {
|
|
|
757
941
|
const r = request(makeCtx({ args: {}, identity: asUser('ops-1', ['Admin']) }))
|
|
758
942
|
expect(r.filter.expressionValues[':owner']).toEqual({ S: 'ops-1' })
|
|
759
943
|
})
|
|
944
|
+
|
|
945
|
+
// ── The same identities, once the scope has an index to read ──────────────
|
|
946
|
+
//
|
|
947
|
+
// The predicate moves from the FilterExpression to a key condition, so who is
|
|
948
|
+
// narrowed and who is not now selects a different PHYSICAL READ. The defect
|
|
949
|
+
// class this guards against was invisible precisely because each path was
|
|
950
|
+
// individually correct — so the identity table runs over both, rather than a
|
|
951
|
+
// parallel suite growing beside it.
|
|
952
|
+
//
|
|
953
|
+
// (labelField, filterFields, rangeFields, sortFields, requireAttribute,
|
|
954
|
+
// ownerField, elevatedGroups, retiredField, retiredValues,
|
|
955
|
+
// ownerIndex, ownerIndexSortField)
|
|
956
|
+
const indexed = () =>
|
|
957
|
+
evalResolver(
|
|
958
|
+
F.listAllItemsConnection(
|
|
959
|
+
'name', ['status'], [], [], undefined, 'customerId', ['Admin'],
|
|
960
|
+
undefined, undefined, '_owner', 'id',
|
|
961
|
+
),
|
|
962
|
+
)
|
|
963
|
+
|
|
964
|
+
const identities = [
|
|
965
|
+
['a plain caller', asUser('cust-a'), 'Query'],
|
|
966
|
+
['a caller in an elevated group', asUser('ops-1', ['Admin']), 'Scan'],
|
|
967
|
+
['an IAM-shaped identity with no sub', { userArn: 'arn:aws:sts::1:x', username: 'Ing' }, 'Scan'],
|
|
968
|
+
['a wholly absent identity', null, 'Scan'],
|
|
969
|
+
]
|
|
970
|
+
|
|
971
|
+
test.each(identities)('%s reads through the operation its scope implies', (_, identity, op) => {
|
|
972
|
+
const { request } = indexed()
|
|
973
|
+
expect(request(makeCtx({ args: {}, identity })).operation).toBe(op)
|
|
974
|
+
})
|
|
975
|
+
|
|
976
|
+
test('a scoped caller is keyed on their own id, not filtered on it', () => {
|
|
977
|
+
const { request } = indexed()
|
|
978
|
+
const r = request(makeCtx({ args: {}, identity: asUser('cust-a') }))
|
|
979
|
+
expect(r.index).toBe('_owner')
|
|
980
|
+
expect(r.query.expression).toBe('#owner = :owner')
|
|
981
|
+
expect(r.query.expressionNames['#owner']).toBe('customerId')
|
|
982
|
+
expect(r.query.expressionValues[':owner']).toEqual({ S: 'cust-a' })
|
|
983
|
+
// The whole point: no owner clause survives anywhere in the filter. A copy
|
|
984
|
+
// left there would be a second, redundant predicate — and an expressionName
|
|
985
|
+
// the filter no longer references is a ValidationException.
|
|
986
|
+
expect(r.filter).toBeUndefined()
|
|
987
|
+
})
|
|
988
|
+
|
|
989
|
+
// The elevated read is the one that must not have changed: it is the same
|
|
990
|
+
// request the unindexed resolver builds, argument for argument.
|
|
991
|
+
test.each([
|
|
992
|
+
['an elevated caller', asUser('ops-1', ['Admin'])],
|
|
993
|
+
['an IAM service caller', { userArn: 'arn:aws:sts::1:x', username: 'Ing' }],
|
|
994
|
+
])("%s's read is unchanged by the index existing", (_, identity) => {
|
|
995
|
+
const before = evalResolver(
|
|
996
|
+
F.listAllItemsConnection('name', ['status'], [], [], undefined, 'customerId', ['Admin']),
|
|
997
|
+
).request(makeCtx({ args: { first: 20, filter: { statusEq: 'Placed' } }, identity }))
|
|
998
|
+
const after = indexed().request(
|
|
999
|
+
makeCtx({ args: { first: 20, filter: { statusEq: 'Placed' } }, identity }),
|
|
1000
|
+
)
|
|
1001
|
+
expect(after).toEqual(before)
|
|
1002
|
+
})
|
|
1003
|
+
|
|
1004
|
+
// The read window exists because a FilterExpression cuts rows AFTER `Limit`.
|
|
1005
|
+
// A key condition does not, so a scoped caller with no filter of their own
|
|
1006
|
+
// examines the page they asked for and the one row that says whether another
|
|
1007
|
+
// follows — the arithmetic the window was hiding, gone rather than papered over.
|
|
1008
|
+
test('a scoped caller with no filter examines only the page, plus one', () => {
|
|
1009
|
+
const { request } = indexed()
|
|
1010
|
+
expect(request(makeCtx({ args: { first: 25 }, identity: asUser('cust-a') })).limit).toBe(26)
|
|
1011
|
+
})
|
|
1012
|
+
|
|
1013
|
+
test("a scoped caller's own filter still widens the window", () => {
|
|
1014
|
+
const { request } = indexed()
|
|
1015
|
+
const r = request(
|
|
1016
|
+
makeCtx({ args: { first: 25, filter: { statusEq: 'Placed' } }, identity: asUser('cust-a') }),
|
|
1017
|
+
)
|
|
1018
|
+
expect(r.limit).toBe(1000)
|
|
1019
|
+
expect(r.filter.expression).toBe('#status = :statusEq')
|
|
1020
|
+
})
|
|
1021
|
+
|
|
1022
|
+
// Both branches are reachable by the SAME caller across requests — an active
|
|
1023
|
+
// role switch mid-pagination flips `_exempt`. A `nextToken` continues the
|
|
1024
|
+
// operation that minted it, so replaying one on the other branch would answer
|
|
1025
|
+
// a different question in silence.
|
|
1026
|
+
describe('the cursor path tag', () => {
|
|
1027
|
+
const { request, response } = indexed()
|
|
1028
|
+
const pageFor = identity =>
|
|
1029
|
+
response(makeCtx({
|
|
1030
|
+
args: {},
|
|
1031
|
+
identity,
|
|
1032
|
+
result: { items: [{ id: 'a' }], nextToken: 'TOK' },
|
|
1033
|
+
})).pageInfo.endCursor
|
|
1034
|
+
|
|
1035
|
+
test('a Scan cursor replayed on the Query branch is refused', () => {
|
|
1036
|
+
const scanCursor = pageFor(asUser('ops-1', ['Admin']))
|
|
1037
|
+
expect(() =>
|
|
1038
|
+
request(makeCtx({ args: { after: scanCursor }, identity: asUser('cust-a') })),
|
|
1039
|
+
).toThrow('different read of this list')
|
|
1040
|
+
})
|
|
1041
|
+
|
|
1042
|
+
test('a Query cursor replayed on the Scan branch is refused', () => {
|
|
1043
|
+
const queryCursor = pageFor(asUser('cust-a'))
|
|
1044
|
+
expect(() =>
|
|
1045
|
+
request(makeCtx({ args: { after: queryCursor }, identity: asUser('ops-1', ['Admin']) })),
|
|
1046
|
+
).toThrow('different read of this list')
|
|
1047
|
+
})
|
|
1048
|
+
|
|
1049
|
+
test('each branch resumes its own cursor', () => {
|
|
1050
|
+
expect(
|
|
1051
|
+
request(makeCtx({ args: { after: pageFor(asUser('cust-a')) }, identity: asUser('cust-a') }))
|
|
1052
|
+
.nextToken,
|
|
1053
|
+
).toBe('TOK')
|
|
1054
|
+
const ops = asUser('ops-1', ['Admin'])
|
|
1055
|
+
expect(request(makeCtx({ args: { after: pageFor(ops) }, identity: ops })).nextToken).toBe('TOK')
|
|
1056
|
+
})
|
|
1057
|
+
|
|
1058
|
+
// Cursors minted before the tag existed all came off a Scan, and the callers
|
|
1059
|
+
// holding one are the exempt callers whose read did not change.
|
|
1060
|
+
test('an untagged cursor still resumes the Scan branch', () => {
|
|
1061
|
+
const legacy = util.base64Encode(JSON.stringify({ t: 'TOK9', n: 2 }))
|
|
1062
|
+
const ops = asUser('ops-1', ['Admin'])
|
|
1063
|
+
expect(request(makeCtx({ args: { after: legacy }, identity: ops })).nextToken).toBe('TOK9')
|
|
1064
|
+
})
|
|
1065
|
+
})
|
|
1066
|
+
|
|
1067
|
+
// Ordering on the index's own sort key is `ScanIndexForward`'s job across the
|
|
1068
|
+
// caller's whole partition. Re-sorting the page in JS afterwards is the one
|
|
1069
|
+
// way to BREAK that order — a sort over a page is not a sort over the rows.
|
|
1070
|
+
describe('orderBy on the index sort key', () => {
|
|
1071
|
+
const sorted = () =>
|
|
1072
|
+
evalResolver(
|
|
1073
|
+
F.listAllItemsConnection(
|
|
1074
|
+
'name', [], [], ['id', 'placedAt'], undefined, 'customerId', ['Admin'],
|
|
1075
|
+
undefined, undefined, '_owner', 'id',
|
|
1076
|
+
),
|
|
1077
|
+
)
|
|
1078
|
+
const items = [{ id: 'c' }, { id: 'a' }, { id: 'b' }]
|
|
1079
|
+
const scoped = asUser('cust-a')
|
|
1080
|
+
|
|
1081
|
+
test('DESC flips scanIndexForward instead of reversing a page', () => {
|
|
1082
|
+
const { request } = sorted()
|
|
1083
|
+
const args = { orderBy: { field: 'id', direction: 'DESC' } }
|
|
1084
|
+
expect(request(makeCtx({ args, identity: scoped })).scanIndexForward).toBe(false)
|
|
1085
|
+
expect(
|
|
1086
|
+
request(makeCtx({ args: { orderBy: { field: 'id', direction: 'ASC' } }, identity: scoped }))
|
|
1087
|
+
.scanIndexForward,
|
|
1088
|
+
).toBe(true)
|
|
1089
|
+
})
|
|
1090
|
+
|
|
1091
|
+
test('the page DynamoDB ordered is handed back untouched', () => {
|
|
1092
|
+
const { response } = sorted()
|
|
1093
|
+
const r = response(makeCtx({
|
|
1094
|
+
args: { orderBy: { field: 'id', direction: 'ASC' } },
|
|
1095
|
+
identity: scoped,
|
|
1096
|
+
result: { items, nextToken: null },
|
|
1097
|
+
}))
|
|
1098
|
+
expect(r.edges.map(e => e.node.id)).toEqual(['c', 'a', 'b'])
|
|
1099
|
+
})
|
|
1100
|
+
|
|
1101
|
+
test('any other sort field still sorts in JS', () => {
|
|
1102
|
+
const { response } = sorted()
|
|
1103
|
+
const r = response(makeCtx({
|
|
1104
|
+
args: { orderBy: { field: 'placedAt', direction: 'ASC' } },
|
|
1105
|
+
identity: scoped,
|
|
1106
|
+
result: { items: [{ placedAt: 'z' }, { placedAt: 'a' }], nextToken: null },
|
|
1107
|
+
}))
|
|
1108
|
+
expect(r.edges.map(e => e.node.placedAt)).toEqual(['a', 'z'])
|
|
1109
|
+
})
|
|
1110
|
+
|
|
1111
|
+
// The exempt caller Scans, which has no ordering of its own, so the per-page
|
|
1112
|
+
// sort is still the only order they get.
|
|
1113
|
+
test('an exempt caller keeps the per-page sort on the same field', () => {
|
|
1114
|
+
const { response } = sorted()
|
|
1115
|
+
const r = response(makeCtx({
|
|
1116
|
+
args: { orderBy: { field: 'id', direction: 'ASC' } },
|
|
1117
|
+
identity: asUser('ops-1', ['Admin']),
|
|
1118
|
+
result: { items, nextToken: null },
|
|
1119
|
+
}))
|
|
1120
|
+
expect(r.edges.map(e => e.node.id)).toEqual(['a', 'b', 'c'])
|
|
1121
|
+
})
|
|
1122
|
+
})
|
|
1123
|
+
|
|
1124
|
+
// Backward paging works the same way on both branches, and for the same reason:
|
|
1125
|
+
// it re-reads the window the cursor names and cuts the page ending at it, which
|
|
1126
|
+
// is operation-agnostic. `last` stays refused on both — it needs the end of the
|
|
1127
|
+
// list, which neither a Scan nor this Query's cursor can reach.
|
|
1128
|
+
test.each([['scoped', asUser('cust-a')], ['exempt', asUser('ops-1', ['Admin'])]])(
|
|
1129
|
+
'a %s caller is still refused `last`',
|
|
1130
|
+
(_, identity) => {
|
|
1131
|
+
const { request } = indexed()
|
|
1132
|
+
expect(() => request(makeCtx({ args: { last: 5 }, identity }))).toThrow(
|
|
1133
|
+
'last is not supported',
|
|
1134
|
+
)
|
|
1135
|
+
},
|
|
1136
|
+
)
|
|
1137
|
+
|
|
1138
|
+
// The path tag has to survive the direction change: a backward cursor still
|
|
1139
|
+
// names the read that minted it, and replaying it on the other branch is the
|
|
1140
|
+
// same mistake as replaying a forward one.
|
|
1141
|
+
test('a backward cursor is path-checked like a forward one', () => {
|
|
1142
|
+
const { request, response } = indexed()
|
|
1143
|
+
const scopedPage = response(makeCtx({
|
|
1144
|
+
args: { first: 10 },
|
|
1145
|
+
identity: asUser('cust-a'),
|
|
1146
|
+
result: { items: Array.from({ length: 10 }, (_, i) => ({ id: 'r' + i })), nextToken: null },
|
|
1147
|
+
}))
|
|
1148
|
+
expect(() =>
|
|
1149
|
+
request(makeCtx({
|
|
1150
|
+
args: { first: 10, before: scopedPage.pageInfo.endCursor },
|
|
1151
|
+
identity: asUser('ops-1', ['Admin']),
|
|
1152
|
+
})),
|
|
1153
|
+
).toThrow('different read of this list')
|
|
1154
|
+
})
|
|
760
1155
|
})
|
|
761
1156
|
|
|
762
1157
|
// ---------------------------------------------------------------------------
|
|
763
1158
|
// By-key reads — owner scoping
|
|
764
1159
|
// ---------------------------------------------------------------------------
|
|
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.
|
|
1160
|
+
// The list resolver carried the predicate while these did not, so a caller
|
|
1161
|
+
// narrowed to their own rows could still read any row they could name. The guard
|
|
1162
|
+
// belongs in the response — GetItem has no FilterExpression — and answers null.
|
|
770
1163
|
describe('getItemById / queryByIdSort — owner scoping', () => {
|
|
771
1164
|
const asUser = (sub, groups = []) => ({
|
|
772
1165
|
username: sub,
|