cbc573b0a0
executor_ssh.targetFromMetadata: reject malformed / out-of-range ssh_port instead of silently falling back to 0 → 22 in the resolver. "abc", "0", "70000" all now return a "must be 1-65535" error so a broken config can't masquerade as a successful launch routed through port 22. Cubic P2. scanFingerprintInsideNetwork (bastion fixture): retry on empty output. Mirrors the host-side scanFingerprintViaHost retry loop — sshd accepts TCP before host-key negotiation completes on a cold container, so the keyscan can come back empty on the first try. Bound at 15s; surfaces "status=..., stderr=..." on final failure for diagnosis. Cubic P2. executor-crud D5 (changing config…): restore the seeded executor in a try/finally. The test pinned the executor to host=10.255.255.1 to verify the snapshot stayed sticky on the live session, but never put the real host back, so any later spec in the same worker that reused seedData.sshExecutorId would dial the unroutable address and hang. Cubic P2. e2e-plan.md: replace the "roughly 75" estimate with the actual 80 / 17-files current count plus the ~2.4 min wall-clock. Cubic P3.
162 lines
6.1 KiB
TypeScript
162 lines
6.1 KiB
TypeScript
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. Restore the original config in a
|
||
// try/finally so any subsequent spec in this worker that reuses
|
||
// seedData.sshExecutorId sees a working executor — without restore,
|
||
// later launches would dial 10.255.255.1 and hang.
|
||
try {
|
||
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);
|
||
} finally {
|
||
await apiClient.updateExecutor(seedData.sshExecutorId, {
|
||
config: {
|
||
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,
|
||
},
|
||
});
|
||
}
|
||
});
|
||
|
||
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.
|
||
});
|
||
});
|