@reventlessdev/reventless-local 3.0.0-alpha.287 → 3.0.0-alpha.289
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 +20 -0
- package/package.json +10 -10
- package/src/adapter/Webhook/LocalWebhookRegistry.res +64 -0
- package/src/adapter/Webhook/LocalWebhookRegistry.res.mjs +52 -0
- package/src/components/InboundTranslationSlice_Builder.res +10 -1
- package/src/components/InboundTranslationSlice_Builder.res.mjs +9 -1
- package/tests/adapter/InboundTranslationMutationTest.res.mjs +6 -4
- package/tests/components/inboundtranslationslice/InboundTranslationSliceCallbackTest.res +2 -0
- package/tests/components/inboundtranslationslice/InboundTranslationSliceCallbackTest.res.mjs +25 -20
- package/tests/components/inboundtranslationslice/InboundTranslationSliceFixtures.res +3 -0
- package/tests/components/inboundtranslationslice/InboundTranslationSliceFixtures.res.mjs +3 -0
- package/tests/components/inboundtranslationslice/InboundTranslationWebhookTest.res +305 -0
- package/tests/components/inboundtranslationslice/InboundTranslationWebhookTest.res.mjs +473 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,26 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# 3.0.0-alpha.289 (2026-10-16)
|
|
7
|
+
|
|
8
|
+
* feat!(inbound): a translation slice can declare a signed webhook URL ([0ad6994](https://github.com/ReventlessDev/reventless-core/commit/0ad69949fcc91c2756c60ca8cb120dd468841d8b))
|
|
9
|
+
|
|
10
|
+
### BREAKING CHANGES
|
|
11
|
+
|
|
12
|
+
* InboundTranslationSlice.Spec requires `endpoint`. Specs under
|
|
13
|
+
@@reventless.spec get it from the PPX (republish reventless-ppx with this
|
|
14
|
+
release); hand-written specs add `let endpoint = None`.
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# 3.0.0-alpha.288 (2026-10-16)
|
|
19
|
+
|
|
20
|
+
**Note:** Version bump only for package @reventlessdev/reventless-local
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
6
26
|
# 3.0.0-alpha.287 (2026-10-16)
|
|
7
27
|
|
|
8
28
|
* feat!(automation): schedules set by events ([53bc436](https://github.com/ReventlessDev/reventless-core/commit/53bc4363dffce81ef36475728bf0dae704716021))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-local",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.289",
|
|
4
4
|
"description": "Local platform for Reventless (in-memory or SQLite backend, for development and testing without AWS)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"bin": {
|
|
@@ -35,23 +35,23 @@
|
|
|
35
35
|
"sury": "11.0.0",
|
|
36
36
|
"ws": "^8.18.0",
|
|
37
37
|
"@reventlessdev/rescript-effect": "1.0.0-alpha.0",
|
|
38
|
-
"@reventlessdev/rescript-jest": "1.0.0-alpha.12",
|
|
39
38
|
"@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.23",
|
|
40
39
|
"@reventlessdev/rescript-node": "2.0.0-alpha.22",
|
|
40
|
+
"@reventlessdev/rescript-jest": "1.0.0-alpha.12",
|
|
41
41
|
"@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.29",
|
|
42
|
-
"@reventlessdev/rescript-pulumi-pulumi": "
|
|
43
|
-
"@reventlessdev/reventless-
|
|
44
|
-
"@reventlessdev/reventless-
|
|
45
|
-
"@reventlessdev/reventless-gwt": "1.0.0-alpha.
|
|
46
|
-
"@reventlessdev/reventless-infra": "3.0.0-alpha.
|
|
42
|
+
"@reventlessdev/rescript-pulumi-pulumi": "3.0.0-alpha.0",
|
|
43
|
+
"@reventlessdev/reventless-core": "3.0.0-alpha.298",
|
|
44
|
+
"@reventlessdev/reventless-graphql-server": "1.0.0-alpha.146",
|
|
45
|
+
"@reventlessdev/reventless-gwt": "1.0.0-alpha.246",
|
|
46
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.195",
|
|
47
47
|
"@reventlessdev/reventless-seed": "1.0.0-alpha.34",
|
|
48
|
-
"@reventlessdev/reventless-
|
|
49
|
-
"@reventlessdev/reventless-
|
|
48
|
+
"@reventlessdev/reventless-postgres": "3.0.0-alpha.162",
|
|
49
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.167"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"rescript": "12.3.1",
|
|
53
53
|
"sury-ppx": "11.0.0",
|
|
54
|
-
"@reventlessdev/reventless-ppx": "1.0.0-alpha.
|
|
54
|
+
"@reventlessdev/reventless-ppx": "1.0.0-alpha.102"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
57
57
|
"rescript": "^12.3.1"
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// The local platform's webhook routes, served in process: no HTTP server, so a
|
|
2
|
+
// test (or a dev tool) posts a raw body with `post`, which runs the same
|
|
3
|
+
// signature check and dispatch as the AWS Function URL.
|
|
4
|
+
|
|
5
|
+
open ReventlessCore
|
|
6
|
+
|
|
7
|
+
let slices: dict<InboundTranslationSlice_Webhook.slice> = Dict.make()
|
|
8
|
+
|
|
9
|
+
/** Register a slice's route now, and bind its operations when they resolve. A
|
|
10
|
+
request posted before then waits for them rather than failing. Two slices may
|
|
11
|
+
not share a path. */
|
|
12
|
+
let register = (
|
|
13
|
+
~route: InboundTranslationSlice_Webhook.route,
|
|
14
|
+
~operations: Pulumi.Output.t<ReventlessInfra.InboundTranslationSlice.operations>,
|
|
15
|
+
) => {
|
|
16
|
+
switch slices->Dict.get(route.path) {
|
|
17
|
+
| Some({route: taken}) =>
|
|
18
|
+
JsError.throwWithMessage(
|
|
19
|
+
`webhook path /${route.path} is declared by both ${taken.slice} and ${route.slice}`,
|
|
20
|
+
)
|
|
21
|
+
| None => ()
|
|
22
|
+
}
|
|
23
|
+
let bind = ref(_ => ())
|
|
24
|
+
let bound: promise<ReventlessInfra.InboundTranslationSlice.operations> = Promise.make((
|
|
25
|
+
resolve,
|
|
26
|
+
_,
|
|
27
|
+
) => bind := resolve)
|
|
28
|
+
let _ = operations->Pulumi.Output.apply(ops => bind.contents(ops))
|
|
29
|
+
slices->Dict.set(
|
|
30
|
+
route.path,
|
|
31
|
+
{
|
|
32
|
+
route,
|
|
33
|
+
receive: async (input, ~caller) => await (await bound).receive(input, ~caller, ~door=Webhook),
|
|
34
|
+
refuse: async (~body, ~errorCode, error) =>
|
|
35
|
+
await (await bound).refuse(~body, ~errorCode, error),
|
|
36
|
+
},
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Serve one request. Header names are matched case-insensitively. */
|
|
41
|
+
let post = (
|
|
42
|
+
~path: string,
|
|
43
|
+
~method: string="POST",
|
|
44
|
+
~headers: dict<string>=Dict.make(),
|
|
45
|
+
~secretOf=?,
|
|
46
|
+
~nowSeconds=?,
|
|
47
|
+
body: string,
|
|
48
|
+
): promise<InboundTranslationSlice_Webhook.response> =>
|
|
49
|
+
InboundTranslationSlice_Webhook.dispatch(
|
|
50
|
+
slices->Dict.valuesToArray,
|
|
51
|
+
~secretOf?,
|
|
52
|
+
~nowSeconds?,
|
|
53
|
+
{
|
|
54
|
+
method,
|
|
55
|
+
path,
|
|
56
|
+
headers: headers
|
|
57
|
+
->Dict.toArray
|
|
58
|
+
->Array.map(((name, value)) => (name->String.toLowerCase, value))
|
|
59
|
+
->Dict.fromArray,
|
|
60
|
+
body,
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
let reset = () => slices->Dict.keysToArray->Array.forEach(path => slices->Dict.delete(path))
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
|
|
4
|
+
import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
5
|
+
import * as InboundTranslationSlice_Webhook$ReventlessCore from "@reventlessdev/reventless-core/src/components/InboundTranslationSlice/InboundTranslationSlice_Webhook.res.mjs";
|
|
6
|
+
|
|
7
|
+
let slices = {};
|
|
8
|
+
|
|
9
|
+
function register(route, operations) {
|
|
10
|
+
let match = slices[route.path];
|
|
11
|
+
if (match !== undefined) {
|
|
12
|
+
Stdlib_JsError.throwWithMessage(`webhook path /` + route.path + ` is declared by both ` + match.route.slice + ` and ` + route.slice);
|
|
13
|
+
}
|
|
14
|
+
let bind = {
|
|
15
|
+
contents: param => {}
|
|
16
|
+
};
|
|
17
|
+
let bound = new Promise((resolve, param) => {
|
|
18
|
+
bind.contents = resolve;
|
|
19
|
+
});
|
|
20
|
+
operations.apply(ops => bind.contents(ops));
|
|
21
|
+
slices[route.path] = {
|
|
22
|
+
route: route,
|
|
23
|
+
receive: async (input, caller) => await (await bound).receive(input, caller, "Webhook"),
|
|
24
|
+
refuse: async (body, errorCode, error) => await (await bound).refuse(body, errorCode, error)
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function post(path, methodOpt, headersOpt, secretOf, nowSeconds, body) {
|
|
29
|
+
let method = methodOpt !== undefined ? methodOpt : "POST";
|
|
30
|
+
let headers = headersOpt !== undefined ? headersOpt : ({});
|
|
31
|
+
return InboundTranslationSlice_Webhook$ReventlessCore.dispatch(Object.values(slices), secretOf, nowSeconds, {
|
|
32
|
+
method: method,
|
|
33
|
+
path: path,
|
|
34
|
+
headers: Object.fromEntries(Object.entries(headers).map(param => [
|
|
35
|
+
param[0].toLowerCase(),
|
|
36
|
+
param[1]
|
|
37
|
+
])),
|
|
38
|
+
body: body
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function reset() {
|
|
43
|
+
Object.keys(slices).forEach(path => Stdlib_Dict.$$delete(slices, path));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export {
|
|
47
|
+
slices,
|
|
48
|
+
register,
|
|
49
|
+
post,
|
|
50
|
+
reset,
|
|
51
|
+
}
|
|
52
|
+
/* InboundTranslationSlice_Webhook-ReventlessCore Not a pure module */
|
|
@@ -26,7 +26,16 @@ module Make = (Bus: LocalBus.T) => {
|
|
|
26
26
|
module Translation = Translation
|
|
27
27
|
type component = Inner.component
|
|
28
28
|
let queryDbName = Inner.queryDbName
|
|
29
|
-
let make =
|
|
29
|
+
let make = (~publishJsons, ~runtime=?, ~opts=?) => {
|
|
30
|
+
let component = Inner.make(~publishJsons, ~runtime?, ~opts?)
|
|
31
|
+
Spec.endpoint->Option.forEach(endpoint =>
|
|
32
|
+
LocalWebhookRegistry.register(
|
|
33
|
+
~route=ReventlessCore.InboundTranslationSlice_Webhook.routeOf(~slice=Spec.name, endpoint),
|
|
34
|
+
~operations=component->ReventlessCore.Component.operations,
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
component
|
|
38
|
+
}
|
|
30
39
|
// Re-expose operations for test resolution
|
|
31
40
|
let operations: component => Pulumi.Output.t<
|
|
32
41
|
ReventlessCore.InboundTranslationSlice.operations,
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
3
4
|
import * as Component$ReventlessCore from "@reventlessdev/reventless-core/src/components/Component.res.mjs";
|
|
4
5
|
import * as LocalQueryDbStorage$ReventlessLocal from "../adapter/QueryDb/LocalQueryDbStorage.res.mjs";
|
|
6
|
+
import * as LocalWebhookRegistry$ReventlessLocal from "../adapter/Webhook/LocalWebhookRegistry.res.mjs";
|
|
5
7
|
import * as QueryDbResolvers_GraphQL$ReventlessLocal from "../adapter/QueryDb/QueryDbResolvers_GraphQL.res.mjs";
|
|
6
8
|
import * as InboundTranslationSlice_Builder$ReventlessCore from "@reventlessdev/reventless-core/src/components/InboundTranslationSlice/InboundTranslationSlice_Builder.res.mjs";
|
|
9
|
+
import * as InboundTranslationSlice_Webhook$ReventlessCore from "@reventlessdev/reventless-core/src/components/InboundTranslationSlice/InboundTranslationSlice_Webhook.res.mjs";
|
|
7
10
|
|
|
8
11
|
function Make(Bus) {
|
|
9
12
|
let QueryDbStorage = LocalQueryDbStorage$ReventlessLocal.Make(Bus);
|
|
@@ -21,12 +24,17 @@ function Make(Bus) {
|
|
|
21
24
|
})(Api);
|
|
22
25
|
let Make$1 = Spec => (Translation => {
|
|
23
26
|
let Inner = CoreMaker.Make(Spec)(Translation);
|
|
27
|
+
let make = (publishJsons, runtime, opts) => {
|
|
28
|
+
let component = Inner.make(publishJsons, runtime, opts);
|
|
29
|
+
Stdlib_Option.forEach(Spec.endpoint, endpoint => LocalWebhookRegistry$ReventlessLocal.register(InboundTranslationSlice_Webhook$ReventlessCore.routeOf(Spec.name, endpoint), Component$ReventlessCore.operations(component)));
|
|
30
|
+
return component;
|
|
31
|
+
};
|
|
24
32
|
return {
|
|
25
33
|
Inner: Inner,
|
|
26
34
|
Spec: Spec,
|
|
27
35
|
Translation: Translation,
|
|
28
36
|
queryDbName: Inner.queryDbName,
|
|
29
|
-
make:
|
|
37
|
+
make: make,
|
|
30
38
|
operations: Component$ReventlessCore.operations
|
|
31
39
|
};
|
|
32
40
|
});
|
|
@@ -29,7 +29,8 @@ let Callback = InboundTranslationSlice_Callback$ReventlessCore.Make({
|
|
|
29
29
|
commandSchema: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.commandSchema,
|
|
30
30
|
targetName: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.targetName,
|
|
31
31
|
externalSystem: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.externalSystem,
|
|
32
|
-
authorizationOf: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.authorizationOf
|
|
32
|
+
authorizationOf: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.authorizationOf,
|
|
33
|
+
endpoint: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.endpoint
|
|
33
34
|
})(PaymentWebhookTranslation);
|
|
34
35
|
|
|
35
36
|
let scope = "Payments";
|
|
@@ -106,7 +107,7 @@ globalThis.describe("InboundTranslationSlice mutation — response envelope", ()
|
|
|
106
107
|
DomainGraphQL_Server$ReventlessLocal.setScope(scope);
|
|
107
108
|
InboundTranslationResolvers_GraphQL$ReventlessLocal.register(fieldName, InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.externalInputSchema, "AllowAuthenticated", DomainGraphQL_Server$ReventlessLocal.asInterface);
|
|
108
109
|
DomainGraphQL_Server$ReventlessLocal.resetScope();
|
|
109
|
-
InboundTranslationResolvers_GraphQL$ReventlessLocal.bindReceive(fieldName, (inputJson, caller) => Callback.receive(publishJsons, inputJson, caller));
|
|
110
|
+
InboundTranslationResolvers_GraphQL$ReventlessLocal.bindReceive(fieldName, (inputJson, caller) => Callback.receive(publishJsons, inputJson, caller, undefined));
|
|
110
111
|
});
|
|
111
112
|
globalThis.test("a successful translation resolves as CommandAccepted, with no errors", async () => {
|
|
112
113
|
let result = await runMutation("completed");
|
|
@@ -157,7 +158,8 @@ let CatalogFeedCallback = InboundTranslationSlice_Callback$ReventlessCore.Make({
|
|
|
157
158
|
commandSchema: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.commandSchema,
|
|
158
159
|
targetName: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.targetName,
|
|
159
160
|
externalSystem: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.externalSystem,
|
|
160
|
-
authorizationOf: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.authorizationOf
|
|
161
|
+
authorizationOf: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.authorizationOf,
|
|
162
|
+
endpoint: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.endpoint
|
|
161
163
|
})(CatalogFeedTranslation);
|
|
162
164
|
|
|
163
165
|
let feedField = "Catalog_CatalogFeed";
|
|
@@ -173,7 +175,7 @@ globalThis.describe("InboundTranslationSlice mutation — the door", () => {
|
|
|
173
175
|
DomainGraphQL_Server$ReventlessLocal.setScope("Catalog");
|
|
174
176
|
InboundTranslationResolvers_GraphQL$ReventlessLocal.register(feedField, InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.externalInputSchema, Dcb_Builder$ReventlessCore.inboundDoorPermission(InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.commandSchema, InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.authorizationOf), DomainGraphQL_Server$ReventlessLocal.asInterface);
|
|
175
177
|
DomainGraphQL_Server$ReventlessLocal.resetScope();
|
|
176
|
-
InboundTranslationResolvers_GraphQL$ReventlessLocal.bindReceive(feedField, (inputJson, caller) => CatalogFeedCallback.receive(publishJsons, inputJson, caller));
|
|
178
|
+
InboundTranslationResolvers_GraphQL$ReventlessLocal.bindReceive(feedField, (inputJson, caller) => CatalogFeedCallback.receive(publishJsons, inputJson, caller, undefined));
|
|
177
179
|
});
|
|
178
180
|
globalThis.test("a caller holding none of the commands' roles is refused", async () => {
|
|
179
181
|
let node = payload(await runFeed(signedIn(["Ops"]), "product"));
|
|
@@ -215,6 +215,7 @@ describe("InboundTranslationSlice Callback", () => {
|
|
|
215
215
|
|
|
216
216
|
let targetName = "ConfirmPayment"
|
|
217
217
|
let externalSystem = None
|
|
218
|
+
let endpoint = None
|
|
218
219
|
|
|
219
220
|
let translate = (input: externalInput) => Ok(
|
|
220
221
|
input.items->Array.map(
|
|
@@ -285,6 +286,7 @@ describe("InboundTranslationSlice Callback", () => {
|
|
|
285
286
|
|
|
286
287
|
let targetName = "ConfirmPayment"
|
|
287
288
|
let externalSystem = None
|
|
289
|
+
let endpoint = None
|
|
288
290
|
|
|
289
291
|
let translate = (_input: externalInput) => Ok([])
|
|
290
292
|
|
package/tests/components/inboundtranslationslice/InboundTranslationSliceCallbackTest.res.mjs
CHANGED
|
@@ -26,7 +26,8 @@ let Callback = InboundTranslationSlice_Callback$ReventlessCore.Make({
|
|
|
26
26
|
commandSchema: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.commandSchema,
|
|
27
27
|
targetName: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.targetName,
|
|
28
28
|
externalSystem: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.externalSystem,
|
|
29
|
-
authorizationOf: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.authorizationOf
|
|
29
|
+
authorizationOf: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.authorizationOf,
|
|
30
|
+
endpoint: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.endpoint
|
|
30
31
|
})(PaymentWebhookTranslation);
|
|
31
32
|
|
|
32
33
|
globalThis.describe("InboundTranslationSlice Callback", () => {
|
|
@@ -46,7 +47,7 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
|
|
|
46
47
|
orderId: "ord-1",
|
|
47
48
|
status: "completed"
|
|
48
49
|
};
|
|
49
|
-
let result = await Callback.receive(mockPublish, inputJson, undefined);
|
|
50
|
+
let result = await Callback.receive(mockPublish, inputJson, undefined, undefined);
|
|
50
51
|
if (result.TAG === "Ok") {
|
|
51
52
|
let match = result._0;
|
|
52
53
|
globalThis.expect(match.targetIds).toEqual(["ord-1"]);
|
|
@@ -77,7 +78,7 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
|
|
|
77
78
|
orderId: "ord-1",
|
|
78
79
|
status: "pending"
|
|
79
80
|
};
|
|
80
|
-
let result = await Callback.receive(mockPublish, inputJson, undefined);
|
|
81
|
+
let result = await Callback.receive(mockPublish, inputJson, undefined, undefined);
|
|
81
82
|
if (result.TAG === "Ok") {
|
|
82
83
|
globalThis.expect(true).toBe(false);
|
|
83
84
|
} else {
|
|
@@ -94,7 +95,7 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
|
|
|
94
95
|
let inputJson = {
|
|
95
96
|
unexpected: "data"
|
|
96
97
|
};
|
|
97
|
-
let result = await Callback.receive(mockPublish, inputJson, undefined);
|
|
98
|
+
let result = await Callback.receive(mockPublish, inputJson, undefined, undefined);
|
|
98
99
|
if (result.TAG === "Ok") {
|
|
99
100
|
globalThis.expect(true).toBe(false);
|
|
100
101
|
} else {
|
|
@@ -112,7 +113,7 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
|
|
|
112
113
|
orderId: "ord-1",
|
|
113
114
|
status: "completed"
|
|
114
115
|
};
|
|
115
|
-
let result = await Callback.receive(failingPublish, inputJson, undefined);
|
|
116
|
+
let result = await Callback.receive(failingPublish, inputJson, undefined, undefined);
|
|
116
117
|
if (result.TAG === "Ok") {
|
|
117
118
|
globalThis.expect(true).toBe(false);
|
|
118
119
|
} else {
|
|
@@ -131,7 +132,7 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
|
|
|
131
132
|
paymentId: "pay-1",
|
|
132
133
|
orderId: "ord-1",
|
|
133
134
|
status: "completed"
|
|
134
|
-
}, undefined);
|
|
135
|
+
}, undefined, undefined);
|
|
135
136
|
let firstId = InboundTranslationSlice_Callback$ReventlessCore.requestIdOf(first);
|
|
136
137
|
globalThis.expect(Stdlib_Option.isSome(InboundTranslationSlice_Callback$ReventlessCore.takeAuditRow(Callback.auditLog, firstId))).toBe(true);
|
|
137
138
|
globalThis.expect(Object.entries(Callback.auditLog).length).toBe(0);
|
|
@@ -139,7 +140,7 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
|
|
|
139
140
|
paymentId: "pay-1",
|
|
140
141
|
orderId: "ord-1",
|
|
141
142
|
status: "completed"
|
|
142
|
-
}, undefined);
|
|
143
|
+
}, undefined, undefined);
|
|
143
144
|
let secondId = InboundTranslationSlice_Callback$ReventlessCore.requestIdOf(second);
|
|
144
145
|
globalThis.expect(secondId === firstId).toBe(false);
|
|
145
146
|
globalThis.expect(Object.entries(Callback.auditLog).length).toBe(1);
|
|
@@ -152,7 +153,7 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
|
|
|
152
153
|
paymentId: "pay-1",
|
|
153
154
|
orderId: "ord-1",
|
|
154
155
|
status: "pending"
|
|
155
|
-
}, undefined);
|
|
156
|
+
}, undefined, undefined);
|
|
156
157
|
let id = InboundTranslationSlice_Callback$ReventlessCore.requestIdOf(result);
|
|
157
158
|
let row = InboundTranslationSlice_Callback$ReventlessCore.takeAuditRow(Callback.auditLog, id);
|
|
158
159
|
if (row !== undefined) {
|
|
@@ -198,7 +199,8 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
|
|
|
198
199
|
commandSchema: commandSchema,
|
|
199
200
|
targetName: "ConfirmPayment",
|
|
200
201
|
externalSystem: undefined,
|
|
201
|
-
authorizationOf: authorizationOf
|
|
202
|
+
authorizationOf: authorizationOf,
|
|
203
|
+
endpoint: undefined
|
|
202
204
|
})(MultiTranslation);
|
|
203
205
|
let publishedCommands = {
|
|
204
206
|
contents: []
|
|
@@ -214,7 +216,7 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
|
|
|
214
216
|
"pay-3"
|
|
215
217
|
]
|
|
216
218
|
};
|
|
217
|
-
let result = await MultiCallback.receive(mockPublish, inputJson, undefined);
|
|
219
|
+
let result = await MultiCallback.receive(mockPublish, inputJson, undefined, undefined);
|
|
218
220
|
if (result.TAG === "Ok") {
|
|
219
221
|
let match = result._0;
|
|
220
222
|
globalThis.expect(match.targetIds).toEqual([
|
|
@@ -260,7 +262,8 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
|
|
|
260
262
|
commandSchema: commandSchema,
|
|
261
263
|
targetName: "ConfirmPayment",
|
|
262
264
|
externalSystem: undefined,
|
|
263
|
-
authorizationOf: authorizationOf
|
|
265
|
+
authorizationOf: authorizationOf,
|
|
266
|
+
endpoint: undefined
|
|
264
267
|
})(EmptyTranslation);
|
|
265
268
|
let publishedCommands = {
|
|
266
269
|
contents: []
|
|
@@ -271,7 +274,7 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
|
|
|
271
274
|
let inputJson = {
|
|
272
275
|
orderId: "ord-1"
|
|
273
276
|
};
|
|
274
|
-
let result = await EmptyCallback.receive(mockPublish, inputJson, undefined);
|
|
277
|
+
let result = await EmptyCallback.receive(mockPublish, inputJson, undefined, undefined);
|
|
275
278
|
if (result.TAG === "Ok") {
|
|
276
279
|
let match = result._0;
|
|
277
280
|
globalThis.expect(match.targetIds).toEqual([]);
|
|
@@ -300,7 +303,8 @@ let CatalogFeedCallback = InboundTranslationSlice_Callback$ReventlessCore.Make({
|
|
|
300
303
|
commandSchema: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.commandSchema,
|
|
301
304
|
targetName: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.targetName,
|
|
302
305
|
externalSystem: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.externalSystem,
|
|
303
|
-
authorizationOf: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.authorizationOf
|
|
306
|
+
authorizationOf: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.authorizationOf,
|
|
307
|
+
endpoint: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.endpoint
|
|
304
308
|
})(CatalogFeedTranslation);
|
|
305
309
|
|
|
306
310
|
function caller(groups) {
|
|
@@ -336,7 +340,7 @@ globalThis.describe("receive checks each command against its own rule", () => {
|
|
|
336
340
|
published.contents = [];
|
|
337
341
|
});
|
|
338
342
|
globalThis.test("a caller lacking one command's role is refused, and nothing is published", async () => {
|
|
339
|
-
let result = await CatalogFeedCallback.receive(publish, catalogInput("both"), caller(["Merchandiser"]));
|
|
343
|
+
let result = await CatalogFeedCallback.receive(publish, catalogInput("both"), caller(["Merchandiser"]), undefined);
|
|
340
344
|
if (result.TAG === "Ok") {
|
|
341
345
|
globalThis.expect(true).toBe(false);
|
|
342
346
|
} else {
|
|
@@ -351,22 +355,22 @@ globalThis.describe("receive checks each command against its own rule", () => {
|
|
|
351
355
|
let result = await CatalogFeedCallback.receive(publish, catalogInput("both"), caller([
|
|
352
356
|
"Merchandiser",
|
|
353
357
|
"Admin"
|
|
354
|
-
]));
|
|
358
|
+
]), undefined);
|
|
355
359
|
globalThis.expect(Stdlib_Result.isOk(result)).toBe(true);
|
|
356
360
|
globalThis.expect(published.contents.length).toBe(2);
|
|
357
361
|
});
|
|
358
362
|
globalThis.test("the role one command needs is enough when the input yields only it", async () => {
|
|
359
|
-
let result = await CatalogFeedCallback.receive(publish, catalogInput("product"), caller(["Merchandiser"]));
|
|
363
|
+
let result = await CatalogFeedCallback.receive(publish, catalogInput("product"), caller(["Merchandiser"]), undefined);
|
|
360
364
|
globalThis.expect(Stdlib_Result.isOk(result)).toBe(true);
|
|
361
365
|
globalThis.expect(published.contents.length).toBe(1);
|
|
362
366
|
});
|
|
363
367
|
globalThis.test("no caller is the platform itself, and publishes", async () => {
|
|
364
|
-
let result = await CatalogFeedCallback.receive(publish, catalogInput("both"), undefined);
|
|
368
|
+
let result = await CatalogFeedCallback.receive(publish, catalogInput("both"), undefined, undefined);
|
|
365
369
|
globalThis.expect(Stdlib_Result.isOk(result)).toBe(true);
|
|
366
370
|
globalThis.expect(published.contents.length).toBe(2);
|
|
367
371
|
});
|
|
368
372
|
globalThis.test("a refusal answers CommandRejected with Forbidden", async () => {
|
|
369
|
-
let result = await CatalogFeedCallback.receive(publish, catalogInput("both"), Identity$Reventless.anonymous);
|
|
373
|
+
let result = await CatalogFeedCallback.receive(publish, catalogInput("both"), Identity$Reventless.anonymous, undefined);
|
|
370
374
|
let match = InboundTranslationSlice_Callback$ReventlessCore.receiveResultToOutcome(result);
|
|
371
375
|
switch (match.TAG) {
|
|
372
376
|
case "Rejected" :
|
|
@@ -396,7 +400,8 @@ let NoteFeedCallback = InboundTranslationSlice_Callback$ReventlessCore.Make({
|
|
|
396
400
|
commandSchema: InboundTranslationSliceFixtures$ReventlessLocal.NoteFeedSpec.commandSchema,
|
|
397
401
|
targetName: InboundTranslationSliceFixtures$ReventlessLocal.NoteFeedSpec.targetName,
|
|
398
402
|
externalSystem: InboundTranslationSliceFixtures$ReventlessLocal.NoteFeedSpec.externalSystem,
|
|
399
|
-
authorizationOf: InboundTranslationSliceFixtures$ReventlessLocal.NoteFeedSpec.authorizationOf
|
|
403
|
+
authorizationOf: InboundTranslationSliceFixtures$ReventlessLocal.NoteFeedSpec.authorizationOf,
|
|
404
|
+
endpoint: InboundTranslationSliceFixtures$ReventlessLocal.NoteFeedSpec.endpoint
|
|
400
405
|
})(NoteFeedTranslation);
|
|
401
406
|
|
|
402
407
|
globalThis.describe("receive encodes commands with their schema", () => {
|
|
@@ -410,7 +415,7 @@ globalThis.describe("receive encodes commands with their schema", () => {
|
|
|
410
415
|
await NoteFeedCallback.receive(publish, Object.fromEntries([[
|
|
411
416
|
"orderId",
|
|
412
417
|
"ord-1"
|
|
413
|
-
]]), undefined);
|
|
418
|
+
]]), undefined, undefined);
|
|
414
419
|
let commandJson = published.contents[0].commandJson;
|
|
415
420
|
let decoded = Sury.parseOrThrow(commandJson, InboundTranslationSliceFixtures$ReventlessLocal.NoteFeedSpec.commandSchema);
|
|
416
421
|
globalThis.expect(decoded).toEqual({
|
|
@@ -28,6 +28,7 @@ module PaymentWebhookSpec = {
|
|
|
28
28
|
|
|
29
29
|
let targetName = "ConfirmPayment"
|
|
30
30
|
let externalSystem = None
|
|
31
|
+
let endpoint = None
|
|
31
32
|
|
|
32
33
|
let translate = (input: externalInput) =>
|
|
33
34
|
switch input.status {
|
|
@@ -52,6 +53,7 @@ module CatalogFeedSpec = {
|
|
|
52
53
|
|
|
53
54
|
let targetName = "AddProduct"
|
|
54
55
|
let externalSystem = None
|
|
56
|
+
let endpoint = None
|
|
55
57
|
|
|
56
58
|
type role = Reventless.Role.name
|
|
57
59
|
let merchandiser = Reventless.Role.make("Merchandiser")
|
|
@@ -89,6 +91,7 @@ module NoteFeedSpec = {
|
|
|
89
91
|
|
|
90
92
|
let targetName = "Annotate"
|
|
91
93
|
let externalSystem = None
|
|
94
|
+
let endpoint = None
|
|
92
95
|
|
|
93
96
|
let translate = (input: externalInput) => Ok([
|
|
94
97
|
(input.orderId, Annotate({orderId: input.orderId, note: None})),
|
|
@@ -65,6 +65,7 @@ let PaymentWebhookSpec = {
|
|
|
65
65
|
commandSchema: commandSchema,
|
|
66
66
|
targetName: "ConfirmPayment",
|
|
67
67
|
externalSystem: undefined,
|
|
68
|
+
endpoint: undefined,
|
|
68
69
|
translate: translate,
|
|
69
70
|
authorizationOf: authorizationOf,
|
|
70
71
|
traits: traits
|
|
@@ -160,6 +161,7 @@ let CatalogFeedSpec = {
|
|
|
160
161
|
commandSchema: commandSchema$1,
|
|
161
162
|
targetName: "AddProduct",
|
|
162
163
|
externalSystem: undefined,
|
|
164
|
+
endpoint: undefined,
|
|
163
165
|
merchandiser: merchandiser,
|
|
164
166
|
admin: admin,
|
|
165
167
|
authorizationOf: authorizationOf$1,
|
|
@@ -206,6 +208,7 @@ let NoteFeedSpec = {
|
|
|
206
208
|
commandSchema: commandSchema$2,
|
|
207
209
|
targetName: "Annotate",
|
|
208
210
|
externalSystem: undefined,
|
|
211
|
+
endpoint: undefined,
|
|
209
212
|
translate: translate$2,
|
|
210
213
|
authorizationOf: authorizationOf$2,
|
|
211
214
|
traits: traits$2
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
// The webhook door of an inbound translation slice: signature checks over the raw
|
|
2
|
+
// body, dispatch by path through the local registry, the caller a signed request
|
|
3
|
+
// acts as, and the audit row telling the two doors apart.
|
|
4
|
+
|
|
5
|
+
@@warning("-44")
|
|
6
|
+
|
|
7
|
+
open JestGlobals
|
|
8
|
+
|
|
9
|
+
let _ = TestRunner.setup()
|
|
10
|
+
|
|
11
|
+
module Webhook = ReventlessCore.InboundTranslationSlice_Webhook
|
|
12
|
+
module Callback = ReventlessCore.InboundTranslationSlice_Callback
|
|
13
|
+
|
|
14
|
+
let merchandiser = Reventless.Role.make("Merchandiser")
|
|
15
|
+
|
|
16
|
+
module FeedSpec = {
|
|
17
|
+
let name = "SignedFeed"
|
|
18
|
+
let moduleUrl = "signed-feed://spec"
|
|
19
|
+
@schema type externalInput = {kind: string, id: string}
|
|
20
|
+
@schema
|
|
21
|
+
type command =
|
|
22
|
+
| AddProduct({productId: @s.matches(Reventless.DcbTag.string) string})
|
|
23
|
+
| AddCategory({categoryId: @s.matches(Reventless.DcbTag.string) string})
|
|
24
|
+
let targetName = "AddProduct"
|
|
25
|
+
let externalSystem = None
|
|
26
|
+
type role = Reventless.Role.name
|
|
27
|
+
let authorizationOf = (name: string): Reventless.Authorization.rule<role> =>
|
|
28
|
+
switch name {
|
|
29
|
+
| "AddProduct" => AllowRoles([merchandiser])
|
|
30
|
+
| _ => AllowRoles([Reventless.Role.admin])
|
|
31
|
+
}
|
|
32
|
+
let endpoint = Some({
|
|
33
|
+
Reventless.InboundTranslationSlice.path: "/feeds/signed/",
|
|
34
|
+
auth: SignedBody({
|
|
35
|
+
header: "X-Signature",
|
|
36
|
+
prefix: "sha256=",
|
|
37
|
+
timestampHeader: "X-Timestamp",
|
|
38
|
+
secret: "feed",
|
|
39
|
+
}),
|
|
40
|
+
role: merchandiser,
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let translateFeed = (input: FeedSpec.externalInput) =>
|
|
45
|
+
switch input.kind {
|
|
46
|
+
| "product" => Ok([(input.id, FeedSpec.AddProduct({productId: input.id}))])
|
|
47
|
+
| "category" => Ok([(input.id, FeedSpec.AddCategory({categoryId: input.id}))])
|
|
48
|
+
| kind => Error(`unknown kind ${kind}`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module FeedCallback = Callback.Make(
|
|
52
|
+
FeedSpec,
|
|
53
|
+
{
|
|
54
|
+
let translate = translateFeed
|
|
55
|
+
let moduleUrl = FeedSpec.moduleUrl
|
|
56
|
+
},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
module OpenSpec = {
|
|
60
|
+
let name = "OpenFeed"
|
|
61
|
+
let moduleUrl = "open-feed://spec"
|
|
62
|
+
@schema type externalInput = {kind: string, id: string}
|
|
63
|
+
@schema type command = AddProduct({productId: @s.matches(Reventless.DcbTag.string) string})
|
|
64
|
+
let targetName = "AddProduct"
|
|
65
|
+
let externalSystem = None
|
|
66
|
+
type role = Reventless.Role.name
|
|
67
|
+
let authorizationOf = (_: string): Reventless.Authorization.rule<role> => AllowAuthenticated
|
|
68
|
+
let endpoint = Some({Reventless.InboundTranslationSlice.path: "open", auth: Open})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module OpenCallback = Callback.Make(
|
|
72
|
+
OpenSpec,
|
|
73
|
+
{
|
|
74
|
+
let translate = (input: OpenSpec.externalInput) => Ok([
|
|
75
|
+
(input.id, OpenSpec.AddProduct({productId: input.id})),
|
|
76
|
+
])
|
|
77
|
+
let moduleUrl = OpenSpec.moduleUrl
|
|
78
|
+
},
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
@schema type input = {id: string}
|
|
82
|
+
|
|
83
|
+
let published: ref<array<Reventless.Message.commandJson>> = ref([])
|
|
84
|
+
let publishJsons: ReventlessInfra.CommandTopic.publishJsons = async cmds =>
|
|
85
|
+
published := published.contents->Array.concat(cmds)
|
|
86
|
+
|
|
87
|
+
let feedOps: ReventlessInfra.InboundTranslationSlice.operations = {
|
|
88
|
+
receive: (input, ~caller=?, ~door=?) =>
|
|
89
|
+
FeedCallback.receive(publishJsons, input, ~caller?, ~door?),
|
|
90
|
+
refuse: async (~body, ~errorCode, error) => FeedCallback.refuse(~body, ~errorCode, error),
|
|
91
|
+
}
|
|
92
|
+
let openOps: ReventlessInfra.InboundTranslationSlice.operations = {
|
|
93
|
+
receive: (input, ~caller=?, ~door=?) =>
|
|
94
|
+
OpenCallback.receive(publishJsons, input, ~caller?, ~door?),
|
|
95
|
+
refuse: async (~body, ~errorCode, error) => OpenCallback.refuse(~body, ~errorCode, error),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The door an audit row records.
|
|
99
|
+
let viaWebhook: option<ReventlessInfra.InboundTranslationSlice.door> = Some(Webhook)
|
|
100
|
+
let viaMutation: option<ReventlessInfra.InboundTranslationSlice.door> = Some(Mutation)
|
|
101
|
+
|
|
102
|
+
let secret = "s3cret"
|
|
103
|
+
let now = 1_700_000_000.
|
|
104
|
+
let secretOf = name => name == "feed" ? Some(secret) : None
|
|
105
|
+
|
|
106
|
+
let signature: Reventless.InboundTranslationSlice.signature = {
|
|
107
|
+
header: "X-Signature",
|
|
108
|
+
prefix: "sha256=",
|
|
109
|
+
timestampHeader: "X-Timestamp",
|
|
110
|
+
secret: "feed",
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let hmac = (~secret, text) =>
|
|
114
|
+
NodeCrypto.createHmac("sha256", secret)->NodeCrypto.hmacUpdate(text)->NodeCrypto.hmacDigest("hex")
|
|
115
|
+
|
|
116
|
+
let signedHeaders = (~secret=secret, ~at=now, body) => {
|
|
117
|
+
let at = at->Float.toString
|
|
118
|
+
Dict.fromArray([
|
|
119
|
+
("x-timestamp", at),
|
|
120
|
+
("x-signature", "sha256=" ++ hmac(~secret, `${at}.${body}`)),
|
|
121
|
+
])
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let body = (~kind="product", ~id="p-1") => `{"kind":"${kind}","id":"${id}"}`
|
|
125
|
+
|
|
126
|
+
let post = (~path="feeds/signed", ~method=?, ~headers=?, body) =>
|
|
127
|
+
LocalWebhookRegistry.post(~path, ~method?, ~headers?, ~secretOf, ~nowSeconds=now, body)
|
|
128
|
+
|
|
129
|
+
let throws = (run: unit => unit): bool =>
|
|
130
|
+
try {
|
|
131
|
+
run()
|
|
132
|
+
false
|
|
133
|
+
} catch {
|
|
134
|
+
| _ => true
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let rows = (log: dict<Callback.auditRow>) => log->Dict.valuesToArray
|
|
138
|
+
|
|
139
|
+
let clear = (log: dict<Callback.auditRow>) =>
|
|
140
|
+
log->Dict.keysToArray->Array.forEach(key => log->Dict.delete(key))
|
|
141
|
+
|
|
142
|
+
describe("webhook signature", () => {
|
|
143
|
+
let check = (~secrets=[secret], ~headers, text) =>
|
|
144
|
+
Webhook.verify(signature, ~secrets, ~headers, ~body=text, ~nowSeconds=now)
|
|
145
|
+
|
|
146
|
+
testSync("a signature by the shared secret passes", () =>
|
|
147
|
+
expect(check(~headers=signedHeaders(body()), body()))->toEqual(Ok())
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
testSync("a signature by another secret is refused", () =>
|
|
151
|
+
expect(check(~headers=signedHeaders(~secret="other", body()), body())->Result.isError)->toBe(
|
|
152
|
+
true,
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
testSync("a body changed after signing is refused", () =>
|
|
157
|
+
expect(check(~headers=signedHeaders(body()), body(~id="p-2"))->Result.isError)->toBe(true)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
testSync(
|
|
161
|
+
"a timestamp outside the window is refused, so a captured request cannot be replayed",
|
|
162
|
+
() =>
|
|
163
|
+
expect(check(~headers=signedHeaders(~at=now -. 301., body()), body())->Result.isError)->toBe(
|
|
164
|
+
true,
|
|
165
|
+
),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
testSync("a request without the signature header is refused", () => {
|
|
169
|
+
let headers = signedHeaders(body())
|
|
170
|
+
headers->Dict.delete("x-signature")
|
|
171
|
+
expect(check(~headers, body())->Result.isError)->toBe(true)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
testSync("while a secret is rotated, either one passes", () =>
|
|
175
|
+
expect(
|
|
176
|
+
check(
|
|
177
|
+
~secrets=Webhook.secretsOf("new, old"),
|
|
178
|
+
~headers=signedHeaders(~secret="old", body()),
|
|
179
|
+
body(),
|
|
180
|
+
),
|
|
181
|
+
)->toEqual(Ok())
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
testSync("a secret is read from a variable named after it", () =>
|
|
185
|
+
expect(Webhook.secretEnvKey("supplier-feed"))->toBe("REVENTLESS_WEBHOOK_SECRET_SUPPLIER_FEED")
|
|
186
|
+
)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
describe("webhook door", () => {
|
|
190
|
+
beforeAll(() => {
|
|
191
|
+
LocalWebhookRegistry.reset()
|
|
192
|
+
switch (FeedCallback.webhook, OpenCallback.webhook) {
|
|
193
|
+
| (Some(feed), Some(open_)) =>
|
|
194
|
+
LocalWebhookRegistry.register(~route=feed, ~operations=Pulumi.Output.make(feedOps))
|
|
195
|
+
LocalWebhookRegistry.register(~route=open_, ~operations=Pulumi.Output.make(openOps))
|
|
196
|
+
| _ => JsError.throwWithMessage("fixture specs declare endpoints")
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
afterAll(() => LocalWebhookRegistry.reset())
|
|
200
|
+
beforeEach(() => {
|
|
201
|
+
published := []
|
|
202
|
+
clear(FeedCallback.auditLog)
|
|
203
|
+
clear(OpenCallback.auditLog)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
testPromise("a signed request publishes its commands as the endpoint's role", async () => {
|
|
207
|
+
let response = await post(~headers=signedHeaders(body()), body())
|
|
208
|
+
expect(response.status)->toBe(200)
|
|
209
|
+
expect(published.contents->Array.map(c => c.id))->toEqual(["p-1"])
|
|
210
|
+
expect(rows(FeedCallback.auditLog)->Array.map(row => row.door))->toEqual([viaWebhook])
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
testPromise("a command the endpoint's role may not send is refused", async () => {
|
|
214
|
+
let text = body(~kind="category")
|
|
215
|
+
let response = await post(~headers=signedHeaders(text), text)
|
|
216
|
+
expect(response.status)->toBe(403)
|
|
217
|
+
expect(published.contents)->toEqual([])
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
testPromise("a bad signature is refused before the body is read, with an audit row", async () => {
|
|
221
|
+
let response = await post(~headers=signedHeaders(~secret="other", body()), body())
|
|
222
|
+
expect(response.status)->toBe(401)
|
|
223
|
+
expect(published.contents)->toEqual([])
|
|
224
|
+
expect(
|
|
225
|
+
rows(FeedCallback.auditLog)->Array.map(row => (row.status, row.door, row.input)),
|
|
226
|
+
)->toEqual([(Callback.Failure, viaWebhook, body())])
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
testPromise("a signed body that is not JSON is refused", async () => {
|
|
230
|
+
let response = await post(~headers=signedHeaders("not json"), "not json")
|
|
231
|
+
expect(response.status)->toBe(400)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
testPromise("an unknown path and a wrong method are refused", async () => {
|
|
235
|
+
let unknown = await post(~path="nowhere", body())
|
|
236
|
+
let wrongMethod = await post(~method="PUT", ~headers=signedHeaders(body()), body())
|
|
237
|
+
expect((unknown.status, wrongMethod.status))->toEqual((404, 405))
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
testPromise("a secret the platform lacks fails closed, without an audit row", async () => {
|
|
241
|
+
let response = await LocalWebhookRegistry.post(
|
|
242
|
+
~path="feeds/signed",
|
|
243
|
+
~headers=signedHeaders(body()),
|
|
244
|
+
~secretOf=_ => None,
|
|
245
|
+
~nowSeconds=now,
|
|
246
|
+
body(),
|
|
247
|
+
)
|
|
248
|
+
expect(response.status)->toBe(500)
|
|
249
|
+
expect(published.contents)->toEqual([])
|
|
250
|
+
expect(rows(FeedCallback.auditLog))->toEqual([])
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
testPromise("an open URL identifies nobody, so a signed-in rule refuses it", async () => {
|
|
254
|
+
let response = await post(~path="open", body())
|
|
255
|
+
expect(response.status)->toBe(403)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
testPromise("both doors publish the same commands and are told apart in the audit", async () => {
|
|
259
|
+
let caller: Reventless.Identity.t = {
|
|
260
|
+
userId: "u-1",
|
|
261
|
+
username: "alice",
|
|
262
|
+
groups: [(merchandiser :> string)],
|
|
263
|
+
provider: InMemory,
|
|
264
|
+
}
|
|
265
|
+
let mutationResult = await feedOps.receive(JSON.parseOrThrow(body()), ~caller)
|
|
266
|
+
let _ = await post(~headers=signedHeaders(body()), body())
|
|
267
|
+
expect(mutationResult->Result.isOk)->toBe(true)
|
|
268
|
+
switch published.contents->Array.map(c => c.commandJson) {
|
|
269
|
+
| [viaMutationCommand, viaWebhookCommand] =>
|
|
270
|
+
expect(viaWebhookCommand)->toEqual(viaMutationCommand)
|
|
271
|
+
| commands => expect(commands->Array.length)->toBe(2)
|
|
272
|
+
}
|
|
273
|
+
expect(rows(FeedCallback.auditLog)->Array.map(row => row.door))->toEqual([
|
|
274
|
+
viaMutation,
|
|
275
|
+
viaWebhook,
|
|
276
|
+
])
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
testSync("two slices may not share a path", () =>
|
|
280
|
+
switch FeedCallback.webhook {
|
|
281
|
+
| Some(route) =>
|
|
282
|
+
expect(
|
|
283
|
+
throws(
|
|
284
|
+
() => LocalWebhookRegistry.register(~route, ~operations=Pulumi.Output.make(feedOps)),
|
|
285
|
+
),
|
|
286
|
+
)->toBe(true)
|
|
287
|
+
| None => JsError.throwWithMessage("fixture spec declares an endpoint")
|
|
288
|
+
}
|
|
289
|
+
)
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
describe("which doors a slice has", () => {
|
|
293
|
+
testSync("@noApi on externalInput withholds the mutation", () =>
|
|
294
|
+
expect((
|
|
295
|
+
Callback.exposesMutation(inputSchema->ReventlessInfra.Api.markNoApi->S.castToUnknown),
|
|
296
|
+
Callback.exposesMutation(inputSchema->S.castToUnknown),
|
|
297
|
+
))->toEqual((false, true))
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
testSync("a slice with neither door is refused", () => {
|
|
301
|
+
let check = (~webhook) =>
|
|
302
|
+
throws(() => Callback.checkReachable(~plugin="P", ~slice="S", ~mutation=false, ~webhook))
|
|
303
|
+
expect((check(~webhook=false), check(~webhook=true)))->toEqual((true, false))
|
|
304
|
+
})
|
|
305
|
+
})
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Sury from "sury";
|
|
4
|
+
import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
|
|
5
|
+
import * as Nodecrypto from "node:crypto";
|
|
6
|
+
import * as Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
|
|
7
|
+
import * as Pulumi from "@pulumi/pulumi";
|
|
8
|
+
import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
9
|
+
import * as Role$Reventless from "@reventlessdev/reventless-spec/src/types/Role.res.mjs";
|
|
10
|
+
import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
|
|
11
|
+
import * as Api$ReventlessInfra from "@reventlessdev/reventless-infra/src/components/Api.res.mjs";
|
|
12
|
+
import * as TestRunner$ReventlessLocal from "../../../src/test/TestRunner.res.mjs";
|
|
13
|
+
import * as LocalWebhookRegistry$ReventlessLocal from "../../../src/adapter/Webhook/LocalWebhookRegistry.res.mjs";
|
|
14
|
+
import * as InboundTranslationSlice_Webhook$ReventlessCore from "@reventlessdev/reventless-core/src/components/InboundTranslationSlice/InboundTranslationSlice_Webhook.res.mjs";
|
|
15
|
+
import * as InboundTranslationSlice_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/InboundTranslationSlice/InboundTranslationSlice_Callback.res.mjs";
|
|
16
|
+
|
|
17
|
+
TestRunner$ReventlessLocal.setup();
|
|
18
|
+
|
|
19
|
+
let merchandiser = "Merchandiser";
|
|
20
|
+
|
|
21
|
+
let moduleUrl = "signed-feed://spec";
|
|
22
|
+
|
|
23
|
+
let externalInputSchema = Sury.$schema(s => ({
|
|
24
|
+
kind: s.m(Sury.string),
|
|
25
|
+
id: s.m(Sury.string)
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
let commandSchema = Sury.union([
|
|
29
|
+
Sury.$schema(s => ({
|
|
30
|
+
TAG: "AddProduct",
|
|
31
|
+
productId: s.m(DcbTag$Reventless.string)
|
|
32
|
+
})),
|
|
33
|
+
Sury.$schema(s => ({
|
|
34
|
+
TAG: "AddCategory",
|
|
35
|
+
categoryId: s.m(DcbTag$Reventless.string)
|
|
36
|
+
}))
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
function authorizationOf(name) {
|
|
40
|
+
if (name === "AddProduct") {
|
|
41
|
+
return {
|
|
42
|
+
TAG: "AllowRoles",
|
|
43
|
+
_0: [merchandiser]
|
|
44
|
+
};
|
|
45
|
+
} else {
|
|
46
|
+
return {
|
|
47
|
+
TAG: "AllowRoles",
|
|
48
|
+
_0: [Role$Reventless.admin]
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let endpoint = {
|
|
54
|
+
path: "/feeds/signed/",
|
|
55
|
+
auth: {
|
|
56
|
+
TAG: "SignedBody",
|
|
57
|
+
_0: {
|
|
58
|
+
header: "X-Signature",
|
|
59
|
+
prefix: "sha256=",
|
|
60
|
+
timestampHeader: "X-Timestamp",
|
|
61
|
+
secret: "feed"
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
role: merchandiser
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
let traits = [];
|
|
68
|
+
|
|
69
|
+
let FeedSpec = {
|
|
70
|
+
name: "SignedFeed",
|
|
71
|
+
moduleUrl: moduleUrl,
|
|
72
|
+
externalInputSchema: externalInputSchema,
|
|
73
|
+
commandSchema: commandSchema,
|
|
74
|
+
targetName: "AddProduct",
|
|
75
|
+
externalSystem: undefined,
|
|
76
|
+
authorizationOf: authorizationOf,
|
|
77
|
+
endpoint: endpoint,
|
|
78
|
+
traits: traits
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function translateFeed(input) {
|
|
82
|
+
let kind = input.kind;
|
|
83
|
+
switch (kind) {
|
|
84
|
+
case "category" :
|
|
85
|
+
return {
|
|
86
|
+
TAG: "Ok",
|
|
87
|
+
_0: [[
|
|
88
|
+
input.id,
|
|
89
|
+
{
|
|
90
|
+
TAG: "AddCategory",
|
|
91
|
+
categoryId: input.id
|
|
92
|
+
}
|
|
93
|
+
]]
|
|
94
|
+
};
|
|
95
|
+
case "product" :
|
|
96
|
+
return {
|
|
97
|
+
TAG: "Ok",
|
|
98
|
+
_0: [[
|
|
99
|
+
input.id,
|
|
100
|
+
{
|
|
101
|
+
TAG: "AddProduct",
|
|
102
|
+
productId: input.id
|
|
103
|
+
}
|
|
104
|
+
]]
|
|
105
|
+
};
|
|
106
|
+
default:
|
|
107
|
+
return {
|
|
108
|
+
TAG: "Error",
|
|
109
|
+
_0: `unknown kind ` + kind
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let FeedCallback = InboundTranslationSlice_Callback$ReventlessCore.Make(FeedSpec)({
|
|
115
|
+
translate: translateFeed,
|
|
116
|
+
moduleUrl: moduleUrl
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
let moduleUrl$1 = "open-feed://spec";
|
|
120
|
+
|
|
121
|
+
let externalInputSchema$1 = Sury.$schema(s => ({
|
|
122
|
+
kind: s.m(Sury.string),
|
|
123
|
+
id: s.m(Sury.string)
|
|
124
|
+
}));
|
|
125
|
+
|
|
126
|
+
let commandSchema$1 = Sury.$schema(s => ({
|
|
127
|
+
TAG: "AddProduct",
|
|
128
|
+
productId: s.m(DcbTag$Reventless.string)
|
|
129
|
+
}));
|
|
130
|
+
|
|
131
|
+
function authorizationOf$1(param) {
|
|
132
|
+
return "AllowAuthenticated";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let traits$1 = [];
|
|
136
|
+
|
|
137
|
+
let OpenSpec_endpoint = {
|
|
138
|
+
path: "open",
|
|
139
|
+
auth: "Open"
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
let OpenSpec = {
|
|
143
|
+
name: "OpenFeed",
|
|
144
|
+
moduleUrl: moduleUrl$1,
|
|
145
|
+
externalInputSchema: externalInputSchema$1,
|
|
146
|
+
commandSchema: commandSchema$1,
|
|
147
|
+
targetName: "AddProduct",
|
|
148
|
+
externalSystem: undefined,
|
|
149
|
+
authorizationOf: authorizationOf$1,
|
|
150
|
+
endpoint: OpenSpec_endpoint,
|
|
151
|
+
traits: traits$1
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
function translate(input) {
|
|
155
|
+
return {
|
|
156
|
+
TAG: "Ok",
|
|
157
|
+
_0: [[
|
|
158
|
+
input.id,
|
|
159
|
+
{
|
|
160
|
+
TAG: "AddProduct",
|
|
161
|
+
productId: input.id
|
|
162
|
+
}
|
|
163
|
+
]]
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let OpenCallback = InboundTranslationSlice_Callback$ReventlessCore.Make(OpenSpec)({
|
|
168
|
+
translate: translate,
|
|
169
|
+
moduleUrl: moduleUrl$1
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
let inputSchema = Sury.$schema(s => ({
|
|
173
|
+
id: s.m(Sury.string)
|
|
174
|
+
}));
|
|
175
|
+
|
|
176
|
+
let published = {
|
|
177
|
+
contents: []
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
async function publishJsons(cmds) {
|
|
181
|
+
published.contents = published.contents.concat(cmds);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function feedOps_receive(input, caller, door) {
|
|
185
|
+
return FeedCallback.receive(publishJsons, input, caller, door);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function feedOps_refuse(body, errorCode, error) {
|
|
189
|
+
return FeedCallback.refuse(body, errorCode, error);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let feedOps = {
|
|
193
|
+
receive: feedOps_receive,
|
|
194
|
+
refuse: feedOps_refuse
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
function openOps_receive(input, caller, door) {
|
|
198
|
+
return OpenCallback.receive(publishJsons, input, caller, door);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function openOps_refuse(body, errorCode, error) {
|
|
202
|
+
return OpenCallback.refuse(body, errorCode, error);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let openOps = {
|
|
206
|
+
receive: openOps_receive,
|
|
207
|
+
refuse: openOps_refuse
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
let viaWebhook = "Webhook";
|
|
211
|
+
|
|
212
|
+
let viaMutation = "Mutation";
|
|
213
|
+
|
|
214
|
+
let secret = "s3cret";
|
|
215
|
+
|
|
216
|
+
function secretOf(name) {
|
|
217
|
+
if (name === "feed") {
|
|
218
|
+
return secret;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let signature = {
|
|
223
|
+
header: "X-Signature",
|
|
224
|
+
prefix: "sha256=",
|
|
225
|
+
timestampHeader: "X-Timestamp",
|
|
226
|
+
secret: "feed"
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
function hmac(secret, text) {
|
|
230
|
+
return Nodecrypto.createHmac("sha256", secret).update(text).digest("hex");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function signedHeaders(secretOpt, atOpt, body) {
|
|
234
|
+
let secret$1 = secretOpt !== undefined ? secretOpt : secret;
|
|
235
|
+
let at = atOpt !== undefined ? atOpt : 1700000000;
|
|
236
|
+
let at$1 = at.toString();
|
|
237
|
+
return Object.fromEntries([
|
|
238
|
+
[
|
|
239
|
+
"x-timestamp",
|
|
240
|
+
at$1
|
|
241
|
+
],
|
|
242
|
+
[
|
|
243
|
+
"x-signature",
|
|
244
|
+
"sha256=" + hmac(secret$1, at$1 + `.` + body)
|
|
245
|
+
]
|
|
246
|
+
]);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function body(kindOpt, idOpt) {
|
|
250
|
+
let kind = kindOpt !== undefined ? kindOpt : "product";
|
|
251
|
+
let id = idOpt !== undefined ? idOpt : "p-1";
|
|
252
|
+
return `{"kind":"` + kind + `","id":"` + id + `"}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function post(pathOpt, method, headers, body) {
|
|
256
|
+
let path = pathOpt !== undefined ? pathOpt : "feeds/signed";
|
|
257
|
+
return LocalWebhookRegistry$ReventlessLocal.post(path, method, headers, secretOf, 1700000000, body);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function throws(run) {
|
|
261
|
+
try {
|
|
262
|
+
run();
|
|
263
|
+
return false;
|
|
264
|
+
} catch (exn) {
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function rows(log) {
|
|
270
|
+
return Object.values(log);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function clear(log) {
|
|
274
|
+
Object.keys(log).forEach(key => Stdlib_Dict.$$delete(log, key));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
globalThis.describe("webhook signature", () => {
|
|
278
|
+
let check = (secretsOpt, headers, text) => {
|
|
279
|
+
let secrets = secretsOpt !== undefined ? secretsOpt : [secret];
|
|
280
|
+
return InboundTranslationSlice_Webhook$ReventlessCore.verify(signature, secrets, headers, text, 1700000000);
|
|
281
|
+
};
|
|
282
|
+
globalThis.test("a signature by the shared secret passes", () => {
|
|
283
|
+
globalThis.expect(check(undefined, signedHeaders(undefined, undefined, body(undefined, undefined)), body(undefined, undefined))).toEqual({
|
|
284
|
+
TAG: "Ok",
|
|
285
|
+
_0: undefined
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
globalThis.test("a signature by another secret is refused", () => {
|
|
289
|
+
globalThis.expect(Stdlib_Result.isError(check(undefined, signedHeaders("other", undefined, body(undefined, undefined)), body(undefined, undefined)))).toBe(true);
|
|
290
|
+
});
|
|
291
|
+
globalThis.test("a body changed after signing is refused", () => {
|
|
292
|
+
globalThis.expect(Stdlib_Result.isError(check(undefined, signedHeaders(undefined, undefined, body(undefined, undefined)), body(undefined, "p-2")))).toBe(true);
|
|
293
|
+
});
|
|
294
|
+
globalThis.test("a timestamp outside the window is refused, so a captured request cannot be replayed", () => {
|
|
295
|
+
globalThis.expect(Stdlib_Result.isError(check(undefined, signedHeaders(undefined, 1700000000 - 301, body(undefined, undefined)), body(undefined, undefined)))).toBe(true);
|
|
296
|
+
});
|
|
297
|
+
globalThis.test("a request without the signature header is refused", () => {
|
|
298
|
+
let headers = signedHeaders(undefined, undefined, body(undefined, undefined));
|
|
299
|
+
Stdlib_Dict.$$delete(headers, "x-signature");
|
|
300
|
+
globalThis.expect(Stdlib_Result.isError(check(undefined, headers, body(undefined, undefined)))).toBe(true);
|
|
301
|
+
});
|
|
302
|
+
globalThis.test("while a secret is rotated, either one passes", () => {
|
|
303
|
+
globalThis.expect(check(InboundTranslationSlice_Webhook$ReventlessCore.secretsOf("new, old"), signedHeaders("old", undefined, body(undefined, undefined)), body(undefined, undefined))).toEqual({
|
|
304
|
+
TAG: "Ok",
|
|
305
|
+
_0: undefined
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
globalThis.test("a secret is read from a variable named after it", () => {
|
|
309
|
+
globalThis.expect(InboundTranslationSlice_Webhook$ReventlessCore.secretEnvKey("supplier-feed")).toBe("REVENTLESS_WEBHOOK_SECRET_SUPPLIER_FEED");
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
globalThis.describe("webhook door", () => {
|
|
314
|
+
globalThis.beforeAll(() => {
|
|
315
|
+
LocalWebhookRegistry$ReventlessLocal.reset();
|
|
316
|
+
let match = FeedCallback.webhook;
|
|
317
|
+
let match$1 = OpenCallback.webhook;
|
|
318
|
+
if (match !== undefined && match$1 !== undefined) {
|
|
319
|
+
LocalWebhookRegistry$ReventlessLocal.register(match, Pulumi.output(feedOps));
|
|
320
|
+
return LocalWebhookRegistry$ReventlessLocal.register(match$1, Pulumi.output(openOps));
|
|
321
|
+
} else {
|
|
322
|
+
return Stdlib_JsError.throwWithMessage("fixture specs declare endpoints");
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
globalThis.afterAll(() => LocalWebhookRegistry$ReventlessLocal.reset());
|
|
326
|
+
globalThis.beforeEach(() => {
|
|
327
|
+
published.contents = [];
|
|
328
|
+
clear(FeedCallback.auditLog);
|
|
329
|
+
clear(OpenCallback.auditLog);
|
|
330
|
+
});
|
|
331
|
+
globalThis.test("a signed request publishes its commands as the endpoint's role", async () => {
|
|
332
|
+
let response = await post(undefined, undefined, signedHeaders(undefined, undefined, body(undefined, undefined)), body(undefined, undefined));
|
|
333
|
+
globalThis.expect(response.status).toBe(200);
|
|
334
|
+
globalThis.expect(published.contents.map(c => c.id)).toEqual(["p-1"]);
|
|
335
|
+
globalThis.expect(Object.values(FeedCallback.auditLog).map(row => row.door)).toEqual([viaWebhook]);
|
|
336
|
+
});
|
|
337
|
+
globalThis.test("a command the endpoint's role may not send is refused", async () => {
|
|
338
|
+
let text = body("category", undefined);
|
|
339
|
+
let response = await post(undefined, undefined, signedHeaders(undefined, undefined, text), text);
|
|
340
|
+
globalThis.expect(response.status).toBe(403);
|
|
341
|
+
globalThis.expect(published.contents).toEqual([]);
|
|
342
|
+
});
|
|
343
|
+
globalThis.test("a bad signature is refused before the body is read, with an audit row", async () => {
|
|
344
|
+
let response = await post(undefined, undefined, signedHeaders("other", undefined, body(undefined, undefined)), body(undefined, undefined));
|
|
345
|
+
globalThis.expect(response.status).toBe(401);
|
|
346
|
+
globalThis.expect(published.contents).toEqual([]);
|
|
347
|
+
globalThis.expect(Object.values(FeedCallback.auditLog).map(row => [
|
|
348
|
+
row.status,
|
|
349
|
+
row.door,
|
|
350
|
+
row.input
|
|
351
|
+
])).toEqual([[
|
|
352
|
+
"Failure",
|
|
353
|
+
viaWebhook,
|
|
354
|
+
body(undefined, undefined)
|
|
355
|
+
]]);
|
|
356
|
+
});
|
|
357
|
+
globalThis.test("a signed body that is not JSON is refused", async () => {
|
|
358
|
+
let response = await post(undefined, undefined, signedHeaders(undefined, undefined, "not json"), "not json");
|
|
359
|
+
globalThis.expect(response.status).toBe(400);
|
|
360
|
+
});
|
|
361
|
+
globalThis.test("an unknown path and a wrong method are refused", async () => {
|
|
362
|
+
let unknown = await post("nowhere", undefined, undefined, body(undefined, undefined));
|
|
363
|
+
let wrongMethod = await post(undefined, "PUT", signedHeaders(undefined, undefined, body(undefined, undefined)), body(undefined, undefined));
|
|
364
|
+
globalThis.expect([
|
|
365
|
+
unknown.status,
|
|
366
|
+
wrongMethod.status
|
|
367
|
+
]).toEqual([
|
|
368
|
+
404,
|
|
369
|
+
405
|
|
370
|
+
]);
|
|
371
|
+
});
|
|
372
|
+
globalThis.test("a secret the platform lacks fails closed, without an audit row", async () => {
|
|
373
|
+
let response = await LocalWebhookRegistry$ReventlessLocal.post("feeds/signed", undefined, signedHeaders(undefined, undefined, body(undefined, undefined)), param => {}, 1700000000, body(undefined, undefined));
|
|
374
|
+
globalThis.expect(response.status).toBe(500);
|
|
375
|
+
globalThis.expect(published.contents).toEqual([]);
|
|
376
|
+
globalThis.expect(Object.values(FeedCallback.auditLog)).toEqual([]);
|
|
377
|
+
});
|
|
378
|
+
globalThis.test("an open URL identifies nobody, so a signed-in rule refuses it", async () => {
|
|
379
|
+
let response = await post("open", undefined, undefined, body(undefined, undefined));
|
|
380
|
+
globalThis.expect(response.status).toBe(403);
|
|
381
|
+
});
|
|
382
|
+
globalThis.test("both doors publish the same commands and are told apart in the audit", async () => {
|
|
383
|
+
let caller_groups = [merchandiser];
|
|
384
|
+
let caller = {
|
|
385
|
+
userId: "u-1",
|
|
386
|
+
username: "alice",
|
|
387
|
+
groups: caller_groups,
|
|
388
|
+
provider: "InMemory"
|
|
389
|
+
};
|
|
390
|
+
let mutationResult = await feedOps_receive(JSON.parse(body(undefined, undefined)), caller, undefined);
|
|
391
|
+
await post(undefined, undefined, signedHeaders(undefined, undefined, body(undefined, undefined)), body(undefined, undefined));
|
|
392
|
+
globalThis.expect(Stdlib_Result.isOk(mutationResult)).toBe(true);
|
|
393
|
+
let commands = published.contents.map(c => c.commandJson);
|
|
394
|
+
if (commands.length !== 2) {
|
|
395
|
+
globalThis.expect(commands.length).toBe(2);
|
|
396
|
+
} else {
|
|
397
|
+
let viaMutationCommand = commands[0];
|
|
398
|
+
let viaWebhookCommand = commands[1];
|
|
399
|
+
globalThis.expect(viaWebhookCommand).toEqual(viaMutationCommand);
|
|
400
|
+
}
|
|
401
|
+
globalThis.expect(Object.values(FeedCallback.auditLog).map(row => row.door)).toEqual([
|
|
402
|
+
viaMutation,
|
|
403
|
+
viaWebhook
|
|
404
|
+
]);
|
|
405
|
+
});
|
|
406
|
+
globalThis.test("two slices may not share a path", () => {
|
|
407
|
+
let route = FeedCallback.webhook;
|
|
408
|
+
if (route !== undefined) {
|
|
409
|
+
globalThis.expect(throws(() => LocalWebhookRegistry$ReventlessLocal.register(route, Pulumi.output(feedOps)))).toBe(true);
|
|
410
|
+
return;
|
|
411
|
+
} else {
|
|
412
|
+
return Stdlib_JsError.throwWithMessage("fixture spec declares an endpoint");
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
globalThis.describe("which doors a slice has", () => {
|
|
418
|
+
globalThis.test("@noApi on externalInput withholds the mutation", () => {
|
|
419
|
+
globalThis.expect([
|
|
420
|
+
InboundTranslationSlice_Callback$ReventlessCore.exposesMutation(Api$ReventlessInfra.markNoApi(inputSchema)),
|
|
421
|
+
InboundTranslationSlice_Callback$ReventlessCore.exposesMutation(inputSchema)
|
|
422
|
+
]).toEqual([
|
|
423
|
+
false,
|
|
424
|
+
true
|
|
425
|
+
]);
|
|
426
|
+
});
|
|
427
|
+
globalThis.test("a slice with neither door is refused", () => {
|
|
428
|
+
let check = webhook => throws(() => InboundTranslationSlice_Callback$ReventlessCore.checkReachable("P", "S", false, webhook));
|
|
429
|
+
globalThis.expect([
|
|
430
|
+
check(false),
|
|
431
|
+
check(true)
|
|
432
|
+
]).toEqual([
|
|
433
|
+
true,
|
|
434
|
+
false
|
|
435
|
+
]);
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
let Webhook;
|
|
440
|
+
|
|
441
|
+
let Callback;
|
|
442
|
+
|
|
443
|
+
let now = 1700000000;
|
|
444
|
+
|
|
445
|
+
export {
|
|
446
|
+
Webhook,
|
|
447
|
+
Callback,
|
|
448
|
+
merchandiser,
|
|
449
|
+
FeedSpec,
|
|
450
|
+
translateFeed,
|
|
451
|
+
FeedCallback,
|
|
452
|
+
OpenSpec,
|
|
453
|
+
OpenCallback,
|
|
454
|
+
inputSchema,
|
|
455
|
+
published,
|
|
456
|
+
publishJsons,
|
|
457
|
+
feedOps,
|
|
458
|
+
openOps,
|
|
459
|
+
viaWebhook,
|
|
460
|
+
viaMutation,
|
|
461
|
+
secret,
|
|
462
|
+
now,
|
|
463
|
+
secretOf,
|
|
464
|
+
signature,
|
|
465
|
+
hmac,
|
|
466
|
+
signedHeaders,
|
|
467
|
+
body,
|
|
468
|
+
post,
|
|
469
|
+
throws,
|
|
470
|
+
rows,
|
|
471
|
+
clear,
|
|
472
|
+
}
|
|
473
|
+
/* Not a pure module */
|