miravo 0.4.0 → 0.6.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 (161) hide show
  1. package/README.md +1 -1
  2. package/console-dist/assets/activity-DsWeRq4h.js +1 -0
  3. package/console-dist/assets/api-4AqKvsm9.js +1 -0
  4. package/console-dist/assets/arrow-right-Ea_UhTNi.js +1 -0
  5. package/console-dist/assets/arrow-up-down-DFSjzzl2.js +1 -0
  6. package/console-dist/assets/blueprint-card-classes-DBFCDy0M.js +1 -0
  7. package/console-dist/assets/box-C1WaoOMF.js +1 -0
  8. package/console-dist/assets/card-BjeP32PN.js +1 -0
  9. package/console-dist/assets/catalog-BJzyzbLR.js +1 -0
  10. package/console-dist/assets/catalog-DKjEm-c4.js +1 -0
  11. package/console-dist/assets/catalog-template-icons-BrUeHaCY.js +1 -0
  12. package/console-dist/assets/checkbox-DZlJXTE4.js +1 -0
  13. package/console-dist/assets/chunk-OE4NN4TA-CEjpfhS6.js +3 -0
  14. package/console-dist/assets/combobox-BGnuCYxO.js +1 -0
  15. package/console-dist/assets/control-CPhvwx9o.js +1 -0
  16. package/console-dist/assets/control-Cg2up9NA.js +1 -0
  17. package/console-dist/assets/copy-button-WkbNkMcd.js +1 -0
  18. package/console-dist/assets/createLucideIcon-Bwxw859K.js +1 -0
  19. package/console-dist/assets/deploy-tab-JeWDwPoZ.js +1 -0
  20. package/console-dist/assets/dist-BhbDML0a.js +1 -0
  21. package/console-dist/assets/dist-Cy8XTNFT.js +2 -0
  22. package/console-dist/assets/download-DQ6NH16U.js +1 -0
  23. package/console-dist/assets/engine-store-CrLnz692.js +1 -0
  24. package/console-dist/assets/events-CXm29Ok4.js +1 -0
  25. package/console-dist/assets/events-D01IxhnP.js +1 -0
  26. package/console-dist/assets/fleet-CfxbKXt9.js +1 -0
  27. package/console-dist/assets/fleet-lotMGVqe.js +1 -0
  28. package/console-dist/assets/format-kXTonpDJ.js +1 -0
  29. package/console-dist/assets/gauge-Ci8zNZlk.js +1 -0
  30. package/console-dist/assets/get-error-message-3Q5Q-2G5.js +1 -0
  31. package/console-dist/assets/index-CVPKSDvJ.js +12 -0
  32. package/console-dist/assets/index-DJkgDeun.css +2 -0
  33. package/console-dist/assets/input-BWEQFptx.js +1 -0
  34. package/console-dist/assets/instance-detail-C5IVwuh3.js +1 -0
  35. package/console-dist/assets/label-uephX5ZG.js +1 -0
  36. package/console-dist/assets/layers-CUsLQ1fq.js +1 -0
  37. package/console-dist/assets/login-C3RJli0m.js +126 -0
  38. package/console-dist/assets/models-B_UioWQ5.js +1 -0
  39. package/console-dist/assets/models-_IPlPiZ7.js +1 -0
  40. package/console-dist/assets/not-found-B8gAm08S.js +1 -0
  41. package/console-dist/assets/platform-targets-R3T-4eJ7.js +1 -0
  42. package/console-dist/assets/platforms-BWh0BQ3Y.js +1 -0
  43. package/console-dist/assets/platforms-DStNbtVc.js +1 -0
  44. package/console-dist/assets/proxy-CsnYqnPp.js +9 -0
  45. package/console-dist/assets/query-keys-BvtE7BMs.js +1 -0
  46. package/console-dist/assets/radio-CoHegOv5.js +1 -0
  47. package/console-dist/assets/rolldown-runtime-COnpUsM8.js +1 -0
  48. package/console-dist/assets/select-CeO4vP0G.js +1 -0
  49. package/console-dist/assets/select-display-items-CIzY8953.js +1 -0
  50. package/console-dist/assets/server-D9Kfn8fQ.js +1 -0
  51. package/console-dist/assets/shallow-6lGjQhtJ.js +1 -0
  52. package/console-dist/assets/tags-CiXOIHtY.js +4 -0
  53. package/console-dist/assets/tags-FQCXIxrP.js +1 -0
  54. package/console-dist/assets/tooltip-HGLF366Q.js +1 -0
  55. package/console-dist/assets/transition-nav-link-Df4Rk6vV.js +1 -0
  56. package/console-dist/assets/transition-types-CPlGtM8w.js +1 -0
  57. package/console-dist/assets/triangle-alert-adNk_sea.js +1 -0
  58. package/console-dist/assets/use-platform-stream-DGbK1k2L.js +2 -0
  59. package/console-dist/assets/use-transition-navigate-CCpcWXB0.js +2 -0
  60. package/console-dist/assets/validation-Blvud9_O.js +1 -0
  61. package/console-dist/assets/vendor-charts-CAZdaPlq.js +60 -0
  62. package/console-dist/assets/vendor-table-wd8JuMmT.js +4 -0
  63. package/console-dist/assets/vendor-ui-ByahnaY0.js +1 -0
  64. package/console-dist/index.html +70 -2
  65. package/package.json +9 -8
  66. package/src/__tests__/integration/executable-methods-runner.ts +7 -6
  67. package/src/__tests__/integration/executable-methods.integration.ts +214 -122
  68. package/src/__tests__/integration/opcua-concurrent-clients.integration.ts +314 -0
  69. package/src/commands/adapter.ts +180 -7
  70. package/src/commands/apply.test.ts +62 -0
  71. package/src/commands/apply.ts +216 -0
  72. package/src/commands/cleanup.test.ts +34 -0
  73. package/src/commands/cleanup.ts +201 -0
  74. package/src/commands/export.test.ts +51 -0
  75. package/src/commands/export.ts +404 -0
  76. package/src/commands/fork.ts +2 -2
  77. package/src/commands/inject.ts +2 -2
  78. package/src/commands/load.ts +1 -1
  79. package/src/commands/logs.ts +13 -15
  80. package/src/commands/models.test.ts +1 -1
  81. package/src/commands/models.ts +1 -1
  82. package/src/commands/pause.ts +3 -3
  83. package/src/commands/platforms.test.ts +186 -0
  84. package/src/commands/platforms.ts +371 -0
  85. package/src/commands/remove.ts +1 -1
  86. package/src/commands/reset.ts +2 -2
  87. package/src/commands/spawn.ts +2 -2
  88. package/src/commands/speed.ts +2 -2
  89. package/src/commands/start/adapter-config.ts +157 -0
  90. package/src/commands/start/catalog-le-dt.test.ts +50 -0
  91. package/src/commands/start/catalog-le-dt.ts +187 -0
  92. package/src/commands/start/catalog-model-schema.ts +89 -0
  93. package/src/commands/start/catalog-template-detail.ts +54 -0
  94. package/src/commands/start/daemon.ts +255 -0
  95. package/src/commands/start/display.ts +150 -0
  96. package/src/commands/start/engine-setup.ts +247 -0
  97. package/src/commands/start/events.ts +126 -0
  98. package/src/commands/start/index.ts +897 -0
  99. package/src/commands/start/protocol-plans.ts +239 -0
  100. package/src/commands/start/template-loader.ts +197 -0
  101. package/src/commands/start/template-resolver.ts +185 -0
  102. package/src/commands/start/types.ts +73 -0
  103. package/src/commands/start.test.ts +137 -16
  104. package/src/commands/status.ts +1 -1
  105. package/src/commands/stop.ts +19 -8
  106. package/src/commands/templates.test.ts +1 -1
  107. package/src/commands/templates.ts +1 -3
  108. package/src/index.ts +11 -3
  109. package/src/lib/admin-auth.ts +157 -0
  110. package/src/lib/admin-client.test.ts +31 -15
  111. package/src/lib/admin-client.ts +106 -6
  112. package/src/lib/admin-platform-routes.ts +1017 -0
  113. package/src/lib/admin-platform-stream.test.ts +434 -0
  114. package/src/lib/admin-platform-stream.ts +788 -0
  115. package/src/lib/admin-responses.ts +211 -0
  116. package/src/lib/admin-server.test.ts +274 -55
  117. package/src/lib/admin-server.ts +387 -248
  118. package/src/lib/auth-store.ts +87 -0
  119. package/src/lib/daemon-registry.ts +12 -14
  120. package/src/lib/le/le-apply.test.ts +591 -0
  121. package/src/lib/le/le-apply.ts +832 -0
  122. package/src/lib/le/le-auth.ts +89 -0
  123. package/src/lib/le/le-cleanup.test.ts +168 -0
  124. package/src/lib/le/le-cleanup.ts +399 -0
  125. package/src/lib/le/le-cli-helpers.ts +77 -0
  126. package/src/lib/le/le-client.ts +184 -0
  127. package/src/lib/le/le-concurrency.test.ts +157 -0
  128. package/src/lib/le/le-concurrency.ts +78 -0
  129. package/src/lib/le/le-connection-test.ts +42 -0
  130. package/src/lib/le/le-devicehub-properties.test.ts +53 -0
  131. package/src/lib/le/le-devicehub-properties.ts +33 -0
  132. package/src/lib/le/le-devicehub.ts +213 -0
  133. package/src/lib/le/le-digital-twins.ts +285 -0
  134. package/src/lib/le/le-drivers.ts +23 -0
  135. package/src/lib/le/le-endpoint.ts +40 -0
  136. package/src/lib/le/le-environments.test.ts +73 -0
  137. package/src/lib/le/le-environments.ts +156 -0
  138. package/src/lib/le/le-markers.ts +87 -0
  139. package/src/lib/le/le-plan.test.ts +26 -0
  140. package/src/lib/le/le-plan.ts +109 -0
  141. package/src/lib/le/le-platform-operations.test.ts +198 -0
  142. package/src/lib/le/le-platform-operations.ts +340 -0
  143. package/src/lib/le/le-static-model-schema.ts +78 -0
  144. package/src/lib/le/le-static-planner.test.ts +255 -0
  145. package/src/lib/le/le-static-planner.ts +442 -0
  146. package/src/lib/le/le-template-exporter.test.ts +527 -0
  147. package/src/lib/le/le-template-exporter.ts +459 -0
  148. package/src/lib/le/le-value-types.ts +23 -0
  149. package/src/lib/mqtt-client.ts +1 -2
  150. package/src/lib/resolve-simulation.ts +1 -6
  151. package/src/lib/session-store.ts +110 -0
  152. package/src/lib/shutdown.test.ts +184 -2
  153. package/src/lib/shutdown.ts +104 -34
  154. package/src/lib/template-category.ts +3 -3
  155. package/src/lib/template-locator.ts +8 -2
  156. package/src/lib/ws-hub.ts +72 -46
  157. package/src/logger.ts +39 -14
  158. package/src/output.ts +3 -0
  159. package/console-dist/assets/index-C3XMYq26.css +0 -2
  160. package/console-dist/assets/index-hUJSpKtB.js +0 -119
  161. package/src/commands/start.ts +0 -2023
