mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 08:29:42 +02:00
Review round 2 on #439. 1. The bare TCP probe connects to host:port, which a host behind a jump host or SOCKS proxy does not answer even while ssh works. Acting on that verdict drew a permanent banner over a healthy session, replaced a real "needs tmux" error with "not reachable" in quick-start, and - with a wake target - buffered every HTTP input for the life of the session, since the readiness poll could never succeed. `WakeableRemote` now carries `jumpHost`/`socksProxy`/`extraSshOptions`, and `isProbeable()` turns such a host into reachability-UNKNOWN: input is delivered, `checkReachable` / `checkHostReachable` answer `null` (never `false`), `ensureHostAwake` returns `'unprobeable'` (handled like `'no-target'`), the quick-start gate fires on `=== false` only, and `GET …/reachability` reports `reachable: null, probeable: false` so the banner has nothing to key on. A wake target can still be fired for it, blind: no readiness poll, no reattach, no toast - the response says only whether the packet went out. 2. `'remote:'` joins the session-scoped SSE prefixes. The create/attach wake has no session yet, so the registry names the requesting user (`ensureHostAwake({ requestedBy })` -> `username` in the payload) and `deriveSseHint` routes on it; with neither it fails closed to admins. Single-user mode is unaffected. Smaller, from the same review: - A flush write that fails now drops the remaining buffer (logged) instead of retaining it: the wake still resolved and marked the host reachable, so the retained chunk waited for the NEXT wake and was replayed hours later, after everything typed since. Same policy as the oversized paste. - The banner polls on tab activation (a user action) and on its 30 s timer only for a host with a wake target; a timer connecting to a host Codeman cannot wake is the traffic invariant #2 rejects keepalives for. A proxied host is never polled. - `probeRemoteHostReachable`, `runRemoteWakeCommand` and the default UDP socket refuse under VITEST, as remote-files.ts does. The guard caught a leak on the spot: `createDefaultRemoteWakeDeps({ probe })` overrode the probe but still polled readiness with the real one, so the shutdown test had been connecting to a production address. The poll now uses the injected probe. - docs/remote-sessions.md is additions only again (the reformatting is gone); the architecture-invariants overlap resolved itself in the merge. Live, against a throwaway instance with a non-routable ghost host: proxied -> no probe, no wake, the genuine ssh error after 10 s; direct (control) -> probe, magic packet, "did not come back" after the 40 s budget. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdGP4jUTjc9J2RYYykDrCG
251 lines
10 KiB
TypeScript
251 lines
10 KiB
TypeScript
/**
|
|
* @fileoverview Route tests for wake-on-LAN on `POST /api/sessions/:id/input`.
|
|
*
|
|
* The behavior that matters and cannot be tested at the registry level: a
|
|
* wake-enabled remote session whose host is asleep must return 200 WITHOUT
|
|
* writing into the stalled pane (the bytes would vanish), while every other
|
|
* session keeps the historical fire-and-forget path untouched.
|
|
*
|
|
* The registry is injected through `registerSessionRoutes`'s test seam so no real
|
|
* TCP connect, ssh, or WoL happens in CI.
|
|
*/
|
|
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import fastifyCookie from '@fastify/cookie';
|
|
import Fastify, { type FastifyInstance } from 'fastify';
|
|
import { registerSessionRoutes, _resetPaneLivenessState } from '../../src/web/routes/session-routes.js';
|
|
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
|
import { createMockRouteContext } from '../mocks/index.js';
|
|
import { sessionWaits } from '../../src/web/session-wait-registry.js';
|
|
import { RemoteWakeRegistry, type RemoteWakeDeps } from '../../src/remote-wake.js';
|
|
import type { SessionRemote } from '../../src/types.js';
|
|
|
|
const SESSION_ID = 'remote-wake-session';
|
|
const URL = `/api/sessions/${SESSION_ID}/input`;
|
|
|
|
afterEach(() => {
|
|
sessionWaits.cancelAll(SESSION_ID);
|
|
_resetPaneLivenessState();
|
|
});
|
|
|
|
interface Harness {
|
|
app: FastifyInstance;
|
|
ctx: ReturnType<typeof createMockRouteContext>;
|
|
registry: RemoteWakeRegistry;
|
|
probe: ReturnType<typeof vi.fn>;
|
|
wake: ReturnType<typeof vi.fn>;
|
|
events: string[];
|
|
/** Let a held wake finish (see `holdWake`). */
|
|
releaseWake: () => void;
|
|
}
|
|
|
|
const remoteSession: SessionRemote = {
|
|
hostId: 'hufflepuff',
|
|
label: 'Hufflepuff',
|
|
host: '192.168.50.137',
|
|
username: 'j',
|
|
remotePath: '/home/j/codeman-pi-test',
|
|
wakeCommand: '/home/joe/bin/whuff',
|
|
};
|
|
|
|
async function harness(opts: { remote?: SessionRemote; hostUp?: boolean; holdWake?: boolean } = {}): Promise<Harness> {
|
|
const app = Fastify({ logger: false });
|
|
await app.register(fastifyCookie);
|
|
const ctx = createMockRouteContext({ sessionId: SESSION_ID });
|
|
const session = ctx.sessions.get(SESSION_ID)!;
|
|
session.remote = opts.remote ?? remoteSession;
|
|
|
|
const probe = vi.fn(async () => opts.hostUp ?? false);
|
|
const wake = vi.fn(async () => true);
|
|
const events: string[] = [];
|
|
// With instantaneous mocks the whole wake chain (wake -> wait -> reattach ->
|
|
// flush) can finish inside one `await`, so a test that wants to observe the
|
|
// in-flight state has to hold the readiness poll open.
|
|
let release: (() => void) | null = null;
|
|
const deps: RemoteWakeDeps = {
|
|
probe,
|
|
wake,
|
|
waitUntilReady: () =>
|
|
opts.holdWake
|
|
? new Promise<boolean>((resolve) => {
|
|
release = () => resolve(true);
|
|
})
|
|
: Promise.resolve(true),
|
|
delay: async () => {},
|
|
noteReconnected: () => {},
|
|
broadcast: (event) => events.push(event),
|
|
log: () => {},
|
|
};
|
|
const registry = new RemoteWakeRegistry(deps);
|
|
|
|
registerSessionRoutes(app, ctx as never, { remoteWake: registry });
|
|
installRouteErrorHandler(app);
|
|
await app.ready();
|
|
return { app, ctx, registry, probe, wake, events, releaseWake: () => release?.() };
|
|
}
|
|
|
|
const send = (app: FastifyInstance, payload: Record<string, unknown>) =>
|
|
app.inject({ method: 'POST', url: URL, payload });
|
|
|
|
describe('POST /api/sessions/:id/input — wake-on-LAN', () => {
|
|
it('buffers input instead of writing into a sleeping host, then flushes after the wake', async () => {
|
|
const h = await harness({ hostUp: false, holdWake: true });
|
|
const session = h.ctx.sessions.get(SESSION_ID)!;
|
|
|
|
const res = await send(h.app, { input: 'hallo', useMux: true });
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json()).toEqual({});
|
|
// Nothing reached the pane: writing now would be swallowed by the stalled ssh.
|
|
expect(session.writeBuffer).toEqual([]);
|
|
expect(h.wake).toHaveBeenCalledWith({ kind: 'command', command: '/home/joe/bin/whuff' });
|
|
expect(h.registry.isWaking(SESSION_ID)).toBe(true);
|
|
|
|
h.releaseWake();
|
|
await h.registry.wake(session);
|
|
expect(session.writeBuffer).toEqual(['hallo']);
|
|
expect(session.reattachRemote).toHaveBeenCalled();
|
|
});
|
|
|
|
it('flushes several inputs typed during a wake IN ORDER (the browser posts one per keystroke)', async () => {
|
|
// The concurrency surface that only exists in production: xterm's onData posts each
|
|
// keystroke as its OWN request, so a wake collects N concurrent buffer writes and must
|
|
// replay them in order. Route-level, so it is covered on every run instead of only in a
|
|
// hand-driven browser session.
|
|
const h = await harness({ hostUp: false, holdWake: true });
|
|
const session = h.ctx.sessions.get(SESSION_ID)!;
|
|
|
|
for (const chunk of ['h', 'a', 'llo']) {
|
|
const res = await send(h.app, { input: chunk, useMux: true });
|
|
expect(res.statusCode).toBe(200);
|
|
}
|
|
// Nothing written while the host is asleep/dead — that is the whole point.
|
|
expect(session.writeBuffer).toEqual([]);
|
|
|
|
h.releaseWake();
|
|
await h.registry.wake(session);
|
|
expect(session.writeBuffer).toEqual(['h', 'a', 'llo']);
|
|
});
|
|
|
|
it('keeps the historical fire-and-forget write when the host is reachable', async () => {
|
|
const h = await harness({ hostUp: true });
|
|
const session = h.ctx.sessions.get(SESSION_ID)!;
|
|
|
|
const res = await send(h.app, { input: 'hallo', useMux: true });
|
|
|
|
expect(res.json()).toEqual({});
|
|
await vi.waitFor(() => expect(session.writeBuffer).toEqual(['hallo']));
|
|
expect(h.wake).not.toHaveBeenCalled();
|
|
expect(session.reattachRemote).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('never probes or wakes a session without a wake command', async () => {
|
|
const { wakeCommand, ...withoutWake } = remoteSession;
|
|
const h = await harness({ remote: withoutWake as SessionRemote });
|
|
const session = h.ctx.sessions.get(SESSION_ID)!;
|
|
|
|
await send(h.app, { input: 'hallo', useMux: true });
|
|
|
|
await vi.waitFor(() => expect(session.writeBuffer).toEqual(['hallo']));
|
|
expect(h.probe).not.toHaveBeenCalled();
|
|
expect(h.wake).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('writes straight into a proxied host with a wake target: no probe, no buffer, no wake', async () => {
|
|
// With a target configured, the old verdict buffered EVERY input for the life of
|
|
// the session: the readiness poll can never succeed through a proxy, so nothing was
|
|
// ever flushed (three inputs, nothing written, buffer non-empty — reproduced upstream).
|
|
const h = await harness({ remote: { ...remoteSession, socksProxy: '127.0.0.1:1080' }, hostUp: false });
|
|
const session = h.ctx.sessions.get(SESSION_ID)!;
|
|
for (const input of ['a', 'b', 'c']) expect((await send(h.app, { input, useMux: true })).statusCode).toBe(200);
|
|
expect(session.writeBuffer).toEqual(['a', 'b', 'c']);
|
|
expect(h.probe).not.toHaveBeenCalled();
|
|
expect(h.wake).not.toHaveBeenCalled();
|
|
expect(h.registry.pendingBytes(SESSION_ID)).toBe(0);
|
|
});
|
|
|
|
it('wakes before writing on the send-and-wait path (no buffering, the response waits anyway)', async () => {
|
|
const h = await harness({ hostUp: false });
|
|
const session = h.ctx.sessions.get(SESSION_ID)!;
|
|
|
|
await send(h.app, { input: 'hallo', useMux: true, wait: 'idle', waitTimeout: 60 });
|
|
|
|
expect(h.wake).toHaveBeenCalledTimes(1);
|
|
// `ensureAwake` is awaited on this path, so the write happens inline and the
|
|
// waiter is registered against a live pane.
|
|
expect(session.writeBuffer).toEqual(['hallo']);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/sessions/:id/reachability', () => {
|
|
const get = (app: FastifyInstance, url: string) => app.inject({ method: 'GET', url });
|
|
|
|
it('reports the probe result and how the host can be woken', async () => {
|
|
const up = await harness({ hostUp: true });
|
|
const upBody = (await get(up.app, `/api/sessions/${SESSION_ID}/reachability`)).json();
|
|
expect(upBody.data.reachable).toBe(true);
|
|
expect(upBody.data.wakeConfigured).toBe('command');
|
|
expect(upBody.data.label).toBe('Hufflepuff');
|
|
|
|
const down = await harness({ hostUp: false });
|
|
const downBody = (await get(down.app, `/api/sessions/${SESSION_ID}/reachability`)).json();
|
|
expect(downBody.data.reachable).toBe(false);
|
|
// A reachability check is a QUESTION, never an action: the host stays asleep.
|
|
expect(down.wake).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('says nothing can wake a host without a configured target', async () => {
|
|
const { wakeCommand, ...withoutWake } = remoteSession;
|
|
const h = await harness({ remote: withoutWake as SessionRemote, hostUp: false });
|
|
const body = (await get(h.app, `/api/sessions/${SESSION_ID}/reachability`)).json();
|
|
expect(body.data.reachable).toBe(false);
|
|
expect(body.data.wakeConfigured).toBe('none');
|
|
});
|
|
|
|
it('reports a proxied host as unknown, not unreachable, and never probes it', async () => {
|
|
// A jump-host / SOCKS host does not answer the bare TCP probe even while ssh works;
|
|
// `reachable:false` here drew a permanent banner over a healthy session.
|
|
const h = await harness({ remote: { ...remoteSession, jumpHost: 'bastion.example' }, hostUp: false });
|
|
const body = (await get(h.app, `/api/sessions/${SESSION_ID}/reachability`)).json();
|
|
expect(body.data.reachable).toBeNull();
|
|
expect(body.data.probeable).toBe(false);
|
|
expect(body.data.wakeConfigured).toBe('command');
|
|
expect(h.probe).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('POST /api/sessions/:id/wake', () => {
|
|
const wake = (app: FastifyInstance) => app.inject({ method: 'POST', url: `/api/sessions/${SESSION_ID}/wake` });
|
|
|
|
it('wakes the host, reattaches the pane and reports both', async () => {
|
|
const h = await harness({ hostUp: false });
|
|
const session = h.ctx.sessions.get(SESSION_ID)!;
|
|
|
|
const body = (await wake(h.app)).json();
|
|
|
|
expect(body.success).toBe(true);
|
|
expect(body.data.woke).toBe(true);
|
|
expect(body.data.reachable).toBe(true);
|
|
expect(session.reattachRemote).toHaveBeenCalled();
|
|
});
|
|
|
|
it('answers with an error the UI can route to the config dialog', async () => {
|
|
const { wakeCommand, ...withoutWake } = remoteSession;
|
|
const h = await harness({ remote: withoutWake as SessionRemote, hostUp: false });
|
|
|
|
const res = await wake(h.app);
|
|
const body = res.json();
|
|
|
|
expect(body.success).toBe(false);
|
|
expect(body.error).toMatch(/No wake-on-LAN target/);
|
|
expect(h.wake).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not send a wake when the host answers, but still settles the session', async () => {
|
|
const h = await harness({ hostUp: true });
|
|
const body = (await wake(h.app)).json();
|
|
expect(body.data.woke).toBe(true);
|
|
expect(h.wake).not.toHaveBeenCalled();
|
|
});
|
|
});
|