184 lines
6.8 KiB
TypeScript
184 lines
6.8 KiB
TypeScript
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));
|
||
});
|
||
});
|