feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)

This commit is contained in:
LukeGus committed 2026-09-23 11:24:16 -05:00
1 parent d2ff5fc78f
commit 3b2c849514
59 files changed
+31807 -2977

No files matched your search

+106 -371
View File
@@ -1,388 +1,125 @@
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";
import { parseInventoryProbe } from "../../src/backend/routes.js";
const state = vi.hoisted(() => ({
currentUserId: "user-1",
fleets: new Map<number, { id: number; userId: string; name: string }>(),
members: new Map<number, { id: number; name: string }[]>(),
hostAccess: new Map<string, { hasAccess: boolean; isOwner: boolean }>(),
hosts: new Map<number, Record<string, unknown>>(),
}));
let server: TestServer;
vi.mock("../../../../src/backend/database/db/index.js", () => ({ db: {} }));
vi.mock("../../../../src/backend/utils/logger.js", () => ({
authLogger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
success: vi.fn(),
},
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();
},
createDataAccessMiddleware:
() => (_req: unknown, _res: unknown, next: () => void) =>
next(),
}),
},
}));
vi.mock("../../../../src/backend/utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
canAccessHost: async (userId: string, hostId: number, level: string) => {
const key = `${userId}:${hostId}:${level}`;
const found = state.hostAccess.get(key);
if (found) return found;
// default: full access unless a test explicitly denies it
return { hasAccess: true, isOwner: true, permissionLevel: "manage" };
},
}),
},
}));
vi.mock("../../../../src/backend/hosts/host-resolver.js", () => ({
resolveHostById: async (hostId: number) => state.hosts.get(hostId) ?? null,
}));
vi.mock("../../src/backend/ssh.js", () => ({
withSshConnection: async (
_host: unknown,
_options: unknown,
fn: (client: unknown) => unknown,
) => fn({}),
}));
vi.mock("../../../../src/backend/hosts/metrics-shared/common-utils.js", () => ({
execCommand: vi.fn(async () => ({ stdout: "ok", stderr: "", code: 0 })),
}));
vi.mock("../../../../src/backend/hosts/metrics-shared/platform.js", () => ({
detectPlatform: vi.fn(async () => ({ pkg: "apt", osPrettyName: "Debian" })),
}));
vi.mock(
"../../../../src/backend/hosts/metrics-shared/exec-elevated.js",
async () => {
class ElevationError extends Error {
code: string;
constructor(code: string, message: string) {
super(message);
this.code = code;
}
}
return {
execElevated: vi.fn(),
ElevationError,
};
},
);
vi.mock(
"../../../../src/backend/hosts/metrics-shared/package-commands.js",
() => ({
buildPackageActionCommand: vi.fn(() => "apt-get install -y foo"),
}),
);
vi.mock("../../../../src/backend/hosts/metrics-shared/validation.js", () => ({
isValidPackageName: (v: unknown) => typeof v === "string" && v.length > 0,
}));
vi.mock(
"../../../../src/backend/database/routes/snippets-execution.js",
() => ({
resolveSnippetCommand: (command: string) => command,
}),
);
vi.mock("../../../../src/backend/database/routes/rbac.js", () => ({
isSharePermissionLevel: (v: unknown) =>
["connect", "view", "edit", "manage"].includes(v as string),
expiryFromDuration: () => null,
parseShareTargets: () => null,
}));
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
createCurrentFleetRepository: () => ({
findById: async (userId: string, fleetId: number) => {
const fleet = state.fleets.get(fleetId);
return fleet && fleet.userId === userId ? fleet : null;
},
listEffectiveMembers: async (_userId: string, fleetId: number) =>
state.members.get(fleetId) ?? [],
listStaticMemberIds: async () => [],
listByUser: async (userId: string) =>
[...state.fleets.values()].filter((f) => f.userId === userId),
}),
createCurrentFleetInventoryRepository: () => ({
listForHosts: async () => [],
upsert: async () => ({}),
}),
createCurrentRbacAccessRepository: () => ({}),
createCurrentRoleRepository: () => ({}),
createCurrentUserRepository: () => ({}),
}));
vi.mock("../../../../src/backend/database/routes/fleet-dispatch.js", () => ({
registerFleetsRouter: vi.fn(),
unregisterFleetsRouter: vi.fn(),
}));
const { router, parseInventoryProbe, buildRemoveCommand } =
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>;
params?: Record<string, unknown>;
}) {
const req = {
userId: state.currentUserId,
body: overrides.body ?? {},
params: overrides.params ?? {},
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>;
params?: 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.fleets = new Map([[1, { id: 1, userId: "user-1", name: "web fleet" }]]);
state.members = new Map([
[
1,
[
{ id: 10, name: "host-a" },
{ id: 11, name: "host-b" },
],
],
]);
state.hostAccess = new Map();
state.hosts = new Map([
[
10,
{
id: 10,
userId: "user-1",
name: "host-a",
ip: "10.0.0.1",
port: 22,
username: "root",
sudoPassword: "secret",
},
],
[
11,
{
id: 11,
userId: "user-1",
name: "host-b",
ip: "10.0.0.2",
port: 22,
username: "root",
sudoPassword: "secret",
},
],
]);
afterEach(async () => {
await server?.close();
});
describe("GET /:id/members", () => {
it("404s for a fleet the caller does not own", async () => {
const res = await invoke("get", "/:id/members", {
params: { id: "999" },
async function createFleet(
server: TestServer,
body: Record<string, unknown> = { name: "Web fleet" },
) {
const res = await server.request("POST", "/", { body });
expect(res.status).toBe(200);
return res.body;
}
describe("fleet CRUD routes", () => {
beforeEach(async () => {
server = await startServer();
});
it("creates and lists fleets with member counts", async () => {
const created = await createFleet(server);
expect(created).toMatchObject({ name: "Web fleet", tagRules: [] });
const list = await server.request("GET", "/");
expect(list.status).toBe(200);
expect(list.body).toHaveLength(1);
expect(list.body[0]).toMatchObject({ id: created.id, memberCount: 0 });
});
it("400s on a missing name", async () => {
const res = await server.request("POST", "/", { body: {} });
expect(res.status).toBe(400);
});
it("updates and deletes a fleet", async () => {
const created = await createFleet(server);
const updated = await server.request("PATCH", `/${created.id}`, {
body: { name: "Renamed" },
});
expect(res.statusCode).toBe(404);
expect(updated.status).toBe(200);
expect(updated.body.name).toBe("Renamed");
const deleted = await server.request("DELETE", `/${created.id}`);
expect(deleted.status).toBe(200);
expect((await server.request("GET", "/")).body).toHaveLength(0);
});
it("404s for a fleet the caller does not own", async () => {
const res = await server.request("GET", "/999/members");
expect(res.status).toBe(404);
});
it("400s on a non-numeric fleet id", async () => {
const res = await invoke("get", "/:id/members", {
params: { id: "not-a-number" },
});
expect(res.statusCode).toBe(400);
const res = await server.request("GET", "/not-a-number/members");
expect(res.status).toBe(400);
});
});
describe("POST /:id/execute", () => {
it("400s when command is missing", async () => {
const res = await invoke("post", "/:id/execute", {
params: { id: "1" },
body: {},
});
expect(res.statusCode).toBe(400);
describe("fleet membership routes", () => {
beforeEach(async () => {
server = await startServer();
});
it("reports per-host success with the {hostId, hostName, success} shape", async () => {
const res = await invoke("post", "/:id/execute", {
params: { id: "1" },
body: { command: "uptime" },
});
expect(res.statusCode).toBe(200);
const results = res.jsonBody?.results as Array<Record<string, unknown>>;
expect(results).toHaveLength(2);
expect(results[0]).toMatchObject({
hostId: 10,
hostName: "host-a",
success: true,
it("adds and lists members, then removes one", async () => {
const fleet = await createFleet(server);
const added = await server.request("POST", `/${fleet.id}/members`, {
body: { hostId: 10 },
});
expect(added.status).toBe(200);
const members = await server.request("GET", `/${fleet.id}/members`);
expect(members.status).toBe(200);
expect(members.body).toHaveLength(1);
expect(members.body[0]).toMatchObject({ id: 10, static: true });
const removed = await server.request("DELETE", `/${fleet.id}/members/10`);
expect(removed.status).toBe(200);
expect(
(await server.request("GET", `/${fleet.id}/members`)).body,
).toHaveLength(0);
});
it("isolates one host's access denial - the other host still succeeds", async () => {
state.hostAccess.set("user-1:10:edit", {
hasAccess: false,
isOwner: false,
it("404s when adding a host outside the fixture host list", async () => {
const fleet = await createFleet(server);
const res = await server.request("POST", `/${fleet.id}/members`, {
body: { hostId: 999 },
});
const res = await invoke("post", "/:id/execute", {
params: { id: "1" },
body: { command: "uptime" },
});
const results = res.jsonBody?.results as Array<Record<string, unknown>>;
const denied = results.find((r) => r.hostId === 10);
const allowed = results.find((r) => r.hostId === 11);
expect(denied).toMatchObject({ success: false });
expect(String(denied?.error)).toMatch(/edit/);
expect(allowed).toMatchObject({ success: true });
});
it("404s for a fleet the caller does not own", async () => {
const res = await invoke("post", "/:id/execute", {
params: { id: "999" },
body: { command: "uptime" },
});
expect(res.statusCode).toBe(404);
expect(res.status).toBe(404);
});
});
describe("POST /:id/packages", () => {
it("400s on an invalid action", async () => {
const res = await invoke("post", "/:id/packages", {
params: { id: "1" },
body: { action: "reformat-disk" },
describe("permission gates", () => {
it("view routes 403 without fleets.view", async () => {
server = await startServer({
permissions: ["fleets.manage", "fleets.execute"],
});
expect(res.statusCode).toBe(400);
const res = await server.request("GET", "/");
expect(res.status).toBe(403);
});
it("surfaces an ElevationError as a per-host error, not a request failure", async () => {
const { execElevated, ElevationError } =
await import("../../../../src/backend/hosts/metrics-shared/exec-elevated.js");
(execElevated as ReturnType<typeof vi.fn>).mockRejectedValue(
new ElevationError("SUDO_REQUIRED", "sudo password required"),
);
const res = await invoke("post", "/:id/packages", {
params: { id: "1" },
body: { action: "install", package: "curl" },
it("manage routes 403 without fleets.manage", async () => {
server = await startServer({
permissions: ["fleets.view", "fleets.execute"],
});
expect(res.statusCode).toBe(200);
const results = res.jsonBody?.results as Array<Record<string, unknown>>;
expect(results.every((r) => r.success === false)).toBe(true);
expect(results[0].error).toMatch(/sudo password required/);
const res = await server.request("POST", "/", { body: { name: "x" } });
expect(res.status).toBe(403);
});
it("requires manage-level access, not just edit", async () => {
state.hostAccess.set("user-1:10:manage", {
hasAccess: false,
isOwner: false,
it("execute routes 403 without fleets.execute", async () => {
server = await startServer({
permissions: ["fleets.view", "fleets.manage"],
});
state.hostAccess.set("user-1:11:manage", {
hasAccess: false,
isOwner: false,
const fleet = await createFleet(server);
const res = await server.request("POST", `/${fleet.id}/execute`, {
body: { command: "uptime" },
});
const { execElevated } =
await import("../../../../src/backend/hosts/metrics-shared/exec-elevated.js");
(execElevated as ReturnType<typeof vi.fn>).mockResolvedValue({
code: 0,
stdout: "done",
stderr: "",
});
const res = await invoke("post", "/:id/packages", {
params: { id: "1" },
body: { action: "upgrade-all" },
});
const results = res.jsonBody?.results as Array<Record<string, unknown>>;
expect(results.every((r) => r.success === false)).toBe(true);
expect(String(results[0].error)).toMatch(/manage/);
});
});
describe("POST /:id/members", () => {
it("404s when the caller cannot access the target host", async () => {
state.hostAccess.set("user-1:99:connect", {
hasAccess: false,
isOwner: false,
});
const res = await invoke("post", "/:id/members", {
params: { id: "1" },
body: { hostId: 99 },
});
expect(res.statusCode).toBe(404);
expect(res.status).toBe(403);
});
});
@@ -413,8 +150,7 @@ describe("parseInventoryProbe", () => {
});
it("nulls uptimeSeconds when the value is not a bare integer", () => {
const out = "uptime_seconds=";
expect(parseInventoryProbe(out).uptimeSeconds).toBeNull();
expect(parseInventoryProbe("uptime_seconds=").uptimeSeconds).toBeNull();
});
it("ignores lines with no '=' separator", () => {
@@ -423,19 +159,18 @@ describe("parseInventoryProbe", () => {
});
});
describe("buildRemoveCommand", () => {
it("builds the correct remove command per package manager", () => {
expect(buildRemoveCommand("apt", "curl")).toContain(
"apt-get -y remove curl",
);
expect(buildRemoveCommand("dnf", "curl")).toBe("dnf -y remove curl");
expect(buildRemoveCommand("yum", "curl")).toBe("yum -y remove curl");
expect(buildRemoveCommand("pacman", "curl")).toBe(
"pacman -R --noconfirm curl",
);
describe("share target pickers", () => {
beforeEach(async () => {
server = await startServer();
});
it("returns null when no package manager was detected", () => {
expect(buildRemoveCommand(null, "curl")).toBeNull();
it("lists users and roles through ctx.hosts", async () => {
const users = await server.request("GET", "/share-targets/users");
expect(users.status).toBe(200);
expect(users.body).toEqual({ users: [] });
const roles = await server.request("GET", "/share-targets/roles");
expect(roles.status).toBe(200);
expect(roles.body).toEqual({ roles: [] });
});
});