feat: finish the homepage plugin, move service links and canvas out of core (B19)

This commit is contained in:
LukeGus committed 2026-09-24 15:07:25 -05:00
1 parent 21937a6b02
commit d4ec444558
256 files changed
+27365 -9077

No files matched your search

+100
View File
@@ -0,0 +1,100 @@
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[];
fetch?: (url: string, init?: { method?: string }) => Promise<Response>;
} = {},
): 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 ?? ["homepage.use"],
fetch: options.fetch,
});
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 contentType = response.headers.get("content-type") ?? "";
if (!contentType.includes("json")) {
return { status: response.status, body: await response.text() };
}
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,176 @@
import { afterEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { pluginDir } from "./helpers";
// The three tables as core's SQLite bootstrap created them before 2.9.0,
// plus the homepage_items index performance-indexes.ts added.
const LEGACY_DDL = `
CREATE TABLE homepage_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type_id TEXT NOT NULL,
title TEXT,
config TEXT NOT NULL DEFAULT '{}',
folder_id INTEGER,
sync_id TEXT UNIQUE,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_homepage_items_user_id ON homepage_items (user_id);
CREATE TABLE homepage_layouts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
layout TEXT NOT NULL DEFAULT '{}',
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE dashboard_service_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
label TEXT NOT NULL,
url TEXT NOT NULL,
"order" INTEGER NOT NULL DEFAULT 0,
sync_id TEXT UNIQUE,
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);
const indexOn = (table: string) =>
(
db!.sqlite
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
)
.all(table) as { name: string }[]
).map((row) => row.name);
describe("adopting the homepage tables", () => {
it("keeps every existing row when the legacy tables are 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 homepage_items (user_id, type_id, title, config, sync_id) VALUES (?, ?, ?, ?, ?)",
)
.run("user-1", "clock", "My Clock", '{"format":"24h"}', "sync-item");
sqlite
.prepare(
"INSERT INTO homepage_layouts (user_id, layout) VALUES (?, ?)",
)
.run("user-1", '{"entries":[],"pan":{"x":0,"y":0},"zoom":1}');
sqlite
.prepare(
"INSERT INTO dashboard_service_links (user_id, label, url, sync_id) VALUES (?, ?, ?, ?)",
)
.run("user-1", "Grafana", "https://grafana.local", "sync-link");
},
});
expect(db.applied).toEqual(["0001_adopt_homepage_tables"]);
expect(tableExists("homepage_items")).toBe(false);
expect(tableExists("homepage_layouts")).toBe(false);
expect(tableExists("dashboard_service_links")).toBe(false);
expect(tableExists("p_homepage_homepage_items")).toBe(true);
expect(tableExists("p_homepage_homepage_layouts")).toBe(true);
expect(tableExists("p_homepage_dashboard_service_links")).toBe(true);
const items = db.sqlite
.prepare(
"SELECT user_id, type_id, title, sync_id FROM p_homepage_homepage_items",
)
.all();
expect(items).toEqual([
{
user_id: "user-1",
type_id: "clock",
title: "My Clock",
sync_id: "sync-item",
},
]);
const layouts = db.sqlite
.prepare("SELECT user_id, layout FROM p_homepage_homepage_layouts")
.all();
expect(layouts).toEqual([
{
user_id: "user-1",
layout: '{"entries":[],"pan":{"x":0,"y":0},"zoom":1}',
},
]);
const links = db.sqlite
.prepare(
"SELECT user_id, label, url, sync_id FROM p_homepage_dashboard_service_links",
)
.all();
expect(links).toEqual([
{
user_id: "user-1",
label: "Grafana",
url: "https://grafana.local",
sync_id: "sync-link",
},
]);
expect(indexOn("p_homepage_homepage_items")).toEqual([
"idx_homepage_items_user_id",
]);
expect(indexOn("p_homepage_dashboard_service_links")).toEqual([
"idx_dashboard_service_links_user_id",
]);
});
it("creates the tables fresh when there are no legacy ones", async () => {
db = await createTestDb(pluginDir);
expect(tableExists("p_homepage_homepage_items")).toBe(true);
expect(tableExists("p_homepage_homepage_layouts")).toBe(true);
expect(tableExists("p_homepage_dashboard_service_links")).toBe(true);
expect(
db.sqlite.prepare("SELECT * FROM p_homepage_homepage_items").all(),
).toEqual([]);
});
it("removes a user's rows when the user is deleted", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
},
});
db.sqlite
.prepare(
"INSERT INTO p_homepage_homepage_items (user_id, type_id) VALUES (?, ?)",
)
.run("user-1", "clock");
db.sqlite
.prepare(
"INSERT INTO p_homepage_homepage_items (user_id, type_id) VALUES (?, ?)",
)
.run("user-2", "clock");
db.sqlite.exec("DELETE FROM users WHERE id = 'user-1'");
const remaining = (
db.sqlite
.prepare("SELECT user_id FROM p_homepage_homepage_items")
.all() as { user_id: string }[]
).map((row) => row.user_id);
expect(remaining).toEqual(["user-2"]);
});
});
@@ -0,0 +1,96 @@
import { afterEach, describe, expect, it } from "vitest";
import { startServer, type TestServer } from "./helpers";
let server: TestServer;
afterEach(async () => {
await server.close();
});
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
...init,
});
}
describe("outbound routes go through ctx.fetch", () => {
it("proxy: returns the parsed JSON body from ctx.fetch", async () => {
server = await startServer({
fetch: async () => jsonResponse({ hello: "world" }),
});
const res = await server.request(
"GET",
"/proxy?url=https://example.com/api",
);
expect(res.status).toBe(200);
expect(res.body).toEqual({ hello: "world" });
});
it("proxy: every request to a private address is refused by the SSRF guard, never reaching ctx.fetch's caller", async () => {
// The route never gets to call the stub for a rejected URL because
// safeOutboundFetch (behind ctx.fetch) throws before any response comes
// back; simulate that by having the stub itself reject, matching what
// the real safeOutboundFetch does for a blocked host.
server = await startServer({
fetch: async () => {
throw new Error("Private destinations are not allowed");
},
});
const res = await server.request(
"GET",
"/proxy?url=http://127.0.0.1:9999/secret",
);
expect(res.status).toBe(500);
});
it("proxy: rejects a non-JSON response", async () => {
server = await startServer({
fetch: async () =>
new Response("<html></html>", {
status: 200,
headers: { "content-type": "text/html" },
}),
});
const res = await server.request("GET", "/proxy?url=https://example.com");
expect(res.status).toBe(400);
});
it("ping: reports ok for a 2xx response", async () => {
server = await startServer({
fetch: async () => new Response(null, { status: 200 }),
});
const res = await server.request("GET", "/ping?url=https://example.com");
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
});
it("rss: parses items from the feed ctx.fetch returned", async () => {
const xml = `<?xml version="1.0"?><rss><channel>
<item><title>Hello</title><link>https://example.com/1</link></item>
</channel></rss>`;
server = await startServer({
fetch: async () =>
new Response(xml, {
status: 200,
headers: { "content-type": "application/xml" },
}),
});
const res = await server.request(
"GET",
"/rss?url=https://example.com/feed",
);
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].title).toBe("Hello");
});
it("rejects a request with no url", async () => {
server = await startServer();
expect((await server.request("GET", "/ping")).status).toBe(400);
expect((await server.request("GET", "/rss")).status).toBe(400);
expect((await server.request("GET", "/proxy")).status).toBe(400);
expect((await server.request("GET", "/favicon")).status).toBe(400);
});
});
@@ -0,0 +1,124 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { startServer, type TestServer } from "./helpers";
let server: TestServer;
afterEach(async () => {
await server.close();
});
describe("homepage item routes", () => {
beforeEach(async () => {
server = await startServer();
});
it("creates, lists, updates and deletes an item", async () => {
const created = await server.request("POST", "/items", {
body: { typeId: "clock", title: "My Clock", config: { format: "24h" } },
});
expect(created.status).toBe(201);
expect(created.body).toMatchObject({ typeId: "clock", title: "My Clock" });
expect(created.body.syncId).toEqual(expect.any(String));
const list = await server.request("GET", "/items");
expect(list.body).toHaveLength(1);
const updated = await server.request("PUT", `/items/${created.body.id}`, {
body: { title: "Renamed" },
});
expect(updated.status).toBe(200);
expect(updated.body.title).toBe("Renamed");
const deleted = await server.request("DELETE", `/items/${created.body.id}`);
expect(deleted.status).toBe(200);
expect((await server.request("GET", "/items")).body).toHaveLength(0);
});
it("rejects a missing typeId", async () => {
const res = await server.request("POST", "/items", { body: {} });
expect(res.status).toBe(400);
});
it("404s updating or deleting another user's item", async () => {
const created = await server.request("POST", "/items", {
body: { typeId: "clock" },
});
const res = await server.request("DELETE", `/items/${created.body.id}`, {
user: "user-2",
});
expect(res.status).toBe(404);
});
});
describe("homepage layout routes", () => {
beforeEach(async () => {
server = await startServer();
});
it("returns null before any layout is saved, then round-trips one", async () => {
expect((await server.request("GET", "/layout")).body).toBeNull();
const saved = await server.request("PUT", "/layout", {
body: { entries: [], pan: { x: 0, y: 0 }, zoom: 1 },
});
expect(saved.status).toBe(200);
expect(saved.body.layout).toEqual({
entries: [],
pan: { x: 0, y: 0 },
zoom: 1,
});
const fetched = await server.request("GET", "/layout");
expect(fetched.body.layout.zoom).toBe(1);
});
});
describe("service link routes", () => {
beforeEach(async () => {
server = await startServer();
});
it("creates, lists, updates and deletes a service link", async () => {
const created = await server.request("POST", "/service-links", {
body: { label: "Grafana", url: "grafana.local" },
});
expect(created.status).toBe(201);
// A bare host gets normalized to http://.
expect(created.body.url).toBe("http://grafana.local");
const list = await server.request("GET", "/service-links");
expect(list.body).toHaveLength(1);
const updated = await server.request(
"PUT",
`/service-links/${created.body.id}`,
{ body: { label: "Grafana Dashboard" } },
);
expect(updated.body.label).toBe("Grafana Dashboard");
const deleted = await server.request(
"DELETE",
`/service-links/${created.body.id}`,
);
expect(deleted.status).toBe(200);
expect((await server.request("GET", "/service-links")).body).toHaveLength(
0,
);
});
it("rejects an invalid url", async () => {
const res = await server.request("POST", "/service-links", {
body: { label: "Bad", url: "javascript:alert(1)" },
});
expect(res.status).toBe(400);
});
});
describe("permission denial", () => {
it("answers 403 for every route without homepage.use", async () => {
server = await startServer({ permissions: [] });
expect((await server.request("GET", "/items")).status).toBe(403);
expect((await server.request("GET", "/layout")).status).toBe(403);
expect((await server.request("GET", "/service-links")).status).toBe(403);
});
});
@@ -0,0 +1,40 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { startServer, type TestServer } from "./helpers";
let server: TestServer;
beforeEach(async () => {
server = await startServer();
});
afterEach(async () => {
await server.close();
});
describe("homepage.items service", () => {
it("lists the acting user's items", async () => {
await server.request("POST", "/items", {
body: { typeId: "clock", title: "My Clock" },
});
await server.request("POST", "/items", {
body: { typeId: "notes" },
user: "user-2",
});
server.mock.setActor("user-1");
const service = server.mock.services.get("homepage.items") as
| { list: () => Promise<Array<{ typeId: string; title: string | null }>> }
| undefined;
const items = await service!.list();
expect(items).toEqual([
{ id: expect.any(Number), typeId: "clock", title: "My Clock" },
]);
});
it("returns an empty list with no acting user", async () => {
server.mock.setActor(undefined);
const service = server.mock.services.get("homepage.items") as
{ list: () => Promise<unknown[]> } | undefined;
expect(await service!.list()).toEqual([]);
});
});
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import {
isValidServiceLinkUrl,
normalizeServiceLinkUrl,
} from "../../src/backend/url.js";
describe("service link URL handling", () => {
it("keeps explicit http and https URLs", () => {
expect(normalizeServiceLinkUrl("https://example.com")).toBe(
"https://example.com",
);
expect(normalizeServiceLinkUrl("http://192.168.1.10:8080")).toBe(
"http://192.168.1.10:8080",
);
});
it("adds http to bare service addresses", () => {
expect(normalizeServiceLinkUrl("192.168.1.10:8080")).toBe(
"http://192.168.1.10:8080",
);
expect(normalizeServiceLinkUrl("termix.local")).toBe("http://termix.local");
});
it("rejects unsupported schemes", () => {
expect(isValidServiceLinkUrl("ssh://example.com")).toBe(false);
expect(isValidServiceLinkUrl("javascript:alert(1)")).toBe(false);
});
});