palaryn 0.1.0 → 0.3.2

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 (344) hide show
  1. package/README.md +243 -588
  2. package/dist/sdk/typescript/src/client.js +2 -2
  3. package/dist/sdk/typescript/src/client.js.map +1 -1
  4. package/dist/src/anomaly/detector.d.ts +7 -4
  5. package/dist/src/anomaly/detector.d.ts.map +1 -1
  6. package/dist/src/anomaly/detector.js +22 -12
  7. package/dist/src/anomaly/detector.js.map +1 -1
  8. package/dist/src/audit/logger.d.ts +10 -0
  9. package/dist/src/audit/logger.d.ts.map +1 -1
  10. package/dist/src/audit/logger.js +52 -38
  11. package/dist/src/audit/logger.js.map +1 -1
  12. package/dist/src/auth/routes.d.ts.map +1 -1
  13. package/dist/src/auth/routes.js +35 -0
  14. package/dist/src/auth/routes.js.map +1 -1
  15. package/dist/src/budget/manager.d.ts +5 -0
  16. package/dist/src/budget/manager.d.ts.map +1 -1
  17. package/dist/src/budget/manager.js +32 -0
  18. package/dist/src/budget/manager.js.map +1 -1
  19. package/dist/src/budget/model-pricing.d.ts +20 -0
  20. package/dist/src/budget/model-pricing.d.ts.map +1 -0
  21. package/dist/src/budget/model-pricing.js +107 -0
  22. package/dist/src/budget/model-pricing.js.map +1 -0
  23. package/dist/src/budget/usage-extractor.d.ts +3 -1
  24. package/dist/src/budget/usage-extractor.d.ts.map +1 -1
  25. package/dist/src/budget/usage-extractor.js +47 -3
  26. package/dist/src/budget/usage-extractor.js.map +1 -1
  27. package/dist/src/config/defaults.d.ts.map +1 -1
  28. package/dist/src/config/defaults.js +65 -13
  29. package/dist/src/config/defaults.js.map +1 -1
  30. package/dist/src/dlp/tool-patterns.d.ts +7 -0
  31. package/dist/src/dlp/tool-patterns.d.ts.map +1 -0
  32. package/dist/src/dlp/tool-patterns.js +34 -0
  33. package/dist/src/dlp/tool-patterns.js.map +1 -0
  34. package/dist/src/executor/filesystem-executor.d.ts +28 -0
  35. package/dist/src/executor/filesystem-executor.d.ts.map +1 -0
  36. package/dist/src/executor/filesystem-executor.js +192 -0
  37. package/dist/src/executor/filesystem-executor.js.map +1 -0
  38. package/dist/src/executor/http-executor.d.ts.map +1 -1
  39. package/dist/src/executor/http-executor.js +22 -2
  40. package/dist/src/executor/http-executor.js.map +1 -1
  41. package/dist/src/executor/index.d.ts +4 -0
  42. package/dist/src/executor/index.d.ts.map +1 -1
  43. package/dist/src/executor/index.js +9 -1
  44. package/dist/src/executor/index.js.map +1 -1
  45. package/dist/src/executor/shell-executor.d.ts +22 -0
  46. package/dist/src/executor/shell-executor.d.ts.map +1 -0
  47. package/dist/src/executor/shell-executor.js +119 -0
  48. package/dist/src/executor/shell-executor.js.map +1 -0
  49. package/dist/src/executor/sql-executor.d.ts +29 -0
  50. package/dist/src/executor/sql-executor.d.ts.map +1 -0
  51. package/dist/src/executor/sql-executor.js +114 -0
  52. package/dist/src/executor/sql-executor.js.map +1 -0
  53. package/dist/src/executor/websocket-executor.d.ts +26 -0
  54. package/dist/src/executor/websocket-executor.d.ts.map +1 -0
  55. package/dist/src/executor/websocket-executor.js +205 -0
  56. package/dist/src/executor/websocket-executor.js.map +1 -0
  57. package/dist/src/interceptor/index.d.ts +2 -0
  58. package/dist/src/interceptor/index.d.ts.map +1 -0
  59. package/dist/src/interceptor/index.js +6 -0
  60. package/dist/src/interceptor/index.js.map +1 -0
  61. package/dist/src/interceptor/provider-interceptor.d.ts +36 -0
  62. package/dist/src/interceptor/provider-interceptor.d.ts.map +1 -0
  63. package/dist/src/interceptor/provider-interceptor.js +302 -0
  64. package/dist/src/interceptor/provider-interceptor.js.map +1 -0
  65. package/dist/src/mcp/auth-verifier.d.ts.map +1 -1
  66. package/dist/src/mcp/auth-verifier.js +3 -2
  67. package/dist/src/mcp/auth-verifier.js.map +1 -1
  68. package/dist/src/mcp/bridge.d.ts +14 -10
  69. package/dist/src/mcp/bridge.d.ts.map +1 -1
  70. package/dist/src/mcp/bridge.js +51 -227
  71. package/dist/src/mcp/bridge.js.map +1 -1
  72. package/dist/src/mcp/http-transport.d.ts +2 -0
  73. package/dist/src/mcp/http-transport.d.ts.map +1 -1
  74. package/dist/src/mcp/http-transport.js +117 -66
  75. package/dist/src/mcp/http-transport.js.map +1 -1
  76. package/dist/src/mcp/internal-auth.d.ts +13 -0
  77. package/dist/src/mcp/internal-auth.d.ts.map +1 -0
  78. package/dist/src/mcp/internal-auth.js +12 -0
  79. package/dist/src/mcp/internal-auth.js.map +1 -0
  80. package/dist/src/mcp/tool-definitions.d.ts +41 -0
  81. package/dist/src/mcp/tool-definitions.d.ts.map +1 -0
  82. package/dist/src/mcp/tool-definitions.js +491 -0
  83. package/dist/src/mcp/tool-definitions.js.map +1 -0
  84. package/dist/src/middleware/auth.js.map +1 -1
  85. package/dist/src/middleware/session.js.map +1 -1
  86. package/dist/src/middleware/validate.d.ts +8 -0
  87. package/dist/src/middleware/validate.d.ts.map +1 -1
  88. package/dist/src/middleware/validate.js +45 -0
  89. package/dist/src/middleware/validate.js.map +1 -1
  90. package/dist/src/policy/engine.d.ts +4 -0
  91. package/dist/src/policy/engine.d.ts.map +1 -1
  92. package/dist/src/policy/engine.js +117 -0
  93. package/dist/src/policy/engine.js.map +1 -1
  94. package/dist/src/saas/routes.d.ts.map +1 -1
  95. package/dist/src/saas/routes.js +355 -22
  96. package/dist/src/saas/routes.js.map +1 -1
  97. package/dist/src/server/app.d.ts.map +1 -1
  98. package/dist/src/server/app.js +24 -3
  99. package/dist/src/server/app.js.map +1 -1
  100. package/dist/src/server/gateway.d.ts.map +1 -1
  101. package/dist/src/server/gateway.js +17 -0
  102. package/dist/src/server/gateway.js.map +1 -1
  103. package/dist/src/server/index.d.ts.map +1 -1
  104. package/dist/src/server/index.js +18 -0
  105. package/dist/src/server/index.js.map +1 -1
  106. package/dist/src/storage/interfaces.d.ts +14 -3
  107. package/dist/src/storage/interfaces.d.ts.map +1 -1
  108. package/dist/src/storage/memory.d.ts +2 -0
  109. package/dist/src/storage/memory.d.ts.map +1 -1
  110. package/dist/src/storage/memory.js +6 -0
  111. package/dist/src/storage/memory.js.map +1 -1
  112. package/dist/src/storage/postgres.d.ts +5 -0
  113. package/dist/src/storage/postgres.d.ts.map +1 -1
  114. package/dist/src/storage/postgres.js +16 -0
  115. package/dist/src/storage/postgres.js.map +1 -1
  116. package/dist/src/storage/redis.d.ts +10 -0
  117. package/dist/src/storage/redis.d.ts.map +1 -1
  118. package/dist/src/storage/redis.js +65 -0
  119. package/dist/src/storage/redis.js.map +1 -1
  120. package/dist/src/types/budget.d.ts +4 -0
  121. package/dist/src/types/budget.d.ts.map +1 -1
  122. package/dist/src/types/config.d.ts +58 -0
  123. package/dist/src/types/config.d.ts.map +1 -1
  124. package/dist/src/types/events.d.ts +1 -0
  125. package/dist/src/types/events.d.ts.map +1 -1
  126. package/dist/src/types/policy.d.ts +11 -1
  127. package/dist/src/types/policy.d.ts.map +1 -1
  128. package/dist/src/types/tool-result.d.ts +11 -0
  129. package/dist/src/types/tool-result.d.ts.map +1 -1
  130. package/dist/tests/unit/app-routes.test.d.ts +2 -0
  131. package/dist/tests/unit/app-routes.test.d.ts.map +1 -0
  132. package/dist/tests/unit/app-routes.test.js +715 -0
  133. package/dist/tests/unit/app-routes.test.js.map +1 -0
  134. package/dist/tests/unit/audit-logger.test.js +105 -0
  135. package/dist/tests/unit/audit-logger.test.js.map +1 -1
  136. package/dist/tests/unit/auth-providers.test.d.ts +2 -0
  137. package/dist/tests/unit/auth-providers.test.d.ts.map +1 -0
  138. package/dist/tests/unit/auth-providers.test.js +279 -0
  139. package/dist/tests/unit/auth-providers.test.js.map +1 -0
  140. package/dist/tests/unit/auth-routes-extended.test.d.ts +2 -0
  141. package/dist/tests/unit/auth-routes-extended.test.d.ts.map +1 -0
  142. package/dist/tests/unit/auth-routes-extended.test.js +993 -0
  143. package/dist/tests/unit/auth-routes-extended.test.js.map +1 -0
  144. package/dist/tests/unit/auth-verifier.test.d.ts +2 -0
  145. package/dist/tests/unit/auth-verifier.test.d.ts.map +1 -0
  146. package/dist/tests/unit/auth-verifier.test.js +505 -0
  147. package/dist/tests/unit/auth-verifier.test.js.map +1 -0
  148. package/dist/tests/unit/billing-routes.test.d.ts +2 -0
  149. package/dist/tests/unit/billing-routes.test.d.ts.map +1 -0
  150. package/dist/tests/unit/billing-routes.test.js +432 -0
  151. package/dist/tests/unit/billing-routes.test.js.map +1 -0
  152. package/dist/tests/unit/config-defaults.test.d.ts +2 -0
  153. package/dist/tests/unit/config-defaults.test.d.ts.map +1 -0
  154. package/dist/tests/unit/config-defaults.test.js +119 -0
  155. package/dist/tests/unit/config-defaults.test.js.map +1 -0
  156. package/dist/tests/unit/defaults.test.js +0 -10
  157. package/dist/tests/unit/defaults.test.js.map +1 -1
  158. package/dist/tests/unit/filesystem-executor.test.d.ts +2 -0
  159. package/dist/tests/unit/filesystem-executor.test.d.ts.map +1 -0
  160. package/dist/tests/unit/filesystem-executor.test.js +280 -0
  161. package/dist/tests/unit/filesystem-executor.test.js.map +1 -0
  162. package/dist/tests/unit/gateway-branches.test.d.ts +2 -0
  163. package/dist/tests/unit/gateway-branches.test.d.ts.map +1 -0
  164. package/dist/tests/unit/gateway-branches.test.js +1039 -0
  165. package/dist/tests/unit/gateway-branches.test.js.map +1 -0
  166. package/dist/tests/unit/http-executor-branches.test.d.ts +2 -0
  167. package/dist/tests/unit/http-executor-branches.test.d.ts.map +1 -0
  168. package/dist/tests/unit/http-executor-branches.test.js +495 -0
  169. package/dist/tests/unit/http-executor-branches.test.js.map +1 -0
  170. package/dist/tests/unit/logger.test.d.ts +2 -0
  171. package/dist/tests/unit/logger.test.d.ts.map +1 -0
  172. package/dist/tests/unit/logger.test.js +97 -0
  173. package/dist/tests/unit/logger.test.js.map +1 -0
  174. package/dist/tests/unit/mcp-internal-auth.test.d.ts +2 -0
  175. package/dist/tests/unit/mcp-internal-auth.test.d.ts.map +1 -0
  176. package/dist/tests/unit/mcp-internal-auth.test.js +445 -0
  177. package/dist/tests/unit/mcp-internal-auth.test.js.map +1 -0
  178. package/dist/tests/unit/metrics.test.js +102 -0
  179. package/dist/tests/unit/metrics.test.js.map +1 -1
  180. package/dist/tests/unit/model-pricing.test.d.ts +2 -0
  181. package/dist/tests/unit/model-pricing.test.d.ts.map +1 -0
  182. package/dist/tests/unit/model-pricing.test.js +87 -0
  183. package/dist/tests/unit/model-pricing.test.js.map +1 -0
  184. package/dist/tests/unit/oauth-stores.test.d.ts +2 -0
  185. package/dist/tests/unit/oauth-stores.test.d.ts.map +1 -0
  186. package/dist/tests/unit/oauth-stores.test.js +260 -0
  187. package/dist/tests/unit/oauth-stores.test.js.map +1 -0
  188. package/dist/tests/unit/policy-engine.test.js +466 -0
  189. package/dist/tests/unit/policy-engine.test.js.map +1 -1
  190. package/dist/tests/unit/provider-interceptor.test.d.ts +2 -0
  191. package/dist/tests/unit/provider-interceptor.test.d.ts.map +1 -0
  192. package/dist/tests/unit/provider-interceptor.test.js +472 -0
  193. package/dist/tests/unit/provider-interceptor.test.js.map +1 -0
  194. package/dist/tests/unit/saas-routes-branches.test.d.ts +2 -0
  195. package/dist/tests/unit/saas-routes-branches.test.d.ts.map +1 -0
  196. package/dist/tests/unit/saas-routes-branches.test.js +2165 -0
  197. package/dist/tests/unit/saas-routes-branches.test.js.map +1 -0
  198. package/dist/tests/unit/saas-routes-crud.test.d.ts +2 -0
  199. package/dist/tests/unit/saas-routes-crud.test.d.ts.map +1 -0
  200. package/dist/tests/unit/saas-routes-crud.test.js +332 -0
  201. package/dist/tests/unit/saas-routes-crud.test.js.map +1 -0
  202. package/dist/tests/unit/saas-routes-data.test.d.ts +2 -0
  203. package/dist/tests/unit/saas-routes-data.test.d.ts.map +1 -0
  204. package/dist/tests/unit/saas-routes-data.test.js +405 -0
  205. package/dist/tests/unit/saas-routes-data.test.js.map +1 -0
  206. package/dist/tests/unit/saas-routes.test.js +3 -3
  207. package/dist/tests/unit/saas-routes.test.js.map +1 -1
  208. package/dist/tests/unit/shell-executor.test.d.ts +2 -0
  209. package/dist/tests/unit/shell-executor.test.d.ts.map +1 -0
  210. package/dist/tests/unit/shell-executor.test.js +145 -0
  211. package/dist/tests/unit/shell-executor.test.js.map +1 -0
  212. package/dist/tests/unit/sql-executor.test.d.ts +2 -0
  213. package/dist/tests/unit/sql-executor.test.d.ts.map +1 -0
  214. package/dist/tests/unit/sql-executor.test.js +177 -0
  215. package/dist/tests/unit/sql-executor.test.js.map +1 -0
  216. package/dist/tests/unit/stream-proxy.test.d.ts +2 -0
  217. package/dist/tests/unit/stream-proxy.test.d.ts.map +1 -0
  218. package/dist/tests/unit/stream-proxy.test.js +147 -0
  219. package/dist/tests/unit/stream-proxy.test.js.map +1 -0
  220. package/dist/tests/unit/tool-definitions.test.d.ts +2 -0
  221. package/dist/tests/unit/tool-definitions.test.d.ts.map +1 -0
  222. package/dist/tests/unit/tool-definitions.test.js +184 -0
  223. package/dist/tests/unit/tool-definitions.test.js.map +1 -0
  224. package/dist/tests/unit/usage-extractor.test.js +140 -0
  225. package/dist/tests/unit/usage-extractor.test.js.map +1 -1
  226. package/dist/tests/unit/webhook-handler.test.d.ts +2 -0
  227. package/dist/tests/unit/webhook-handler.test.d.ts.map +1 -0
  228. package/dist/tests/unit/webhook-handler.test.js +453 -0
  229. package/dist/tests/unit/webhook-handler.test.js.map +1 -0
  230. package/dist/tests/unit/webhook-routes.test.d.ts +2 -0
  231. package/dist/tests/unit/webhook-routes.test.d.ts.map +1 -0
  232. package/dist/tests/unit/webhook-routes.test.js +69 -0
  233. package/dist/tests/unit/webhook-routes.test.js.map +1 -0
  234. package/dist/tests/unit/websocket-executor.test.d.ts +2 -0
  235. package/dist/tests/unit/websocket-executor.test.d.ts.map +1 -0
  236. package/dist/tests/unit/websocket-executor.test.js +121 -0
  237. package/dist/tests/unit/websocket-executor.test.js.map +1 -0
  238. package/package.json +8 -2
  239. package/policy-packs/demo_fail.yaml +41 -0
  240. package/policy-packs/full_tools.yaml +136 -0
  241. package/src/admin/index.ts +1 -0
  242. package/src/admin/routes.ts +509 -0
  243. package/src/admin/templates.ts +572 -0
  244. package/src/anomaly/detector.ts +730 -0
  245. package/src/anomaly/index.ts +1 -0
  246. package/src/approval/manager.ts +569 -0
  247. package/src/approval/webhook.ts +133 -0
  248. package/src/audit/logger.ts +490 -0
  249. package/src/auth/index.ts +5 -0
  250. package/src/auth/password.ts +21 -0
  251. package/src/auth/pkce.ts +22 -0
  252. package/src/auth/providers.ts +208 -0
  253. package/src/auth/routes.ts +561 -0
  254. package/src/auth/session.ts +84 -0
  255. package/src/billing/index.ts +6 -0
  256. package/src/billing/plan-enforcer.ts +135 -0
  257. package/src/billing/routes.ts +229 -0
  258. package/src/billing/stripe-client.ts +58 -0
  259. package/src/billing/webhook-handler.ts +182 -0
  260. package/src/billing/webhook-routes.ts +28 -0
  261. package/src/budget/manager.ts +679 -0
  262. package/src/budget/model-pricing.ts +119 -0
  263. package/src/budget/usage-extractor.ts +214 -0
  264. package/src/cli.ts +91 -0
  265. package/src/config/defaults.ts +261 -0
  266. package/src/config/validate.ts +88 -0
  267. package/src/dlp/composite-scanner.ts +213 -0
  268. package/src/dlp/index.ts +9 -0
  269. package/src/dlp/interfaces.ts +34 -0
  270. package/src/dlp/patterns.ts +30 -0
  271. package/src/dlp/prompt-injection-backend.ts +181 -0
  272. package/src/dlp/prompt-injection-patterns.ts +302 -0
  273. package/src/dlp/regex-backend.ts +181 -0
  274. package/src/dlp/scanner.ts +502 -0
  275. package/src/dlp/text-normalizer.ts +225 -0
  276. package/src/dlp/tool-patterns.ts +35 -0
  277. package/src/dlp/trufflehog-backend.ts +190 -0
  278. package/src/executor/filesystem-executor.ts +196 -0
  279. package/src/executor/http-executor.ts +349 -0
  280. package/src/executor/index.ts +9 -0
  281. package/src/executor/interfaces.ts +11 -0
  282. package/src/executor/noop-executor.ts +23 -0
  283. package/src/executor/registry.ts +64 -0
  284. package/src/executor/shell-executor.ts +148 -0
  285. package/src/executor/slack-executor.ts +176 -0
  286. package/src/executor/sql-executor.ts +146 -0
  287. package/src/executor/websocket-executor.ts +211 -0
  288. package/src/index.ts +24 -0
  289. package/src/interceptor/index.ts +1 -0
  290. package/src/interceptor/provider-interceptor.ts +315 -0
  291. package/src/mcp/auth-verifier.ts +152 -0
  292. package/src/mcp/bridge.ts +703 -0
  293. package/src/mcp/http-transport.ts +698 -0
  294. package/src/mcp/index.ts +9 -0
  295. package/src/mcp/internal-auth.ts +14 -0
  296. package/src/mcp/oauth-pages.ts +139 -0
  297. package/src/mcp/oauth-postgres-stores.ts +278 -0
  298. package/src/mcp/oauth-provider.ts +536 -0
  299. package/src/mcp/oauth-stores.ts +202 -0
  300. package/src/mcp/server.ts +55 -0
  301. package/src/mcp/tool-definitions.ts +562 -0
  302. package/src/metrics/collector.ts +357 -0
  303. package/src/metrics/index.ts +1 -0
  304. package/src/middleware/auth.ts +814 -0
  305. package/src/middleware/session.ts +85 -0
  306. package/src/middleware/validate.ts +130 -0
  307. package/src/policy/engine.ts +815 -0
  308. package/src/policy/index.ts +2 -0
  309. package/src/policy/opa-engine.ts +829 -0
  310. package/src/proxy/forward-proxy.ts +649 -0
  311. package/src/proxy/index.ts +1 -0
  312. package/src/ratelimit/limiter.ts +196 -0
  313. package/src/replay/engine.ts +142 -0
  314. package/src/replay/index.ts +1 -0
  315. package/src/saas/index.ts +1 -0
  316. package/src/saas/routes.ts +2178 -0
  317. package/src/server/app.ts +985 -0
  318. package/src/server/errors.ts +49 -0
  319. package/src/server/gateway.ts +1130 -0
  320. package/src/server/index.ts +307 -0
  321. package/src/server/logger.ts +255 -0
  322. package/src/server/stream-proxy.ts +202 -0
  323. package/src/storage/file-persistence.ts +315 -0
  324. package/src/storage/index.ts +4 -0
  325. package/src/storage/interfaces.ts +287 -0
  326. package/src/storage/memory.ts +686 -0
  327. package/src/storage/postgres.ts +1831 -0
  328. package/src/storage/redis.ts +835 -0
  329. package/src/tracing/index.ts +1 -0
  330. package/src/tracing/provider.ts +100 -0
  331. package/src/trust/calculator.ts +141 -0
  332. package/src/trust/index.ts +7 -0
  333. package/src/types/budget.ts +36 -0
  334. package/src/types/config.ts +278 -0
  335. package/src/types/events.ts +41 -0
  336. package/src/types/express.d.ts +14 -0
  337. package/src/types/index.ts +7 -0
  338. package/src/types/policy.ts +83 -0
  339. package/src/types/stripe-config.ts +11 -0
  340. package/src/types/subscription.ts +59 -0
  341. package/src/types/tool-call.ts +47 -0
  342. package/src/types/tool-result.ts +82 -0
  343. package/src/types/user.ts +125 -0
  344. package/tsconfig.json +24 -0
