@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.
- package/CHANGELOG.md +14 -0
- package/package.json +6 -6
- package/src/admin/Platform_Admin_Structure.res +6 -0
- package/src/admin/Platform_Admin_Structure.res.mjs +9 -3
- package/src/admin/Platform_BakedManifest.res +154 -0
- package/src/admin/Platform_BakedManifest.res.mjs +156 -0
- package/src/admin/Platform_ComponentDefinitionsApi.res +9 -2
- package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +18 -2
- package/src/components/Api/OwnerScopeDiagnostics.res +54 -0
- package/src/components/Api/OwnerScopeDiagnostics.res.mjs +27 -0
- package/src/components/Api/QueryDbListQuery.res +25 -1
- package/src/components/Api/QueryDbListQuery.res.mjs +10 -3
- package/src/components/Api/SuryToJsonSchema.res +19 -0
- package/src/components/Api/SuryToJsonSchema.res.mjs +18 -4
- package/src/components/CommandGenerator/CommandGenerator_Callback.res +48 -0
- package/src/components/CommandGenerator/CommandGenerator_Callback.res.mjs +27 -1
- package/src/plugin/component/Plugin_Structure.res +59 -3
- package/src/plugin/component/Plugin_Structure.res.mjs +29 -9
- package/tests/admin/Platform_BakedManifestTest.res +244 -0
- package/tests/admin/Platform_BakedManifestTest.res.mjs +294 -0
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res +10 -0
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +24 -8
- package/tests/admin/Platform_PluginStructuresApiTest.res +2 -0
- package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +6 -2
- package/tests/api/Api_IdsTest.res.mjs +1 -1
- package/tests/api/OwnerScopeDiagnosticsTest.res +109 -0
- package/tests/api/OwnerScopeDiagnosticsTest.res.mjs +91 -0
- package/tests/api/SuryToJsonSchemaTest.res +45 -0
- package/tests/api/SuryToJsonSchemaTest.res.mjs +23 -0
- package/tests/commandgenerator/OwnerStampingTest.res +174 -0
- package/tests/commandgenerator/OwnerStampingTest.res.mjs +195 -0
- package/tests/plugin/PluginStructureAccessTest.res +110 -0
- package/tests/plugin/PluginStructureAccessTest.res.mjs +144 -0
- package/tests/plugin/StateChangeSlice/PsGatedCommands.res +28 -0
- package/tests/plugin/StateChangeSlice/PsGatedCommands.res.mjs +84 -0
- package/tests/plugin/StateViewSlice/PsGatedView.res +15 -0
- package/tests/plugin/StateViewSlice/PsGatedView.res.mjs +60 -0
- 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
|
+
})
|