mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
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
231 lines
9.9 KiB
TypeScript
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 } }]);
|
|
});
|