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

@@ -1,120 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { DashboardServiceLinkRepository } from "../../../database/repositories/dashboard-service-link-repository.js";
describe("DashboardServiceLinkRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<DashboardServiceLinkRepository> {
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 DashboardServiceLinkRepository(context, onWrite);
}
it("creates and lists links ordered by order then id", async () => {
const repo = await createRepository();
const first = await repo.createForUser(
"user-1",
{ label: "Docs", url: "https://docs.example.com" },
"2026-06-27T00:00:00.000Z",
);
const second = await repo.createForUser("user-1", {
label: "Status",
url: "https://status.example.com",
});
await repo.createForUser("user-2", {
label: "Other",
url: "https://other.example.com",
});
expect(first).toMatchObject({
userId: "user-1",
label: "Docs",
order: 0,
createdAt: "2026-06-27T00:00:00.000Z",
});
expect(second.order).toBe(1);
expect(
(await repo.listByUserId("user-1")).map((link) => link.label),
).toEqual(["Docs", "Status"]);
});
it("finds, updates, and deletes a user-owned link", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const link = await repo.createForUser("user-1", {
label: "Docs",
url: "https://docs.example.com",
});
expect(writeCount).toBe(1);
expect(await repo.findByIdForUser("user-2", link.id)).toBeNull();
const updated = await repo.updateForUser("user-1", link.id, {
label: "Docs renamed",
});
expect(updated).toMatchObject({
id: link.id,
label: "Docs renamed",
url: "https://docs.example.com",
});
expect(writeCount).toBe(2);
expect(await repo.updateForUser("user-2", link.id, { label: "Nope" })).toBe(
null,
);
expect(writeCount).toBe(2);
expect(await repo.deleteForUser("user-2", link.id)).toBeNull();
expect(await repo.deleteForUser("user-1", link.id)).toEqual({
syncId: expect.any(String),
});
expect(writeCount).toBe(3);
});
it("deletes all dashboard links for a user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.createForUser("user-1", {
label: "Docs",
url: "https://docs.example.com",
});
await repo.createForUser("user-1", {
label: "Status",
url: "https://status.example.com",
});
await repo.createForUser("user-2", {
label: "Other",
url: "https://other.example.com",
});
await expect(repo.deleteByUserId("user-1")).resolves.toBe(2);
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
expect(await repo.listByUserId("user-1")).toEqual([]);
expect(
(await repo.listByUserId("user-2")).map((link) => link.label),
).toEqual(["Other"]);
expect(writeCount).toBe(4);
});
});
@@ -1,131 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { HomepageItemRepository } from "../../../database/repositories/homepage-item-repository.js";
describe("HomepageItemRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<HomepageItemRepository> {
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 HomepageItemRepository(context, onWrite);
}
it("creates and lists homepage items by id", async () => {
const repo = await createRepository();
const first = await repo.createForUser(
"user-1",
{ typeId: "clock", title: "Clock", config: "{}" },
"2026-06-27T00:00:00.000Z",
);
const second = await repo.createForUser("user-1", {
typeId: "terminal",
title: null,
config: '{"hostId":1}',
});
await repo.createForUser("user-2", {
typeId: "other",
title: "Other",
config: "{}",
});
expect(first).toMatchObject({
userId: "user-1",
typeId: "clock",
title: "Clock",
config: "{}",
createdAt: "2026-06-27T00:00:00.000Z",
});
expect((await repo.listByUserId("user-1")).map((item) => item.id)).toEqual([
first.id,
second.id,
]);
});
it("finds, updates, and deletes user-owned homepage items", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const item = await repo.createForUser("user-1", {
typeId: "clock",
title: "Clock",
config: "{}",
});
expect(writeCount).toBe(1);
expect(await repo.findByIdForUser("user-2", item.id)).toBeNull();
const updated = await repo.updateForUser(
"user-1",
item.id,
{ title: "Clock renamed", config: '{"timezone":"UTC"}' },
"2026-06-27T01:00:00.000Z",
);
expect(updated).toMatchObject({
id: item.id,
title: "Clock renamed",
config: '{"timezone":"UTC"}',
updatedAt: "2026-06-27T01:00:00.000Z",
});
expect(writeCount).toBe(2);
expect(
await repo.updateForUser("user-2", item.id, { title: "Nope" }),
).toBeNull();
expect(writeCount).toBe(2);
expect(await repo.deleteForUser("user-2", item.id)).toBeNull();
expect(await repo.deleteForUser("user-1", item.id)).toEqual({
syncId: expect.any(String),
});
expect(writeCount).toBe(3);
});
it("deletes all homepage items for a user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.createForUser("user-1", {
typeId: "clock",
title: "Clock",
config: "{}",
});
await repo.createForUser("user-1", {
typeId: "terminal",
title: "Terminal",
config: "{}",
});
await repo.createForUser("user-2", {
typeId: "other",
title: "Other",
config: "{}",
});
await expect(repo.deleteByUserId("user-1")).resolves.toBe(2);
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
expect(await repo.listByUserId("user-1")).toEqual([]);
expect(
(await repo.listByUserId("user-2")).map((item) => item.title),
).toEqual(["Other"]);
expect(writeCount).toBe(4);
});
});
@@ -1,85 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { HomepageLayoutRepository } from "../../../database/repositories/homepage-layout-repository.js";
describe("HomepageLayoutRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<HomepageLayoutRepository> {
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 HomepageLayoutRepository(context, onWrite);
}
it("finds, creates, and updates a layout by user id", async () => {
const repo = await createRepository();
expect(await repo.findByUserId("user-1")).toBeNull();
const created = await repo.upsertForUser(
"user-1",
JSON.stringify({ entries: [{ id: "w1" }], zoom: 1 }),
"2026-06-27T00:00:00.000Z",
);
expect(created).toMatchObject({
userId: "user-1",
layout: '{"entries":[{"id":"w1"}],"zoom":1}',
updatedAt: "2026-06-27T00:00:00.000Z",
});
const updated = await repo.upsertForUser(
"user-1",
JSON.stringify({ entries: [], zoom: 1.25 }),
"2026-06-27T01:00:00.000Z",
);
expect(updated).toMatchObject({
id: created.id,
userId: "user-1",
layout: '{"entries":[],"zoom":1.25}',
updatedAt: "2026-06-27T01:00:00.000Z",
});
});
it("triggers writes after create and update", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.upsertForUser("user-1", "{}");
await repo.upsertForUser("user-1", '{"zoom":2}');
expect(writeCount).toBe(2);
});
it("deletes a layout for a user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.upsertForUser("user-1", '{"zoom":1}');
await repo.upsertForUser("user-2", '{"zoom":2}');
await expect(repo.deleteByUserId("user-1")).resolves.toBe(1);
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
expect(await repo.findByUserId("user-1")).toBeNull();
expect((await repo.findByUserId("user-2"))?.layout).toBe('{"zoom":2}');
expect(writeCount).toBe(3);
});
});
@@ -0,0 +1,139 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import http from "node:http";
import type { AddressInfo } from "node:net";
import express from "express";
const state = vi.hoisted(() => ({
unlocked: true,
activity: [] as unknown[],
recordResult: { status: "logged", id: 1 } as {
status: string;
id?: number;
},
}));
vi.mock("../../../utils/logger.js", () => ({
dashboardLogger: { warn: vi.fn(), error: vi.fn(), success: vi.fn() },
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() => (req: express.Request, _res: unknown, next: () => void) => {
(req as unknown as { userId: string }).userId = "user-1";
next();
},
}),
},
}));
vi.mock("../../../utils/data-crypto.js", () => ({
DataCrypto: {
getUserDataKey: () => (state.unlocked ? "key" : null),
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentRecentActivityRepository: () => ({
listByUserId: async () => state.activity,
deleteByUserId: async () => {},
}),
}));
vi.mock("../../../services/recent-activity.js", () => ({
recordRecentActivity: async () => state.recordResult,
}));
const { default: dashboardRoutes } =
await import("../../../database/routes/dashboard-routes.js");
let server: http.Server;
let baseUrl: string;
beforeEach(async () => {
state.unlocked = true;
state.activity = [];
state.recordResult = { status: "logged", id: 1 };
const app = express();
app.use(express.json());
app.use("/dashboard", dashboardRoutes);
server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
baseUrl = `http://127.0.0.1:${port}`;
});
afterEach(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
describe("GET /dashboard/uptime", () => {
it("returns a formatted uptime", async () => {
const res = await fetch(`${baseUrl}/dashboard/uptime`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toMatchObject({
uptimeMs: expect.any(Number),
formatted: expect.stringMatching(/^\d+d \d+h \d+m$/),
});
});
});
describe("GET /dashboard/activity/recent", () => {
it("401s when the user's data key is not unlocked", async () => {
state.unlocked = false;
const res = await fetch(`${baseUrl}/dashboard/activity/recent`);
expect(res.status).toBe(401);
});
it("returns the user's recent activity", async () => {
state.activity = [{ id: 1, type: "terminal" }];
const res = await fetch(`${baseUrl}/dashboard/activity/recent`);
expect(res.status).toBe(200);
expect(await res.json()).toEqual(state.activity);
});
});
describe("POST /dashboard/activity/log", () => {
it("rejects a body missing required fields", async () => {
const res = await fetch(`${baseUrl}/dashboard/activity/log`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "terminal" }),
});
expect(res.status).toBe(400);
});
it("logs a valid activity", async () => {
const res = await fetch(`${baseUrl}/dashboard/activity/log`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "terminal", hostId: 1, hostName: "prod" }),
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ message: "Activity logged", id: 1 });
});
it("404s a host the user cannot access", async () => {
state.recordResult = { status: "denied" };
const res = await fetch(`${baseUrl}/dashboard/activity/log`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "terminal", hostId: 1, hostName: "prod" }),
});
expect(res.status).toBe(404);
});
});
describe("DELETE /dashboard/activity/reset", () => {
it("clears the user's recent activity", async () => {
const res = await fetch(`${baseUrl}/dashboard/activity/reset`, {
method: "DELETE",
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ message: "Recent activity cleared" });
});
});
@@ -1,28 +0,0 @@
import { describe, expect, it } from "vitest";
import {
isValidServiceLinkUrl,
normalizeServiceLinkUrl,
} from "../../../database/routes/service-link-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);
});
});
@@ -34,8 +34,6 @@ describe("isValidEntityType", () => {
"sshCredentials",
"sshFolders",
"vaultProfiles",
"dashboardServiceLinks",
"homepageItems",
"userPreferences",
]) {
expect(isValidEntityType(type)).toBe(true);