mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
1017 lines
31 KiB
TypeScript
1017 lines
31 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const h = vi.hoisted(() => ({
|
|
granted: new Set<string>(),
|
|
actor: undefined as string | undefined,
|
|
access: {
|
|
hasAccess: true,
|
|
isOwner: true,
|
|
isShared: false,
|
|
} as Record<string, unknown>,
|
|
ownedHosts: [] as Array<Record<string, unknown>>,
|
|
sharedRows: [] as Array<Record<string, unknown>>,
|
|
visibleGrants: [] as Array<{ hostId: number }>,
|
|
ownerId: "user-1",
|
|
hostById: null as Record<string, unknown> | null,
|
|
upserts: [] as Array<Record<string, unknown>>,
|
|
created: [] as Array<Record<string, unknown>>,
|
|
updates: [] as Array<Record<string, unknown>>,
|
|
updateResult: null as Record<string, unknown> | null,
|
|
users: [] as Array<{ id: string; username: string }>,
|
|
activities: [] as Array<Record<string, unknown>>,
|
|
sessions: [] as number[],
|
|
released: [] as number[],
|
|
importRows: [] as Array<{ hostId: number; row: Record<string, unknown> }>,
|
|
statusReports: [] as Array<Record<string, unknown>>,
|
|
statusPorts: [] as string[],
|
|
statusPortsRemoved: [] as string[],
|
|
userPermissions: new Set<string>(),
|
|
deleted: [] as Array<{ userId: string; hostId: number }>,
|
|
existingAccess: [] as Array<{ id: number }>,
|
|
revoked: [] as number[],
|
|
protocolWrites: [] as Array<{ hostId: number; protocols: string[] }>,
|
|
folderCalls: [] as Array<unknown[]>,
|
|
folderEmpty: true,
|
|
roles: [] as Array<{
|
|
id: number;
|
|
name: string;
|
|
displayName: string | null;
|
|
isSystem: boolean;
|
|
}>,
|
|
}));
|
|
|
|
vi.mock("../../plugins/permissions.js", async () => {
|
|
const { PluginCapabilityError } =
|
|
await import("@termix-ssh/plugin-sdk/backend");
|
|
return {
|
|
assertCapability: async (
|
|
pluginId: string,
|
|
capability: string,
|
|
declared: readonly string[],
|
|
) => {
|
|
if (!declared.includes(capability) || !h.granted.has(capability)) {
|
|
throw new PluginCapabilityError(pluginId, capability);
|
|
}
|
|
},
|
|
capabilityRefused: (pluginId: string, capability: string) =>
|
|
new PluginCapabilityError(pluginId, capability),
|
|
};
|
|
});
|
|
|
|
/** The access check behind trackSession and reportLogin is async. */
|
|
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
vi.mock("../../plugins/actor.js", () => ({ getActor: () => h.actor }));
|
|
vi.mock("../../utils/permission-manager.js", () => ({
|
|
PermissionManager: {
|
|
getInstance: () => ({
|
|
canAccessHost: async () => h.access,
|
|
hasPermission: async (_userId: string, permission: string) =>
|
|
h.userPermissions.has(permission),
|
|
}),
|
|
},
|
|
}));
|
|
vi.mock("../../database/repositories/factory.js", () => ({
|
|
createCurrentHostDefaultsRepository: () => ({
|
|
listHosts: async () => [],
|
|
}),
|
|
createCurrentHostResolutionRepository: () => ({
|
|
findHostsByUserId: async () => h.ownedHosts,
|
|
listHostRowsForAccessList: async () => h.sharedRows,
|
|
findHostOwnerId: async () => h.ownerId,
|
|
findHostById: async () => h.hostById,
|
|
}),
|
|
createCurrentRoleRepository: () => ({
|
|
listUserRoleIds: async () => [],
|
|
listRoles: async () => h.roles,
|
|
}),
|
|
createCurrentRbacAccessRepository: () => ({
|
|
listVisibleHostAccessEntries: async () => h.visibleGrants,
|
|
upsertHostAccess: async (input: Record<string, unknown>) => {
|
|
h.upserts.push(input);
|
|
return { id: h.upserts.length, created: true };
|
|
},
|
|
listHostAccess: async () => h.existingAccess,
|
|
revokeHostAccess: async (accessId: number) => {
|
|
h.revoked.push(accessId);
|
|
},
|
|
}),
|
|
createCurrentUserRepository: () => ({
|
|
listAll: async () => h.users,
|
|
}),
|
|
createCurrentHostFolderRepository: () => ({
|
|
upsertMetadata: async (...args: unknown[]) => {
|
|
h.folderCalls.push(["ensure", ...args]);
|
|
},
|
|
deleteEmptyFolderRecords: async (...args: unknown[]) => {
|
|
h.folderCalls.push(["remove", ...args]);
|
|
return h.folderEmpty;
|
|
},
|
|
}),
|
|
createCurrentHostRepository: () => ({
|
|
createEncryptedForUser: async (
|
|
userId: string,
|
|
host: Record<string, unknown>,
|
|
) => {
|
|
const created = { id: 99, ...host, userId };
|
|
h.created.push(created);
|
|
return created;
|
|
},
|
|
updateEncryptedForUser: async (
|
|
userId: string,
|
|
hostId: number,
|
|
patch: Record<string, unknown>,
|
|
) => {
|
|
h.updates.push({ userId, hostId, patch });
|
|
if (!h.updateResult) return null;
|
|
return { ...h.updateResult, ...patch, id: hostId, userId };
|
|
},
|
|
listDecryptedByUserId: async () => h.ownedHosts,
|
|
}),
|
|
}));
|
|
vi.mock("../../plugins/activity-provider.js", () => ({
|
|
recentActivity: {
|
|
record: async (userId: string, entry: Record<string, unknown>) => {
|
|
h.activities.push({ userId, ...entry });
|
|
return { status: "logged", id: 1 };
|
|
},
|
|
},
|
|
provideActivity: vi.fn(() => () => {}),
|
|
}));
|
|
vi.mock("../../hosts/host-session-status.js", () => ({
|
|
hostSessionStatus: {
|
|
register: (hostId: number) => {
|
|
h.sessions.push(hostId);
|
|
return () => h.released.push(hostId);
|
|
},
|
|
},
|
|
}));
|
|
vi.mock("../../plugins/host-status-provider.js", () => ({
|
|
provideHostStatus: vi.fn(() => () => {}),
|
|
hostStatus: {
|
|
get: (hostId: number) =>
|
|
hostId === 4 ? { status: "online", lastChecked: "t" } : null,
|
|
check: async (hostId: number) => ({
|
|
status: "offline",
|
|
lastChecked: String(hostId),
|
|
}),
|
|
reportLogin: (hostId: number, outcome: Record<string, unknown>) =>
|
|
h.statusReports.push({ hostId, ...outcome }),
|
|
registerPort: (connectionType: string) => {
|
|
h.statusPorts.push(connectionType);
|
|
return () => h.statusPortsRemoved.push(connectionType);
|
|
},
|
|
},
|
|
}));
|
|
vi.mock("../../database/routes/host-plugin-settings.js", () => ({
|
|
hostSettingsPlugins: () => [
|
|
{
|
|
id: "metrics",
|
|
contributes: { settings: { host: { enableKey: "enableMetrics" } } },
|
|
},
|
|
{ id: "noswitch", contributes: { settings: { host: { fields: [] } } } },
|
|
],
|
|
loadHostPluginSettings: async (hostIds: number[]) =>
|
|
new Map(
|
|
hostIds.map((id) => [
|
|
id,
|
|
{ fixture: { mine: `own-${id}` }, other: { theirs: true } },
|
|
]),
|
|
),
|
|
checkHostPluginSettingsInput: (
|
|
input: Record<string, Record<string, unknown>>,
|
|
) => {
|
|
const errors: string[] = [];
|
|
const writes: Array<{
|
|
manifest: { id: string };
|
|
values: Record<string, unknown>;
|
|
}> = [];
|
|
const skipped: string[] = [];
|
|
for (const [pluginId, values] of Object.entries(input)) {
|
|
if (pluginId === "off") {
|
|
skipped.push(pluginId);
|
|
continue;
|
|
}
|
|
for (const key of Object.keys(values)) {
|
|
if (key === "undeclared") errors.push(`${pluginId}.${key}`);
|
|
}
|
|
writes.push({ manifest: { id: pluginId }, values });
|
|
}
|
|
return { writes, skipped, errors };
|
|
},
|
|
writeHostPluginSettings: async (
|
|
manifest: { id: string },
|
|
hostId: number,
|
|
values: Record<string, unknown>,
|
|
) => {
|
|
h.importRows.push({ hostId, row: { [manifest.id]: values } });
|
|
},
|
|
}));
|
|
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
|
|
SharedHostSecretsManager: {
|
|
getInstance: () => ({
|
|
snapshotForUser: async () => {},
|
|
snapshotForRole: async () => {},
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../hosts/defaults/index.js", () => ({
|
|
applyHostDefaultsToWrite: async () => ({ core: [] }),
|
|
applyDefaultsAfterHostWrite: async () => {},
|
|
}));
|
|
vi.mock("../../hosts/defaults/overrides.js", () => ({
|
|
changeHostOverrides: async () => {},
|
|
}));
|
|
|
|
vi.mock("../../database/routes/rbac.js", () => ({
|
|
pruneHostGrants: async () => 0,
|
|
}));
|
|
vi.mock("../../hosts/usable-credential.js", () => ({
|
|
findUsableCredential: async (credentialId: number) =>
|
|
credentialId === 5 ? { id: 5 } : null,
|
|
}));
|
|
vi.mock("../../hosts/protocol-auth/protocol-auth.js", () => ({
|
|
readProtocolAuthPayload: (data: { protocolAuth: Record<string, unknown> }) =>
|
|
new Map(Object.entries(data.protocolAuth)),
|
|
writeProtocolAuth: async (
|
|
_ownerId: string,
|
|
hostId: number,
|
|
patch: Map<string, unknown>,
|
|
) => {
|
|
h.protocolWrites.push({ hostId, protocols: [...patch.keys()] });
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../hosts/delete-host.js", () => ({
|
|
deleteOwnedHost: async (userId: string, hostId: number) => {
|
|
h.deleted.push({ userId, hostId });
|
|
return hostId === 404 ? null : { id: hostId, name: "gone" };
|
|
},
|
|
}));
|
|
|
|
const { createPluginHosts } = await import("../../plugins/ctx-hosts.js");
|
|
const { PluginCapabilityError } =
|
|
await import("@termix-ssh/plugin-sdk/backend");
|
|
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
|
|
const { clearHostLocks, hostLock, provideHostLocks } =
|
|
await import("../../hosts/locks.js");
|
|
|
|
function manifest(capabilities: string[]) {
|
|
return {
|
|
id: "fixture",
|
|
name: "Fixture",
|
|
version: "1.0.0",
|
|
capabilities,
|
|
} as never;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
h.granted = new Set();
|
|
h.actor = "user-1";
|
|
h.access = { hasAccess: true, isOwner: true, isShared: false };
|
|
h.ownedHosts = [];
|
|
h.sharedRows = [];
|
|
h.visibleGrants = [];
|
|
h.ownerId = "user-1";
|
|
h.hostById = null;
|
|
h.upserts = [];
|
|
h.created = [];
|
|
h.updates = [];
|
|
h.updateResult = null;
|
|
h.users = [];
|
|
h.roles = [];
|
|
h.userPermissions = new Set();
|
|
h.deleted = [];
|
|
h.existingAccess = [];
|
|
h.revoked = [];
|
|
h.protocolWrites = [];
|
|
clearHostLocks();
|
|
});
|
|
|
|
describe("ctx.hosts", () => {
|
|
it("refuses list/get/checkAccess without hosts:read", async () => {
|
|
const audit = vi.fn(async () => {});
|
|
const hosts = createPluginHosts({ manifest: manifest([]), audit });
|
|
await expect(hosts.list()).rejects.toBeInstanceOf(PluginCapabilityError);
|
|
await expect(hosts.get(1)).rejects.toBeInstanceOf(PluginCapabilityError);
|
|
await expect(hosts.checkAccess(1, "view")).rejects.toBeInstanceOf(
|
|
PluginCapabilityError,
|
|
);
|
|
});
|
|
|
|
it("lists owned and shared hosts once granted hosts:read", async () => {
|
|
h.granted = new Set(["hosts:read"]);
|
|
h.ownedHosts = [
|
|
{
|
|
id: 1,
|
|
userId: "user-1",
|
|
name: "own",
|
|
ip: "10.0.0.1",
|
|
port: 22,
|
|
username: "root",
|
|
tags: "prod",
|
|
folder: null,
|
|
authType: "password",
|
|
},
|
|
];
|
|
h.sharedRows = [
|
|
{
|
|
id: 2,
|
|
userId: "user-2",
|
|
name: "shared",
|
|
ip: "10.0.0.2",
|
|
port: 22,
|
|
username: "root",
|
|
tags: null,
|
|
folder: null,
|
|
authType: "key",
|
|
},
|
|
];
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest(["hosts:read"]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
const list = await hosts.list();
|
|
expect(list.map((h2) => h2.id)).toEqual([1, 2]);
|
|
});
|
|
|
|
it("checkAccess reports the granted level", async () => {
|
|
h.granted = new Set(["hosts:read"]);
|
|
h.access = {
|
|
hasAccess: true,
|
|
isOwner: false,
|
|
isShared: true,
|
|
permissionLevel: "edit",
|
|
expiresAt: null,
|
|
};
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest(["hosts:read"]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
const access = await hosts.checkAccess(1, "edit");
|
|
expect(access).toMatchObject({ hasAccess: true, permissionLevel: "edit" });
|
|
});
|
|
|
|
it("refuses share, listUsers, listRoles without hosts:write, auditing the refusal", async () => {
|
|
const audit = vi.fn(async () => {});
|
|
const hosts = createPluginHosts({ manifest: manifest([]), audit });
|
|
await expect(
|
|
hosts.share(1, [{ type: "user", id: "user-2" }], "view"),
|
|
).rejects.toBeInstanceOf(PluginCapabilityError);
|
|
await expect(hosts.listUsers()).rejects.toBeInstanceOf(
|
|
PluginCapabilityError,
|
|
);
|
|
await expect(hosts.listRoles()).rejects.toBeInstanceOf(
|
|
PluginCapabilityError,
|
|
);
|
|
expect(audit).toHaveBeenCalledWith(
|
|
"hosts_share",
|
|
expect.any(String),
|
|
expect.objectContaining({ success: false }),
|
|
);
|
|
});
|
|
|
|
it("shares a host it manages and snapshots secrets per target", async () => {
|
|
h.granted = new Set(["hosts:write"]);
|
|
h.access = { hasAccess: true, isOwner: true, isShared: false };
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest(["hosts:write"]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
const result = await hosts.share(
|
|
5,
|
|
[{ type: "user", id: "user-2" }],
|
|
"view",
|
|
24,
|
|
);
|
|
expect(result).toEqual({ hostId: 5, shared: true });
|
|
expect(h.upserts).toHaveLength(1);
|
|
expect(h.upserts[0]).toMatchObject({
|
|
hostId: 5,
|
|
permissionLevel: "view",
|
|
targetType: "user",
|
|
targetUserId: "user-2",
|
|
});
|
|
});
|
|
|
|
it("refuses to share a host the caller does not manage", async () => {
|
|
h.granted = new Set(["hosts:write"]);
|
|
h.access = { hasAccess: false, isOwner: false, isShared: false };
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest(["hosts:write"]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
const result = await hosts.share(
|
|
5,
|
|
[{ type: "user", id: "user-2" }],
|
|
"view",
|
|
);
|
|
expect(result).toEqual({ hostId: 5, shared: false, reason: "forbidden" });
|
|
});
|
|
|
|
it("lists non-system roles and users once granted hosts:write", async () => {
|
|
h.granted = new Set(["hosts:write"]);
|
|
h.users = [{ id: "user-1", username: "alice" }];
|
|
h.roles = [
|
|
{ id: 1, name: "admin", displayName: "Admin", isSystem: true },
|
|
{ id: 2, name: "auditor", displayName: "Auditor", isSystem: false },
|
|
];
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest(["hosts:write"]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
expect(await hosts.listUsers()).toEqual([
|
|
{ id: "user-1", username: "alice" },
|
|
]);
|
|
expect(await hosts.listRoles()).toEqual([
|
|
{ id: 2, name: "auditor", displayName: "Auditor" },
|
|
]);
|
|
});
|
|
|
|
it("refuses create/update/listOwned without hosts:write", async () => {
|
|
const audit = vi.fn(async () => {});
|
|
const hosts = createPluginHosts({ manifest: manifest([]), audit });
|
|
await expect(
|
|
hosts.create({
|
|
name: "n",
|
|
ip: "10.0.0.1",
|
|
port: 22,
|
|
username: "root",
|
|
authType: "password",
|
|
}),
|
|
).rejects.toBeInstanceOf(PluginCapabilityError);
|
|
await expect(hosts.update(1, { name: "x" })).rejects.toBeInstanceOf(
|
|
PluginCapabilityError,
|
|
);
|
|
await expect(hosts.listOwned()).rejects.toBeInstanceOf(
|
|
PluginCapabilityError,
|
|
);
|
|
expect(audit).toHaveBeenCalledWith(
|
|
"hosts_create",
|
|
expect.any(String),
|
|
expect.objectContaining({ success: false }),
|
|
);
|
|
});
|
|
|
|
it("creates a host once granted hosts:write", async () => {
|
|
h.granted = new Set(["hosts:write"]);
|
|
h.importRows = [];
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest(["hosts:write"]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
const created = await hosts.create({
|
|
name: "pve-guest",
|
|
ip: "10.0.0.5",
|
|
port: 22,
|
|
username: "root",
|
|
authType: "password",
|
|
tags: ["a", "b"],
|
|
jumpHosts: [{ hostId: 3 }],
|
|
pluginSettings: { "remote-desktop": { enableRdp: true } },
|
|
});
|
|
expect(created).toMatchObject({
|
|
id: 99,
|
|
name: "pve-guest",
|
|
userId: "user-1",
|
|
tags: "a,b",
|
|
jumpHosts: [{ hostId: 3 }],
|
|
pluginSettings: { mine: "own-99" },
|
|
});
|
|
expect(h.created[0]).toMatchObject({
|
|
tags: "a,b",
|
|
jumpHosts: '[{"hostId":3}]',
|
|
});
|
|
// Another plugin's settings go to that plugin, validated by core.
|
|
expect(h.importRows).toEqual([
|
|
{ hostId: 99, row: { "remote-desktop": { enableRdp: true } } },
|
|
]);
|
|
});
|
|
|
|
it("publishes host.created for a host it creates", async () => {
|
|
h.granted = new Set(["hosts:write"]);
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest(["hosts:write"]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
const seen: unknown[] = [];
|
|
const off = pluginEvents.on(TOPICS.hostCreated, (payload) => {
|
|
seen.push(payload);
|
|
});
|
|
try {
|
|
await hosts.create({
|
|
ip: "10.0.0.5",
|
|
port: 22,
|
|
username: "root",
|
|
authType: "password",
|
|
});
|
|
} finally {
|
|
off();
|
|
}
|
|
expect(seen).toEqual([{ hostId: 99, userId: "user-1" }]);
|
|
});
|
|
|
|
it("refuses a field the SDK does not take instead of dropping it", async () => {
|
|
h.granted = new Set(["hosts:write"]);
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest(["hosts:write"]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
await expect(
|
|
hosts.create({
|
|
name: "n",
|
|
ip: "10.0.0.1",
|
|
port: 22,
|
|
username: "root",
|
|
authType: "password",
|
|
enableRdp: true,
|
|
} as never),
|
|
).rejects.toThrow(/enableRdp/);
|
|
await expect(
|
|
hosts.update(5, { userId: "someone-else" } as never),
|
|
).rejects.toThrow(/userId/);
|
|
await expect(
|
|
hosts.update(5, { pluginSettings: { docker: { undeclared: 1 } } }),
|
|
).rejects.toThrow(/undeclared/);
|
|
expect(h.created).toHaveLength(0);
|
|
expect(h.updates).toHaveLength(0);
|
|
});
|
|
|
|
it("builds the record field by field, with no secret and no other plugin's settings", async () => {
|
|
h.granted = new Set(["hosts:write"]);
|
|
h.ownedHosts = [
|
|
{
|
|
id: 4,
|
|
userId: "user-1",
|
|
name: "own",
|
|
ip: "10.0.0.4",
|
|
port: 22,
|
|
username: "root",
|
|
authType: "password",
|
|
password: "secret",
|
|
key: "PRIVATE",
|
|
autostartPassword: "x",
|
|
sshOptions: '{"keepaliveInterval":5}',
|
|
enableDocker: true,
|
|
jumpHosts: "[]",
|
|
},
|
|
];
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest(["hosts:write"]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
const [record] = await hosts.listOwned();
|
|
const loose = record as unknown as Record<string, unknown>;
|
|
for (const field of [
|
|
"password",
|
|
"key",
|
|
"autostartPassword",
|
|
"terminalConfig",
|
|
"enableDocker",
|
|
]) {
|
|
expect(loose).not.toHaveProperty(field);
|
|
}
|
|
expect(record.sshOptions).toEqual({ keepaliveInterval: 5 });
|
|
expect(record.pluginSettings).toEqual({ mine: "own-4" });
|
|
expect(record.status).toEqual({ status: "online", lastChecked: "t" });
|
|
expect(record.enableSsh).toBe(true);
|
|
});
|
|
|
|
it("updates a host once granted hosts:write", async () => {
|
|
h.granted = new Set(["hosts:write"]);
|
|
h.updateResult = { id: 5, name: "old" };
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest(["hosts:write"]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
const updated = await hosts.update(5, { name: "new" });
|
|
expect(updated).toMatchObject({ id: 5, name: "new" });
|
|
expect(h.updates).toEqual([
|
|
{ userId: "user-1", hostId: 5, patch: { name: "new" } },
|
|
]);
|
|
expect(updated?.pluginSettings).toEqual({ mine: "own-5" });
|
|
});
|
|
|
|
it("returns null updating a host that does not exist for this user", async () => {
|
|
h.granted = new Set(["hosts:write"]);
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest(["hosts:write"]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
expect(await hosts.update(404, { name: "x" })).toBeNull();
|
|
});
|
|
|
|
it("lists owned hosts decrypted once granted hosts:write", async () => {
|
|
h.granted = new Set(["hosts:write"]);
|
|
h.ownedHosts = [{ id: 1, userId: "user-1", name: "own" }];
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest(["hosts:write"]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
expect(await hosts.listOwned()).toMatchObject([{ id: 1, name: "own" }]);
|
|
});
|
|
});
|
|
|
|
describe("ctx.hosts.delete", () => {
|
|
it("refuses without hosts:write and audits it", async () => {
|
|
const audit = vi.fn(async () => {});
|
|
const hosts = createPluginHosts({ manifest: manifest([]), audit });
|
|
await expect(hosts.delete(1)).rejects.toBeInstanceOf(PluginCapabilityError);
|
|
expect(audit).toHaveBeenCalledWith(
|
|
"hosts_delete",
|
|
"host 1",
|
|
expect.objectContaining({ success: false }),
|
|
);
|
|
expect(h.deleted).toEqual([]);
|
|
});
|
|
|
|
it("refuses an acting user without hosts.delete", async () => {
|
|
h.granted = new Set(["hosts:write"]);
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest(["hosts:write"]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
await expect(hosts.delete(1)).rejects.toThrow("may not delete hosts");
|
|
expect(h.deleted).toEqual([]);
|
|
});
|
|
|
|
it("deletes the actor's own host through core's delete path", async () => {
|
|
h.granted = new Set(["hosts:write"]);
|
|
h.userPermissions = new Set(["hosts.delete"]);
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest(["hosts:write"]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
expect(await hosts.delete(7)).toBe(true);
|
|
expect(await hosts.delete(404)).toBe(false);
|
|
expect(h.deleted).toEqual([
|
|
{ userId: "user-1", hostId: 7 },
|
|
{ userId: "user-1", hostId: 404 },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("ctx.hosts session tracking and activity", () => {
|
|
it("counts a live session through core and releases it", async () => {
|
|
h.sessions = [];
|
|
h.released = [];
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest(["hosts:read"]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
const release = hosts.trackSession(4);
|
|
await settle();
|
|
expect(h.sessions).toEqual([4]);
|
|
release();
|
|
expect(h.released).toEqual([4]);
|
|
});
|
|
|
|
it("refuses to track a session without hosts:read declared", () => {
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest([]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
expect(() => hosts.trackSession(4)).toThrow(PluginCapabilityError);
|
|
});
|
|
|
|
it("records activity for the acting user only", async () => {
|
|
h.activities = [];
|
|
h.granted = new Set(["hosts:read"]);
|
|
h.actor = "user-7";
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest(["hosts:read"]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
await hosts.recordActivity(4, "terminal", "web-01");
|
|
expect(h.activities).toEqual([
|
|
{ userId: "user-7", type: "terminal", hostId: 4, hostName: "web-01" },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("ctx.hosts.status", () => {
|
|
it("reads and checks core's status with hosts:read", async () => {
|
|
h.granted = new Set(["hosts:read"]);
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest(["hosts:read"]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
await expect(hosts.status.get(4)).resolves.toEqual({
|
|
status: "online",
|
|
lastChecked: "t",
|
|
});
|
|
await expect(hosts.status.get(5)).resolves.toBeNull();
|
|
await expect(hosts.status.check(5)).resolves.toEqual({
|
|
status: "offline",
|
|
lastChecked: "5",
|
|
});
|
|
});
|
|
|
|
it("refuses every status member without hosts:read", async () => {
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest([]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
await expect(hosts.status.get(4)).rejects.toBeInstanceOf(
|
|
PluginCapabilityError,
|
|
);
|
|
expect(() => hosts.status.reportLogin(4, { ok: true })).toThrow(
|
|
PluginCapabilityError,
|
|
);
|
|
expect(() => hosts.status.registerPort("rdp", () => 3389)).toThrow(
|
|
PluginCapabilityError,
|
|
);
|
|
await expect(hosts.status.check(4)).rejects.toBeInstanceOf(
|
|
PluginCapabilityError,
|
|
);
|
|
});
|
|
|
|
it("passes login reports on to core", async () => {
|
|
h.statusReports = [];
|
|
h.granted = new Set(["hosts:read"]);
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest(["hosts:read"]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
hosts.status.reportLogin(4, { ok: true });
|
|
await settle();
|
|
expect(h.statusReports).toEqual([{ hostId: 4, ok: true }]);
|
|
});
|
|
|
|
it("removes a registered port when the plugin is disposed", async () => {
|
|
h.statusPorts = [];
|
|
h.statusPortsRemoved = [];
|
|
h.granted = new Set(["hosts:read"]);
|
|
const { DisposableBag } = await import("../../plugins/disposables.js");
|
|
const bag = new DisposableBag("fixture");
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest(["hosts:read"]),
|
|
bag,
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
hosts.status.registerPort("rdp", () => 3389);
|
|
expect(h.statusPorts).toEqual(["rdp"]);
|
|
await bag.disposeAll();
|
|
expect(h.statusPortsRemoved).toEqual(["rdp"]);
|
|
});
|
|
});
|
|
|
|
describe("ctx.hosts secrets and protocol logins", () => {
|
|
it("stores secrets on create and writes protocol logins", async () => {
|
|
h.granted = new Set(["hosts:write"]);
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest(["hosts:write"]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
const created = await hosts.create({
|
|
name: "rdp",
|
|
ip: "10.0.0.9",
|
|
port: 3389,
|
|
username: "",
|
|
authType: "password",
|
|
password: "s3cret",
|
|
sudoPassword: "",
|
|
protocolAuth: { rdp: { authType: "direct", username: "admin" } },
|
|
});
|
|
expect(h.created[0]).toMatchObject({
|
|
password: "s3cret",
|
|
sudoPassword: null,
|
|
});
|
|
expect(created).not.toHaveProperty("password");
|
|
expect(h.protocolWrites).toEqual([{ hostId: 99, protocols: ["rdp"] }]);
|
|
});
|
|
|
|
it("refuses a protocol credential the owner cannot use", async () => {
|
|
h.granted = new Set(["hosts:write"]);
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest(["hosts:write"]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
await expect(
|
|
hosts.create({
|
|
name: "vnc",
|
|
ip: "10.0.0.9",
|
|
port: 5900,
|
|
username: "",
|
|
authType: "password",
|
|
protocolAuth: { vnc: { authType: "credential", credentialId: 6 } },
|
|
}),
|
|
).rejects.toThrow(/credential 6/);
|
|
});
|
|
|
|
it("refuses a secret that is not a string", async () => {
|
|
h.granted = new Set(["hosts:write"]);
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest(["hosts:write"]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
await expect(hosts.update(5, { password: 12 as never })).rejects.toThrow(
|
|
/password/,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("ctx.hosts pluginEnable", () => {
|
|
it("writes each plugin's switch under its enableKey", async () => {
|
|
h.granted = new Set(["hosts:write"]);
|
|
h.importRows = [];
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest(["hosts:write"]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
await hosts.create({
|
|
name: "n",
|
|
ip: "10.0.0.1",
|
|
port: 22,
|
|
username: "root",
|
|
authType: "password",
|
|
pluginEnable: { metrics: false },
|
|
});
|
|
expect(h.importRows).toEqual([
|
|
{ hostId: 99, row: { metrics: { enableMetrics: false } } },
|
|
]);
|
|
});
|
|
|
|
it("refuses a plugin with no switch", async () => {
|
|
h.granted = new Set(["hosts:write"]);
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest(["hosts:write"]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
await expect(
|
|
hosts.update(5, { pluginEnable: { noswitch: true } }),
|
|
).rejects.toThrow(/no host switch/);
|
|
expect(h.updates).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("ctx.hosts.setShares", () => {
|
|
it("upserts the listed grants and revokes the rest", async () => {
|
|
h.granted = new Set(["hosts:write"]);
|
|
h.existingAccess = [{ id: 1 }, { id: 7 }];
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest(["hosts:write"]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
const result = await hosts.setShares(3, [
|
|
{ target: { type: "role", id: 2 }, permissionLevel: "connect" },
|
|
]);
|
|
expect(result).toEqual({ hostId: 3, shared: true });
|
|
expect(h.upserts).toEqual([
|
|
expect.objectContaining({
|
|
hostId: 3,
|
|
targetType: "role",
|
|
targetRoleId: 2,
|
|
permissionLevel: "connect",
|
|
expiresAt: null,
|
|
}),
|
|
]);
|
|
expect(h.revoked).toEqual([7]);
|
|
});
|
|
|
|
it("only works for the host owner", async () => {
|
|
h.granted = new Set(["hosts:write"]);
|
|
h.ownerId = "someone-else";
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest(["hosts:write"]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
expect(await hosts.setShares(3, [])).toEqual({
|
|
hostId: 3,
|
|
shared: false,
|
|
reason: "forbidden",
|
|
});
|
|
expect(h.revoked).toEqual([]);
|
|
});
|
|
|
|
it("refuses a bad level", async () => {
|
|
h.granted = new Set(["hosts:write"]);
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest(["hosts:write"]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
await expect(
|
|
hosts.setShares(3, [
|
|
{ target: { type: "user", id: "u2" }, permissionLevel: "own" as never },
|
|
]),
|
|
).rejects.toThrow(/share level/);
|
|
});
|
|
});
|
|
|
|
describe("ctx.hosts.locks", () => {
|
|
const provider = (locked: number[], allowManualCreate = true) => ({
|
|
isLocked: (hostId: number) => locked.includes(hostId),
|
|
note: () => "From hosts.yaml",
|
|
allowManualCreate: () => allowManualCreate,
|
|
});
|
|
|
|
it("needs hosts:lock", () => {
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest([]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
expect(() => hosts.locks.provide(provider([1]))).toThrow(
|
|
PluginCapabilityError,
|
|
);
|
|
});
|
|
|
|
it("locks hosts until the plugin removes its provider", () => {
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest(["hosts:lock"]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
const remove = hosts.locks.provide(provider([4]));
|
|
expect(hostLock(4)).toEqual({
|
|
pluginId: "fixture",
|
|
pluginName: "Fixture",
|
|
note: "From hosts.yaml",
|
|
});
|
|
expect(hostLock(5)).toBeNull();
|
|
remove();
|
|
expect(hostLock(4)).toBeNull();
|
|
});
|
|
|
|
it("blocks other plugins on a locked host but not the plugin that locks it", async () => {
|
|
h.granted = new Set(["hosts:write"]);
|
|
h.updateResult = { id: 4, name: "x" };
|
|
h.userPermissions = new Set(["hosts.delete"]);
|
|
provideHostLocks("hosts-file", "Hosts File", provider([4], false));
|
|
const other = createPluginHosts({
|
|
manifest: manifest(["hosts:write"]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
await expect(other.update(4, { name: "y" })).rejects.toThrow(
|
|
/managed by Hosts File/,
|
|
);
|
|
await expect(other.delete(4)).rejects.toThrow(/managed by Hosts File/);
|
|
await expect(
|
|
other.share(4, [{ type: "user", id: "u2" }], "view"),
|
|
).rejects.toThrow(/managed by Hosts File/);
|
|
await expect(
|
|
other.create({
|
|
name: "n",
|
|
ip: "10.0.0.1",
|
|
port: 22,
|
|
username: "root",
|
|
authType: "password",
|
|
}),
|
|
).rejects.toThrow(/new hosts can't be added/);
|
|
expect(h.updates).toHaveLength(0);
|
|
expect(h.deleted).toHaveLength(0);
|
|
|
|
const owner = createPluginHosts({
|
|
manifest: {
|
|
id: "hosts-file",
|
|
name: "Hosts File",
|
|
version: "1.0.0",
|
|
capabilities: ["hosts:write"],
|
|
} as never,
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
await owner.update(4, { name: "y" });
|
|
expect(h.updates).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("ctx.hosts.folders", () => {
|
|
beforeEach(() => {
|
|
h.granted = new Set(["hosts:write"]);
|
|
h.actor = "user-1";
|
|
h.folderCalls = [];
|
|
h.folderEmpty = true;
|
|
});
|
|
|
|
it("needs hosts:write", async () => {
|
|
h.granted = new Set();
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest(["hosts:write"]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
await expect(hosts.folders.ensure("Site")).rejects.toThrow(
|
|
PluginCapabilityError,
|
|
);
|
|
});
|
|
|
|
it("creates the folder row for the acting user with its style", async () => {
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest(["hosts:write"]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
await hosts.folders.ensure("Site", { icon: "cloud-cog" });
|
|
expect(h.folderCalls).toEqual([
|
|
["ensure", "user-1", "Site", null, "cloud-cog"],
|
|
]);
|
|
});
|
|
|
|
it("removes only an empty folder", async () => {
|
|
const hosts = createPluginHosts({
|
|
manifest: manifest(["hosts:write"]),
|
|
audit: vi.fn(async () => {}),
|
|
});
|
|
expect(await hosts.folders.removeEmpty("Site")).toBe(true);
|
|
h.folderEmpty = false;
|
|
expect(await hosts.folders.removeEmpty("Site")).toBe(false);
|
|
expect(h.folderCalls[0]).toEqual(["remove", "user-1", "Site"]);
|
|
});
|
|
});
|