@@ -2,8 +2,35 @@ import { afterEach, describe, expect, test } from "bun:test";
2
2
  import type { EventEmitter } from "node:events";
3
3
  import { createServer } from "node:net";
4
4
  import type { EngineCoreMetrics, EngineStateSnapshot } from "@miravo/core";
5
- import type { CommandResult, EngineCommand } from "@miravo/shared";
5
+ import {
6
+ type CommandResult,
7
+ ConsoleBootstrapResponseSchema,
8
+ type EngineCommand,
9
+ } from "@miravo/shared";
6
10
  import { startAdminServer } from "./admin-server.ts";
11
+ import { createSessionStore } from "./session-store.ts";
12
+
13
+ /** Default test auth config: password "admin", not default. */
14
+ function testAuth() {
15
+ return {
16
+ verifyPassword: async (pw: string) => pw === "testpass",
17
+ isDefaultPassword: false,
18
+ sessionStore: createSessionStore(),
19
+ changePassword: async (_old: string, _new: string) => {},
20
+ };
21
+ }
22
+
23
+ /** Login to the test server and return the session cookie string for use in headers. */
24
+ async function loginAndGetCookie(baseUrl: string, password = "testpass"): Promise<string> {
25
+ const res = await fetch(`${baseUrl}/auth/login`, {
26
+ method: "POST",
27
+ headers: { "content-type": "application/json" },
28
+ body: JSON.stringify({ username: "admin", password }),
29
+ });
30
+ if (!res.ok) throw new Error(`Login failed: ${res.status}`);
31
+ const setCookie = res.headers.get("set-cookie") ?? "";
32
+ return setCookie.split(";")[0] ?? "";
33
+ }
7
34
 
