@decohere/http 0.0.1-dev.126 → 0.0.1-dev.5

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 (293) hide show
  1. package/README.md +2 -2
  2. package/package.json +4 -13
  3. package/src/app/app.ts +147 -0
  4. package/src/app/module.ts +19 -0
  5. package/src/auth/ability.ts +56 -0
  6. package/src/auth/auth.config.ts +34 -0
  7. package/src/auth/auth.extensions.ts +115 -0
  8. package/src/auth/auth.types.ts +124 -0
  9. package/src/auth/casl-authorization.service.ts +62 -0
  10. package/{types/src/auth/principal-provider.d.ts → src/auth/principal-provider.ts} +13 -5
  11. package/src/auth/safe-outbound-url.ts +117 -0
  12. package/src/auth/token-service.ts +192 -0
  13. package/src/builder/builder.ts +118 -0
  14. package/{es6/src/const.js → src/const.ts} +25 -17
  15. package/src/health/health.controller.ts +34 -0
  16. package/src/health/health.extensions.ts +53 -0
  17. package/src/health/readiness.service.ts +33 -0
  18. package/{types/src/health/readiness.types.d.ts → src/health/readiness.types.ts} +4 -8
  19. package/src/hosting/hosted-service-host.ts +102 -0
  20. package/src/hosting/hosted-service.ts +31 -0
  21. package/src/http/http.compression.ts +38 -0
  22. package/src/http/http.extensions.ts +244 -0
  23. package/{types/src/http/http.options.store.d.ts → src/http/http.options.store.ts} +11 -1
  24. package/src/http/http.options.ts +41 -0
  25. package/{types/src/http/http.security.d.ts → src/http/http.security.ts} +16 -10
  26. package/src/http/http.static.ts +359 -0
  27. package/{es6/src/index.js → src/index.ts} +1 -0
  28. package/src/openapi/index.ts +22 -0
  29. package/src/openapi/openapi.generator.ts +78 -0
  30. package/src/openapi/openapi.types.ts +60 -0
  31. package/src/openapi/request-schema.ts +155 -0
  32. package/src/openapi/request-validator.ts +95 -0
  33. package/src/providers/fastify.provider.ts +454 -0
  34. package/src/routing/anvil.types.ts +150 -0
  35. package/src/routing/decorators/argument.decorator.ts +33 -0
  36. package/src/routing/decorators/auth.decorator.ts +79 -0
  37. package/src/routing/decorators/controller.decorator.ts +9 -0
  38. package/src/routing/decorators/field.decorator.ts +15 -0
  39. package/src/routing/decorators/request.decorator.ts +34 -0
  40. package/src/routing/decorators/response.decorator.ts +18 -0
  41. package/src/routing/decorators/route.decorator.ts +52 -0
  42. package/src/routing/filters/exception-filter-host.ts +90 -0
  43. package/src/routing/filters/exception-filter.ts +29 -0
  44. package/src/routing/middleware/http-access-log.middleware.ts +36 -0
  45. package/src/routing/middleware/middleware.ts +26 -0
  46. package/src/routing/middleware/request-id.ts +18 -0
  47. package/src/routing/path.util.ts +38 -0
  48. package/src/routing/pipes/pipes.ts +132 -0
  49. package/src/routing/services/route-argument-resolver.ts +92 -0
  50. package/src/routing/services/route-registry.ts +229 -0
  51. package/src/routing/services/router.ts +166 -0
  52. package/src/sse/sse.decorator.ts +41 -0
  53. package/src/sse/sse.encoder.ts +86 -0
  54. package/src/sse/sse.types.ts +47 -0
  55. package/src/sse/sse.wire.ts +34 -0
  56. package/src/testing/boot-module.ts +63 -0
  57. package/src/testing/sse-reader.ts +120 -0
  58. package/tests/app.hosted-services.spec.ts +292 -0
  59. package/tests/app.shutdown.integration.spec.ts +160 -0
  60. package/tests/app.spec.ts +132 -0
  61. package/tests/auth.ability.spec.ts +48 -0
  62. package/tests/auth.decorators.spec.ts +20 -0
  63. package/tests/auth.fail-closed.pentest.spec.ts +77 -0
  64. package/tests/auth.integration.spec.ts +234 -0
  65. package/tests/auth.jwks-cache.spec.ts +52 -0
  66. package/tests/auth.missing-auth.pentest.spec.ts +56 -0
  67. package/tests/auth.oidc-ssrf.pentest.spec.ts +72 -0
  68. package/tests/auth.oidc.integration.spec.ts +161 -0
  69. package/tests/auth.token-service.spec.ts +251 -0
  70. package/tests/builder.spec.ts +12 -0
  71. package/tests/health.integration.spec.ts +111 -0
  72. package/tests/helpers/listen-ephemeral.ts +13 -0
  73. package/tests/helpers/local-oidc-server.ts +100 -0
  74. package/tests/http.access-log.integration.spec.ts +111 -0
  75. package/tests/http.boolean-query.integration.spec.ts +59 -0
  76. package/tests/http.compression.integration.spec.ts +604 -0
  77. package/tests/http.date-query.integration.spec.ts +71 -0
  78. package/tests/http.global-prefix.integration.spec.ts +284 -0
  79. package/tests/http.number-query.integration.spec.ts +65 -0
  80. package/tests/http.pino-roll-hmr.integration.spec.ts +79 -0
  81. package/tests/http.pipeline-order.integration.spec.ts +95 -0
  82. package/tests/http.request-cleanup.integration.spec.ts +57 -0
  83. package/tests/http.request-id-scope.pentest.spec.ts +50 -0
  84. package/tests/http.security.integration.spec.ts +106 -0
  85. package/tests/http.static.integration.spec.ts +847 -0
  86. package/tests/http.static.spec.ts +111 -0
  87. package/tests/http.surface.integration.spec.ts +167 -0
  88. package/tests/openapi.integration.spec.ts +193 -0
  89. package/tests/openapi.validator-cache.integration.spec.ts +55 -0
  90. package/tests/routing.decorators.spec.ts +15 -0
  91. package/tests/routing.registry.spec.ts +44 -0
  92. package/tests/routing.resolver.spec.ts +92 -0
  93. package/tests/sse.integration.spec.ts +421 -0
  94. package/tests/sse.wire.spec.ts +33 -0
  95. package/LICENSE +0 -15
  96. package/commonjs/src/app/app.js +0 -166
  97. package/commonjs/src/app/index.js +0 -19
  98. package/commonjs/src/app/module.js +0 -17
  99. package/commonjs/src/auth/ability.js +0 -54
  100. package/commonjs/src/auth/auth.config.js +0 -23
  101. package/commonjs/src/auth/auth.extensions.js +0 -106
  102. package/commonjs/src/auth/auth.types.js +0 -65
  103. package/commonjs/src/auth/casl-authorization.service.js +0 -36
  104. package/commonjs/src/auth/index.js +0 -23
  105. package/commonjs/src/auth/principal-provider.js +0 -19
  106. package/commonjs/src/auth/safe-outbound-url.js +0 -102
  107. package/commonjs/src/auth/token-service.js +0 -151
  108. package/commonjs/src/builder/builder.js +0 -95
  109. package/commonjs/src/builder/index.js +0 -18
  110. package/commonjs/src/const.js +0 -87
  111. package/commonjs/src/health/health.controller.js +0 -75
  112. package/commonjs/src/health/health.extensions.js +0 -50
  113. package/commonjs/src/health/index.js +0 -21
  114. package/commonjs/src/health/readiness.service.js +0 -67
  115. package/commonjs/src/health/readiness.types.js +0 -7
  116. package/commonjs/src/hosting/hosted-service-host.js +0 -88
  117. package/commonjs/src/hosting/hosted-service.js +0 -11
  118. package/commonjs/src/hosting/index.js +0 -19
  119. package/commonjs/src/http/http.compression.js +0 -27
  120. package/commonjs/src/http/http.extensions.js +0 -269
  121. package/commonjs/src/http/http.options.js +0 -26
  122. package/commonjs/src/http/http.options.store.js +0 -20
  123. package/commonjs/src/http/http.security.js +0 -27
  124. package/commonjs/src/http/http.static.js +0 -248
  125. package/commonjs/src/http/index.js +0 -22
  126. package/commonjs/src/index.js +0 -27
  127. package/commonjs/src/openapi/index.js +0 -45
  128. package/commonjs/src/openapi/openapi.generator.js +0 -103
  129. package/commonjs/src/openapi/openapi.types.js +0 -4
  130. package/commonjs/src/openapi/request-schema.js +0 -187
  131. package/commonjs/src/openapi/request-validator.js +0 -85
  132. package/commonjs/src/providers/fastify.provider.js +0 -391
  133. package/commonjs/src/providers/index.js +0 -18
  134. package/commonjs/src/routing/anvil.types.js +0 -99
  135. package/commonjs/src/routing/decorators/argument.decorator.js +0 -49
  136. package/commonjs/src/routing/decorators/auth.decorator.js +0 -70
  137. package/commonjs/src/routing/decorators/controller.decorator.js +0 -16
  138. package/commonjs/src/routing/decorators/field.decorator.js +0 -25
  139. package/commonjs/src/routing/decorators/index.js +0 -24
  140. package/commonjs/src/routing/decorators/request.decorator.js +0 -30
  141. package/commonjs/src/routing/decorators/response.decorator.js +0 -34
  142. package/commonjs/src/routing/decorators/route.decorator.js +0 -67
  143. package/commonjs/src/routing/filters/exception-filter-host.js +0 -145
  144. package/commonjs/src/routing/filters/exception-filter.js +0 -28
  145. package/commonjs/src/routing/index.js +0 -25
  146. package/commonjs/src/routing/middleware/http-access-log.middleware.js +0 -47
  147. package/commonjs/src/routing/middleware/middleware.js +0 -20
  148. package/commonjs/src/routing/middleware/request-id.js +0 -30
  149. package/commonjs/src/routing/path.util.js +0 -51
  150. package/commonjs/src/routing/pipes/index.js +0 -18
  151. package/commonjs/src/routing/pipes/pipes.js +0 -145
  152. package/commonjs/src/routing/services/index.js +0 -18
  153. package/commonjs/src/routing/services/route-argument-resolver.js +0 -77
  154. package/commonjs/src/routing/services/route-registry.js +0 -194
  155. package/commonjs/src/routing/services/router.js +0 -158
  156. package/commonjs/src/sse/index.js +0 -26
  157. package/commonjs/src/sse/sse.decorator.js +0 -42
  158. package/commonjs/src/sse/sse.encoder.js +0 -73
  159. package/commonjs/src/sse/sse.types.js +0 -36
  160. package/commonjs/src/sse/sse.wire.js +0 -40
  161. package/commonjs/src/testing/boot-module.js +0 -38
  162. package/commonjs/src/testing/index.js +0 -20
  163. package/commonjs/src/testing/sse-reader.js +0 -98
  164. package/es6/src/app/app.js +0 -148
  165. package/es6/src/app/index.js +0 -2
  166. package/es6/src/app/module.js +0 -7
  167. package/es6/src/auth/ability.js +0 -45
  168. package/es6/src/auth/auth.config.js +0 -19
  169. package/es6/src/auth/auth.extensions.js +0 -83
  170. package/es6/src/auth/auth.types.js +0 -35
  171. package/es6/src/auth/casl-authorization.service.js +0 -40
  172. package/es6/src/auth/index.js +0 -6
  173. package/es6/src/auth/principal-provider.js +0 -14
  174. package/es6/src/auth/safe-outbound-url.js +0 -99
  175. package/es6/src/auth/token-service.js +0 -141
  176. package/es6/src/builder/builder.js +0 -85
  177. package/es6/src/builder/index.js +0 -1
  178. package/es6/src/health/health.controller.js +0 -65
  179. package/es6/src/health/health.extensions.js +0 -40
  180. package/es6/src/health/index.js +0 -4
  181. package/es6/src/health/readiness.service.js +0 -57
  182. package/es6/src/health/readiness.types.js +0 -4
  183. package/es6/src/hosting/hosted-service-host.js +0 -81
  184. package/es6/src/hosting/hosted-service.js +0 -8
  185. package/es6/src/hosting/index.js +0 -2
  186. package/es6/src/http/http.compression.js +0 -9
  187. package/es6/src/http/http.extensions.js +0 -259
  188. package/es6/src/http/http.options.js +0 -16
  189. package/es6/src/http/http.options.store.js +0 -13
  190. package/es6/src/http/http.security.js +0 -9
  191. package/es6/src/http/http.static.js +0 -224
  192. package/es6/src/http/index.js +0 -5
  193. package/es6/src/openapi/index.js +0 -3
  194. package/es6/src/openapi/openapi.generator.js +0 -82
  195. package/es6/src/openapi/openapi.types.js +0 -1
  196. package/es6/src/openapi/request-schema.js +0 -160
  197. package/es6/src/openapi/request-validator.js +0 -71
  198. package/es6/src/providers/fastify.provider.js +0 -376
  199. package/es6/src/providers/index.js +0 -1
  200. package/es6/src/routing/anvil.types.js +0 -57
  201. package/es6/src/routing/decorators/argument.decorator.js +0 -28
  202. package/es6/src/routing/decorators/auth.decorator.js +0 -58
  203. package/es6/src/routing/decorators/controller.decorator.js +0 -6
  204. package/es6/src/routing/decorators/field.decorator.js +0 -15
  205. package/es6/src/routing/decorators/index.js +0 -7
  206. package/es6/src/routing/decorators/request.decorator.js +0 -24
  207. package/es6/src/routing/decorators/response.decorator.js +0 -16
  208. package/es6/src/routing/decorators/route.decorator.js +0 -37
  209. package/es6/src/routing/filters/exception-filter-host.js +0 -127
  210. package/es6/src/routing/filters/exception-filter.js +0 -10
  211. package/es6/src/routing/index.js +0 -8
  212. package/es6/src/routing/middleware/http-access-log.middleware.js +0 -37
  213. package/es6/src/routing/middleware/middleware.js +0 -10
  214. package/es6/src/routing/middleware/request-id.js +0 -14
  215. package/es6/src/routing/path.util.js +0 -33
  216. package/es6/src/routing/pipes/index.js +0 -1
  217. package/es6/src/routing/pipes/pipes.js +0 -118
  218. package/es6/src/routing/services/index.js +0 -1
  219. package/es6/src/routing/services/route-argument-resolver.js +0 -67
  220. package/es6/src/routing/services/route-registry.js +0 -184
  221. package/es6/src/routing/services/router.js +0 -148
  222. package/es6/src/sse/index.js +0 -2
  223. package/es6/src/sse/sse.decorator.js +0 -31
  224. package/es6/src/sse/sse.encoder.js +0 -67
  225. package/es6/src/sse/sse.types.js +0 -22
  226. package/es6/src/sse/sse.wire.js +0 -30
  227. package/es6/src/testing/boot-module.js +0 -35
  228. package/es6/src/testing/index.js +0 -2
  229. package/es6/src/testing/sse-reader.js +0 -91
  230. package/types/src/app/app.d.ts +0 -41
  231. package/types/src/app/module.d.ts +0 -15
  232. package/types/src/auth/ability.d.ts +0 -19
  233. package/types/src/auth/auth.config.d.ts +0 -28
  234. package/types/src/auth/auth.extensions.d.ts +0 -53
  235. package/types/src/auth/auth.types.d.ts +0 -73
  236. package/types/src/auth/casl-authorization.service.d.ts +0 -28
  237. package/types/src/auth/safe-outbound-url.d.ts +0 -9
  238. package/types/src/auth/token-service.d.ts +0 -31
  239. package/types/src/builder/builder.d.ts +0 -36
  240. package/types/src/const.d.ts +0 -32
  241. package/types/src/health/health.controller.d.ts +0 -16
  242. package/types/src/health/health.extensions.d.ts +0 -31
  243. package/types/src/health/readiness.service.d.ts +0 -9
  244. package/types/src/hosting/hosted-service-host.d.ts +0 -37
  245. package/types/src/hosting/hosted-service.d.ts +0 -31
  246. package/types/src/http/http.compression.d.ts +0 -32
  247. package/types/src/http/http.extensions.d.ts +0 -79
  248. package/types/src/http/http.options.d.ts +0 -39
  249. package/types/src/http/http.static.d.ts +0 -160
  250. package/types/src/index.d.ts +0 -10
  251. package/types/src/openapi/index.d.ts +0 -6
  252. package/types/src/openapi/openapi.generator.d.ts +0 -6
  253. package/types/src/openapi/openapi.types.d.ts +0 -58
  254. package/types/src/openapi/request-schema.d.ts +0 -21
  255. package/types/src/openapi/request-validator.d.ts +0 -9
  256. package/types/src/providers/fastify.provider.d.ts +0 -56
  257. package/types/src/routing/anvil.types.d.ts +0 -119
  258. package/types/src/routing/decorators/argument.decorator.d.ts +0 -11
  259. package/types/src/routing/decorators/auth.decorator.d.ts +0 -30
  260. package/types/src/routing/decorators/controller.decorator.d.ts +0 -3
  261. package/types/src/routing/decorators/field.decorator.d.ts +0 -2
  262. package/types/src/routing/decorators/request.decorator.d.ts +0 -8
  263. package/types/src/routing/decorators/response.decorator.d.ts +0 -4
  264. package/types/src/routing/decorators/route.decorator.d.ts +0 -7
  265. package/types/src/routing/filters/exception-filter-host.d.ts +0 -19
  266. package/types/src/routing/filters/exception-filter.d.ts +0 -16
  267. package/types/src/routing/middleware/http-access-log.middleware.d.ts +0 -8
  268. package/types/src/routing/middleware/middleware.d.ts +0 -10
  269. package/types/src/routing/middleware/request-id.d.ts +0 -6
  270. package/types/src/routing/path.util.d.ts +0 -9
  271. package/types/src/routing/pipes/pipes.d.ts +0 -21
  272. package/types/src/routing/services/route-argument-resolver.d.ts +0 -10
  273. package/types/src/routing/services/route-registry.d.ts +0 -20
  274. package/types/src/routing/services/router.d.ts +0 -22
  275. package/types/src/sse/sse.decorator.d.ts +0 -17
  276. package/types/src/sse/sse.encoder.d.ts +0 -16
  277. package/types/src/sse/sse.types.d.ts +0 -37
  278. package/types/src/sse/sse.wire.d.ts +0 -9
  279. package/types/src/testing/boot-module.d.ts +0 -34
  280. package/types/src/testing/sse-reader.d.ts +0 -29
  281. /package/{types/src/app/index.d.ts → src/app/index.ts} +0 -0
  282. /package/{types/src/auth/index.d.ts → src/auth/index.ts} +0 -0
  283. /package/{types/src/builder/index.d.ts → src/builder/index.ts} +0 -0
  284. /package/{types/src/health/index.d.ts → src/health/index.ts} +0 -0
  285. /package/{types/src/hosting/index.d.ts → src/hosting/index.ts} +0 -0
  286. /package/{types/src/http/index.d.ts → src/http/index.ts} +0 -0
  287. /package/{types/src/providers/index.d.ts → src/providers/index.ts} +0 -0
  288. /package/{types/src/routing/decorators/index.d.ts → src/routing/decorators/index.ts} +0 -0
  289. /package/{types/src/routing/index.d.ts → src/routing/index.ts} +0 -0
  290. /package/{types/src/routing/pipes/index.d.ts → src/routing/pipes/index.ts} +0 -0
  291. /package/{types/src/routing/services/index.d.ts → src/routing/services/index.ts} +0 -0
  292. /package/{types/src/sse/index.d.ts → src/sse/index.ts} +0 -0
  293. /package/{types/src/testing/index.d.ts → src/testing/index.ts} +0 -0
