@reventlessdev/reventless-aws 3.0.0-alpha.282 → 3.0.0-alpha.284
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 +19 -0
- package/package.json +10 -10
- package/src/Platform.res +21 -12
- package/src/Platform.res.mjs +2 -2
- package/src/adapter/QueryDb/PgQueryResolver_Lambda.res +85 -31
- package/src/adapter/QueryDb/PgQueryResolver_Lambda.res.mjs +71 -36
- package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res +36 -1
- package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res.mjs +13 -2
- package/src/adapter/Runtime/PgQueryResolverEntryPoint_Ops.res +4 -0
- package/src/adapter/Runtime/PgQueryResolverEntryPoint_Ops.res.mjs +5 -3
- package/tests/PgQueryResolver_LambdaTest.res +239 -9
- package/tests/PgQueryResolver_LambdaTest.res.mjs +202 -40
- package/tests/QueryDbResolvers_AppSyncTest.res.mjs +3 -3
|
@@ -11,18 +11,19 @@ open ReventlessCore
|
|
|
11
11
|
|
|
12
12
|
@val external btoa: string => string = "btoa"
|
|
13
13
|
|
|
14
|
-
let mk = (id, status, name) => {
|
|
14
|
+
let mk = (id, status, name, owner) => {
|
|
15
15
|
let o = Dict.make()
|
|
16
16
|
o->Dict.set("id", JSON.Encode.string(id))
|
|
17
17
|
o->Dict.set("status", JSON.Encode.string(status))
|
|
18
18
|
o->Dict.set("name", JSON.Encode.string(name))
|
|
19
|
+
o->Dict.set("owner", JSON.Encode.string(owner))
|
|
19
20
|
JSON.Encode.object(o)
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
let store = Dict.fromArray([
|
|
23
|
-
("p-1", mk("p-1", "active", "Alpha")),
|
|
24
|
-
("p-2", mk("p-2", "inactive", "Bravo")),
|
|
25
|
-
("p-3", mk("p-3", "active", "Charlie")),
|
|
24
|
+
("p-1", mk("p-1", "active", "Alpha", "u-a")),
|
|
25
|
+
("p-2", mk("p-2", "inactive", "Bravo", "u-b")),
|
|
26
|
+
("p-3", mk("p-3", "active", "Charlie", "u-a")),
|
|
26
27
|
])
|
|
27
28
|
let allItems = () => store->Dict.valuesToArray
|
|
28
29
|
|
|
@@ -35,6 +36,11 @@ let fieldEq = (item, field, value) =>
|
|
|
35
36
|
|
|
36
37
|
// Records whether listPage was consulted, and what it returns (sentinel or None).
|
|
37
38
|
let listPageReturn: ref<option<JSON.t>> = ref(None)
|
|
39
|
+
// The `ownerScope` the dispatcher handed the push-down on the last list call.
|
|
40
|
+
// The push-down's own SQL is covered by the engine's parity harness; what this
|
|
41
|
+
// file has to establish is that the dispatcher computes the scope from the
|
|
42
|
+
// caller and actually passes it on.
|
|
43
|
+
let lastOwnerScope: ref<option<(string, string)>> = ref(None)
|
|
38
44
|
|
|
39
45
|
let ops: QueryDb_Adapter.operations = {
|
|
40
46
|
load: async id => Ok(store->Dict.get(id)->Option.mapOr([], i => [i])),
|
|
@@ -51,9 +57,17 @@ let pushdowns: PgQueryResolver_Lambda.pushdowns = {
|
|
|
51
57
|
allItems()->Array.filter(i => fieldEq(i, field, value)),
|
|
52
58
|
byIds: async (~readModelName as _, ids) =>
|
|
53
59
|
ids->Array.filterMap(id => store->Dict.get(id)),
|
|
54
|
-
listPage: async (
|
|
55
|
-
|
|
56
|
-
|
|
60
|
+
listPage: async (
|
|
61
|
+
~readModelName as _,
|
|
62
|
+
~argsDict as _,
|
|
63
|
+
~capability as _,
|
|
64
|
+
~labelField as _,
|
|
65
|
+
~ownerScope: option<(string, string)>=?,
|
|
66
|
+
) => {
|
|
67
|
+
lastOwnerScope := ownerScope
|
|
68
|
+
listPageReturn.contents
|
|
69
|
+
},
|
|
70
|
+
itemsPage: async (~readModelName as _, ~subIdField as _, ~id, ~argsDict as _, ~ownerScope as _=?) =>
|
|
57
71
|
// Sentinel echoing the requested id, so the dispatch routing is observable.
|
|
58
72
|
JSON.Encode.object(Dict.fromArray([("itemsFor", JSON.Encode.string(id))])),
|
|
59
73
|
scanAll: async (~readModelName as _) => allItems(),
|
|
@@ -67,6 +81,7 @@ let capability: GraphQL_FragmentGenerator.serverCapability = {
|
|
|
67
81
|
let makeBinding = (
|
|
68
82
|
~authorization=Reventless.Authorization.AllowAnonymous,
|
|
69
83
|
~subIdField=None,
|
|
84
|
+
~ownerField=None,
|
|
70
85
|
(),
|
|
71
86
|
): PgQueryResolver_Lambda.binding => {
|
|
72
87
|
ops,
|
|
@@ -80,16 +95,28 @@ let makeBinding = (
|
|
|
80
95
|
labelField: "name",
|
|
81
96
|
includeIdParam: true,
|
|
82
97
|
authorization,
|
|
98
|
+
ownerField,
|
|
83
99
|
}
|
|
84
100
|
|
|
85
|
-
let mkPayload = (
|
|
101
|
+
let mkPayload = (
|
|
102
|
+
~kind,
|
|
103
|
+
~index=?,
|
|
104
|
+
~args=JSON.Encode.object(Dict.make()),
|
|
105
|
+
~identity=Reventless.Identity.anonymous,
|
|
106
|
+
(),
|
|
107
|
+
): PgQueryResolver_Lambda.payload => {
|
|
86
108
|
readModelName: "Things",
|
|
87
109
|
kind,
|
|
88
110
|
?index,
|
|
89
111
|
arguments: args,
|
|
90
|
-
identity
|
|
112
|
+
identity,
|
|
91
113
|
}
|
|
92
114
|
|
|
115
|
+
external asIdentity: 'a => Reventless.Identity.t = "%identity"
|
|
116
|
+
|
|
117
|
+
let cognito = (~userId, ~groups): Reventless.Identity.t =>
|
|
118
|
+
asIdentity({"userId": userId, "username": userId, "groups": groups, "provider": "Cognito"})
|
|
119
|
+
|
|
93
120
|
let objArgs = pairs => JSON.Encode.object(Dict.fromArray(pairs))
|
|
94
121
|
let field = (j, k) => j->JSON.Decode.object->Option.flatMap(d => d->Dict.get(k))
|
|
95
122
|
let str = (j, k) => j->field(k)->Option.flatMap(JSON.Decode.string)
|
|
@@ -365,3 +392,206 @@ describe("PgQueryResolver_Lambda.dispatch", () => {
|
|
|
365
392
|
expect(threw)->toBe(true)
|
|
366
393
|
})
|
|
367
394
|
})
|
|
395
|
+
|
|
396
|
+
// A client holding a row reads its `id` and passes it back. On the local platform
|
|
397
|
+
// that `id` is a Relay global id; the typed doors used to take only the storage
|
|
398
|
+
// key and answered null, with no error to point at. These pin the either-form
|
|
399
|
+
// rule at the door, so the same client call works whichever form it holds.
|
|
400
|
+
describe("id form — the typed doors take either", () => {
|
|
401
|
+
let globalIdFor = localId => ReventlessCore.Api_Ids.encode(~typeName="Thing", ~localId)
|
|
402
|
+
|
|
403
|
+
testPromise("getById resolves a Relay global id", async () => {
|
|
404
|
+
let r = await PgQueryResolver_Lambda.dispatch(
|
|
405
|
+
~binding=makeBinding(),
|
|
406
|
+
~payload=mkPayload(
|
|
407
|
+
~kind="getById",
|
|
408
|
+
~args=objArgs([("id", JSON.Encode.string(globalIdFor("p-2")))]),
|
|
409
|
+
(),
|
|
410
|
+
),
|
|
411
|
+
)
|
|
412
|
+
expect(r->str("name"))->toEqual(Some("Bravo"))
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
// The row's `id` must come back as the storage key it was found under — echoing
|
|
416
|
+
// the argument would hand back an id nothing else accepts.
|
|
417
|
+
testPromise("getById reports the storage key it resolved to", async () => {
|
|
418
|
+
let r = await PgQueryResolver_Lambda.dispatch(
|
|
419
|
+
~binding=makeBinding(),
|
|
420
|
+
~payload=mkPayload(
|
|
421
|
+
~kind="getById",
|
|
422
|
+
~args=objArgs([("id", JSON.Encode.string(globalIdFor("p-2")))]),
|
|
423
|
+
(),
|
|
424
|
+
),
|
|
425
|
+
)
|
|
426
|
+
expect(r->str("id"))->toEqual(Some("p-2"))
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
testPromise("getById still resolves a plain storage key", async () => {
|
|
430
|
+
let r = await PgQueryResolver_Lambda.dispatch(
|
|
431
|
+
~binding=makeBinding(),
|
|
432
|
+
~payload=mkPayload(~kind="getById", ~args=objArgs([("id", JSON.Encode.string("p-2"))]), ()),
|
|
433
|
+
)
|
|
434
|
+
expect(r->str("name"))->toEqual(Some("Bravo"))
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
testPromise("getById on a global id for a row that does not exist is still null", async () => {
|
|
438
|
+
let r = await PgQueryResolver_Lambda.dispatch(
|
|
439
|
+
~binding=makeBinding(),
|
|
440
|
+
~payload=mkPayload(
|
|
441
|
+
~kind="getById",
|
|
442
|
+
~args=objArgs([("id", JSON.Encode.string(globalIdFor("absent")))]),
|
|
443
|
+
(),
|
|
444
|
+
),
|
|
445
|
+
)
|
|
446
|
+
expect(r)->toBe(JSON.Encode.null)
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
testPromise("byIds accepts the two forms mixed in one call", async () => {
|
|
450
|
+
let r = await PgQueryResolver_Lambda.dispatch(
|
|
451
|
+
~binding=makeBinding(),
|
|
452
|
+
~payload=mkPayload(
|
|
453
|
+
~kind="byIds",
|
|
454
|
+
~args=objArgs([
|
|
455
|
+
(
|
|
456
|
+
"ids",
|
|
457
|
+
JSON.Encode.array([
|
|
458
|
+
JSON.Encode.string("p-1"),
|
|
459
|
+
JSON.Encode.string(globalIdFor("p-3")),
|
|
460
|
+
]),
|
|
461
|
+
),
|
|
462
|
+
]),
|
|
463
|
+
(),
|
|
464
|
+
),
|
|
465
|
+
)
|
|
466
|
+
expect(r->ids)->toEqual(["p-1", "p-3"])
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
// The fallback lookup must not fire when the first pass already answered — an
|
|
470
|
+
// all-raw call is the common path and pays for one round trip, not two.
|
|
471
|
+
testPromise("byIds with every id found does not double-count", async () => {
|
|
472
|
+
let r = await PgQueryResolver_Lambda.dispatch(
|
|
473
|
+
~binding=makeBinding(),
|
|
474
|
+
~payload=mkPayload(
|
|
475
|
+
~kind="byIds",
|
|
476
|
+
~args=objArgs([
|
|
477
|
+
("ids", JSON.Encode.array(["p-1", "p-3"]->Array.map(JSON.Encode.string))),
|
|
478
|
+
]),
|
|
479
|
+
(),
|
|
480
|
+
),
|
|
481
|
+
)
|
|
482
|
+
expect(r->ids)->toEqual(["p-1", "p-3"])
|
|
483
|
+
})
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
// ── Owner scoping ───────────────────────────────────────────────────────────
|
|
487
|
+
// The push-down's SQL is the engine's business and is covered by its parity
|
|
488
|
+
// harness. What has to hold HERE is that the dispatcher derives the scope from
|
|
489
|
+
// the caller and hands it on — a push-down that supports scoping and a
|
|
490
|
+
// dispatcher that never passes one produces a fully unscoped deployment while
|
|
491
|
+
// every SQL test stays green.
|
|
492
|
+
describe("owner scoping", () => {
|
|
493
|
+
let ownedBinding = () => makeBinding(~ownerField=Some("owner"), ())
|
|
494
|
+
let shopper = cognito(~userId="u-a", ~groups=["User"])
|
|
495
|
+
|
|
496
|
+
let listWith = async (~binding, ~identity) => {
|
|
497
|
+
lastOwnerScope := None
|
|
498
|
+
listPageReturn := Some(JSON.Encode.string("pushed"))
|
|
499
|
+
let r = await PgQueryResolver_Lambda.dispatch(
|
|
500
|
+
~binding,
|
|
501
|
+
~payload=mkPayload(~kind="list", ~identity, ()),
|
|
502
|
+
)
|
|
503
|
+
(r, lastOwnerScope.contents)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
testPromise("a shopper's list is scoped to their own id", async () => {
|
|
507
|
+
let (_, scope) = await listWith(~binding=ownedBinding(), ~identity=shopper)
|
|
508
|
+
expect(scope)->toEqual(Some(("owner", "u-a")))
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
testPromise("an elevated caller's list is not scoped", async () => {
|
|
512
|
+
Reventless.OwnerScope.setElevatedGroups(["Admin"])
|
|
513
|
+
let (_, scope) = await listWith(
|
|
514
|
+
~binding=ownedBinding(),
|
|
515
|
+
~identity=cognito(~userId="ops-1", ~groups=["Admin"]),
|
|
516
|
+
)
|
|
517
|
+
Reventless.OwnerScope.setElevatedGroups([])
|
|
518
|
+
expect(scope)->toBe(None)
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
// The control that keeps this from being vacuous: a view with no `@owner`
|
|
522
|
+
// field must reach the push-down with no scope at all, for every caller.
|
|
523
|
+
testPromise("a view with no owner field is never scoped", async () => {
|
|
524
|
+
let (_, scope) = await listWith(~binding=makeBinding(), ~identity=shopper)
|
|
525
|
+
expect(scope)->toBe(None)
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
testPromise("an unidentified caller is refused before the push-down runs", async () => {
|
|
529
|
+
lastOwnerScope := None
|
|
530
|
+
listPageReturn := Some(JSON.Encode.string("pushed"))
|
|
531
|
+
let r = await PgQueryResolver_Lambda.dispatch(
|
|
532
|
+
~binding=ownedBinding(),
|
|
533
|
+
~payload=mkPayload(~kind="list", ~identity=Reventless.Identity.anonymous, ()),
|
|
534
|
+
)
|
|
535
|
+
// Not the sentinel, and the push-down was never consulted — the refusal
|
|
536
|
+
// happens above it rather than by handing it a value nobody holds.
|
|
537
|
+
expect((r->field("edges"), lastOwnerScope.contents))->toEqual((
|
|
538
|
+
Some(JSON.Encode.array([])),
|
|
539
|
+
None,
|
|
540
|
+
))
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
testPromise("getById hides a row the caller does not own", async () => {
|
|
544
|
+
let r = await PgQueryResolver_Lambda.dispatch(
|
|
545
|
+
~binding=ownedBinding(),
|
|
546
|
+
~payload=mkPayload(
|
|
547
|
+
~kind="getById",
|
|
548
|
+
~args=objArgs([("id", JSON.Encode.string("p-2"))]),
|
|
549
|
+
~identity=shopper,
|
|
550
|
+
(),
|
|
551
|
+
),
|
|
552
|
+
)
|
|
553
|
+
expect(r)->toBe(JSON.Encode.null)
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
testPromise("getById still returns a row the caller does own", async () => {
|
|
557
|
+
let r = await PgQueryResolver_Lambda.dispatch(
|
|
558
|
+
~binding=ownedBinding(),
|
|
559
|
+
~payload=mkPayload(
|
|
560
|
+
~kind="getById",
|
|
561
|
+
~args=objArgs([("id", JSON.Encode.string("p-1"))]),
|
|
562
|
+
~identity=shopper,
|
|
563
|
+
(),
|
|
564
|
+
),
|
|
565
|
+
)
|
|
566
|
+
expect(r->str("id"))->toEqual(Some("p-1"))
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
testPromise("byIds drops rows the caller does not own", async () => {
|
|
570
|
+
let r = await PgQueryResolver_Lambda.dispatch(
|
|
571
|
+
~binding=ownedBinding(),
|
|
572
|
+
~payload=mkPayload(
|
|
573
|
+
~kind="byIds",
|
|
574
|
+
~args=objArgs([
|
|
575
|
+
("ids", JSON.Encode.array(["p-1", "p-2", "p-3"]->Array.map(JSON.Encode.string))),
|
|
576
|
+
]),
|
|
577
|
+
~identity=shopper,
|
|
578
|
+
(),
|
|
579
|
+
),
|
|
580
|
+
)
|
|
581
|
+
expect(r->ids)->toEqual(["p-1", "p-3"])
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
testPromise("an index query drops rows the caller does not own", async () => {
|
|
585
|
+
let r = await PgQueryResolver_Lambda.dispatch(
|
|
586
|
+
~binding=ownedBinding(),
|
|
587
|
+
~payload=mkPayload(
|
|
588
|
+
~kind="index",
|
|
589
|
+
~index="byStatus",
|
|
590
|
+
~args=objArgs([("byStatus", JSON.Encode.string("active"))]),
|
|
591
|
+
~identity=shopper,
|
|
592
|
+
(),
|
|
593
|
+
),
|
|
594
|
+
)
|
|
595
|
+
expect(r->ids)->toEqual(["p-1", "p-3"])
|
|
596
|
+
})
|
|
597
|
+
})
|