mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
feat: move vault ssh signing into a plugin (C5)
This commit is contained in:
1 parent
b027717c46
commit
6e56c90aac
198 files changed
+19717
-3445
No files matched your search
@@ -0,0 +1,174 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import express, { type Router } from "express";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type MockPluginContext,
|
||||
type TestDb,
|
||||
type TestDbOptions,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type {
|
||||
PluginFetchInit,
|
||||
PluginSshAuthProvider,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import manifestJson from "../../manifest.json";
|
||||
|
||||
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
|
||||
export const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
export const VAULT_ADDR = "https://vault.internal:8200";
|
||||
export const SIGNED_CERT = "ssh-ed25519-cert-v01@openssh.com AAAAsigned";
|
||||
|
||||
/** A fake Vault answering the three calls the plugin makes. */
|
||||
export function createFakeVault(
|
||||
options: { signStatus?: number; state?: string } = {},
|
||||
) {
|
||||
const calls: Array<{ url: string; init?: PluginFetchInit }> = [];
|
||||
const state = options.state ?? "vault-state-1";
|
||||
const json = (status: number, body: unknown) =>
|
||||
new Response(JSON.stringify(body), { status });
|
||||
|
||||
const fetch = async (url: string, init?: PluginFetchInit) => {
|
||||
calls.push({ url, init });
|
||||
const { pathname } = new URL(url);
|
||||
if (pathname === "/v1/auth/oidc/oidc/auth_url") {
|
||||
return json(200, {
|
||||
data: {
|
||||
auth_url: `https://idp.test/authorize?client_id=termix&state=${state}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (pathname === "/v1/auth/oidc/oidc/callback") {
|
||||
return json(200, { auth: { client_token: "hvs.token" } });
|
||||
}
|
||||
if (pathname.startsWith("/v1/ssh-client-signer/sign/")) {
|
||||
if (options.signStatus && options.signStatus !== 200) {
|
||||
return json(options.signStatus, { errors: ["permission denied"] });
|
||||
}
|
||||
return json(200, { data: { signed_key: SIGNED_CERT } });
|
||||
}
|
||||
return json(404, { errors: ["not found"] });
|
||||
};
|
||||
return { fetch, calls, state };
|
||||
}
|
||||
|
||||
/** A fake terminal socket that records what it was sent. */
|
||||
export function fakeSocket() {
|
||||
const sent: Array<Record<string, unknown>> = [];
|
||||
return {
|
||||
sent,
|
||||
socket: { send: (data: string) => sent.push(JSON.parse(data)) },
|
||||
};
|
||||
}
|
||||
|
||||
export interface TestServer {
|
||||
db: TestDb;
|
||||
mock: MockPluginContext;
|
||||
provider: () => PluginSshAuthProvider;
|
||||
request: (
|
||||
method: string,
|
||||
path: string,
|
||||
options?: { user?: string; body?: unknown },
|
||||
// Route bodies vary per test; asserting on them is the point.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => Promise<{ status: number; body: any; text: string }>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin activated against a real in-memory database, its router mounted
|
||||
* the way core mounts it: JSON parsed and the acting user taken from the
|
||||
* request.
|
||||
*/
|
||||
export async function startServer(
|
||||
options: {
|
||||
permissions?: string[];
|
||||
settings?: Record<string, unknown>;
|
||||
fetch?: (url: string, init?: PluginFetchInit) => Promise<Response>;
|
||||
before?: TestDbOptions["before"];
|
||||
} = {},
|
||||
): Promise<TestServer> {
|
||||
const db = await createTestDb(pluginDir, { before: options.before });
|
||||
for (const user of ["user-1", "user-2"]) {
|
||||
db.sqlite.prepare("INSERT OR IGNORE INTO users (id) VALUES (?)").run(user);
|
||||
}
|
||||
db.sqlite.prepare("INSERT OR IGNORE INTO ssh_data (id) VALUES (?)").run(7);
|
||||
|
||||
let router: Router | null = null;
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities,
|
||||
db: db.database,
|
||||
router: () => (router = express.Router()),
|
||||
permissions: options.permissions,
|
||||
settings: options.settings,
|
||||
fetch: options.fetch,
|
||||
actor: "user-1",
|
||||
});
|
||||
|
||||
const { activate } = await import("../../src/backend/index.js");
|
||||
await activate(mock.ctx);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
mock.setActor(req.header("x-test-user") ?? undefined);
|
||||
next();
|
||||
});
|
||||
app.use((req, res, next) => router!(req, res, next));
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
db,
|
||||
mock,
|
||||
provider: () =>
|
||||
mock.auth.sshAuthProviders.find((provider) => provider.type === "vault")!,
|
||||
async request(method, path, { user = "user-1", body } = {}) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method,
|
||||
redirect: "manual",
|
||||
headers: {
|
||||
"x-test-user": user,
|
||||
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await response.text();
|
||||
let parsed: unknown = null;
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
return { status: response.status, body: parsed, text };
|
||||
},
|
||||
async close() {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const env = (userId = "user-1", hostId = 7) => ({
|
||||
client: {},
|
||||
userId,
|
||||
hostId,
|
||||
purpose: "terminal",
|
||||
interactive: true,
|
||||
log: () => {},
|
||||
});
|
||||
|
||||
export const HOST = { id: 7, ip: "10.0.0.7", port: 22, username: "root" };
|
||||
|
||||
export const PROFILE = {
|
||||
name: "Prod",
|
||||
vaultAddr: VAULT_ADDR,
|
||||
sshRole: "ops",
|
||||
oidcRole: "dev",
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
|
||||
import { pluginDir } from "./helpers";
|
||||
|
||||
// vault_profiles and vault_tokens as core's SQLite bootstrap created them
|
||||
// before 2.9.0, including the sync_id column added later.
|
||||
const LEGACY_DDL = `
|
||||
CREATE TABLE vault_profiles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
vault_addr TEXT NOT NULL,
|
||||
vault_namespace TEXT,
|
||||
oidc_mount TEXT,
|
||||
oidc_role TEXT,
|
||||
ssh_mount TEXT,
|
||||
ssh_role TEXT NOT NULL,
|
||||
valid_principals TEXT,
|
||||
key_type TEXT,
|
||||
shared INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
sync_id TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE vault_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
profile_id INTEGER NOT NULL,
|
||||
ssh_cert TEXT NOT NULL,
|
||||
private_key TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT NOT NULL,
|
||||
last_used TEXT,
|
||||
UNIQUE(user_id, profile_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (profile_id) REFERENCES vault_profiles (id) ON DELETE CASCADE
|
||||
);
|
||||
`;
|
||||
|
||||
let db: TestDb | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
db?.close();
|
||||
db = null;
|
||||
});
|
||||
|
||||
const tableExists = (name: string) =>
|
||||
!!db!.sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(name);
|
||||
|
||||
describe("adopting vault_profiles and vault_tokens", () => {
|
||||
it("keeps every existing row when the legacy tables are there", async () => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
|
||||
sqlite.exec(LEGACY_DDL);
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO vault_profiles (id, user_id, name, vault_addr, ssh_role, shared, sync_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(4, "user-1", "Prod", "https://vault:8200", "ops", 1, "sync-a");
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO vault_tokens (user_id, profile_id, ssh_cert, private_key, expires_at) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run("user-2", 4, "cert", "key", "2999-01-01T00:00:00.000Z");
|
||||
},
|
||||
});
|
||||
|
||||
expect(tableExists("vault_profiles")).toBe(false);
|
||||
expect(tableExists("vault_tokens")).toBe(false);
|
||||
expect(db.sqlite.prepare("SELECT * FROM p_vault_profiles").all()).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 4,
|
||||
user_id: "user-1",
|
||||
name: "Prod",
|
||||
shared: 1,
|
||||
sync_id: "sync-a",
|
||||
}),
|
||||
]);
|
||||
expect(db.sqlite.prepare("SELECT * FROM p_vault_tokens").all()).toEqual([
|
||||
expect.objectContaining({ user_id: "user-2", profile_id: 4 }),
|
||||
]);
|
||||
|
||||
// The 2.8 foreign key follows the rename, so deleting a profile still
|
||||
// takes its cached certificates with it.
|
||||
db.sqlite.exec("PRAGMA foreign_keys = ON");
|
||||
db.sqlite.prepare("DELETE FROM p_vault_profiles WHERE id = 4").run();
|
||||
expect(
|
||||
db.sqlite.prepare("SELECT COUNT(*) AS n FROM p_vault_tokens").get(),
|
||||
).toEqual({ n: 0 });
|
||||
});
|
||||
|
||||
it("creates both tables on a fresh install", async () => {
|
||||
db = await createTestDb(pluginDir);
|
||||
expect(tableExists("p_vault_profiles")).toBe(true);
|
||||
expect(tableExists("p_vault_tokens")).toBe(true);
|
||||
expect(
|
||||
db.sqlite
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_vault_tokens_user_profile'",
|
||||
)
|
||||
.get(),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps one certificate per user and profile", async () => {
|
||||
db = await createTestDb(pluginDir);
|
||||
db.sqlite.exec("INSERT INTO users (id) VALUES ('user-1')");
|
||||
const insert = db.sqlite.prepare(
|
||||
"INSERT INTO p_vault_tokens (user_id, profile_id, ssh_cert, private_key, expires_at) VALUES ('user-1', 1, 'c', 'k', 'x')",
|
||||
);
|
||||
insert.run();
|
||||
expect(() => insert.run()).toThrow(/UNIQUE/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import ssh2Pkg from "ssh2";
|
||||
import type { PluginFetchInit } from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
allowedHosts,
|
||||
completeVaultOidc,
|
||||
generateEphemeralKeyPair,
|
||||
parseCertValidBefore,
|
||||
signWithVault,
|
||||
startVaultOidc,
|
||||
type VaultProfileConfig,
|
||||
} from "../../src/backend/vault-client.js";
|
||||
|
||||
const { utils: ssh2Utils } = ssh2Pkg;
|
||||
|
||||
describe("generateEphemeralKeyPair", () => {
|
||||
for (const keyType of [
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"ssh-rsa",
|
||||
] as const) {
|
||||
it(`generates a parseable ${keyType} keypair`, () => {
|
||||
const pair = generateEphemeralKeyPair(keyType);
|
||||
expect(pair.privateKey).toContain("BEGIN OPENSSH PRIVATE KEY");
|
||||
expect(pair.publicKey.split(/\s+/)[0]).toBe(keyType);
|
||||
expect(ssh2Utils.parseKey(pair.privateKey) instanceof Error).toBe(false);
|
||||
expect(ssh2Utils.parseKey(pair.publicKey) instanceof Error).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
it("defaults to ed25519 for unknown key types", () => {
|
||||
const pair = generateEphemeralKeyPair("nonsense");
|
||||
expect(pair.publicKey.startsWith("ssh-ed25519")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCertValidBefore", () => {
|
||||
let cert = "";
|
||||
let signedAt = 0;
|
||||
let haveSshKeygen = true;
|
||||
|
||||
beforeAll(() => {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "vault-cert-test-"));
|
||||
try {
|
||||
for (const name of ["ca", "user"]) {
|
||||
execFileSync("ssh-keygen", [
|
||||
"-t",
|
||||
"ed25519",
|
||||
"-f",
|
||||
`${dir}/${name}`,
|
||||
"-N",
|
||||
"",
|
||||
"-q",
|
||||
]);
|
||||
}
|
||||
signedAt = Math.floor(Date.now() / 1000);
|
||||
execFileSync("ssh-keygen", [
|
||||
"-s",
|
||||
`${dir}/ca`,
|
||||
"-I",
|
||||
"test-id",
|
||||
"-n",
|
||||
"root",
|
||||
"-V",
|
||||
"+60m",
|
||||
`${dir}/user.pub`,
|
||||
]);
|
||||
cert = readFileSync(`${dir}/user-cert.pub`, "utf8").trim();
|
||||
} catch {
|
||||
haveSshKeygen = false;
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("reads valid_before from a real ssh-keygen certificate", () => {
|
||||
if (!haveSshKeygen) return;
|
||||
const validBefore = parseCertValidBefore(cert);
|
||||
expect(validBefore).toBeGreaterThan(signedAt + 3300);
|
||||
expect(validBefore).toBeLessThan(signedAt + 3900);
|
||||
});
|
||||
|
||||
it("returns 0 for malformed input", () => {
|
||||
expect(parseCertValidBefore("")).toBe(0);
|
||||
expect(parseCertValidBefore("not-a-cert")).toBe(0);
|
||||
expect(parseCertValidBefore("ssh-ed25519 AAAAnotbase64!!")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the Vault HTTP flow", () => {
|
||||
const profile: VaultProfileConfig = {
|
||||
id: 1,
|
||||
vaultAddr: "https://vault.example.com:8200/",
|
||||
vaultNamespace: "team-a",
|
||||
oidcMount: "oidc",
|
||||
oidcRole: "developer",
|
||||
sshMount: "ssh-client-signer",
|
||||
sshRole: "my-role",
|
||||
validPrincipals: "root,deploy",
|
||||
keyType: "ssh-ed25519",
|
||||
};
|
||||
|
||||
function fakeFetch(respond: () => { status?: number; body: unknown }) {
|
||||
const calls: Array<{ url: string; init?: PluginFetchInit }> = [];
|
||||
const fetch = async (url: string, init?: PluginFetchInit) => {
|
||||
calls.push({ url, init });
|
||||
const { status = 200, body } = respond();
|
||||
return new Response(JSON.stringify(body), { status });
|
||||
};
|
||||
return { fetch, calls };
|
||||
}
|
||||
|
||||
it("allows only the profile's own Vault host past the private address guard", () => {
|
||||
expect(allowedHosts(profile)).toEqual(["vault.example.com"]);
|
||||
expect(allowedHosts({ ...profile, vaultAddr: "not a url" })).toEqual([]);
|
||||
});
|
||||
|
||||
it("startVaultOidc posts auth_url and reads the state", async () => {
|
||||
const { fetch, calls } = fakeFetch(() => ({
|
||||
body: {
|
||||
data: {
|
||||
auth_url:
|
||||
"https://idp.example.com/authorize?client_id=x&state=ST-abc123&nonce=n",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const result = await startVaultOidc(
|
||||
fetch,
|
||||
profile,
|
||||
"https://termix/plugin-api/vault/oidc/callback",
|
||||
);
|
||||
|
||||
expect(result.state).toBe("ST-abc123");
|
||||
expect(result.clientNonce).toMatch(/^[0-9a-f]{40}$/);
|
||||
expect(calls[0].url).toBe(
|
||||
"https://vault.example.com:8200/v1/auth/oidc/oidc/auth_url",
|
||||
);
|
||||
expect(calls[0].init?.method).toBe("POST");
|
||||
expect(calls[0].init?.headers?.["X-Vault-Namespace"]).toBe("team-a");
|
||||
expect(JSON.parse(String(calls[0].init?.body))).toMatchObject({
|
||||
role: "developer",
|
||||
redirect_uri: "https://termix/plugin-api/vault/oidc/callback",
|
||||
client_nonce: result.clientNonce,
|
||||
});
|
||||
});
|
||||
|
||||
it("completeVaultOidc returns the client token", async () => {
|
||||
const { fetch, calls } = fakeFetch(() => ({
|
||||
body: { auth: { client_token: "hvs.TESTTOKEN" } },
|
||||
}));
|
||||
|
||||
const token = await completeVaultOidc(fetch, profile, {
|
||||
state: "ST-abc123",
|
||||
code: "auth-code",
|
||||
clientNonce: "nonce123",
|
||||
});
|
||||
|
||||
expect(token).toBe("hvs.TESTTOKEN");
|
||||
const url = new URL(calls[0].url);
|
||||
expect(url.pathname).toBe("/v1/auth/oidc/oidc/callback");
|
||||
expect(url.searchParams.get("state")).toBe("ST-abc123");
|
||||
expect(url.searchParams.get("code")).toBe("auth-code");
|
||||
expect(url.searchParams.get("client_nonce")).toBe("nonce123");
|
||||
expect(calls[0].init?.method).toBe("GET");
|
||||
});
|
||||
|
||||
it("signWithVault posts the public key and returns signed_key", async () => {
|
||||
const { fetch, calls } = fakeFetch(() => ({
|
||||
body: {
|
||||
data: { signed_key: "ssh-ed25519-cert-v01@openssh.com AAAAcert" },
|
||||
},
|
||||
}));
|
||||
|
||||
const cert = await signWithVault(
|
||||
fetch,
|
||||
profile,
|
||||
"hvs.TESTTOKEN",
|
||||
"ssh-ed25519 AAAApub comment",
|
||||
);
|
||||
|
||||
expect(cert).toBe("ssh-ed25519-cert-v01@openssh.com AAAAcert");
|
||||
expect(calls[0].url).toBe(
|
||||
"https://vault.example.com:8200/v1/ssh-client-signer/sign/my-role",
|
||||
);
|
||||
expect(calls[0].init?.headers?.["X-Vault-Token"]).toBe("hvs.TESTTOKEN");
|
||||
expect(JSON.parse(String(calls[0].init?.body))).toMatchObject({
|
||||
public_key: "ssh-ed25519 AAAApub comment",
|
||||
cert_type: "user",
|
||||
valid_principals: "root,deploy",
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces Vault error messages", async () => {
|
||||
const { fetch } = fakeFetch(() => ({
|
||||
status: 400,
|
||||
body: { errors: ["role not found", "permission denied"] },
|
||||
}));
|
||||
await expect(
|
||||
signWithVault(fetch, profile, "tok", "ssh-ed25519 AAAA"),
|
||||
).rejects.toThrow(/role not found; permission denied/);
|
||||
});
|
||||
|
||||
it("reports an unreachable Vault", async () => {
|
||||
const fetch = async () => {
|
||||
throw new Error("refused");
|
||||
};
|
||||
await expect(
|
||||
startVaultOidc(fetch, profile, "https://termix/cb"),
|
||||
).rejects.toThrow(/Failed to reach Vault/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,471 @@
|
||||
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("records a tombstone and 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");
|
||||
const syncId = (
|
||||
server.db.sqlite
|
||||
.prepare("SELECT sync_id FROM p_vault_profiles WHERE id = ?")
|
||||
.get(id) as { sync_id: string }
|
||||
).sync_id;
|
||||
|
||||
await server.request("DELETE", `/profiles/${id}`);
|
||||
|
||||
expect(server.mock.tombstones).toEqual([
|
||||
{ userId: "user-1", entityType: "vaultProfiles", syncId },
|
||||
]);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import * as plugin from "../../src/frontend/index";
|
||||
import { VaultOverlay } from "../../src/frontend/VaultOverlay";
|
||||
import { VaultAuthEditor } from "../../src/frontend/VaultAuthEditor";
|
||||
import { redirectUri } from "../../src/frontend/RedirectUriSetting";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import locales from "../../locales/en.json";
|
||||
|
||||
const stubs = vi.hoisted(() => ({
|
||||
permissions: new Set<string>(["use", "share"]),
|
||||
api: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
},
|
||||
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@termix/plugin-sdk/frontend")>()),
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) =>
|
||||
options?.name ? `${key}:${options.name}` : key,
|
||||
}),
|
||||
usePluginApi: () => stubs.api,
|
||||
usePermission: (permission: string) => stubs.permissions.has(permission),
|
||||
useToast: () => stubs.toast,
|
||||
}));
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
let rendered: RenderedPluginApp | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
stubs.permissions = new Set(["use", "share"]);
|
||||
for (const fn of Object.values(stubs.api)) fn.mockReset();
|
||||
stubs.api.get.mockResolvedValue({ data: [] });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rendered?.deactivate();
|
||||
rendered = null;
|
||||
});
|
||||
|
||||
describe("vault activate", () => {
|
||||
it("registers the terminal overlay and the settings field, and removes them on deactivate", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
expect(rendered.registered.settingsComponents()).toEqual(["redirectUri"]);
|
||||
expect(rendered.registered.slot("terminal.overlay")).toEqual([
|
||||
"vault.signIn",
|
||||
]);
|
||||
await rendered.deactivate();
|
||||
expect(rendered.registered.settingsComponents()).toEqual([]);
|
||||
expect(rendered.registered.slot("terminal.overlay")).toEqual([]);
|
||||
rendered = null;
|
||||
});
|
||||
});
|
||||
|
||||
describe("redirectUri", () => {
|
||||
it("names the plugin callback, or the 2.8 one", () => {
|
||||
expect(redirectUri(false, "https://t.example/app/")).toBe(
|
||||
"https://t.example/app/plugin-api/vault/oidc/callback",
|
||||
);
|
||||
expect(redirectUri(true, "https://t.example/")).toBe(
|
||||
"https://t.example/vault/oidc/callback",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("VaultAuthEditor", () => {
|
||||
const profiles = [
|
||||
{
|
||||
id: 3,
|
||||
name: "Prod",
|
||||
vaultAddr: "https://v",
|
||||
sshRole: "r",
|
||||
shared: false,
|
||||
owned: true,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Team",
|
||||
vaultAddr: "https://v",
|
||||
sshRole: "r",
|
||||
shared: true,
|
||||
owned: false,
|
||||
},
|
||||
];
|
||||
|
||||
it("lists the profiles and stores the choice as the profileId host setting", async () => {
|
||||
stubs.api.get.mockResolvedValue({ data: profiles });
|
||||
const setField = vi.fn();
|
||||
render(
|
||||
<VaultAuthEditor
|
||||
form={{ pluginSettings: { other: { x: 1 } } }}
|
||||
setField={setField}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText("Prod")).toBeTruthy());
|
||||
expect(stubs.api.get).toHaveBeenCalledWith("/profiles");
|
||||
expect(screen.getByText("editor.sharedProfile:Team")).toBeTruthy();
|
||||
|
||||
fireEvent.change(screen.getByRole("combobox"), { target: { value: "4" } });
|
||||
expect(setField).toHaveBeenCalledWith("pluginSettings", {
|
||||
other: { x: 1 },
|
||||
vault: { profileId: 4 },
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the saved profile", async () => {
|
||||
stubs.api.get.mockResolvedValue({ data: profiles });
|
||||
render(
|
||||
<VaultAuthEditor
|
||||
form={{ pluginSettings: { vault: { profileId: 3 } } }}
|
||||
setField={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect((screen.getByRole("combobox") as HTMLSelectElement).value).toBe(
|
||||
"3",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a profile from the manager, shared only with the share permission", async () => {
|
||||
stubs.api.post.mockResolvedValue({ data: {} });
|
||||
render(<VaultAuthEditor form={{}} setField={vi.fn()} />);
|
||||
fireEvent.click(screen.getByText("profiles.manage"));
|
||||
fireEvent.click(screen.getByText("profiles.new"));
|
||||
const inputs = screen.getAllByRole("textbox");
|
||||
fireEvent.change(inputs[0], { target: { value: "Prod" } });
|
||||
fireEvent.change(inputs[1], { target: { value: "https://vault:8200" } });
|
||||
fireEvent.change(inputs[6], { target: { value: "ops" } });
|
||||
fireEvent.click(screen.getByRole("checkbox"));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("profiles.create"));
|
||||
});
|
||||
expect(stubs.api.post).toHaveBeenCalledWith(
|
||||
"/profiles",
|
||||
expect.objectContaining({
|
||||
name: "Prod",
|
||||
vaultAddr: "https://vault:8200",
|
||||
sshRole: "ops",
|
||||
shared: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("hides sharing without the share permission and managing without use", async () => {
|
||||
stubs.permissions = new Set(["use"]);
|
||||
const { unmount } = render(
|
||||
<VaultAuthEditor form={{}} setField={vi.fn()} />,
|
||||
);
|
||||
fireEvent.click(screen.getByText("profiles.manage"));
|
||||
fireEvent.click(screen.getByText("profiles.new"));
|
||||
expect(screen.queryByRole("checkbox")).toBeNull();
|
||||
unmount();
|
||||
|
||||
stubs.permissions = new Set();
|
||||
stubs.api.get.mockClear();
|
||||
render(<VaultAuthEditor form={{}} setField={vi.fn()} />);
|
||||
expect(screen.queryByText("profiles.manage")).toBeNull();
|
||||
expect(stubs.api.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("VaultOverlay", () => {
|
||||
function setup() {
|
||||
let listener: (message: {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}) => void = () => {};
|
||||
const props = {
|
||||
host: { id: 7 },
|
||||
subscribe: vi.fn((next: typeof listener) => {
|
||||
listener = next;
|
||||
return () => {};
|
||||
}),
|
||||
holdConnectTimeout: vi.fn(),
|
||||
fail: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
send: vi.fn(),
|
||||
connectPayload: vi.fn(() => ({ hostId: 7, cols: 80, rows: 24 })),
|
||||
};
|
||||
render(<VaultOverlay {...props} />);
|
||||
const emit = (message: { type: string; [key: string]: unknown }) =>
|
||||
act(() => listener(message));
|
||||
return { props, emit };
|
||||
}
|
||||
|
||||
it("starts the sign-in once, then reports a second request as a failure", () => {
|
||||
const { props, emit } = setup();
|
||||
emit({ type: "vault_auth_required", hostId: 7 });
|
||||
expect(props.send).toHaveBeenCalledWith("vault_start_auth", { hostId: 7 });
|
||||
expect(props.holdConnectTimeout).toHaveBeenCalledWith(true);
|
||||
|
||||
emit({ type: "vault_auth_required", hostId: 7 });
|
||||
expect(props.fail).toHaveBeenCalledWith("dialog.failed");
|
||||
});
|
||||
|
||||
it("opens Vault's sign-in, waits, and reconnects once signed", () => {
|
||||
const close = vi.fn();
|
||||
const open = vi
|
||||
.spyOn(window, "open")
|
||||
.mockReturnValue({ close, focus: vi.fn() } as unknown as Window);
|
||||
const { props, emit } = setup();
|
||||
emit({ type: "vault_auth_required", hostId: 7 });
|
||||
emit({
|
||||
type: "vault_auth_url",
|
||||
hostId: 7,
|
||||
requestId: "st-1",
|
||||
url: "https://idp.test/authorize",
|
||||
});
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
"https://idp.test/authorize",
|
||||
"termix-vault-oidc",
|
||||
"width=540,height=720",
|
||||
);
|
||||
expect(screen.getByText("dialog.description")).toBeTruthy();
|
||||
|
||||
emit({ type: "vault_completed", hostId: 7 });
|
||||
expect(close).toHaveBeenCalled();
|
||||
expect(props.send).toHaveBeenCalledWith("vault_auth_completed", {
|
||||
hostId: 7,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
expect(screen.queryByText("dialog.title")).toBeNull();
|
||||
open.mockRestore();
|
||||
});
|
||||
|
||||
it("cancels a pending sign-in", () => {
|
||||
const open = vi.spyOn(window, "open").mockReturnValue(null);
|
||||
const { props, emit } = setup();
|
||||
emit({
|
||||
type: "vault_auth_url",
|
||||
hostId: 7,
|
||||
requestId: "st-2",
|
||||
url: "https://idp.test/authorize",
|
||||
});
|
||||
fireEvent.click(screen.getByText("common.cancel"));
|
||||
expect(props.send).toHaveBeenCalledWith("vault_cancel", {
|
||||
hostId: 7,
|
||||
requestId: "st-2",
|
||||
});
|
||||
open.mockRestore();
|
||||
});
|
||||
|
||||
it("shows Vault's error", () => {
|
||||
const { emit } = setup();
|
||||
emit({ type: "vault_error", hostId: 7, error: "permission denied" });
|
||||
expect(screen.getByText("permission denied")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user