mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
405 lines
13 KiB
TypeScript
405 lines
13 KiB
TypeScript
/**
|
|
* A fixture plugin declares a protocol core has never heard of ("spice") and
|
|
* gets a per-host login stored, encrypted, shared with a recipient (and
|
|
* overridden by them) and resolved, with no core change: only the manifest
|
|
* declaration. Runs on a real database with real repositories, sharing
|
|
* manager and override service; only keys, permissions and audit are faked.
|
|
*/
|
|
|
|
import crypto from "node:crypto";
|
|
import { sql } from "drizzle-orm";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
|
import { TestSqliteDatabase } from "../database/repositories/test-support.js";
|
|
import type { DatabaseContext } from "../../database/repositories/database-context.js";
|
|
|
|
const state = vi.hoisted(() => ({
|
|
db: null as null | { drizzle: unknown },
|
|
keys: new Map<string, Buffer>(),
|
|
levels: new Map<string, string>(),
|
|
}));
|
|
|
|
vi.mock("../../database/db/index.js", () => ({
|
|
getDb: () => state.db!.drizzle,
|
|
getSqlite: () => null,
|
|
}));
|
|
vi.mock("../../utils/database-save-trigger.js", () => ({
|
|
DatabaseSaveTrigger: {
|
|
triggerSave: vi.fn(),
|
|
forceSave: vi.fn(async () => {}),
|
|
},
|
|
}));
|
|
vi.mock("../../utils/audit-logger.js", () => ({
|
|
logAudit: vi.fn(async () => {}),
|
|
getAuditUsername: async (id: string) => id,
|
|
getRequestMeta: () => ({}),
|
|
}));
|
|
vi.mock("../../utils/data-crypto.js", async (importOriginal) => {
|
|
const actual =
|
|
await importOriginal<typeof import("../../utils/data-crypto.js")>();
|
|
const DataCrypto = actual.DataCrypto as unknown as Record<string, unknown>;
|
|
DataCrypto.getUserDataKey = (userId: string) =>
|
|
state.keys.get(userId) ?? null;
|
|
DataCrypto.validateUserAccess = (userId: string) => {
|
|
const key = state.keys.get(userId);
|
|
if (!key) throw new Error(`User ${userId} has no data encryption key`);
|
|
return key;
|
|
};
|
|
return actual;
|
|
});
|
|
vi.mock("../../utils/permission-manager.js", () => ({
|
|
PermissionManager: {
|
|
getInstance: () => ({
|
|
canAccessHost: async (userId: string, _hostId: number) => {
|
|
const level = state.levels.get(userId);
|
|
return level
|
|
? { hasAccess: true, isShared: true, permissionLevel: level }
|
|
: { hasAccess: false };
|
|
},
|
|
hasPermission: async () => true,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
import { setHostProtocolSource } from "../../hosts/protocol-auth/registry.js";
|
|
import {
|
|
loadProtocolAuthSummaries,
|
|
ProtocolAuthWriteError,
|
|
readProtocolAuthPayload,
|
|
writeProtocolAuth,
|
|
} from "../../hosts/protocol-auth/protocol-auth.js";
|
|
import { SharedHostSecretsManager } from "../../utils/shared-host-secrets-manager.js";
|
|
import { SharedHostAuthOverrideService } from "../../utils/shared-host-auth-override-service.js";
|
|
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
|
|
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
|
import { runAsActor } from "../../plugins/actor.js";
|
|
import { sanitizeHostForRecipient } from "../../database/routes/host-normalizers.js";
|
|
import { FieldCrypto } from "../../utils/field-crypto.js";
|
|
|
|
const HOST_ID = 10;
|
|
const GRANT_ID = 1;
|
|
|
|
const manifest = {
|
|
id: "fixture-spice",
|
|
name: "Fixture Spice",
|
|
version: "1.0.0",
|
|
description: "",
|
|
author: { name: "test" },
|
|
license: "MIT",
|
|
category: "Productivity",
|
|
engine: { termix: ">=2.9.0", api: "1" },
|
|
capabilities: ["credentials:read"],
|
|
contributes: {
|
|
protocols: [
|
|
{
|
|
id: "spice",
|
|
credentialFields: [{ key: "display" }, { key: "ticket", secret: true }],
|
|
},
|
|
],
|
|
},
|
|
} as PluginManifest;
|
|
|
|
let database: TestSqliteDatabase | null = null;
|
|
let context: DatabaseContext;
|
|
|
|
async function exec(query: string): Promise<void> {
|
|
await context.drizzle.run(sql.raw(query));
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
database = new TestSqliteDatabase("sqlite");
|
|
context = await database.connect();
|
|
state.db = context;
|
|
state.keys = new Map([
|
|
["owner", crypto.randomBytes(32)],
|
|
["recipient", crypto.randomBytes(32)],
|
|
]);
|
|
state.levels = new Map([["recipient", "connect"]]);
|
|
for (const statement of [
|
|
`INSERT INTO users (id, username, password_hash) VALUES
|
|
('owner', 'owner', 'x'), ('recipient', 'recipient', 'x')`,
|
|
`INSERT INTO ssh_credentials (id, user_id, name, username, password, auth_type)
|
|
VALUES (5, 'recipient', 'mine', 'own-user', 'own-pass', 'password'),
|
|
(6, 'owner', 'owners', 'cred-user', 'cred-pass', 'password')`,
|
|
`INSERT INTO ssh_data (id, user_id, name, ip, port, username, password, auth_type)
|
|
VALUES (${HOST_ID}, 'owner', 'desk', '10.0.0.10', 22, 'root', 'ssh-pass', 'password')`,
|
|
`INSERT INTO host_access (id, host_id, user_id, granted_by, permission_level)
|
|
VALUES (${GRANT_ID}, ${HOST_ID}, 'recipient', 'owner', 'connect')`,
|
|
`INSERT INTO plugins (id, name, version, state, manifest_json)
|
|
VALUES ('fixture-spice', 'Fixture Spice', '1.0.0', 'enabled', '{}')`,
|
|
`INSERT INTO plugin_permission_grants (plugin_id, capability, source)
|
|
VALUES ('fixture-spice', 'credentials:read', 'bundled')`,
|
|
]) {
|
|
await exec(statement);
|
|
}
|
|
invalidatePluginPermissionCache();
|
|
// What the loader does with every installed manifest.
|
|
setHostProtocolSource(() =>
|
|
manifest.contributes!.protocols!.map((protocol) => ({
|
|
...protocol,
|
|
pluginId: manifest.id,
|
|
pluginName: manifest.name,
|
|
})),
|
|
);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
setHostProtocolSource(() => []);
|
|
await database?.close();
|
|
database = null;
|
|
});
|
|
|
|
function pluginCtx() {
|
|
return createPluginContext(
|
|
manifest,
|
|
createPluginHandle(manifest.id, { activate: () => {} }),
|
|
);
|
|
}
|
|
|
|
async function storeLogin(): Promise<void> {
|
|
const patch = readProtocolAuthPayload({
|
|
protocolAuth: {
|
|
spice: {
|
|
authType: "direct",
|
|
username: "viewer",
|
|
password: "spice-pass",
|
|
fields: { display: "2", ticket: "t-secret" },
|
|
},
|
|
unknown: { username: "ignored" },
|
|
},
|
|
});
|
|
await writeProtocolAuth("owner", HOST_ID, patch!, { isOwner: true });
|
|
}
|
|
|
|
async function storedRow(): Promise<Record<string, unknown>> {
|
|
const rows = (await context.drizzle.all(
|
|
sql.raw(`SELECT * FROM host_protocol_auth WHERE host_id = ${HOST_ID}`),
|
|
)) as Record<string, unknown>[];
|
|
expect(rows).toHaveLength(1);
|
|
return rows[0];
|
|
}
|
|
|
|
describe("a plugin protocol core does not know", () => {
|
|
it("stores the login encrypted with the owner's key, secrets apart", async () => {
|
|
await storeLogin();
|
|
const row = await storedRow();
|
|
|
|
expect(row.protocol).toBe("spice");
|
|
expect(row.username).toBe("viewer");
|
|
expect(JSON.parse(row.fields as string)).toEqual({ display: "2" });
|
|
expect(String(row.password)).not.toContain("spice-pass");
|
|
expect(String(row.secret_fields)).not.toContain("t-secret");
|
|
expect(
|
|
FieldCrypto.decryptField(
|
|
row.password as string,
|
|
state.keys.get("owner")!,
|
|
"",
|
|
"password",
|
|
),
|
|
).toBe("spice-pass");
|
|
expect(() =>
|
|
FieldCrypto.decryptField(
|
|
row.password as string,
|
|
state.keys.get("recipient")!,
|
|
"",
|
|
"password",
|
|
),
|
|
).toThrow();
|
|
|
|
const summaries = await loadProtocolAuthSummaries([{ id: HOST_ID }]);
|
|
expect(summaries.get(HOST_ID)).toEqual({
|
|
spice: {
|
|
authType: "direct",
|
|
credentialId: null,
|
|
username: "viewer",
|
|
fields: { display: "2" },
|
|
hasPassword: true,
|
|
secretFieldKeys: ["ticket"],
|
|
},
|
|
});
|
|
});
|
|
|
|
it("resolves the owner's login for the declaring plugin", async () => {
|
|
await storeLogin();
|
|
const target = await runAsActor("owner", "request", () =>
|
|
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
|
|
);
|
|
expect(target?.shared).toBe(false);
|
|
expect(target?.auth).toEqual({
|
|
authType: "direct",
|
|
username: "viewer",
|
|
password: "spice-pass",
|
|
fields: { display: "2", ticket: "t-secret" },
|
|
});
|
|
});
|
|
|
|
it("treats a tampered password as missing, never as a value", async () => {
|
|
await storeLogin();
|
|
const row = await storedRow();
|
|
const envelope = JSON.parse(row.password as string);
|
|
envelope.tag = "0".repeat(envelope.tag.length);
|
|
await exec(
|
|
`UPDATE host_protocol_auth SET password = '${JSON.stringify(envelope)}' WHERE host_id = ${HOST_ID}`,
|
|
);
|
|
|
|
const target = await runAsActor("owner", "request", () =>
|
|
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
|
|
);
|
|
expect(target?.auth.password).toBe("");
|
|
expect(target?.auth.fields.ticket).toBe("t-secret");
|
|
});
|
|
|
|
it("gives a recipient the shared snapshot, not the owner's secret", async () => {
|
|
await storeLogin();
|
|
await SharedHostSecretsManager.getInstance().snapshotForUser(
|
|
GRANT_ID,
|
|
HOST_ID,
|
|
"recipient",
|
|
"owner",
|
|
);
|
|
// The owner's row loses its password without a resync: the recipient
|
|
// keeps reading their own copy, never the owner's row.
|
|
await exec(
|
|
`UPDATE host_protocol_auth SET password = NULL WHERE host_id = ${HOST_ID}`,
|
|
);
|
|
|
|
const target = await runAsActor("recipient", "request", () =>
|
|
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
|
|
);
|
|
expect(target?.shared).toBe(true);
|
|
expect(target?.auth).toEqual({
|
|
authType: "direct",
|
|
username: "viewer",
|
|
password: "spice-pass",
|
|
fields: { display: "2", ticket: "t-secret" },
|
|
});
|
|
});
|
|
|
|
it("uses the recipient's own override over the shared snapshot", async () => {
|
|
await storeLogin();
|
|
await SharedHostSecretsManager.getInstance().snapshotForUser(
|
|
GRANT_ID,
|
|
HOST_ID,
|
|
"recipient",
|
|
"owner",
|
|
);
|
|
await SharedHostAuthOverrideService.getInstance().setCredentialId(
|
|
HOST_ID,
|
|
"recipient",
|
|
"spice",
|
|
5,
|
|
);
|
|
|
|
const target = await runAsActor("recipient", "request", () =>
|
|
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
|
|
);
|
|
expect(target?.auth).toEqual({
|
|
authType: "credential",
|
|
username: "own-user",
|
|
password: "own-pass",
|
|
// The owner's plain fields, never their secret ones.
|
|
fields: { display: "2", ticket: "" },
|
|
});
|
|
});
|
|
|
|
it("refuses an override for a protocol nobody declares", async () => {
|
|
await expect(
|
|
SharedHostAuthOverrideService.getInstance().setCredentialId(
|
|
HOST_ID,
|
|
"recipient",
|
|
"made-up",
|
|
5,
|
|
),
|
|
).rejects.toThrow(/No plugin declares/);
|
|
});
|
|
|
|
it("follows a credential the owner points the login at", async () => {
|
|
const patch = readProtocolAuthPayload({
|
|
protocolAuth: { spice: { authType: "credential", credentialId: 6 } },
|
|
});
|
|
await writeProtocolAuth("owner", HOST_ID, patch!, { isOwner: true });
|
|
|
|
const target = await runAsActor("owner", "request", () =>
|
|
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
|
|
);
|
|
expect(target?.auth).toMatchObject({
|
|
authType: "credential",
|
|
username: "cred-user",
|
|
password: "cred-pass",
|
|
});
|
|
});
|
|
|
|
it("summarizes the usable credential username without exposing its secret", async () => {
|
|
const patch = readProtocolAuthPayload({
|
|
protocolAuth: { spice: { authType: "credential", credentialId: 6 } },
|
|
});
|
|
await writeProtocolAuth("owner", HOST_ID, patch!, { isOwner: true });
|
|
const summaries = await loadProtocolAuthSummaries([{ id: HOST_ID }]);
|
|
expect(summaries.get(HOST_ID)?.spice.username).toBe("cred-user");
|
|
expect(JSON.stringify(summaries.get(HOST_ID))).not.toContain("cred-pass");
|
|
const recipient = sanitizeHostForRecipient(
|
|
{ protocolAuth: summaries.get(HOST_ID) },
|
|
"connect",
|
|
);
|
|
expect(recipient.protocolAuth).toEqual({
|
|
spice: { authType: "credential" },
|
|
});
|
|
});
|
|
|
|
it("lets a shared editor change the login but not its credential", async () => {
|
|
await storeLogin();
|
|
const rename = readProtocolAuthPayload({
|
|
protocolAuth: { spice: { username: "renamed", password: "" } },
|
|
});
|
|
await writeProtocolAuth("owner", HOST_ID, rename!, { isOwner: false });
|
|
expect((await storedRow()).username).toBe("renamed");
|
|
// An empty password from an editor who cannot see it keeps it.
|
|
const target = await runAsActor("owner", "request", () =>
|
|
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
|
|
);
|
|
expect(target?.auth.password).toBe("spice-pass");
|
|
|
|
const repoint = readProtocolAuthPayload({
|
|
protocolAuth: { spice: { authType: "credential", credentialId: 6 } },
|
|
});
|
|
await expect(
|
|
writeProtocolAuth("owner", HOST_ID, repoint!, { isOwner: false }),
|
|
).rejects.toBeInstanceOf(ProtocolAuthWriteError);
|
|
const remove = readProtocolAuthPayload({ protocolAuth: { spice: null } });
|
|
await writeProtocolAuth("owner", HOST_ID, remove!, { isOwner: false });
|
|
expect(
|
|
(await context.drizzle.all(
|
|
sql.raw(`SELECT id FROM host_protocol_auth`),
|
|
)) as unknown[],
|
|
).toHaveLength(0);
|
|
});
|
|
|
|
it("shows each share level only what it may see", async () => {
|
|
await storeLogin();
|
|
const summaries = await loadProtocolAuthSummaries([{ id: HOST_ID }]);
|
|
const host = {
|
|
id: HOST_ID,
|
|
name: "desk",
|
|
ip: "10.0.0.10",
|
|
protocolAuth: summaries.get(HOST_ID),
|
|
};
|
|
|
|
expect(
|
|
sanitizeHostForRecipient({ ...host }, "connect").protocolAuth,
|
|
).toEqual({ spice: { authType: "direct" } });
|
|
for (const level of ["view", "edit", "manage"]) {
|
|
expect(
|
|
sanitizeHostForRecipient({ ...host }, level).protocolAuth,
|
|
level,
|
|
).toEqual({
|
|
spice: {
|
|
authType: "direct",
|
|
credentialId: null,
|
|
username: "viewer",
|
|
fields: { display: "2" },
|
|
},
|
|
});
|
|
}
|
|
});
|
|
});
|