Files
Termix/plugins/vault/tests/backend/vault.test.ts
T

464 lines
15 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import { createMockCtx, createTestDb } from "@termix/plugin-sdk/testing";
import {
HOST,
PROFILE,
SIGNED_CERT,
VAULT_ADDR,
createFakeVault,
env,
fakeSocket,
manifest,
pluginDir,
startServer,
type TestServer,
} from "./helpers.js";
const certs = vi.hoisted(() => ({ applyCertificateAuth: vi.fn() }));
vi.mock("@termix/plugin-sdk/ssh-certs", () => certs);
let server: TestServer | null = null;
beforeEach(() => {
certs.applyCertificateAuth.mockReset();
});
afterEach(async () => {
await server?.close();
server = null;
});
/** Creates a profile as user-1 and points host 7 at it. */
async function profileOnHost(options: { shared?: boolean } = {}) {
const created = await server!.request("POST", "/profiles", {
body: { ...PROFILE, shared: options.shared ?? false },
});
expect(created.status).toBe(201);
await server!.mock.ctx.settings.setHost(7, "profileId", created.body.id);
return created.body as { id: number };
}
async function startSignIn(userId = "user-1") {
const socket = fakeSocket();
await server!.provider().startInteraction!({
userId,
hostId: 7,
host: { name: "box", ip: HOST.ip, username: HOST.username },
socket: socket.socket,
requestOrigin: "https://termix.test",
payload: {},
});
return socket;
}
describe("activate", () => {
it("registers the vault auth type, the sync entity and a public callback", async () => {
server = await startServer();
const provider = server.provider();
expect(provider).toMatchObject({
type: "vault",
interaction: "vault",
needsUserInteraction: true,
supportsBackground: false,
});
expect(server.mock.syncEntities).toEqual([
expect.objectContaining({ type: "vaultProfiles", order: 20 }),
]);
expect(server.mock.httpRouters[0]?.public).toEqual(["/oidc/callback"]);
});
// secrets:own and network:outbound are checked when used, ui:surface by the
// frontend.
it.each(["db:own", "network:serve", "auth:provide"])(
"fails closed without %s",
async (capability) => {
const db = await createTestDb(pluginDir);
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities.filter((c) => c !== capability),
db: db.database,
router: () => ({ get: () => {}, use: () => {} }),
});
const { activate } = await import("../../src/backend/index.js");
await expect(activate(mock.ctx)).rejects.toThrow(PluginCapabilityError);
db.close();
},
);
});
describe("profiles", () => {
it("creates, lists, updates and deletes a profile", async () => {
server = await startServer();
const created = await server.request("POST", "/profiles", {
body: { ...PROFILE, vaultNamespace: " team ", tags: ["a", "b"] },
});
expect(created.status).toBe(201);
expect(created.body).toMatchObject({
name: "Prod",
vaultAddr: VAULT_ADDR,
vaultNamespace: "team",
sshRole: "ops",
tags: ["a", "b"],
shared: false,
owned: true,
});
const listed = await server.request("GET", "/profiles");
expect(listed.body).toHaveLength(1);
const updated = await server.request(
"PUT",
`/profiles/${created.body.id}`,
{ body: { name: "Staging", keyType: "ssh-rsa" } },
);
expect(updated.status).toBe(200);
expect(updated.body).toMatchObject({ name: "Staging", keyType: "ssh-rsa" });
const deleted = await server.request(
"DELETE",
`/profiles/${created.body.id}`,
);
expect(deleted.status).toBe(200);
expect((await server.request("GET", "/profiles")).body).toEqual([]);
});
it("drops cached certificates on delete", async () => {
server = await startServer();
const { id } = await profileOnHost();
server.db.sqlite
.prepare(
"INSERT INTO p_vault_tokens (user_id, profile_id, ssh_cert, private_key, expires_at) VALUES (?, ?, ?, ?, ?)",
)
.run("user-1", id, "c", "k", "2999-01-01T00:00:00.000Z");
await server.request("DELETE", `/profiles/${id}`);
expect(
server.db.sqlite
.prepare("SELECT COUNT(*) AS n FROM p_vault_tokens")
.get(),
).toEqual({ n: 0 });
});
it("requires name, address and signer role", async () => {
server = await startServer();
const response = await server.request("POST", "/profiles", {
body: { name: "x" },
});
expect(response.status).toBe(400);
});
it("shows other users only shared profiles, and only the owner may change one", async () => {
server = await startServer();
const own = await server.request("POST", "/profiles", { body: PROFILE });
const shared = await server.request("POST", "/profiles", {
body: { ...PROFILE, name: "Shared", shared: true },
});
const seen = await server.request("GET", "/profiles", { user: "user-2" });
expect(seen.body.map((p: { name: string }) => p.name)).toEqual(["Shared"]);
expect(seen.body[0].owned).toBe(false);
for (const id of [own.body.id, shared.body.id]) {
const put = await server.request("PUT", `/profiles/${id}`, {
user: "user-2",
body: { name: "mine" },
});
expect(put.status).toBe(403);
const del = await server.request("DELETE", `/profiles/${id}`, {
user: "user-2",
});
expect(del.status).toBe(403);
}
});
it("needs the share permission to share a profile", async () => {
server = await startServer({ permissions: ["vault.use"] });
const create = await server.request("POST", "/profiles", {
body: { ...PROFILE, shared: true },
});
expect(create.status).toBe(403);
const own = await server.request("POST", "/profiles", { body: PROFILE });
expect(own.status).toBe(201);
const share = await server.request("PUT", `/profiles/${own.body.id}`, {
body: { shared: true },
});
expect(share.status).toBe(403);
const unshare = await server.request("PUT", `/profiles/${own.body.id}`, {
body: { shared: false },
});
expect(unshare.status).toBe(200);
});
it("refuses every profile route without the use permission", async () => {
server = await startServer({ permissions: [] });
for (const [method, path] of [
["GET", "/profiles"],
["POST", "/profiles"],
["PUT", "/profiles/1"],
["DELETE", "/profiles/1"],
]) {
const response = await server.request(method, path, {
body: method === "GET" ? undefined : PROFILE,
});
expect(response.status, `${method} ${path}`).toBe(403);
}
});
it("answers 404 for a profile that does not exist", async () => {
server = await startServer();
expect(
(await server.request("PUT", "/profiles/99", { body: {} })).status,
).toBe(404);
expect((await server.request("DELETE", "/profiles/99")).status).toBe(404);
expect((await server.request("DELETE", "/profiles/abc")).status).toBe(400);
});
});
describe("signing a certificate", () => {
it("runs the OIDC flow against Vault and stores the certificate sealed", async () => {
const vault = createFakeVault();
server = await startServer({ fetch: vault.fetch });
const { id } = await profileOnHost();
const socket = await startSignIn();
expect(socket.sent).toEqual([
expect.objectContaining({
type: "vault_auth_url",
hostId: 7,
requestId: vault.state,
url: expect.stringContaining("https://idp.test/authorize"),
}),
]);
const authUrlCall = vault.calls[0];
expect(JSON.parse(String(authUrlCall.init?.body))).toMatchObject({
role: "dev",
redirect_uri: "https://termix.test/plugin-api/vault/oidc/callback",
});
expect(authUrlCall.init?.allowPrivateHosts).toEqual(["vault.internal"]);
const callback = await server.request(
"GET",
`/oidc/callback?state=${vault.state}&code=abc`,
{ user: "" },
);
expect(callback.status).toBe(200);
expect(callback.text).toContain("Vault sign-in complete");
expect(socket.sent.at(-1)).toMatchObject({
type: "vault_completed",
hostId: 7,
});
const signCall = vault.calls.find((call) => call.url.includes("/sign/"));
expect(signCall?.url).toBe(`${VAULT_ADDR}/v1/ssh-client-signer/sign/ops`);
expect(signCall?.init?.headers?.["X-Vault-Token"]).toBe("hvs.token");
const row = server.db.sqlite
.prepare("SELECT * FROM p_vault_tokens WHERE profile_id = ?")
.get(id) as { ssh_cert: string; private_key: string };
expect(row.ssh_cert).not.toBe(SIGNED_CERT);
expect(await server.mock.ctx.secrets.unseal(row.ssh_cert)).toBe(
SIGNED_CERT,
);
expect(await server.mock.ctx.secrets.unseal(row.private_key)).toContain(
"OPENSSH PRIVATE KEY",
);
const outcome = await server.provider().prepare({}, HOST, env());
expect(outcome).toEqual({ status: "ready" });
expect(certs.applyCertificateAuth).toHaveBeenCalledWith(
{},
{},
expect.objectContaining({ certificate: SIGNED_CERT }),
"root",
);
});
it("answers a second use of the same callback with an error", async () => {
const vault = createFakeVault();
server = await startServer({ fetch: vault.fetch });
await profileOnHost();
await startSignIn();
const path = `/oidc/callback?state=${vault.state}&code=abc`;
expect((await server.request("GET", path)).status).toBe(200);
expect((await server.request("GET", path)).status).toBe(400);
});
it("reports Vault refusing to sign", async () => {
const vault = createFakeVault({ signStatus: 403 });
server = await startServer({ fetch: vault.fetch });
await profileOnHost();
const socket = await startSignIn();
const callback = await server.request(
"GET",
`/oidc/callback?state=${vault.state}&code=abc`,
);
expect(callback.status).toBe(400);
expect(socket.sent.at(-1)).toMatchObject({
type: "vault_error",
error: expect.stringContaining("permission denied"),
});
});
it("passes an identity provider error through", async () => {
server = await startServer();
const callback = await server.request(
"GET",
"/oidc/callback?error=access_denied",
);
expect(callback.status).toBe(400);
expect(callback.text).toContain("access_denied");
});
it("sends the 2.8 redirect URI while legacyCallback is on", async () => {
const vault = createFakeVault();
server = await startServer({
fetch: vault.fetch,
settings: { legacyCallback: true },
});
await profileOnHost();
await startSignIn();
expect(JSON.parse(String(vault.calls[0].init?.body)).redirect_uri).toBe(
"https://termix.test/vault/oidc/callback",
);
});
it("lets a pending sign-in be cancelled", async () => {
const vault = createFakeVault();
server = await startServer({ fetch: vault.fetch });
await profileOnHost();
await startSignIn();
await server.provider().cancelInteraction!({ userId: "user-1", hostId: 7 });
const callback = await server.request(
"GET",
`/oidc/callback?state=${vault.state}&code=abc`,
);
expect(callback.status).toBe(400);
});
it("refuses to start without a profile on the host", async () => {
server = await startServer();
await expect(startSignIn()).rejects.toThrow(/No Vault signer profile/);
});
});
describe("the vault provider", () => {
it("fails without a profile on the host", async () => {
server = await startServer();
expect(await server.provider().prepare({}, HOST, env())).toMatchObject({
status: "error",
message: "Host has no Vault signer profile configured",
});
});
it("asks for a sign-in without a certificate", async () => {
server = await startServer();
await profileOnHost();
expect(await server.provider().prepare({}, HOST, env())).toEqual({
status: "interaction-required",
interaction: "vault",
message: expect.any(String),
flag: "requiresVaultAuth",
});
});
it("uses a profile another user shared with the host's owner", async () => {
server = await startServer();
const shared = await server.request("POST", "/profiles", {
user: "user-2",
body: { ...PROFILE, shared: true },
});
await server.mock.ctx.settings.setHost(7, "profileId", shared.body.id);
expect((await server.provider().prepare({}, HOST, env())).status).toBe(
"interaction-required",
);
});
it("drops a certificate 2.8 cached with the user's data key", async () => {
server = await startServer();
const { id } = await profileOnHost();
server.db.sqlite
.prepare(
"INSERT INTO p_vault_tokens (user_id, profile_id, ssh_cert, private_key, expires_at) VALUES (?, ?, ?, ?, ?)",
)
.run(
"user-1",
id,
"v1:dek-cipher",
"v1:dek-cipher",
"2999-01-01T00:00:00.000Z",
);
const outcome = await server.provider().prepare({}, HOST, env());
expect(outcome.status).toBe("interaction-required");
expect(
server.db.sqlite
.prepare("SELECT COUNT(*) AS n FROM p_vault_tokens")
.get(),
).toEqual({ n: 0 });
});
it("forgets an expired certificate", async () => {
server = await startServer();
const { id } = await profileOnHost();
const seal = server.mock.ctx.secrets.seal;
server.db.sqlite
.prepare(
"INSERT INTO p_vault_tokens (user_id, profile_id, ssh_cert, private_key, expires_at) VALUES (?, ?, ?, ?, ?)",
)
.run(
"user-1",
id,
await seal("c"),
await seal("k"),
"2000-01-01T00:00:00.000Z",
);
expect((await server.provider().prepare({}, HOST, env())).status).toBe(
"interaction-required",
);
expect(
server.db.sqlite
.prepare("SELECT COUNT(*) AS n FROM p_vault_tokens")
.get(),
).toEqual({ n: 0 });
});
it("drops the certificate when the host rejects it", async () => {
const vault = createFakeVault();
server = await startServer({ fetch: vault.fetch });
await profileOnHost();
await startSignIn();
await server.request("GET", `/oidc/callback?state=${vault.state}&code=abc`);
const outcome = server.provider().onAuthFailed!(HOST, env(), {
error: new Error("All configured authentication methods failed"),
retries: 0,
canRetry: false,
methodNotAvailable: false,
});
expect(outcome).toMatchObject({ status: "interaction-required" });
await vi.waitFor(() =>
expect(
server!.db.sqlite
.prepare("SELECT COUNT(*) AS n FROM p_vault_tokens")
.get(),
).toEqual({ n: 0 }),
);
});
it("ignores other auth failures", async () => {
server = await startServer();
expect(
server.provider().onAuthFailed!(HOST, env(), {
error: new Error("connection reset"),
retries: 0,
canRetry: false,
methodNotAvailable: false,
}),
).toBeUndefined();
});
});