595 lines
18 KiB
TypeScript
595 lines
18 KiB
TypeScript
import { existsSync, rmSync } from "node:fs";
|
|
import { mkdtemp } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, describe, expect, test } from "vitest";
|
|
import type { DashboardDocument } from "$lib/model";
|
|
import { genericDashboardFixture } from "$lib/model/fixtures/generic";
|
|
import { createDashboardStore, type DashboardStore } from "$lib/server/db/dashboard-store";
|
|
import {
|
|
AgentConfigAuthorizationError,
|
|
authorizeAgentConfigRequest,
|
|
handleAgentDashboardRequest,
|
|
previewDashboardChanges,
|
|
publishDashboardChanges,
|
|
rollbackDashboardRevision,
|
|
type AgentDashboardOperation,
|
|
type JsonPatchOperation,
|
|
} from ".";
|
|
|
|
const stores: DashboardStore[] = [];
|
|
const tempRoots: string[] = [];
|
|
|
|
afterEach(() => {
|
|
stores.splice(0).forEach((store) => store.close());
|
|
tempRoots.splice(0).forEach((path) => rmSync(path, { force: true, recursive: true }));
|
|
});
|
|
|
|
describe("agent dashboard configuration API", () => {
|
|
test("previews typed dashboard operations with an RFC 6902-compatible patch", () => {
|
|
const operations = exampleOperations();
|
|
|
|
const result = previewDashboardChanges(genericDashboardFixture, operations);
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) throw new Error("expected preview success");
|
|
expect(result.document.serviceGroups[0]?.id).toBe("edge");
|
|
expect(result.document.serviceGroups[0]?.services[0]).toMatchObject({
|
|
id: "edge-router",
|
|
datasource: {
|
|
type: "external",
|
|
adapter: "http-status",
|
|
reference: "GET https://edge.example.test/api/status",
|
|
},
|
|
});
|
|
expect(result.document.layout.telemetry).toEqual([
|
|
"service-uptime",
|
|
"edge-latency",
|
|
"queue-depth",
|
|
]);
|
|
expect(genericDashboardFixture.serviceGroups.map((group) => group.id)).toEqual([
|
|
"core-services",
|
|
]);
|
|
expect(result.patch.length).toBeGreaterThan(0);
|
|
expect(result.patch.every((operation) => operation.path.startsWith("/"))).toBe(true);
|
|
expect(result.patch.map((operation) => operation.op)).toContain("add");
|
|
});
|
|
|
|
test("returns structured repairable errors for invalid operations", () => {
|
|
const result = previewDashboardChanges(genericDashboardFixture, [
|
|
{
|
|
type: "add_service",
|
|
groupId: "missing-group",
|
|
service: exampleService(),
|
|
},
|
|
]);
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) throw new Error("expected preview failure");
|
|
expect(result.errors).toEqual([
|
|
expect.objectContaining({
|
|
code: "group_not_found",
|
|
operationIndex: 0,
|
|
path: "/serviceGroups",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
test("rejects unsupported target-specific mutations", () => {
|
|
const datasourceResult = previewDashboardChanges(genericDashboardFixture, [
|
|
{
|
|
type: "connect_datasource",
|
|
target: { kind: "statusItem", stripId: "runtime", id: "status" },
|
|
datasource: {
|
|
type: "external",
|
|
adapter: "http-status",
|
|
reference: "GET https://status.example.test/api",
|
|
},
|
|
},
|
|
]);
|
|
expect(datasourceResult.ok).toBe(false);
|
|
if (datasourceResult.ok) throw new Error("expected datasource failure");
|
|
expect(datasourceResult.errors[0]).toMatchObject({
|
|
code: "unsupported_target",
|
|
operationIndex: 0,
|
|
path: "/statusStrips",
|
|
});
|
|
|
|
const thresholdResult = previewDashboardChanges(genericDashboardFixture, [
|
|
{
|
|
type: "set_status_rule",
|
|
target: { kind: "service", id: "identity" },
|
|
thresholds: { warning: 1 },
|
|
},
|
|
]);
|
|
expect(thresholdResult.ok).toBe(false);
|
|
if (thresholdResult.ok) throw new Error("expected threshold failure");
|
|
expect(thresholdResult.errors[0]).toMatchObject({
|
|
code: "unsupported_target",
|
|
operationIndex: 0,
|
|
path: "/serviceGroups",
|
|
});
|
|
});
|
|
|
|
test("removes status strip items through the shared remove operation", () => {
|
|
const result = previewDashboardChanges(genericDashboardFixture, [
|
|
{
|
|
type: "remove_item",
|
|
target: { kind: "statusItem", stripId: "runtime", id: "sync" },
|
|
},
|
|
]);
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) throw new Error("expected remove success");
|
|
expect(result.document.statusStrips[0]?.items.map((item) => item.id)).toEqual([
|
|
"status",
|
|
]);
|
|
expect(result.patch.map((operation) => operation.op)).toContain("remove");
|
|
});
|
|
|
|
test("returns RFC 6902-applicable patches for multiple array removals", () => {
|
|
const result = previewDashboardChanges(genericDashboardFixture, [
|
|
{
|
|
type: "remove_item",
|
|
target: { kind: "statusItem", stripId: "runtime", id: "status" },
|
|
},
|
|
{
|
|
type: "remove_item",
|
|
target: { kind: "statusItem", stripId: "runtime", id: "sync" },
|
|
},
|
|
]);
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) throw new Error("expected remove success");
|
|
expect(
|
|
result.patch
|
|
.filter((operation) => operation.op === "remove")
|
|
.map((operation) => operation.path),
|
|
).toEqual(["/statusStrips/0/items/1", "/statusStrips/0/items/0"]);
|
|
expect(applyJsonPatch(genericDashboardFixture, result.patch)).toMatchObject({
|
|
statusStrips: [{ items: [] }],
|
|
});
|
|
});
|
|
|
|
test("rejects ambiguous service and status targets", () => {
|
|
const duplicateDocument = documentWithDuplicateNestedIds();
|
|
|
|
const connect = previewDashboardChanges(duplicateDocument, [
|
|
{
|
|
type: "connect_datasource",
|
|
target: { kind: "service", id: "identity" },
|
|
datasource: {
|
|
type: "external",
|
|
adapter: "http-status",
|
|
reference: "GET https://identity.example.test/health",
|
|
},
|
|
},
|
|
]);
|
|
expect(connect.ok).toBe(false);
|
|
if (connect.ok) throw new Error("expected ambiguous service failure");
|
|
expect(connect.errors[0]).toMatchObject({
|
|
code: "ambiguous_target",
|
|
operationIndex: 0,
|
|
path: "/serviceGroups",
|
|
});
|
|
|
|
const remove = previewDashboardChanges(duplicateDocument, [
|
|
{
|
|
type: "remove_item",
|
|
target: { kind: "service", id: "identity" },
|
|
},
|
|
]);
|
|
expect(remove.ok).toBe(false);
|
|
if (remove.ok) throw new Error("expected ambiguous removal failure");
|
|
expect(remove.errors[0]).toMatchObject({
|
|
code: "ambiguous_target",
|
|
operationIndex: 0,
|
|
path: "/serviceGroups",
|
|
});
|
|
|
|
const status = previewDashboardChanges(duplicateDocument, [
|
|
{
|
|
type: "set_status_rule",
|
|
target: { kind: "statusItem", id: "status" },
|
|
value: "Healthy",
|
|
},
|
|
]);
|
|
expect(status.ok).toBe(false);
|
|
if (status.ok) throw new Error("expected ambiguous status failure");
|
|
expect(status.errors[0]).toMatchObject({
|
|
code: "ambiguous_target",
|
|
operationIndex: 0,
|
|
path: "/statusStrips",
|
|
});
|
|
});
|
|
|
|
test("rejects create_dashboard when it is not the first operation", () => {
|
|
const result = previewDashboardChanges(genericDashboardFixture, [
|
|
{
|
|
type: "remove_item",
|
|
target: { kind: "telemetry", id: "queue-depth" },
|
|
},
|
|
{
|
|
type: "create_dashboard",
|
|
document: genericDashboardFixture,
|
|
},
|
|
]);
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) throw new Error("expected sequence failure");
|
|
expect(result.errors[0]).toMatchObject({
|
|
code: "invalid_operation_sequence",
|
|
operationIndex: 1,
|
|
path: "/1",
|
|
});
|
|
});
|
|
|
|
test("publishes valid operations as a persisted dashboard revision", async () => {
|
|
const { dbPath, store } = await createTestStore();
|
|
const seed = store.seedDashboardIfEmpty(genericDashboardFixture, {
|
|
actor: "seed",
|
|
message: "initial dashboard",
|
|
});
|
|
|
|
const result = publishDashboardChanges(store, exampleOperations(), {
|
|
actor: "agent",
|
|
message: "add edge router",
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) throw new Error("expected publish success");
|
|
expect(existsSync(dbPath)).toBe(true);
|
|
expect(result.revision.operation).toBe("commit");
|
|
expect(result.revision.actor).toBe("agent");
|
|
expect(result.revision.message).toBe("add edge router");
|
|
expect(result.previousRevisionId).toBe(seed.id);
|
|
expect(result.patch.length).toBeGreaterThan(0);
|
|
expect(store.getActiveDashboard()?.document.serviceGroups[0]?.id).toBe("edge");
|
|
expect(store.listRevisions()).toHaveLength(2);
|
|
});
|
|
|
|
test("does not publish invalid operations", async () => {
|
|
const { store } = await createTestStore();
|
|
const seed = store.seedDashboardIfEmpty(genericDashboardFixture, {
|
|
actor: "seed",
|
|
});
|
|
|
|
const result = publishDashboardChanges(store, [
|
|
{
|
|
type: "remove_item",
|
|
target: { kind: "telemetry", id: "missing-metric" },
|
|
},
|
|
]);
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) throw new Error("expected publish failure");
|
|
expect(result.errors[0]).toMatchObject({
|
|
code: "item_not_found",
|
|
operationIndex: 0,
|
|
});
|
|
expect(store.getActiveDashboard()?.currentRevisionId).toBe(seed.id);
|
|
expect(store.listRevisions()).toHaveLength(1);
|
|
});
|
|
|
|
test("rolls back through the agent-safe revision path", async () => {
|
|
const { store } = await createTestStore();
|
|
const seed = store.seedDashboardIfEmpty(genericDashboardFixture, {
|
|
actor: "seed",
|
|
});
|
|
const publish = publishDashboardChanges(store, exampleOperations(), {
|
|
actor: "agent",
|
|
});
|
|
expect(publish.ok).toBe(true);
|
|
|
|
const rollback = rollbackDashboardRevision(store, seed.id, {
|
|
actor: "agent",
|
|
message: "restore previous dashboard",
|
|
});
|
|
|
|
expect(rollback.operation).toBe("rollback");
|
|
expect(rollback.actor).toBe("agent");
|
|
expect(rollback.sourceRevisionId).toBe(seed.id);
|
|
expect(store.getActiveDashboard()?.document.metadata.title).toBe("Operations Console");
|
|
expect(store.listRevisions().map((revision) => revision.operation)).toEqual([
|
|
"rollback",
|
|
"commit",
|
|
"seed",
|
|
]);
|
|
});
|
|
|
|
test("authenticates agent configuration requests with a shared token", () => {
|
|
const authorized = new Request("https://dimensionlab.test/api/agent/dashboard", {
|
|
headers: { authorization: "Bearer shared-secret" },
|
|
});
|
|
const rejected = new Request("https://dimensionlab.test/api/agent/dashboard");
|
|
|
|
expect(authorizeAgentConfigRequest(authorized, "shared-secret")).toEqual({
|
|
ok: true,
|
|
});
|
|
expect(() => authorizeAgentConfigRequest(rejected, "shared-secret")).toThrow(
|
|
AgentConfigAuthorizationError,
|
|
);
|
|
expect(() => authorizeAgentConfigRequest(authorized, "")).toThrow(
|
|
AgentConfigAuthorizationError,
|
|
);
|
|
});
|
|
|
|
test("handles preview, publish, and rollback requests through the HTTP adapter", async () => {
|
|
const { store } = await createTestStore();
|
|
const seed = store.seedDashboardIfEmpty(genericDashboardFixture, {
|
|
actor: "seed",
|
|
});
|
|
|
|
const preview = await handleAgentDashboardRequest(
|
|
jsonRequest({
|
|
action: "preview_changes",
|
|
operations: exampleOperations(),
|
|
}),
|
|
{ store, token: "shared-secret" },
|
|
);
|
|
expect(preview.status).toBe(200);
|
|
expect(await preview.json()).toMatchObject({
|
|
ok: true,
|
|
action: "preview_changes",
|
|
patch: expect.any(Array),
|
|
});
|
|
expect(store.listRevisions()).toHaveLength(1);
|
|
|
|
const publish = await handleAgentDashboardRequest(
|
|
jsonRequest({
|
|
action: "publish_changes",
|
|
actor: "agent",
|
|
message: "publish edge router",
|
|
operations: exampleOperations(),
|
|
}),
|
|
{ store, token: "shared-secret" },
|
|
);
|
|
expect(publish.status).toBe(200);
|
|
expect(await publish.json()).toMatchObject({
|
|
ok: true,
|
|
action: "publish_changes",
|
|
revision: { operation: "commit", actor: "agent" },
|
|
});
|
|
expect(store.listRevisions()).toHaveLength(2);
|
|
|
|
const rollback = await handleAgentDashboardRequest(
|
|
jsonRequest({
|
|
action: "rollback_revision",
|
|
actor: "agent",
|
|
revisionId: seed.id,
|
|
}),
|
|
{ store, token: "shared-secret" },
|
|
);
|
|
expect(rollback.status).toBe(200);
|
|
expect(await rollback.json()).toMatchObject({
|
|
ok: true,
|
|
action: "rollback_revision",
|
|
revision: { operation: "rollback", sourceRevisionId: seed.id },
|
|
});
|
|
});
|
|
|
|
test("returns a structured error for unknown rollback revisions", async () => {
|
|
const { store } = await createTestStore();
|
|
store.seedDashboardIfEmpty(genericDashboardFixture, {
|
|
actor: "seed",
|
|
});
|
|
|
|
const rollback = await handleAgentDashboardRequest(
|
|
jsonRequest({
|
|
action: "rollback_revision",
|
|
actor: "agent",
|
|
revisionId: "missing-revision",
|
|
}),
|
|
{ store, token: "shared-secret" },
|
|
);
|
|
|
|
expect(rollback.status).toBe(404);
|
|
expect(await rollback.json()).toMatchObject({
|
|
ok: false,
|
|
errors: [
|
|
{
|
|
code: "revision_not_found",
|
|
path: "/revisionId",
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("previews create_dashboard requests against an empty store", async () => {
|
|
const { store } = await createTestStore();
|
|
|
|
const preview = await handleAgentDashboardRequest(
|
|
jsonRequest({
|
|
action: "preview_changes",
|
|
operations: [
|
|
{
|
|
type: "create_dashboard",
|
|
document: genericDashboardFixture,
|
|
},
|
|
],
|
|
}),
|
|
{ store, token: "shared-secret" },
|
|
);
|
|
|
|
expect(preview.status).toBe(200);
|
|
expect(await preview.json()).toMatchObject({
|
|
ok: true,
|
|
action: "preview_changes",
|
|
document: {
|
|
metadata: {
|
|
title: "Operations Console",
|
|
},
|
|
},
|
|
});
|
|
expect(store.listRevisions()).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
async function createTestStore() {
|
|
const root = await mkdtemp(join(tmpdir(), "dimensionlab-agent-config-"));
|
|
tempRoots.push(root);
|
|
const dbPath = join(root, "dashboard.sqlite");
|
|
const store = createDashboardStore({
|
|
databaseUrl: `file:${dbPath}`,
|
|
});
|
|
stores.push(store);
|
|
|
|
return { dbPath, store };
|
|
}
|
|
|
|
function exampleOperations(): AgentDashboardOperation[] {
|
|
return [
|
|
{
|
|
type: "add_section",
|
|
section: {
|
|
id: "edge",
|
|
title: "Edge",
|
|
layout: "list",
|
|
},
|
|
},
|
|
{
|
|
type: "add_service",
|
|
groupId: "edge",
|
|
service: exampleService(),
|
|
},
|
|
{
|
|
type: "add_metric_card",
|
|
card: {
|
|
id: "edge-latency",
|
|
label: "Edge Latency",
|
|
value: { kind: "latency", value: 12, precision: 0 },
|
|
severity: "ok",
|
|
detail: "p95",
|
|
datasource: {
|
|
type: "external",
|
|
adapter: "prometheus",
|
|
reference: "histogram_quantile(0.95, edge_request_duration_seconds_bucket)",
|
|
},
|
|
},
|
|
position: { afterId: "service-uptime" },
|
|
},
|
|
{
|
|
type: "connect_datasource",
|
|
target: {
|
|
kind: "service",
|
|
groupId: "edge",
|
|
id: "edge-router",
|
|
},
|
|
datasource: {
|
|
type: "external",
|
|
adapter: "http-status",
|
|
reference: "GET https://edge.example.test/api/status",
|
|
},
|
|
},
|
|
{
|
|
type: "set_status_rule",
|
|
target: { kind: "telemetry", id: "edge-latency" },
|
|
thresholds: { warning: 50, danger: 100 },
|
|
},
|
|
{
|
|
type: "arrange_item",
|
|
area: "serviceGroups",
|
|
id: "edge",
|
|
index: 0,
|
|
},
|
|
];
|
|
}
|
|
|
|
function exampleService() {
|
|
return {
|
|
id: "edge-router",
|
|
label: "Edge Router",
|
|
description: "Ingress and routing",
|
|
icon: "mdi:router-network",
|
|
severity: "ok" as const,
|
|
detail: "pending datasource",
|
|
datasource: { type: "placeholder" as const, reason: "health adapter pending" },
|
|
link: {
|
|
href: "https://edge.example.test",
|
|
label: "Open Edge Router",
|
|
external: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
function documentWithDuplicateNestedIds(): DashboardDocument {
|
|
const document = structuredClone(genericDashboardFixture);
|
|
document.layout.serviceGroups.push("secondary-services");
|
|
document.serviceGroups.push({
|
|
id: "secondary-services",
|
|
title: "Secondary Services",
|
|
layout: "list",
|
|
services: [
|
|
{
|
|
...document.serviceGroups[0].services[0],
|
|
label: "Shadow Identity",
|
|
},
|
|
],
|
|
});
|
|
document.layout.statusStrips.push("secondary-runtime");
|
|
document.statusStrips.push({
|
|
id: "secondary-runtime",
|
|
items: [
|
|
{
|
|
...document.statusStrips[0].items[0],
|
|
value: "Healthy",
|
|
},
|
|
],
|
|
});
|
|
return document;
|
|
}
|
|
|
|
function applyJsonPatch<T>(value: T, patch: JsonPatchOperation[]): T {
|
|
const next = structuredClone(value);
|
|
for (const operation of patch) {
|
|
const { parent, key } = jsonPointerTarget(next, operation.path);
|
|
if (operation.op === "remove") {
|
|
if (Array.isArray(parent)) {
|
|
parent.splice(Number(key), 1);
|
|
} else {
|
|
delete parent[key];
|
|
}
|
|
} else if (operation.op === "add") {
|
|
if (Array.isArray(parent)) {
|
|
parent.splice(Number(key), 0, operation.value);
|
|
} else {
|
|
parent[key] = operation.value;
|
|
}
|
|
} else if (operation.op === "replace") {
|
|
if (Array.isArray(parent)) {
|
|
parent[Number(key)] = operation.value;
|
|
} else {
|
|
parent[key] = operation.value;
|
|
}
|
|
}
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function jsonPointerTarget(value: unknown, path: string) {
|
|
const segments = path
|
|
.split("/")
|
|
.slice(1)
|
|
.map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"));
|
|
const key = segments.pop();
|
|
if (key === undefined) throw new Error(`Invalid JSON pointer: ${path}`);
|
|
|
|
let parent = value as Record<string, unknown> | unknown[];
|
|
for (const segment of segments) {
|
|
parent = Array.isArray(parent)
|
|
? (parent[Number(segment)] as Record<string, unknown> | unknown[])
|
|
: (parent[segment] as Record<string, unknown> | unknown[]);
|
|
}
|
|
return { parent, key };
|
|
}
|
|
|
|
function jsonRequest(body: unknown) {
|
|
return new Request("https://dimensionlab.test/api/agent/dashboard", {
|
|
body: JSON.stringify(body),
|
|
headers: {
|
|
authorization: "Bearer shared-secret",
|
|
"content-type": "application/json",
|
|
},
|
|
method: "POST",
|
|
});
|
|
}
|