@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.
Files changed (30) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/package.json +10 -10
  3. package/src/adapter/Webhook/LocalWebhookRegistry.res +64 -0
  4. package/src/adapter/Webhook/LocalWebhookRegistry.res.mjs +52 -0
  5. package/src/components/InboundTranslationSlice_Builder.res +10 -1
  6. package/src/components/InboundTranslationSlice_Builder.res.mjs +9 -1
  7. package/tests/adapter/InboundTranslationMutationTest.res.mjs +6 -4
  8. package/tests/components/automationslice/AutomationRescheduleFixtures.res +0 -1
  9. package/tests/components/automationslice/AutomationRescheduleFixtures.res.mjs +0 -1
  10. package/tests/components/automationslice/AutomationScheduleFixtures.res +0 -1
  11. package/tests/components/automationslice/AutomationScheduleFixtures.res.mjs +0 -5
  12. package/tests/components/automationslice/AutomationSliceFixtures.res +0 -2
  13. package/tests/components/automationslice/AutomationSliceFixtures.res.mjs +0 -2
  14. package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res +0 -1
  15. package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res.mjs +0 -1
  16. package/tests/components/automationslice/AutomationSweepFixtures.res +0 -1
  17. package/tests/components/automationslice/AutomationSweepFixtures.res.mjs +0 -1
  18. package/tests/components/automationslice/MixedSourceAutomationSliceFixtures.res +0 -1
  19. package/tests/components/automationslice/MixedSourceAutomationSliceFixtures.res.mjs +0 -1
  20. package/tests/components/inboundtranslationslice/InboundTranslationSliceCallbackTest.res +2 -0
  21. package/tests/components/inboundtranslationslice/InboundTranslationSliceCallbackTest.res.mjs +25 -20
  22. package/tests/components/inboundtranslationslice/InboundTranslationSliceFixtures.res +3 -0
  23. package/tests/components/inboundtranslationslice/InboundTranslationSliceFixtures.res.mjs +3 -0
  24. package/tests/components/inboundtranslationslice/InboundTranslationWebhookTest.res +305 -0
  25. package/tests/components/inboundtranslationslice/InboundTranslationWebhookTest.res.mjs +473 -0
  26. package/tests/components/outboundtranslationslice/OutboundTranslationSliceCallbackTest.res.mjs +0 -2
  27. package/tests/components/outboundtranslationslice/OutboundTranslationSliceFixtures.res +0 -2
  28. package/tests/components/outboundtranslationslice/OutboundTranslationSliceFixtures.res.mjs +0 -2
  29. package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformFixtures.res +0 -1
  30. 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
+ })