@synth-deploy/server 1.0.6 → 1.2.0

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 (97) hide show
  1. package/dist/agent/envoy-client.d.ts +65 -15
  2. package/dist/agent/envoy-client.d.ts.map +1 -1
  3. package/dist/agent/envoy-client.js +58 -8
  4. package/dist/agent/envoy-client.js.map +1 -1
  5. package/dist/agent/stale-deployment-detector.js +1 -1
  6. package/dist/agent/stale-deployment-detector.js.map +1 -1
  7. package/dist/agent/synth-agent.d.ts +7 -5
  8. package/dist/agent/synth-agent.d.ts.map +1 -1
  9. package/dist/agent/synth-agent.js +59 -50
  10. package/dist/agent/synth-agent.js.map +1 -1
  11. package/dist/alert-webhooks/alert-parsers.d.ts +21 -0
  12. package/dist/alert-webhooks/alert-parsers.d.ts.map +1 -0
  13. package/dist/alert-webhooks/alert-parsers.js +184 -0
  14. package/dist/alert-webhooks/alert-parsers.js.map +1 -0
  15. package/dist/api/agent.d.ts +0 -6
  16. package/dist/api/agent.d.ts.map +1 -1
  17. package/dist/api/agent.js +6 -459
  18. package/dist/api/agent.js.map +1 -1
  19. package/dist/api/alert-webhooks.d.ts +13 -0
  20. package/dist/api/alert-webhooks.d.ts.map +1 -0
  21. package/dist/api/alert-webhooks.js +279 -0
  22. package/dist/api/alert-webhooks.js.map +1 -0
  23. package/dist/api/envoy-reports.js +2 -2
  24. package/dist/api/envoy-reports.js.map +1 -1
  25. package/dist/api/envoys.js +1 -1
  26. package/dist/api/envoys.js.map +1 -1
  27. package/dist/api/fleet.d.ts.map +1 -1
  28. package/dist/api/fleet.js +14 -15
  29. package/dist/api/fleet.js.map +1 -1
  30. package/dist/api/graph.js +3 -3
  31. package/dist/api/graph.js.map +1 -1
  32. package/dist/api/operations.d.ts +7 -0
  33. package/dist/api/operations.d.ts.map +1 -0
  34. package/dist/api/operations.js +1900 -0
  35. package/dist/api/operations.js.map +1 -0
  36. package/dist/api/partitions.js +1 -1
  37. package/dist/api/partitions.js.map +1 -1
  38. package/dist/api/schemas.d.ts +434 -133
  39. package/dist/api/schemas.d.ts.map +1 -1
  40. package/dist/api/schemas.js +53 -25
  41. package/dist/api/schemas.js.map +1 -1
  42. package/dist/api/system.d.ts.map +1 -1
  43. package/dist/api/system.js +22 -21
  44. package/dist/api/system.js.map +1 -1
  45. package/dist/artifact-analyzer.js +2 -2
  46. package/dist/artifact-analyzer.js.map +1 -1
  47. package/dist/fleet/fleet-executor.js +3 -3
  48. package/dist/fleet/fleet-executor.js.map +1 -1
  49. package/dist/graph/graph-executor.d.ts.map +1 -1
  50. package/dist/graph/graph-executor.js +18 -4
  51. package/dist/graph/graph-executor.js.map +1 -1
  52. package/dist/index.js +89 -61
  53. package/dist/index.js.map +1 -1
  54. package/dist/mcp/resources.js +3 -3
  55. package/dist/mcp/resources.js.map +1 -1
  56. package/dist/mcp/tools.d.ts.map +1 -1
  57. package/dist/mcp/tools.js +2 -9
  58. package/dist/mcp/tools.js.map +1 -1
  59. package/dist/middleware/auth.js +1 -1
  60. package/dist/middleware/auth.js.map +1 -1
  61. package/package.json +1 -1
  62. package/src/agent/envoy-client.ts +111 -19
  63. package/src/agent/stale-deployment-detector.ts +1 -1
  64. package/src/agent/synth-agent.ts +76 -56
  65. package/src/alert-webhooks/alert-parsers.ts +291 -0
  66. package/src/api/agent.ts +9 -528
  67. package/src/api/alert-webhooks.ts +354 -0
  68. package/src/api/envoy-reports.ts +2 -2
  69. package/src/api/envoys.ts +1 -1
  70. package/src/api/fleet.ts +14 -15
  71. package/src/api/graph.ts +3 -3
  72. package/src/api/operations.ts +2260 -0
  73. package/src/api/partitions.ts +1 -1
  74. package/src/api/schemas.ts +59 -27
  75. package/src/api/system.ts +23 -21
  76. package/src/artifact-analyzer.ts +2 -2
  77. package/src/fleet/fleet-executor.ts +3 -3
  78. package/src/graph/graph-executor.ts +18 -4
  79. package/src/index.ts +91 -61
  80. package/src/mcp/resources.ts +3 -3
  81. package/src/mcp/tools.ts +5 -9
  82. package/src/middleware/auth.ts +1 -1
  83. package/tests/agent-mode.test.ts +5 -376
  84. package/tests/api-handlers.test.ts +27 -27
  85. package/tests/composite-operations.test.ts +557 -0
  86. package/tests/decision-diary.test.ts +62 -63
  87. package/tests/diary-reader.test.ts +14 -18
  88. package/tests/mcp-tools.test.ts +1 -1
  89. package/tests/orchestration.test.ts +34 -30
  90. package/tests/partition-isolation.test.ts +4 -9
  91. package/tests/rbac-enforcement.test.ts +8 -8
  92. package/tests/ui-journey.test.ts +9 -9
  93. package/dist/api/deployments.d.ts +0 -11
  94. package/dist/api/deployments.d.ts.map +0 -1
  95. package/dist/api/deployments.js +0 -1098
  96. package/dist/api/deployments.js.map +0 -1
  97. package/src/api/deployments.ts +0 -1347
