mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
This commit is contained in:
1 parent
d2ff5fc78f
commit
3b2c849514
59 files changed
+31807
-2977
No files matched your search
@@ -0,0 +1,209 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
|
||||
import type {
|
||||
PluginHostSummary,
|
||||
PluginHosts,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import { pluginDir } from "./helpers";
|
||||
import {
|
||||
fleets,
|
||||
fleetMembers,
|
||||
fleetInventory,
|
||||
} from "../../src/backend/tables.js";
|
||||
import { createFleetRepository } from "../../src/backend/repository.js";
|
||||
|
||||
let db: TestDb | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
db?.close();
|
||||
db = null;
|
||||
});
|
||||
|
||||
function fakeHosts(hosts: PluginHostSummary[]): PluginHosts {
|
||||
return {
|
||||
list: async () => hosts,
|
||||
get: async (hostId) => hosts.find((h) => h.id === hostId) ?? null,
|
||||
checkAccess: async (hostId) => {
|
||||
const found = hosts.find((h) => h.id === hostId);
|
||||
return found
|
||||
? {
|
||||
hasAccess: true,
|
||||
isOwner: true,
|
||||
isShared: false,
|
||||
permissionLevel: "manage",
|
||||
}
|
||||
: { hasAccess: false, isOwner: false, isShared: false };
|
||||
},
|
||||
share: async (hostId) => ({ hostId, shared: true }),
|
||||
listUsers: async () => [],
|
||||
listRoles: async () => [],
|
||||
};
|
||||
}
|
||||
|
||||
async function setup(hosts: PluginHostSummary[]) {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
|
||||
sqlite.exec(
|
||||
`INSERT INTO ssh_data (id) VALUES ${hosts.map((h) => `(${h.id})`).join(", ")}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const fleetsTable = await db.database.define(fleets);
|
||||
const membersTable = await db.database.define(fleetMembers);
|
||||
const inventoryTable = await db.database.define(fleetInventory);
|
||||
const repo = createFleetRepository(
|
||||
db.database,
|
||||
fakeHosts(hosts),
|
||||
fleetsTable,
|
||||
membersTable,
|
||||
inventoryTable,
|
||||
);
|
||||
return repo;
|
||||
}
|
||||
|
||||
function host(
|
||||
id: number,
|
||||
overrides: Partial<PluginHostSummary> = {},
|
||||
): PluginHostSummary {
|
||||
return {
|
||||
id,
|
||||
userId: "user-1",
|
||||
name: `host-${id}`,
|
||||
ip: `10.0.0.${id}`,
|
||||
port: 22,
|
||||
username: "root",
|
||||
tags: null,
|
||||
folder: null,
|
||||
authType: "password",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("FleetRepository.listEffectiveMembers", () => {
|
||||
it("unions static membership and tag-matched hosts, deduplicated by id", async () => {
|
||||
const repo = await setup([
|
||||
host(1, { tags: "prod-web,edge" }),
|
||||
host(2, { tags: "prod-web" }),
|
||||
host(3, { tags: "prod-db" }),
|
||||
host(4, { tags: null }),
|
||||
host(5, { userId: "user-2", tags: "prod-web" }),
|
||||
]);
|
||||
|
||||
const fleet = await repo.create("user-1", {
|
||||
name: "web fleet",
|
||||
tagRules: ["prod-web"],
|
||||
});
|
||||
// host 1 matches by tag AND is added statically - must appear once.
|
||||
await repo.addMember(fleet.id, 1);
|
||||
// host 4 has no tags - only reachable via static membership.
|
||||
await repo.addMember(fleet.id, 4);
|
||||
|
||||
const members = await repo.listEffectiveMembers("user-1", fleet.id);
|
||||
const ids = members.map((m) => m.id).sort((a, b) => a - b);
|
||||
|
||||
expect(ids).toEqual([1, 2, 4]);
|
||||
});
|
||||
|
||||
it("never returns another user's hosts even if tags match", async () => {
|
||||
const repo = await setup([
|
||||
host(1, { tags: "prod-web" }),
|
||||
host(5, { userId: "user-2", tags: "prod-web" }),
|
||||
]);
|
||||
|
||||
const fleet = await repo.create("user-1", {
|
||||
name: "web fleet",
|
||||
tagRules: ["prod-web"],
|
||||
});
|
||||
|
||||
const members = await repo.listEffectiveMembers("user-1", fleet.id);
|
||||
expect(members.map((m) => m.id)).not.toContain(5);
|
||||
});
|
||||
|
||||
it("returns only static members when a fleet has no tag rules", async () => {
|
||||
const repo = await setup([host(3, { tags: "prod-db" })]);
|
||||
|
||||
const fleet = await repo.create("user-1", { name: "static fleet" });
|
||||
await repo.addMember(fleet.id, 3);
|
||||
|
||||
const members = await repo.listEffectiveMembers("user-1", fleet.id);
|
||||
expect(members.map((m) => m.id)).toEqual([3]);
|
||||
});
|
||||
|
||||
it("removeMember only drops the static row, not tag-based membership", async () => {
|
||||
const repo = await setup([host(1, { tags: "prod-web" })]);
|
||||
|
||||
const fleet = await repo.create("user-1", {
|
||||
name: "web fleet",
|
||||
tagRules: ["prod-web"],
|
||||
});
|
||||
await repo.addMember(fleet.id, 1);
|
||||
|
||||
await expect(repo.removeMember(fleet.id, 1)).resolves.toBe(true);
|
||||
|
||||
// host 1 still matches the tag rule, so it remains an effective member.
|
||||
const members = await repo.listEffectiveMembers("user-1", fleet.id);
|
||||
expect(members.map((m) => m.id)).toContain(1);
|
||||
});
|
||||
|
||||
it("returns an empty list for a fleet the caller does not own", async () => {
|
||||
const repo = await setup([host(1)]);
|
||||
const fleet = await repo.create("user-1", { name: "private" });
|
||||
|
||||
await expect(
|
||||
repo.listEffectiveMembers("user-2", fleet.id),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FleetRepository CRUD", () => {
|
||||
it("creates, updates and deletes a fleet", async () => {
|
||||
const repo = await setup([host(1)]);
|
||||
const created = await repo.create("user-1", {
|
||||
name: "Fleet",
|
||||
tagRules: ["a"],
|
||||
});
|
||||
expect(created.name).toBe("Fleet");
|
||||
expect(created.tagRules).toBe('["a"]');
|
||||
|
||||
const updated = await repo.update("user-1", created.id, {
|
||||
name: "Renamed",
|
||||
});
|
||||
expect(updated?.name).toBe("Renamed");
|
||||
|
||||
await expect(repo.delete("user-1", created.id)).resolves.toBe(true);
|
||||
await expect(repo.findById("user-1", created.id)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("upserts and lists inventory per host", async () => {
|
||||
const repo = await setup([host(1)]);
|
||||
await repo.upsertInventory("user-1", 1, {
|
||||
osPrettyName: "Debian",
|
||||
kernel: "6.1.0",
|
||||
architecture: "x86_64",
|
||||
hostname: "web-1",
|
||||
uptimeSeconds: 100,
|
||||
ip: "10.0.0.1",
|
||||
packageManager: "apt",
|
||||
});
|
||||
|
||||
const rows = await repo.listInventoryForHosts("user-1", [1]);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({ hostId: 1, osPrettyName: "Debian" });
|
||||
|
||||
// Refreshing overwrites the latest-only snapshot rather than adding a row.
|
||||
await repo.upsertInventory("user-1", 1, {
|
||||
osPrettyName: "Ubuntu",
|
||||
kernel: "6.2.0",
|
||||
architecture: "x86_64",
|
||||
hostname: "web-1",
|
||||
uptimeSeconds: 200,
|
||||
ip: "10.0.0.1",
|
||||
packageManager: "apt",
|
||||
});
|
||||
const refreshed = await repo.listInventoryForHosts("user-1", [1]);
|
||||
expect(refreshed).toHaveLength(1);
|
||||
expect(refreshed[0].osPrettyName).toBe("Ubuntu");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user