eat: implement Holesail-Browser enhancement plan
CI / Build & Test (push) Successful in 3m19s

- Add unit tests (hostname-validator, TLDs, payload-schemas) and integration tests for message handler registry
- Refactor native host message router into handler registry (handlers/state, tunnels, ssh, rdp, backup, ca, connections)
- Add ESLint config and npm test + lint steps in CI
- Dashboard: visibility-based refresh pause, configurable refresh interval (2s/5s/10s/paused)
- Accessibility: ARIA on nav and modals, focus trap and restore, prefers-reduced-motion
- Empty states: primary action buttons for virtual hosts, servers, service tunnels
- Native host rate limiting for backup and CA operations; update SECURITY.md
- CONTRIBUTING: "Adding a new dashboard page", dev workflow; add npm run dev script
This commit is contained in:
Raven Scott
2026-03-15 00:24:31 -04:00
parent 8df1ef3ec6
commit 6fcd9fcf2b
38 changed files with 2028 additions and 279 deletions
+93
View File
@@ -0,0 +1,93 @@
/**
* Unit tests for extension/dashboard/data/hostname-validator.js
* Run with: node --test test/hostname-validator.test.js
*/
const path = require('path');
require(path.join(__dirname, 'setup-validator.js'));
const { isValidVhostHostname, extractBaseDomain, extractActiveTlds } = require(path.join(__dirname, '../extension/dashboard/data/hostname-validator.js'));
const { test, describe } = require('node:test');
const assert = require('node:assert');
describe('isValidVhostHostname', () => {
test('rejects empty or missing hostname', () => {
assert.deepStrictEqual(isValidVhostHostname(''), { ok: false, error: 'Hostname is required' });
assert.deepStrictEqual(isValidVhostHostname(null), { ok: false, error: 'Hostname is required' });
assert.deepStrictEqual(isValidVhostHostname(undefined), { ok: false, error: 'Hostname is required' });
});
test('rejects hostnames with fewer than 3 labels', () => {
assert.ok(!isValidVhostHostname('hole.sail').ok);
assert.ok(!isValidVhostHostname('single').ok);
assert.ok(isValidVhostHostname('myapp.hole.sail').ok);
});
test('accepts valid private TLD hostnames', () => {
assert.deepStrictEqual(isValidVhostHostname('myapp.hole.sail'), { ok: true });
assert.deepStrictEqual(isValidVhostHostname('api.my.internal'), { ok: true });
assert.deepStrictEqual(isValidVhostHostname('i.love.hole.sail'), { ok: true });
assert.deepStrictEqual(isValidVhostHostname('a.b.c.my.internal'), { ok: true });
});
test('rejects real single-label TLDs', () => {
const r = isValidVhostHostname('foo.bar.com');
assert.strictEqual(r.ok, false);
assert.ok(r.error.includes('real registered domain'));
assert.ok(r.error.includes('bar.com'));
});
test('rejects real two-label public suffixes', () => {
const r = isValidVhostHostname('foo.co.uk');
assert.strictEqual(r.ok, false);
assert.ok(r.error.includes('co.uk'));
});
test('rejects invalid characters in labels', () => {
const r = isValidVhostHostname('my_app.hole.sail');
assert.strictEqual(r.ok, false);
assert.ok(r.error.includes('invalid characters'));
});
test('rejects leading or trailing hyphen in label', () => {
assert.ok(!isValidVhostHostname('-x.hole.sail').ok);
assert.ok(!isValidVhostHostname('x-.hole.sail').ok);
assert.deepStrictEqual(isValidVhostHostname('my-app.hole.sail'), { ok: true });
});
test('rejects empty labels', () => {
const r = isValidVhostHostname('myapp..hole.sail');
assert.strictEqual(r.ok, false);
assert.ok(r.error.includes('empty'));
});
});
describe('extractBaseDomain', () => {
test('returns last two labels', () => {
assert.strictEqual(extractBaseDomain('myapp.hole.sail'), 'hole.sail');
assert.strictEqual(extractBaseDomain('api.my.internal'), 'my.internal');
assert.strictEqual(extractBaseDomain('i.love.hole.sail'), 'hole.sail');
assert.strictEqual(extractBaseDomain('a.b.c.d.tld'), 'd.tld');
});
});
describe('extractActiveTlds', () => {
test('always includes hole.sail', () => {
const tlds = extractActiveTlds([]);
assert.ok(tlds.includes('.hole.sail'));
});
test('returns unique TLDs with leading dot', () => {
const tlds = extractActiveTlds([
{ hostname: 'a.hole.sail' },
{ hostname: 'b.hole.sail' },
{ hostname: 'c.my.internal' }
]);
assert.ok(tlds.includes('.hole.sail'));
assert.ok(tlds.includes('.my.internal'));
assert.strictEqual(tlds.filter(t => t === '.hole.sail').length, 1);
});
test('ignores entries without hostname', () => {
const tlds = extractActiveTlds([{}, { hostname: 'x.hole.sail' }]);
assert.ok(tlds.includes('.hole.sail'));
});
});
+163
View File
@@ -0,0 +1,163 @@
/**
* Integration-style tests for the native host message handler registry.
* Uses mocked dependencies so we don't need the full Bare runtime or real managers.
* Run with: node --test test/message-router.test.js
*
* Note: This test loads native-host/host/handlers/index.js and the handler modules.
* It does NOT load message-router.js or the real managers (holesail-manager, etc.),
* so we test the handler registry and handler logic with mocks.
*/
const path = require('path');
const { buildHandlers } = require(path.join(__dirname, '../native-host/host/handlers/index.js'));
const { test, describe } = require('node:test');
const assert = require('node:assert');
function mockReply() {
const calls = [];
const reply = (result) => { calls.push(result); };
reply.calls = calls;
return reply;
}
function makeMockDeps(overrides = {}) {
return {
log: () => {},
debugLog: () => {},
holesailManager: {
getServers: () => [],
getVirtualHosts: () => [],
getServiceTunnels: () => [],
getProxyPort: () => 8443,
getSettings: () => ({}),
getSshConnections: () => [],
getRdpConnections: () => [],
setSshConnections: () => {},
setRdpConnections: () => {},
startServer: async () => ({}),
stopServer: async () => ({}),
startServiceTunnel: async () => ({}),
stopServiceTunnel: async () => ({}),
setVirtualHost: async () => ({}),
removeVirtualHost: async () => ({}),
lookup: async () => ({}),
getLocalBackend: () => null,
restorePersistedState: () => ({}),
cleanup: async () => {},
updateSettings: () => ({}),
DEFAULT_RETENTION: 5
},
certificateAuthority: {
isRootCAInstalled: (cb) => cb(false)
},
httpsProxy: { getTrafficStats: () => ({ bytesIn: 0, bytesOut: 0 }) },
connectProxy: { getPort: () => 8442 },
getProxiesReadyPromise: () => null,
getTunnelsRestoredPromise: () => null,
setTunnelsRestoredPromise: () => {},
restorePersistedTunnels: async () => {},
scheduleNextAutoBackup: () => {},
sshManager: { startSession: async () => ({}), stopSession: async () => ({}), resizeSession: () => {}, getSessions: () => [] },
rdpManager: { startSession: async () => ({}), stopSession: async () => ({}), getSessions: () => [] },
backupManager: {
createBackup: async () => ({}),
listBackups: () => ({}),
restoreBackup: async () => ({}),
deleteBackup: () => ({}),
pruneOldBackups: () => {},
DEFAULT_RETENTION: 5
},
...overrides
};
}
describe('handler registry', () => {
test('buildHandlers returns a Map with expected command types', () => {
const mockDeps = makeMockDeps();
const handlers = buildHandlers(mockDeps);
assert.ok(handlers instanceof Map);
const expectedTypes = [
'getState', 'getSettings', 'updateSettings',
'getSshConnections', 'setSshConnections', 'getRdpConnections', 'setRdpConnections',
'startServer', 'stopServer', 'startServiceTunnel', 'updateServiceTunnel', 'stopServiceTunnel', 'getServiceTunnels',
'getVirtualHosts', 'setVirtualHost', 'removeVirtualHost', 'getProxyPort', 'lookup', 'pingTunnel',
'installRootCA',
'startSshSession', 'stopSshSession', 'resizeSshSession', 'getSshSessions',
'startRdpSession', 'stopRdpSession', 'getRdpSessions',
'createBackup', 'listBackups', 'restoreBackup', 'deleteBackup'
];
for (const type of expectedTypes) {
assert.ok(handlers.has(type), `missing handler: ${type}`);
}
});
test('getSettings handler calls reply with ok and settings', async () => {
const settings = { proxyPort: 8443, connectProxyPort: 8442 };
const mockDeps = makeMockDeps({
holesailManager: { getSettings: () => settings },
certificateAuthority: {},
httpsProxy: {},
connectProxy: {},
getProxiesReadyPromise: () => null,
getTunnelsRestoredPromise: () => null,
setTunnelsRestoredPromise: () => {},
restorePersistedTunnels: async () => {},
scheduleNextAutoBackup: () => {},
sshManager: {},
rdpManager: {},
backupManager: {}
});
const handlers = buildHandlers(mockDeps);
const reply = mockReply();
await handlers.get('getSettings')({}, reply);
assert.strictEqual(reply.calls.length, 1);
assert.strictEqual(reply.calls[0].ok, true);
assert.deepStrictEqual(reply.calls[0].settings, settings);
});
test('getSshConnections handler calls reply with ok and sshConnections', async () => {
const list = [{ id: 'ssh-1', label: 'My Server' }];
const mockDeps = makeMockDeps({
holesailManager: {
getSettings: () => ({}),
getServers: () => [],
getVirtualHosts: () => [],
getServiceTunnels: () => [],
getProxyPort: () => 8443,
getSshConnections: () => list,
getRdpConnections: () => [],
setSshConnections: () => {},
setRdpConnections: () => {},
startServer: async () => ({}),
stopServer: async () => ({}),
startServiceTunnel: async () => ({}),
stopServiceTunnel: async () => ({}),
setVirtualHost: async () => ({}),
removeVirtualHost: async () => ({}),
lookup: async () => ({}),
getLocalBackend: () => null,
restorePersistedState: () => ({}),
cleanup: async () => {},
updateSettings: () => ({}),
DEFAULT_RETENTION: 5
},
certificateAuthority: {},
httpsProxy: {},
connectProxy: {},
getProxiesReadyPromise: () => null,
getTunnelsRestoredPromise: () => null,
setTunnelsRestoredPromise: () => {},
restorePersistedTunnels: async () => {},
scheduleNextAutoBackup: () => {},
sshManager: {},
rdpManager: {},
backupManager: {}
});
const handlers = buildHandlers(mockDeps);
const reply = mockReply();
await handlers.get('getSshConnections')({}, reply);
assert.strictEqual(reply.calls.length, 1);
assert.strictEqual(reply.calls[0].ok, true);
assert.deepStrictEqual(reply.calls[0].sshConnections, list);
});
});
+65
View File
@@ -0,0 +1,65 @@
/**
* Unit tests for native-host/host/payload-schemas.js
* Run with: node --test test/payload-schemas.test.js
*/
const path = require('path');
const {
validateSetVirtualHost,
validateRemoveVirtualHost,
validateStartServer,
validateStopServer
} = require(path.join(__dirname, '../native-host/host/payload-schemas.js'));
const { test, describe } = require('node:test');
const assert = require('node:assert');
describe('validateSetVirtualHost', () => {
test('accepts valid payload', () => {
assert.deepStrictEqual(validateSetVirtualHost({ hostname: 'myapp.hole.sail', hsUrl: 'hs://abc123' }), { ok: true });
});
test('rejects missing hostname', () => {
const r = validateSetVirtualHost({ hsUrl: 'hs://x' });
assert.strictEqual(r.ok, false);
assert.ok(r.error.includes('hostname'));
});
test('rejects missing hsUrl', () => {
const r = validateSetVirtualHost({ hostname: 'myapp.hole.sail' });
assert.strictEqual(r.ok, false);
assert.ok(r.error.includes('hsUrl'));
});
test('rejects hsUrl not starting with hs://', () => {
const r = validateSetVirtualHost({ hostname: 'myapp.hole.sail', hsUrl: 'http://x' });
assert.strictEqual(r.ok, false);
assert.ok(r.error.includes('hs://'));
});
});
describe('validateRemoveVirtualHost', () => {
test('accepts valid payload', () => {
assert.deepStrictEqual(validateRemoveVirtualHost({ hostname: 'myapp.hole.sail' }), { ok: true });
});
test('rejects missing hostname', () => {
const r = validateRemoveVirtualHost({});
assert.strictEqual(r.ok, false);
});
});
describe('validateStartServer', () => {
test('accepts valid payload', () => {
assert.deepStrictEqual(validateStartServer({ port: 3000 }), { ok: true });
assert.deepStrictEqual(validateStartServer({ port: 3000, host: '127.0.0.1', label: 'My App' }), { ok: true });
});
test('rejects missing or invalid port', () => {
assert.ok(!validateStartServer({}).ok);
assert.ok(!validateStartServer({ port: 0 }).ok);
assert.ok(!validateStartServer({ port: '3000' }).ok);
});
});
describe('validateStopServer', () => {
test('accepts valid payload', () => {
assert.deepStrictEqual(validateStopServer({ serverId: 'server_1' }), { ok: true });
});
test('rejects missing serverId', () => {
assert.ok(!validateStopServer({}).ok);
});
});
+8
View File
@@ -0,0 +1,8 @@
/**
* Set globals required by hostname-validator.js when run under Node.
* Must be required before hostname-validator so REAL_TLDS/REAL_SLD_TLDS are set.
*/
const path = require('path');
const tlds = require(path.join(__dirname, '../extension/dashboard/data/tlds.js'));
global.REAL_TLDS = tlds.REAL_TLDS;
global.REAL_SLD_TLDS = tlds.REAL_SLD_TLDS;
+47
View File
@@ -0,0 +1,47 @@
/**
* Unit tests for extension/dashboard/data/tlds.js
* Run with: node --test test/tlds.test.js
*/
const path = require('path');
const { REAL_TLDS, REAL_SLD_TLDS } = require(path.join(__dirname, '../extension/dashboard/data/tlds.js'));
const { test, describe } = require('node:test');
const assert = require('node:assert');
describe('REAL_TLDS', () => {
test('is a Set', () => {
assert.ok(REAL_TLDS instanceof Set);
});
test('contains expected common TLDs', () => {
assert.ok(REAL_TLDS.has('com'));
assert.ok(REAL_TLDS.has('net'));
assert.ok(REAL_TLDS.has('org'));
assert.ok(REAL_TLDS.has('io'));
assert.ok(REAL_TLDS.has('uk'));
assert.ok(REAL_TLDS.has('de'));
});
test('does not contain hole.sail or private TLDs', () => {
assert.strictEqual(REAL_TLDS.has('hole'), false);
assert.strictEqual(REAL_TLDS.has('sail'), false);
assert.strictEqual(REAL_TLDS.has('internal'), false);
});
});
describe('REAL_SLD_TLDS', () => {
test('is a Set', () => {
assert.ok(REAL_SLD_TLDS instanceof Set);
});
test('contains expected two-label public suffixes', () => {
assert.ok(REAL_SLD_TLDS.has('co.uk'));
assert.ok(REAL_SLD_TLDS.has('com.au'));
assert.ok(REAL_SLD_TLDS.has('co.jp'));
assert.ok(REAL_SLD_TLDS.has('com.br'));
});
test('does not contain hole.sail or private base domains', () => {
assert.strictEqual(REAL_SLD_TLDS.has('hole.sail'), false);
assert.strictEqual(REAL_SLD_TLDS.has('my.internal'), false);
});
});