Files
kandev/apps/web/e2e/tests/ssh/executor-crud.spec.ts
T
José Almeida c5d678a37a test(e2e): add SSH executor Playwright suite (17 specs, ~75 cases)
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.
2026-05-17 23:45:07 +01:00

146 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect } from "../../fixtures/ssh-test-base";
/**
* CRUD against the SSH executor record + profile. Asserts type, config map,
* profile workdir round-trip, and that running-session warnings on
* edit/delete are surfaced (UI is purely client-side via window.confirm; we
* assert the backend semantics — config snapshots, no auto-stop).
*
* Covers e2e-plan.md group D (D1–D7).
*/
test.describe("ssh executor CRUD", () => {
test("POST /api/v1/executors with type=ssh persists host_fingerprint in Config", async ({
apiClient,
seedData,
}) => {
const exec = await apiClient.createSSHExecutor("D1 manual create", {
ssh_host: seedData.sshTarget.host,
ssh_port: String(seedData.sshTarget.port),
ssh_user: seedData.sshTarget.user,
ssh_identity_source: "file",
ssh_identity_file: seedData.sshTarget.identityFile,
ssh_host_fingerprint: seedData.sshTarget.hostFingerprint,
});
expect(exec.type).toBe("ssh");
const persisted = await apiClient.getExecutor(exec.id);
expect(persisted.config?.ssh_host_fingerprint).toBe(seedData.sshTarget.hostFingerprint);
expect(persisted.config?.ssh_host).toBe(seedData.sshTarget.host);
expect(persisted.config?.ssh_user).toBe(seedData.sshTarget.user);
});
test("listExecutors returns the ssh row with the right type", async ({ apiClient, seedData }) => {
const { executors } = await apiClient.listExecutors();
const ssh = executors.find((e) => e.id === seedData.sshExecutorId);
expect(ssh).toBeDefined();
expect(ssh!.type).toBe("ssh");
});
test("profile workdir_root survives round-trip", async ({ apiClient, seedData }) => {
const profile = await apiClient.createExecutorProfile(seedData.sshExecutorId, {
name: "D3 workdir round-trip",
config: { ssh_workdir_root: "~/.kandev-d3" },
prepare_script: "",
cleanup_script: "",
env_vars: [],
});
const persisted = await apiClient.getExecutorProfile(seedData.sshExecutorId, profile.id);
expect(persisted.config?.ssh_workdir_root).toBe("~/.kandev-d3");
});
test("editing executor with a running session does NOT stop it", async ({
apiClient,
seedData,
}) => {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"D4 keep-session-alive",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
executor_profile_id: seedData.sshExecutorProfileId,
},
);
await expect
.poll(async () => (await apiClient.getTaskEnvironment(task.id))?.executor_type ?? null, {
timeout: 60_000,
})
.toBe("ssh");
// Mutate the executor's name. Backend should accept silently; running
// sessions retain their snapshot of host/user/etc. in metadata.
await apiClient.updateExecutor(seedData.sshExecutorId, { name: "D4 renamed" });
const renamed = await apiClient.getExecutor(seedData.sshExecutorId);
expect(renamed.name).toBe("D4 renamed");
const sessions = await apiClient.listSSHSessions(seedData.sshExecutorId);
const row = sessions.find((s) => s.task_id === task.id);
expect(row, "session should still be reported as active after rename").toBeDefined();
});
test("changing executor config does not change live session's metadata snapshot", async ({
apiClient,
seedData,
}) => {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"D5 metadata snapshot",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
executor_profile_id: seedData.sshExecutorProfileId,
},
);
await expect
.poll(async () => (await apiClient.getTaskEnvironment(task.id))?.executor_type ?? null, {
timeout: 60_000,
})
.toBe("ssh");
const before = await apiClient.listSSHSessions(seedData.sshExecutorId);
const beforeRow = before.find((s) => s.task_id === task.id);
expect(beforeRow).toBeDefined();
const originalHost = beforeRow!.host;
// Bait: change the executor's host to something different. The live
// session must keep its snapshot.
await apiClient.updateExecutor(seedData.sshExecutorId, {
config: {
ssh_host: "10.255.255.1",
ssh_port: String(seedData.sshTarget.port),
ssh_user: seedData.sshTarget.user,
ssh_identity_source: "file",
ssh_identity_file: seedData.sshTarget.identityFile,
ssh_host_fingerprint: seedData.sshTarget.hostFingerprint,
},
});
const after = await apiClient.listSSHSessions(seedData.sshExecutorId);
const afterRow = after.find((s) => s.task_id === task.id);
expect(afterRow).toBeDefined();
expect(afterRow!.host).toBe(originalHost);
});
test("delete executor profile is accepted regardless of session count", async ({
apiClient,
seedData,
}) => {
// Spin up a throwaway profile so we don't break the rest of the suite.
const profile = await apiClient.createExecutorProfile(seedData.sshExecutorId, {
name: "D6 throwaway",
config: {},
prepare_script: "",
cleanup_script: "",
env_vars: [],
});
await apiClient.deleteExecutorProfile(profile.id);
// No throw == accepted. Backend doesn't 4xx on dead profile delete either.
});
});