@@ -0,0 +1,2165 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const supertest_1 = __importDefault(require("supertest"));
7
+ const app_1 = require("../../src/server/app");
8
+ const defaults_1 = require("../../src/config/defaults");
9
+ // ---------------------------------------------------------------------------
10
+ // Setup
11
+ // ---------------------------------------------------------------------------
12
+ let app;
13
+ let saasStores;
14
+ let gateway;
15
+ const now = new Date().toISOString();
16
+ function createTestConfig() {
17
+ return {
18
+ ...defaults_1.DEFAULT_CONFIG,
19
+ port: 0,
20
+ host: '127.0.0.1',
21
+ auth: {
22
+ ...defaults_1.DEFAULT_CONFIG.auth,
23
+ enabled: true,
24
+ api_keys: {},
25
+ rbac: {
26
+ enabled: false,
27
+ roles: {},
28
+ },
29
+ },
30
+ policy: {
31
+ pack_path: './policy-packs/dev_fast.yaml',
32
+ default_effect: 'DENY',
33
+ hot_reload: false,
34
+ },
35
+ audit: {
36
+ enabled: true,
37
+ log_dir: '',
38
+ console_output: false,
39
+ retention_days: 30,
40
+ },
41
+ rate_limit: {
42
+ enabled: false,
43
+ actor_max_per_window: 100,
44
+ workspace_max_per_window: 500,
45
+ window_ms: 60000,
46
+ },
47
+ oauth: {
48
+ enabled: true,
49
+ session_secret: 'test-session-secret-for-branches',
50
+ session_ttl_seconds: 3600,
51
+ },
52
+ };
53
+ }
54
+ beforeAll(() => {
55
+ const config = createTestConfig();
56
+ const result = (0, app_1.createApp)(config);
57
+ app = result.app;
58
+ saasStores = result.saasStores;
59
+ gateway = result.gateway;
60
+ });
61
+ afterAll(() => {
62
+ gateway.shutdown();
63
+ });
64
+ beforeEach(() => {
65
+ saasStores.userStore.create({
66
+ id: 'u1',
67
+ email: 'test@test.com',
68
+ display_name: 'Test User',
69
+ avatar_url: 'https://img.example.com/avatar.png',
70
+ status: 'active',
71
+ onboarding_completed: false,
72
+ created_at: now,
73
+ updated_at: now,
74
+ });
75
+ saasStores.sessionStore.create({
76
+ id: 'sess1',
77
+ user_id: 'u1',
78
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
79
+ last_active_at: now,
80
+ created_at: now,
81
+ });
82
+ });
83
+ afterEach(() => {
84
+ saasStores.userStore.delete('u1');
85
+ saasStores.userStore.delete('u2');
86
+ saasStores.userStore.delete('u3');
87
+ saasStores.sessionStore.delete('sess1');
88
+ saasStores.sessionStore.delete('sess2');
89
+ const allWorkspaces = saasStores.workspaceStore.list();
90
+ for (const ws of allWorkspaces) {
91
+ const members = saasStores.workspaceMemberStore.getByWorkspace(ws.id);
92
+ for (const m of members) {
93
+ saasStores.workspaceMemberStore.delete(m.id);
94
+ }
95
+ const keys = saasStores.userApiKeyStore.getByWorkspace(ws.id);
96
+ for (const k of keys) {
97
+ saasStores.userApiKeyStore.delete(k.id);
98
+ }
99
+ saasStores.workspaceStore.delete(ws.id);
100
+ }
101
+ // Clear approval manager between tests
102
+ gateway.getApprovalManager().clear();
103
+ });
104
+ // ===========================================================================
105
+ // Helper: seed a workspace + membership
106
+ // ===========================================================================
107
+ function seedWorkspace(opts) {
108
+ saasStores.workspaceStore.create({
109
+ id: opts.wsId,
110
+ name: `WS ${opts.wsId}`,
111
+ slug: opts.slug,
112
+ owner_user_id: opts.userId,
113
+ plan: (opts.plan || 'free'),
114
+ settings: {},
115
+ created_at: now,
116
+ updated_at: now,
117
+ });
118
+ saasStores.workspaceMemberStore.create({
119
+ id: opts.memberId,
120
+ workspace_id: opts.wsId,
121
+ user_id: opts.userId,
122
+ role: opts.role,
123
+ joined_at: now,
124
+ });
125
+ }
126
+ // ===========================================================================
127
+ // Tests — Branch Coverage
128
+ // ===========================================================================
129
+ describe('SaaS Routes — Branch Coverage', () => {
130
+ // -------------------------------------------------------------------------
131
+ // POST /workspaces/:id/policies/generate-rule (Lines 798-938)
132
+ // This is the large uncovered block — LLM-powered rule generation
133
+ // -------------------------------------------------------------------------
134
+ describe('POST /workspaces/:id/policies/generate-rule', () => {
135
+ beforeEach(() => {
136
+ seedWorkspace({ wsId: 'ws-gen', slug: 'gen-ws', userId: 'u1', role: 'owner', memberId: 'mem-gen' });
137
+ });
138
+ it('returns 401 or 429 without session', async () => {
139
+ const res = await (0, supertest_1.default)(app)
140
+ .post('/api/v1/workspaces/ws-gen/policies/generate-rule')
141
+ .send({ description: 'Allow GET requests' });
142
+ // Without API key header, the auth middleware returns 401;
143
+ // with many unauthenticated requests, the IP rate limiter may return 429.
144
+ expect([401, 429]).toContain(res.status);
145
+ });
146
+ it('returns 403 when user is not a member', async () => {
147
+ saasStores.workspaceMemberStore.delete('mem-gen');
148
+ const res = await (0, supertest_1.default)(app)
149
+ .post('/api/v1/workspaces/ws-gen/policies/generate-rule')
150
+ .set('Cookie', 'pn_session=sess1')
151
+ .send({ description: 'Allow GET requests' })
152
+ .expect(403);
153
+ expect(res.body.error).toContain('Not a member');
154
+ });
155
+ it('returns 503 when PALARYN_LLM_API_KEY is not set', async () => {
156
+ // Ensure the env var is not set
157
+ const originalKey = process.env.PALARYN_LLM_API_KEY;
158
+ delete process.env.PALARYN_LLM_API_KEY;
159
+ try {
160
+ const res = await (0, supertest_1.default)(app)
161
+ .post('/api/v1/workspaces/ws-gen/policies/generate-rule')
162
+ .set('Cookie', 'pn_session=sess1')
163
+ .send({ description: 'Allow GET requests' })
164
+ .expect(503);
165
+ expect(res.body.error).toContain('AI rule generation is not configured');
166
+ }
167
+ finally {
168
+ if (originalKey !== undefined) {
169
+ process.env.PALARYN_LLM_API_KEY = originalKey;
170
+ }
171
+ }
172
+ });
173
+ it('returns 400 when description is missing', async () => {
174
+ const originalKey = process.env.PALARYN_LLM_API_KEY;
175
+ process.env.PALARYN_LLM_API_KEY = 'test-key';
176
+ try {
177
+ const res = await (0, supertest_1.default)(app)
178
+ .post('/api/v1/workspaces/ws-gen/policies/generate-rule')
179
+ .set('Cookie', 'pn_session=sess1')
180
+ .send({})
181
+ .expect(400);
182
+ expect(res.body.error).toContain('description is required');
183
+ }
184
+ finally {
185
+ if (originalKey !== undefined) {
186
+ process.env.PALARYN_LLM_API_KEY = originalKey;
187
+ }
188
+ else {
189
+ delete process.env.PALARYN_LLM_API_KEY;
190
+ }
191
+ }
192
+ });
193
+ it('returns 400 when description is empty string', async () => {
194
+ const originalKey = process.env.PALARYN_LLM_API_KEY;
195
+ process.env.PALARYN_LLM_API_KEY = 'test-key';
196
+ try {
197
+ const res = await (0, supertest_1.default)(app)
198
+ .post('/api/v1/workspaces/ws-gen/policies/generate-rule')
199
+ .set('Cookie', 'pn_session=sess1')
200
+ .send({ description: ' ' })
201
+ .expect(400);
202
+ expect(res.body.error).toContain('description is required');
203
+ }
204
+ finally {
205
+ if (originalKey !== undefined) {
206
+ process.env.PALARYN_LLM_API_KEY = originalKey;
207
+ }
208
+ else {
209
+ delete process.env.PALARYN_LLM_API_KEY;
210
+ }
211
+ }
212
+ });
213
+ it('returns 400 when description is not a string', async () => {
214
+ const originalKey = process.env.PALARYN_LLM_API_KEY;
215
+ process.env.PALARYN_LLM_API_KEY = 'test-key';
216
+ try {
217
+ const res = await (0, supertest_1.default)(app)
218
+ .post('/api/v1/workspaces/ws-gen/policies/generate-rule')
219
+ .set('Cookie', 'pn_session=sess1')
220
+ .send({ description: 12345 })
221
+ .expect(400);
222
+ expect(res.body.error).toContain('description is required');
223
+ }
224
+ finally {
225
+ if (originalKey !== undefined) {
226
+ process.env.PALARYN_LLM_API_KEY = originalKey;
227
+ }
228
+ else {
229
+ delete process.env.PALARYN_LLM_API_KEY;
230
+ }
231
+ }
232
+ });
233
+ it('handles LLM API error (non-ok response)', async () => {
234
+ const originalKey = process.env.PALARYN_LLM_API_KEY;
235
+ process.env.PALARYN_LLM_API_KEY = 'test-key';
236
+ // Mock global fetch to simulate an LLM API error
237
+ const originalFetch = global.fetch;
238
+ global.fetch = jest.fn().mockResolvedValue({
239
+ ok: false,
240
+ status: 500,
241
+ text: jest.fn().mockResolvedValue('Internal Server Error'),
242
+ });
243
+ try {
244
+ const res = await (0, supertest_1.default)(app)
245
+ .post('/api/v1/workspaces/ws-gen/policies/generate-rule')
246
+ .set('Cookie', 'pn_session=sess1')
247
+ .send({ description: 'Allow GET requests' })
248
+ .expect(502);
249
+ expect(res.body.error).toContain('Failed to generate rule');
250
+ expect(res.body.error).toContain('LLM API returned an error');
251
+ }
252
+ finally {
253
+ global.fetch = originalFetch;
254
+ if (originalKey !== undefined) {
255
+ process.env.PALARYN_LLM_API_KEY = originalKey;
256
+ }
257
+ else {
258
+ delete process.env.PALARYN_LLM_API_KEY;
259
+ }
260
+ }
261
+ });
262
+ it('handles unparseable LLM response', async () => {
263
+ const originalKey = process.env.PALARYN_LLM_API_KEY;
264
+ process.env.PALARYN_LLM_API_KEY = 'test-key';
265
+ const originalFetch = global.fetch;
266
+ global.fetch = jest.fn().mockResolvedValue({
267
+ ok: true,
268
+ json: jest.fn().mockResolvedValue({
269
+ content: [{ type: 'text', text: 'this is not valid json at all' }],
270
+ }),
271
+ });
272
+ try {
273
+ const res = await (0, supertest_1.default)(app)
274
+ .post('/api/v1/workspaces/ws-gen/policies/generate-rule')
275
+ .set('Cookie', 'pn_session=sess1')
276
+ .send({ description: 'Allow GET requests' })
277
+ .expect(500);
278
+ expect(res.body.error).toContain('Failed to parse generated rule');
279
+ }
280
+ finally {
281
+ global.fetch = originalFetch;
282
+ if (originalKey !== undefined) {
283
+ process.env.PALARYN_LLM_API_KEY = originalKey;
284
+ }
285
+ else {
286
+ delete process.env.PALARYN_LLM_API_KEY;
287
+ }
288
+ }
289
+ });
290
+ it('returns a valid rule on successful LLM response', async () => {
291
+ const originalKey = process.env.PALARYN_LLM_API_KEY;
292
+ process.env.PALARYN_LLM_API_KEY = 'test-key';
293
+ const mockRule = {
294
+ name: 'allow-get-github',
295
+ description: 'Allow GET requests to GitHub',
296
+ effect: 'allow',
297
+ priority: 10,
298
+ conditions: { domains: ['api.github.com'], methods: ['GET'] },
299
+ extra_field: 'should be stripped',
300
+ };
301
+ const originalFetch = global.fetch;
302
+ global.fetch = jest.fn().mockResolvedValue({
303
+ ok: true,
304
+ json: jest.fn().mockResolvedValue({
305
+ content: [{ type: 'text', text: JSON.stringify(mockRule) }],
306
+ }),
307
+ });
308
+ try {
309
+ const res = await (0, supertest_1.default)(app)
310
+ .post('/api/v1/workspaces/ws-gen/policies/generate-rule')
311
+ .set('Cookie', 'pn_session=sess1')
312
+ .send({ description: 'Allow GET requests to GitHub' })
313
+ .expect(200);
314
+ expect(res.body.rule).toBeDefined();
315
+ expect(res.body.rule.name).toBe('allow-get-github');
316
+ // Effect should be uppercased
317
+ expect(res.body.rule.effect).toBe('ALLOW');
318
+ // extra_field should be stripped
319
+ expect(res.body.rule.extra_field).toBeUndefined();
320
+ }
321
+ finally {
322
+ global.fetch = originalFetch;
323
+ if (originalKey !== undefined) {
324
+ process.env.PALARYN_LLM_API_KEY = originalKey;
325
+ }
326
+ else {
327
+ delete process.env.PALARYN_LLM_API_KEY;
328
+ }
329
+ }
330
+ });
331
+ it('normalizes rule with missing fields', async () => {
332
+ const originalKey = process.env.PALARYN_LLM_API_KEY;
333
+ process.env.PALARYN_LLM_API_KEY = 'test-key';
334
+ // Rule with no name, no conditions, no effect
335
+ const mockRule = { priority: 5 };
336
+ const originalFetch = global.fetch;
337
+ global.fetch = jest.fn().mockResolvedValue({
338
+ ok: true,
339
+ json: jest.fn().mockResolvedValue({
340
+ content: [{ type: 'text', text: JSON.stringify(mockRule) }],
341
+ }),
342
+ });
343
+ try {
344
+ const res = await (0, supertest_1.default)(app)
345
+ .post('/api/v1/workspaces/ws-gen/policies/generate-rule')
346
+ .set('Cookie', 'pn_session=sess1')
347
+ .send({ description: 'Some rule' })
348
+ .expect(200);
349
+ expect(res.body.rule.name).toBe('generated-rule');
350
+ expect(res.body.rule.conditions).toEqual({});
351
+ expect(res.body.rule.effect).toBe('DENY');
352
+ }
353
+ finally {
354
+ global.fetch = originalFetch;
355
+ if (originalKey !== undefined) {
356
+ process.env.PALARYN_LLM_API_KEY = originalKey;
357
+ }
358
+ else {
359
+ delete process.env.PALARYN_LLM_API_KEY;
360
+ }
361
+ }
362
+ });
363
+ it('strips markdown code fences from LLM output', async () => {
364
+ const originalKey = process.env.PALARYN_LLM_API_KEY;
365
+ process.env.PALARYN_LLM_API_KEY = 'test-key';
366
+ const ruleJson = '{"name":"fenced-rule","effect":"DENY","conditions":{},"priority":1}';
367
+ const wrappedText = '```json\n' + ruleJson + '\n```';
368
+ const originalFetch = global.fetch;
369
+ global.fetch = jest.fn().mockResolvedValue({
370
+ ok: true,
371
+ json: jest.fn().mockResolvedValue({
372
+ content: [{ type: 'text', text: wrappedText }],
373
+ }),
374
+ });
375
+ try {
376
+ const res = await (0, supertest_1.default)(app)
377
+ .post('/api/v1/workspaces/ws-gen/policies/generate-rule')
378
+ .set('Cookie', 'pn_session=sess1')
379
+ .send({ description: 'Block everything' })
380
+ .expect(200);
381
+ expect(res.body.rule.name).toBe('fenced-rule');
382
+ }
383
+ finally {
384
+ global.fetch = originalFetch;
385
+ if (originalKey !== undefined) {
386
+ process.env.PALARYN_LLM_API_KEY = originalKey;
387
+ }
388
+ else {
389
+ delete process.env.PALARYN_LLM_API_KEY;
390
+ }
391
+ }
392
+ });
393
+ it('handles fetch abort/timeout (AbortError)', async () => {
394
+ const originalKey = process.env.PALARYN_LLM_API_KEY;
395
+ process.env.PALARYN_LLM_API_KEY = 'test-key';
396
+ const originalFetch = global.fetch;
397
+ global.fetch = jest.fn().mockRejectedValue(Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }));
398
+ try {
399
+ const res = await (0, supertest_1.default)(app)
400
+ .post('/api/v1/workspaces/ws-gen/policies/generate-rule')
401
+ .set('Cookie', 'pn_session=sess1')
402
+ .send({ description: 'Allow GET' })
403
+ .expect(504);
404
+ expect(res.body.error).toContain('timed out');
405
+ }
406
+ finally {
407
+ global.fetch = originalFetch;
408
+ if (originalKey !== undefined) {
409
+ process.env.PALARYN_LLM_API_KEY = originalKey;
410
+ }
411
+ else {
412
+ delete process.env.PALARYN_LLM_API_KEY;
413
+ }
414
+ }
415
+ });
416
+ it('handles unexpected fetch error', async () => {
417
+ const originalKey = process.env.PALARYN_LLM_API_KEY;
418
+ process.env.PALARYN_LLM_API_KEY = 'test-key';
419
+ const originalFetch = global.fetch;
420
+ global.fetch = jest.fn().mockRejectedValue(new Error('Network failure'));
421
+ try {
422
+ const res = await (0, supertest_1.default)(app)
423
+ .post('/api/v1/workspaces/ws-gen/policies/generate-rule')
424
+ .set('Cookie', 'pn_session=sess1')
425
+ .send({ description: 'Allow GET' })
426
+ .expect(500);
427
+ expect(res.body.error).toContain('Failed to generate rule');
428
+ }
429
+ finally {
430
+ global.fetch = originalFetch;
431
+ if (originalKey !== undefined) {
432
+ process.env.PALARYN_LLM_API_KEY = originalKey;
433
+ }
434
+ else {
435
+ delete process.env.PALARYN_LLM_API_KEY;
436
+ }
437
+ }
438
+ });
439
+ it('handles empty content array from LLM', async () => {
440
+ const originalKey = process.env.PALARYN_LLM_API_KEY;
441
+ process.env.PALARYN_LLM_API_KEY = 'test-key';
442
+ const originalFetch = global.fetch;
443
+ global.fetch = jest.fn().mockResolvedValue({
444
+ ok: true,
445
+ json: jest.fn().mockResolvedValue({
446
+ content: [],
447
+ }),
448
+ });
449
+ try {
450
+ // Empty content => text is '' => cleaned is '' => JSON.parse('') fails
451
+ const res = await (0, supertest_1.default)(app)
452
+ .post('/api/v1/workspaces/ws-gen/policies/generate-rule')
453
+ .set('Cookie', 'pn_session=sess1')
454
+ .send({ description: 'Allow GET' })
455
+ .expect(500);
456
+ expect(res.body.error).toContain('Failed to parse generated rule');
457
+ }
458
+ finally {
459
+ global.fetch = originalFetch;
460
+ if (originalKey !== undefined) {
461
+ process.env.PALARYN_LLM_API_KEY = originalKey;
462
+ }
463
+ else {
464
+ delete process.env.PALARYN_LLM_API_KEY;
465
+ }
466
+ }
467
+ });
468
+ it('returns 429 when per-user LLM rate limit is exceeded (11th request)', async () => {
469
+ // Use a dedicated user so rate limit state is isolated from other tests
470
+ const rlUserId = 'u-ratelimit';
471
+ const rlSessionId = 'sess-ratelimit';
472
+ saasStores.userStore.create({
473
+ id: rlUserId,
474
+ email: 'rl@test.com',
475
+ display_name: 'Rate Limit User',
476
+ status: 'active',
477
+ onboarding_completed: false,
478
+ created_at: now,
479
+ updated_at: now,
480
+ });
481
+ saasStores.sessionStore.create({
482
+ id: rlSessionId,
483
+ user_id: rlUserId,
484
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
485
+ last_active_at: now,
486
+ created_at: now,
487
+ });
488
+ saasStores.workspaceMemberStore.create({
489
+ id: 'mem-gen-rl',
490
+ workspace_id: 'ws-gen',
491
+ user_id: rlUserId,
492
+ role: 'member',
493
+ joined_at: now,
494
+ });
495
+ const originalKey = process.env.PALARYN_LLM_API_KEY;
496
+ process.env.PALARYN_LLM_API_KEY = 'test-key';
497
+ const validRule = JSON.stringify({
498
+ name: 'test-rule',
499
+ effect: 'ALLOW',
500
+ conditions: { methods: ['GET'] },
501
+ });
502
+ const originalFetch = global.fetch;
503
+ global.fetch = jest.fn().mockResolvedValue({
504
+ ok: true,
505
+ json: jest.fn().mockResolvedValue({
506
+ content: [{ type: 'text', text: validRule }],
507
+ }),
508
+ });
509
+ try {
510
+ // Make 10 successful requests (the limit)
511
+ for (let i = 0; i < 10; i++) {
512
+ await (0, supertest_1.default)(app)
513
+ .post('/api/v1/workspaces/ws-gen/policies/generate-rule')
514
+ .set('Cookie', `pn_session=${rlSessionId}`)
515
+ .send({ description: `Allow GET ${i}` })
516
+ .expect(200);
517
+ }
518
+ // 11th request should be rate limited
519
+ const res = await (0, supertest_1.default)(app)
520
+ .post('/api/v1/workspaces/ws-gen/policies/generate-rule')
521
+ .set('Cookie', `pn_session=${rlSessionId}`)
522
+ .send({ description: 'Allow GET again' })
523
+ .expect(429);
524
+ expect(res.body.error).toContain('limit of 10 AI generations per hour');
525
+ expect(res.body.retry_after_ms).toBeGreaterThan(0);
526
+ }
527
+ finally {
528
+ global.fetch = originalFetch;
529
+ saasStores.userStore.delete(rlUserId);
530
+ saasStores.sessionStore.delete(rlSessionId);
531
+ saasStores.workspaceMemberStore.delete('mem-gen-rl');
532
+ if (originalKey !== undefined) {
533
+ process.env.PALARYN_LLM_API_KEY = originalKey;
534
+ }
535
+ else {
536
+ delete process.env.PALARYN_LLM_API_KEY;
537
+ }
538
+ }
539
+ });
540
+ it('uses OpenAI API when key starts with sk-proj-', async () => {
541
+ const originalKey = process.env.PALARYN_LLM_API_KEY;
542
+ process.env.PALARYN_LLM_API_KEY = 'sk-proj-test123';
543
+ const mockRule = {
544
+ name: 'openai-rule',
545
+ effect: 'ALLOW',
546
+ priority: 10,
547
+ conditions: { methods: ['GET'] },
548
+ };
549
+ const originalFetch = global.fetch;
550
+ global.fetch = jest.fn().mockResolvedValue({
551
+ ok: true,
552
+ json: jest.fn().mockResolvedValue({
553
+ choices: [{ message: { content: JSON.stringify(mockRule) } }],
554
+ }),
555
+ });
556
+ try {
557
+ const res = await (0, supertest_1.default)(app)
558
+ .post('/api/v1/workspaces/ws-gen/policies/generate-rule')
559
+ .set('Cookie', 'pn_session=sess1')
560
+ .send({ description: 'Allow GET requests' })
561
+ .expect(200);
562
+ expect(res.body.rule.name).toBe('openai-rule');
563
+ expect(res.body.rule.effect).toBe('ALLOW');
564
+ // Verify fetch was called with OpenAI URL
565
+ const fetchCall = global.fetch.mock.calls[0];
566
+ expect(fetchCall[0]).toBe('https://api.openai.com/v1/chat/completions');
567
+ expect(JSON.parse(fetchCall[1].body).model).toBe('gpt-4.1-mini');
568
+ expect(fetchCall[1].headers['Authorization']).toBe('Bearer sk-proj-test123');
569
+ }
570
+ finally {
571
+ global.fetch = originalFetch;
572
+ if (originalKey !== undefined) {
573
+ process.env.PALARYN_LLM_API_KEY = originalKey;
574
+ }
575
+ else {
576
+ delete process.env.PALARYN_LLM_API_KEY;
577
+ }
578
+ }
579
+ });
580
+ it('uses Anthropic API when key does not start with sk-', async () => {
581
+ const originalKey = process.env.PALARYN_LLM_API_KEY;
582
+ process.env.PALARYN_LLM_API_KEY = 'ant-key-123';
583
+ const mockRule = {
584
+ name: 'anthropic-rule',
585
+ effect: 'DENY',
586
+ conditions: {},
587
+ };
588
+ const originalFetch = global.fetch;
589
+ global.fetch = jest.fn().mockResolvedValue({
590
+ ok: true,
591
+ json: jest.fn().mockResolvedValue({
592
+ content: [{ type: 'text', text: JSON.stringify(mockRule) }],
593
+ }),
594
+ });
595
+ try {
596
+ const res = await (0, supertest_1.default)(app)
597
+ .post('/api/v1/workspaces/ws-gen/policies/generate-rule')
598
+ .set('Cookie', 'pn_session=sess1')
599
+ .send({ description: 'Block everything' })
600
+ .expect(200);
601
+ expect(res.body.rule.name).toBe('anthropic-rule');
602
+ // Verify fetch was called with Anthropic URL
603
+ const fetchCall = global.fetch.mock.calls[0];
604
+ expect(fetchCall[0]).toBe('https://api.anthropic.com/v1/messages');
605
+ expect(fetchCall[1].headers['x-api-key']).toBe('ant-key-123');
606
+ }
607
+ finally {
608
+ global.fetch = originalFetch;
609
+ if (originalKey !== undefined) {
610
+ process.env.PALARYN_LLM_API_KEY = originalKey;
611
+ }
612
+ else {
613
+ delete process.env.PALARYN_LLM_API_KEY;
614
+ }
615
+ }
616
+ });
617
+ it('uses OpenAI API when key starts with sk- (non-proj)', async () => {
618
+ // Use a separate user to avoid rate limit from other tests
619
+ const skUserId = 'u-sk-test';
620
+ const skSessionId = 'sess-sk-test';
621
+ saasStores.userStore.create({
622
+ id: skUserId, email: 'sk@test.com', display_name: 'SK', status: 'active',
623
+ onboarding_completed: false, created_at: now, updated_at: now,
624
+ });
625
+ saasStores.sessionStore.create({
626
+ id: skSessionId, user_id: skUserId,
627
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
628
+ last_active_at: now, created_at: now,
629
+ });
630
+ saasStores.workspaceMemberStore.create({
631
+ id: 'mem-sk', workspace_id: 'ws-gen', user_id: skUserId, role: 'member', joined_at: now,
632
+ });
633
+ const originalKey = process.env.PALARYN_LLM_API_KEY;
634
+ process.env.PALARYN_LLM_API_KEY = 'sk-oldformat123';
635
+ const originalFetch = global.fetch;
636
+ global.fetch = jest.fn().mockResolvedValue({
637
+ ok: true,
638
+ json: jest.fn().mockResolvedValue({
639
+ choices: [{ message: { content: '{"name":"sk-rule","effect":"ALLOW","conditions":{}}' } }],
640
+ }),
641
+ });
642
+ try {
643
+ const res = await (0, supertest_1.default)(app)
644
+ .post('/api/v1/workspaces/ws-gen/policies/generate-rule')
645
+ .set('Cookie', `pn_session=${skSessionId}`)
646
+ .send({ description: 'Allow all' })
647
+ .expect(200);
648
+ expect(res.body.rule.name).toBe('sk-rule');
649
+ const fetchCall = global.fetch.mock.calls[0];
650
+ expect(fetchCall[0]).toBe('https://api.openai.com/v1/chat/completions');
651
+ }
652
+ finally {
653
+ global.fetch = originalFetch;
654
+ saasStores.userStore.delete(skUserId);
655
+ saasStores.sessionStore.delete(skSessionId);
656
+ saasStores.workspaceMemberStore.delete('mem-sk');
657
+ if (originalKey !== undefined) {
658
+ process.env.PALARYN_LLM_API_KEY = originalKey;
659
+ }
660
+ else {
661
+ delete process.env.PALARYN_LLM_API_KEY;
662
+ }
663
+ }
664
+ });
665
+ });
666
+ // -------------------------------------------------------------------------
667
+ // POST /workspaces (workspace limit enforcement — lines 127-143)
668
+ // -------------------------------------------------------------------------
669
+ describe('POST /workspaces — workspace limit enforcement', () => {
670
+ it('returns 403 when workspace limit is reached for free plan', async () => {
671
+ // Free plan allows 1 workspace. Create one first (as owner).
672
+ seedWorkspace({ wsId: 'ws-limit-1', slug: 'limit-1', userId: 'u1', role: 'owner', memberId: 'mem-limit-1', plan: 'free' });
673
+ const res = await (0, supertest_1.default)(app)
674
+ .post('/api/v1/workspaces')
675
+ .set('Cookie', 'pn_session=sess1')
676
+ .send({ name: 'Second Workspace' })
677
+ .expect(403);
678
+ expect(res.body.error).toContain('maximum');
679
+ });
680
+ it('determines highest plan across owned workspaces (lines 134-137)', async () => {
681
+ // User owns a free workspace and a pro workspace. Pro allows 5 workspaces.
682
+ seedWorkspace({ wsId: 'ws-plan-free', slug: 'plan-free', userId: 'u1', role: 'owner', memberId: 'mem-plan-free', plan: 'free' });
683
+ seedWorkspace({ wsId: 'ws-plan-pro', slug: 'plan-pro', userId: 'u1', role: 'owner', memberId: 'mem-plan-pro', plan: 'pro' });
684
+ // Pro plan allows 5 workspaces, user has 2, so a 3rd should succeed
685
+ const res = await (0, supertest_1.default)(app)
686
+ .post('/api/v1/workspaces')
687
+ .set('Cookie', 'pn_session=sess1')
688
+ .send({ name: 'Third Workspace' })
689
+ .expect(201);
690
+ expect(res.body.name).toBe('Third Workspace');
691
+ });
692
+ });
693
+ // -------------------------------------------------------------------------
694
+ // GET /workspaces/:id — workspace not found after membership check (lines 199-200)
695
+ // -------------------------------------------------------------------------
696
+ describe('GET /workspaces/:id — workspace not found edge case', () => {
697
+ it('returns 404 when workspace record is deleted but membership remains', async () => {
698
+ // Create workspace + membership, then delete the workspace record
699
+ seedWorkspace({ wsId: 'ws-phantom', slug: 'phantom-ws', userId: 'u1', role: 'owner', memberId: 'mem-phantom' });
700
+ saasStores.workspaceStore.delete('ws-phantom');
701
+ const res = await (0, supertest_1.default)(app)
702
+ .get('/api/v1/workspaces/ws-phantom')
703
+ .set('Cookie', 'pn_session=sess1')
704
+ .expect(404);
705
+ expect(res.body.error).toContain('Workspace not found');
706
+ });
707
+ });
708
+ // -------------------------------------------------------------------------
709
+ // GET /workspaces/:id/events — filter branches (lines 265, 293-294, 300, 312-313)
710
+ // -------------------------------------------------------------------------
711
+ describe('GET /workspaces/:id/events — filter branches', () => {
712
+ beforeEach(() => {
713
+ seedWorkspace({ wsId: 'ws-events', slug: 'events-ws', userId: 'u1', role: 'owner', memberId: 'mem-events' });
714
+ // Seed some events
715
+ const auditLogger = gateway.getAuditLogger();
716
+ auditLogger.log({
717
+ event_type: 'TOOL_EXECUTED',
718
+ tool_call_id: 'tc-ev-1',
719
+ task_id: 'task-ev-1',
720
+ workspace_id: 'ws-events',
721
+ actor_id: 'agent-alpha',
722
+ tool_name: 'http.request',
723
+ metadata: { status: 'allowed', decision: 'allow' },
724
+ });
725
+ auditLogger.log({
726
+ event_type: 'POLICY_DECIDED',
727
+ tool_call_id: 'tc-ev-2',
728
+ task_id: 'task-ev-2',
729
+ workspace_id: 'ws-events',
730
+ actor_id: 'agent-beta',
731
+ tool_name: 'slack.post_message',
732
+ metadata: { status: 'denied', decision: 'deny' },
733
+ });
734
+ });
735
+ it('filters by event_type', async () => {
736
+ const res = await (0, supertest_1.default)(app)
737
+ .get('/api/v1/workspaces/ws-events/events?event_type=TOOL_EXECUTED')
738
+ .set('Cookie', 'pn_session=sess1')
739
+ .expect(200);
740
+ for (const e of res.body.events) {
741
+ expect(e.event_type).toBe('TOOL_EXECUTED');
742
+ }
743
+ });
744
+ it('filters by tool name', async () => {
745
+ const res = await (0, supertest_1.default)(app)
746
+ .get('/api/v1/workspaces/ws-events/events?tool=slack')
747
+ .set('Cookie', 'pn_session=sess1')
748
+ .expect(200);
749
+ for (const e of res.body.events) {
750
+ expect(e.tool_name).toContain('slack');
751
+ }
752
+ });
753
+ it('filters by actor', async () => {
754
+ const res = await (0, supertest_1.default)(app)
755
+ .get('/api/v1/workspaces/ws-events/events?actor=alpha')
756
+ .set('Cookie', 'pn_session=sess1')
757
+ .expect(200);
758
+ for (const e of res.body.events) {
759
+ expect(e.actor_id).toContain('alpha');
760
+ }
761
+ });
762
+ it('filters by status', async () => {
763
+ const res = await (0, supertest_1.default)(app)
764
+ .get('/api/v1/workspaces/ws-events/events?status=denied')
765
+ .set('Cookie', 'pn_session=sess1')
766
+ .expect(200);
767
+ // All matched events should have a denied status or decision
768
+ expect(res.body.events.length).toBeGreaterThanOrEqual(0);
769
+ });
770
+ it('filters by search query (q)', async () => {
771
+ const res = await (0, supertest_1.default)(app)
772
+ .get('/api/v1/workspaces/ws-events/events?q=http')
773
+ .set('Cookie', 'pn_session=sess1')
774
+ .expect(200);
775
+ for (const e of res.body.events) {
776
+ const matches = e.tool_name?.toLowerCase().includes('http') ||
777
+ e.actor_id?.toLowerCase().includes('http') ||
778
+ e.event_type?.toLowerCase().includes('http');
779
+ expect(matches).toBe(true);
780
+ }
781
+ });
782
+ it('filters by since timestamp', async () => {
783
+ const futureTime = new Date(Date.now() + 86400000).toISOString();
784
+ const res = await (0, supertest_1.default)(app)
785
+ .get(`/api/v1/workspaces/ws-events/events?since=${futureTime}`)
786
+ .set('Cookie', 'pn_session=sess1')
787
+ .expect(200);
788
+ // No events should be in the future
789
+ expect(res.body.events).toEqual([]);
790
+ });
791
+ it('sorts ascending when sort=asc', async () => {
792
+ const res = await (0, supertest_1.default)(app)
793
+ .get('/api/v1/workspaces/ws-events/events?sort=asc')
794
+ .set('Cookie', 'pn_session=sess1')
795
+ .expect(200);
796
+ if (res.body.events.length >= 2) {
797
+ expect(res.body.events[0].timestamp <= res.body.events[1].timestamp).toBe(true);
798
+ }
799
+ });
800
+ it('returns 403 for non-member', async () => {
801
+ saasStores.workspaceMemberStore.delete('mem-events');
802
+ const res = await (0, supertest_1.default)(app)
803
+ .get('/api/v1/workspaces/ws-events/events')
804
+ .set('Cookie', 'pn_session=sess1')
805
+ .expect(403);
806
+ expect(res.body.error).toContain('Not a member');
807
+ });
808
+ });
809
+ // -------------------------------------------------------------------------
810
+ // POST /workspaces/:id/api-keys — tags and plan limit branches (lines 372, 382-383)
811
+ // -------------------------------------------------------------------------
812
+ describe('POST /workspaces/:id/api-keys — tag filtering and key limit', () => {
813
+ beforeEach(() => {
814
+ seedWorkspace({ wsId: 'ws-key-branch', slug: 'key-branch', userId: 'u1', role: 'owner', memberId: 'mem-key-branch', plan: 'free' });
815
+ });
816
+ it('filters tags to valid strings and caps at 20', async () => {
817
+ const mixedTags = [
818
+ 'valid-tag', '', null, 42, 'another-tag', ...Array(20).fill('tag'),
819
+ ];
820
+ const res = await (0, supertest_1.default)(app)
821
+ .post('/api/v1/workspaces/ws-key-branch/api-keys')
822
+ .set('Cookie', 'pn_session=sess1')
823
+ .send({ name: 'Tagged Key', tags: mixedTags })
824
+ .expect(201);
825
+ // Only valid string tags should remain, capped at 20
826
+ expect(res.body.tags.length).toBeLessThanOrEqual(20);
827
+ for (const t of res.body.tags) {
828
+ expect(typeof t).toBe('string');
829
+ expect(t.length).toBeGreaterThan(0);
830
+ }
831
+ });
832
+ it('returns 403 when API key limit is reached', async () => {
833
+ // Free plan allows 5 API keys. Create 5 first.
834
+ for (let i = 0; i < 5; i++) {
835
+ saasStores.userApiKeyStore.create({
836
+ id: `key-limit-${i}`,
837
+ key_hash: `hash-${i}`,
838
+ key_prefix: `pn_${i}`,
839
+ user_id: 'u1',
840
+ workspace_id: 'ws-key-branch',
841
+ name: `Key ${i}`,
842
+ roles: ['agent'],
843
+ tags: [],
844
+ revoked: false,
845
+ created_at: now,
846
+ });
847
+ }
848
+ const res = await (0, supertest_1.default)(app)
849
+ .post('/api/v1/workspaces/ws-key-branch/api-keys')
850
+ .set('Cookie', 'pn_session=sess1')
851
+ .send({ name: 'Sixth Key' })
852
+ .expect(403);
853
+ expect(res.body.error).toContain('maximum');
854
+ });
855
+ it('does not count revoked keys toward limit', async () => {
856
+ // Create 5 keys, all revoked
857
+ for (let i = 0; i < 5; i++) {
858
+ saasStores.userApiKeyStore.create({
859
+ id: `key-revoked-${i}`,
860
+ key_hash: `hash-r-${i}`,
861
+ key_prefix: `pn_r${i}`,
862
+ user_id: 'u1',
863
+ workspace_id: 'ws-key-branch',
864
+ name: `Revoked Key ${i}`,
865
+ roles: ['agent'],
866
+ tags: [],
867
+ revoked: true,
868
+ created_at: now,
869
+ });
870
+ }
871
+ const res = await (0, supertest_1.default)(app)
872
+ .post('/api/v1/workspaces/ws-key-branch/api-keys')
873
+ .set('Cookie', 'pn_session=sess1')
874
+ .send({ name: 'New Key After Revokes' })
875
+ .expect(201);
876
+ expect(res.body.name).toBe('New Key After Revokes');
877
+ });
878
+ });
879
+ // -------------------------------------------------------------------------
880
+ // PATCH /workspaces/:id/api-keys/:keyId — branches (lines 477-478, 470-472)
881
+ // -------------------------------------------------------------------------
882
+ describe('PATCH /workspaces/:id/api-keys/:keyId — validation branches', () => {
883
+ beforeEach(() => {
884
+ seedWorkspace({ wsId: 'ws-patch-key', slug: 'patch-key', userId: 'u1', role: 'owner', memberId: 'mem-patch-key' });
885
+ saasStores.userApiKeyStore.create({
886
+ id: 'key-to-patch',
887
+ key_hash: 'hash-patch',
888
+ key_prefix: 'pn_patch',
889
+ user_id: 'u1',
890
+ workspace_id: 'ws-patch-key',
891
+ name: 'Original Name',
892
+ roles: ['agent'],
893
+ tags: ['original'],
894
+ revoked: false,
895
+ created_at: now,
896
+ });
897
+ });
898
+ it('returns 403 when user is not owner or admin', async () => {
899
+ saasStores.workspaceMemberStore.delete('mem-patch-key');
900
+ saasStores.workspaceMemberStore.create({
901
+ id: 'mem-patch-viewer',
902
+ workspace_id: 'ws-patch-key',
903
+ user_id: 'u1',
904
+ role: 'viewer',
905
+ joined_at: now,
906
+ });
907
+ const res = await (0, supertest_1.default)(app)
908
+ .patch('/api/v1/workspaces/ws-patch-key/api-keys/key-to-patch')
909
+ .set('Cookie', 'pn_session=sess1')
910
+ .send({ name: 'Updated' })
911
+ .expect(403);
912
+ expect(res.body.error).toContain('owners and admins');
913
+ });
914
+ it('returns 404 when key does not exist', async () => {
915
+ const res = await (0, supertest_1.default)(app)
916
+ .patch('/api/v1/workspaces/ws-patch-key/api-keys/nonexistent')
917
+ .set('Cookie', 'pn_session=sess1')
918
+ .send({ name: 'Updated' })
919
+ .expect(404);
920
+ expect(res.body.error).toContain('not found');
921
+ });
922
+ it('returns 404 when key belongs to a different workspace', async () => {
923
+ saasStores.userApiKeyStore.create({
924
+ id: 'key-other-ws-patch',
925
+ key_hash: 'hash-other',
926
+ key_prefix: 'pn_other',
927
+ user_id: 'u1',
928
+ workspace_id: 'other-ws',
929
+ name: 'Other WS Key',
930
+ roles: ['agent'],
931
+ tags: [],
932
+ revoked: false,
933
+ created_at: now,
934
+ });
935
+ const res = await (0, supertest_1.default)(app)
936
+ .patch('/api/v1/workspaces/ws-patch-key/api-keys/key-other-ws-patch')
937
+ .set('Cookie', 'pn_session=sess1')
938
+ .send({ name: 'Updated' })
939
+ .expect(404);
940
+ expect(res.body.error).toContain('not found');
941
+ });
942
+ it('returns 400 when no valid fields to update', async () => {
943
+ const res = await (0, supertest_1.default)(app)
944
+ .patch('/api/v1/workspaces/ws-patch-key/api-keys/key-to-patch')
945
+ .set('Cookie', 'pn_session=sess1')
946
+ .send({ roles: ['admin'] }) // roles is not updatable
947
+ .expect(400);
948
+ expect(res.body.error).toContain('No valid fields');
949
+ });
950
+ it('updates name only', async () => {
951
+ const res = await (0, supertest_1.default)(app)
952
+ .patch('/api/v1/workspaces/ws-patch-key/api-keys/key-to-patch')
953
+ .set('Cookie', 'pn_session=sess1')
954
+ .send({ name: 'Renamed Key' })
955
+ .expect(200);
956
+ expect(res.body.name).toBe('Renamed Key');
957
+ });
958
+ it('updates tags only', async () => {
959
+ const res = await (0, supertest_1.default)(app)
960
+ .patch('/api/v1/workspaces/ws-patch-key/api-keys/key-to-patch')
961
+ .set('Cookie', 'pn_session=sess1')
962
+ .send({ tags: ['new-tag-1', 'new-tag-2'] })
963
+ .expect(200);
964
+ expect(res.body.tags).toEqual(['new-tag-1', 'new-tag-2']);
965
+ });
966
+ it('filters invalid tags during update', async () => {
967
+ const res = await (0, supertest_1.default)(app)
968
+ .patch('/api/v1/workspaces/ws-patch-key/api-keys/key-to-patch')
969
+ .set('Cookie', 'pn_session=sess1')
970
+ .send({ tags: ['good', '', null, 123, 'also-good'] })
971
+ .expect(200);
972
+ expect(res.body.tags).toEqual(['good', 'also-good']);
973
+ });
974
+ });
975
+ // -------------------------------------------------------------------------
976
+ // GET /workspaces/:id/traces — filter branches (lines 519, 525-527)
977
+ // -------------------------------------------------------------------------
978
+ describe('GET /workspaces/:id/traces — filter branches', () => {
979
+ beforeEach(() => {
980
+ seedWorkspace({ wsId: 'ws-traces-f', slug: 'traces-f', userId: 'u1', role: 'owner', memberId: 'mem-traces-f' });
981
+ const auditLogger = gateway.getAuditLogger();
982
+ auditLogger.log({
983
+ event_type: 'TOOL_EXECUTED',
984
+ tool_call_id: 'tc-trace-1',
985
+ task_id: 'task-trace-1',
986
+ workspace_id: 'ws-traces-f',
987
+ actor_id: 'agent-gamma',
988
+ tool_name: 'http.get',
989
+ metadata: { decision: 'allow', status: 'success' },
990
+ });
991
+ auditLogger.log({
992
+ event_type: 'POLICY_DECIDED',
993
+ tool_call_id: 'tc-trace-2',
994
+ task_id: 'task-trace-2',
995
+ workspace_id: 'ws-traces-f',
996
+ actor_id: 'agent-delta',
997
+ tool_name: 'db.query',
998
+ metadata: { decision: 'deny', status: 'blocked' },
999
+ });
1000
+ });
1001
+ it('filters by status', async () => {
1002
+ const res = await (0, supertest_1.default)(app)
1003
+ .get('/api/v1/workspaces/ws-traces-f/traces?status=deny')
1004
+ .set('Cookie', 'pn_session=sess1')
1005
+ .expect(200);
1006
+ // Should return only events with deny decision/status
1007
+ expect(res.body.traces).toBeDefined();
1008
+ });
1009
+ it('filters by tool name', async () => {
1010
+ const res = await (0, supertest_1.default)(app)
1011
+ .get('/api/v1/workspaces/ws-traces-f/traces?tool=http')
1012
+ .set('Cookie', 'pn_session=sess1')
1013
+ .expect(200);
1014
+ for (const t of res.body.traces) {
1015
+ expect(t.tool_name.toLowerCase()).toContain('http');
1016
+ }
1017
+ });
1018
+ it('filters by event_type', async () => {
1019
+ const res = await (0, supertest_1.default)(app)
1020
+ .get('/api/v1/workspaces/ws-traces-f/traces?event_type=blocked')
1021
+ .set('Cookie', 'pn_session=sess1')
1022
+ .expect(200);
1023
+ for (const t of res.body.traces) {
1024
+ expect(t.event_type.toLowerCase()).toContain('blocked');
1025
+ }
1026
+ });
1027
+ it('returns 403 for non-member', async () => {
1028
+ saasStores.workspaceMemberStore.delete('mem-traces-f');
1029
+ const res = await (0, supertest_1.default)(app)
1030
+ .get('/api/v1/workspaces/ws-traces-f/traces')
1031
+ .set('Cookie', 'pn_session=sess1')
1032
+ .expect(403);
1033
+ expect(res.body.error).toContain('Not a member');
1034
+ });
1035
+ });
1036
+ // -------------------------------------------------------------------------
1037
+ // POST /workspaces/:id/approvals/:approvalId/approve — error branches (642-644)
1038
+ // -------------------------------------------------------------------------
1039
+ describe('POST /workspaces/:id/approvals/:approvalId/approve — error handling', () => {
1040
+ beforeEach(() => {
1041
+ gateway.getApprovalManager().clear();
1042
+ seedWorkspace({ wsId: 'ws-app-err', slug: 'app-err', userId: 'u1', role: 'owner', memberId: 'mem-app-err' });
1043
+ });
1044
+ it('returns 403 when user is not a member', async () => {
1045
+ saasStores.workspaceMemberStore.delete('mem-app-err');
1046
+ const res = await (0, supertest_1.default)(app)
1047
+ .post('/api/v1/workspaces/ws-app-err/approvals/some-id/approve')
1048
+ .set('Cookie', 'pn_session=sess1')
1049
+ .expect(403);
1050
+ expect(res.body.error).toContain('Not a member');
1051
+ });
1052
+ it('returns 400 when resolveById throws (already resolved)', async () => {
1053
+ const toolCall = {
1054
+ tool_call_id: 'tc-double-approve',
1055
+ task_id: 'task-double-approve',
1056
+ workspace_id: 'ws-app-err',
1057
+ actor: { id: 'agent-x', type: 'agent' },
1058
+ source: { platform: 'test' },
1059
+ tool: { name: 'http.request', version: '1.0', capability: 'write' },
1060
+ args: { method: 'POST', url: 'https://example.com' },
1061
+ timestamp: new Date().toISOString(),
1062
+ };
1063
+ const { approval } = gateway.getApprovalManager().createApproval(toolCall, 'admin', 'Test');
1064
+ // Approve once
1065
+ await gateway.getApprovalManager().resolveById(approval.approval_id, 'u1', true);
1066
+ // Try to approve again — should throw
1067
+ const res = await (0, supertest_1.default)(app)
1068
+ .post(`/api/v1/workspaces/ws-app-err/approvals/${approval.approval_id}/approve`)
1069
+ .set('Cookie', 'pn_session=sess1')
1070
+ .expect(400);
1071
+ expect(res.body.error).toBeDefined();
1072
+ });
1073
+ });
1074
+ // -------------------------------------------------------------------------
1075
+ // POST /workspaces/:id/approvals/:approvalId/deny — branches (658-659, 664-669, 676-678)
1076
+ // -------------------------------------------------------------------------
1077
+ describe('POST /workspaces/:id/approvals/:approvalId/deny — branches', () => {
1078
+ beforeEach(() => {
1079
+ gateway.getApprovalManager().clear();
1080
+ seedWorkspace({ wsId: 'ws-deny-br', slug: 'deny-br', userId: 'u1', role: 'owner', memberId: 'mem-deny-br' });
1081
+ });
1082
+ it('returns 403 when user is not a member', async () => {
1083
+ saasStores.workspaceMemberStore.delete('mem-deny-br');
1084
+ const res = await (0, supertest_1.default)(app)
1085
+ .post('/api/v1/workspaces/ws-deny-br/approvals/some-id/deny')
1086
+ .set('Cookie', 'pn_session=sess1')
1087
+ .expect(403);
1088
+ expect(res.body.error).toContain('Not a member');
1089
+ });
1090
+ it('returns 404 when approval not found', async () => {
1091
+ const res = await (0, supertest_1.default)(app)
1092
+ .post('/api/v1/workspaces/ws-deny-br/approvals/nonexistent/deny')
1093
+ .set('Cookie', 'pn_session=sess1')
1094
+ .expect(404);
1095
+ expect(res.body.error).toContain('not found');
1096
+ });
1097
+ it('returns 404 when approval belongs to different workspace', async () => {
1098
+ const toolCall = {
1099
+ tool_call_id: 'tc-deny-other',
1100
+ task_id: 'task-deny-other',
1101
+ workspace_id: 'ws-other-deny',
1102
+ actor: { id: 'agent-y', type: 'agent' },
1103
+ source: { platform: 'test' },
1104
+ tool: { name: 'http.request', version: '1.0', capability: 'read' },
1105
+ args: { method: 'GET', url: 'https://example.com' },
1106
+ timestamp: new Date().toISOString(),
1107
+ };
1108
+ const { approval } = gateway.getApprovalManager().createApproval(toolCall, 'admin', 'Test');
1109
+ const res = await (0, supertest_1.default)(app)
1110
+ .post(`/api/v1/workspaces/ws-deny-br/approvals/${approval.approval_id}/deny`)
1111
+ .set('Cookie', 'pn_session=sess1')
1112
+ .expect(404);
1113
+ expect(res.body.error).toContain('not found');
1114
+ });
1115
+ it('returns 400 when resolveById throws (already resolved)', async () => {
1116
+ const toolCall = {
1117
+ tool_call_id: 'tc-deny-double',
1118
+ task_id: 'task-deny-double',
1119
+ workspace_id: 'ws-deny-br',
1120
+ actor: { id: 'agent-z', type: 'agent' },
1121
+ source: { platform: 'test' },
1122
+ tool: { name: 'http.request', version: '1.0', capability: 'write' },
1123
+ args: { method: 'POST', url: 'https://example.com' },
1124
+ timestamp: new Date().toISOString(),
1125
+ };
1126
+ const { approval } = gateway.getApprovalManager().createApproval(toolCall, 'admin', 'Test');
1127
+ // Deny once
1128
+ await gateway.getApprovalManager().resolveById(approval.approval_id, 'u1', false, 'First deny');
1129
+ // Deny again — should throw
1130
+ const res = await (0, supertest_1.default)(app)
1131
+ .post(`/api/v1/workspaces/ws-deny-br/approvals/${approval.approval_id}/deny`)
1132
+ .set('Cookie', 'pn_session=sess1')
1133
+ .send({ reason: 'Second deny' })
1134
+ .expect(400);
1135
+ expect(res.body.error).toBeDefined();
1136
+ });
1137
+ });
1138
+ // -------------------------------------------------------------------------
1139
+ // PUT /workspaces/:id/policies — branches (716-717, admin role check)
1140
+ // -------------------------------------------------------------------------
1141
+ describe('PUT /workspaces/:id/policies — role and validation branches', () => {
1142
+ beforeEach(() => {
1143
+ seedWorkspace({ wsId: 'ws-pol-put', slug: 'pol-put', userId: 'u1', role: 'owner', memberId: 'mem-pol-put' });
1144
+ });
1145
+ it('returns 403 when user is not a member', async () => {
1146
+ saasStores.workspaceMemberStore.delete('mem-pol-put');
1147
+ const res = await (0, supertest_1.default)(app)
1148
+ .put('/api/v1/workspaces/ws-pol-put/policies')
1149
+ .set('Cookie', 'pn_session=sess1')
1150
+ .send({})
1151
+ .expect(403);
1152
+ expect(res.body.error).toContain('Not a member');
1153
+ });
1154
+ it('returns 403 when user role is member (not owner/admin)', async () => {
1155
+ saasStores.workspaceMemberStore.delete('mem-pol-put');
1156
+ saasStores.workspaceMemberStore.create({
1157
+ id: 'mem-pol-put-member',
1158
+ workspace_id: 'ws-pol-put',
1159
+ user_id: 'u1',
1160
+ role: 'member',
1161
+ joined_at: now,
1162
+ });
1163
+ const res = await (0, supertest_1.default)(app)
1164
+ .put('/api/v1/workspaces/ws-pol-put/policies')
1165
+ .set('Cookie', 'pn_session=sess1')
1166
+ .send({})
1167
+ .expect(403);
1168
+ expect(res.body.error).toContain('owners and admins');
1169
+ });
1170
+ it('returns 400 when policy is invalid', async () => {
1171
+ const res = await (0, supertest_1.default)(app)
1172
+ .put('/api/v1/workspaces/ws-pol-put/policies')
1173
+ .set('Cookie', 'pn_session=sess1')
1174
+ .send({ name: '', rules: 'not-an-array' })
1175
+ .expect(400);
1176
+ expect(res.body.valid).toBe(false);
1177
+ expect(res.body.errors).toBeDefined();
1178
+ });
1179
+ });
1180
+ // -------------------------------------------------------------------------
1181
+ // DELETE /workspaces/:id/policies — branches (753-754, 756-758)
1182
+ // -------------------------------------------------------------------------
1183
+ describe('DELETE /workspaces/:id/policies — branches', () => {
1184
+ beforeEach(() => {
1185
+ seedWorkspace({ wsId: 'ws-pol-del', slug: 'pol-del', userId: 'u1', role: 'owner', memberId: 'mem-pol-del' });
1186
+ });
1187
+ it('returns 403 when user is not a member', async () => {
1188
+ saasStores.workspaceMemberStore.delete('mem-pol-del');
1189
+ const res = await (0, supertest_1.default)(app)
1190
+ .delete('/api/v1/workspaces/ws-pol-del/policies')
1191
+ .set('Cookie', 'pn_session=sess1')
1192
+ .expect(403);
1193
+ expect(res.body.error).toContain('Not a member');
1194
+ });
1195
+ it('returns 403 when user role is viewer', async () => {
1196
+ saasStores.workspaceMemberStore.delete('mem-pol-del');
1197
+ saasStores.workspaceMemberStore.create({
1198
+ id: 'mem-pol-del-viewer',
1199
+ workspace_id: 'ws-pol-del',
1200
+ user_id: 'u1',
1201
+ role: 'viewer',
1202
+ joined_at: now,
1203
+ });
1204
+ const res = await (0, supertest_1.default)(app)
1205
+ .delete('/api/v1/workspaces/ws-pol-del/policies')
1206
+ .set('Cookie', 'pn_session=sess1')
1207
+ .expect(403);
1208
+ expect(res.body.error).toContain('owners and admins');
1209
+ });
1210
+ it('successfully resets policy for owner', async () => {
1211
+ const res = await (0, supertest_1.default)(app)
1212
+ .delete('/api/v1/workspaces/ws-pol-del/policies')
1213
+ .set('Cookie', 'pn_session=sess1')
1214
+ .expect(200);
1215
+ expect(res.body.status).toBe('reset');
1216
+ expect(res.body.is_custom).toBe(false);
1217
+ });
1218
+ });
1219
+ // -------------------------------------------------------------------------
1220
+ // POST /workspaces/:id/policies/validate — branches (784-785)
1221
+ // -------------------------------------------------------------------------
1222
+ describe('POST /workspaces/:id/policies/validate — branches', () => {
1223
+ beforeEach(() => {
1224
+ seedWorkspace({ wsId: 'ws-pol-val', slug: 'pol-val', userId: 'u1', role: 'owner', memberId: 'mem-pol-val' });
1225
+ });
1226
+ it('returns 403 when not a member', async () => {
1227
+ saasStores.workspaceMemberStore.delete('mem-pol-val');
1228
+ const res = await (0, supertest_1.default)(app)
1229
+ .post('/api/v1/workspaces/ws-pol-val/policies/validate')
1230
+ .set('Cookie', 'pn_session=sess1')
1231
+ .send({})
1232
+ .expect(403);
1233
+ expect(res.body.error).toContain('Not a member');
1234
+ });
1235
+ it('validates a policy pack and returns result', async () => {
1236
+ const res = await (0, supertest_1.default)(app)
1237
+ .post('/api/v1/workspaces/ws-pol-val/policies/validate')
1238
+ .set('Cookie', 'pn_session=sess1')
1239
+ .send({ name: 'test-pack', version: '1.0', rules: [] })
1240
+ .expect(200);
1241
+ expect(res.body.valid).toBeDefined();
1242
+ });
1243
+ });
1244
+ // -------------------------------------------------------------------------
1245
+ // GET /workspaces/:id/security — DLP severity "low" branch (line 974)
1246
+ // -------------------------------------------------------------------------
1247
+ describe('GET /workspaces/:id/security — DLP low severity', () => {
1248
+ beforeEach(() => {
1249
+ seedWorkspace({ wsId: 'ws-sec-low', slug: 'sec-low', userId: 'u1', role: 'owner', memberId: 'mem-sec-low' });
1250
+ });
1251
+ it('counts events with unknown severity as low', async () => {
1252
+ const auditLogger = gateway.getAuditLogger();
1253
+ auditLogger.log({
1254
+ event_type: 'DLP_SCANNED',
1255
+ tool_call_id: 'tc-dlp-low',
1256
+ task_id: 'task-dlp-low',
1257
+ workspace_id: 'ws-sec-low',
1258
+ actor_id: 'agent-dlp-low',
1259
+ tool_name: 'http.post',
1260
+ metadata: { severity: 'info', detected: ['generic_pattern'] },
1261
+ });
1262
+ const res = await (0, supertest_1.default)(app)
1263
+ .get('/api/v1/workspaces/ws-sec-low/security')
1264
+ .set('Cookie', 'pn_session=sess1')
1265
+ .expect(200);
1266
+ expect(res.body.stats.low).toBeGreaterThanOrEqual(1);
1267
+ });
1268
+ });
1269
+ // -------------------------------------------------------------------------
1270
+ // POST /workspaces/:id/switch — workspace not found branch (1013-1014)
1271
+ // -------------------------------------------------------------------------
1272
+ describe('POST /workspaces/:id/switch — workspace not found', () => {
1273
+ it('returns 404 when workspace is deleted but membership exists', async () => {
1274
+ seedWorkspace({ wsId: 'ws-switch-del', slug: 'switch-del', userId: 'u1', role: 'owner', memberId: 'mem-switch-del' });
1275
+ saasStores.workspaceStore.delete('ws-switch-del');
1276
+ const res = await (0, supertest_1.default)(app)
1277
+ .post('/api/v1/workspaces/ws-switch-del/switch')
1278
+ .set('Cookie', 'pn_session=sess1')
1279
+ .expect(404);
1280
+ expect(res.body.error).toContain('Workspace not found');
1281
+ });
1282
+ });
1283
+ // -------------------------------------------------------------------------
1284
+ // PUT /workspaces/:id/members/:memberId — branches (1106-1107, member in different ws)
1285
+ // -------------------------------------------------------------------------
1286
+ describe('PUT /workspaces/:id/members/:memberId — member not found branches', () => {
1287
+ beforeEach(() => {
1288
+ saasStores.userStore.create({
1289
+ id: 'u2',
1290
+ email: 'other@test.com',
1291
+ display_name: 'Other User',
1292
+ avatar_url: '',
1293
+ status: 'active',
1294
+ onboarding_completed: true,
1295
+ created_at: now,
1296
+ updated_at: now,
1297
+ });
1298
+ seedWorkspace({ wsId: 'ws-role-br', slug: 'role-br', userId: 'u1', role: 'owner', memberId: 'mem-role-br-owner' });
1299
+ });
1300
+ it('returns 404 when member belongs to a different workspace', async () => {
1301
+ // Create member in a different workspace
1302
+ seedWorkspace({ wsId: 'ws-other-role', slug: 'other-role', userId: 'u2', role: 'owner', memberId: 'mem-other-role' });
1303
+ const res = await (0, supertest_1.default)(app)
1304
+ .put('/api/v1/workspaces/ws-role-br/members/mem-other-role')
1305
+ .set('Cookie', 'pn_session=sess1')
1306
+ .send({ role: 'admin' })
1307
+ .expect(404);
1308
+ expect(res.body.error).toContain('Member not found');
1309
+ });
1310
+ });
1311
+ // -------------------------------------------------------------------------
1312
+ // DELETE /workspaces/:id/members/:memberId — branches (1106-1107, 1117-1122)
1313
+ // -------------------------------------------------------------------------
1314
+ describe('DELETE /workspaces/:id/members/:memberId — additional branches', () => {
1315
+ beforeEach(() => {
1316
+ saasStores.userStore.create({
1317
+ id: 'u2',
1318
+ email: 'other@test.com',
1319
+ display_name: 'Other User',
1320
+ avatar_url: '',
1321
+ status: 'active',
1322
+ onboarding_completed: true,
1323
+ created_at: now,
1324
+ updated_at: now,
1325
+ });
1326
+ seedWorkspace({ wsId: 'ws-del-br', slug: 'del-br', userId: 'u1', role: 'owner', memberId: 'mem-del-br-owner' });
1327
+ });
1328
+ it('returns 404 when target member belongs to different workspace', async () => {
1329
+ seedWorkspace({ wsId: 'ws-del-other', slug: 'del-other', userId: 'u2', role: 'owner', memberId: 'mem-del-other' });
1330
+ const res = await (0, supertest_1.default)(app)
1331
+ .delete('/api/v1/workspaces/ws-del-br/members/mem-del-other')
1332
+ .set('Cookie', 'pn_session=sess1')
1333
+ .expect(404);
1334
+ expect(res.body.error).toContain('Member not found');
1335
+ });
1336
+ it('allows removing owner when another owner exists', async () => {
1337
+ // Add u2 as owner in ws-del-br
1338
+ saasStores.workspaceMemberStore.create({
1339
+ id: 'mem-del-br-u2',
1340
+ workspace_id: 'ws-del-br',
1341
+ user_id: 'u2',
1342
+ role: 'owner',
1343
+ joined_at: now,
1344
+ });
1345
+ const res = await (0, supertest_1.default)(app)
1346
+ .delete('/api/v1/workspaces/ws-del-br/members/mem-del-br-u2')
1347
+ .set('Cookie', 'pn_session=sess1')
1348
+ .expect(200);
1349
+ expect(res.body.status).toBe('ok');
1350
+ });
1351
+ });
1352
+ // -------------------------------------------------------------------------
1353
+ // POST /workspaces/:id/members — plan limit branch (lines 1153-1154)
1354
+ // -------------------------------------------------------------------------
1355
+ describe('POST /workspaces/:id/members — member limit enforcement', () => {
1356
+ beforeEach(() => {
1357
+ seedWorkspace({ wsId: 'ws-mem-limit', slug: 'mem-limit', userId: 'u1', role: 'owner', memberId: 'mem-limit-owner', plan: 'free' });
1358
+ });
1359
+ it('returns 403 when member limit is reached for free plan', async () => {
1360
+ // Free plan allows 3 members. u1 is already a member (1). Add 2 more.
1361
+ for (let i = 2; i <= 3; i++) {
1362
+ const uid = `u-limit-${i}`;
1363
+ saasStores.userStore.create({
1364
+ id: uid,
1365
+ email: `limit${i}@test.com`,
1366
+ display_name: `Limit User ${i}`,
1367
+ avatar_url: '',
1368
+ status: 'active',
1369
+ onboarding_completed: true,
1370
+ created_at: now,
1371
+ updated_at: now,
1372
+ });
1373
+ saasStores.workspaceMemberStore.create({
1374
+ id: `mem-limit-${i}`,
1375
+ workspace_id: 'ws-mem-limit',
1376
+ user_id: uid,
1377
+ role: 'member',
1378
+ joined_at: now,
1379
+ });
1380
+ }
1381
+ // Now at 3 members (limit). Try to add a 4th.
1382
+ saasStores.userStore.create({
1383
+ id: 'u-limit-4',
1384
+ email: 'limit4@test.com',
1385
+ display_name: 'Limit User 4',
1386
+ avatar_url: '',
1387
+ status: 'active',
1388
+ onboarding_completed: true,
1389
+ created_at: now,
1390
+ updated_at: now,
1391
+ });
1392
+ const res = await (0, supertest_1.default)(app)
1393
+ .post('/api/v1/workspaces/ws-mem-limit/members')
1394
+ .set('Cookie', 'pn_session=sess1')
1395
+ .send({ email: 'limit4@test.com', role: 'member' })
1396
+ .expect(403);
1397
+ expect(res.body.error).toContain('maximum');
1398
+ // Cleanup extra users
1399
+ saasStores.userStore.delete('u-limit-2');
1400
+ saasStores.userStore.delete('u-limit-3');
1401
+ saasStores.userStore.delete('u-limit-4');
1402
+ });
1403
+ });
1404
+ // -------------------------------------------------------------------------
1405
+ // GET /workspaces/:id/traces/:taskId — slug matching branch (line 1217)
1406
+ // -------------------------------------------------------------------------
1407
+ describe('GET /workspaces/:id/traces/:taskId — slug matching', () => {
1408
+ beforeEach(() => {
1409
+ seedWorkspace({ wsId: 'ws-trace-slug', slug: 'trace-slug-ws', userId: 'u1', role: 'owner', memberId: 'mem-trace-slug' });
1410
+ });
1411
+ it('returns 403 for non-member', async () => {
1412
+ saasStores.workspaceMemberStore.delete('mem-trace-slug');
1413
+ const res = await (0, supertest_1.default)(app)
1414
+ .get('/api/v1/workspaces/ws-trace-slug/traces/some-task')
1415
+ .set('Cookie', 'pn_session=sess1')
1416
+ .expect(403);
1417
+ expect(res.body.error).toContain('Not a member');
1418
+ });
1419
+ });
1420
+ // -------------------------------------------------------------------------
1421
+ // GET /workspaces/:id/anomalies/baseline — no detector branch (1320-1321)
1422
+ // -------------------------------------------------------------------------
1423
+ describe('GET /workspaces/:id/anomalies/baseline', () => {
1424
+ beforeEach(() => {
1425
+ seedWorkspace({ wsId: 'ws-anomaly', slug: 'anomaly-ws', userId: 'u1', role: 'owner', memberId: 'mem-anomaly' });
1426
+ });
1427
+ it('returns fallback when no anomaly detector exists', async () => {
1428
+ const res = await (0, supertest_1.default)(app)
1429
+ .get('/api/v1/workspaces/ws-anomaly/anomalies/baseline')
1430
+ .set('Cookie', 'pn_session=sess1')
1431
+ .expect(200);
1432
+ // Either returns baseline report or the fallback empty object
1433
+ expect(res.body).toBeDefined();
1434
+ });
1435
+ it('returns 403 when not a member', async () => {
1436
+ saasStores.workspaceMemberStore.delete('mem-anomaly');
1437
+ const res = await (0, supertest_1.default)(app)
1438
+ .get('/api/v1/workspaces/ws-anomaly/anomalies/baseline')
1439
+ .set('Cookie', 'pn_session=sess1')
1440
+ .expect(403);
1441
+ expect(res.body.error).toContain('Not a member');
1442
+ });
1443
+ });
1444
+ // -------------------------------------------------------------------------
1445
+ // GET /workspaces/:id/agents/:actorId/trust — no detector branch (1346-1347)
1446
+ // -------------------------------------------------------------------------
1447
+ describe('GET /workspaces/:id/agents/:actorId/trust', () => {
1448
+ beforeEach(() => {
1449
+ seedWorkspace({ wsId: 'ws-trust', slug: 'trust-ws', userId: 'u1', role: 'owner', memberId: 'mem-trust' });
1450
+ });
1451
+ it('returns trust score', async () => {
1452
+ const res = await (0, supertest_1.default)(app)
1453
+ .get('/api/v1/workspaces/ws-trust/agents/agent-1/trust')
1454
+ .set('Cookie', 'pn_session=sess1')
1455
+ .expect(200);
1456
+ expect(res.body.actor_id).toBeDefined();
1457
+ expect(res.body.score).toBeDefined();
1458
+ });
1459
+ it('returns 403 when not a member', async () => {
1460
+ saasStores.workspaceMemberStore.delete('mem-trust');
1461
+ const res = await (0, supertest_1.default)(app)
1462
+ .get('/api/v1/workspaces/ws-trust/agents/agent-1/trust')
1463
+ .set('Cookie', 'pn_session=sess1')
1464
+ .expect(403);
1465
+ expect(res.body.error).toContain('Not a member');
1466
+ });
1467
+ });
1468
+ // -------------------------------------------------------------------------
1469
+ // GET /workspaces/:id/agents/trust-leaderboard — branches (1376-1377, 1387-1394)
1470
+ // -------------------------------------------------------------------------
1471
+ describe('GET /workspaces/:id/agents/trust-leaderboard', () => {
1472
+ beforeEach(() => {
1473
+ seedWorkspace({ wsId: 'ws-leader', slug: 'leader-ws', userId: 'u1', role: 'owner', memberId: 'mem-leader' });
1474
+ });
1475
+ it('returns empty agents when no events', async () => {
1476
+ const res = await (0, supertest_1.default)(app)
1477
+ .get('/api/v1/workspaces/ws-leader/agents/trust-leaderboard')
1478
+ .set('Cookie', 'pn_session=sess1')
1479
+ .expect(200);
1480
+ expect(res.body.agents).toBeDefined();
1481
+ });
1482
+ it('returns leaderboard with events present', async () => {
1483
+ const auditLogger = gateway.getAuditLogger();
1484
+ auditLogger.log({
1485
+ event_type: 'TOOL_EXECUTED',
1486
+ tool_call_id: 'tc-leader-1',
1487
+ task_id: 'task-leader-1',
1488
+ workspace_id: 'ws-leader',
1489
+ actor_id: 'leaderboard-agent',
1490
+ tool_name: 'http.request',
1491
+ metadata: { decision: 'allow' },
1492
+ });
1493
+ const res = await (0, supertest_1.default)(app)
1494
+ .get('/api/v1/workspaces/ws-leader/agents/trust-leaderboard')
1495
+ .set('Cookie', 'pn_session=sess1')
1496
+ .expect(200);
1497
+ expect(res.body.agents).toBeDefined();
1498
+ });
1499
+ it('returns 403 when not a member', async () => {
1500
+ saasStores.workspaceMemberStore.delete('mem-leader');
1501
+ const res = await (0, supertest_1.default)(app)
1502
+ .get('/api/v1/workspaces/ws-leader/agents/trust-leaderboard')
1503
+ .set('Cookie', 'pn_session=sess1')
1504
+ .expect(403);
1505
+ expect(res.body.error).toContain('Not a member');
1506
+ });
1507
+ });
1508
+ // -------------------------------------------------------------------------
1509
+ // POST /workspaces/:id/replay — branches (1413-1420)
1510
+ // -------------------------------------------------------------------------
1511
+ describe('POST /workspaces/:id/replay', () => {
1512
+ beforeEach(() => {
1513
+ seedWorkspace({ wsId: 'ws-replay', slug: 'replay-ws', userId: 'u1', role: 'owner', memberId: 'mem-replay' });
1514
+ });
1515
+ it('returns 403 when not a member', async () => {
1516
+ saasStores.workspaceMemberStore.delete('mem-replay');
1517
+ const res = await (0, supertest_1.default)(app)
1518
+ .post('/api/v1/workspaces/ws-replay/replay')
1519
+ .set('Cookie', 'pn_session=sess1')
1520
+ .send({ task_id: 'task-1', policy_pack_path: './policy-packs/dev_fast.yaml' })
1521
+ .expect(403);
1522
+ expect(res.body.error).toContain('Not a member');
1523
+ });
1524
+ it('returns 400 when task_id is missing', async () => {
1525
+ const res = await (0, supertest_1.default)(app)
1526
+ .post('/api/v1/workspaces/ws-replay/replay')
1527
+ .set('Cookie', 'pn_session=sess1')
1528
+ .send({ policy_pack_path: './policy-packs/dev_fast.yaml' })
1529
+ .expect(400);
1530
+ expect(res.body.error).toContain('task_id is required');
1531
+ });
1532
+ it('returns 400 when policy_pack_path is missing', async () => {
1533
+ const res = await (0, supertest_1.default)(app)
1534
+ .post('/api/v1/workspaces/ws-replay/replay')
1535
+ .set('Cookie', 'pn_session=sess1')
1536
+ .send({ task_id: 'task-1' })
1537
+ .expect(400);
1538
+ expect(res.body.error).toContain('policy_pack_path is required');
1539
+ });
1540
+ it('runs replay successfully', async () => {
1541
+ const res = await (0, supertest_1.default)(app)
1542
+ .post('/api/v1/workspaces/ws-replay/replay')
1543
+ .set('Cookie', 'pn_session=sess1')
1544
+ .send({ task_id: 'task-nonexistent', policy_pack_path: './policy-packs/dev_fast.yaml' })
1545
+ .expect(200);
1546
+ expect(res.body).toBeDefined();
1547
+ });
1548
+ });
1549
+ // -------------------------------------------------------------------------
1550
+ // GET /workspaces/:id/llm-usage — branches (1459-1461, 1481-1503, 1514, 1529-1533)
1551
+ // -------------------------------------------------------------------------
1552
+ describe('GET /workspaces/:id/llm-usage', () => {
1553
+ beforeEach(() => {
1554
+ seedWorkspace({ wsId: 'ws-llm', slug: 'llm-ws', userId: 'u1', role: 'owner', memberId: 'mem-llm' });
1555
+ });
1556
+ it('returns empty summary when no LLM events exist', async () => {
1557
+ const res = await (0, supertest_1.default)(app)
1558
+ .get('/api/v1/workspaces/ws-llm/llm-usage')
1559
+ .set('Cookie', 'pn_session=sess1')
1560
+ .expect(200);
1561
+ expect(res.body.summary.total_requests).toBe(0);
1562
+ expect(res.body.summary.total_cost_usd).toBe(0);
1563
+ expect(res.body.summary.total_tokens).toBe(0);
1564
+ expect(res.body.by_model).toEqual([]);
1565
+ expect(res.body.time_series).toBeDefined();
1566
+ });
1567
+ it('aggregates LLM usage from events with model metadata', async () => {
1568
+ const auditLogger = gateway.getAuditLogger();
1569
+ auditLogger.log({
1570
+ event_type: 'TOOL_EXECUTED',
1571
+ tool_call_id: 'tc-llm-1',
1572
+ task_id: 'task-llm-1',
1573
+ workspace_id: 'ws-llm',
1574
+ actor_id: 'agent-llm',
1575
+ tool_name: 'http.request',
1576
+ metadata: {
1577
+ model: 'claude-3-opus',
1578
+ provider: 'anthropic',
1579
+ input_tokens: 100,
1580
+ output_tokens: 50,
1581
+ cost_usd: 0.05,
1582
+ duration_ms: 1200,
1583
+ },
1584
+ });
1585
+ auditLogger.log({
1586
+ event_type: 'TOOL_EXECUTED',
1587
+ tool_call_id: 'tc-llm-2',
1588
+ task_id: 'task-llm-2',
1589
+ workspace_id: 'ws-llm',
1590
+ actor_id: 'agent-llm',
1591
+ tool_name: 'http.request',
1592
+ metadata: {
1593
+ model: 'claude-3-opus',
1594
+ provider: 'anthropic',
1595
+ input_tokens: 200,
1596
+ output_tokens: 100,
1597
+ cost_usd: 0.10,
1598
+ duration_ms: 800,
1599
+ },
1600
+ });
1601
+ auditLogger.log({
1602
+ event_type: 'TOOL_EXECUTED',
1603
+ tool_call_id: 'tc-llm-3',
1604
+ task_id: 'task-llm-3',
1605
+ workspace_id: 'ws-llm',
1606
+ actor_id: 'agent-llm',
1607
+ tool_name: 'http.request',
1608
+ metadata: {
1609
+ model: 'gpt-4',
1610
+ provider: 'openai',
1611
+ input_tokens: 300,
1612
+ output_tokens: 150,
1613
+ cost_usd: 0.20,
1614
+ duration_ms: 2000,
1615
+ },
1616
+ });
1617
+ const res = await (0, supertest_1.default)(app)
1618
+ .get('/api/v1/workspaces/ws-llm/llm-usage')
1619
+ .set('Cookie', 'pn_session=sess1')
1620
+ .expect(200);
1621
+ expect(res.body.summary.total_requests).toBe(3);
1622
+ expect(res.body.summary.total_cost_usd).toBeCloseTo(0.35, 2);
1623
+ expect(res.body.summary.total_tokens).toBe(900); // 100+50+200+100+300+150
1624
+ expect(res.body.by_model.length).toBe(2);
1625
+ expect(res.body.time_series.length).toBeGreaterThan(0);
1626
+ });
1627
+ it('supports different range query params', async () => {
1628
+ const res = await (0, supertest_1.default)(app)
1629
+ .get('/api/v1/workspaces/ws-llm/llm-usage?range=7d')
1630
+ .set('Cookie', 'pn_session=sess1')
1631
+ .expect(200);
1632
+ expect(res.body.summary).toBeDefined();
1633
+ expect(res.body.time_series).toBeDefined();
1634
+ });
1635
+ it('defaults to 24h when invalid range given', async () => {
1636
+ const res = await (0, supertest_1.default)(app)
1637
+ .get('/api/v1/workspaces/ws-llm/llm-usage?range=invalid')
1638
+ .set('Cookie', 'pn_session=sess1')
1639
+ .expect(200);
1640
+ expect(res.body.summary).toBeDefined();
1641
+ });
1642
+ it('returns 403 when not a member', async () => {
1643
+ saasStores.workspaceMemberStore.delete('mem-llm');
1644
+ const res = await (0, supertest_1.default)(app)
1645
+ .get('/api/v1/workspaces/ws-llm/llm-usage')
1646
+ .set('Cookie', 'pn_session=sess1')
1647
+ .expect(403);
1648
+ expect(res.body.error).toContain('Not a member');
1649
+ });
1650
+ });
1651
+ // -------------------------------------------------------------------------
1652
+ // GET /workspaces/:id/rate-limits — branches (1579-1580)
1653
+ // -------------------------------------------------------------------------
1654
+ describe('GET /workspaces/:id/rate-limits', () => {
1655
+ beforeEach(() => {
1656
+ seedWorkspace({ wsId: 'ws-rl-get', slug: 'rl-get', userId: 'u1', role: 'owner', memberId: 'mem-rl-get' });
1657
+ });
1658
+ it('returns global config when no custom config', async () => {
1659
+ const res = await (0, supertest_1.default)(app)
1660
+ .get('/api/v1/workspaces/ws-rl-get/rate-limits')
1661
+ .set('Cookie', 'pn_session=sess1')
1662
+ .expect(200);
1663
+ expect(res.body.config).toBeDefined();
1664
+ expect(res.body.is_custom).toBe(false);
1665
+ });
1666
+ it('returns 403 when not a member', async () => {
1667
+ saasStores.workspaceMemberStore.delete('mem-rl-get');
1668
+ const res = await (0, supertest_1.default)(app)
1669
+ .get('/api/v1/workspaces/ws-rl-get/rate-limits')
1670
+ .set('Cookie', 'pn_session=sess1')
1671
+ .expect(403);
1672
+ expect(res.body.error).toContain('Not a member');
1673
+ });
1674
+ });
1675
+ // -------------------------------------------------------------------------
1676
+ // PUT /workspaces/:id/rate-limits — branches (1597-1598, 1613, 1616)
1677
+ // -------------------------------------------------------------------------
1678
+ describe('PUT /workspaces/:id/rate-limits', () => {
1679
+ beforeEach(() => {
1680
+ seedWorkspace({ wsId: 'ws-rl-put', slug: 'rl-put', userId: 'u1', role: 'owner', memberId: 'mem-rl-put' });
1681
+ });
1682
+ it('returns 403 when not a member', async () => {
1683
+ saasStores.workspaceMemberStore.delete('mem-rl-put');
1684
+ const res = await (0, supertest_1.default)(app)
1685
+ .put('/api/v1/workspaces/ws-rl-put/rate-limits')
1686
+ .set('Cookie', 'pn_session=sess1')
1687
+ .send({ actor_max_per_window: 50 })
1688
+ .expect(403);
1689
+ expect(res.body.error).toContain('Not a member');
1690
+ });
1691
+ it('returns 403 when user is not owner/admin', async () => {
1692
+ saasStores.workspaceMemberStore.delete('mem-rl-put');
1693
+ saasStores.workspaceMemberStore.create({
1694
+ id: 'mem-rl-put-viewer',
1695
+ workspace_id: 'ws-rl-put',
1696
+ user_id: 'u1',
1697
+ role: 'viewer',
1698
+ joined_at: now,
1699
+ });
1700
+ const res = await (0, supertest_1.default)(app)
1701
+ .put('/api/v1/workspaces/ws-rl-put/rate-limits')
1702
+ .set('Cookie', 'pn_session=sess1')
1703
+ .send({ actor_max_per_window: 50 })
1704
+ .expect(403);
1705
+ expect(res.body.error).toContain('owners and admins');
1706
+ });
1707
+ it('returns 400 when actor_max_per_window is invalid', async () => {
1708
+ const res = await (0, supertest_1.default)(app)
1709
+ .put('/api/v1/workspaces/ws-rl-put/rate-limits')
1710
+ .set('Cookie', 'pn_session=sess1')
1711
+ .send({ actor_max_per_window: -5 })
1712
+ .expect(400);
1713
+ expect(res.body.error).toContain('Validation failed');
1714
+ expect(res.body.errors).toContain('actor_max_per_window must be a positive number');
1715
+ });
1716
+ it('returns 400 when workspace_max_per_window is not a number', async () => {
1717
+ const res = await (0, supertest_1.default)(app)
1718
+ .put('/api/v1/workspaces/ws-rl-put/rate-limits')
1719
+ .set('Cookie', 'pn_session=sess1')
1720
+ .send({ workspace_max_per_window: 'abc' })
1721
+ .expect(400);
1722
+ expect(res.body.errors).toContain('workspace_max_per_window must be a positive number');
1723
+ });
1724
+ it('returns 400 when window_ms is zero', async () => {
1725
+ const res = await (0, supertest_1.default)(app)
1726
+ .put('/api/v1/workspaces/ws-rl-put/rate-limits')
1727
+ .set('Cookie', 'pn_session=sess1')
1728
+ .send({ window_ms: 0 })
1729
+ .expect(400);
1730
+ expect(res.body.errors).toContain('window_ms must be a positive number');
1731
+ });
1732
+ it('successfully sets rate limit config', async () => {
1733
+ const res = await (0, supertest_1.default)(app)
1734
+ .put('/api/v1/workspaces/ws-rl-put/rate-limits')
1735
+ .set('Cookie', 'pn_session=sess1')
1736
+ .send({ actor_max_per_window: 200, workspace_max_per_window: 1000 })
1737
+ .expect(200);
1738
+ expect(res.body.is_custom).toBe(true);
1739
+ expect(res.body.config.actor_max_per_window).toBe(200);
1740
+ expect(res.body.config.workspace_max_per_window).toBe(1000);
1741
+ });
1742
+ });
1743
+ // -------------------------------------------------------------------------
1744
+ // DELETE /workspaces/:id/rate-limits — branches (1651-1652, 1654-1656)
1745
+ // -------------------------------------------------------------------------
1746
+ describe('DELETE /workspaces/:id/rate-limits', () => {
1747
+ beforeEach(() => {
1748
+ seedWorkspace({ wsId: 'ws-rl-del', slug: 'rl-del', userId: 'u1', role: 'owner', memberId: 'mem-rl-del' });
1749
+ });
1750
+ it('returns 403 when not a member', async () => {
1751
+ saasStores.workspaceMemberStore.delete('mem-rl-del');
1752
+ const res = await (0, supertest_1.default)(app)
1753
+ .delete('/api/v1/workspaces/ws-rl-del/rate-limits')
1754
+ .set('Cookie', 'pn_session=sess1')
1755
+ .expect(403);
1756
+ expect(res.body.error).toContain('Not a member');
1757
+ });
1758
+ it('returns 403 when not owner/admin', async () => {
1759
+ saasStores.workspaceMemberStore.delete('mem-rl-del');
1760
+ saasStores.workspaceMemberStore.create({
1761
+ id: 'mem-rl-del-member',
1762
+ workspace_id: 'ws-rl-del',
1763
+ user_id: 'u1',
1764
+ role: 'member',
1765
+ joined_at: now,
1766
+ });
1767
+ const res = await (0, supertest_1.default)(app)
1768
+ .delete('/api/v1/workspaces/ws-rl-del/rate-limits')
1769
+ .set('Cookie', 'pn_session=sess1')
1770
+ .expect(403);
1771
+ expect(res.body.error).toContain('owners and admins');
1772
+ });
1773
+ it('successfully resets rate limit config', async () => {
1774
+ const res = await (0, supertest_1.default)(app)
1775
+ .delete('/api/v1/workspaces/ws-rl-del/rate-limits')
1776
+ .set('Cookie', 'pn_session=sess1')
1777
+ .expect(200);
1778
+ expect(res.body.status).toBe('reset');
1779
+ expect(res.body.is_custom).toBe(false);
1780
+ });
1781
+ });
1782
+ // -------------------------------------------------------------------------
1783
+ // GET /workspaces/:id/budget-config — branches (1686-1687)
1784
+ // -------------------------------------------------------------------------
1785
+ describe('GET /workspaces/:id/budget-config', () => {
1786
+ beforeEach(() => {
1787
+ seedWorkspace({ wsId: 'ws-bc-get', slug: 'bc-get', userId: 'u1', role: 'owner', memberId: 'mem-bc-get' });
1788
+ });
1789
+ it('returns global config when no custom budget config', async () => {
1790
+ const res = await (0, supertest_1.default)(app)
1791
+ .get('/api/v1/workspaces/ws-bc-get/budget-config')
1792
+ .set('Cookie', 'pn_session=sess1')
1793
+ .expect(200);
1794
+ expect(res.body.config).toBeDefined();
1795
+ expect(res.body.is_custom).toBe(false);
1796
+ });
1797
+ it('returns 403 when not a member', async () => {
1798
+ saasStores.workspaceMemberStore.delete('mem-bc-get');
1799
+ const res = await (0, supertest_1.default)(app)
1800
+ .get('/api/v1/workspaces/ws-bc-get/budget-config')
1801
+ .set('Cookie', 'pn_session=sess1')
1802
+ .expect(403);
1803
+ expect(res.body.error).toContain('Not a member');
1804
+ });
1805
+ });
1806
+ // -------------------------------------------------------------------------
1807
+ // PUT /workspaces/:id/budget-config — branches (1703-1704, 1706-1708, 1720-1728)
1808
+ // -------------------------------------------------------------------------
1809
+ describe('PUT /workspaces/:id/budget-config', () => {
1810
+ beforeEach(() => {
1811
+ seedWorkspace({ wsId: 'ws-bc-put', slug: 'bc-put', userId: 'u1', role: 'owner', memberId: 'mem-bc-put' });
1812
+ });
1813
+ it('returns 403 when not a member', async () => {
1814
+ saasStores.workspaceMemberStore.delete('mem-bc-put');
1815
+ const res = await (0, supertest_1.default)(app)
1816
+ .put('/api/v1/workspaces/ws-bc-put/budget-config')
1817
+ .set('Cookie', 'pn_session=sess1')
1818
+ .send({ task_budget_usd: 50 })
1819
+ .expect(403);
1820
+ expect(res.body.error).toContain('Not a member');
1821
+ });
1822
+ it('returns 403 when not owner/admin', async () => {
1823
+ saasStores.workspaceMemberStore.delete('mem-bc-put');
1824
+ saasStores.workspaceMemberStore.create({
1825
+ id: 'mem-bc-put-viewer',
1826
+ workspace_id: 'ws-bc-put',
1827
+ user_id: 'u1',
1828
+ role: 'viewer',
1829
+ joined_at: now,
1830
+ });
1831
+ const res = await (0, supertest_1.default)(app)
1832
+ .put('/api/v1/workspaces/ws-bc-put/budget-config')
1833
+ .set('Cookie', 'pn_session=sess1')
1834
+ .send({ task_budget_usd: 50 })
1835
+ .expect(403);
1836
+ expect(res.body.error).toContain('owners and admins');
1837
+ });
1838
+ it('returns 400 when task_budget_usd is negative', async () => {
1839
+ const res = await (0, supertest_1.default)(app)
1840
+ .put('/api/v1/workspaces/ws-bc-put/budget-config')
1841
+ .set('Cookie', 'pn_session=sess1')
1842
+ .send({ task_budget_usd: -10 })
1843
+ .expect(400);
1844
+ expect(res.body.error).toContain('Validation failed');
1845
+ expect(res.body.errors).toContain('task_budget_usd must be a positive number');
1846
+ });
1847
+ it('returns 400 when max_steps_per_task is not a number', async () => {
1848
+ const res = await (0, supertest_1.default)(app)
1849
+ .put('/api/v1/workspaces/ws-bc-put/budget-config')
1850
+ .set('Cookie', 'pn_session=sess1')
1851
+ .send({ max_steps_per_task: 'lots' })
1852
+ .expect(400);
1853
+ expect(res.body.errors).toContain('max_steps_per_task must be a positive number');
1854
+ });
1855
+ it('validates multiple fields at once', async () => {
1856
+ const res = await (0, supertest_1.default)(app)
1857
+ .put('/api/v1/workspaces/ws-bc-put/budget-config')
1858
+ .set('Cookie', 'pn_session=sess1')
1859
+ .send({
1860
+ task_budget_usd: 0,
1861
+ max_wall_clock_ms: -1,
1862
+ max_retries_per_call: 'abc',
1863
+ })
1864
+ .expect(400);
1865
+ expect(res.body.errors.length).toBeGreaterThanOrEqual(3);
1866
+ });
1867
+ it('successfully sets budget config', async () => {
1868
+ const res = await (0, supertest_1.default)(app)
1869
+ .put('/api/v1/workspaces/ws-bc-put/budget-config')
1870
+ .set('Cookie', 'pn_session=sess1')
1871
+ .send({
1872
+ task_budget_usd: 200,
1873
+ workspace_monthly_budget_usd: 10000,
1874
+ })
1875
+ .expect(200);
1876
+ expect(res.body.is_custom).toBe(true);
1877
+ expect(res.body.config.task_budget_usd).toBe(200);
1878
+ expect(res.body.config.workspace_monthly_budget_usd).toBe(10000);
1879
+ });
1880
+ });
1881
+ // -------------------------------------------------------------------------
1882
+ // DELETE /workspaces/:id/budget-config — branches (1758-1759, 1761-1763)
1883
+ // -------------------------------------------------------------------------
1884
+ describe('DELETE /workspaces/:id/budget-config', () => {
1885
+ beforeEach(() => {
1886
+ seedWorkspace({ wsId: 'ws-bc-del', slug: 'bc-del', userId: 'u1', role: 'owner', memberId: 'mem-bc-del' });
1887
+ });
1888
+ it('returns 403 when not a member', async () => {
1889
+ saasStores.workspaceMemberStore.delete('mem-bc-del');
1890
+ const res = await (0, supertest_1.default)(app)
1891
+ .delete('/api/v1/workspaces/ws-bc-del/budget-config')
1892
+ .set('Cookie', 'pn_session=sess1')
1893
+ .expect(403);
1894
+ expect(res.body.error).toContain('Not a member');
1895
+ });
1896
+ it('returns 403 when not owner/admin', async () => {
1897
+ saasStores.workspaceMemberStore.delete('mem-bc-del');
1898
+ saasStores.workspaceMemberStore.create({
1899
+ id: 'mem-bc-del-member',
1900
+ workspace_id: 'ws-bc-del',
1901
+ user_id: 'u1',
1902
+ role: 'member',
1903
+ joined_at: now,
1904
+ });
1905
+ const res = await (0, supertest_1.default)(app)
1906
+ .delete('/api/v1/workspaces/ws-bc-del/budget-config')
1907
+ .set('Cookie', 'pn_session=sess1')
1908
+ .expect(403);
1909
+ expect(res.body.error).toContain('owners and admins');
1910
+ });
1911
+ it('successfully resets budget config', async () => {
1912
+ const res = await (0, supertest_1.default)(app)
1913
+ .delete('/api/v1/workspaces/ws-bc-del/budget-config')
1914
+ .set('Cookie', 'pn_session=sess1')
1915
+ .expect(200);
1916
+ expect(res.body.status).toBe('reset');
1917
+ expect(res.body.is_custom).toBe(false);
1918
+ });
1919
+ });
1920
+ // -------------------------------------------------------------------------
1921
+ // GET /workspaces/:id/dashboard/stats — branches (various)
1922
+ // -------------------------------------------------------------------------
1923
+ describe('GET /workspaces/:id/dashboard/stats', () => {
1924
+ beforeEach(() => {
1925
+ seedWorkspace({ wsId: 'ws-dash', slug: 'dash-ws', userId: 'u1', role: 'owner', memberId: 'mem-dash' });
1926
+ });
1927
+ it('returns dashboard stats', async () => {
1928
+ const res = await (0, supertest_1.default)(app)
1929
+ .get('/api/v1/workspaces/ws-dash/dashboard/stats')
1930
+ .set('Cookie', 'pn_session=sess1')
1931
+ .expect(200);
1932
+ expect(res.body.metrics).toBeDefined();
1933
+ expect(res.body.shield_score).toBeDefined();
1934
+ expect(res.body.pipeline_throughput).toBeDefined();
1935
+ expect(typeof res.body.metrics.requests_per_minute).toBe('number');
1936
+ expect(typeof res.body.metrics.blocked_24h).toBe('number');
1937
+ expect(typeof res.body.metrics.pending_approvals).toBe('number');
1938
+ });
1939
+ it('returns 403 when not a member', async () => {
1940
+ saasStores.workspaceMemberStore.delete('mem-dash');
1941
+ const res = await (0, supertest_1.default)(app)
1942
+ .get('/api/v1/workspaces/ws-dash/dashboard/stats')
1943
+ .set('Cookie', 'pn_session=sess1')
1944
+ .expect(403);
1945
+ expect(res.body.error).toContain('Not a member');
1946
+ });
1947
+ });
1948
+ // -------------------------------------------------------------------------
1949
+ // 401 tests for many endpoints to cover requireSession branch
1950
+ // -------------------------------------------------------------------------
1951
+ describe('401 — requireSession for unauthenticated requests', () => {
1952
+ // Test a few representative endpoints without session cookie.
1953
+ // With auth enabled but no API key, middleware returns 401.
1954
+ // After many unauthenticated requests the IP rate limiter may return 429.
1955
+ // We accept both to avoid flakiness.
1956
+ const endpoints = [
1957
+ { method: 'get', path: '/api/v1/workspaces/ws-1/stats' },
1958
+ { method: 'get', path: '/api/v1/workspaces/ws-1/events' },
1959
+ { method: 'get', path: '/api/v1/workspaces/ws-1/traces' },
1960
+ { method: 'delete', path: '/api/v1/workspaces/ws-1/traces' },
1961
+ { method: 'delete', path: '/api/v1/workspaces/ws-1/traces/task-1' },
1962
+ { method: 'delete', path: '/api/v1/workspaces/ws-1/sessions/sess-1' },
1963
+ ];
1964
+ for (const { method, path } of endpoints) {
1965
+ it(`returns 401 or 429 for ${method.toUpperCase()} ${path} without auth`, async () => {
1966
+ const res = await (0, supertest_1.default)(app)[method](path);
1967
+ expect([401, 429]).toContain(res.status);
1968
+ });
1969
+ }
1970
+ });
1971
+ // -------------------------------------------------------------------------
1972
+ // GET /workspaces/:id/stats — slug matching branch (line 228-229, 239)
1973
+ // -------------------------------------------------------------------------
1974
+ describe('GET /workspaces/:id/stats — branches', () => {
1975
+ beforeEach(() => {
1976
+ seedWorkspace({ wsId: 'ws-stats-br', slug: 'stats-br', userId: 'u1', role: 'owner', memberId: 'mem-stats-br' });
1977
+ });
1978
+ it('returns stats with API keys counted', async () => {
1979
+ // Create some API keys, one revoked
1980
+ saasStores.userApiKeyStore.create({
1981
+ id: 'key-stats-1',
1982
+ key_hash: 'hash-s1',
1983
+ key_prefix: 'pn_s1',
1984
+ user_id: 'u1',
1985
+ workspace_id: 'ws-stats-br',
1986
+ name: 'Active Key',
1987
+ roles: ['agent'],
1988
+ tags: [],
1989
+ revoked: false,
1990
+ created_at: now,
1991
+ });
1992
+ saasStores.userApiKeyStore.create({
1993
+ id: 'key-stats-2',
1994
+ key_hash: 'hash-s2',
1995
+ key_prefix: 'pn_s2',
1996
+ user_id: 'u1',
1997
+ workspace_id: 'ws-stats-br',
1998
+ name: 'Revoked Key',
1999
+ roles: ['agent'],
2000
+ tags: [],
2001
+ revoked: true,
2002
+ created_at: now,
2003
+ });
2004
+ const res = await (0, supertest_1.default)(app)
2005
+ .get('/api/v1/workspaces/ws-stats-br/stats')
2006
+ .set('Cookie', 'pn_session=sess1')
2007
+ .expect(200);
2008
+ // Only non-revoked keys counted
2009
+ expect(res.body.api_keys).toBe(1);
2010
+ expect(res.body.members).toBe(1);
2011
+ });
2012
+ it('returns 403 when not a member', async () => {
2013
+ saasStores.workspaceMemberStore.delete('mem-stats-br');
2014
+ const res = await (0, supertest_1.default)(app)
2015
+ .get('/api/v1/workspaces/ws-stats-br/stats')
2016
+ .set('Cookie', 'pn_session=sess1')
2017
+ .expect(403);
2018
+ expect(res.body.error).toContain('Not a member');
2019
+ });
2020
+ });
2021
+ // -------------------------------------------------------------------------
2022
+ // DELETE /workspaces/:id/traces/:taskId (single trace delete)
2023
+ // DELETE /workspaces/:id/sessions/:sessionId (session delete)
2024
+ // -------------------------------------------------------------------------
2025
+ describe('Trace & Session Deletion', () => {
2026
+ const wsId = 'ws-del-traces';
2027
+ beforeEach(() => {
2028
+ seedWorkspace({ wsId, slug: 'del-traces', userId: 'u1', role: 'owner', memberId: 'mem-del-traces' });
2029
+ // Seed some audit events
2030
+ const logger = gateway.getAuditLogger();
2031
+ logger.log({
2032
+ event_type: 'TOOL_CALL_RECEIVED',
2033
+ tool_call_id: 'tc-del-1',
2034
+ session_id: 'sess-del-1',
2035
+ task_id: 'task-del-1',
2036
+ workspace_id: wsId,
2037
+ actor_id: 'agent-1',
2038
+ tool_name: 'http.get',
2039
+ metadata: {},
2040
+ });
2041
+ logger.log({
2042
+ event_type: 'TOOL_RESULT_RETURNED',
2043
+ tool_call_id: 'tc-del-1',
2044
+ session_id: 'sess-del-1',
2045
+ task_id: 'task-del-1',
2046
+ workspace_id: wsId,
2047
+ actor_id: 'agent-1',
2048
+ tool_name: 'http.get',
2049
+ metadata: { status: 'ok', duration_ms: 50 },
2050
+ });
2051
+ logger.log({
2052
+ event_type: 'TOOL_CALL_RECEIVED',
2053
+ tool_call_id: 'tc-del-2',
2054
+ session_id: 'sess-del-2',
2055
+ task_id: 'task-del-2',
2056
+ workspace_id: wsId,
2057
+ actor_id: 'agent-1',
2058
+ tool_name: 'http.post',
2059
+ metadata: {},
2060
+ });
2061
+ });
2062
+ afterEach(() => {
2063
+ gateway.getAuditLogger().clear();
2064
+ });
2065
+ it('DELETE /workspaces/:id/traces clears all events', async () => {
2066
+ const before = gateway.getAuditLogger().getAllEvents().length;
2067
+ expect(before).toBeGreaterThan(0);
2068
+ const res = await (0, supertest_1.default)(app)
2069
+ .delete(`/api/v1/workspaces/${wsId}/traces`)
2070
+ .set('Cookie', 'pn_session=sess1')
2071
+ .expect(200);
2072
+ expect(res.body.status).toBe('ok');
2073
+ expect(gateway.getAuditLogger().getAllEvents()).toHaveLength(0);
2074
+ });
2075
+ it('DELETE /workspaces/:id/traces/:taskId removes events for that task_id', async () => {
2076
+ // Verify events exist before
2077
+ const before = gateway.getAuditLogger().getAllEvents().filter(e => e.task_id === 'task-del-1');
2078
+ expect(before.length).toBe(2);
2079
+ const res = await (0, supertest_1.default)(app)
2080
+ .delete(`/api/v1/workspaces/${wsId}/traces/task-del-1`)
2081
+ .set('Cookie', 'pn_session=sess1')
2082
+ .expect(200);
2083
+ expect(res.body.status).toBe('ok');
2084
+ // Verify events removed
2085
+ const after = gateway.getAuditLogger().getAllEvents().filter(e => e.task_id === 'task-del-1');
2086
+ expect(after.length).toBe(0);
2087
+ // Other events still exist
2088
+ const remaining = gateway.getAuditLogger().getAllEvents().filter(e => e.task_id === 'task-del-2');
2089
+ expect(remaining.length).toBe(1);
2090
+ });
2091
+ it('DELETE /workspaces/:id/sessions/:sessionId removes events for that session_id', async () => {
2092
+ const before = gateway.getAuditLogger().getAllEvents().filter(e => e.session_id === 'sess-del-1');
2093
+ expect(before.length).toBe(2);
2094
+ const res = await (0, supertest_1.default)(app)
2095
+ .delete(`/api/v1/workspaces/${wsId}/sessions/sess-del-1`)
2096
+ .set('Cookie', 'pn_session=sess1')
2097
+ .expect(200);
2098
+ expect(res.body.status).toBe('ok');
2099
+ const after = gateway.getAuditLogger().getAllEvents().filter(e => e.session_id === 'sess-del-1');
2100
+ expect(after.length).toBe(0);
2101
+ // Other session's events still exist
2102
+ const remaining = gateway.getAuditLogger().getAllEvents().filter(e => e.session_id === 'sess-del-2');
2103
+ expect(remaining.length).toBe(1);
2104
+ });
2105
+ it('returns 403 for non-admin on trace delete', async () => {
2106
+ saasStores.userStore.create({
2107
+ id: 'u2',
2108
+ email: 'viewer@test.com',
2109
+ display_name: 'Viewer',
2110
+ status: 'active',
2111
+ onboarding_completed: true,
2112
+ created_at: now,
2113
+ updated_at: now,
2114
+ });
2115
+ saasStores.sessionStore.create({
2116
+ id: 'sess2',
2117
+ user_id: 'u2',
2118
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
2119
+ last_active_at: now,
2120
+ created_at: now,
2121
+ });
2122
+ saasStores.workspaceMemberStore.create({
2123
+ id: 'mem-del-traces-viewer',
2124
+ workspace_id: wsId,
2125
+ user_id: 'u2',
2126
+ role: 'viewer',
2127
+ joined_at: now,
2128
+ });
2129
+ await (0, supertest_1.default)(app)
2130
+ .delete(`/api/v1/workspaces/${wsId}/traces/task-del-1`)
2131
+ .set('Cookie', 'pn_session=sess2')
2132
+ .expect(403);
2133
+ });
2134
+ it('returns 403 for non-admin on session delete', async () => {
2135
+ saasStores.userStore.create({
2136
+ id: 'u2',
2137
+ email: 'viewer@test.com',
2138
+ display_name: 'Viewer',
2139
+ status: 'active',
2140
+ onboarding_completed: true,
2141
+ created_at: now,
2142
+ updated_at: now,
2143
+ });
2144
+ saasStores.sessionStore.create({
2145
+ id: 'sess2',
2146
+ user_id: 'u2',
2147
+ expires_at: new Date(Date.now() + 3600000).toISOString(),
2148
+ last_active_at: now,
2149
+ created_at: now,
2150
+ });
2151
+ saasStores.workspaceMemberStore.create({
2152
+ id: 'mem-del-traces-viewer',
2153
+ workspace_id: wsId,
2154
+ user_id: 'u2',
2155
+ role: 'viewer',
2156
+ joined_at: now,
2157
+ });
2158
+ await (0, supertest_1.default)(app)
2159
+ .delete(`/api/v1/workspaces/${wsId}/sessions/sess-del-1`)
2160
+ .set('Cookie', 'pn_session=sess2')
2161
+ .expect(403);
2162
+ });
2163
+ });
2164
+ });
2165
+ //# sourceMappingURL=saas-routes-branches.test.js.map