mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #439
feat(remote): wake a sleeping host (Wake-on-LAN) from input, banner and native magic packet
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
// Port: none (pure frontend module in a node VM with a fake DOM — no browser, no server).
|
||||
//
|
||||
// The remote-host wake banner (src/web/public/host-wake-ui.js) is a SINGLE global
|
||||
// element that is shown only for the active remote session. The regression this
|
||||
// guards: `refreshHostWakeBanner` clears `_hostWake` when the tab switches, but
|
||||
// `_hostWakeTick`'s clear branch only re-rendered when IT was the one clearing —
|
||||
// so switching from an unreachable remote session to a LOCAL one left the banner
|
||||
// visible ("Hufflepuff is not reachable") on every chat until a full reload.
|
||||
//
|
||||
// The bug is a pure ordering problem between two methods, so it can be reproduced
|
||||
// here without a browser: render the remote state, switch to a local session, and
|
||||
// assert the banner is hidden again.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
|
||||
const REMOTE_ID = 'remote-session-0001';
|
||||
const LOCAL_ID = 'local-session-0001';
|
||||
|
||||
type El = { hidden: boolean; textContent: string; disabled: boolean; classList: { add(): void; remove(): void } };
|
||||
|
||||
function fakeElement(): El {
|
||||
return { hidden: false, textContent: '', disabled: false, classList: { add() {}, remove() {} } };
|
||||
}
|
||||
|
||||
const PROXIED_ID = 'remote-session-proxied';
|
||||
const NOWOL_ID = 'remote-session-nowol';
|
||||
|
||||
/** Load `host-wake-ui.js` with the minimal DOM it touches, and return a wired app. */
|
||||
function loadWakeApp() {
|
||||
const fetches: string[] = [];
|
||||
const elements = new Map<string, El>([
|
||||
['hostWakeBanner', fakeElement()],
|
||||
['hostWakeBannerText', fakeElement()],
|
||||
['hostWakeBannerDetail', fakeElement()],
|
||||
['hostWakeBannerAction', fakeElement()],
|
||||
]);
|
||||
const CodemanApp = function CodemanApp(this: unknown) {};
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
console,
|
||||
setInterval: () => 1,
|
||||
clearInterval: () => {},
|
||||
fetch: (url: string) => {
|
||||
fetches.push(url);
|
||||
return Promise.resolve({ json: () => Promise.resolve({ success: false }) });
|
||||
},
|
||||
document: {
|
||||
visibilityState: 'visible',
|
||||
getElementById: (id: string) => elements.get(id) ?? null,
|
||||
addEventListener: () => {},
|
||||
},
|
||||
window: {},
|
||||
});
|
||||
vm.runInContext(readFileSync(resolve(PUBLIC, 'host-wake-ui.js'), 'utf8'), context, { filename: 'host-wake-ui.js' });
|
||||
|
||||
const app = new (CodemanApp as new () => Record<string, unknown>)();
|
||||
app.$ = (id: string) => elements.get(id) ?? null;
|
||||
app.activeSessionId = REMOTE_ID;
|
||||
app.sessions = new Map<string, { remote?: Record<string, unknown> }>([
|
||||
[
|
||||
REMOTE_ID,
|
||||
{ remote: { hostId: 'hufflepuff', host: '192.168.50.137', label: 'Hufflepuff', wakeMac: '04:d9:f5:80:c6:58' } },
|
||||
],
|
||||
[
|
||||
PROXIED_ID,
|
||||
{
|
||||
remote: {
|
||||
hostId: 'bastioned',
|
||||
host: '10.20.0.5',
|
||||
label: 'Behind bastion',
|
||||
jumpHost: 'bastion',
|
||||
wakeMac: '04:d9:f5:80:c6:58',
|
||||
},
|
||||
},
|
||||
],
|
||||
[NOWOL_ID, { remote: { hostId: 'plain', host: '10.0.0.9', label: 'Plain' } }],
|
||||
[LOCAL_ID, {}],
|
||||
]);
|
||||
return {
|
||||
app,
|
||||
fetches,
|
||||
banner: elements.get('hostWakeBanner') as El,
|
||||
text: elements.get('hostWakeBannerText') as El,
|
||||
};
|
||||
}
|
||||
|
||||
describe('host wake banner visibility', () => {
|
||||
it('hides the banner when switching from an unreachable remote session to a local one', () => {
|
||||
const { app, banner, text } = loadWakeApp();
|
||||
|
||||
// The banner is up for the active, unreachable remote session.
|
||||
app._hostWake = {
|
||||
sessionId: REMOTE_ID,
|
||||
reachable: false,
|
||||
wakeConfigured: 'mac',
|
||||
host: '192.168.50.137',
|
||||
label: 'Hufflepuff',
|
||||
waking: false,
|
||||
error: '',
|
||||
};
|
||||
(app._renderHostWakeBanner as () => void)();
|
||||
expect(banner.hidden).toBe(false);
|
||||
expect(text.textContent).toBe('Hufflepuff is not reachable');
|
||||
|
||||
// Switch to a LOCAL session. `refreshHostWakeBanner` clears the state, and the
|
||||
// tick that follows must still repaint the (now empty) banner as hidden.
|
||||
app.activeSessionId = LOCAL_ID;
|
||||
(app.refreshHostWakeBanner as (id: string) => void)(LOCAL_ID);
|
||||
|
||||
expect(app._hostWake).toBeNull();
|
||||
expect(banner.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the banner hidden on a later poller tick once the state is cleared', () => {
|
||||
const { app, banner } = loadWakeApp();
|
||||
app.activeSessionId = LOCAL_ID;
|
||||
app._hostWake = null;
|
||||
// A page-wide tick on a local session must be idempotent and leave it hidden.
|
||||
(app._hostWakeTick as () => void)();
|
||||
expect(banner.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('shows the banner only while the active session is remote and unreachable', () => {
|
||||
const { app, banner } = loadWakeApp();
|
||||
app._hostWake = {
|
||||
sessionId: REMOTE_ID,
|
||||
reachable: false,
|
||||
wakeConfigured: 'mac',
|
||||
host: '192.168.50.137',
|
||||
label: 'Hufflepuff',
|
||||
waking: false,
|
||||
error: '',
|
||||
};
|
||||
(app._renderHostWakeBanner as () => void)();
|
||||
expect(banner.hidden).toBe(false);
|
||||
|
||||
// Reachable again → hidden, state intact (the banner must not leak across the
|
||||
// reachable/unreachable transition either).
|
||||
app._hostWake.reachable = true;
|
||||
(app._renderHostWakeBanner as () => void)();
|
||||
expect(banner.hidden).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('host wake banner polling', () => {
|
||||
// Each poll is a TCP connect to the host from the server. The timer is the one
|
||||
// trigger that is not a user action, so it must not fire for a host Codeman could
|
||||
// not wake anyway (it cannot wake it, but it can keep an activity-based suspend timer
|
||||
// from firing), and a proxied host is never polled: the probe cannot reach it.
|
||||
const tick = (app: Record<string, unknown>, periodic: boolean) =>
|
||||
(app._hostWakeTick as (o: { periodic: boolean }) => void)({ periodic });
|
||||
|
||||
it('polls a wake-configured host on activation and on the timer', () => {
|
||||
const { app, fetches } = loadWakeApp();
|
||||
app.activeSessionId = REMOTE_ID;
|
||||
tick(app, false);
|
||||
tick(app, true);
|
||||
tick(app, true);
|
||||
expect(fetches).toHaveLength(3);
|
||||
expect(fetches[0]).toContain(`/api/sessions/${REMOTE_ID}/reachability`);
|
||||
});
|
||||
|
||||
it('polls a host without a wake target once on activation, never on the timer', () => {
|
||||
const { app, fetches } = loadWakeApp();
|
||||
app.activeSessionId = NOWOL_ID;
|
||||
tick(app, false);
|
||||
tick(app, true);
|
||||
tick(app, true);
|
||||
expect(fetches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('never polls a host behind a jump host or SOCKS proxy', () => {
|
||||
const { app, fetches } = loadWakeApp();
|
||||
app.activeSessionId = PROXIED_ID;
|
||||
tick(app, false);
|
||||
tick(app, true);
|
||||
expect(fetches).toHaveLength(0);
|
||||
expect((app._hostWake as { probeable: boolean }).probeable).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -99,6 +99,14 @@ export class MockSession extends EventEmitter {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors `Session.reattachRemote()` — the COD-108 transport re-establish that
|
||||
* the wake-on-LAN flow calls once a sleeping host is back. Defaults to success;
|
||||
* set `reattachRemote.mockResolvedValue(false)` to model a pane that could not
|
||||
* be respawned.
|
||||
*/
|
||||
reattachRemote = vi.fn(async (): Promise<boolean> => true);
|
||||
|
||||
/** Exactly-once input dedup — mirrors Session.shouldApplyInput so route tests
|
||||
* exercising the reliable-delivery path behave like production. */
|
||||
private _appliedInputSeq = new Map<string, number>();
|
||||
|
||||
@@ -6,11 +6,14 @@ import {
|
||||
defaultRemoteCommandForMode,
|
||||
readRemoteCases,
|
||||
readRemoteHosts,
|
||||
rehydrateRemoteHostFields,
|
||||
remoteDisplayPath,
|
||||
remoteSshTarget,
|
||||
toSessionRemote,
|
||||
writeRemoteCases,
|
||||
writeRemoteHosts,
|
||||
} from '../src/remote-hosts.js';
|
||||
import { RemoteHostSchema } from '../src/web/schemas.js';
|
||||
|
||||
describe('remote-hosts domain', () => {
|
||||
let dir: string | null = null;
|
||||
@@ -69,4 +72,115 @@ describe('remote-hosts domain', () => {
|
||||
'aamer@box.local:/opt/work'
|
||||
);
|
||||
});
|
||||
|
||||
it('carries the wake command from host config into the session', () => {
|
||||
// The input route reads `session.remote.wakeCommand` — it must survive the host
|
||||
// -> session mapping, or wake-on-LAN silently degrades to "no wake command".
|
||||
const remote = toSessionRemote(
|
||||
{
|
||||
id: 'hufflepuff',
|
||||
label: 'Hufflepuff',
|
||||
host: '192.168.50.137',
|
||||
username: 'j',
|
||||
wakeCommand: '/home/joe/bin/whuff',
|
||||
},
|
||||
{ name: 'c', type: 'remote', hostId: 'hufflepuff', remotePath: '/home/j/work' }
|
||||
);
|
||||
expect(remote.wakeCommand).toBe('/home/joe/bin/whuff');
|
||||
});
|
||||
|
||||
it('omits the wake command by default (feature off without a config entry)', () => {
|
||||
const remote = toSessionRemote(
|
||||
{ id: 'h', label: 'H', host: '10.0.0.1', username: 'j' },
|
||||
{ name: 'c', type: 'remote', hostId: 'h', remotePath: '/tmp' }
|
||||
);
|
||||
expect(remote.wakeCommand).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('RemoteHostSchema wakeCommand', () => {
|
||||
const host = { id: 'hufflepuff', label: 'Hufflepuff', host: '192.168.50.137', username: 'j' };
|
||||
|
||||
it('accepts an optional absolute executable path', () => {
|
||||
expect(RemoteHostSchema.safeParse({ ...host, wakeCommand: '/home/joe/bin/whuff' }).success).toBe(true);
|
||||
expect(RemoteHostSchema.safeParse(host).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an argument list (spawn runs the path without a shell)', () => {
|
||||
// `spawn('/home/joe/bin/whuff --mac 00:11:22')` would fail as a confusing
|
||||
// ENOENT at wake time — refuse it at config time instead.
|
||||
expect(RemoteHostSchema.safeParse({ ...host, wakeCommand: '/home/joe/bin/whuff --now' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects shell metacharacters as defence in depth', () => {
|
||||
expect(RemoteHostSchema.safeParse({ ...host, wakeCommand: '/bin/sh$(id)' }).success).toBe(false);
|
||||
expect(RemoteHostSchema.safeParse({ ...host, wakeCommand: '/bin/`id`' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts one or more MAC addresses and rejects anything else', () => {
|
||||
expect(RemoteHostSchema.safeParse({ ...host, wakeMac: '04:d9:f5:80:c6:58' }).success).toBe(true);
|
||||
expect(RemoteHostSchema.safeParse({ ...host, wakeMac: '04-d9-f5-80-c6-58, 1C:61:B4:20:58:EB' }).success).toBe(
|
||||
true
|
||||
);
|
||||
expect(RemoteHostSchema.safeParse({ ...host, wakeMac: '04:d9:f5:80:c6' }).success).toBe(false);
|
||||
expect(RemoteHostSchema.safeParse({ ...host, wakeMac: '04:d9:f5:80:c6:58; rm -rf /' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rehydrateRemoteHostFields', () => {
|
||||
const persisted = {
|
||||
hostId: 'hufflepuff',
|
||||
label: 'Hufflepuff',
|
||||
host: '192.168.50.137',
|
||||
username: 'j',
|
||||
remotePath: '/home/j/work',
|
||||
};
|
||||
const hosts = (wakeCommand?: string) =>
|
||||
new Map([
|
||||
[
|
||||
'hufflepuff',
|
||||
{
|
||||
id: 'hufflepuff',
|
||||
label: 'Hufflepuff',
|
||||
host: '192.168.50.137',
|
||||
username: 'j',
|
||||
...(wakeCommand ? { wakeCommand } : {}),
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
it('adds a wake command that only exists in the host config', () => {
|
||||
// The pre-existing-session case: the field was added to remote-hosts.json after
|
||||
// this session was persisted, so recovery is the only place it can arrive.
|
||||
expect(rehydrateRemoteHostFields(persisted, hosts('/home/joe/bin/whuff'))?.wakeCommand).toBe(
|
||||
'/home/joe/bin/whuff'
|
||||
);
|
||||
});
|
||||
|
||||
it('treats the host config as authoritative (removing it turns the feature off)', () => {
|
||||
const remote = { ...persisted, wakeCommand: '/home/joe/bin/whuff' };
|
||||
expect(rehydrateRemoteHostFields(remote, hosts())?.wakeCommand).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refreshes a MAC that only exists in the host config', () => {
|
||||
const withMac = new Map(
|
||||
hosts()
|
||||
.entries()
|
||||
.map(([id, host]) => [id, { ...host, wakeMac: '04:d9:f5:80:c6:58' }] as const)
|
||||
);
|
||||
expect(rehydrateRemoteHostFields(persisted, withMac)?.wakeMac).toBe('04:d9:f5:80:c6:58');
|
||||
});
|
||||
|
||||
it('leaves the block untouched when the host is gone or the session is local', () => {
|
||||
expect(rehydrateRemoteHostFields(persisted, new Map())).toBe(persisted);
|
||||
expect(rehydrateRemoteHostFields(undefined, hosts('/x'))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the other host-level fields as persisted', () => {
|
||||
// Only wakeCommand is refreshed: silently re-pointing an existing pane's ssh
|
||||
// options would be a behavior change nobody asked for.
|
||||
const remote = { ...persisted, identityFile: '~/.ssh/pinned_key' };
|
||||
const rehydrated = rehydrateRemoteHostFields(remote, hosts('/home/joe/bin/whuff'));
|
||||
expect(rehydrated?.identityFile).toBe('~/.ssh/pinned_key');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,925 @@
|
||||
/**
|
||||
* @fileoverview Wake-on-LAN from user input (see `src/remote-wake.ts`).
|
||||
*
|
||||
* Covers the two things that are easy to get wrong and expensive when wrong:
|
||||
* 1. the decision/throttle table (probe at most once per window, never a probe
|
||||
* burst per keystroke),
|
||||
* 2. the guarantee that a wake is SINGLE-FLIGHT and that buffered input is
|
||||
* flushed IN ORDER once the pane is reattached — plus that no reconnect or
|
||||
* boot-recovery module can reach the wake flow at all (a wake there would
|
||||
* re-wake the host seconds after every suspend, so it could never sleep).
|
||||
*
|
||||
* Pure logic + a fake session/deps: no tmux, no ssh, no real host.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
RemoteWakeRegistry,
|
||||
appendBoundedPending,
|
||||
buildMagicPacket,
|
||||
createDefaultRemoteWakeDeps,
|
||||
decideRemoteInputAction,
|
||||
isProbeable,
|
||||
parseMacList,
|
||||
probeRemoteHostReachable,
|
||||
resolveWakeTarget,
|
||||
runRemoteWakeCommand,
|
||||
sendWakePackets,
|
||||
waitUntilRemoteReady,
|
||||
wakeConfigured,
|
||||
REMOTE_WAKE_PENDING_MAX_BYTES,
|
||||
REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
|
||||
type RemoteWakeDeps,
|
||||
type WakeableRemote,
|
||||
type WakeableSession,
|
||||
} from '../src/remote-wake.js';
|
||||
|
||||
// ========== Pure decisions ==========
|
||||
|
||||
describe('decideRemoteInputAction', () => {
|
||||
const base = { hasWakeTarget: true, waking: false, probeAgeMs: 0, lastReachable: undefined as boolean | undefined };
|
||||
|
||||
it('delivers unchanged when the host has no wake command (feature off)', () => {
|
||||
expect(decideRemoteInputAction({ ...base, hasWakeTarget: false, probeAgeMs: Number.MAX_SAFE_INTEGER })).toBe(
|
||||
'deliver'
|
||||
);
|
||||
});
|
||||
|
||||
it('buffers while a wake is already in flight, whatever the probe state says', () => {
|
||||
expect(decideRemoteInputAction({ ...base, waking: true, probeAgeMs: Number.MAX_SAFE_INTEGER })).toBe('buffer');
|
||||
});
|
||||
|
||||
it('buffers without re-probing when the last probe said the host is down', () => {
|
||||
// Re-probing per keystroke would add seconds of latency to every character.
|
||||
expect(decideRemoteInputAction({ ...base, lastReachable: false, probeAgeMs: 1 })).toBe('buffer');
|
||||
});
|
||||
|
||||
it('delivers inside the throttle window when the host was reachable', () => {
|
||||
expect(decideRemoteInputAction({ ...base, lastReachable: true, probeAgeMs: 10 })).toBe('deliver');
|
||||
});
|
||||
|
||||
it('probes once the throttle window has elapsed', () => {
|
||||
expect(decideRemoteInputAction({ ...base, lastReachable: true, probeAgeMs: 30_001 })).toBe('probe');
|
||||
expect(decideRemoteInputAction({ ...base, lastReachable: true, probeAgeMs: 29_999 })).toBe('deliver');
|
||||
});
|
||||
|
||||
it('probes on the very first input of a session (probeAgeMs 0 is only "never probed")', () => {
|
||||
// probedAt is initialised to 0, so a fresh session's age is huge in real time.
|
||||
expect(decideRemoteInputAction({ ...base, probeAgeMs: Date.now() })).toBe('probe');
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendBoundedPending', () => {
|
||||
it('keeps everything under the cap, in order', () => {
|
||||
expect(appendBoundedPending(['a', 'b'], 'c')).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('drops the OLDEST chunk when the cap is exceeded, keeping the tail', () => {
|
||||
const big = 'x'.repeat(REMOTE_WAKE_PENDING_MAX_BYTES);
|
||||
expect(appendBoundedPending([big], 'newest')).toEqual(['newest']);
|
||||
});
|
||||
|
||||
it('drops an oversized chunk outright instead of delivering a fragment of it', () => {
|
||||
// One large paste is one input value and was never typed character by character, so its
|
||||
// tail is not "what the user just typed" — writing it into the pane would run a partial
|
||||
// command (with the paste's trailing carriage return, if it had one).
|
||||
const huge = 'y'.repeat(REMOTE_WAKE_PENDING_MAX_BYTES + 100);
|
||||
expect(appendBoundedPending([], huge)).toEqual([]);
|
||||
// The bytes already queued are left alone, not replaced by the fragment.
|
||||
expect(appendBoundedPending(['typed'], huge)).toEqual(['typed']);
|
||||
});
|
||||
|
||||
it('measures the cap in UTF-8 bytes, so a multi-byte paste is dropped too', () => {
|
||||
const cap = 10;
|
||||
const value = 'ä'.repeat(8); // 2 bytes each → 16 bytes > cap
|
||||
expect(appendBoundedPending([], value, cap)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MAC parsing + magic packet', () => {
|
||||
it('parses one or more MACs with either separator', () => {
|
||||
expect(parseMacList('04:d9:f5:80:c6:58')).toEqual([[4, 217, 245, 128, 198, 88]]);
|
||||
expect(parseMacList('04-d9-f5-80-c6-58, 1c:61:b4:20:58:eb')).toEqual([
|
||||
[4, 217, 245, 128, 198, 88],
|
||||
[28, 97, 180, 32, 88, 235],
|
||||
]);
|
||||
});
|
||||
|
||||
it('is all-or-nothing so a typo cannot half-arm a host', () => {
|
||||
expect(parseMacList('04:d9:f5:80:c6')).toBeNull();
|
||||
expect(parseMacList('04:d9:f5:80:c6:58, nonsense')).toBeNull();
|
||||
expect(parseMacList('')).toBeNull();
|
||||
expect(
|
||||
parseMacList('04:d9:f5:80:c6:58,1c:61:b4:20:58:eb,aa:bb:cc:dd:ee:ff,11:22:33:44:55:66,99:88:77:66:55:44')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('builds the documented magic packet byte-for-byte', () => {
|
||||
// 6 x 0xFF then the MAC repeated 16 times — a packet off by one byte simply never
|
||||
// wakes anything, so the shape is pinned rather than described.
|
||||
const mac = [4, 217, 245, 128, 198, 88];
|
||||
const packet = buildMagicPacket(mac);
|
||||
expect(packet.length).toBe(6 + 16 * 6);
|
||||
expect([...packet.subarray(0, 6)]).toEqual([255, 255, 255, 255, 255, 255]);
|
||||
for (let repeat = 0; repeat < 16; repeat++) {
|
||||
expect([...packet.subarray(6 + repeat * 6, 12 + repeat * 6)]).toEqual(mac);
|
||||
}
|
||||
});
|
||||
|
||||
it('binds BEFORE enabling broadcast — the order that silently kills the packet on Linux', async () => {
|
||||
// `setBroadcast()` on an unbound socket throws EBADF on Linux and the follow-up
|
||||
// send dies with EACCES, so the magic packet never leaves the machine (verified
|
||||
// against a real sleeping host). The order is asserted, not described.
|
||||
const calls: string[] = [];
|
||||
const sent: { packet: Buffer; port: number; address: string }[] = [];
|
||||
const packets = await sendWakePackets(
|
||||
[
|
||||
[4, 217, 245, 128, 198, 88],
|
||||
[28, 97, 180, 32, 88, 235],
|
||||
],
|
||||
9,
|
||||
() => ({
|
||||
bind: (cb: () => void) => {
|
||||
calls.push('bind');
|
||||
cb();
|
||||
},
|
||||
setBroadcast: () => calls.push('setBroadcast'),
|
||||
send: (packet: Buffer, port: number, address: string, cb: (err?: Error | null) => void) => {
|
||||
calls.push('send');
|
||||
sent.push({ packet, port, address });
|
||||
cb(null);
|
||||
},
|
||||
close: () => calls.push('close'),
|
||||
once: () => undefined,
|
||||
})
|
||||
);
|
||||
|
||||
expect(packets).toBe(true);
|
||||
expect(calls[0]).toBe('bind');
|
||||
expect(calls[1]).toBe('setBroadcast');
|
||||
// One 102-byte magic packet per MAC, to the broadcast address on port 9.
|
||||
expect(sent).toHaveLength(2);
|
||||
expect(sent.every((s) => s.packet.length === 102 && s.port === 9 && s.address === '255.255.255.255')).toBe(true);
|
||||
});
|
||||
|
||||
it('reports failure when the platform refuses to broadcast', async () => {
|
||||
const ok = await sendWakePackets([[4, 217, 245, 128, 198, 88]], 9, () => ({
|
||||
bind: (cb: () => void) => cb(),
|
||||
setBroadcast: () => {
|
||||
throw new Error('EBADF');
|
||||
},
|
||||
send: () => undefined,
|
||||
close: () => undefined,
|
||||
once: () => undefined,
|
||||
}));
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves the wake target with the command as the explicit override', () => {
|
||||
const mac = '04:d9:f5:80:c6:58';
|
||||
expect(resolveWakeTarget(undefined)).toBeNull();
|
||||
expect(resolveWakeTarget({ hostId: 'h', label: 'H', host: '10.0.0.1' })).toBeNull();
|
||||
expect(resolveWakeTarget({ hostId: 'h', label: 'H', host: '10.0.0.1', wakeMac: mac })).toEqual({
|
||||
kind: 'mac',
|
||||
macs: [[4, 217, 245, 128, 198, 88]],
|
||||
});
|
||||
expect(
|
||||
resolveWakeTarget({ hostId: 'h', label: 'H', host: '10.0.0.1', wakeMac: mac, wakeCommand: '/bin/wake' })
|
||||
).toEqual({ kind: 'command', command: '/bin/wake' });
|
||||
// A malformed MAC (hand-written config) must not arm a broken wake.
|
||||
expect(resolveWakeTarget({ hostId: 'h', label: 'H', host: '10.0.0.1', wakeMac: 'nope' })).toBeNull();
|
||||
});
|
||||
|
||||
it('reports which wake path the UI should offer', () => {
|
||||
expect(wakeConfigured(undefined)).toBe('none');
|
||||
expect(wakeConfigured({ hostId: 'h', label: 'H', host: 'x' })).toBe('none');
|
||||
expect(wakeConfigured({ hostId: 'h', label: 'H', host: 'x', wakeMac: '04:d9:f5:80:c6:58' })).toBe('mac');
|
||||
expect(wakeConfigured({ hostId: 'h', label: 'H', host: 'x', wakeCommand: '/bin/wake' })).toBe('command');
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Registry ==========
|
||||
|
||||
const remote: WakeableRemote = {
|
||||
hostId: 'hufflepuff',
|
||||
label: 'Hufflepuff',
|
||||
host: '192.168.50.137',
|
||||
wakeCommand: '/home/joe/bin/whuff',
|
||||
};
|
||||
|
||||
interface Harness {
|
||||
registry: RemoteWakeRegistry;
|
||||
session: WakeableSession;
|
||||
probe: ReturnType<typeof vi.fn>;
|
||||
wake: ReturnType<typeof vi.fn>;
|
||||
waitUntilReady: ReturnType<typeof vi.fn>;
|
||||
reattachRemote: ReturnType<typeof vi.fn>;
|
||||
writeViaMux: ReturnType<typeof vi.fn>;
|
||||
noteReconnected: ReturnType<typeof vi.fn>;
|
||||
events: string[];
|
||||
payloads: Array<{ event: string; payload: Record<string, unknown> }>;
|
||||
}
|
||||
|
||||
function harness(
|
||||
opts: { remote?: WakeableRemote; writesFail?: boolean; resolveRemote?: RemoteWakeDeps['resolveRemote'] } = {}
|
||||
): Harness {
|
||||
const probe = vi.fn(async () => false);
|
||||
const wake = vi.fn(async () => true);
|
||||
const waitUntilReady = vi.fn(async () => true);
|
||||
const reattachRemote = vi.fn(async () => true);
|
||||
const writeViaMux = vi.fn(async () => !opts.writesFail);
|
||||
const noteReconnected = vi.fn();
|
||||
const events: string[] = [];
|
||||
const payloads: Array<{ event: string; payload: Record<string, unknown> }> = [];
|
||||
|
||||
const deps: RemoteWakeDeps = {
|
||||
probe,
|
||||
wake,
|
||||
waitUntilReady,
|
||||
delay: async () => {},
|
||||
noteReconnected,
|
||||
broadcast: (event, payload) => {
|
||||
events.push(event);
|
||||
payloads.push({ event, payload });
|
||||
},
|
||||
log: () => {},
|
||||
...(opts.resolveRemote ? { resolveRemote: opts.resolveRemote } : {}),
|
||||
};
|
||||
|
||||
const session: WakeableSession = {
|
||||
id: 'sess-1',
|
||||
remote: opts.remote ?? remote,
|
||||
reattachRemote,
|
||||
writeViaMux,
|
||||
};
|
||||
|
||||
return {
|
||||
registry: new RemoteWakeRegistry(deps),
|
||||
session,
|
||||
probe,
|
||||
wake,
|
||||
waitUntilReady,
|
||||
reattachRemote,
|
||||
writeViaMux,
|
||||
noteReconnected,
|
||||
events,
|
||||
payloads,
|
||||
};
|
||||
}
|
||||
|
||||
describe('RemoteWakeRegistry', () => {
|
||||
it('does nothing at all when the host has no wake command', async () => {
|
||||
const h = harness({ remote: { hostId: 'x', label: 'X', host: '10.0.0.9' } });
|
||||
await expect(h.registry.handleInput(h.session, 'a')).resolves.toBe('deliver');
|
||||
expect(h.probe).not.toHaveBeenCalled();
|
||||
expect(h.wake).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delivers normally when the host is reachable, without waking', async () => {
|
||||
const h = harness();
|
||||
h.probe.mockResolvedValue(true);
|
||||
await expect(h.registry.handleInput(h.session, 'a')).resolves.toBe('deliver');
|
||||
expect(h.probe).toHaveBeenCalledTimes(1);
|
||||
expect(h.wake).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips the probe inside the throttle window once the host was reachable', async () => {
|
||||
const h = harness();
|
||||
h.probe.mockResolvedValue(true);
|
||||
await h.registry.handleInput(h.session, 'a');
|
||||
await h.registry.handleInput(h.session, 'b');
|
||||
await h.registry.handleInput(h.session, 'c');
|
||||
expect(h.probe).toHaveBeenCalledTimes(1);
|
||||
expect(h.wake).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('wakes an unreachable host once, then flushes buffered input in order after reattach', async () => {
|
||||
const h = harness();
|
||||
h.probe.mockResolvedValue(false);
|
||||
// Hold the wake open so the second input lands while it is genuinely in flight
|
||||
// (with instantaneous mocks the whole wake chain can finish between two awaits).
|
||||
let releaseWake: (() => void) | undefined;
|
||||
h.waitUntilReady.mockImplementation(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
releaseWake = () => resolve(true);
|
||||
})
|
||||
);
|
||||
|
||||
await expect(h.registry.handleInput(h.session, 'hal')).resolves.toBe('buffered');
|
||||
await expect(h.registry.handleInput(h.session, 'lo')).resolves.toBe('buffered');
|
||||
// Single-flight: the second input joins the in-flight wake, it does not start another.
|
||||
expect(h.registry.isWaking('sess-1')).toBe(true);
|
||||
expect(h.wake).toHaveBeenCalledTimes(1);
|
||||
|
||||
releaseWake?.();
|
||||
await h.registry.wake(h.session);
|
||||
|
||||
expect(h.wake).toHaveBeenCalledWith({ kind: 'command', command: '/home/joe/bin/whuff' });
|
||||
expect(h.reattachRemote).toHaveBeenCalledTimes(1);
|
||||
expect(h.noteReconnected).toHaveBeenCalledWith('sess-1', true);
|
||||
expect(h.writeViaMux.mock.calls.map((c) => c[0])).toEqual(['hal', 'lo']);
|
||||
expect(h.registry.pendingBytes('sess-1')).toBe(0);
|
||||
expect(h.events).toEqual(['remote:hostWaking', 'remote:sessionReconnected']);
|
||||
});
|
||||
|
||||
it('keeps input buffered and reports failure when the host never comes back', async () => {
|
||||
const h = harness();
|
||||
h.probe.mockResolvedValue(false);
|
||||
h.waitUntilReady.mockResolvedValue(false);
|
||||
|
||||
await h.registry.handleInput(h.session, 'hello');
|
||||
await h.registry.wake(h.session);
|
||||
|
||||
expect(h.reattachRemote).not.toHaveBeenCalled();
|
||||
expect(h.writeViaMux).not.toHaveBeenCalled();
|
||||
expect(h.registry.pendingBytes('sess-1')).toBe(5);
|
||||
expect(h.events).toContain('remote:hostWakeFailed');
|
||||
});
|
||||
|
||||
it('retries the wake on the next input after a failed wake (probe state reset)', async () => {
|
||||
const h = harness();
|
||||
h.probe.mockResolvedValue(false);
|
||||
h.waitUntilReady.mockResolvedValueOnce(false);
|
||||
|
||||
await h.registry.handleInput(h.session, 'a');
|
||||
await h.registry.wake(h.session);
|
||||
expect(h.wake).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Next keystroke must probe again (not trust the stale "down" verdict) and retry.
|
||||
await h.registry.handleInput(h.session, 'b');
|
||||
await h.registry.wake(h.session);
|
||||
expect(h.probe).toHaveBeenCalledTimes(2);
|
||||
expect(h.wake).toHaveBeenCalledTimes(2);
|
||||
expect(h.writeViaMux.mock.calls.map((c) => c[0])).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('does not claim reconnected when the pane cannot be reattached', async () => {
|
||||
const h = harness();
|
||||
h.probe.mockResolvedValue(false);
|
||||
h.reattachRemote.mockResolvedValue(false);
|
||||
|
||||
await h.registry.handleInput(h.session, 'a');
|
||||
await h.registry.wake(h.session);
|
||||
|
||||
expect(h.noteReconnected).not.toHaveBeenCalled();
|
||||
expect(h.writeViaMux).not.toHaveBeenCalled();
|
||||
expect(h.events).not.toContain('remote:sessionReconnected');
|
||||
});
|
||||
|
||||
it('reports an oversized chunk as dropped, and flushes as user input so the tab can be named', async () => {
|
||||
const h = harness();
|
||||
h.probe.mockResolvedValue(false);
|
||||
let release: (() => void) | undefined;
|
||||
h.waitUntilReady.mockImplementation(() => new Promise<boolean>((resolve) => (release = () => resolve(true))));
|
||||
await expect(h.registry.handleInput(h.session, 'ok')).resolves.toBe('buffered');
|
||||
// Over the cap: never enters the buffer, and the caller is told — a bare 200 could
|
||||
// not distinguish delivered from buffered from gone.
|
||||
await expect(h.registry.handleInput(h.session, 'x'.repeat(REMOTE_WAKE_PENDING_MAX_BYTES + 1))).resolves.toBe(
|
||||
'dropped'
|
||||
);
|
||||
expect(h.registry.pendingBytes('sess-1')).toBe(2);
|
||||
release?.();
|
||||
await h.registry.wake(h.session);
|
||||
// `fromUser`: a first prompt that was buffered through a wake may still name the tab.
|
||||
expect(h.writeViaMux).toHaveBeenCalledWith('ok', { fromUser: true });
|
||||
});
|
||||
|
||||
it('drops the buffer when a flush write fails, so nothing is replayed by a later wake', async () => {
|
||||
// Retaining the chunk was the earlier behaviour, and it was worse: the wake still
|
||||
// resolves and marks the host reachable, so the next input takes the deliver path
|
||||
// while the retained chunk waits for the NEXT wake — replayed hours later, after
|
||||
// everything typed since. Same policy as the oversized paste: dropped, logged.
|
||||
const h = harness({ writesFail: true });
|
||||
h.probe.mockResolvedValue(false);
|
||||
|
||||
await h.registry.handleInput(h.session, 'abc');
|
||||
await h.registry.handleInput(h.session, 'def');
|
||||
await h.registry.wake(h.session);
|
||||
|
||||
expect(h.writeViaMux).toHaveBeenCalledTimes(1);
|
||||
expect(h.registry.pendingBytes('sess-1')).toBe(0);
|
||||
// And the recovered host takes the deliver path from here, with nothing behind it.
|
||||
h.probe.mockClear();
|
||||
await expect(h.registry.handleInput(h.session, 'g')).resolves.toBe('deliver');
|
||||
expect(h.registry.pendingBytes('sess-1')).toBe(0);
|
||||
});
|
||||
|
||||
it('flushes the chunk it is writing out of the buffer first, so a concurrent enqueue cannot drop a different one', async () => {
|
||||
// Input arriving DURING the flush is enqueued (`waking` is still set), and the cap
|
||||
// then drops the OLDEST chunk — the one already on its way to the pane. Shifting the
|
||||
// buffer after the write removed the NEXT chunk instead, so the drop-oldest
|
||||
// bookkeeping lost a chunk that was never written.
|
||||
const h = harness();
|
||||
h.probe.mockResolvedValue(false);
|
||||
let release: (() => void) | undefined;
|
||||
h.waitUntilReady.mockImplementation(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
release = () => resolve(true);
|
||||
})
|
||||
);
|
||||
|
||||
const big = 'a'.repeat(REMOTE_WAKE_PENDING_MAX_BYTES - 10);
|
||||
await h.registry.handleInput(h.session, big);
|
||||
await h.registry.handleInput(h.session, 'bbbbbbbbbb'); // fills the cap exactly
|
||||
// The third chunk arrives while the FIRST write is in flight, which is what pushes
|
||||
// the buffer over the cap mid-flush.
|
||||
h.writeViaMux.mockImplementationOnce(async () => {
|
||||
await h.registry.handleInput(h.session, 'c');
|
||||
return true;
|
||||
});
|
||||
|
||||
release?.();
|
||||
await h.registry.wake(h.session);
|
||||
|
||||
expect(h.writeViaMux.mock.calls.map((c) => c[0])).toEqual([big, 'bbbbbbbbbb', 'c']);
|
||||
expect(h.registry.pendingBytes('sess-1')).toBe(0);
|
||||
});
|
||||
|
||||
it('ensureAwake blocks only for the wait path and returns true without a wake command', async () => {
|
||||
const h = harness({ remote: { hostId: 'x', label: 'X', host: '10.0.0.9' } });
|
||||
await expect(h.registry.ensureAwake(h.session)).resolves.toBe(true);
|
||||
expect(h.probe).not.toHaveBeenCalled();
|
||||
expect(h.wake).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ensureAwake wakes an unreachable host without buffering anything', async () => {
|
||||
const h = harness();
|
||||
h.probe.mockResolvedValue(false);
|
||||
await expect(h.registry.ensureAwake(h.session)).resolves.toBe(true);
|
||||
expect(h.wake).toHaveBeenCalledTimes(1);
|
||||
expect(h.registry.pendingBytes('sess-1')).toBe(0);
|
||||
});
|
||||
|
||||
it('wakes a MAC-configured host by magic packet, with no external command', async () => {
|
||||
const h = harness({
|
||||
remote: { hostId: 'h', label: 'H', host: '10.0.0.9', wakeMac: '04:d9:f5:80:c6:58' },
|
||||
});
|
||||
h.probe.mockResolvedValue(false);
|
||||
await expect(h.registry.handleInput(h.session, 'hi')).resolves.toBe('buffered');
|
||||
await h.registry.wake(h.session);
|
||||
expect(h.wake).toHaveBeenCalledWith({ kind: 'mac', macs: [[4, 217, 245, 128, 198, 88]] });
|
||||
expect(h.writeViaMux.mock.calls.map((c) => c[0])).toEqual(['hi']);
|
||||
});
|
||||
|
||||
it('resolves host config for a session that predates it, so a saved MAC works live', async () => {
|
||||
// The persisted `remote` snapshot is taken at launch: without this the banner's
|
||||
// config dialog would only take effect after restarting the session.
|
||||
const resolveRemote = vi.fn(async () => ({
|
||||
hostId: 'hufflepuff',
|
||||
label: 'Hufflepuff',
|
||||
host: '192.168.50.137',
|
||||
wakeMac: '04:d9:f5:80:c6:58',
|
||||
}));
|
||||
const h = harness({
|
||||
remote: { hostId: 'hufflepuff', label: 'Hufflepuff', host: '192.168.50.137' },
|
||||
resolveRemote,
|
||||
});
|
||||
h.probe.mockResolvedValue(false);
|
||||
|
||||
expect(await h.registry.hasWakeTarget(h.session)).toBe(true);
|
||||
await expect(h.registry.handleInput(h.session, 'a')).resolves.toBe('buffered');
|
||||
await h.registry.wake(h.session);
|
||||
expect(h.wake).toHaveBeenCalledWith({ kind: 'mac', macs: [[4, 217, 245, 128, 198, 88]] });
|
||||
expect(resolveRemote).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Cached: the next keystroke must not re-read the host config.
|
||||
await h.registry.hasWakeTarget(h.session);
|
||||
expect(resolveRemote).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('consults the resolver on the TTL even when the session has a target', async () => {
|
||||
// The host config is authoritative in BOTH directions: a target removed in the config
|
||||
// (or the dialog) must turn the feature off for a live session, which it cannot do if
|
||||
// the session's own snapshot short-circuits the lookup.
|
||||
const resolveRemote = vi.fn(async () => ({
|
||||
hostId: 'hufflepuff',
|
||||
label: 'Hufflepuff',
|
||||
host: '192.168.50.137',
|
||||
}));
|
||||
const h = harness({
|
||||
remote: {
|
||||
hostId: 'hufflepuff',
|
||||
label: 'Hufflepuff',
|
||||
host: '192.168.50.137',
|
||||
wakeMac: '04:d9:f5:80:c6:58',
|
||||
},
|
||||
resolveRemote,
|
||||
});
|
||||
|
||||
expect(await h.registry.hasWakeTarget(h.session)).toBe(false);
|
||||
expect(await h.registry.wakeConfigured(h.session)).toBe('none');
|
||||
// ... and with the feature off there is nothing to buffer for.
|
||||
expect(await h.registry.handleInput(h.session, 'x')).toBe('deliver');
|
||||
// Cached for the TTL — not one host-config read per keystroke.
|
||||
expect(resolveRemote).toHaveBeenCalledTimes(1);
|
||||
await h.registry.hasWakeTarget(h.session);
|
||||
expect(resolveRemote).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('drops buffered input with the session', async () => {
|
||||
const h = harness();
|
||||
h.probe.mockResolvedValue(false);
|
||||
await h.registry.handleInput(h.session, 'abc');
|
||||
h.registry.drop('sess-1');
|
||||
expect(h.registry.pendingBytes('sess-1')).toBe(0);
|
||||
expect(h.registry.isWaking('sess-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('never keys the state map on a local session', async () => {
|
||||
// `hasWakeTarget` runs on EVERY input chunk (it is the route's gate), so allocating
|
||||
// state before the `!session.remote` return would put an entry — and later a pending
|
||||
// buffer — in the map for every local session the user types in.
|
||||
const h = harness();
|
||||
const local: WakeableSession = {
|
||||
id: 'local-1',
|
||||
remote: undefined,
|
||||
reattachRemote: h.reattachRemote,
|
||||
writeViaMux: h.writeViaMux,
|
||||
};
|
||||
expect(await h.registry.hasWakeTarget(local)).toBe(false);
|
||||
expect(await h.registry.wakeConfigured(local)).toBe('none');
|
||||
expect(h.registry.stateCount()).toBe(0);
|
||||
expect(h.probe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ensureAwake hands the caller’s budget to the readiness poll (the wake button’s case)', async () => {
|
||||
// The button is pressed from the same dashboard as Run/Attach, so it must not inherit
|
||||
// the 90 s session default and get cut off by the proxy's 60 s read timeout.
|
||||
const h = harness();
|
||||
h.probe.mockResolvedValue(false);
|
||||
await expect(
|
||||
h.registry.ensureAwake(h.session, { force: true, timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS })
|
||||
).resolves.toBe(true);
|
||||
expect(h.waitUntilReady).toHaveBeenCalledWith(remote, {
|
||||
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Host-scoped wake (session create/attach) ==========
|
||||
|
||||
describe('isProbeable', () => {
|
||||
const base: WakeableRemote = { hostId: 'h', label: 'H', host: '10.0.0.9', wakeMac: '04:d9:f5:80:c6:58' };
|
||||
|
||||
it('is true for a host reached directly', () => {
|
||||
expect(isProbeable(base)).toBe(true);
|
||||
expect(isProbeable({ ...base, extraSshOptions: ['ServerAliveCountMax=3', 'StrictHostKeyChecking=no'] })).toBe(true);
|
||||
});
|
||||
|
||||
it('is false behind a jump host, a SOCKS proxy, or a ProxyCommand/ProxyJump option', () => {
|
||||
expect(isProbeable({ ...base, jumpHost: 'bastion.example' })).toBe(false);
|
||||
expect(isProbeable({ ...base, socksProxy: '127.0.0.1:1080' })).toBe(false);
|
||||
expect(isProbeable({ ...base, extraSshOptions: ['ProxyCommand=cloudflared access ssh --hostname %h'] })).toBe(
|
||||
false
|
||||
);
|
||||
expect(isProbeable({ ...base, extraSshOptions: ['proxyjump=bastion'] })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RemoteWakeRegistry — a proxied host is reachability-unknown', () => {
|
||||
// The bare TCP probe connects to `host:port`, which a jump-host/SOCKS host does not
|
||||
// answer even while ssh works. Acting on that verdict buffered input for the life of
|
||||
// the session (the readiness poll could never succeed), showed a permanent banner and
|
||||
// hid the real ssh error behind "not reachable". Unknown is not asleep.
|
||||
const proxied: WakeableRemote = {
|
||||
hostId: 'behind-bastion',
|
||||
label: 'Behind bastion',
|
||||
host: '10.20.0.5',
|
||||
jumpHost: 'bastion.example',
|
||||
wakeCommand: '/usr/local/bin/wake-behind-bastion',
|
||||
};
|
||||
|
||||
it('delivers every input without probing, buffering or waking', async () => {
|
||||
const h = harness({ remote: proxied });
|
||||
await expect(h.registry.handleInput(h.session, 'ls\r')).resolves.toBe('deliver');
|
||||
await expect(h.registry.handleInput(h.session, 'pwd\r')).resolves.toBe('deliver');
|
||||
expect(h.probe).not.toHaveBeenCalled();
|
||||
expect(h.wake).not.toHaveBeenCalled();
|
||||
expect(h.registry.pendingBytes('sess-1')).toBe(0);
|
||||
});
|
||||
|
||||
it('answers null (unknown), never false, so the UI has no banner to raise', async () => {
|
||||
const h = harness({ remote: proxied });
|
||||
await expect(h.registry.checkReachable(h.session, { force: true })).resolves.toBeNull();
|
||||
await expect(h.registry.checkHostReachable(proxied, { force: true })).resolves.toBeNull();
|
||||
expect(h.probe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not gate a create/attach request on it (unprobeable, like no-target)', async () => {
|
||||
const h = harness({ remote: proxied });
|
||||
await expect(h.registry.ensureHostAwake(proxied)).resolves.toBe('unprobeable');
|
||||
expect(h.probe).not.toHaveBeenCalled();
|
||||
expect(h.wake).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets the send-and-wait path through, and fires the manual wake blind', async () => {
|
||||
const h = harness({ remote: proxied });
|
||||
await expect(h.registry.ensureAwake(h.session)).resolves.toBe(true);
|
||||
expect(h.wake).not.toHaveBeenCalled();
|
||||
|
||||
// The button: the user asked, so the target goes out — but nothing can verify the
|
||||
// host came back, so there is no readiness poll, no reattach and no "waking" toast
|
||||
// promising a wait that does not happen.
|
||||
await expect(h.registry.ensureAwake(h.session, { force: true })).resolves.toBe(true);
|
||||
expect(h.wake).toHaveBeenCalledTimes(1);
|
||||
expect(h.waitUntilReady).not.toHaveBeenCalled();
|
||||
expect(h.reattachRemote).not.toHaveBeenCalled();
|
||||
expect(h.events).toEqual([]);
|
||||
|
||||
h.wake.mockResolvedValueOnce(false);
|
||||
await expect(h.registry.ensureAwake(h.session, { force: true })).resolves.toBe(false);
|
||||
|
||||
// A wake IO that throws is a failed wake, not a rejected route — and the public
|
||||
// `wake()` takes the same blind path, so nobody can poll readiness through a proxy.
|
||||
h.wake.mockRejectedValueOnce(new Error('udp socket exploded'));
|
||||
await expect(h.registry.wake(h.session)).resolves.toBe(false);
|
||||
expect(h.waitUntilReady).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RemoteWakeRegistry — SSE payload routing', () => {
|
||||
const hostRemote: WakeableRemote = {
|
||||
hostId: 'hufflepuff',
|
||||
label: 'Hufflepuff',
|
||||
host: '192.168.50.137',
|
||||
wakeMac: '04:d9:f5:80:c6:58',
|
||||
};
|
||||
|
||||
it('a session wake names its session, so the server routes it to the owner', async () => {
|
||||
const h = harness({ remote: hostRemote });
|
||||
h.probe.mockResolvedValue(false);
|
||||
await h.registry.handleInput(h.session, 'x');
|
||||
await h.registry.wake(h.session);
|
||||
const waking = h.payloads.find((p) => p.event === 'remote:hostWaking')!;
|
||||
expect(waking.payload).toMatchObject({ sessionId: 'sess-1', hostId: 'hufflepuff', label: 'Hufflepuff' });
|
||||
expect(waking.payload).not.toHaveProperty('username');
|
||||
});
|
||||
|
||||
it('a create/attach wake has no session, so it names the requesting user instead', async () => {
|
||||
// Without it the server can only fail closed (admins only) — the requester would
|
||||
// never see their own wake. The payload carries `hostId`/`label`, which non-admins
|
||||
// are not shown elsewhere, so it must not go global either.
|
||||
const h = harness({ remote: hostRemote });
|
||||
h.probe.mockResolvedValue(false);
|
||||
h.waitUntilReady.mockResolvedValue(false);
|
||||
await expect(h.registry.ensureHostAwake(hostRemote, { requestedBy: 'alice' })).resolves.toBe('failed');
|
||||
const [waking, failed] = ['remote:hostWaking', 'remote:hostWakeFailed'].map(
|
||||
(event) => h.payloads.find((p) => p.event === event)!.payload
|
||||
);
|
||||
expect(waking).toMatchObject({ forNewSession: true, username: 'alice' });
|
||||
expect(failed).toMatchObject({ forNewSession: true, username: 'alice' });
|
||||
expect(waking).not.toHaveProperty('sessionId');
|
||||
});
|
||||
|
||||
it('omits the requester when the route did not name one (single-user mode)', async () => {
|
||||
const h = harness({ remote: hostRemote });
|
||||
h.probe.mockResolvedValue(false);
|
||||
await h.registry.ensureHostAwake(hostRemote);
|
||||
expect(h.payloads.find((p) => p.event === 'remote:hostWaking')!.payload).not.toHaveProperty('username');
|
||||
});
|
||||
});
|
||||
|
||||
describe('real IO is refused under vitest', () => {
|
||||
// Every consumer injects its IO (RemoteWakeDeps, the socket factory). The guard is
|
||||
// what makes that seam mandatory: a test that reaches the defaults fails loudly here
|
||||
// instead of opening a TCP connection, spawning a process or broadcasting UDP from CI.
|
||||
const target: WakeableRemote = { hostId: 'h', label: 'H', host: '127.0.0.1', port: 1 };
|
||||
|
||||
it('the TCP probe', () => {
|
||||
expect(() => probeRemoteHostReachable(target)).toThrow(/disabled under test/);
|
||||
});
|
||||
|
||||
it('the wake command', () => {
|
||||
expect(() => runRemoteWakeCommand('/bin/true')).toThrow(/disabled under test/);
|
||||
});
|
||||
|
||||
it('the UDP broadcast — only with the DEFAULT socket, an injected one still works', async () => {
|
||||
await expect(sendWakePackets([[1, 2, 3, 4, 5, 6]])).rejects.toThrow(/disabled under test/);
|
||||
});
|
||||
|
||||
it('the readiness poll, which probes by default', async () => {
|
||||
await expect(waitUntilRemoteReady(target, { timeoutMs: 10, intervalMs: 1 })).rejects.toThrow(/disabled under test/);
|
||||
});
|
||||
|
||||
it('the default deps poll readiness with the INJECTED probe, never the real one', async () => {
|
||||
// `createDefaultRemoteWakeDeps({ probe })` used to override `probe` alone while
|
||||
// `waitUntilReady` kept the module default — so a shutdown test polled a production
|
||||
// address until the guard above made it fail instead of connecting.
|
||||
const probe = vi.fn(async () => true);
|
||||
const deps = createDefaultRemoteWakeDeps({ probe });
|
||||
await expect(deps.waitUntilReady(target, { timeoutMs: 10 })).resolves.toBe(true);
|
||||
expect(probe).toHaveBeenCalledWith(target);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RemoteWakeRegistry — host-scoped wake for a request that waits on it', () => {
|
||||
const hostRemote: WakeableRemote = {
|
||||
hostId: 'hufflepuff',
|
||||
label: 'Hufflepuff',
|
||||
host: '192.168.50.137',
|
||||
wakeMac: '04:d9:f5:80:c6:58',
|
||||
};
|
||||
|
||||
it('does not even probe a host without a wake target (byte-identical to no feature)', async () => {
|
||||
const h = harness({ remote: { hostId: 'x', label: 'X', host: '10.0.0.9' } });
|
||||
await expect(h.registry.ensureHostAwake(h.session.remote!)).resolves.toBe('no-target');
|
||||
expect(h.probe).not.toHaveBeenCalled();
|
||||
expect(h.wake).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports ready without waking when the host already answers', async () => {
|
||||
const h = harness({ remote: hostRemote });
|
||||
h.probe.mockResolvedValue(true);
|
||||
await expect(h.registry.ensureHostAwake(hostRemote)).resolves.toBe('ready');
|
||||
expect(h.wake).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('wakes a sleeping host and waits with the caller’s budget, not the 90 s default', async () => {
|
||||
const h = harness({ remote: hostRemote });
|
||||
h.probe.mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
h.registry.ensureHostAwake(hostRemote, { timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS })
|
||||
).resolves.toBe('ready');
|
||||
|
||||
expect(h.wake).toHaveBeenCalledWith({ kind: 'mac', macs: [[4, 217, 245, 128, 198, 88]] });
|
||||
// The budget has to reach the readiness poll: the reverse proxy cuts a request at
|
||||
// 60 s, so a create-path wake must not inherit the 90 s session default.
|
||||
expect(h.waitUntilReady).toHaveBeenCalledWith(hostRemote, {
|
||||
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
|
||||
// The shutdown signal rides along so `WebServer.stop()` can end the poll.
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(h.events).toContain('remote:hostWaking');
|
||||
});
|
||||
|
||||
it('reports failed when the host never comes back, and probes again on the next attempt', async () => {
|
||||
const h = harness({ remote: hostRemote });
|
||||
h.probe.mockResolvedValue(false);
|
||||
h.waitUntilReady.mockResolvedValue(false);
|
||||
|
||||
await expect(h.registry.ensureHostAwake(hostRemote)).resolves.toBe('failed');
|
||||
expect(h.events).toContain('remote:hostWakeFailed');
|
||||
|
||||
// The failure resets the probe verdict, so a second Run probes instead of
|
||||
// trusting a stale "down" forever.
|
||||
h.waitUntilReady.mockResolvedValue(true);
|
||||
h.probe.mockClear();
|
||||
await expect(h.registry.ensureHostAwake(hostRemote)).resolves.toBe('ready');
|
||||
expect(h.probe).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('single-flights two concurrent create-path wakes for the same host', async () => {
|
||||
const h = harness({ remote: hostRemote });
|
||||
h.probe.mockResolvedValue(false);
|
||||
let release: (value: boolean) => void = () => {};
|
||||
h.waitUntilReady.mockImplementation(() => new Promise<boolean>((resolve) => (release = resolve)));
|
||||
|
||||
const first = h.registry.ensureHostAwake(hostRemote);
|
||||
const second = h.registry.ensureHostAwake(hostRemote);
|
||||
await vi.waitFor(() => expect(h.wake).toHaveBeenCalledTimes(1));
|
||||
release(true);
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toEqual(['ready', 'ready']);
|
||||
// One magic packet for a double click, not two.
|
||||
expect(h.wake).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('checkHostReachable is a question, never an action', async () => {
|
||||
const h = harness({ remote: hostRemote });
|
||||
h.probe.mockResolvedValue(false);
|
||||
|
||||
await expect(h.registry.checkHostReachable(hostRemote)).resolves.toBe(false);
|
||||
expect(h.wake).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports failed instead of rejecting when the wake IO itself throws', async () => {
|
||||
// A create route must answer with its own error, not a 500 from an unexpected
|
||||
// rejection — the session flow catches for the same reason.
|
||||
const h = harness({ remote: hostRemote });
|
||||
h.probe.mockResolvedValue(false);
|
||||
h.wake.mockRejectedValue(new Error('udp socket exploded'));
|
||||
|
||||
await expect(h.registry.ensureHostAwake(hostRemote)).resolves.toBe('failed');
|
||||
});
|
||||
|
||||
it('stop() resolves an in-flight wake as failed, so shutdown cannot wait it out', async () => {
|
||||
// `WebServer.stop()` ends with `app.close()`, which does not abort in-flight requests:
|
||||
// without this the shutdown sits out the whole readiness poll. Real `waitUntilReady`
|
||||
// (abortable sleep) with fake probe/wake, which is the shape of a restart mid-wake.
|
||||
const registry = new RemoteWakeRegistry(
|
||||
createDefaultRemoteWakeDeps({ probe: async () => false, wake: async () => true, log: () => {} })
|
||||
);
|
||||
const pending = registry.ensureHostAwake(hostRemote, { timeoutMs: 60_000 });
|
||||
await vi.waitFor(() => expect(registry.isWaking('host:hufflepuff')).toBe(true));
|
||||
|
||||
registry.stop();
|
||||
await expect(pending).resolves.toBe('failed');
|
||||
|
||||
// ... and nothing new starts afterwards.
|
||||
await expect(registry.ensureHostAwake(hostRemote)).resolves.toBe('failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('waitUntilRemoteReady', () => {
|
||||
const remote: WakeableRemote = { hostId: 'h', label: 'H', host: '10.0.0.9' };
|
||||
|
||||
it('ends on abort instead of waiting out the current interval', async () => {
|
||||
const controller = new AbortController();
|
||||
const started = Date.now();
|
||||
const pending = waitUntilRemoteReady(remote, {
|
||||
intervalMs: 1_000,
|
||||
timeoutMs: 60_000,
|
||||
probe: async () => false,
|
||||
signal: controller.signal,
|
||||
});
|
||||
setTimeout(() => controller.abort(), 10);
|
||||
await expect(pending).resolves.toBe(false);
|
||||
expect(Date.now() - started).toBeLessThan(1_000);
|
||||
});
|
||||
|
||||
it('returns false immediately when the signal is already aborted', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const probe = vi.fn(async () => true);
|
||||
await expect(waitUntilRemoteReady(remote, { probe, signal: controller.signal })).resolves.toBe(false);
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Wiring guard ==========
|
||||
|
||||
const SRC = fileURLToPath(new URL('../src', import.meta.url));
|
||||
|
||||
function walkTs(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
const full = join(dir, name);
|
||||
if (statSync(full).isDirectory()) {
|
||||
out.push(...walkTs(full));
|
||||
continue;
|
||||
}
|
||||
if (name.endsWith('.ts')) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('wake wiring guard', () => {
|
||||
it('only the route module and the server may import remote-wake', () => {
|
||||
// The auto-reconnect watcher (tmux-manager.ts), the server's dropped-session
|
||||
// handler and any boot-recovery path must NOT be able to WAKE a host: waking there
|
||||
// re-wakes the host seconds after each suspend. `web/server.ts` is allowed to hold
|
||||
// the registry for its LIFETIME only (`drop()` on session cleanup, `stop()` on
|
||||
// shutdown) — the test below pins that it never calls a waking method, which is the
|
||||
// property this import list is an approximation of.
|
||||
const allowed = new Set([join('web', 'routes', 'session-routes.ts'), join('web', 'server.ts')]);
|
||||
const importers = walkTs(SRC)
|
||||
.filter((full) => /from\s+['"][^'"]*remote-wake(\.js)?['"]/.test(readFileSync(full, 'utf-8')))
|
||||
.map((full) => relative(SRC, full));
|
||||
|
||||
expect(importers.sort()).toEqual([...allowed].sort());
|
||||
});
|
||||
|
||||
it('the server only ever calls drop/stop on the registry — never a waking method', () => {
|
||||
// `server.ts` holds the registry because `cleanupSession` and `stop()` need it, and
|
||||
// those run on timers and shutdown paths. Any wake-capable call from this file is the
|
||||
// exact failure invariant #1 exists to prevent, so it is asserted here rather than
|
||||
// left to the import check above (which the field's type alone would satisfy).
|
||||
const server = readFileSync(join(SRC, 'web', 'server.ts'), 'utf-8');
|
||||
for (const method of [
|
||||
'wake',
|
||||
'ensureAwake',
|
||||
'ensureHostAwake',
|
||||
'handleInput',
|
||||
'checkReachable',
|
||||
'checkHostReachable',
|
||||
]) {
|
||||
expect(server).not.toContain(`remoteWake.${method}(`);
|
||||
expect(server).not.toContain(`remoteWake?.${method}(`);
|
||||
}
|
||||
expect(server).toContain('remoteWake?.drop(');
|
||||
expect(server).toContain('remoteWake?.stop(');
|
||||
});
|
||||
|
||||
it('wakes a host for a create/attach request ONLY from the HTTP route', () => {
|
||||
// The create-path wake (`ensureHostAwake`) is a USER request, so it belongs to the
|
||||
// HTTP route. `cron-service.ts` builds sessions through the shared service with
|
||||
// nobody waiting on the answer, so a wake down there would power the host on for
|
||||
// every schedule — the failure invariant #1 exists to prevent. Asserted across the
|
||||
// source tree, so a future caller has to come through this test.
|
||||
// `remote-wake.ts` names itself: that is the definition, not a caller, and the
|
||||
// import guard above already pins the file to the route.
|
||||
const allowed = new Set([join('web', 'routes', 'session-routes.ts'), 'remote-wake.ts']);
|
||||
const callers = walkTs(SRC)
|
||||
.filter((full) => /ensureHostAwake\s*\(/.test(readFileSync(full, 'utf-8')))
|
||||
.map((full) => relative(SRC, full));
|
||||
|
||||
expect(callers.sort()).toEqual([...allowed].sort());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* @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 { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getDataDir } from '../../src/config/instance.js';
|
||||
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 { httpStatusForErrorCode, type ApiErrorCode } from '../../src/types.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';
|
||||
|
||||
/**
|
||||
* Mirror production's envelope + status mapping (as inbox-routes.test.ts does): a
|
||||
* returned `createErrorResponse` carries its 4xx, a plain object is wrapped in
|
||||
* `{success:true, data}`. Without it every error would read as a 200.
|
||||
*/
|
||||
function installEnvelope(app: FastifyInstance): void {
|
||||
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
|
||||
if (!req.url.startsWith('/api')) return done(null, payload);
|
||||
if (payload === null || typeof payload !== 'object') return done(null, payload);
|
||||
const p = payload as { success?: unknown; errorCode?: unknown };
|
||||
if (p.success === false) {
|
||||
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
|
||||
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
|
||||
}
|
||||
return done(null, payload);
|
||||
}
|
||||
if (p.success === true) return done(null, payload);
|
||||
return done(null, { success: true, data: payload });
|
||||
});
|
||||
}
|
||||
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;
|
||||
/** Stands in for the auth middleware (multi-user mode); absent = synthetic admin. */
|
||||
authUser?: { username: string; role: 'admin' | 'user' };
|
||||
} = {}
|
||||
): Promise<Harness> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
if (opts.authUser) {
|
||||
const authUser = opts.authUser;
|
||||
app.addHook('onRequest', async (req) => {
|
||||
(req as unknown as { authUser: typeof authUser }).authUser = authUser;
|
||||
});
|
||||
}
|
||||
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 });
|
||||
installEnvelope(app);
|
||||
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({ success: true, data: { buffered: true } });
|
||||
// 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({ success: true, data: {} }); // the historical bare answer, untouched
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/sessions + attachRemoteSession — authorization before the wake', () => {
|
||||
// Remote hosts are admin-only infra everywhere else, and the attach wake spawns the
|
||||
// host's `wakeCommand` (or broadcasts a packet). Before this gate a non-admin could
|
||||
// post an attach for any configured hostId, have that executable run and the request
|
||||
// held for the wake budget, and only THEN get a 403 for the workingDir (reproduced
|
||||
// upstream: wake spy fired once, response 403).
|
||||
it('403s a non-admin in multi-user mode without probing or waking the host', async () => {
|
||||
const prev = process.env.CODEMAN_MULTIUSER;
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
try {
|
||||
// `session-routes.ts` reads hosts from the sandboxed data dir (module-load-time
|
||||
// constant), so a host with a wake command is written THERE: a regression would
|
||||
// find it and fire the spy.
|
||||
await mkdir(getDataDir(), { recursive: true });
|
||||
await writeFile(
|
||||
join(getDataDir(), 'remote-hosts.json'),
|
||||
JSON.stringify([
|
||||
{
|
||||
id: 'hufflepuff',
|
||||
label: 'Hufflepuff',
|
||||
host: '192.168.50.137',
|
||||
username: 'j',
|
||||
wakeCommand: '/home/joe/bin/whuff',
|
||||
},
|
||||
])
|
||||
);
|
||||
const h = await harness({ hostUp: false, authUser: { username: 'mallory', role: 'user' } });
|
||||
const res = await h.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: { attachRemoteSession: { hostId: 'hufflepuff', remoteSessionName: 'codeman-abc12345' } },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.json().error).toMatch(/admin-only/);
|
||||
expect(h.probe).not.toHaveBeenCalled();
|
||||
expect(h.wake).not.toHaveBeenCalled();
|
||||
expect(h.events).toEqual([]);
|
||||
await h.app.close();
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.CODEMAN_MULTIUSER;
|
||||
else process.env.CODEMAN_MULTIUSER = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/sessions/:id/input — what the caller is told', () => {
|
||||
it('says buffered, and dropped for a chunk over the wake buffer cap', async () => {
|
||||
// The non-wait branch always answered a bare `{}`; these fields are additive. Without
|
||||
// them a prompt over 4 KB posted to a sleeping host was accepted and silently lost.
|
||||
const h = await harness({ hostUp: false, holdWake: true });
|
||||
const small = await send(h.app, { input: 'hallo', useMux: true });
|
||||
expect(small.statusCode).toBe(200);
|
||||
expect(small.json()).toEqual({ success: true, data: { buffered: true } });
|
||||
|
||||
const big = await send(h.app, { input: 'x'.repeat(5000), useMux: true });
|
||||
expect(big.statusCode).toBe(200);
|
||||
expect(big.json()).toEqual({ success: true, data: { buffered: true, dropped: true } });
|
||||
expect(h.registry.pendingBytes(SESSION_ID)).toBe(5);
|
||||
h.releaseWake();
|
||||
await h.registry.wake(h.ctx.sessions.get(SESSION_ID)!);
|
||||
});
|
||||
|
||||
it('fails the send-and-wait path when the host never comes back, instead of writing into the stalled pane', async () => {
|
||||
// Readiness never arrives: the wake resolves false.
|
||||
const failing = await harnessWithFailingWake();
|
||||
const session = failing.ctx.sessions.get(SESSION_ID)!;
|
||||
const res = await send(failing.app, { input: 'hallo', useMux: true, wait: true, waitTimeout: 1000 });
|
||||
expect(res.statusCode).toBe(422);
|
||||
expect(res.json().errorCode).toBe('OPERATION_FAILED');
|
||||
expect(res.json().error).toMatch(/did not come back/);
|
||||
expect(session.writeBuffer).toEqual([]);
|
||||
await failing.app.close();
|
||||
});
|
||||
});
|
||||
|
||||
/** A harness whose readiness poll answers false: the wake command runs, the host stays down. */
|
||||
async function harnessWithFailingWake(): Promise<Harness> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
const ctx = createMockRouteContext({ sessionId: SESSION_ID });
|
||||
ctx.sessions.get(SESSION_ID)!.remote = remoteSession;
|
||||
const probe = vi.fn(async () => false);
|
||||
const wake = vi.fn(async () => true);
|
||||
const events: string[] = [];
|
||||
const registry = new RemoteWakeRegistry({
|
||||
probe,
|
||||
wake,
|
||||
waitUntilReady: async () => false,
|
||||
delay: async () => {},
|
||||
noteReconnected: () => {},
|
||||
broadcast: (event) => events.push(event),
|
||||
log: () => {},
|
||||
});
|
||||
registerSessionRoutes(app, ctx as never, { remoteWake: registry });
|
||||
installEnvelope(app);
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
return { app, ctx, registry, probe, wake, events, releaseWake: () => {} };
|
||||
}
|
||||
@@ -55,6 +55,7 @@ vi.mock('../../src/remote-hosts.js', async (orig) => {
|
||||
});
|
||||
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
import { RemoteWakeRegistry, REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS } from '../../src/remote-wake.js';
|
||||
import { resolveTerminalHistoryConfig } from '../../src/config/terminal-history.js';
|
||||
|
||||
interface LocalHarness {
|
||||
@@ -62,6 +63,15 @@ interface LocalHarness {
|
||||
ctx: MockRouteContext;
|
||||
}
|
||||
|
||||
// Wake-on-LAN seam: the production registry opens a real TCP connection to the host
|
||||
// and can run a real wake command, so every route registered here gets a fake one
|
||||
// (the same seam `test/routes/session-remote-wake.test.ts` uses). Default: the host
|
||||
// answers, so nothing ever wakes.
|
||||
const wakeProbe = vi.fn(async () => true);
|
||||
const wakeCommandRun = vi.fn(async () => true);
|
||||
const wakeWaitUntilReady = vi.fn(async () => true);
|
||||
let wakeRegistry: RemoteWakeRegistry;
|
||||
|
||||
/**
|
||||
* Build a Fastify instance that mirrors production's uniform-envelope behavior
|
||||
* (server.ts preSerialization hook) so the test wire format matches the contract:
|
||||
@@ -108,7 +118,17 @@ describe('session-routes', () => {
|
||||
let harness: LocalHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createEnvelopeHarness(registerSessionRoutes);
|
||||
wakeProbe.mockReset().mockResolvedValue(true);
|
||||
wakeCommandRun.mockReset().mockResolvedValue(true);
|
||||
wakeWaitUntilReady.mockReset().mockResolvedValue(true);
|
||||
wakeRegistry = new RemoteWakeRegistry({
|
||||
probe: wakeProbe,
|
||||
wake: wakeCommandRun,
|
||||
waitUntilReady: wakeWaitUntilReady,
|
||||
delay: async () => {},
|
||||
log: () => {},
|
||||
});
|
||||
harness = await createEnvelopeHarness((app, ctx) => registerSessionRoutes(app, ctx, { remoteWake: wakeRegistry }));
|
||||
// Reset remote store so tests start with empty hosts/cases and a passing tmux probe
|
||||
remoteStore.hosts = [];
|
||||
remoteStore.cases = [];
|
||||
@@ -1990,6 +2010,134 @@ describe('session-routes', () => {
|
||||
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.OPERATION_FAILED });
|
||||
});
|
||||
|
||||
describe('remote create/attach wakes a sleeping host (Wake-on-LAN)', () => {
|
||||
const host = (extra: Record<string, unknown> = {}) => ({
|
||||
id: 'hufflepuff',
|
||||
label: 'Hufflepuff',
|
||||
host: '192.168.50.137',
|
||||
username: 'j',
|
||||
wakeMac: '04:d9:f5:80:c6:58',
|
||||
...extra,
|
||||
});
|
||||
const remoteCase = { name: 'hufflepuff-work', type: 'remote', hostId: 'hufflepuff', remotePath: '/home/j/work' };
|
||||
const quickStart = () =>
|
||||
harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/quick-start',
|
||||
payload: { caseName: 'hufflepuff-work', mode: 'shell' },
|
||||
});
|
||||
|
||||
it('wakes the host before the tmux probe when the user runs a remote case', async () => {
|
||||
const startShell = vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
|
||||
try {
|
||||
remoteStore.hosts = [host()];
|
||||
remoteStore.cases = [remoteCase];
|
||||
wakeProbe.mockResolvedValue(false); // asleep
|
||||
|
||||
const res = await quickStart();
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body).success).toBe(true);
|
||||
expect(wakeCommandRun).toHaveBeenCalledWith({ kind: 'mac', macs: [[4, 217, 245, 128, 198, 88]] });
|
||||
// The request budget, not the 90 s session default: the reverse proxy would
|
||||
// cut the request at 60 s while the session was still being built.
|
||||
expect(wakeWaitUntilReady).toHaveBeenCalledWith(expect.objectContaining({ hostId: 'hufflepuff' }), {
|
||||
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
|
||||
// The shutdown signal rides along so `WebServer.stop()` can end the poll.
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
} finally {
|
||||
startShell.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not wake a host that answers, and never probes a host without a wake target', async () => {
|
||||
const startShell = vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
|
||||
try {
|
||||
remoteStore.hosts = [host()];
|
||||
remoteStore.cases = [remoteCase];
|
||||
// The fake probe answers `true` by default — a reachable host.
|
||||
expect((await quickStart()).statusCode).toBe(200);
|
||||
expect(wakeCommandRun).not.toHaveBeenCalled();
|
||||
|
||||
// No wake target at all: not even a probe, so hosts without WoL keep the
|
||||
// exact behavior (and latency) they had before this feature.
|
||||
wakeProbe.mockClear();
|
||||
remoteStore.hosts = [host({ wakeMac: undefined })];
|
||||
expect((await quickStart()).statusCode).toBe(200);
|
||||
expect(wakeProbe).not.toHaveBeenCalled();
|
||||
expect(wakeCommandRun).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
startShell.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses the run when the host never comes back, and starts no session', async () => {
|
||||
remoteStore.hosts = [host()];
|
||||
remoteStore.cases = [remoteCase];
|
||||
wakeProbe.mockResolvedValue(false);
|
||||
wakeWaitUntilReady.mockResolvedValue(false);
|
||||
const sessionsBefore = harness.ctx.sessions.size;
|
||||
|
||||
const res = await quickStart();
|
||||
|
||||
expect(res.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.OPERATION_FAILED));
|
||||
expect(JSON.parse(res.body).error).toMatch(/did not come back after a wake-on-LAN request/);
|
||||
// No half-created session: the failure is the answer, not a dead tab.
|
||||
expect(harness.ctx.sessions.size).toBe(sessionsBefore);
|
||||
});
|
||||
|
||||
it('blames the sleeping host, not tmux, when the host has no wake target', async () => {
|
||||
remoteStore.hosts = [host({ wakeMac: undefined })];
|
||||
remoteStore.cases = [remoteCase];
|
||||
remoteStore.tmuxCheck = {
|
||||
ok: false,
|
||||
error: 'remote host 192.168.50.137 needs tmux installed for durable remote sessions',
|
||||
};
|
||||
wakeProbe.mockResolvedValue(false);
|
||||
|
||||
const res = await quickStart();
|
||||
|
||||
expect(res.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.OPERATION_FAILED));
|
||||
expect(JSON.parse(res.body).error).toMatch(/has no wake-on-LAN target/);
|
||||
});
|
||||
|
||||
it('keeps the tmux error when the host is up but tmux is really missing', async () => {
|
||||
remoteStore.hosts = [host()];
|
||||
remoteStore.cases = [remoteCase];
|
||||
remoteStore.tmuxCheck = {
|
||||
ok: false,
|
||||
error: 'remote host 192.168.50.137 needs tmux installed for durable remote sessions',
|
||||
};
|
||||
// Probe answers `true`: the ssh failure is genuinely about tmux.
|
||||
|
||||
const res = await quickStart();
|
||||
|
||||
expect(JSON.parse(res.body).error).toMatch(/needs tmux installed/);
|
||||
});
|
||||
|
||||
it('wakes the host when attaching to a discovered remote session', async () => {
|
||||
const startInteractive = vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined);
|
||||
const startShell = vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
|
||||
try {
|
||||
remoteStore.hosts = [host()];
|
||||
wakeProbe.mockResolvedValue(false);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: { attachRemoteSession: { hostId: 'hufflepuff', remoteSessionName: 'codeman-abc12345' } },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(wakeCommandRun).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
startInteractive.mockRestore();
|
||||
startShell.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('does not run local codex availability check for a remote codex case', async () => {
|
||||
// A remote codex case must NOT be blocked by the LOCAL codex availability gate
|
||||
// (the CLI runs on the remote host). Probe is stubbed ok in remoteStore.tmuxCheck.
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @fileoverview Static guard: every SSE dispatch entry must actually resolve.
|
||||
*
|
||||
* `app.js` dispatches server events through a table of `[SSE_EVENTS.X, '_onFoo']`
|
||||
* pairs. Both halves fail SILENTLY when they are wrong:
|
||||
*
|
||||
* - a handler name that exists in no module (renamed method, typo) → the event is
|
||||
* received and nothing happens, with no error anywhere;
|
||||
* - an `SSE_EVENTS.X` key that `constants.js` does not define → the table key is
|
||||
* `undefined`, so the entry can never match an incoming event.
|
||||
*
|
||||
* Both have happened in this codebase's feature areas (a new banner/toast that simply
|
||||
* never appears), and neither is visible to a test that only checks the modules compile.
|
||||
* Pure static analysis — no server, no browser.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const PUBLIC_DIR = fileURLToPath(new URL('../src/web/public', import.meta.url));
|
||||
|
||||
const appJs = readFileSync(join(PUBLIC_DIR, 'app.js'), 'utf-8');
|
||||
const constantsJs = readFileSync(join(PUBLIC_DIR, 'constants.js'), 'utf-8');
|
||||
const allModules = readdirSync(PUBLIC_DIR)
|
||||
.filter((name) => name.endsWith('.js'))
|
||||
.map((name) => readFileSync(join(PUBLIC_DIR, name), 'utf-8'))
|
||||
.join('\n');
|
||||
|
||||
/** `[SSE_EVENTS.FOO, '_onFoo'],` entries of the dispatch table. */
|
||||
function dispatchEntries(): { constant: string; handler: string }[] {
|
||||
const entries: { constant: string; handler: string }[] = [];
|
||||
const re = /\[SSE_EVENTS\.([A-Z0-9_]+),\s*'(_[A-Za-z0-9_]+)'\]/g;
|
||||
for (const match of appJs.matchAll(re)) {
|
||||
entries.push({ constant: match[1], handler: match[2] });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
describe('SSE dispatch table', () => {
|
||||
it('has entries to check (the table is what this guard exists for)', () => {
|
||||
expect(dispatchEntries().length).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
it('names only events that constants.js defines', () => {
|
||||
const defined = new Set([...constantsJs.matchAll(/^\s{2}([A-Z0-9_]+):\s*'/gm)].map((m) => m[1]));
|
||||
const missing = dispatchEntries()
|
||||
.map((entry) => entry.constant)
|
||||
.filter((name) => !defined.has(name));
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('names only handlers that some frontend module actually defines', () => {
|
||||
const missing = dispatchEntries()
|
||||
.map((entry) => entry.handler)
|
||||
.filter((handler) => !new RegExp(`(^|\\s)${handler}\\s*\\(`, 'm').test(allModules));
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('defines every handler in exactly ONE module (a second copy is shadowed)', () => {
|
||||
// Modules mix into `CodemanApp.prototype` and run in script order, so two
|
||||
// definitions of the same handler name silently shadow each other: the later file
|
||||
// wins and the earlier one never runs. The existence check above cannot see that
|
||||
// (both names resolve), which is how a duplicate banner handler can leave a toast
|
||||
// dead with no error anywhere.
|
||||
const byModule = readdirSync(PUBLIC_DIR)
|
||||
.filter((name) => name.endsWith('.js'))
|
||||
.map((name) => ({ name, source: readFileSync(join(PUBLIC_DIR, name), 'utf-8') }));
|
||||
const shadowed = dispatchEntries()
|
||||
.map((entry) => entry.handler)
|
||||
.filter((handler) => {
|
||||
const re = new RegExp(`(^|\\s)${handler}\\s*\\(`, 'm');
|
||||
return byModule.filter((mod) => re.test(mod.source)).length > 1;
|
||||
});
|
||||
expect(shadowed).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @fileoverview Multi-user routing of the `remote:*` SSE family (server.ts `deriveSseHint`).
|
||||
*
|
||||
* The wake events carry `hostId`/`label`, which `GET /api/remote-hosts` withholds from
|
||||
* non-admins, and their toast fires before any session check on the client — so an
|
||||
* event that falls through to the global branch shows every logged-in user "Waking
|
||||
* <label>" for a session they do not own. Constructs the server without starting it:
|
||||
* the hint is a pure function of the event, the payload and the sessions map.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
type Hint = { owner?: string; username?: string; adminOnly?: boolean; sessionScoped?: boolean } | undefined;
|
||||
|
||||
function hintFor(event: string, payload: Record<string, unknown>, owners: Record<string, string> = {}): Hint {
|
||||
const server = new WebServer(3999, false, true) as unknown as {
|
||||
sessions: Map<string, { owner?: string }>;
|
||||
deriveSseHint(event: string, data: unknown): Hint;
|
||||
};
|
||||
for (const [id, owner] of Object.entries(owners)) server.sessions.set(id, { owner });
|
||||
return server.deriveSseHint(event, payload);
|
||||
}
|
||||
|
||||
describe('deriveSseHint — remote: events are session-scoped', () => {
|
||||
it('routes a session wake to that session’s owner', () => {
|
||||
expect(hintFor('remote:hostWaking', { sessionId: 's1', hostId: 'h', label: 'H' }, { s1: 'alice' })).toEqual({
|
||||
owner: 'alice',
|
||||
sessionScoped: true,
|
||||
});
|
||||
expect(hintFor('remote:sessionReconnected', { sessionId: 's1' }, { s1: 'alice' })).toEqual({
|
||||
owner: 'alice',
|
||||
sessionScoped: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('routes a create/attach wake (no session yet) to the user who asked for it', () => {
|
||||
expect(hintFor('remote:hostWaking', { forNewSession: true, username: 'bob', hostId: 'h', label: 'H' })).toEqual({
|
||||
username: 'bob',
|
||||
sessionScoped: true,
|
||||
});
|
||||
expect(hintFor('remote:hostWakeFailed', { forNewSession: true, username: 'bob', hostId: 'h' })).toEqual({
|
||||
username: 'bob',
|
||||
sessionScoped: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed (admins only) when it names neither a session nor a requester', () => {
|
||||
const hint = hintFor('remote:hostWaking', { forNewSession: true, hostId: 'h', label: 'H' });
|
||||
expect(hint).toEqual({ owner: undefined, sessionScoped: true });
|
||||
});
|
||||
|
||||
it('never lets a wake event reach the global branch', () => {
|
||||
expect(hintFor('remote:hostWaking', {})).not.toBeUndefined();
|
||||
expect(hintFor('remote:reconnectExhausted', {})).not.toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user