@reventlessdev/reventless-local 3.0.0-alpha.288 → 3.0.0-alpha.290
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/automationslice/AutomationRescheduleFixtures.res +0 -1
- package/tests/components/automationslice/AutomationRescheduleFixtures.res.mjs +0 -1
- package/tests/components/automationslice/AutomationScheduleFixtures.res +0 -1
- package/tests/components/automationslice/AutomationScheduleFixtures.res.mjs +0 -5
- package/tests/components/automationslice/AutomationSliceFixtures.res +0 -2
- package/tests/components/automationslice/AutomationSliceFixtures.res.mjs +0 -2
- package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res +0 -1
- package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res.mjs +0 -1
- package/tests/components/automationslice/AutomationSweepFixtures.res +0 -1
- package/tests/components/automationslice/AutomationSweepFixtures.res.mjs +0 -1
- package/tests/components/automationslice/MixedSourceAutomationSliceFixtures.res +0 -1
- package/tests/components/automationslice/MixedSourceAutomationSliceFixtures.res.mjs +0 -1
- 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/tests/components/outboundtranslationslice/OutboundTranslationSliceCallbackTest.res.mjs +0 -2
- package/tests/components/outboundtranslationslice/OutboundTranslationSliceFixtures.res +0 -2
- package/tests/components/outboundtranslationslice/OutboundTranslationSliceFixtures.res.mjs +0 -2
- package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformFixtures.res +0 -1
- package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformFixtures.res.mjs +0 -1
|
@@ -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
|
+
})
|