c5d678a37a
Every spec drives the real sshd container set up by the ssh-test-base fixture; no mocks. Specs are grouped by concern (file per group), mirroring the docker/ directory style. Each one maps 1:1 to a row in docs/specs/ssh-executor/e2e-plan.md. UI surface: - connection-form.spec.ts — field rendering, save/test gating, identity-source toggle, default port. - test-result.spec.ts — successful + failed Test Connection paths, per-step badges, fingerprint surfacing, cached/will-upload copy. - trust-gate.spec.ts — save disabled until trust ticked, edits reset result + trust, fingerprint-change warning when pinned != observed. - sessions-card.spec.ts — empty state, row rendering, manual refresh, status badges, truncation, polling pickup. - persistence.spec.ts — fingerprint persists across reload, new form starts blank, executor list label, partial state doesn't leak. API contract: - test-endpoint.spec.ts — POST /api/v1/ssh/test step ordering, invalid body, host-required, handshake short-circuit, cached/uploaded states. - sessions-endpoint.spec.ts — GET /api/v1/ssh/executors/:id/sessions filters to ssh-runtime rows, populates metadata, monotonic uptime. - executor-crud.spec.ts — type=ssh persists fingerprint, profile workdir round-trip, edit-with-running-sessions keeps snapshots. Real-SSH integration: - launch-task.spec.ts — full smoke: agentctl upload + sha256 cache hit on second launch, task/session dir layout, stop cleans session dir. - recovery.spec.ts — backend restart reconnects, dead pid surfaces failure, persisted metadata keys present. - auth-methods.spec.ts — key + valid, missing key, passphrase-protected rejection, agent without SSH_AUTH_SOCK, wrong-key auth failure. - concurrency.spec.ts — two sessions share task dir distinct ports, two tasks distinct dirs, traffic drop + restore exercises eviction + reconnect. - hostkey-rotation.spec.ts — regenerate host key surfaces new fingerprint, stale pinned fingerprint blocks launch verbatim, re-trust restores function. - config-inheritance.spec.ts — ~/.ssh/config HostName / Port / IdentityFile / ProxyJump inheritance with form-overrides-config rules. - workdir-per-profile.spec.ts — per-profile ssh_workdir_root routes to separate remote workdir trees; default falls back to ~/.kandev. - proxy-jump.spec.ts — 2-container bastion topology asserts target is unreachable directly, reachable via ProxyJump, chained ProxyJump fails. - error-surfacing.spec.ts — TCP refused, auth failed, bastion unreachable surface verbatim; backend 400 stays a 400 not a synthesised failure. Also adds an AGENTS.md (CLAUDE.md symlink) line pointing at the e2e/README.md so future agents discover the containers project + KANDEV_E2E_CONTAINERS env var without grepping playwright.config.ts.
98 lines
3.8 KiB
TypeScript
98 lines
3.8 KiB
TypeScript
import { test, expect } from "../../fixtures/ssh-test-base";
|
||
import { execFileSync } from "node:child_process";
|
||
import fs from "node:fs";
|
||
import path from "node:path";
|
||
|
||
/**
|
||
* The SSH executor commits to surfacing connection errors verbatim — no
|
||
* generic "executor failed" wrapping. Spec lists this as a hard
|
||
* requirement; the suite asserts the strings users see.
|
||
*
|
||
* Covers e2e-plan.md group Q (Q1–Q5).
|
||
*/
|
||
test.describe("ssh error surfacing", () => {
|
||
test("TCP refused — error mentions 'connection refused'", async ({ apiClient }) => {
|
||
const result = await apiClient.testSSHConnection({
|
||
name: "Q1",
|
||
host: "127.0.0.1",
|
||
port: 1,
|
||
user: "kandev",
|
||
identity_source: "agent",
|
||
});
|
||
expect(result.success).toBe(false);
|
||
const handshake = result.steps.find((s) => s.name === "SSH handshake");
|
||
expect(handshake?.error).toMatch(/connection refused|tcp dial/i);
|
||
});
|
||
|
||
test("auth failed — error names the failure mode", async ({ apiClient, seedData }) => {
|
||
const strangerKey = path.join(seedData.sshTarget.workDir, "q2-stranger");
|
||
fs.rmSync(strangerKey, { force: true });
|
||
fs.rmSync(`${strangerKey}.pub`, { force: true });
|
||
execFileSync("ssh-keygen", ["-t", "ed25519", "-f", strangerKey, "-N", "", "-q"]);
|
||
|
||
const result = await apiClient.testSSHConnection({
|
||
name: "Q2 auth fail",
|
||
host: seedData.sshTarget.host,
|
||
port: seedData.sshTarget.port,
|
||
user: seedData.sshTarget.user,
|
||
identity_source: "file",
|
||
identity_file: strangerKey,
|
||
});
|
||
expect(result.success).toBe(false);
|
||
const handshake = result.steps.find((s) => s.name === "SSH handshake");
|
||
expect(handshake?.error).toMatch(
|
||
/handshake|unable to authenticate|permission denied|no supported methods/i,
|
||
);
|
||
});
|
||
|
||
test("ProxyJump unreachable — error mentions the bastion", async ({ apiClient, seedData }) => {
|
||
const result = await apiClient.testSSHConnection({
|
||
name: "Q4 bastion unreachable",
|
||
host: seedData.sshTarget.host,
|
||
port: seedData.sshTarget.port,
|
||
user: seedData.sshTarget.user,
|
||
identity_source: "file",
|
||
identity_file: seedData.sshTarget.identityFile,
|
||
proxy_jump: "127.0.0.1:1",
|
||
});
|
||
expect(result.success).toBe(false);
|
||
const handshake = result.steps.find((s) => s.name === "SSH handshake");
|
||
expect(handshake?.error).toMatch(/bastion|proxy|connection refused/i);
|
||
});
|
||
|
||
test("unsupported arm64 host surfaces the linux/amd64-only guidance", async ({
|
||
apiClient,
|
||
seedData,
|
||
}) => {
|
||
// We can't trivially spin up arm64 sshd, but we can assert the message
|
||
// shape via the existing Verify arch step on the running container.
|
||
// When the host is the expected x86_64, this step succeeds — that's a
|
||
// negative control. The arm64 message itself is unit-tested in
|
||
// executor_ssh_connection_test.go (TestRequireSupportedArch).
|
||
const result = await apiClient.testSSHConnection({
|
||
name: "Q-arch-control",
|
||
host: seedData.sshTarget.host,
|
||
port: seedData.sshTarget.port,
|
||
user: seedData.sshTarget.user,
|
||
identity_source: "file",
|
||
identity_file: seedData.sshTarget.identityFile,
|
||
});
|
||
expect(result.success).toBe(true);
|
||
const arch = result.steps.find((s) => s.name === "Verify arch");
|
||
expect(arch?.success).toBe(true);
|
||
expect(arch?.output).toBe("x86_64");
|
||
});
|
||
|
||
test("backend 5xx on /api/v1/ssh/test is not the same as 'test failed'", async ({
|
||
apiClient,
|
||
}) => {
|
||
// POST with a body that triggers a 400 from the gin route. Asserts the
|
||
// client surfaces the HTTP error verbatim rather than synthesising a
|
||
// SSHTestResult-shaped success=false.
|
||
const res = await apiClient.rawRequest("POST", "/api/v1/ssh/test", "garbage");
|
||
expect(res.status).toBe(400);
|
||
const body = await res.text();
|
||
expect(body).toMatch(/invalid request body|json/i);
|
||
});
|
||
});
|