@reventlessdev/reventless-core 3.0.0-alpha.224 → 3.0.0-alpha.225

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/package.json +6 -6
  3. package/src/admin/Platform_Admin_Structure.res +6 -0
  4. package/src/admin/Platform_Admin_Structure.res.mjs +9 -3
  5. package/src/admin/Platform_BakedManifest.res +154 -0
  6. package/src/admin/Platform_BakedManifest.res.mjs +156 -0
  7. package/src/admin/Platform_ComponentDefinitionsApi.res +9 -2
  8. package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +18 -2
  9. package/src/components/Api/OwnerScopeDiagnostics.res +54 -0
  10. package/src/components/Api/OwnerScopeDiagnostics.res.mjs +27 -0
  11. package/src/components/Api/QueryDbListQuery.res +25 -1
  12. package/src/components/Api/QueryDbListQuery.res.mjs +10 -3
  13. package/src/components/Api/SuryToJsonSchema.res +19 -0
  14. package/src/components/Api/SuryToJsonSchema.res.mjs +18 -4
  15. package/src/components/CommandGenerator/CommandGenerator_Callback.res +48 -0
  16. package/src/components/CommandGenerator/CommandGenerator_Callback.res.mjs +27 -1
  17. package/src/plugin/component/Plugin_Structure.res +59 -3
  18. package/src/plugin/component/Plugin_Structure.res.mjs +29 -9
  19. package/tests/admin/Platform_BakedManifestTest.res +244 -0
  20. package/tests/admin/Platform_BakedManifestTest.res.mjs +294 -0
  21. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +10 -0
  22. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +24 -8
  23. package/tests/admin/Platform_PluginStructuresApiTest.res +2 -0
  24. package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +6 -2
  25. package/tests/api/Api_IdsTest.res.mjs +1 -1
  26. package/tests/api/OwnerScopeDiagnosticsTest.res +109 -0
  27. package/tests/api/OwnerScopeDiagnosticsTest.res.mjs +91 -0
  28. package/tests/api/SuryToJsonSchemaTest.res +45 -0
  29. package/tests/api/SuryToJsonSchemaTest.res.mjs +23 -0
  30. package/tests/commandgenerator/OwnerStampingTest.res +174 -0
  31. package/tests/commandgenerator/OwnerStampingTest.res.mjs +195 -0
  32. package/tests/plugin/PluginStructureAccessTest.res +110 -0
  33. package/tests/plugin/PluginStructureAccessTest.res.mjs +144 -0
  34. package/tests/plugin/StateChangeSlice/PsGatedCommands.res +28 -0
  35. package/tests/plugin/StateChangeSlice/PsGatedCommands.res.mjs +84 -0
  36. package/tests/plugin/StateViewSlice/PsGatedView.res +15 -0
  37. package/tests/plugin/StateViewSlice/PsGatedView.res.mjs +60 -0
  38. package/tests/plugin/pluginDefinitionRequiredScalars.txt +4 -0
@@ -32,11 +32,13 @@ let publicRm = {
32
32
  searchableFields: publicRm_searchableFields,
33
33
  labelFieldSource: publicRm_labelFieldSource,
34
34
  statusField: undefined,
35
+ ownerField: undefined,
35
36
  visibility: undefined,
36
37
  chapter: publicRm_chapter,
37
38
  singleQueryField: publicRm_singleQueryField,
38
39
  idField: publicRm_idField,
39
- idFieldSource: publicRm_idFieldSource
40
+ idFieldSource: publicRm_idFieldSource,
41
+ requiredAccess: undefined
40
42
  };
41
43
 
42
44
  let internalRm_consumedEventTypes = publicRm_consumedEventTypes;
