Files
Codeman/test/tunnel-enable-confirm-ui.test.ts
T
JD fc0ecc6051 fix(tunnel): give the unauthenticated tunnel its own acknowledgement
The network acknowledgement that install.sh writes for every passwordless LAN install also satisfied the tunnel guard, so one click on the welcome screen's tunnel link published full terminal control to a public URL with no login and no confirm. The tunnel now needs CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL or --allow-unauthenticated-tunnel (carried by web -d and service install), and with no login the browser asks before every enable, reading a new loginConfigured field from GET /api/tunnel/status.

The guard is now decided once per request and required again where the tunnel starts, so a save that found the tunnel running can no longer restart it unguarded after a concurrent stop.

Closes #706
2026-10-26 03:13:25 -04:00

231 lines
9.9 KiB
TypeScript

/**
* @fileoverview Issue #706 browser confirmation policy through the real welcome, tunnel panel and App Settings entry points. HTTP and browser prompts are stubbed; settings and acknowledgement logic run unchanged.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { JSDOM } from 'jsdom';
import { afterEach, describe, expect, it, vi } from 'vitest';
const PUBLIC = join(process.cwd(), 'src/web/public');
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
const INDEX = read('index.html');
const SCRIPTS = ['constants.js', 'app.js', 'settings-ui.js', 'api-client.js'].map(read);
const STORAGE_KEY = 'codeman-app-settings';
const NO_LOGIN = { running: false, url: null, loginConfigured: false };
type Status = { running: boolean; url: string | null; loginConfigured?: boolean };
type Call = { method: string; url: string; body: Record<string, unknown> | undefined };
type Entry = 'welcome' | 'panel' | 'save';
const windows: JSDOM[] = [];
afterEach(() => {
for (const dom of windows.splice(0)) dom.window.close();
});
async function boot(
status: Status = NO_LOGIN,
accept = true,
statusFailure?: 'reject' | 'non-ok',
refuseFirstPut = false
) {
const dom = new JSDOM(INDEX, { url: 'http://localhost/', runScripts: 'outside-only' });
windows.push(dom);
const win = dom.window;
if (win.document.readyState !== 'complete') await new Promise((resolve) => win.addEventListener('load', resolve));
// Keep status polling and unrelated rendering timers outside this user action.
win.setTimeout = (() => 0) as unknown as typeof win.setTimeout;
win.setInterval = (() => 0) as unknown as typeof win.setInterval;
win.localStorage.setItem(STORAGE_KEY, JSON.stringify({ tunnelEnabled: false }));
const calls: Call[] = [];
const order: string[] = [];
const confirm = vi.fn(() => {
order.push('confirm');
return accept;
});
win.confirm = confirm;
let putCount = 0;
const response = (body: unknown, statusCode = 200) => ({
ok: statusCode >= 200 && statusCode < 300,
status: statusCode,
json: async () => body,
clone: () => response(body, statusCode),
});
const fetch = vi.fn(async (url: string, opts: { method?: string; body?: string } = {}) => {
const method = opts.method || 'GET';
calls.push({ method, url, body: opts.body === undefined ? undefined : JSON.parse(opts.body) });
order.push(`${method} ${url}`);
if (url === '/api/tunnel/status' && method === 'GET') {
if (statusFailure === 'reject') throw new Error('Status unavailable');
// A failed HTTP response must not be trusted, even if its JSON resembles a status.
return response(status, statusFailure === 'non-ok' ? 503 : 200);
}
if (url === '/api/settings' && method === 'PUT') {
putCount++;
if (refuseFirstPut && putCount === 1) {
return response(
{ success: false, error: 'Tunnel acknowledgement required', errorCode: 'OPERATION_FAILED' },
403
);
}
return response({ success: true, data: {} });
}
if (url === '/api/execution/model-config' && method === 'PUT') return response({ success: true, data: {} });
throw new Error(`Unexpected request: ${method} ${url}`);
});
Object.assign(win, { fetch });
win.eval(
[
`var MobileDetection = {
getDeviceType: () => 'desktop', isHandheldDevice: () => false,
isMobile: () => false, isTouchDevice: () => false,
};
var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {};
var KeyboardAccessoryBar = { setMode() {} };
var VoiceInput = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; };
var DEFAULT_VOICE_KEYTERMS = '';`,
...SCRIPTS,
'window.__App = CodemanApp;',
].join('\n')
);
// Only peripheral rendering is stubbed. All tunnel logic, settings reads/writes and HTTP helpers run for real.
const app = Object.create((win as unknown as { __App: { prototype: object } }).__App.prototype);
app.sessions = new Map();
app.sessionOrder = [];
for (const name of [
'showToast',
'closeAppSettings',
'_updateLocalEchoState',
'applyTerminalFontFamily',
'applyTerminalFontWeights',
'_syncPushPreferences',
'applyHeaderVisibilitySettings',
'applySkin',
'applyLocalization',
'applySessionListLayout',
'applyTabOrientation',
'applyLineageLineSettings',
'_updateTokensImmediate',
'applyMonitorVisibility',
'renderApprovals',
'renderProjectInsightsPanel',
'updateSubagentWindowVisibility',
'_updateCjkInputState',
'showWelcome',
])
app[name] = vi.fn();
const box = win.document.getElementById('appSettingsTunnelEnabled') as HTMLInputElement;
const button = win.document.getElementById('welcomeTunnelBtn') as HTMLButtonElement;
// The welcome screen has finished its status-dependent reveal before the user clicks.
button.style.display = '';
const puts = () => calls.filter((call) => call.method === 'PUT' && call.url === '/api/settings');
const events = () =>
order.filter(
(event) => event === 'confirm' || event.endsWith('/api/settings') || event.endsWith('/api/tunnel/status')
);
const stored = () => JSON.parse(win.localStorage.getItem(STORAGE_KEY) || '{}');
const act = async (entry: Entry, enable = true) => {
if (entry === 'save') box.checked = enable;
if (entry === 'welcome') button.classList.toggle('active', !enable);
vi.clearAllMocks();
calls.length = 0;
order.length = 0;
if (entry === 'welcome') await app.toggleTunnelFromWelcome();
if (entry === 'panel') await app._tunnelPanelToggle(enable);
if (entry === 'save') await app.saveAppSettings();
};
return { act, puts, events, confirm, box, button, stored };
}
function expectEnablePut(calls: Call[], entry: Entry, acknowledge: boolean) {
expect(calls).toHaveLength(1);
const expected = { tunnelEnabled: true, ...(acknowledge ? { acknowledgeUnauthTunnel: true } : {}) };
if (entry === 'save') {
// The spec leaves the other App Settings fields unchanged.
expect(calls[0].body).toMatchObject(expected);
if (!acknowledge) expect(calls[0].body).not.toHaveProperty('acknowledgeUnauthTunnel');
} else {
expect(calls).toEqual([{ method: 'PUT', url: '/api/settings', body: expected }]);
}
}
describe.each(['welcome', 'panel', 'save'] as const)('%s tunnel enable', (entry) => {
it('confirms before a successful PUT and sends the acknowledgement', async () => {
const h = await boot();
await h.act(entry);
expect.soft(h.confirm).toHaveBeenCalledTimes(1);
expect.soft(h.events()).toEqual(['GET /api/tunnel/status', 'confirm', 'PUT /api/settings']);
expectEnablePut(h.puts(), entry, true);
});
it('asks again on every enable attempt', async () => {
const h = await boot();
for (let attempt = 0; attempt < 2; attempt++) {
await h.act(entry);
expect.soft(h.confirm).toHaveBeenCalledTimes(1);
expect.soft(h.events()).toEqual(['GET /api/tunnel/status', 'confirm', 'PUT /api/settings']);
}
});
it('cancels without a PUT and leaves the tunnel off', async () => {
const h = await boot(NO_LOGIN, false);
await h.act(entry);
expect.soft(h.confirm).toHaveBeenCalledTimes(1);
expect.soft(h.puts()).toEqual([]);
expect.soft(h.events()).toEqual(['GET /api/tunnel/status', 'confirm']);
expect.soft(h.button.classList.contains('active')).toBe(false);
expect.soft(h.button.disabled).toBe(false);
expect.soft(h.button.style.display).not.toBe('none');
expect.soft(h.box.disabled).toBe(false);
expect.soft(h.box.checked).toBe(false);
if (entry === 'save') expect.soft(h.stored().tunnelEnabled).toBe(false);
});
it('does not confirm or acknowledge when login is configured', async () => {
const h = await boot({ ...NO_LOGIN, loginConfigured: true });
await h.act(entry);
expect(h.confirm).not.toHaveBeenCalled();
expectEnablePut(h.puts(), entry, false);
});
it('does not confirm or acknowledge when loginConfigured is absent', async () => {
const h = await boot({ running: false, url: null });
await h.act(entry);
expect(h.confirm).not.toHaveBeenCalled();
expect(h.events()).toEqual(['GET /api/tunnel/status', 'PUT /api/settings']);
expectEnablePut(h.puts(), entry, false);
});
it.each(['reject', 'non-ok'] as const)('sends no acknowledgement when the status read is %s', async (failure) => {
const h = await boot(NO_LOGIN, true, failure);
await h.act(entry);
expect(h.confirm).not.toHaveBeenCalled();
expect.soft(h.events()).toEqual(['GET /api/tunnel/status', 'PUT /api/settings']);
expectEnablePut(h.puts(), entry, false);
});
});
it('save does not confirm or acknowledge an already running tunnel', async () => {
const h = await boot({ running: true, url: 'https://test.trycloudflare.com', loginConfigured: false });
await h.act('save');
expect(h.confirm).not.toHaveBeenCalled();
expectEnablePut(h.puts(), 'save', false);
});
it.each(['reject', 'non-ok'] as const)('welcome keeps the 403 fallback when the status read is %s', async (failure) => {
const h = await boot(NO_LOGIN, true, failure, true);
await h.act('welcome');
expect.soft(h.confirm).toHaveBeenCalledTimes(1);
expect.soft(h.events()).toEqual(['GET /api/tunnel/status', 'PUT /api/settings', 'confirm', 'PUT /api/settings']);
expect(h.puts()).toEqual([
{ method: 'PUT', url: '/api/settings', body: { tunnelEnabled: true } },
{ method: 'PUT', url: '/api/settings', body: { tunnelEnabled: true, acknowledgeUnauthTunnel: true } },
]);
});
it.each(['welcome', 'panel'] as const)('%s disables without confirmation', async (entry) => {
const h = await boot();
await h.act(entry, false);
expect(h.confirm).not.toHaveBeenCalled();
expect(h.puts()).toEqual([{ method: 'PUT', url: '/api/settings', body: { tunnelEnabled: false } }]);
});