Files
kandev/apps/web/e2e/tests/ssh/test-endpoint.spec.ts
T
2026-06-29 19:06:33 +01:00

184 lines
6.8 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";
import { execInContainer } from "../../helpers/ssh";
/**
* HTTP contract for POST /api/v1/ssh/test. The endpoint dials the host with a
* permissive host-key callback (records the fingerprint but does not pin it),
* runs uname / platform / git probes, and reports whether agentctl is already
* cached on the remote. Drives the "Test Connection" UI flow.
*
* Covers e2e-plan.md group F (F1–F6).
*/
test.describe("ssh test-endpoint contract", () => {
// F2 — happy-path step ordering against a reachable sshd container.
test("returns step ordering Resolve target -> SSH handshake -> Probe remote -> Verify platform -> Verify agentctl cache", async ({
apiClient,
seedData,
}) => {
const result = await apiClient.testSSHConnection({
name: "F2",
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);
expect(result.fingerprint).toBe(seedData.sshTarget.hostFingerprint);
expect(result.os).toBe("Linux");
expect(result.arch).toBe("x86_64");
expect(result.platform).toBe("linux/amd64");
const stepNames = result.steps.map((s) => s.name);
expect(stepNames).toEqual([
"Resolve target",
"SSH handshake",
"Probe remote",
"Verify platform",
"Verify agentctl cache",
]);
for (const step of result.steps) {
expect(step.success, `step ${step.name} should succeed`).toBe(true);
}
});
// F1 — invalid body shape returns 400 (FastAPI/gin produces a structured error).
test("rejects malformed body with 400", async ({ apiClient }) => {
const res = await apiClient.rawRequest("POST", "/api/v1/ssh/test", "not json");
expect(res.status).toBe(400);
});
// F3 — host required either directly or via host_alias. ResolveSSHTarget short-circuits.
test("Resolve target step fails when both host and host_alias are empty", async ({
apiClient,
}) => {
const result = await apiClient.testSSHConnection({
name: "F3",
user: "kandev",
identity_source: "agent",
});
expect(result.success).toBe(false);
expect(result.steps[0]?.name).toBe("Resolve target");
expect(result.steps[0]?.success).toBe(false);
expect(result.steps[0]?.error).toMatch(/host is required/i);
// Should not have proceeded to handshake / probe steps.
expect(result.steps.length).toBe(1);
});
// F4 — handshake against a closed port surfaces verbatim, no probe steps emitted.
test("handshake failure short-circuits with no probe steps", async ({ apiClient, seedData }) => {
const result = await apiClient.testSSHConnection({
name: "F4",
host: "127.0.0.1",
port: 1, // reserved, refused
user: "kandev",
identity_source: "file",
identity_file: seedData.sshTarget.identityFile,
});
expect(result.success).toBe(false);
const stepNames = result.steps.map((s) => s.name);
expect(stepNames).toContain("Resolve target");
expect(stepNames).toContain("SSH handshake");
expect(stepNames).not.toContain("Probe remote");
expect(stepNames).not.toContain("Verify platform");
const handshake = result.steps.find((s) => s.name === "SSH handshake");
expect(handshake?.success).toBe(false);
expect(handshake?.error).toMatch(/tcp dial|connection refused|handshake/i);
});
// F5 — agentctl cache step states (cached vs upload-on-launch).
test("agentctl cache step reports cached after a real launch primes it", async ({
apiClient,
seedData,
}) => {
// Wipe any prior agentctl + sha256 sidecar from the per-worker container
// so the "before" probe sees a fresh-install state regardless of which
// tests ran earlier in this worker.
execInContainer(seedData.sshTarget, [
"rm",
"-f",
"/home/kandev/.kandev/bin/agentctl",
"/home/kandev/.kandev/bin/agentctl.sha256",
]);
// First test run: agentctl has never been uploaded, status = "needs_upload"
// (the test endpoint only probes for the sidecar; the actual upload runs
// on the first real launch below).
const before = await apiClient.testSSHConnection({
name: "F5-before",
host: seedData.sshTarget.host,
port: seedData.sshTarget.port,
user: seedData.sshTarget.user,
identity_source: "file",
identity_file: seedData.sshTarget.identityFile,
});
expect(before.success).toBe(true);
expect(before.agentctl_action).toBe("needs_upload");
// Now actually run a task so the SSH executor uploads agentctl + sidecar.
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"F5 prime agentctl cache",
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 () => {
const env = await apiClient.getTaskEnvironment(task.id);
return env?.executor_type ?? null;
},
{ message: "task should pick up the SSH executor", timeout: 60_000 },
)
.toBe("ssh");
// Subsequent test run: sha256 matches, status = "cached".
const after = await apiClient.testSSHConnection({
name: "F5-after",
host: seedData.sshTarget.host,
port: seedData.sshTarget.port,
user: seedData.sshTarget.user,
identity_source: "file",
identity_file: seedData.sshTarget.identityFile,
});
expect(after.success).toBe(true);
expect(after.agentctl_action).toBe("cached");
});
// F6 — WS dispatcher returns the same shape as the HTTP route. Doesn't
// exercise the binary frames; just confirms the dispatcher routes the
// payload through and the response body matches.
test("ws ssh.test action returns the same shape as POST /api/v1/ssh/test", async ({
apiClient,
seedData,
}) => {
const payload = {
name: "F6",
host: seedData.sshTarget.host,
port: seedData.sshTarget.port,
user: seedData.sshTarget.user,
identity_source: "file" as const,
identity_file: seedData.sshTarget.identityFile,
};
const http = await apiClient.testSSHConnection(payload);
expect(http.success).toBe(true);
expect(http.steps.length).toBeGreaterThanOrEqual(5);
// Hit the WS dispatcher entry directly so a regression in the gateway
// (e.g. the action handler diverging from the HTTP route) is caught
// here, not just at the HTTP layer.
const ws = await apiClient.wsRequest<typeof http>("ssh.test", payload);
expect(ws.success).toBe(true);
expect(ws.steps.map((s) => s.name)).toEqual(http.steps.map((s) => s.name));
});
});