@@ -0,0 +1,132 @@
1
+ import { scribe } from "@decohere/scribe";
2
+ import axios from "axios";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import type { App } from "../src/app/app.js";
5
+ import { Builder } from "../src/builder/builder.js";
6
+ import { FastifyProvider } from "../src/providers/fastify.provider.js";
7
+ import { Controller, Get, Param, Query } from "../src/routing/index.js";
8
+ import "../src/http/http.extensions.js";
9
+ import { listenEphemeral } from "./helpers/listen-ephemeral.js";
10
+
11
+ @Controller({ prefix: "/echo" })
12
+ class EchoController {
13
+ @Get()
14
+ public root() {
15
+ return { ok: true };
16
+ }
17
+
18
+ @Get("/query")
19
+ public getWithArgs(@Query("arg1") arg1: string) {
20
+ return { arg1 };
21
+ }
22
+
23
+ @Get("/:param1/:param2")
24
+ public getWithParams(@Param("param1") param1: string, @Param("param2") param2: string) {
25
+ return { param1, param2 };
26
+ }
27
+
28
+ @Get("/count")
29
+ public count(@Query("n") n: number) {
30
+ return { n };
31
+ }
32
+ }
33
+
34
+ describe("App HTTP routing", () => {
35
+ let app: App;
36
+
37
+ beforeEach(async () => {
38
+ app = await Builder.create()
39
+ .withHttpProvider(FastifyProvider)
40
+ .withControllers(EchoController)
41
+ .build();
42
+ axios.defaults.baseURL = await listenEphemeral(app);
43
+ });
44
+
45
+ afterEach(async () => {
46
+ await app.dispose();
47
+ });
48
+
49
+ it("returns the handler body for GET with no arguments", async () => {
50
+ const res = await axios.get("/echo");
51
+ expect(res.status).toBe(200);
52
+ expect(res.data).toEqual({ ok: true });
53
+ });
54
+
55
+ it("binds a query string argument into the response", async () => {
56
+ const res = await axios.get("/echo/query", { params: { arg1: "arg1Value" } });
57
+ expect(res.status).toBe(200);
58
+ expect(res.data).toEqual({ arg1: "arg1Value" });
59
+ });
60
+
61
+ it("binds path params into the response", async () => {
62
+ const res = await axios.get("/echo/arg1Value/arg2Value");
63
+ expect(res.status).toBe(200);
64
+ expect(res.data).toEqual({ param1: "arg1Value", param2: "arg2Value" });
65
+ });
66
+
67
+ it("coerces a numeric query argument", async () => {
68
+ const res = await axios.get("/echo/count", { params: { n: "5" } });
69
+ expect(res.status).toBe(200);
70
+ expect(res.data).toEqual({ n: 5 });
71
+ });
72
+
73
+ it("advertises the OS-assigned port after listen(0)", () => {
74
+ const origin = axios.defaults.baseURL;
75
+ expect(origin).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
76
+ expect(Number(new URL(origin ?? "").port)).toBeGreaterThan(0);
77
+ expect(app.addresses).toContain(origin);
78
+ });
79
+
80
+ it("starts two apps without a port collision", async () => {
81
+ const originA = axios.defaults.baseURL;
82
+ const other = await Builder.create()
83
+ .withHttpProvider(FastifyProvider)
84
+ .withControllers(EchoController)
85
+ .build();
86
+ try {
87
+ const originB = await listenEphemeral(other);
88
+ expect(originA).not.toBe(originB);
89
+ expect(originB).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
90
+ } finally {
91
+ await other.dispose();
92
+ }
93
+ });
94
+
95
+ it("dispose drains scribe after the container", async () => {
96
+ const stop = vi.spyOn(scribe, "stop").mockResolvedValue();
97
+ await app.dispose();
98
+ expect(stop).toHaveBeenCalledOnce();
99
+ stop.mockRestore();
100
+ });
101
+ });
102
+
103
+ describe("App container dispose", () => {
104
+ class SocketHub {
105
+ public closed = false;
106
+ public async [Symbol.asyncDispose]() {
107
+ this.closed = true;
108
+ }
109
+ }
110
+
111
+ it("closes HTTP and constructed DI services on dispose", async () => {
112
+ let hub: SocketHub | undefined;
113
+ const builder = Builder.create()
114
+ .withHttpProvider(FastifyProvider)
115
+ .withControllers(EchoController)
116
+ .withServices((cb) => {
117
+ cb.registerSingleton(SocketHub);
118
+ });
119
+ builder.registerPostBuildStep((container) => {
120
+ hub = container.resolve(SocketHub);
121
+ });
122
+ const disposable = await builder.build();
123
+ const origin = await listenEphemeral(disposable);
124
+ await axios.get(`${origin}/echo`);
125
+
126
+ await disposable.dispose();
127
+ await disposable.dispose();
128
+
129
+ expect(hub?.closed).toBe(true);
130
+ await expect(axios.get(`${origin}/echo`, { timeout: 1_000 })).rejects.toThrow();
131
+ });
132
+ });
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ ADMIN_ROLE,
4
+ createPrincipalFromPayload,
5
+ defineAbilitiesFor,
6
+ } from "../src/auth/ability.js";
7
+ import { CaslAuthorizationService } from "../src/auth/casl-authorization.service.js";
8
+ import { ForbiddenError } from "../src/routing/anvil.types.js";
9
+
10
+ describe("defineAbilitiesFor", () => {
11
+ it("grants manage/all for the admin role", async () => {
12
+ const svc = new CaslAuthorizationService(defineAbilitiesFor);
13
+ await expect(
14
+ svc.authorize({ sub: "a", roles: [ADMIN_ROLE] }, "delete", "Hello"),
15
+ ).resolves.toBeUndefined();
16
+ await expect(
17
+ svc.authorize({ sub: "a", roles: [ADMIN_ROLE] }, "create", "Invoice"),
18
+ ).resolves.toBeUndefined();
19
+ });
20
+
21
+ it("maps action:Subject scopes for non-admin principals", async () => {
22
+ const svc = new CaslAuthorizationService(defineAbilitiesFor);
23
+ const user = { sub: "u", scopes: ["read:Hello", "create:Hello"] };
24
+ await expect(svc.authorize(user, "read", "Hello")).resolves.toBeUndefined();
25
+ await expect(svc.authorize(user, "create", "Hello")).resolves.toBeUndefined();
26
+ await expect(svc.authorize(user, "delete", "Hello")).rejects.toThrow(ForbiddenError);
27
+ });
28
+ });
29
+
30
+ describe("createPrincipalFromPayload", () => {
31
+ it("merges scope string and permissions claim", () => {
32
+ const principal = createPrincipalFromPayload({
33
+ sub: "auth0|user",
34
+ scope: "read:Hello openid",
35
+ permissions: ["create:Hello", "read:Hello"],
36
+ roles: ["viewer"],
37
+ });
38
+ expect(principal.sub).toBe("auth0|user");
39
+ expect(principal.roles).toEqual(["viewer"]);
40
+ expect(principal.scopes?.sort()).toEqual(["create:Hello", "openid", "read:Hello"].sort());
41
+ });
42
+
43
+ it("returns undefined roles/scopes when absent", () => {
44
+ const principal = createPrincipalFromPayload({ sub: "x" });
45
+ expect(principal.roles).toBeUndefined();
46
+ expect(principal.scopes).toBeUndefined();
47
+ });
48
+ });
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ Authorize,
4
+ Controller,
5
+ Get,
6
+ getAuthorizeOptions,
7
+ } from "../src/routing/decorators/index.js";
8
+
9
+ describe("@Authorize decorator", () => {
10
+ it("defaults subject to 'all' when omitted", () => {
11
+ @Controller()
12
+ class TestController {
13
+ @Authorize("read")
14
+ @Get("/")
15
+ public index() {}
16
+ }
17
+ const opts = getAuthorizeOptions(TestController.prototype, TestController.prototype.index);
18
+ expect(opts).toEqual({ action: "read", subject: "all" });
19
+ });
20
+ });
@@ -0,0 +1,77 @@
1
+ import axios from "axios";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import type { App } from "../src/app/app.js";
4
+ import { AuthConfig } from "../src/auth/auth.config.js";
5
+ import "../src/auth/auth.extensions.js";
6
+ import { Builder } from "../src/builder/builder.js";
7
+ import "../src/http/http.extensions.js";
8
+ import type { IConfigurationService } from "@decohere/core/configuration";
9
+ import { ConfigurationServiceToken } from "@decohere/core/configuration";
10
+ import type { Constructable } from "@decohere/core/reflection";
11
+ import { FastifyProvider } from "../src/providers/fastify.provider.js";
12
+ import { AllowAnonymous, Controller, Get } from "../src/routing/index.js";
13
+ import { listenEphemeral } from "./helpers/listen-ephemeral.js";
14
+
15
+ class DevStaticConfigService implements IConfigurationService {
16
+ public get dataSources() {
17
+ return [];
18
+ }
19
+ public async get<T extends object>(type: Constructable<T> | string): Promise<T> {
20
+ if (type === AuthConfig) {
21
+ const config = new AuthConfig();
22
+ config.devStatic = { sub: "dev-user", scopes: ["read:Article"] };
23
+ return config as unknown as T;
24
+ }
25
+ return {} as T;
26
+ }
27
+ }
28
+
29
+ /** No @Authorize / @AllowAnonymous — must not be open when auth is enabled. */
30
+ @Controller()
31
+ class UndecoratedController {
32
+ @Get("/open-by-mistake")
33
+ public openByMistake() {
34
+ return { ok: true };
35
+ }
36
+
37
+ @AllowAnonymous()
38
+ @Get("/explicit-open")
39
+ public explicitOpen() {
40
+ return { ok: true };
41
+ }
42
+ }
43
+
44
+ describe("pen-test — fail closed undecorated routes with withAuth", () => {
45
+ let app: App;
46
+
47
+ beforeEach(async () => {
48
+ vi.stubEnv("NODE_ENV", "development");
49
+
50
+ app = await Builder.create()
51
+ .withHttpProvider(FastifyProvider)
52
+ .withServices((c) => {
53
+ c.registerSingleton(ConfigurationServiceToken, new DevStaticConfigService());
54
+ })
55
+ .withAuth()
56
+ .withControllers(UndecoratedController)
57
+ .build();
58
+
59
+ axios.defaults.baseURL = await listenEphemeral(app);
60
+ });
61
+
62
+ afterEach(async () => {
63
+ await app.dispose();
64
+ vi.unstubAllEnvs();
65
+ });
66
+
67
+ it("rejects unauthenticated access to an undecorated route when auth is enabled", async () => {
68
+ await expect(axios.get("/undecorated/open-by-mistake")).rejects.toMatchObject({
69
+ response: { status: 401 },
70
+ });
71
+ });
72
+
73
+ it("still allows explicitly @AllowAnonymous routes", async () => {
74
+ const res = await axios.get("/undecorated/explicit-open");
75
+ expect(res.status).toBe(200);
76
+ });
77
+ });
@@ -0,0 +1,234 @@
1
+ import axios from "axios";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import type { App } from "../src/app/app.js";
4
+ import { AuthConfig } from "../src/auth/auth.config.js";
5
+ import "../src/auth/auth.extensions.js";
6
+ import type { IPrincipalProvider } from "../src/auth/principal-provider.js";
7
+ import { Builder } from "../src/builder/builder.js";
8
+ import { IPrincipalProviderToken } from "../src/const.js";
9
+ import "../src/http/http.extensions.js";
10
+ import { inject } from "@decohere/di/decorators";
11
+ import type { IConfigurationService } from "@decohere/core/configuration";
12
+ import { ConfigurationServiceToken } from "@decohere/core/configuration";
13
+ import type { Constructable } from "@decohere/core/reflection";
14
+ import type { Principal } from "../src/auth/auth.types.js";
15
+ import { FastifyProvider } from "../src/providers/fastify.provider.js";
16
+ import {
17
+ AllowAnonymous,
18
+ Authorize,
19
+ Controller,
20
+ CurrentUser,
21
+ Get,
22
+ Query,
23
+ } from "../src/routing/index.js";
24
+ import { listenEphemeral } from "./helpers/listen-ephemeral.js";
25
+
26
+ // Provides devStatic auth config — no JWT signing required.
27
+ class DevStaticConfigService implements IConfigurationService {
28
+ public get dataSources() {
29
+ return [];
30
+ }
31
+ public async get<T extends object>(type: Constructable<T> | string): Promise<T> {
32
+ if (type === AuthConfig) {
33
+ const config = new AuthConfig();
34
+ config.devStatic = { sub: "dev-user", scopes: ["read:Article"] };
35
+ return config as unknown as T;
36
+ }
37
+ return {} as T;
38
+ }
39
+ }
40
+
41
+ // Defined at module scope so decorator metadata is stable across all tests.
42
+ // Class name "GuardController" → prefix "guard" → routes at /guard/...
43
+ @Controller()
44
+ class GuardController {
45
+ public constructor(
46
+ @inject(IPrincipalProviderToken) private readonly principalProvider: IPrincipalProvider,
47
+ ) {}
48
+
49
+ @Authorize("read", "Article")
50
+ @Get("/me")
51
+ public me(@CurrentUser() user: Principal) {
52
+ return { sub: user?.sub };
53
+ }
54
+
55
+ @Authorize("read", "Article")
56
+ @Get("/me-search")
57
+ public meSearch(@CurrentUser() user: Principal, @Query("q") q: string) {
58
+ return { sub: user?.sub, q };
59
+ }
60
+
61
+ @Authorize("read", "Article")
62
+ @Get("/protected")
63
+ public secure() {
64
+ return { sub: this.principalProvider.currentPrincipal?.sub };
65
+ }
66
+
67
+ @Authorize("delete", "Article")
68
+ @Get("/admin")
69
+ public admin() {
70
+ return { ok: true };
71
+ }
72
+
73
+ @AllowAnonymous()
74
+ @Get("/open")
75
+ public open() {
76
+ return { ok: true };
77
+ }
78
+ }
79
+
80
+ // Class-level @Authorize — methods inherit unless they override or opt out.
81
+ @Authorize("read", "Article")
82
+ @Controller()
83
+ class ClassAuthController {
84
+ @Get("/inherited")
85
+ public inherited() {
86
+ return { ok: true };
87
+ }
88
+
89
+ @AllowAnonymous()
90
+ @Get("/opt-out")
91
+ public optOut() {
92
+ return { ok: true };
93
+ }
94
+
95
+ @Authorize("delete", "Article")
96
+ @Get("/override")
97
+ public override() {
98
+ return { ok: true };
99
+ }
100
+ }
101
+
102
+ describe("Auth integration — devStatic", () => {
103
+ let app: App;
104
+
105
+ beforeEach(async () => {
106
+ vi.stubEnv("NODE_ENV", "development");
107
+
108
+ app = await Builder.create()
109
+ .withHttpProvider(FastifyProvider)
110
+ .withServices((c) => {
111
+ c.registerSingleton(ConfigurationServiceToken, new DevStaticConfigService());
112
+ })
113
+ .withAuth()
114
+ .withControllers(GuardController, ClassAuthController)
115
+ .build();
116
+
117
+ axios.defaults.baseURL = await listenEphemeral(app);
118
+ });
119
+
120
+ afterEach(async () => {
121
+ await app.dispose();
122
+ vi.unstubAllEnvs();
123
+ });
124
+
125
+ describe("@AllowAnonymous", () => {
126
+ it("allows requests with no token", async () => {
127
+ const res = await axios.get("/guard/open");
128
+ expect(res.status).toBe(200);
129
+ expect(res.data).toEqual({ ok: true });
130
+ });
131
+ });
132
+
133
+ describe("missing token", () => {
134
+ it("returns 401 when no Authorization header is present", async () => {
135
+ await expect(axios.get("/guard/protected")).rejects.toMatchObject({
136
+ response: { status: 401 },
137
+ });
138
+ });
139
+
140
+ it("returns a generic Unauthorized body (no internal auth detail)", async () => {
141
+ const res = await axios.get("/guard/protected", { validateStatus: () => true });
142
+ expect(res.status).toBe(401);
143
+ expect(res.data).toEqual({ message: "Unauthorized" });
144
+ expect(JSON.stringify(res.data)).not.toMatch(/Authorization|header|Bearer/i);
145
+ });
146
+
147
+ it("returns 401 when the Authorization header has no Bearer prefix", async () => {
148
+ await expect(
149
+ axios.get("/guard/protected", { headers: { Authorization: "any-token" } }),
150
+ ).rejects.toMatchObject({ response: { status: 401 } });
151
+ });
152
+
153
+ it("sanitizes malformed Authorization header bodies to Unauthorized", async () => {
154
+ const res = await axios.get("/guard/protected", {
155
+ headers: { Authorization: "any-token" },
156
+ validateStatus: () => true,
157
+ });
158
+ expect(res.status).toBe(401);
159
+ expect(res.data).toEqual({ message: "Unauthorized" });
160
+ });
161
+ });
162
+
163
+ describe("insufficient ability", () => {
164
+ it("returns 403 when the principal cannot perform the required action", async () => {
165
+ // devStatic principal has read:Article but not delete:Article
166
+ const res = await axios.get("/guard/admin", {
167
+ headers: { Authorization: "Bearer any-token" },
168
+ validateStatus: () => true,
169
+ });
170
+ expect(res.status).toBe(403);
171
+ expect(res.data).toEqual({ message: expect.stringMatching(/delete.*Article/) });
172
+ });
173
+ });
174
+
175
+ describe("authorized access", () => {
176
+ it("allows the devStatic principal to access a permitted route", async () => {
177
+ const res = await axios.get("/guard/protected", {
178
+ headers: { Authorization: "Bearer any-token" },
179
+ });
180
+ expect(res.status).toBe(200);
181
+ });
182
+
183
+ it("resolves the principal via IPrincipalProvider", async () => {
184
+ const res = await axios.get("/guard/protected", {
185
+ headers: { Authorization: "Bearer any-token" },
186
+ });
187
+ expect(res.data).toEqual({ sub: "dev-user" });
188
+ });
189
+
190
+ it("injects the principal through @CurrentUser", async () => {
191
+ const res = await axios.get("/guard/me", {
192
+ headers: { Authorization: "Bearer any-token" },
193
+ });
194
+ expect(res.status).toBe(200);
195
+ expect(res.data).toEqual({ sub: "dev-user" });
196
+ });
197
+
198
+ it("injects @CurrentUser alongside a query argument", async () => {
199
+ const res = await axios.get("/guard/me-search", {
200
+ headers: { Authorization: "Bearer any-token" },
201
+ params: { q: "hi" },
202
+ });
203
+ expect(res.status).toBe(200);
204
+ expect(res.data).toEqual({ sub: "dev-user", q: "hi" });
205
+ });
206
+ });
207
+
208
+ describe("class-level @Authorize", () => {
209
+ it("protects a method with no decorator using the class-level constraint", async () => {
210
+ await expect(axios.get("/classauth/inherited")).rejects.toMatchObject({
211
+ response: { status: 401 },
212
+ });
213
+ });
214
+
215
+ it("allows access to an inherited route when the principal satisfies the class constraint", async () => {
216
+ const res = await axios.get("/classauth/inherited", {
217
+ headers: { Authorization: "Bearer any-token" },
218
+ });
219
+ expect(res.status).toBe(200);
220
+ });
221
+
222
+ it("allows anonymous access to a method decorated with @AllowAnonymous", async () => {
223
+ const res = await axios.get("/classauth/opt-out");
224
+ expect(res.status).toBe(200);
225
+ });
226
+
227
+ it("uses the method-level constraint when it overrides the class-level one", async () => {
228
+ // devStatic principal has read:Article but not delete:Article
229
+ await expect(
230
+ axios.get("/classauth/override", { headers: { Authorization: "Bearer any-token" } }),
231
+ ).rejects.toMatchObject({ response: { status: 403 } });
232
+ });
233
+ });
234
+ });
@@ -0,0 +1,52 @@
1
+ import { createServer, type Server } from "node:http";
2
+ import type { AddressInfo } from "node:net";
3
+ import { exportJWK, generateKeyPair, SignJWT } from "jose";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { TokenService } from "../src/auth/token-service.js";
6
+
7
+ // #499: in jwksUrl mode the signing keys were fetched again for every token verified.
8
+ // The key set must be created once and reused, so keys are fetched once and then cached.
9
+ describe("TokenService with jwksUrl", () => {
10
+ let server: Server | undefined;
11
+
12
+ afterEach(async () => {
13
+ vi.unstubAllEnvs();
14
+ await new Promise((resolve) => server?.close(resolve));
15
+ server = undefined;
16
+ });
17
+
18
+ it("fetches the key set once across many verifications", async () => {
19
+ vi.stubEnv("NODE_ENV", "test"); // loopback URLs are only allowed in development/test
20
+ const { publicKey, privateKey } = await generateKeyPair("RS256");
21
+ const jwk = { ...(await exportJWK(publicKey)), kid: "k1", alg: "RS256", use: "sig" };
22
+ let fetches = 0;
23
+ server = createServer((_req, res) => {
24
+ fetches++;
25
+ res.setHeader("content-type", "application/json");
26
+ res.end(JSON.stringify({ keys: [jwk] }));
27
+ });
28
+ await new Promise<void>((resolve) => server?.listen(0, "127.0.0.1", resolve));
29
+ const { port } = server.address() as AddressInfo;
30
+
31
+ const service = new TokenService({
32
+ jwksUrl: `http://127.0.0.1:${port}/jwks.json`,
33
+ issuer: "test-issuer",
34
+ audience: "test-audience",
35
+ });
36
+ const mint = (sub: string) =>
37
+ new SignJWT({})
38
+ .setProtectedHeader({ alg: "RS256", kid: "k1" })
39
+ .setSubject(sub)
40
+ .setIssuer("test-issuer")
41
+ .setAudience("test-audience")
42
+ .setExpirationTime("5m")
43
+ .sign(privateKey);
44
+
45
+ for (let i = 0; i < 5; i++) {
46
+ const principal = await service.verify(await mint(`user-${i}`));
47
+ expect(principal.sub).toBe(`user-${i}`);
48
+ }
49
+
50
+ expect(fetches).toBe(1);
51
+ });
52
+ });
@@ -0,0 +1,56 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import type { App } from "../src/app/app.js";
3
+ import { Builder } from "../src/builder/builder.js";
4
+ import "../src/http/http.extensions.js";
5
+ import { FastifyProvider } from "../src/providers/fastify.provider.js";
6
+ import { AllowAnonymous, Authorize, Controller, Get } from "../src/routing/index.js";
7
+ import { listenEphemeral } from "./helpers/listen-ephemeral.js";
8
+
9
+ @Controller({ prefix: "/admin" })
10
+ class AdminController {
11
+ @Authorize("manage", "all")
12
+ @Get()
13
+ public secrets() {
14
+ return { secret: true };
15
+ }
16
+ }
17
+
18
+ @Controller({ prefix: "/public" })
19
+ class PublicController {
20
+ @AllowAnonymous()
21
+ @Get()
22
+ public hello() {
23
+ return { hello: true };
24
+ }
25
+ }
26
+
27
+ // #500: without withAuth() there is nothing to check tokens, and routes marked @Authorize
28
+ // used to be served to anyone. The app must refuse to start instead.
29
+ describe("pen test: @Authorize without withAuth()", () => {
30
+ let app: App | undefined;
31
+
32
+ afterEach(async () => {
33
+ await app?.dispose();
34
+ app = undefined;
35
+ });
36
+
37
+ it("refuses to start when a route requires authorization but auth is not configured", async () => {
38
+ const start = async () => {
39
+ app = await Builder.create()
40
+ .withHttp(FastifyProvider)
41
+ .withControllers(AdminController)
42
+ .build();
43
+ await listenEphemeral(app);
44
+ };
45
+
46
+ await expect(start()).rejects.toThrow(/@Authorize.*withAuth/s);
47
+ });
48
+
49
+ it("still starts when no route requires authorization", async () => {
50
+ app = await Builder.create()
51
+ .withHttp(FastifyProvider)
52
+ .withControllers(PublicController)
53
+ .build();
54
+ await expect(listenEphemeral(app)).resolves.toBeTruthy();
55
+ });
56
+ });
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { assertSafeOutboundUrl } from "../src/auth/safe-outbound-url.js";
3
+ import { TokenService } from "../src/auth/token-service.js";
4
+
5
+ /**
6
+ * Pen test: OIDC discovery / JWKS URL SSRF.
7
+ *
8
+ * Scenario: discoveryUrl or a discovery document's jwks_uri points at cloud
9
+ * metadata / private network. The HTTP package must refuse the fetch instead of making
10
+ * a server-side request.
11
+ */
12
+ describe("pen test — OIDC discovery / JWKS SSRF CU-869eh25et", () => {
13
+ it("rejects discoveryUrl targeting link-local metadata", async () => {
14
+ const service = new TokenService({
15
+ discoveryUrl: "http://169.254.169.254",
16
+ audience: "https://api.example",
17
+ });
18
+
19
+ await expect(service.verify("not.a.jwt")).rejects.toThrow(/blocked|not allowed|https/i);
20
+ });
21
+
22
+ it("rejects explicit jwksUrl targeting private network", async () => {
23
+ const service = new TokenService({
24
+ jwksUrl: "https://10.0.0.5/jwks.json",
25
+ issuer: "https://idp.example",
26
+ audience: "https://api.example",
27
+ });
28
+
29
+ await expect(service.verify("not.a.jwt")).rejects.toThrow(/blocked|not allowed/i);
30
+ });
31
+
32
+ it("rejects jwks_uri from discovery when it points at metadata", async () => {
33
+ vi.stubEnv("NODE_ENV", "test");
34
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
35
+ new Response(
36
+ JSON.stringify({
37
+ issuer: "http://127.0.0.1:9",
38
+ jwks_uri: "http://169.254.169.254/latest/meta-data/",
39
+ }),
40
+ { status: 200, headers: { "Content-Type": "application/json" } },
41
+ ),
42
+ );
43
+
44
+ const service = new TokenService({
45
+ discoveryUrl: "http://127.0.0.1:9",
46
+ audience: "https://api.example",
47
+ });
48
+
49
+ await expect(service.verify("not.a.jwt")).rejects.toThrow(
50
+ /jwks_uri|blocked|not allowed|https/i,
51
+ );
52
+ expect(fetchMock).toHaveBeenCalled();
53
+ fetchMock.mockRestore();
54
+ vi.unstubAllEnvs();
55
+ });
56
+
57
+ it("assertSafeOutboundUrl allows loopback https/http in test", async () => {
58
+ vi.stubEnv("NODE_ENV", "test");
59
+ await expect(
60
+ assertSafeOutboundUrl("http://127.0.0.1:8080", "discoveryUrl"),
61
+ ).resolves.toBeInstanceOf(URL);
62
+ vi.unstubAllEnvs();
63
+ });
64
+
65
+ it("assertSafeOutboundUrl blocks http to non-loopback", async () => {
66
+ vi.stubEnv("NODE_ENV", "test");
67
+ await expect(
68
+ assertSafeOutboundUrl("http://93.184.215.14", "discoveryUrl"),
69
+ ).rejects.toThrow(/https/i);
70
+ vi.unstubAllEnvs();
71
+ });
72
+ });