@@ -67,11 +69,13 @@ let internalRm = {
67
69
  searchableFields: internalRm_searchableFields,
68
70
  labelFieldSource: internalRm_labelFieldSource,
69
71
  statusField: undefined,
72
+ ownerField: undefined,
70
73
  visibility: internalRm_visibility,
71
74
  chapter: internalRm_chapter,
72
75
  singleQueryField: internalRm_singleQueryField,
73
76
  idField: internalRm_idField,
74
- idFieldSource: internalRm_idFieldSource
77
+ idFieldSource: internalRm_idFieldSource,
78
+ requiredAccess: undefined
75
79
  };
76
80
 
77
81
  let structure_readModels = [
@@ -77,7 +77,7 @@ globalThis.describe("filter.ids matches whichever form each side holds", () => {
77
77
  "ids",
78
78
  filterIds.map(prim => prim)
79
79
  ]]);
80
- let conn = QueryDbListQuery$ReventlessCore.run([row(rowId)], args, GraphQL_FragmentGenerator$ReventlessCore.emptyCapability, "name", undefined);
80
+ let conn = QueryDbListQuery$ReventlessCore.run([row(rowId)], args, GraphQL_FragmentGenerator$ReventlessCore.emptyCapability, "name", undefined, undefined);
81
81
  return Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(conn), d => d["edges"]), Stdlib_JSON.Decode.array), []).length;
82
82
  };
