feat: convert network-topology to a full plugin (B1)

This commit is contained in:
LukeGus committed 2026-09-23 07:36:39 -05:00
1 parent 95abe24798
commit fdedca39fd
51 files changed
+30321 -814

No files matched your search

@@ -0,0 +1,81 @@
import { afterEach, describe, expect, it } from "vitest";
import express from "express";
import {
createMockCtx,
createTestDb,
type TestDb,
} from "@termix/plugin-sdk/testing";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import { activate } from "../../src/backend/index.js";
import { manifest, pluginDir, startServer, type TestServer } from "./helpers";
let db: TestDb | null = null;
let server: TestServer | null = null;
afterEach(async () => {
db?.close();
db = null;
await server?.close();
server = null;
});
describe("network-topology activate", () => {
it("registers a singleton sync entity with the JSON blob serializer", async () => {
server = await startServer();
const [entity] = server.mock.syncEntities;
expect(entity.type).toBe("networkTopology");
expect(entity.singleton).toBe(true);
expect(entity.serialize).toBeTypeOf("function");
expect(entity.deserialize).toBeTypeOf("function");
});
it("offers the user's saved graph as a service", async () => {
server = await startServer();
await server.request("POST", "/", {
body: { topology: { nodes: [{ data: { id: "1" } }], edges: [] } },
});
const service = server.mock.services.get("network-topology.graph") as {
get: () => Promise<unknown>;
};
const result = await server.mock.ctx.asUser("user-1", () => service.get());
expect(result).toEqual({ nodes: [{ data: { id: "1" } }], edges: [] });
});
it("returns null from the service when the user has no saved graph", async () => {
server = await startServer();
const service = server.mock.services.get("network-topology.graph") as {
get: () => Promise<unknown>;
};
const result = await server.mock.ctx.asUser("user-1", () => service.get());
expect(result).toBeNull();
});
it("fails closed without db:own", async () => {
db = await createTestDb(pluginDir);
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: ["network:serve", "ui:surface"],
db: db.database,
router: () => express.Router(),
});
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
PluginCapabilityError,
);
});
it("fails closed without network:serve", async () => {
db = await createTestDb(pluginDir);
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: ["db:own", "ui:surface"],
db: db.database,
router: () => express.Router(),
});
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
PluginCapabilityError,
);
});
});
@@ -0,0 +1,91 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { fileURLToPath } from "node:url";
import express, { type Router } from "express";
import {
createMockCtx,
createTestDb,
type MockPluginContext,
type TestDb,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
export const manifest = manifestJson as unknown as PluginManifest;
export interface TestServer {
db: TestDb;
mock: MockPluginContext;
request: (
method: string,
path: string,
options?: { user?: string; body?: unknown },
// Route bodies vary per test; asserting on them is the point.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<{ status: number; body: any }>;
close: () => Promise<void>;
}
/**
* The plugin activated against a real in-memory database, its router mounted
* on an express app the way core mounts it: JSON parsed, the acting user set
* from the request, everything else left to the plugin.
*/
export async function startServer(
options: { permissions?: string[]; users?: string[] } = {},
): Promise<TestServer> {
const db = await createTestDb(pluginDir);
for (const user of options.users ?? ["user-1", "user-2"]) {
db.sqlite
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
.run(user, user);
}
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
permissions: options.permissions ?? ["network-topology.use"],
});
const { activate } = await import("../../src/backend/index.js");
await activate(mock.ctx);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
mock.setActor(req.header("x-test-user") ?? undefined);
next();
});
app.use((req, res, next) => router!(req, res, next));
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
return {
db,
mock,
async request(method, path, { user = "user-1", body: given } = {}) {
const body = method === "GET" ? undefined : given;
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: {
"x-test-user": user,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
return { status: response.status, body: text ? JSON.parse(text) : null };
},
async close() {
await new Promise<void>((resolve) => server.close(() => resolve()));
db.close();
},
};
}
@@ -0,0 +1,95 @@
import { afterEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { pluginDir } from "./helpers";
// network_topology as core's SQLite bootstrap created it before 2.9.0.
const LEGACY_DDL = `
CREATE TABLE network_topology (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
topology TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`;
let db: TestDb | null = null;
afterEach(() => {
db?.close();
db = null;
});
const tableExists = (name: string) =>
!!db!.sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(name);
describe("adopting network_topology", () => {
it("keeps every existing row when the legacy table is there", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
sqlite.exec(LEGACY_DDL);
sqlite
.prepare(
"INSERT INTO network_topology (user_id, topology, updated_at) VALUES (?, ?, ?)",
)
.run(
"user-1",
'{"nodes":[{"data":{"id":"1"}}],"edges":[]}',
"2026-01-01T00:00:00.000Z",
);
},
});
expect(db.applied).toEqual(["0001_adopt_network_topology"]);
expect(tableExists("network_topology")).toBe(false);
expect(tableExists("p_network_topology_graphs")).toBe(true);
const rows = db.sqlite
.prepare(
"SELECT user_id, topology, updated_at FROM p_network_topology_graphs ORDER BY id",
)
.all();
expect(rows).toEqual([
{
user_id: "user-1",
topology: '{"nodes":[{"data":{"id":"1"}}],"edges":[]}',
updated_at: "2026-01-01T00:00:00.000Z",
},
]);
});
it("creates the table fresh when there is no legacy one", async () => {
db = await createTestDb(pluginDir);
expect(tableExists("network_topology")).toBe(false);
expect(tableExists("p_network_topology_graphs")).toBe(true);
expect(
db.sqlite.prepare("SELECT * FROM p_network_topology_graphs").all(),
).toEqual([]);
});
it("removes a user's topology when the user is deleted", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
},
});
const insert = db.sqlite.prepare(
"INSERT INTO p_network_topology_graphs (user_id, topology) VALUES (?, ?)",
);
insert.run("user-1", "{}");
insert.run("user-2", "{}");
db.sqlite.exec("DELETE FROM users WHERE id = 'user-1'");
const remaining = (
db.sqlite
.prepare("SELECT user_id FROM p_network_topology_graphs")
.all() as { user_id: string }[]
).map((row) => row.user_id);
expect(remaining).toEqual(["user-2"]);
});
});
@@ -1,73 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "../../../../../src/backend/tests/database/repositories/test-support.js";
import { NetworkTopologyRepository } from "../../../../../src/backend/database/repositories/network-topology-repository.js";
describe("NetworkTopologyRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<NetworkTopologyRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
`);
return new NetworkTopologyRepository(context, onWrite);
}
it("finds, creates, and updates topology by user id", async () => {
const repo = await createRepository();
expect(await repo.findByUserId("user-1")).toBeNull();
await repo.upsertForUser(
"user-1",
JSON.stringify({ nodes: [{ id: "host-1" }], edges: [] }),
"2026-06-27T00:00:00.000Z",
);
expect(await repo.findByUserId("user-1")).toMatchObject({
userId: "user-1",
topology: '{"nodes":[{"id":"host-1"}],"edges":[]}',
updatedAt: "2026-06-27T00:00:00.000Z",
});
await repo.upsertForUser(
"user-1",
JSON.stringify({ nodes: [], edges: [{ id: "edge-1" }] }),
"2026-06-27T01:00:00.000Z",
);
expect(await repo.findByUserId("user-1")).toMatchObject({
userId: "user-1",
topology: '{"nodes":[],"edges":[{"id":"edge-1"}]}',
updatedAt: "2026-06-27T01:00:00.000Z",
});
});
it("deletes topology and only triggers writes for changed rows", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.upsertForUser("user-1", "{}");
await repo.upsertForUser("user-1", '{"nodes":[]}');
expect(writeCount).toBe(2);
expect(await repo.deleteByUserId("missing")).toBe(0);
expect(writeCount).toBe(2);
expect(await repo.deleteByUserId("user-1")).toBe(1);
expect(writeCount).toBe(3);
expect(await repo.findByUserId("user-1")).toBeNull();
});
});
@@ -0,0 +1,55 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { graphs } from "../../src/backend/tables.js";
import {
createGraphRepository,
type GraphRepository,
} from "../../src/backend/repository.js";
import { pluginDir } from "./helpers";
let db: TestDb;
let repo: GraphRepository;
beforeEach(async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) =>
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')"),
});
repo = createGraphRepository(db.database, await db.database.define(graphs));
});
afterEach(() => db.close());
describe("graph repository", () => {
it("finds, creates, and updates topology by user id", async () => {
expect(await repo.findByUserId("user-1")).toBeNull();
await repo.upsertForUser(
"user-1",
JSON.stringify({ nodes: [{ id: "host-1" }], edges: [] }),
);
expect(await repo.findByUserId("user-1")).toMatchObject({
userId: "user-1",
topology: '{"nodes":[{"id":"host-1"}],"edges":[]}',
});
await repo.upsertForUser(
"user-1",
JSON.stringify({ nodes: [], edges: [{ id: "edge-1" }] }),
);
expect(await repo.findByUserId("user-1")).toMatchObject({
userId: "user-1",
topology: '{"nodes":[],"edges":[{"id":"edge-1"}]}',
});
});
it("keeps rows for different users separate", async () => {
await repo.upsertForUser("user-1", '{"nodes":[]}');
await repo.upsertForUser("user-2", '{"nodes":["a"]}');
expect((await repo.findByUserId("user-1"))?.topology).toBe('{"nodes":[]}');
expect((await repo.findByUserId("user-2"))?.topology).toBe(
'{"nodes":["a"]}',
);
});
});
@@ -1,154 +1,81 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Request, Response, Router } from "express";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { startServer, type TestServer } from "./helpers";
const state = vi.hoisted(() => ({
currentUserId: "user-1" as string | undefined,
records: new Map<string, { userId: string; topology: string | null }>(),
}));
let server: TestServer;
vi.mock("../../../../src/backend/database/db/index.js", () => ({ db: {} }));
vi.mock("../../../../src/backend/utils/logger.js", () => ({
databaseLogger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("../../../../src/backend/utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() =>
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
req.userId = state.currentUserId;
next();
},
}),
},
}));
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
createCurrentNetworkTopologyRepository: () => ({
findByUserId: async (userId: string) => state.records.get(userId) ?? null,
upsertForUser: async (userId: string, topology: string) => {
state.records.set(userId, { userId, topology });
},
}),
}));
vi.mock(
"../../../../src/backend/database/routes/network-topology-dispatch.js",
() => ({
registerNetworkTopologyRouter: vi.fn(),
unregisterNetworkTopologyRouter: vi.fn(),
}),
);
const { router } = await import("../../src/backend/routes.js");
function findLayer(method: string, path: string) {
const stack = (router as unknown as Router).stack as Array<{
route?: {
path: string;
methods: Record<string, boolean>;
stack: Array<{ handle: (req: Request, res: Response) => unknown }>;
};
}>;
const layer = stack.find(
(l) => l.route?.path === path && l.route?.methods[method],
);
if (!layer?.route) throw new Error(`No route for ${method} ${path}`);
return layer.route.stack[layer.route.stack.length - 1].handle;
}
function makeReqRes(overrides: { body?: Record<string, unknown> }) {
const req = {
userId: state.currentUserId,
body: overrides.body ?? {},
headers: {},
} as unknown as Request;
const res = {
statusCode: 200,
jsonBody: null as unknown,
status(code: number) {
(this as unknown as { statusCode: number }).statusCode = code;
return this;
},
json(payload: unknown) {
(this as unknown as { jsonBody: unknown }).jsonBody = payload;
return this;
},
} as unknown as Response & { statusCode: number; jsonBody: unknown };
return { req, res };
}
async function invoke(
method: string,
path: string,
overrides: { body?: Record<string, unknown> } = {},
) {
const handler = findLayer(method, path);
const { req, res } = makeReqRes(overrides);
await handler(req, res);
return res as unknown as {
statusCode: number;
jsonBody: Record<string, unknown> | null;
};
}
beforeEach(() => {
state.currentUserId = "user-1";
state.records = new Map();
beforeEach(async () => {
server = await startServer();
});
describe("GET /network-topology", () => {
afterEach(async () => {
await server.close();
});
describe("GET /", () => {
it("returns null when the user has no saved topology", async () => {
const res = await invoke("get", "/");
expect(res.jsonBody).toBeNull();
const res = await server.request("GET", "/");
expect(res.status).toBe(200);
expect(res.body).toBeNull();
});
it("returns the parsed topology when one exists", async () => {
state.records.set("user-1", {
userId: "user-1",
topology: JSON.stringify({ nodes: [{ id: "host-1" }], edges: [] }),
it("returns the saved topology", async () => {
await server.request("POST", "/", {
body: { topology: { nodes: [{ data: { id: "host-1" } }], edges: [] } },
});
const res = await invoke("get", "/");
expect(res.jsonBody).toEqual({ nodes: [{ id: "host-1" }], edges: [] });
const res = await server.request("GET", "/");
expect(res.body).toEqual({
nodes: [{ data: { id: "host-1" } }],
edges: [],
});
});
it("rejects an unauthenticated request", async () => {
state.currentUserId = undefined;
const res = await invoke("get", "/");
expect(res.statusCode).toBe(401);
it("keeps each user's topology separate", async () => {
await server.request("POST", "/", {
user: "user-1",
body: { topology: { nodes: [{ data: { id: "a" } }], edges: [] } },
});
const res = await server.request("GET", "/", { user: "user-2" });
expect(res.body).toBeNull();
});
});
describe("POST /network-topology", () => {
it("saves the topology and returns success", async () => {
const res = await invoke("post", "/", {
describe("POST /", () => {
it("saves and overwrites the topology", async () => {
const first = await server.request("POST", "/", {
body: { topology: { nodes: [], edges: [] } },
});
expect(first.body).toEqual({ success: true });
expect(res.jsonBody).toEqual({ success: true });
expect(state.records.get("user-1")?.topology).toBe(
JSON.stringify({ nodes: [], edges: [] }),
);
await server.request("POST", "/", {
body: { topology: { nodes: [{ data: { id: "a" } }], edges: [] } },
});
const res = await server.request("GET", "/");
expect(res.body).toEqual({ nodes: [{ data: { id: "a" } }], edges: [] });
});
it("rejects a request with no topology", async () => {
const res = await invoke("post", "/", { body: {} });
expect(res.statusCode).toBe(400);
const res = await server.request("POST", "/", { body: {} });
expect(res.status).toBe(400);
});
});
it("rejects an unauthenticated request", async () => {
state.currentUserId = undefined;
const res = await invoke("post", "/", { body: { topology: {} } });
expect(res.statusCode).toBe(401);
describe("permissions", () => {
it("refuses every route without network-topology.use", async () => {
const denied = await startServer({ permissions: [] });
try {
expect((await denied.request("GET", "/")).status).toBe(403);
expect(
(
await denied.request("POST", "/", {
body: { topology: { nodes: [], edges: [] } },
})
).status,
).toBe(403);
} finally {
await denied.close();
}
});
});