diff --git a/main.js b/main.js index 65b3b9c..29b18c6 100644 --- a/main.js +++ b/main.js @@ -78,6 +78,8 @@ function registerIpc(channel, handler) { function registerIpcHandlers() { registerIpc('get-devices', () => runtime.getDevices()); registerIpc('get-device-sites', () => runtime.getDeviceSites()); + registerIpc('get-device-groups', () => runtime.getDeviceGroups()); + registerIpc('get-device-hierarchy', () => runtime.getDeviceHierarchy()); registerIpc('get-auth-info', () => runtime.getAuthInfo()); registerIpc('launch-proxy', (deviceId, username) => runtime.launchProxy(deviceId, username)); registerIpc('stop-proxy', async (key) => { diff --git a/preload.js b/preload.js index 93f85ba..bd60372 100644 --- a/preload.js +++ b/preload.js @@ -12,6 +12,8 @@ function onConnectionStateChanged(callback) { contextBridge.exposeInMainWorld('electronAPI', Object.freeze({ getDevices: () => ipcRenderer.invoke('get-devices'), getDeviceSites: () => ipcRenderer.invoke('get-device-sites'), + getDeviceGroups: () => ipcRenderer.invoke('get-device-groups'), + getDeviceHierarchy: () => ipcRenderer.invoke('get-device-hierarchy'), getAuthInfo: () => ipcRenderer.invoke('get-auth-info'), launchProxy: (deviceId, username) => ipcRenderer.invoke('launch-proxy', deviceId, username), stopProxy: (key) => ipcRenderer.invoke('stop-proxy', key), diff --git a/src/alta-client.js b/src/alta-client.js index f2a53a1..ff16611 100644 --- a/src/alta-client.js +++ b/src/alta-client.js @@ -3,13 +3,24 @@ const { canonicalizeAltaOrigin, assertSameAltaOrigin } = require('./url-policy'); const { formatAltaError } = require('./error-redaction'); -const DEFAULT_TIMEOUT_MS = 10_000; -const DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024; +const DEFAULT_TIMEOUT_MS = 30_000; +const DEVICE_MAX_RESPONSE_BYTES = 32 * 1024 * 1024; +const HIERARCHY_MAX_RESPONSE_BYTES = 4 * 1024 * 1024; +const AUTH_MAX_RESPONSE_BYTES = 2 * 1024 * 1024; +const DEFAULT_MAX_RESPONSE_BYTES = DEVICE_MAX_RESPONSE_BYTES; +const MAX_ARRAY_OBJECTS = 10_000; const DEFAULT_MAX_REDIRECTS = 3; const ENDPOINTS = Object.freeze({ - getDevices: Object.freeze({ path: '/api/v1/devices', shape: 'array' }), - getDeviceSites: Object.freeze({ path: '/api/v1/deviceSites', shape: 'array' }), - getAuthInfo: Object.freeze({ path: '/api/v1/auth', shape: 'object' }), + getDevices: Object.freeze({ + path: '/api/v1/devices', shape: 'array', maxResponseBytes: DEVICE_MAX_RESPONSE_BYTES, maxObjects: MAX_ARRAY_OBJECTS, + }), + getDeviceSites: Object.freeze({ + path: '/api/v1/deviceSites', shape: 'array', maxResponseBytes: HIERARCHY_MAX_RESPONSE_BYTES, maxObjects: MAX_ARRAY_OBJECTS, + }), + getDeviceGroups: Object.freeze({ + path: '/api/v1/deviceGroups', shape: 'array', maxResponseBytes: HIERARCHY_MAX_RESPONSE_BYTES, maxObjects: MAX_ARRAY_OBJECTS, + }), + getAuthInfo: Object.freeze({ path: '/api/v1/auth', shape: 'object', maxResponseBytes: AUTH_MAX_RESPONSE_BYTES }), }); const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); @@ -118,7 +129,7 @@ class AltaClient { } if (typeof transport !== 'function') throw new TypeError('AltaClient transport must be a function'); if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > DEFAULT_TIMEOUT_MS) { - throw new RangeError('AltaClient timeout must be between 1 and 10000ms'); + throw new RangeError('AltaClient timeout must be between 1 and 30000ms'); } if (!Number.isInteger(maxResponseBytes) || maxResponseBytes < 1 || maxResponseBytes > DEFAULT_MAX_RESPONSE_BYTES) { throw new RangeError('Invalid Alta response size limit'); @@ -141,6 +152,10 @@ class AltaClient { return this.#invoke('getDeviceSites', args); } + getDeviceGroups(...args) { + return this.#invoke('getDeviceGroups', args); + } + getAuthInfo(...args) { return this.#invoke('getAuthInfo', args); } @@ -150,6 +165,7 @@ class AltaClient { throw altaError('INVALID_ALTA_ARGUMENTS', 'Alta API methods do not accept renderer parameters'); } const endpoint = ENDPOINTS[operation]; + const maxResponseBytes = Math.min(this.maxResponseBytes, endpoint.maxResponseBytes); const session = this.sessionStore.requireSession(); const origin = canonicalizeAltaOrigin(session.origin); const cookie = session.cookie; @@ -157,10 +173,15 @@ class AltaClient { const controller = new AbortController(); try { - const data = await this.#request(operation, `${origin}${endpoint.path}`, origin, cookie, deadline, controller, 0); + const data = await this.#request( + operation, `${origin}${endpoint.path}`, origin, cookie, deadline, controller, 0, maxResponseBytes, + ); if (endpoint.shape === 'array' && !Array.isArray(data)) { throw altaError('INVALID_ALTA_RESPONSE_DATA', 'Alta response did not contain an array'); } + if (endpoint.shape === 'array' && data.length > endpoint.maxObjects) { + throw altaError('ALTA_RESPONSE_TOO_MANY_OBJECTS', 'Alta response exceeded the object limit'); + } if (endpoint.shape === 'object' && (!data || typeof data !== 'object' || Array.isArray(data))) { throw altaError('INVALID_ALTA_RESPONSE_DATA', 'Alta response did not contain an object'); } @@ -172,7 +193,7 @@ class AltaClient { } } - async #request(operation, url, origin, cookie, deadline, controller, redirectCount) { + async #request(operation, url, origin, cookie, deadline, controller, redirectCount, maxResponseBytes) { assertSameAltaOrigin(url, origin); const remaining = deadline - Date.now(); if (remaining <= 0) throw altaError('ALTA_TIMEOUT', 'Alta request timed out', { timeout: true }); @@ -196,7 +217,7 @@ class AltaClient { signal: controller.signal, proxy: false, maxRedirects: 0, - maxResponseBytes: this.maxResponseBytes, + maxResponseBytes, }))), timeoutPromise, ]); @@ -209,7 +230,7 @@ class AltaClient { } const headers = normalizeHeaders(response.headers); // Bound every response, including redirects and errors, before acting on it. - enforceResponseSize(response.data, headers, this.maxResponseBytes); + enforceResponseSize(response.data, headers, maxResponseBytes); if (REDIRECT_STATUSES.has(response.status)) { if (typeof headers.location !== 'string' || headers.location.length === 0) { @@ -225,14 +246,16 @@ class AltaClient { } catch { throw altaError('UNSAFE_ALTA_REDIRECT', 'Alta redirect changed the validated origin'); } - return this.#request(operation, target.href, origin, cookie, deadline, controller, redirectCount + 1); + return this.#request( + operation, target.href, origin, cookie, deadline, controller, redirectCount + 1, maxResponseBytes, + ); } if (response.status < 200 || response.status > 299) { throw altaError('ALTA_HTTP_ERROR', 'Alta request returned an error status', { status: response.status }); } - return parseData(response.data, this.maxResponseBytes); + return parseData(response.data, maxResponseBytes); } } @@ -242,10 +265,14 @@ function createAltaClient(options) { module.exports = { AltaClient, + AUTH_MAX_RESPONSE_BYTES, DEFAULT_MAX_REDIRECTS, DEFAULT_MAX_RESPONSE_BYTES, DEFAULT_TIMEOUT_MS, + DEVICE_MAX_RESPONSE_BYTES, ENDPOINTS, + HIERARCHY_MAX_RESPONSE_BYTES, + MAX_ARRAY_OBJECTS, axiosTransport, createAltaClient, }; diff --git a/src/device-projection.js b/src/device-projection.js new file mode 100644 index 0000000..fdebb16 --- /dev/null +++ b/src/device-projection.js @@ -0,0 +1,127 @@ +'use strict'; + +const { validateDeviceId } = require('./proxy-launch'); + +const MAX_PROJECTED_PAYLOAD_BYTES = 8 * 1024 * 1024; + +function projectionError(code, message) { + const error = new Error(message); + error.code = code; + return error; +} + +function nullableString(value) { + return typeof value === 'string' ? value : null; +} + +function nullableId(value) { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function nullableBoolean(value) { + return typeof value === 'boolean' ? value : null; +} + +function projectDevice(raw) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; + let id; + try { + id = validateDeviceId(raw.guid); + } catch { + return null; + } + return { + id, + name: nullableString(raw.name), + type: nullableString(raw.type), + model: nullableString(raw.model), + address: nullableString(raw.address), + siteId: nullableId(raw.server_group_id), + deviceGroupId: nullableId(raw.device_group_id), + displayStatus: nullableString(raw.live && raw.live.display_status), + localStorage: nullableBoolean(raw.capabilities && raw.capabilities.localStorage), + }; +} + +function projectSite(raw) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; + const id = nullableId(raw.id); + if (!id) return null; + return { + id, + name: nullableString(raw.name), + pendingDeletionStart: nullableString(raw.pending_deletion_start), + }; +} + +function projectGroup(raw) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; + const id = nullableId(raw.id); + if (!id) return null; + return { + id, + name: nullableString(raw.name), + parentId: nullableId(raw.parent_id), + pendingDeletionStart: nullableString(raw.pending_deletion_start), + }; +} + +function projectArray(values, projector) { + const projected = []; + let invalid = 0; + for (const value of Array.isArray(values) ? values : []) { + const item = projector(value); + if (item) projected.push(item); + else invalid += 1; + } + return { projected, invalid }; +} + +function safeMetadataError(value) { + return typeof value === 'string' && value.length > 0 ? value.slice(0, 512) : null; +} + +function projectDeviceHierarchy({ devices = [], sites = [], groups = [], metadataErrors = {} } = {}) { + if (!Array.isArray(devices) || !Array.isArray(sites) || !Array.isArray(groups)) { + throw new TypeError('Hierarchy projection requires arrays'); + } + const deviceProjection = projectArray(devices, projectDevice); + const siteProjection = projectArray(sites, projectSite); + const groupProjection = projectArray(groups, projectGroup); + const payload = { + devices: deviceProjection.projected, + sites: siteProjection.projected, + groups: groupProjection.projected, + diagnostics: { + devices: { + received: devices.length, + eligible: deviceProjection.projected.length, + invalid: deviceProjection.invalid, + }, + sites: { + received: sites.length, + projected: siteProjection.projected.length, + invalid: siteProjection.invalid, + error: safeMetadataError(metadataErrors.sites), + }, + groups: { + received: groups.length, + projected: groupProjection.projected.length, + invalid: groupProjection.invalid, + error: safeMetadataError(metadataErrors.groups), + }, + }, + }; + if (Buffer.byteLength(JSON.stringify(payload)) > MAX_PROJECTED_PAYLOAD_BYTES) { + throw projectionError('PROJECTED_PAYLOAD_TOO_LARGE', 'Projected Alta hierarchy exceeded the IPC size limit'); + } + return payload; +} + +module.exports = { + MAX_PROJECTED_PAYLOAD_BYTES, + projectDevice, + projectDeviceHierarchy, + projectGroup, + projectSite, +}; diff --git a/src/electron-runtime.js b/src/electron-runtime.js index 77efd1e..9c0324a 100644 --- a/src/electron-runtime.js +++ b/src/electron-runtime.js @@ -10,14 +10,18 @@ const { readJsonBody, } = require('./bridge-auth'); const { RELEASES_PAGE_URL } = require('./update-policy'); +const { projectDeviceHierarchy, projectGroup, projectSite } = require('./device-projection'); const { validateDeviceId, validateUsername } = require('./proxy-launch'); +const DEFAULT_DISCOVERY_TIMEOUT_MS = 60_000; + const PAIRING_ENVELOPE_FILENAME = 'bridge-pairing.json'; function safeErrorMessage(error, fallback) { const allowed = new Set([ 'NO_ALTA_SESSION', 'INVALID_ALTA_ARGUMENTS', 'ALTA_TIMEOUT', 'ALTA_HTTP_ERROR', 'INVALID_ALTA_RESPONSE', 'INVALID_ALTA_RESPONSE_DATA', 'ALTA_RESPONSE_TOO_LARGE', + 'ALTA_RESPONSE_TOO_MANY_OBJECTS', 'PROJECTED_PAYLOAD_TOO_LARGE', 'UNSAFE_ALTA_REDIRECT', 'TOO_MANY_ALTA_REDIRECTS', 'HELPER_NOT_FOUND', 'UNSUPPORTED_PLATFORM', 'INVALID_DEVICE_ID', 'INVALID_USERNAME', 'SPAWN_FAILED', ]); @@ -196,13 +200,19 @@ class AppRuntime { checkForUpdate, currentVersion, openExternal, + discoveryTimeoutMs = DEFAULT_DISCOVERY_TIMEOUT_MS, } = {}) { + if (!Number.isInteger(discoveryTimeoutMs) || discoveryTimeoutMs < 1 || + discoveryTimeoutMs > DEFAULT_DISCOVERY_TIMEOUT_MS) { + throw new RangeError('AppRuntime discovery timeout must be between 1 and 60000ms'); + } this.sessionStore = sessionStore; this.altaClient = altaClient; this.proxyManager = proxyManager; this.checkForUpdatePolicy = checkForUpdate; this.currentVersion = currentVersion; this.openExternal = openExternal; + this.discoveryTimeoutMs = discoveryTimeoutMs; this.allowedDeviceIds = new Set(); this.proxyByDevice = new Map(); } @@ -223,15 +233,17 @@ class AppRuntime { return tracked.filter((proxy) => this.proxyByDevice.get(proxy.deviceId) === proxy.processId); } + _setAllowedDevices(devices) { + this.allowedDeviceIds = new Set(devices.map((device) => device.id)); + } + async getDevices() { try { - const devices = await this.altaClient.getDevices(); - this.allowedDeviceIds = new Set(); - for (const device of devices) { - const candidate = device && (device.guid || device.id); - try { this.allowedDeviceIds.add(validateDeviceId(candidate)); } catch {} - } - return { success: true, devices }; + const hierarchy = projectDeviceHierarchy({ + devices: await this.altaClient.getDevices(), sites: [], groups: [], + }); + this._setAllowedDevices(hierarchy.devices); + return { success: true, devices: hierarchy.devices }; } catch (error) { return { success: false, devices: [], message: safeErrorMessage(error, 'Failed to get devices') }; } @@ -239,12 +251,66 @@ class AppRuntime { async getDeviceSites() { try { - return { success: true, sites: await this.altaClient.getDeviceSites() }; + const sites = (await this.altaClient.getDeviceSites()).map(projectSite).filter(Boolean); + return { success: true, sites }; } catch (error) { return { success: false, sites: [], message: safeErrorMessage(error, 'Failed to get device sites') }; } } + async getDeviceGroups() { + try { + const groups = (await this.altaClient.getDeviceGroups()).map(projectGroup).filter(Boolean); + return { success: true, groups }; + } catch (error) { + return { success: false, groups: [], message: safeErrorMessage(error, 'Failed to get device groups') }; + } + } + + async getDeviceHierarchy() { + let timer; + try { + const discovery = Promise.allSettled([ + this.altaClient.getDevices(), + this.altaClient.getDeviceSites(), + this.altaClient.getDeviceGroups(), + ]); + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => { + const error = new Error('Alta device discovery timed out'); + error.code = 'DISCOVERY_TIMEOUT'; + reject(error); + }, this.discoveryTimeoutMs); + }); + const [deviceResult, siteResult, groupResult] = await Promise.race([discovery, deadline]); + if (deviceResult.status === 'rejected') throw deviceResult.reason; + + const metadataErrors = {}; + if (siteResult.status === 'rejected') { + metadataErrors.sites = 'Device sites unavailable'; + } + if (groupResult.status === 'rejected') { + metadataErrors.groups = 'Device groups unavailable'; + } + const hierarchy = projectDeviceHierarchy({ + devices: deviceResult.value, + sites: siteResult.status === 'fulfilled' ? siteResult.value : [], + groups: groupResult.status === 'fulfilled' ? groupResult.value : [], + metadataErrors, + }); + this._setAllowedDevices(hierarchy.devices); + return { success: true, hierarchy }; + } catch (error) { + this.allowedDeviceIds.clear(); + const message = error && error.code === 'DISCOVERY_TIMEOUT' + ? 'Alta device discovery timed out' + : safeErrorMessage(error, 'Failed to discover Alta devices'); + return { success: false, hierarchy: { devices: [], sites: [], groups: [] }, message }; + } finally { + clearTimeout(timer); + } + } + async getAuthInfo() { try { return { success: true, authInfo: await this.altaClient.getAuthInfo() }; diff --git a/test/alta-client.test.js b/test/alta-client.test.js index b33c295..e276600 100644 --- a/test/alta-client.test.js +++ b/test/alta-client.test.js @@ -3,7 +3,12 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const { SessionStore } = require('../src/session-store'); -const { AltaClient } = require('../src/alta-client'); +const { + AltaClient, + DEVICE_MAX_RESPONSE_BYTES, + HIERARCHY_MAX_RESPONSE_BYTES, + MAX_ARRAY_OBJECTS, +} = require('../src/alta-client'); const ORIGIN = 'https://tenant.avasecurity.com'; const SENTINEL = 'HERMES_SENTINEL_SECRET'; @@ -24,17 +29,20 @@ test('uses only stored authority, fixed endpoint paths and hardened transport op calls.push(options); if (options.url.endsWith('/devices')) return response([]); if (options.url.endsWith('/deviceSites')) return response([{ id: 'site-1' }]); + if (options.url.endsWith('/deviceGroups')) return response([{ id: 'group-1' }]); return response({ user: 'engineer' }); }; const client = new AltaClient({ sessionStore: readyStore(), transport }); assert.deepEqual(await client.getDevices(), []); assert.deepEqual(await client.getDeviceSites(), [{ id: 'site-1' }]); + assert.deepEqual(await client.getDeviceGroups(), [{ id: 'group-1' }]); assert.deepEqual(await client.getAuthInfo(), { user: 'engineer' }); assert.deepEqual(calls.map((call) => call.url), [ `${ORIGIN}/api/v1/devices`, `${ORIGIN}/api/v1/deviceSites`, + `${ORIGIN}/api/v1/deviceGroups`, `${ORIGIN}/api/v1/auth`, ]); for (const call of calls) { @@ -42,11 +50,56 @@ test('uses only stored authority, fixed endpoint paths and hardened transport op assert.equal(call.proxy, false); assert.equal(call.maxRedirects, 0); assert.match(call.headers.Cookie, /^va=HERMES_SENTINEL_SECRET$/); - assert.ok(call.timeout > 0 && call.timeout <= 10_000); + assert.ok(call.timeout > 0 && call.timeout <= 30_000); assert.ok(call.maxResponseBytes > 0); } }); +test('uses endpoint-specific 32 MiB/4 MiB limits and a 30 second request deadline', async () => { + const calls = []; + const client = new AltaClient({ + sessionStore: readyStore(), + transport: async (options) => { calls.push(options); return response([]); }, + }); + + await client.getDevices(); + await client.getDeviceSites(); + await client.getDeviceGroups(); + + assert.deepEqual(calls.map(({ maxResponseBytes }) => maxResponseBytes), [ + DEVICE_MAX_RESPONSE_BYTES, + HIERARCHY_MAX_RESPONSE_BYTES, + HIERARCHY_MAX_RESPONSE_BYTES, + ]); + assert.ok(calls.every(({ timeout }) => timeout > 0 && timeout <= 30_000)); +}); + +test('accepts device bodies above 2 MiB and rejects bodies above 32 MiB before parsing', async () => { + const acceptedBody = JSON.stringify([{ + guid: '550e8400-e29b-41d4-a716-446655440000', + padding: 'x'.repeat((2 * 1024 * 1024) + 1), + }]); + const accepted = new AltaClient({ + sessionStore: readyStore(), + transport: async () => response(Buffer.from(acceptedBody)), + }); + assert.equal((await accepted.getDevices()).length, 1); + + const rejected = new AltaClient({ + sessionStore: readyStore(), + transport: async () => response(Buffer.alloc(DEVICE_MAX_RESPONSE_BYTES + 1, 0x20)), + }); + await assert.rejects(rejected.getDevices(), { code: 'ALTA_RESPONSE_TOO_LARGE' }); +}); + +test('rejects plain array responses containing more than 10,000 objects', async () => { + const client = new AltaClient({ + sessionStore: readyStore(), + transport: async () => response(Array.from({ length: MAX_ARRAY_OBJECTS + 1 }, () => ({}))), + }); + await assert.rejects(client.getDevices(), { code: 'ALTA_RESPONSE_TOO_MANY_OBJECTS' }); +}); + test('rejects renderer-supplied URL/cookie parameters before transport', async () => { let calls = 0; const client = new AltaClient({ diff --git a/test/device-projection.test.js b/test/device-projection.test.js new file mode 100644 index 0000000..78722a9 --- /dev/null +++ b/test/device-projection.test.js @@ -0,0 +1,83 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + MAX_PROJECTED_PAYLOAD_BYTES, + projectDeviceHierarchy, +} = require('../src/device-projection'); + +function uuid(index) { + return `00000000-0000-4000-8000-${index.toString(16).padStart(12, '0')}`; +} + +function rawDevice(index, overrides = {}) { + return { + guid: uuid(index), + name: `Camera ${index}`, + type: 'camera', + model: 'Synthetic', + address: `10.0.${Math.floor(index / 255)}.${index % 255}`, + server_group_id: 'site-1', + device_group_id: 'group-1', + capabilities: { localStorage: index % 2 === 0, secretCapability: 'drop-me' }, + live: { display_status: 'online', private: 'drop-me' }, + cookie: 'must-not-cross-ipc', + ...overrides, + }; +} + +test('projects only allowlisted fields and honestly diagnoses ineligible devices', () => { + const payload = projectDeviceHierarchy({ + devices: [rawDevice(1), rawDevice(2, { guid: 'not-a-uuid' })], + sites: [{ id: 'site-1', name: 'HQ', pending_deletion_start: null, secret: 'drop-me' }], + groups: [{ id: 'group-1', name: 'Lobby', parent_id: null, pending_deletion_start: null, secret: 'drop-me' }], + }); + + assert.deepEqual(payload.devices, [{ + id: uuid(1), name: 'Camera 1', type: 'camera', model: 'Synthetic', address: '10.0.0.1', + siteId: 'site-1', deviceGroupId: 'group-1', displayStatus: 'online', localStorage: false, + }]); + assert.deepEqual(payload.sites, [{ id: 'site-1', name: 'HQ', pendingDeletionStart: null }]); + assert.deepEqual(payload.groups, [{ id: 'group-1', name: 'Lobby', parentId: null, pendingDeletionStart: null }]); + assert.deepEqual(payload.diagnostics.devices, { received: 2, eligible: 1, invalid: 1 }); + assert.doesNotMatch(JSON.stringify(payload), /drop-me|cookie|secretCapability|private/); +}); + +test('projects synthetic 1,500 and 10,000 camera deployments below the 8 MiB IPC cap', () => { + for (const count of [1_500, 10_000]) { + const payload = projectDeviceHierarchy({ + devices: Array.from({ length: count }, (_, index) => rawDevice(index)), + sites: [], + groups: [], + }); + assert.equal(payload.devices.length, count); + assert.equal(payload.diagnostics.devices.received, count); + assert.ok(Buffer.byteLength(JSON.stringify(payload)) <= MAX_PROJECTED_PAYLOAD_BYTES); + } +}); + +test('malformed hierarchy metadata does not hide valid cameras', () => { + const payload = projectDeviceHierarchy({ + devices: [rawDevice(7)], + sites: [null, { id: {}, name: 'bad' }], + groups: ['bad'], + metadataErrors: { sites: 'Unavailable', groups: 'Unavailable' }, + }); + assert.equal(payload.devices.length, 1); + assert.deepEqual(payload.sites, []); + assert.deepEqual(payload.groups, []); + assert.equal(payload.diagnostics.sites.invalid, 2); + assert.equal(payload.diagnostics.groups.invalid, 1); + assert.equal(payload.diagnostics.sites.error, 'Unavailable'); +}); + +test('rejects a projected hierarchy payload above 8 MiB', () => { + const devices = Array.from({ length: 10_000 }, (_, index) => rawDevice(index, { + name: `Camera ${index} ${'x'.repeat(900)}`, + })); + assert.throws( + () => projectDeviceHierarchy({ devices, sites: [], groups: [] }), + { code: 'PROJECTED_PAYLOAD_TOO_LARGE' }, + ); +}); diff --git a/test/runtime-contract.test.js b/test/runtime-contract.test.js index 596befc..f2f3036 100644 --- a/test/runtime-contract.test.js +++ b/test/runtime-contract.test.js @@ -78,7 +78,20 @@ test('runtime keeps Alta credentials in main-owned modules and exposes only non- openExternal: async () => {}, }); - assert.deepEqual(await runtime.getDevices(), { success: true, devices: [{ guid: '550e8400-e29b-41d4-a716-446655440000' }] }); + assert.deepEqual(await runtime.getDevices(), { + success: true, + devices: [{ + id: '550e8400-e29b-41d4-a716-446655440000', + name: null, + type: null, + model: null, + address: null, + siteId: null, + deviceGroupId: null, + displayStatus: null, + localStorage: null, + }], + }); const launched = await runtime.launchProxy( '550e8400-e29b-41d4-a716-446655440000', 'proxy.operator@example.com' @@ -102,6 +115,57 @@ test('runtime keeps Alta credentials in main-owned modules and exposes only non- assert.equal((await runtime.stopProxy(999)).success, false); }); +test('runtime discovers and projects hierarchy while metadata failures preserve cameras', async () => { + const deviceId = '550e8400-e29b-41d4-a716-446655440000'; + const runtime = new AppRuntime({ + sessionStore: createSessionStore(), + altaClient: { + getDevices: async () => [{ + guid: deviceId, + name: 'Camera', + server_group_id: 'site-1', + secret: 'drop-me', + }], + getDeviceSites: async () => { throw Object.assign(new Error('tenant secret'), { code: 'ALTA_HTTP_ERROR' }); }, + getDeviceGroups: async () => [{ id: 'group-1', name: 'Lobby', parent_id: null }], + }, + proxyManager: { listTrackedProxies: () => [] }, + }); + + const result = await runtime.getDeviceHierarchy(); + assert.equal(result.success, true); + assert.deepEqual(result.hierarchy.devices.map(({ id }) => id), [deviceId]); + assert.deepEqual(result.hierarchy.sites, []); + assert.deepEqual(result.hierarchy.groups, [{ + id: 'group-1', name: 'Lobby', parentId: null, pendingDeletionStart: null, + }]); + assert.equal(result.hierarchy.diagnostics.sites.error, 'Device sites unavailable'); + assert.doesNotMatch(JSON.stringify(result), /drop-me|tenant secret/); +}); + +test('runtime enforces a bounded whole-discovery deadline', async () => { + const runtime = new AppRuntime({ + sessionStore: createSessionStore(), + altaClient: { + getDevices: async () => new Promise(() => {}), + getDeviceSites: async () => [], + getDeviceGroups: async () => [], + }, + proxyManager: { listTrackedProxies: () => [] }, + discoveryTimeoutMs: 25, + }); + const result = await runtime.getDeviceHierarchy(); + assert.deepEqual(result, { + success: false, + hierarchy: { devices: [], sites: [], groups: [] }, + message: 'Alta device discovery timed out', + }); +}); + +test('runtime rejects discovery deadlines above 60 seconds', () => { + assert.throws(() => new AppRuntime({ discoveryTimeoutMs: 60_001 }), /discovery timeout/i); +}); + test('runtime rejects invalid usernames before calling the proxy manager', async () => { const sessionStore = createSessionStore(); sessionStore.establish('https://customer.avasecurity.com', 'top-secret-cookie'); @@ -364,7 +428,8 @@ test('preload and renderer expose only narrow, credential-free contracts', () => const renderer = read('renderer.js'); const html = read('index.html'); const expectedMethods = [ - 'getDevices', 'getDeviceSites', 'getAuthInfo', 'launchProxy', 'stopProxy', 'disconnect', + 'getDevices', 'getDeviceSites', 'getDeviceGroups', 'getDeviceHierarchy', 'getAuthInfo', + 'launchProxy', 'stopProxy', 'disconnect', 'getConnectionState', 'checkForUpdates', 'openFixedReleasesPage', 'rotatePairing', 'revokePairing', 'getPairingStatus', 'onConnectionStateChanged', ];