83
83
  globalThis.test("storage key row, storage key filter", () => {
@@ -0,0 +1,109 @@
1
+ open JestGlobals
2
+
3
+ // This file asserts on EMITTED log output, so it pins the level it needs rather
4
+ // than inheriting whatever the runner exported — `LOG_LEVEL=silent` would
5
+ // suppress the warning and turn every case below green for the wrong reason.
6
+ // `Logger` re-reads process.env on each call, so a file-scope assignment holds.
7
+ NodeProcess.env->Dict.set("LOG_LEVEL", "warn")
8
+
9
+ type consoleObj = {mutable log: string => unit}
10
+ @val external console: consoleObj = "console"
11
+
12
+ let capture = (fn: unit => unit): array<string> => {
13
+ let lines: array<string> = []
14
+ let original = console.log
15
+ console.log = s => lines->Array.push(s)
16
+ let result = try {
17
+ fn()
18
+ Ok()
19
+ } catch {
20
+ | e => Error(e)
21
+ }
22
+ console.log = original
23
+ switch result {
24
+ | Ok() => lines
25
+ | Error(e) => throw(e)
26
+ }
27
+ }
28
+
29
+ let warnFor = (~view, ~ownerField, ~elevated) => {
30
+ OwnerScopeDiagnostics.resetWarnings()
31
+ Reventless.OwnerScope.setElevatedGroups(elevated)
32
+ let lines = capture(() =>
33
+ OwnerScopeDiagnostics.warnIfNoElevatedGroups(~comp="Test", ~view, ~ownerField)
34
+ )
35
+ Reventless.OwnerScope.setElevatedGroups([])
36
+ lines
37
+ }
38
+
39
+ describe("OwnerScopeDiagnostics:", () => {
40
+ testSync("an owner-scoped view with no elevated groups warns", () =>
41
+ expect(warnFor(~view="Orders", ~ownerField=Some("customerId"), ~elevated=[])->Array.length)->toBe(
42
+ 1,
43
+ )
44
+ )
45
+
46
+ // The consequence is the whole point of the line — an operator reading
47
+ // "configure elevated groups" without being told that admins are currently
48
+ // scoped has no reason to treat it as urgent.
49
+ testSync("the warning names the view, the field, and what will happen", () => {
50
+ let line =
51
+ warnFor(~view="Orders", ~ownerField=Some("customerId"), ~elevated=[])
52
+ ->Array.get(0)
53
+ ->Option.getOr("")
54
+ expect((
55
+ line->String.includes("Orders"),
56
+ line->String.includes("customerId"),
57
+ line->String.includes("only their own rows"),
58
+ ))->toEqual((true, true, true))
59
+ })
60
+
61
+ testSync("a view with no owner field is silent", () =>
62
+ expect(warnFor(~view="Products", ~ownerField=None, ~elevated=[])->Array.length)->toBe(0)
63
+ )
64
+
65
+ testSync("a configured deployment is silent", () =>
66
+ expect(
67
+ warnFor(~view="Orders", ~ownerField=Some("customerId"), ~elevated=["Admin"])->Array.length,
68
+ )->toBe(0)
69
+ )
70
+
71
+ // Registration runs more than once per process — tests build several
72
+ // platforms, a deploy walks every plugin. A line repeated per construction is
73
+ // one people learn to scroll past.
74
+ testSync("the same view warns once, not once per registration", () => {
75
+ OwnerScopeDiagnostics.resetWarnings()
76
+ Reventless.OwnerScope.setElevatedGroups([])
77
+ let lines = capture(() => {
78
+ OwnerScopeDiagnostics.warnIfNoElevatedGroups(
79
+ ~comp="Test",
80
+ ~view="Orders",
81
+ ~ownerField=Some("customerId"),
82
+ )
83
+ OwnerScopeDiagnostics.warnIfNoElevatedGroups(
84
+ ~comp="Test",
85
+ ~view="Orders",
86
+ ~ownerField=Some("customerId"),
87
+ )
88
+ })
89
+ expect(lines->Array.length)->toBe(1)
90
+ })
91
+
92
+ testSync("a second view gets its own warning", () => {
93
+ OwnerScopeDiagnostics.resetWarnings()
94
+ Reventless.OwnerScope.setElevatedGroups([])
95
+ let lines = capture(() => {
96
+ OwnerScopeDiagnostics.warnIfNoElevatedGroups(
97
+ ~comp="Test",
98
+ ~view="Orders",
99
+ ~ownerField=Some("customerId"),
100
+ )
101
+ OwnerScopeDiagnostics.warnIfNoElevatedGroups(
102
+ ~comp="Test",
103
+ ~view="Invoices",
104
+ ~ownerField=Some("payerId"),
105
+ )
106
+ })
107
+ expect(lines->Array.length)->toBe(2)
108
+ })
109
+ })
@@ -0,0 +1,91 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
+ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
5
+ import * as OwnerScope$Reventless from "@reventlessdev/reventless-spec/src/types/OwnerScope.res.mjs";
6
+ import * as OwnerScopeDiagnostics$ReventlessCore from "../../src/components/Api/OwnerScopeDiagnostics.res.mjs";
7
+
8
+ process.env["LOG_LEVEL"] = "warn";
9
+
10
+ function capture(fn) {
11
+ let lines = [];
12
+ let original = console.log;
13
+ console.log = s => {
14
+ lines.push(s);
15
+ };
16
+ let result;
17
+ try {
18
+ fn();
19
+ result = {
20
+ TAG: "Ok",
21
+ _0: undefined
22
+ };
23
+ } catch (raw_e) {
24
+ let e = Primitive_exceptions.internalToException(raw_e);
25
+ result = {
26
+ TAG: "Error",
27
+ _0: e
28
+ };
29
+ }
30
+ console.log = original;
31
+ if (result.TAG === "Ok") {
32
+ return lines;
33
+ }
34
+ throw result._0;
35
+ }
36
+
37
+ function warnFor(view, ownerField, elevated) {
38
+ OwnerScopeDiagnostics$ReventlessCore.resetWarnings();
39
+ OwnerScope$Reventless.setElevatedGroups(elevated);
40
+ let lines = capture(() => OwnerScopeDiagnostics$ReventlessCore.warnIfNoElevatedGroups("Test", view, ownerField));
41
+ OwnerScope$Reventless.setElevatedGroups([]);
42
+ return lines;
43
+ }
44
+
45
+ globalThis.describe("OwnerScopeDiagnostics:", () => {
46
+ globalThis.test("an owner-scoped view with no elevated groups warns", () => {
47
+ globalThis.expect(warnFor("Orders", "customerId", []).length).toBe(1);
48
+ });
49
+ globalThis.test("the warning names the view, the field, and what will happen", () => {
50
+ let line = Stdlib_Option.getOr(warnFor("Orders", "customerId", [])[0], "");
51
+ globalThis.expect([
52
+ line.includes("Orders"),
53
+ line.includes("customerId"),
54
+ line.includes("only their own rows")
55
+ ]).toEqual([
56
+ true,
57
+ true,
58
+ true
59
+ ]);
60
+ });
61
+ globalThis.test("a view with no owner field is silent", () => {
62
+ globalThis.expect(warnFor("Products", undefined, []).length).toBe(0);
63
+ });
64
+ globalThis.test("a configured deployment is silent", () => {
65
+ globalThis.expect(warnFor("Orders", "customerId", ["Admin"]).length).toBe(0);
66
+ });
67
+ globalThis.test("the same view warns once, not once per registration", () => {
68
+ OwnerScopeDiagnostics$ReventlessCore.resetWarnings();
69
+ OwnerScope$Reventless.setElevatedGroups([]);
70
+ let lines = capture(() => {
71
+ OwnerScopeDiagnostics$ReventlessCore.warnIfNoElevatedGroups("Test", "Orders", "customerId");
72
+ OwnerScopeDiagnostics$ReventlessCore.warnIfNoElevatedGroups("Test", "Orders", "customerId");
73
+ });
74
+ globalThis.expect(lines.length).toBe(1);
75
+ });
76
+ globalThis.test("a second view gets its own warning", () => {
77
+ OwnerScopeDiagnostics$ReventlessCore.resetWarnings();
78
+ OwnerScope$Reventless.setElevatedGroups([]);
79
+ let lines = capture(() => {
80
+ OwnerScopeDiagnostics$ReventlessCore.warnIfNoElevatedGroups("Test", "Orders", "customerId");
81
+ OwnerScopeDiagnostics$ReventlessCore.warnIfNoElevatedGroups("Test", "Invoices", "payerId");
82
+ });
83
+ globalThis.expect(lines.length).toBe(2);
84
+ });
85
+ });
86
+
87
+ export {
88
+ capture,
89
+ warnFor,
90
+ }
91
+ /* Not a pure module */
@@ -73,6 +73,51 @@ describe("SuryToJsonSchema:", () => {
73
73
  })
74
74
  })
