- 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:
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user