145e62a249
G2 (sessions-endpoint filter) iterated `for (const s of sessions)` over
the listSSHSessions result without first asserting `length > 0` — an
empty response let the test pass without checking anything. Seed an SSH
launch before the loop and require at least one row, so a handler
regression that returned [] would now fail loud. CodeRabbit P1.
F6 (ws ssh.test parity) called apiClient.testSSHConnection twice (HTTP
both times) and compared the two HTTP responses, which would have
passed even if the WS dispatcher entry was broken. Use apiClient.
wsRequest("ssh.test", payload) for the second call so the gateway's
WS handler is actually invoked, and assert the step names match across
transports. CodeRabbit P1.
155 lines
5.9 KiB
TypeScript
155 lines
5.9 KiB
TypeScript
import { test, expect } from "../../fixtures/ssh-test-base";
|
||
/**
|
||
* HTTP contract for GET /api/v1/ssh/executors/:id/sessions. Backs the
|
||
* SSHSessionsCard table. Filtered to ssh-runtime ExecutorRunning rows for
|
||
* the named executor; metadata fields populate host/user/port/etc.
|
||
*
|
||
* Covers e2e-plan.md group G (G1–G5).
|
||
*/
|
||
test.describe("ssh sessions-endpoint contract", () => {
|
||
// G1 — only ssh-runtime rows for the requested executor come back.
|
||
test("returns only ssh-runtime rows tied to the requested executor", async ({
|
||
apiClient,
|
||
seedData,
|
||
}) => {
|
||
await apiClient.createTaskWithAgent(
|
||
seedData.workspaceId,
|
||
"G1 launch SSH session",
|
||
seedData.agentProfileId,
|
||
{
|
||
description: "/e2e:simple-message",
|
||
workflow_id: seedData.workflowId,
|
||
workflow_step_id: seedData.startStepId,
|
||
repository_ids: [seedData.repositoryId],
|
||
executor_profile_id: seedData.sshExecutorProfileId,
|
||
},
|
||
);
|
||
// Poll listSSHSessions directly. We want the row to appear while the
|
||
// executor is up; the per-turn session may complete quickly and the row
|
||
// gets removed when the executor is fully torn down — so we race the
|
||
// query against teardown.
|
||
let sessions: Awaited<ReturnType<typeof apiClient.listSSHSessions>> = [];
|
||
await expect
|
||
.poll(
|
||
async () => {
|
||
sessions = await apiClient.listSSHSessions(seedData.sshExecutorId);
|
||
return sessions.length;
|
||
},
|
||
{ message: "wait for ssh session row to appear", timeout: 60_000 },
|
||
)
|
||
.toBeGreaterThan(0);
|
||
|
||
for (const s of sessions) {
|
||
expect(s.host).toBe(seedData.sshTarget.host);
|
||
expect(s.user).toBe(seedData.sshTarget.user);
|
||
}
|
||
});
|
||
|
||
// G2 — non-ssh ExecutorRunning rows are filtered out even if they reference
|
||
// the same executor id (extra defensive guard the handler enforces).
|
||
test("non-ssh runtime rows are filtered out", async ({ apiClient, seedData }) => {
|
||
// Seed at least one live SSH row so the per-row checks aren't vacuous —
|
||
// a handler regression that returned [] would otherwise still pass.
|
||
await apiClient.createTaskWithAgent(
|
||
seedData.workspaceId,
|
||
"G2 filter contract",
|
||
seedData.agentProfileId,
|
||
{
|
||
description: "/e2e:simple-message",
|
||
workflow_id: seedData.workflowId,
|
||
workflow_step_id: seedData.startStepId,
|
||
repository_ids: [seedData.repositoryId],
|
||
executor_profile_id: seedData.sshExecutorProfileId,
|
||
},
|
||
);
|
||
let sessions: Awaited<ReturnType<typeof apiClient.listSSHSessions>> = [];
|
||
await expect
|
||
.poll(
|
||
async () => {
|
||
sessions = await apiClient.listSSHSessions(seedData.sshExecutorId);
|
||
return sessions.length;
|
||
},
|
||
{ message: "wait for at least one ssh session row", timeout: 60_000 },
|
||
)
|
||
.toBeGreaterThan(0);
|
||
// Every returned row's runtime must trace back to the seeded SSH target;
|
||
// a row with a different host would mean either the runtime filter or
|
||
// the executor-id filter leaked a non-SSH / cross-executor row.
|
||
for (const s of sessions) {
|
||
expect(s.host).toBe(seedData.sshTarget.host);
|
||
}
|
||
});
|
||
|
||
// G3 — metadata fields populate host/user/ports.
|
||
test("populates host, user, remote_agentctl_port, local_forward_port from metadata", async ({
|
||
apiClient,
|
||
seedData,
|
||
}) => {
|
||
const task = await apiClient.createTaskWithAgent(
|
||
seedData.workspaceId,
|
||
"G3 metadata populated",
|
||
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 sessions = await apiClient.listSSHSessions(seedData.sshExecutorId);
|
||
const row = sessions.find((s) => s.task_id === task.id);
|
||
expect(row, "expected a session row for the just-launched task").toBeDefined();
|
||
expect(row!.host).toBe(seedData.sshTarget.host);
|
||
expect(row!.user).toBe(seedData.sshTarget.user);
|
||
expect(row!.remote_agentctl_port).toBeGreaterThan(0);
|
||
expect(row!.local_forward_port).toBeGreaterThan(0);
|
||
expect(row!.remote_task_dir).toMatch(/\/tasks\/[^/]+$/);
|
||
});
|
||
|
||
// G4 — unknown executor id returns an empty list, not 404.
|
||
test("unknown executor id returns empty array", async ({ apiClient }) => {
|
||
const sessions = await apiClient.listSSHSessions("does-not-exist");
|
||
expect(Array.isArray(sessions)).toBe(true);
|
||
expect(sessions.length).toBe(0);
|
||
});
|
||
|
||
// G5 — uptime_seconds increases on subsequent polls of the same session.
|
||
test("uptime_seconds is monotonic across polls", async ({ apiClient, seedData }) => {
|
||
const task = await apiClient.createTaskWithAgent(
|
||
seedData.workspaceId,
|
||
"G5 uptime monotonic",
|
||
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();
|
||
|
||
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
||
|
||
const after = await apiClient.listSSHSessions(seedData.sshExecutorId);
|
||
const afterRow = after.find((s) => s.task_id === task.id);
|
||
expect(afterRow).toBeDefined();
|
||
expect(afterRow!.uptime_seconds).toBeGreaterThanOrEqual(beforeRow!.uptime_seconds);
|
||
});
|
||
});
|