75
75
 
76
+ describe("deriveObjectSchema with an owner field:", () => {
77
+ let ownerOf = (json, name) =>
78
+ getPropertyOf(json, name)->Option.flatMap(s => getProperty(s, "x-reventless-owner"))
79
+
80
+ let json = SuryToJsonSchema.deriveObjectSchema(
81
+ S.schema(s =>
82
+ {
83
+ "customerId": s.matches(Reventless.Owner.string),
84
+ "note": s.matches(S.string),
85
+ }
86
+ )->S.castToUnknown,
87
+ )
88
+
89
+ testSync("the marked field carries x-reventless-owner", () =>
90
+ expect(ownerOf(json, "customerId"))->toEqual(Some(JSON.Encode.bool(true)))
91
+ )
92
+
93
+ // The control: ownership is opt-in, and an unmarked string must stay one.
94
+ // Without this, a bug that marked every field would still pass above.
95
+ testSync("an unmarked string field carries nothing", () =>
96
+ expect(ownerOf(json, "note"))->toBe(None)
97
+ )
98
+
99
+ testSync("the field is still a plain string on the wire", () =>
100
+ expect(
101
+ getPropertyOf(json, "customerId")->Option.flatMap(s => getProperty(s, "type")),
102
+ )->toEqual(Some(JSON.Encode.string("string")))
103
+ )
104
+
105
+ // An optional field keeps its marker inside the union wrapper. A reader that
106
+ // only inspects the outer schema answers "not the owner" here — which for a
107
+ // predicate that scopes reads means an unscoped view, silently. This is the
108
+ // case `Owner.isFieldOwner` exists for.
109
+ testSync("an optional owner field is still recognised", () => {
110
+ let optJson = SuryToJsonSchema.deriveObjectSchema(
111
+ S.schema(s =>
112
+ {
113
+ "customerId": s.matches(S.option(Reventless.Owner.string)),
114
+ }
115
+ )->S.castToUnknown,
116
+ )
117
+ expect(ownerOf(optJson, "customerId"))->toEqual(Some(JSON.Encode.bool(true)))
118
+ })
119
+ })
120
+
76
121
  describe("deriveObjectSchema with stateAnnotations metadata:", () => {
77
122
  let withSpec = (schema, spec) =>
78
123
  schema->S.Metadata.set(~id=Reventless.StateAnnotations.stateAnnotationsId, spec)
@@ -6,6 +6,7 @@ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
6
6
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
7
  import * as Email$Reventless from "@reventlessdev/reventless-spec/src/semantic/Email.res.mjs";
8
8
  import * as Money$Reventless from "@reventlessdev/reventless-spec/src/semantic/Money.res.mjs";
9
+ import * as Owner$Reventless from "@reventlessdev/reventless-spec/src/components/Owner.res.mjs";
9
10
  import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
10
11
  import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
11
12
  import * as Percent$Reventless from "@reventlessdev/reventless-spec/src/semantic/Percent.res.mjs";
@@ -106,6 +107,28 @@ globalThis.describe("SuryToJsonSchema:", () => {
106
107
  globalThis.expect(Stdlib_Option.flatMap(getPropertyOf(json, "name"), s => getProperty(s, "format"))).toBe(undefined);
107
108
  });
108
109
  });