8
35
  async function canBindLocalTcp(): Promise<boolean> {
9
36
  const probe = createServer();
@@ -140,6 +167,12 @@ function createMockAdminEngine(
140
167
  getModelSchema: (_name: string) => undefined,
141
168
  getEventLog: () => ({ events: [], nextCursor: null, hasMore: false }),
142
169
  getRunningTemplates: () => [],
170
+ getManifest: () => ({
171
+ generatedAt: new Date().toISOString(),
172
+ protocols: {},
173
+ tags: [],
174
+ summary: { totalTags: 0, totalInstances: 0, enabledProtocols: [] },
175
+ }),
143
176
  execute: async (command: EngineCommand): Promise<CommandResult> => {
144
177
  runtime.executedCommands.push(command);
145
178
  return {
@@ -187,6 +220,12 @@ interface StartServerOptions {
187
220
  readonly connectedAdapters?: string[];
188
221
  /** Adapter names that should report as enabled in the runtime state. */
189
222
  readonly enabledAdapters?: string[];
223
+ readonly getLeExport?:
224
+ | ((
225
+ id: string,
226
+ opts: { host: string; protocol: import("@miravo/shared").ProtocolName | undefined },
227
+ ) => Promise<{ templateJson: string; tagsCSV: string } | null>)
228
+ | undefined;
190
229
  }
191
230
 
192
231
  async function startTestServer(options: StartServerOptions, handles: Array<{ stop: () => void }>) {
@@ -207,6 +246,8 @@ async function startTestServer(options: StartServerOptions, handles: Array<{ sto
207
246
  protocols: {
208
247
  getLastTickProgressAt: () => mock.getLastTickProgressAt(),
209
248
  },
249
+ auth: testAuth(),
250
+ ...(options.getLeExport ? { getLeExport: options.getLeExport } : {}),
210
251
  });
211
252
  handles.push(handle);
212
253
  const baseUrl = serverBaseUrl(handle.endpoint);
@@ -224,6 +265,7 @@ async function startShutdownTestServer(handles: Array<{ stop: () => void }>): Pr
224
265
  engine: mock.engine,
225
266
  network: { host: "127.0.0.1", port, tickIntervalMs: 1000 },
226
267
  protocols: { getLastTickProgressAt: () => mock.getLastTickProgressAt() },
268
+ auth: testAuth(),
227
269
  requestShutdown: (reason) => {
228
270
  shutdownReason = reason;
229
271
  },
@@ -242,6 +284,8 @@ describeIfTcp("admin server", () => {
242
284
  handles.length = 0;
243
285
  });
244
286
 
287
+ // --- Health endpoints (unauthenticated) ---
288
+
245
289
  test.each([
246
290
  {
247
291
  label: "200 when running and mqtt connected",
@@ -326,11 +370,16 @@ describeIfTcp("admin server", () => {
326
370
  expect(recovered.status).toBe(200);
327
371
  });
328
372
 
373
+ // --- Protected endpoints (require session cookie) ---
374
+
329
375
  test("GET /metrics returns the engine metrics snapshot", async () => {
330
376
  const { mock, baseUrl } = await startTestServer({ state: "running" }, handles);
331
377
  mock.setTickCount(12);
378
+ const cookie = await loginAndGetCookie(baseUrl);
332
379
 
333
- const response = await fetch(`${baseUrl}/metrics`);
380
+ const response = await fetch(`${baseUrl}/metrics`, {
381
+ headers: { cookie },
382
+ });
334
383
  const body = (await response.json()) as {
335
384
  core: { tickCount: number; state: EngineCoreMetrics["state"] };
336
385
  simulationName: string;
@@ -344,8 +393,11 @@ describeIfTcp("admin server", () => {
344
393
 
345
394
  test("GET /state returns the engine state snapshot", async () => {
346
395
  const { baseUrl } = await startTestServer({ state: "running" }, handles);
396
+ const cookie = await loginAndGetCookie(baseUrl);
347
397
 
348
- const response = await fetch(`${baseUrl}/state`);
398
+ const response = await fetch(`${baseUrl}/state`, {
399
+ headers: { cookie },
400
+ });
349
401
  const body = (await response.json()) as EngineStateSnapshot;
350
402
 
351
403
  expect(response.status).toBe(200);
@@ -355,10 +407,11 @@ describeIfTcp("admin server", () => {
355
407
 
356
408
  test("POST /commands validates and dispatches engine commands", async () => {
357
409
  const { mock, baseUrl } = await startTestServer({ state: "running" }, handles);
410
+ const cookie = await loginAndGetCookie(baseUrl);
358
411
 
359
412
  const response = await fetch(`${baseUrl}/commands`, {
360
413
  method: "POST",
361
- headers: { "content-type": "application/json" },
414
+ headers: { "content-type": "application/json", cookie },
362
415
  body: JSON.stringify({
363
416
  type: "pause",
364
417
  correlationId: "req-1",
@@ -372,30 +425,24 @@ describeIfTcp("admin server", () => {
372
425
  expect(mock.getExecutedCommands()).toEqual([{ type: "pause", correlationId: "req-1" }]);
373
426
  });
374
427
 
375
- test("POST /commands requires auth on non-loopback binds", async () => {
376
- const { baseUrl } = await startTestServer({ state: "running", host: "0.0.0.0" }, handles);
377
-
428
+ test("POST /commands requires session auth", async () => {
429
+ const { baseUrl } = await startTestServer({ state: "running" }, handles);
430
+ // Without session cookie
378
431
  const response = await fetch(`${baseUrl}/commands`, {
379
432
  method: "POST",
380
433
  headers: { "content-type": "application/json" },
381
- body: JSON.stringify({
382
- type: "pause",
383
- correlationId: "req-1",
384
- }),
434
+ body: JSON.stringify({ type: "pause", correlationId: "req-1" }),
385
435
  });
386
- const body = (await response.json()) as { message: string; error: string };
387
-
388
- expect(response.status).toBe(403);
389
- expect(body.message).toBe("Mutation endpoints require authentication.");
390
- expect(body.error).toBe("Set MIRAVO_ADMIN_TOKEN when binding to a non-loopback address.");
436
+ expect(response.status).toBe(401);
391
437
  });
392
438
 
393
439
  test("POST /commands returns 400 for invalid JSON", async () => {
394
440
  const { baseUrl } = await startTestServer({ state: "running" }, handles);
441
+ const cookie = await loginAndGetCookie(baseUrl);
395
442
 
396
443
  const response = await fetch(`${baseUrl}/commands`, {
397
444
  method: "POST",
398
- headers: { "content-type": "application/json" },
445
+ headers: { "content-type": "application/json", cookie },
399
446
  body: "{bad json",
400
447
  });
401
448
  const body = (await response.json()) as { message: string };
@@ -406,10 +453,11 @@ describeIfTcp("admin server", () => {
406
453
 
407
454
  test("POST /commands returns 400 for invalid command shape", async () => {
408
455
  const { baseUrl } = await startTestServer({ state: "running" }, handles);
456
+ const cookie = await loginAndGetCookie(baseUrl);
409
457
 
410
458
  const response = await fetch(`${baseUrl}/commands`, {
411
459
  method: "POST",
412
- headers: { "content-type": "application/json" },
460
+ headers: { "content-type": "application/json", cookie },
413
461
  body: JSON.stringify({ type: "setSpeed" }),
414
462
  });
415
463
  const body = (await response.json()) as CommandResult;
@@ -419,6 +467,60 @@ describeIfTcp("admin server", () => {
419
467
  expect(body.command).toBe("setSpeed");
420
468
  });
421
469
 
470
+ test.each([
471
+ { label: "missing host", suffix: "" },
472
+ { label: "blank host", suffix: "?host=%20%20%20" },
473
+ ])("GET /catalog/templates/:id/le-template.json returns 400 for $label", async ({ suffix }) => {
474
+ const { baseUrl } = await startTestServer(
475
+ {
476
+ state: "running",
477
+ getLeExport: async () => ({
478
+ templateJson: JSON.stringify({ ok: true }),
479
+ tagsCSV: "name,value\n",
480
+ }),
481
+ },
482
+ handles,
483
+ );
484
+ const cookie = await loginAndGetCookie(baseUrl);
485
+
486
+ const response = await fetch(
487
+ `${baseUrl}/catalog/templates/smart-factory/le-template.json${suffix}`,
488
+ {
489
+ headers: { cookie },
490
+ },
491
+ );
492
+ const body = (await response.json()) as { message: string; error: string };
493
+
494
+ expect(response.status).toBe(400);
495
+ expect(body.message).toBe("Missing host query parameter.");
496
+ expect(body.error).toContain("?host=<hostname-or-ip>");
497
+ });
498
+
499
+ test("GET /catalog/templates/:id/le-template.json accepts a non-empty host", async () => {
500
+ const { baseUrl } = await startTestServer(
501
+ {
502
+ state: "running",
503
+ getLeExport: async (_id, { host }) => ({
504
+ templateJson: JSON.stringify({ host }),
505
+ tagsCSV: "name,value\n",
506
+ }),
507
+ },
508
+ handles,
509
+ );
510
+ const cookie = await loginAndGetCookie(baseUrl);
511
+
512
+ const response = await fetch(
513
+ `${baseUrl}/catalog/templates/smart-factory/le-template.json?host=172.17.0.1`,
514
+ {
515
+ headers: { cookie },
516
+ },
517
+ );
518
+ const body = (await response.json()) as { host: string };
519
+
520
+ expect(response.status).toBe(200);
521
+ expect(body.host).toBe("172.17.0.1");
522
+ });
523
+
422
524
  // --- Daemon mode endpoints ---
423
525
 
424
526
  test("/readyz includes simulationName and runId when provided", async () => {
@@ -428,6 +530,7 @@ describeIfTcp("admin server", () => {
428
530
  engine: mock.engine,
429
531
  network: { host: "127.0.0.1", port, tickIntervalMs: 1000 },
430
532
  protocols: { getLastTickProgressAt: () => mock.getLastTickProgressAt() },
533
+ auth: testAuth(),
431
534
  simulationName: "test-daemon",
432
535
  runId: "run-abc-123",
433
536
  });
@@ -455,10 +558,11 @@ describeIfTcp("admin server", () => {
455
558
 
456
559
  test("POST /shutdown calls requestShutdown callback and returns 200", async () => {
457
560
  const { baseUrl, getShutdownReason } = await startShutdownTestServer(handles);
561
+ const cookie = await loginAndGetCookie(baseUrl);
458
562
 
459
563
  const response = await fetch(`${baseUrl}/shutdown`, {
460
564
  method: "POST",
461
- headers: { "content-type": "application/json" },
565
+ headers: { "content-type": "application/json", cookie },
462
566
  body: JSON.stringify({ reason: "miravo stop" }),
463
567
  });
464
568
  const body = (await response.json()) as Record<string, unknown>;
@@ -470,10 +574,11 @@ describeIfTcp("admin server", () => {
470
574
 
471
575
  test("POST /shutdown uses default reason when body is empty", async () => {
472
576
  const { baseUrl, getShutdownReason } = await startShutdownTestServer(handles);
577
+ const cookie = await loginAndGetCookie(baseUrl);
473
578
 
474
579
  const response = await fetch(`${baseUrl}/shutdown`, {
475
580
  method: "POST",
476
- headers: { "content-type": "application/json" },
581
+ headers: { "content-type": "application/json", cookie },
477
582
  });
478
583
  expect(response.status).toBe(200);
479
584
  expect(getShutdownReason()).toBe("remote shutdown request");
@@ -486,6 +591,7 @@ describeIfTcp("admin server", () => {
486
591
  engine: mock.engine,
487
592
  network: { host: "127.0.0.1", port, tickIntervalMs: 1000 },
488
593
  protocols: { getLastTickProgressAt: () => mock.getLastTickProgressAt() },
594
+ auth: testAuth(),
489
595
  requestShutdown: () => {},
490
596
  });
491
597
  handles.push(handle);
@@ -497,66 +603,179 @@ describeIfTcp("admin server", () => {
497
603
 
498
604
  test("POST /shutdown returns 501 when requestShutdown not provided", async () => {
499
605
  const { baseUrl } = await startTestServer({ state: "running" }, handles);
606
+ const cookie = await loginAndGetCookie(baseUrl);
500
607
 
501
- const response = await fetch(`${baseUrl}/shutdown`, { method: "POST" });
608
+ const response = await fetch(`${baseUrl}/shutdown`, {
609
+ method: "POST",
610
+ headers: { cookie },
611
+ });
502
612
  expect(response.status).toBe(501);
503
613
  });
504
614
 
505
- test("POST /shutdown requires auth when adminToken is set", async () => {
506
- const port = await reservePort();
507
- const mock = createMockAdminEngine("running", 1000);
508
- const handle = startAdminServer({
509
- engine: mock.engine,
510
- network: { host: "127.0.0.1", port, tickIntervalMs: 1000 },
511
- protocols: { getLastTickProgressAt: () => mock.getLastTickProgressAt() },
512
- requestShutdown: () => {},
513
- adminToken: "secret-token",
514
- });
515
- handles.push(handle);
516
- const baseUrl = serverBaseUrl(handle.endpoint);
517
-
518
- // Without token
615
+ test("POST /shutdown requires session auth", async () => {
616
+ const { baseUrl } = await startShutdownTestServer(handles);
617
+ // Without session cookie
519
618
  const noAuth = await fetch(`${baseUrl}/shutdown`, {
520
619
  method: "POST",
521
620
  headers: { "content-type": "application/json" },
522
621
  });
523
622
  expect(noAuth.status).toBe(401);
524
-
525
- // With token
623
+ // With session cookie
624
+ const cookie = await loginAndGetCookie(baseUrl);
526
625
  const withAuth = await fetch(`${baseUrl}/shutdown`, {
527
626
  method: "POST",
528
- headers: { "content-type": "application/json", authorization: "Bearer secret-token" },
627
+ headers: { "content-type": "application/json", cookie },
529
628
  });
530
629
  expect(withAuth.status).toBe(200);
531
630
  });
532
631
 
533
- test("POST /commands accepts bearer auth on non-loopback binds", async () => {
632
+ test("POST /commands accepts session auth", async () => {
633
+ const { mock, baseUrl } = await startTestServer({ state: "running" }, handles);
634
+ const cookie = await loginAndGetCookie(baseUrl);
635
+ const response = await fetch(`${baseUrl}/commands`, {
636
+ method: "POST",
637
+ headers: { "content-type": "application/json", cookie },
638
+ body: JSON.stringify({ type: "pause", correlationId: "req-2" }),
639
+ });
640
+ const body = (await response.json()) as CommandResult;
641
+ expect(response.status).toBe(200);
642
+ expect(body.success).toBe(true);
643
+ expect(mock.getExecutedCommands()).toEqual([{ type: "pause", correlationId: "req-2" }]);
644
+ });
645
+
646
+ // --- Auth endpoint tests ---
647
+
648
+ test("POST /auth/login returns 200 with valid credentials", async () => {
649
+ const { baseUrl } = await startTestServer({ state: "running" }, handles);
650
+ const res = await fetch(`${baseUrl}/auth/login`, {
651
+ method: "POST",
652
+ headers: { "content-type": "application/json" },
653
+ body: JSON.stringify({ username: "admin", password: "testpass" }),
654
+ });
655
+ expect(res.status).toBe(200);
656
+ const body = (await res.json()) as { ok: boolean; changePassword: boolean };
657
+ expect(body.ok).toBe(true);
658
+ expect(body.changePassword).toBe(false);
659
+ expect(res.headers.get("set-cookie")).toContain("miravo_session=");
660
+ });
661
+
662
+ test("POST /auth/login returns 401 with invalid credentials", async () => {
663
+ const { baseUrl } = await startTestServer({ state: "running" }, handles);
664
+ const res = await fetch(`${baseUrl}/auth/login`, {
665
+ method: "POST",
666
+ headers: { "content-type": "application/json" },
667
+ body: JSON.stringify({ username: "admin", password: "wrong" }),
668
+ });
669
+ expect(res.status).toBe(401);
670
+ });
671
+
672
+ test("GET /auth/status returns authenticated with valid session", async () => {
673
+ const { baseUrl } = await startTestServer({ state: "running" }, handles);
674
+ const cookie = await loginAndGetCookie(baseUrl);
675
+ const res = await fetch(`${baseUrl}/auth/status`, { headers: { cookie } });
676
+ const body = (await res.json()) as { authenticated: boolean; username?: string };
677
+ expect(body.authenticated).toBe(true);
678
+ expect(body.username).toBe("admin");
679
+ });
680
+
681
+ test("GET /auth/status returns unauthenticated without session", async () => {
682
+ const { baseUrl } = await startTestServer({ state: "running" }, handles);
683
+ const res = await fetch(`${baseUrl}/auth/status`);
684
+ const body = (await res.json()) as { authenticated: boolean };
685
+ expect(body.authenticated).toBe(false);
686
+ });
687
+
688
+ test("GET /bootstrap returns unauthenticated without session", async () => {
689
+ const { baseUrl } = await startTestServer({ state: "running" }, handles);
690
+ const res = await fetch(`${baseUrl}/bootstrap`);
691
+ expect(res.status).toBe(200);
692
+ const parsed = ConsoleBootstrapResponseSchema.safeParse(await res.json());
693
+ expect(parsed.success).toBe(true);
694
+ expect(parsed.data).toEqual({ authenticated: false });
695
+ });
696
+
697
+ test("GET /bootstrap returns auth plus canonical runtime snapshot with valid session", async () => {
698
+ const { baseUrl } = await startTestServer({ state: "running" }, handles);
699
+ const cookie = await loginAndGetCookie(baseUrl);
700
+ const res = await fetch(`${baseUrl}/bootstrap`, { headers: { cookie } });
701
+ expect(res.status).toBe(200);
702
+ const parsed = ConsoleBootstrapResponseSchema.safeParse(await res.json());
703
+ expect(parsed.success).toBe(true);
704
+ if (!parsed.success || !parsed.data.authenticated) {
705
+ throw new Error("Expected authenticated bootstrap payload.");
706
+ }
707
+ expect(parsed.data.username).toBe("admin");
708
+ expect(parsed.data.runtime.state.instances.length).toBeGreaterThanOrEqual(0);
709
+ expect(parsed.data.runtime.metrics.core.state).toBe("running");
710
+ expect(Array.isArray(parsed.data.runtime.runningTemplates)).toBe(true);
711
+ expect(typeof parsed.data.runtime.revision).toBe("number");
712
+ });
713
+
714
+ test("GET /state requires session auth", async () => {
715
+ const { baseUrl } = await startTestServer({ state: "running" }, handles);
716
+ const res = await fetch(`${baseUrl}/state`);
717
+ expect(res.status).toBe(401);
718
+ });
719
+ });
720
+
721
+ describeIfTcp("anonymous mode (--no-auth)", () => {
722
+ const handles: Array<{ stop: () => void }> = [];
723
+ afterEach(() => {
724
+ for (const h of handles) h.stop();
725
+ handles.length = 0;
726
+ });
727
+
728
+ async function startAnonServer() {
534
729
  const port = await reservePort();
535
730
  const mock = createMockAdminEngine("running", 1000);
536
731
  const handle = startAdminServer({
537
732
  engine: mock.engine,
538
- network: { host: "0.0.0.0", port, tickIntervalMs: 1000 },
733
+ network: { host: "127.0.0.1", port, tickIntervalMs: 1000 },
539
734
  protocols: { getLastTickProgressAt: () => mock.getLastTickProgressAt() },
540
- adminToken: "secret-token",
735
+ auth: { ...testAuth(), anonymousMode: true },
541
736
  });
542
737
  handles.push(handle);
543
- const baseUrl = serverBaseUrl(handle.endpoint);
738
+ return { baseUrl: serverBaseUrl(handle.endpoint) };
739
+ }
740
+
741
+ test("GET /bootstrap returns authenticated:true without a session cookie", async () => {
742
+ const { baseUrl } = await startAnonServer();
743
+ const res = await fetch(`${baseUrl}/bootstrap`);
744
+ expect(res.status).toBe(200);
745
+ const parsed = ConsoleBootstrapResponseSchema.safeParse(await res.json());
746
+ expect(parsed.success).toBe(true);
747
+ if (!parsed.success || !parsed.data.authenticated) {
748
+ throw new Error("Expected authenticated bootstrap payload.");
749
+ }
750
+ expect(parsed.data.username).toBe("anonymous");
751
+ expect(parsed.data.runtime.metrics.core.state).toBe("running");
752
+ });
544
753
 
545
- const response = await fetch(`${baseUrl}/commands`, {
754
+ test("GET /state succeeds without a session cookie", async () => {
755
+ const { baseUrl } = await startAnonServer();
756
+ const res = await fetch(`${baseUrl}/state`);
757
+ expect(res.status).toBe(200);
758
+ });
759
+
760
+ test("POST /auth/login succeeds with any credentials", async () => {
761
+ const { baseUrl } = await startAnonServer();
762
+ const res = await fetch(`${baseUrl}/auth/login`, {
546
763
  method: "POST",
547
- headers: {
548
- "content-type": "application/json",
549
- authorization: "Bearer secret-token",
550
- },
551
- body: JSON.stringify({
552
- type: "pause",
553
- correlationId: "req-2",
554
- }),
764
+ headers: { "content-type": "application/json" },
765
+ body: JSON.stringify({ username: "whoever", password: "anything" }),
555
766
  });
556
- const body = (await response.json()) as CommandResult;
767
+ expect(res.status).toBe(200);
768
+ const body = (await res.json()) as { ok: boolean; changePassword: boolean };
769
+ expect(body.ok).toBe(true);
770
+ expect(body.changePassword).toBe(false);
771
+ });
557
772
 
558
- expect(response.status).toBe(200);
559
- expect(body.success).toBe(true);
560
- expect(mock.getExecutedCommands()).toEqual([{ type: "pause", correlationId: "req-2" }]);
773
+ test("GET /auth/status returns authenticated:true without a session cookie", async () => {
774
+ const { baseUrl } = await startAnonServer();
775
+ const res = await fetch(`${baseUrl}/auth/status`);
776
+ expect(res.status).toBe(200);
777
+ const body = (await res.json()) as { authenticated: boolean; username?: string };
778
+ expect(body.authenticated).toBe(true);
779
+ expect(body.username).toBe("anonymous");
561
780
  });
562
781
  });