@@ -0,0 +1,557 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+ import Fastify from "fastify";
3
+ import type { FastifyInstance } from "fastify";
4
+ import {
5
+ DecisionDebrief,
6
+ PartitionStore,
7
+ EnvironmentStore,
8
+ ArtifactStore,
9
+ SettingsStore,
10
+ TelemetryStore,
11
+ } from "@synth-deploy/core";
12
+ import type { Operation, OperationPlan } from "@synth-deploy/core";
13
+ import { InMemoryDeploymentStore } from "../src/agent/synth-agent.js";
14
+ import { registerOperationRoutes } from "../src/api/operations.js";
15
+ import { registerArtifactRoutes } from "../src/api/artifacts.js";
16
+ import type { EnvoyRegistry, EnvoyRegistration } from "../src/agent/envoy-registry.js";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Mock EnvoyClient — prevents real network calls in all tests in this file
20
+ // ---------------------------------------------------------------------------
21
+
22
+ vi.mock("../src/agent/envoy-client.js", () => ({
23
+ // Must use a regular function (not arrow) so 'new EnvoyClient()' works correctly
24
+ EnvoyClient: vi.fn().mockImplementation(function (this: any) {
25
+ this.requestPlan = vi.fn().mockResolvedValue({
26
+ blocked: false,
27
+ plan: { scriptedPlan: { platform: "bash", executionScript: "echo test", dryRunScript: null, rollbackScript: null, reasoning: "test plan", stepSummary: [{ description: "Step 1", reversible: false }] }, reasoning: "test plan" },
28
+ rollbackPlan: { scriptedPlan: { platform: "bash", executionScript: "echo rollback", dryRunScript: null, rollbackScript: null, reasoning: "no rollback", stepSummary: [] }, reasoning: "no rollback" },
29
+ });
30
+ this.executeApprovedPlan = vi.fn().mockResolvedValue({});
31
+ this.removeMonitoringDirective = vi.fn().mockResolvedValue({});
32
+ }),
33
+ }));
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Shared envoy registry mock
37
+ // ---------------------------------------------------------------------------
38
+
39
+ const MOCK_ENVOY: EnvoyRegistration = {
40
+ id: "envoy-composite-test",
41
+ name: "Composite Test Envoy",
42
+ url: "http://localhost:19999",
43
+ token: "test-token",
44
+ assignedEnvironments: [],
45
+ assignedPartitions: [],
46
+ registeredAt: new Date().toISOString(),
47
+ lastHealthCheck: null,
48
+ lastHealthStatus: null,
49
+ cachedHostname: null,
50
+ cachedOs: null,
51
+ cachedSummary: null,
52
+ cachedReadiness: null,
53
+ };
54
+
55
+ const MOCK_REGISTRY: EnvoyRegistry = {
56
+ list: () => [MOCK_ENVOY],
57
+ get: (id: string) => id === MOCK_ENVOY.id ? MOCK_ENVOY : undefined,
58
+ findForEnvironment: () => undefined,
59
+ register: () => MOCK_ENVOY,
60
+ deregister: () => true,
61
+ update: () => undefined,
62
+ updateHealth: () => undefined,
63
+ } as unknown as EnvoyRegistry;
64
+
65
+ const MOCK_PLAN: OperationPlan = {
66
+ scriptedPlan: { platform: "bash", executionScript: "systemctl status", dryRunScript: null, rollbackScript: null, reasoning: "standard maintenance check", stepSummary: [{ description: "Check services", reversible: false }] },
67
+ reasoning: "standard maintenance check",
68
+ };
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Test server factory
72
+ // ---------------------------------------------------------------------------
73
+
74
+ interface TestContext {
75
+ app: FastifyInstance;
76
+ deployments: InMemoryDeploymentStore;
77
+ diary: DecisionDebrief;
78
+ }
79
+
80
+ async function createTestServer(opts: { withRegistry?: boolean } = {}): Promise<TestContext> {
81
+ const diary = new DecisionDebrief();
82
+ const deployments = new InMemoryDeploymentStore();
83
+ const partitions = new PartitionStore();
84
+ const environments = new EnvironmentStore();
85
+ const artifactStore = new ArtifactStore();
86
+ const settings = new SettingsStore();
87
+ const telemetry = new TelemetryStore();
88
+
89
+ const app = Fastify({ logger: false });
90
+ app.addHook("onRequest", async (request) => {
91
+ request.user = {
92
+ id: "test-user-id" as any,
93
+ email: "test@example.com",
94
+ name: "Test User",
95
+ permissions: [
96
+ "deployment.create", "deployment.approve", "deployment.reject",
97
+ "deployment.view", "deployment.rollback", "artifact.create", "artifact.view",
98
+ ],
99
+ };
100
+ });
101
+
102
+ registerOperationRoutes(
103
+ app, deployments, diary, partitions, environments, artifactStore, settings, telemetry,
104
+ undefined, undefined,
105
+ opts.withRegistry ? MOCK_REGISTRY : undefined,
106
+ );
107
+ registerArtifactRoutes(app, artifactStore, telemetry);
108
+
109
+ await app.ready();
110
+ return { app, deployments, diary };
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Store helpers
115
+ // ---------------------------------------------------------------------------
116
+
117
+ function seedComposite(
118
+ deployments: InMemoryDeploymentStore,
119
+ status: Operation["status"] = "awaiting_approval",
120
+ ): Operation {
121
+ const op: any = {
122
+ id: crypto.randomUUID(),
123
+ input: { type: "composite", operations: [] },
124
+ status,
125
+ variables: {},
126
+ debriefEntryIds: [],
127
+ createdAt: new Date(),
128
+ version: "",
129
+ };
130
+ deployments.save(op);
131
+ return op as Operation;
132
+ }
133
+
134
+ function seedChild(
135
+ deployments: InMemoryDeploymentStore,
136
+ parentId: string,
137
+ overrides: Partial<any> = {},
138
+ ): Operation {
139
+ const child: any = {
140
+ id: crypto.randomUUID(),
141
+ input: { type: "query", intent: "check disk usage" },
142
+ lineage: parentId,
143
+ status: "awaiting_approval",
144
+ variables: {},
145
+ debriefEntryIds: [],
146
+ createdAt: new Date(),
147
+ version: "",
148
+ sequenceIndex: 0,
149
+ ...overrides,
150
+ };
151
+ deployments.save(child);
152
+ return child as Operation;
153
+ }
154
+
155
+ /** Flush pending microtasks and macrotasks (lets fire-and-forget async settle) */
156
+ async function flushAsync(): Promise<void> {
157
+ await Promise.resolve();
158
+ await new Promise((r) => setTimeout(r, 0));
159
+ await Promise.resolve();
160
+ }
161
+
162
+ // ===========================================================================
163
+ // Tests
164
+ // ===========================================================================
165
+
166
+ describe("Composite Operations — creation via HTTP (no envoy registry)", () => {
167
+ let ctx: TestContext;
168
+
169
+ beforeEach(async () => {
170
+ ctx = await createTestServer();
171
+ });
172
+
173
+ it("creates a composite operation and returns 201", async () => {
174
+ const res = await ctx.app.inject({
175
+ method: "POST",
176
+ url: "/api/operations",
177
+ payload: {
178
+ type: "composite",
179
+ operations: [
180
+ { type: "query", intent: "check disk usage" },
181
+ { type: "investigate", intent: "look for memory leaks" },
182
+ ],
183
+ },
184
+ });
185
+
186
+ expect(res.statusCode).toBe(201);
187
+ const body = JSON.parse(res.payload);
188
+ expect(body.deployment).toBeDefined();
189
+ expect(body.deployment.status).toBe("pending");
190
+ expect(body.deployment.input.type).toBe("composite");
191
+ expect(body.deployment.input.operations).toHaveLength(2);
192
+ });
193
+
194
+ it("creates a composite with empty operations array", async () => {
195
+ const res = await ctx.app.inject({
196
+ method: "POST",
197
+ url: "/api/operations",
198
+ payload: { type: "composite", operations: [] },
199
+ });
200
+
201
+ expect(res.statusCode).toBe(201);
202
+ const body = JSON.parse(res.payload);
203
+ expect(body.deployment.input.type).toBe("composite");
204
+ expect(body.deployment.input.operations).toHaveLength(0);
205
+ });
206
+
207
+ it("stores the composite operation in the deployment store", async () => {
208
+ const res = await ctx.app.inject({
209
+ method: "POST",
210
+ url: "/api/operations",
211
+ payload: {
212
+ type: "composite",
213
+ operations: [{ type: "maintain", intent: "rotate certs" }],
214
+ },
215
+ });
216
+
217
+ const body = JSON.parse(res.payload);
218
+ const stored = ctx.deployments.get(body.deployment.id);
219
+ expect(stored).toBeDefined();
220
+ expect(stored?.input.type).toBe("composite");
221
+ });
222
+ });
223
+
224
+ describe("Composite Operations — planning via envoy registry", () => {
225
+ let ctx: TestContext;
226
+
227
+ beforeEach(async () => {
228
+ vi.clearAllMocks();
229
+ ctx = await createTestServer({ withRegistry: true });
230
+ });
231
+
232
+ it("planCompositeChildren marks parent as failed when operations array is empty", async () => {
233
+ await ctx.app.inject({
234
+ method: "POST",
235
+ url: "/api/operations",
236
+ payload: { type: "composite", operations: [] },
237
+ });
238
+
239
+ // planCompositeChildren is fire-and-forget — let it settle
240
+ await flushAsync();
241
+
242
+ // With registry, the route returns before sending {deployment}; find via store
243
+ const ops = ctx.deployments.list().filter((d: any) => d.input.type === "composite");
244
+ expect(ops).toHaveLength(1);
245
+ expect(ops[0].status).toBe("failed");
246
+ expect((ops[0] as any).failureReason).toContain("no child operations");
247
+ });
248
+
249
+ it("planCompositeChildren creates child operations and awaits approval", async () => {
250
+ await ctx.app.inject({
251
+ method: "POST",
252
+ url: "/api/operations",
253
+ payload: {
254
+ type: "composite",
255
+ operations: [
256
+ { type: "query", intent: "check health" },
257
+ { type: "investigate", intent: "diagnose slowness" },
258
+ ],
259
+ },
260
+ });
261
+
262
+ await flushAsync();
263
+
264
+ const all = ctx.deployments.list();
265
+ const parents = all.filter((d: any) => d.input.type === "composite");
266
+ expect(parents).toHaveLength(1);
267
+ const parentId = parents[0].id;
268
+
269
+ expect(parents[0].status).toBe("awaiting_approval");
270
+
271
+ const children = all.filter((d: any) => d.lineage === parentId);
272
+ expect(children).toHaveLength(2);
273
+ expect(children.every((c: any) => c.status === "awaiting_approval")).toBe(true);
274
+ });
275
+
276
+ it("planCompositeChildren marks parent as failed when planning is blocked", async () => {
277
+ const { EnvoyClient } = await import("../src/agent/envoy-client.js");
278
+ // operations.ts line 125 creates a planningClient BEFORE the composite check (unused for composite)
279
+ (EnvoyClient as any).mockImplementationOnce(function (this: any) {
280
+ this.requestPlan = vi.fn(); // unused — composite route returns before calling this
281
+ });
282
+ // Inside planCompositeChildren, a new EnvoyClient is created per child
283
+ (EnvoyClient as any).mockImplementationOnce(function (this: any) {
284
+ this.requestPlan = vi.fn().mockResolvedValue({
285
+ blocked: true,
286
+ blockReason: "insufficient permissions",
287
+ plan: { scriptedPlan: { platform: "bash", executionScript: "", dryRunScript: null, rollbackScript: null, reasoning: "", stepSummary: [] }, reasoning: "" },
288
+ rollbackPlan: { scriptedPlan: { platform: "bash", executionScript: "", dryRunScript: null, rollbackScript: null, reasoning: "", stepSummary: [] }, reasoning: "" },
289
+ });
290
+ });
291
+
292
+ await ctx.app.inject({
293
+ method: "POST",
294
+ url: "/api/operations",
295
+ payload: {
296
+ type: "composite",
297
+ operations: [{ type: "query", intent: "check something" }],
298
+ },
299
+ });
300
+
301
+ await flushAsync();
302
+
303
+ const parents = ctx.deployments.list().filter((d: any) => d.input.type === "composite");
304
+ expect(parents[0].status).toBe("failed");
305
+ expect((parents[0] as any).failureReason).toContain("blocked");
306
+ });
307
+
308
+ it("planCompositeChildren marks parent as failed when requestPlan throws", async () => {
309
+ const { EnvoyClient } = await import("../src/agent/envoy-client.js");
310
+ // Unused client created at operations.ts line 125 before the composite check
311
+ (EnvoyClient as any).mockImplementationOnce(function (this: any) {
312
+ this.requestPlan = vi.fn();
313
+ });
314
+ // Client inside planCompositeChildren that throws on requestPlan
315
+ (EnvoyClient as any).mockImplementationOnce(function (this: any) {
316
+ this.requestPlan = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
317
+ });
318
+
319
+ await ctx.app.inject({
320
+ method: "POST",
321
+ url: "/api/operations",
322
+ payload: {
323
+ type: "composite",
324
+ operations: [{ type: "query", intent: "check something" }],
325
+ },
326
+ });
327
+
328
+ await flushAsync();
329
+
330
+ const parents = ctx.deployments.list().filter((d: any) => d.input.type === "composite");
331
+ expect(parents[0].status).toBe("failed");
332
+ expect((parents[0] as any).failureReason).toContain("ECONNREFUSED");
333
+ });
334
+ });
335
+
336
+ describe("Composite Operations — approval and execution", () => {
337
+ let ctx: TestContext;
338
+
339
+ beforeEach(async () => {
340
+ ctx = await createTestServer();
341
+ });
342
+
343
+ it("approving composite with no children succeeds", async () => {
344
+ const parent = seedComposite(ctx.deployments, "awaiting_approval");
345
+
346
+ const res = await ctx.app.inject({
347
+ method: "POST",
348
+ url: `/api/operations/${parent.id}/approve`,
349
+ payload: { approvedBy: "ops@example.com" },
350
+ });
351
+
352
+ expect(res.statusCode).toBe(200);
353
+ expect(JSON.parse(res.payload).approved).toBe(true);
354
+
355
+ // executeCompositeSequentially([]) runs synchronously → already succeeded
356
+ const final = ctx.deployments.get(parent.id);
357
+ expect(final?.status).toBe("succeeded");
358
+ });
359
+
360
+ it("approving composite transitions all children to approved", async () => {
361
+ const parent = seedComposite(ctx.deployments, "awaiting_approval");
362
+ const child1 = seedChild(ctx.deployments, parent.id, { sequenceIndex: 0 });
363
+ const child2 = seedChild(ctx.deployments, parent.id, { sequenceIndex: 1 });
364
+
365
+ await ctx.app.inject({
366
+ method: "POST",
367
+ url: `/api/operations/${parent.id}/approve`,
368
+ payload: { approvedBy: "ops@example.com" },
369
+ });
370
+
371
+ const c1 = ctx.deployments.get(child1.id);
372
+ const c2 = ctx.deployments.get(child2.id);
373
+ expect(c1?.status).toBe("approved");
374
+ expect(c2?.status).toBe("approved");
375
+ expect((c1 as any)?.approvedBy).toBe("ops@example.com");
376
+ });
377
+
378
+ it("returns 409 when approving composite in non-awaiting_approval status", async () => {
379
+ const parent = seedComposite(ctx.deployments, "pending");
380
+
381
+ const res = await ctx.app.inject({
382
+ method: "POST",
383
+ url: `/api/operations/${parent.id}/approve`,
384
+ payload: { approvedBy: "ops@example.com" },
385
+ });
386
+
387
+ expect(res.statusCode).toBe(409);
388
+ });
389
+
390
+ it("executeCompositeSequentially fails when child has no plan", async () => {
391
+ const parent = seedComposite(ctx.deployments, "awaiting_approval");
392
+ seedChild(ctx.deployments, parent.id); // child exists but has no plan
393
+
394
+ await ctx.app.inject({
395
+ method: "POST",
396
+ url: `/api/operations/${parent.id}/approve`,
397
+ payload: { approvedBy: "ops@example.com" },
398
+ });
399
+
400
+ await flushAsync();
401
+
402
+ const final = ctx.deployments.get(parent.id);
403
+ expect(final?.status).toBe("failed");
404
+ expect((final as any)?.failureReason).toContain("no plan");
405
+ });
406
+
407
+ it("executeCompositeSequentially fails when no envoy available for child", async () => {
408
+ const parent = seedComposite(ctx.deployments, "awaiting_approval");
409
+ // Child has a plan but no envoyId, and this ctx has no envoy registry
410
+ seedChild(ctx.deployments, parent.id, {
411
+ plan: MOCK_PLAN,
412
+ rollbackPlan: { scriptedPlan: { platform: "bash", executionScript: "", dryRunScript: null, rollbackScript: null, reasoning: "", stepSummary: [] }, reasoning: "" },
413
+ });
414
+
415
+ await ctx.app.inject({
416
+ method: "POST",
417
+ url: `/api/operations/${parent.id}/approve`,
418
+ payload: { approvedBy: "ops@example.com" },
419
+ });
420
+
421
+ await flushAsync();
422
+
423
+ const final = ctx.deployments.get(parent.id);
424
+ expect(final?.status).toBe("failed");
425
+ expect((final as any)?.failureReason).toContain("No envoy available");
426
+ });
427
+
428
+ it("GET /api/operations lists composite operations", async () => {
429
+ const parent = seedComposite(ctx.deployments, "awaiting_approval");
430
+
431
+ const res = await ctx.app.inject({ method: "GET", url: "/api/operations" });
432
+
433
+ expect(res.statusCode).toBe(200);
434
+ const body = JSON.parse(res.payload);
435
+ const found = body.deployments.find((d: any) => d.id === parent.id);
436
+ expect(found).toBeDefined();
437
+ expect(found.input.type).toBe("composite");
438
+ });
439
+
440
+ it("GET /api/operations/:id returns a composite operation", async () => {
441
+ const parent = seedComposite(ctx.deployments, "pending");
442
+
443
+ const res = await ctx.app.inject({ method: "GET", url: `/api/operations/${parent.id}` });
444
+
445
+ expect(res.statusCode).toBe(200);
446
+ const body = JSON.parse(res.payload);
447
+ expect(body.deployment.id).toBe(parent.id);
448
+ expect(body.deployment.input.type).toBe("composite");
449
+ });
450
+
451
+ it("records debrief entries for composite execution", async () => {
452
+ const parent = seedComposite(ctx.deployments, "awaiting_approval");
453
+
454
+ await ctx.app.inject({
455
+ method: "POST",
456
+ url: `/api/operations/${parent.id}/approve`,
457
+ payload: { approvedBy: "ops@example.com" },
458
+ });
459
+
460
+ const entries = ctx.diary.getByOperation(parent.id);
461
+ expect(entries.length).toBeGreaterThan(0);
462
+ const types = entries.map((e) => e.decisionType);
463
+ expect(types).toContain("composite-started");
464
+ expect(types).toContain("composite-completed");
465
+ });
466
+ });
467
+
468
+ // ===========================================================================
469
+ // Artifact version routes (coverage for artifacts.ts lines 153-171, 179-192)
470
+ // ===========================================================================
471
+
472
+ describe("Artifact version routes", () => {
473
+ let ctx: TestContext;
474
+
475
+ beforeEach(async () => {
476
+ ctx = await createTestServer();
477
+ });
478
+
479
+ it("POST /api/artifacts/:id/versions adds a version and returns 201", async () => {
480
+ const createRes = await ctx.app.inject({
481
+ method: "POST",
482
+ url: "/api/artifacts",
483
+ payload: { name: "my-app", type: "docker-image" },
484
+ });
485
+ expect(createRes.statusCode).toBe(201);
486
+ const artifactId = JSON.parse(createRes.payload).artifact.id;
487
+
488
+ const res = await ctx.app.inject({
489
+ method: "POST",
490
+ url: `/api/artifacts/${artifactId}/versions`,
491
+ payload: { version: "1.0.1", source: "docker.io/my-app:1.0.1" },
492
+ });
493
+
494
+ expect(res.statusCode).toBe(201);
495
+ const body = JSON.parse(res.payload);
496
+ expect(body.version).toBeDefined();
497
+ expect(body.version.version).toBe("1.0.1");
498
+ });
499
+
500
+ it("POST /api/artifacts/:id/versions returns 404 for unknown artifact", async () => {
501
+ const res = await ctx.app.inject({
502
+ method: "POST",
503
+ url: "/api/artifacts/nonexistent-id/versions",
504
+ payload: { version: "1.0.0", source: "docker.io/my-app:1.0.0" },
505
+ });
506
+ expect(res.statusCode).toBe(404);
507
+ });
508
+
509
+ it("GET /api/artifacts/:id/versions/:versionId returns a specific version", async () => {
510
+ const createRes = await ctx.app.inject({
511
+ method: "POST",
512
+ url: "/api/artifacts",
513
+ payload: { name: "my-app", type: "docker-image" },
514
+ });
515
+ const artifactId = JSON.parse(createRes.payload).artifact.id;
516
+
517
+ const versionRes = await ctx.app.inject({
518
+ method: "POST",
519
+ url: `/api/artifacts/${artifactId}/versions`,
520
+ payload: { version: "2.0.0", source: "docker.io/my-app:2.0.0" },
521
+ });
522
+ const versionId = JSON.parse(versionRes.payload).version.id;
523
+
524
+ const res = await ctx.app.inject({
525
+ method: "GET",
526
+ url: `/api/artifacts/${artifactId}/versions/${versionId}`,
527
+ });
528
+
529
+ expect(res.statusCode).toBe(200);
530
+ const body = JSON.parse(res.payload);
531
+ expect(body.version.id).toBe(versionId);
532
+ expect(body.version.version).toBe("2.0.0");
533
+ });
534
+
535
+ it("GET /api/artifacts/:id/versions/:versionId returns 404 for unknown artifact", async () => {
536
+ const res = await ctx.app.inject({
537
+ method: "GET",
538
+ url: "/api/artifacts/nonexistent-id/versions/some-version-id",
539
+ });
540
+ expect(res.statusCode).toBe(404);
541
+ });
542
+
543
+ it("GET /api/artifacts/:id/versions/:versionId returns 404 for unknown version", async () => {
544
+ const createRes = await ctx.app.inject({
545
+ method: "POST",
546
+ url: "/api/artifacts",
547
+ payload: { name: "my-app", type: "docker-image" },
548
+ });
549
+ const artifactId = JSON.parse(createRes.payload).artifact.id;
550
+
551
+ const res = await ctx.app.inject({
552
+ method: "GET",
553
+ url: `/api/artifacts/${artifactId}/versions/nonexistent-version-id`,
554
+ });
555
+ expect(res.statusCode).toBe(404);
556
+ });
557
+ });