110
+ globalThis.describe("deriveObjectSchema with an owner field:", () => {
111
+ let ownerOf = (json, name) => Stdlib_Option.flatMap(getPropertyOf(json, name), s => getProperty(s, "x-reventless-owner"));
112
+ let json = SuryToJsonSchema$ReventlessCore.deriveObjectSchema(S.schema(s => ({
113
+ customerId: s.m(Owner$Reventless.string),
114
+ note: s.m(S.string)
115
+ })));
116
+ globalThis.test("the marked field carries x-reventless-owner", () => {
117
+ globalThis.expect(ownerOf(json, "customerId")).toEqual(true);
118
+ });
119
+ globalThis.test("an unmarked string field carries nothing", () => {
120
+ globalThis.expect(ownerOf(json, "note")).toBe(undefined);
121
+ });
122
+ globalThis.test("the field is still a plain string on the wire", () => {
123
+ globalThis.expect(Stdlib_Option.flatMap(getPropertyOf(json, "customerId"), s => getProperty(s, "type"))).toEqual("string");
124
+ });
125
+ globalThis.test("an optional owner field is still recognised", () => {
126
+ let optJson = SuryToJsonSchema$ReventlessCore.deriveObjectSchema(S.schema(s => ({
127
+ customerId: s.m(S.option(Owner$Reventless.string))
128
+ })));
129
+ globalThis.expect(ownerOf(optJson, "customerId")).toEqual(true);
130
+ });
131
+ });
109
132
  globalThis.describe("deriveObjectSchema with stateAnnotations metadata:", () => {
110
133
  let withSpec = (schema, spec) => S.Metadata.set(schema, StateAnnotations$Reventless.stateAnnotationsId, spec);
111
134
  globalThis.test("emits x-reventless-id on field listed in ids", () => {
@@ -0,0 +1,174 @@
1
+ open JestGlobals
2
+
3
+ S.enableJson()
4
+
5
+ // Driven through `makeGenerateCommand` rather than by calling `stampOwnerFields`
6
+ // directly. The unit under test is not really the stamp — it is the claim that
7
+ // the stamp is reached on the path a command actually takes, which is the half a
8
+ // direct call cannot establish. Both transports funnel through this function, so
9
+ // what passes here is true of the local resolver and of AppSync alike.
10
+
11
+ @schema
12
+ type command =
13
+ | PlaceOrder({
14
+ orderId: @s.matches(Reventless.DcbTag.string) string,
15
+ customerId: @s.matches(Reventless.Owner.string) string,
16
+ note: string,
17
+ })
18
+ | // The control variant: same union, no owner. Whatever the caller sends here
19
+ // must survive untouched, or the stamp is reaching across constructors.
20
+ ImportProducts({batchId: @s.matches(Reventless.DcbTag.string) string, source: string})
21
+
22
+ let published: ref<array<Message.commandJson>> = ref([])
23
+
24
+ let generate = CommandGenerator_Callback.makeGenerateCommand(
25
+ ~publishJsons=async cmds => published := published.contents->Array.concat(cmds),
26
+ ~serviceName="Ordering",
27
+ ~commandSchema=commandSchema->S.castToUnknown,
28
+ ~componentKind=CommandGenerator_Callback.StateChangeSlice,
29
+ ~stripIdFromParams=false,
30
+ )
31
+
32
+ external asIdentity: 'a => Reventless.Identity.t = "%identity"
33
+
34
+ let cognito = (~userId, ~groups): Reventless.Identity.t =>
35
+ asIdentity({"userId": userId, "username": userId, "groups": groups, "provider": "Cognito"})
36
+
37
+ let iam: Reventless.Identity.t = asIdentity({
38
+ "userArn": "arn:aws:sts::1:assumed-role/Ingester",
39
+ "accountId": "1",
40
+ "username": "Ingester",
41
+ "provider": "IAM",
42
+ })
43
+
44
+ let payload = (~command, ~args: dict<JSON.t>, ~identity): CommandGenerator.payload =>
45
+ Obj.magic({
46
+ "command": command,
47
+ "arguments": Obj.magic(args),
48
+ "meta": {"ip": ["127.0.0.1"], "user": "test", "info": ""},
49
+ "identity": identity,
50
+ })
51
+
52
+ let str = JSON.Encode.string
53
+
54
+ let placeOrder = (~customerId, ~identity) =>
55
+ payload(
56
+ ~command="PlaceOrder",
57
+ ~args=Dict.fromArray([
58
+ ("orderId", str("o-1")),
59
+ ("customerId", str(customerId)),
60
+ ("note", str("gift wrap")),
61
+ ]),
62
+ ~identity,
63
+ )
64
+
65
+ let fieldOf = (cmd: Message.commandJson, name) =>
66
+ cmd.commandJson->JSON.Decode.object->Option.flatMap(o => o->Dict.get(name))
67
+
68
+ let lastPublished = () => published.contents->Array.getUnsafe(published.contents->Array.length - 1)
69
+
70
+ let _ = beforeEach(() => {
71
+ published := []
72
+ Reventless.OwnerScope.setElevatedGroups(["Admin"])
73
+ })
74
+
75
+ describe("@owner stamping on the command path:", () => {
76
+ testPromise("an omitted owner field is filled from the caller", async () => {
77
+ let _ = await generate(
78
+ payload(
79
+ ~command="PlaceOrder",
80
+ ~args=Dict.fromArray([("orderId", str("o-1")), ("note", str(""))]),
81
+ ~identity=cognito(~userId="cust-A", ~groups=["User"]),
82
+ ),
83
+ )->Effect.runPromise
84
+ expect(lastPublished()->fieldOf("customerId"))->toEqual(Some(str("cust-A")))
85
+ })
86
+
87
+ // The decisive case. An implementation that fills only the absent field passes
88
+ // every other test in this file and hands one customer another's orders.
89
+ testPromise("a FORGED owner field is overwritten, not respected", async () => {
90
+ let _ =
91
+ await generate(
92
+ placeOrder(~customerId="cust-B", ~identity=cognito(~userId="cust-A", ~groups=["User"])),
93
+ )->Effect.runPromise
94
+ expect(lastPublished()->fieldOf("customerId"))->toEqual(Some(str("cust-A")))
95
+ })
96
+
97
+ testPromise("non-owner fields of the same command are untouched", async () => {
98
+ let _ =
99
+ await generate(
100
+ placeOrder(~customerId="cust-B", ~identity=cognito(~userId="cust-A", ~groups=["User"])),
101
+ )->Effect.runPromise
102
+ expect(lastPublished()->fieldOf("note"))->toEqual(Some(str("gift wrap")))
103
+ })
104
+
105
+ testPromise("a command in the same union with no owner field is left alone", async () => {
106
+ let _ =
107
+ await generate(
108
+ payload(
109
+ ~command="ImportProducts",
110
+ ~args=Dict.fromArray([("batchId", str("b-1")), ("source", str("acme"))]),
111
+ ~identity=cognito(~userId="cust-A", ~groups=["User"]),
112
+ ),
113
+ )->Effect.runPromise
114
+ expect((lastPublished()->fieldOf("source"), lastPublished()->fieldOf("customerId")))->toEqual((
115
+ Some(str("acme")),
116
+ None,
117
+ ))
118
+ })
119
+
120
+ describe("exempt callers keep the value they sent:", () => {
121
+ // Acting on another principal's behalf must stay possible for those who may
122
+ // — otherwise an operator can never correct or back-fill anything.
123
+ testPromise("an elevated caller's chosen customerId survives", async () => {
124
+ let _ =
125
+ await generate(
126
+ placeOrder(~customerId="cust-B", ~identity=cognito(~userId="ops-1", ~groups=["Admin"])),
127
+ )->Effect.runPromise
128
+ expect(lastPublished()->fieldOf("customerId"))->toEqual(Some(str("cust-B")))
129
+ })
130
+
131
+ // The IAM shape carries no userId at all. Stamping it would write
132
+ // `undefined`; refusing it would break internal traffic. It is exempt.
133
+ testPromise("an IAM system caller's chosen customerId survives", async () => {
134
+ let _ =
135
+ await generate(placeOrder(~customerId="cust-B", ~identity=iam))->Effect.runPromise
136
+ expect(lastPublished()->fieldOf("customerId"))->toEqual(Some(str("cust-B")))
137
+ })
138
+ })
139
+
140
+ describe("a caller that cannot be identified is refused:", () => {
141
+ let refused = async p =>
142
+ switch await generate(p)->Effect.runPromise {
143
+ | _ => false
144
+ | exception _ => true
145
+ }
146
+
147
+ testPromise("an anonymous caller cannot place an owned command", async () =>
148
+ expect(
149
+ await refused(placeOrder(~customerId="cust-B", ~identity=Reventless.Identity.anonymous)),
150
+ )->toBe(true)
151
+ )
152
+
153
+ testPromise("nothing is published when the caller is refused", async () => {
154
+ let _ = await refused(
155
+ placeOrder(~customerId="cust-B", ~identity=Reventless.Identity.anonymous),
156
+ )
157
+ expect(published.contents->Array.length)->toBe(0)
158
+ })
159
+
160
+ // The refusal is scoped to commands that actually record an owner. An
161
+ // anonymous caller invoking an unowned command has nothing to prove, and
162
+ // refusing it here would silently narrow `AllowAnonymous`.
163
+ testPromise("an anonymous caller may still issue an unowned command", async () => {
164
+ let _ = await generate(
165
+ payload(
166
+ ~command="ImportProducts",
167
+ ~args=Dict.fromArray([("batchId", str("b-1")), ("source", str("acme"))]),
168
+ ~identity=Reventless.Identity.anonymous,
169
+ ),
170
+ )->Effect.runPromise
171
+ expect(lastPublished()->fieldOf("source"))->toEqual(Some(str("acme")))
172
+ })
173
+ })
174
+ })