172 lines
6.0 KiB
TypeScript
172 lines
6.0 KiB
TypeScript
import { EventEmitter } from "node:events";
|
|
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { PassThrough } from "node:stream";
|
|
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
|
import type { Client, ClientConfig } from "pg";
|
|
import { afterEach, expect, test, vi } from "vitest";
|
|
import {
|
|
buildSshArguments,
|
|
ConcreteCatalogPostgresAccess,
|
|
} from "../src/catalog/postgres-access.js";
|
|
import { CATALOG_SECRET_IDS } from "../src/catalog/secrets.js";
|
|
import type { WorkspaceDatabase } from "../src/catalog/types.js";
|
|
import { WorkspaceSecretStore } from "../src/workspaces/secret-store.js";
|
|
|
|
const roots: string[] = [];
|
|
|
|
afterEach(() => {
|
|
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
function secretStore() {
|
|
const root = mkdtempSync(join(tmpdir(), "catalog-ssh-secrets-"));
|
|
const runtimeRoot = mkdtempSync(join(tmpdir(), "catalog-ssh-runtime-"));
|
|
roots.push(root, runtimeRoot);
|
|
return new WorkspaceSecretStore({ root, runtimeRoot, installationId: "test" });
|
|
}
|
|
|
|
function sshDatabase(): WorkspaceDatabase {
|
|
return {
|
|
id: "11111111-1111-4111-8111-111111111111",
|
|
workspaceId: "psd-clinical",
|
|
engine: "postgres",
|
|
databaseName: "warehouse",
|
|
schema: "datawarehouse",
|
|
version: 4,
|
|
createdAt: "2026-08-27T08:00:00Z",
|
|
updatedAt: "2026-08-27T09:00:00Z",
|
|
connectionStatus: "reachable",
|
|
binding: {
|
|
transport: "ssh_tunnel",
|
|
username: "warehouse_reader",
|
|
sshHost: "bastion.internal",
|
|
sshPort: 2222,
|
|
sshUsername: "tunnel_user",
|
|
sshTargetHost: "postgres.internal",
|
|
sshTargetPort: 5432,
|
|
tlsServername: "postgres.internal",
|
|
},
|
|
};
|
|
}
|
|
|
|
function fakeChild(): ChildProcessWithoutNullStreams {
|
|
const child = new EventEmitter() as EventEmitter & {
|
|
stdin: PassThrough;
|
|
stdout: PassThrough;
|
|
stderr: PassThrough;
|
|
exitCode: number | null;
|
|
signalCode: NodeJS.Signals | null;
|
|
kill: (signal?: NodeJS.Signals | number) => boolean;
|
|
};
|
|
child.stdin = new PassThrough();
|
|
child.stdout = new PassThrough();
|
|
child.stderr = new PassThrough();
|
|
child.exitCode = null;
|
|
child.signalCode = null;
|
|
child.kill = vi.fn((signal: NodeJS.Signals | number = "SIGTERM") => {
|
|
child.signalCode = typeof signal === "string" ? signal : "SIGTERM";
|
|
child.emit("exit", null, child.signalCode);
|
|
return true;
|
|
});
|
|
return child as unknown as ChildProcessWithoutNullStreams;
|
|
}
|
|
|
|
test("builds a strict host-verified OpenSSH stdio tunnel", () => {
|
|
const args = buildSshArguments({
|
|
sshHost: "bastion.internal",
|
|
sshPort: 2222,
|
|
sshUsername: "tunnel_user",
|
|
targetHost: "postgres.internal",
|
|
targetPort: 5432,
|
|
privateKeyFile: "/runtime/id",
|
|
knownHostsFile: "/runtime/known_hosts",
|
|
passphraseFile: "/runtime/passphrase",
|
|
connectTimeoutMs: 5_001,
|
|
});
|
|
|
|
expect(args).toEqual(expect.arrayContaining([
|
|
"-F", "/dev/null",
|
|
"-o", "BatchMode=no",
|
|
"-o", "StrictHostKeyChecking=yes",
|
|
"-o", "UserKnownHostsFile=/runtime/known_hosts",
|
|
"-o", "GlobalKnownHostsFile=/dev/null",
|
|
"-o", "IdentitiesOnly=yes",
|
|
"-o", "IdentityAgent=none",
|
|
"-o", "PasswordAuthentication=no",
|
|
"-o", "KbdInteractiveAuthentication=no",
|
|
"-o", "ConnectTimeout=6",
|
|
"-W", "postgres.internal:5432",
|
|
"--", "tunnel_user@bastion.internal",
|
|
]));
|
|
});
|
|
|
|
test("connects pg through OpenSSH, supplies askpass, and releases all secret leases", async () => {
|
|
const store = secretStore();
|
|
store.putMany("psd-clinical", {
|
|
[CATALOG_SECRET_IDS.password]: "db-password ",
|
|
[CATALOG_SECRET_IDS.sshPrivateKey]: "PRIVATE KEY\n",
|
|
[CATALOG_SECRET_IDS.sshPrivateKeyPassphrase]: "key-passphrase",
|
|
[CATALOG_SECRET_IDS.sshKnownHosts]: "bastion.internal ssh-ed25519 AAAATEST\n",
|
|
[CATALOG_SECRET_IDS.tlsCa]: "CA CERTIFICATE\n",
|
|
});
|
|
const child = fakeChild();
|
|
let clientConfig: ClientConfig | undefined;
|
|
let spawnCall: { command: string; args: readonly string[]; env: NodeJS.ProcessEnv } | undefined;
|
|
const end = vi.fn(async () => undefined);
|
|
const query = vi.fn(async () => ({ rows: [{ ok: true }] }));
|
|
const connect = vi.fn(async () => undefined);
|
|
|
|
const access = new ConcreteCatalogPostgresAccess(store, {
|
|
sshBinary: "/usr/bin/ssh",
|
|
askpassPath: "/app/ssh-askpass.mjs",
|
|
connectTimeoutMs: 5_000,
|
|
spawnSsh: (command, args, options) => {
|
|
spawnCall = { command, args, env: options.env };
|
|
return child;
|
|
},
|
|
createClient: (config) => {
|
|
clientConfig = config;
|
|
return { connect, query, end } as unknown as Client;
|
|
},
|
|
});
|
|
|
|
const client = await access.connect(sshDatabase(), new AbortController().signal);
|
|
expect(connect).toHaveBeenCalledOnce();
|
|
expect(clientConfig).toMatchObject({
|
|
host: "postgres.internal",
|
|
port: 5432,
|
|
database: "warehouse",
|
|
user: "warehouse_reader",
|
|
password: "db-password ",
|
|
connectionTimeoutMillis: 5_000,
|
|
ssl: {
|
|
ca: "CA CERTIFICATE\n",
|
|
servername: "postgres.internal",
|
|
rejectUnauthorized: true,
|
|
},
|
|
});
|
|
expect(clientConfig?.stream).toBeTypeOf("function");
|
|
expect(spawnCall?.command).toBe("/usr/bin/ssh");
|
|
expect(spawnCall?.args.some((argument) => argument.startsWith("IdentityFile="))).toBe(true);
|
|
expect(spawnCall?.args.some((argument) => argument.startsWith("UserKnownHostsFile="))).toBe(true);
|
|
expect(spawnCall?.env).toMatchObject({
|
|
DISPLAY: "thothii",
|
|
SSH_ASKPASS: "/app/ssh-askpass.mjs",
|
|
SSH_ASKPASS_REQUIRE: "force",
|
|
});
|
|
const leasedPaths = spawnCall!.args
|
|
.filter((argument) => argument.startsWith("IdentityFile=") || argument.startsWith("UserKnownHostsFile="))
|
|
.map((argument) => argument.slice(argument.indexOf("=") + 1));
|
|
leasedPaths.push(spawnCall!.env.THT_SSH_PASSPHRASE_FILE!);
|
|
expect(leasedPaths.every(existsSync)).toBe(true);
|
|
|
|
await expect(client.query("SELECT 1", [])).resolves.toEqual({ rows: [{ ok: true }] });
|
|
await client.end();
|
|
|
|
expect(end).toHaveBeenCalledOnce();
|
|
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
|
expect(leasedPaths.some(existsSync)).toBe(false);
|
|
});
|