fix: contain malformed metadata and stale discovery
This commit is contained in:
+28
-5
@@ -1,6 +1,9 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
(function exposeDeviceTree(globalScope) {
|
(function exposeDeviceTree(globalScope) {
|
||||||
|
const { toWellFormedString } = typeof module !== 'undefined' && module.exports
|
||||||
|
? require('./well-formed-string')
|
||||||
|
: globalScope.AptWellFormedString;
|
||||||
const SPECIAL = Object.freeze({
|
const SPECIAL = Object.freeze({
|
||||||
ORPHAN_SITE: '__orphaned__',
|
ORPHAN_SITE: '__orphaned__',
|
||||||
UNGROUPED: '__ungrouped__',
|
UNGROUPED: '__ungrouped__',
|
||||||
@@ -9,7 +12,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function text(value) {
|
function text(value) {
|
||||||
return value === null || value === undefined ? '' : String(value).trim();
|
return value === null || value === undefined ? '' : toWellFormedString(value).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalized(value) {
|
function normalized(value) {
|
||||||
@@ -17,7 +20,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function keyPart(value) {
|
function keyPart(value) {
|
||||||
const encoded = encodeURIComponent(String(value));
|
const encoded = encodeURIComponent(toWellFormedString(value));
|
||||||
return `${encoded.length}:${encoded}`;
|
return `${encoded.length}:${encoded}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +112,11 @@
|
|||||||
const site = makeSite(identity.uniqueId, text(safe.name) || identity.canonicalId, ordinal, {
|
const site = makeSite(identity.uniqueId, text(safe.name) || identity.canonicalId, ordinal, {
|
||||||
canonicalId: identity.canonicalId,
|
canonicalId: identity.canonicalId,
|
||||||
pendingDeletion: safe.pendingDeletion === true || Boolean(text(safe.pendingDeletionStart)),
|
pendingDeletion: safe.pendingDeletion === true || Boolean(text(safe.pendingDeletionStart)),
|
||||||
source: safe,
|
source: {
|
||||||
|
id: identity.canonicalId,
|
||||||
|
name: text(safe.name),
|
||||||
|
pendingDeletionStart: text(safe.pendingDeletionStart) || null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
sites.push(site);
|
sites.push(site);
|
||||||
registerRow(site);
|
registerRow(site);
|
||||||
@@ -144,7 +151,12 @@
|
|||||||
canonicalId: identity.canonicalId,
|
canonicalId: identity.canonicalId,
|
||||||
rawParentId: parentId,
|
rawParentId: parentId,
|
||||||
pendingDeletion: safe.pendingDeletion === true || Boolean(text(safe.pendingDeletionStart)),
|
pendingDeletion: safe.pendingDeletion === true || Boolean(text(safe.pendingDeletionStart)),
|
||||||
source: safe,
|
source: {
|
||||||
|
id: identity.canonicalId,
|
||||||
|
name: text(safe.name),
|
||||||
|
parentId,
|
||||||
|
pendingDeletionStart: text(safe.pendingDeletionStart) || null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
site.groups.push(group);
|
site.groups.push(group);
|
||||||
registerRow(group);
|
registerRow(group);
|
||||||
@@ -206,10 +218,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const name = text(safe.name) || identity.canonicalId;
|
const name = text(safe.name) || identity.canonicalId;
|
||||||
|
const source = {
|
||||||
|
id: identity.canonicalId,
|
||||||
|
name,
|
||||||
|
type: text(safe.type) || null,
|
||||||
|
model: text(safe.model) || null,
|
||||||
|
address: text(safe.address) || null,
|
||||||
|
siteId: directSiteId || null,
|
||||||
|
deviceGroupId: requestedGroupId || null,
|
||||||
|
displayStatus: text(safe.displayStatus) || null,
|
||||||
|
localStorage: typeof safe.localStorage === 'boolean' ? safe.localStorage : null,
|
||||||
|
};
|
||||||
const camera = {
|
const camera = {
|
||||||
kind: 'camera', id: identity.uniqueId, canonicalId: identity.canonicalId,
|
kind: 'camera', id: identity.uniqueId, canonicalId: identity.canonicalId,
|
||||||
key: cameraKey, name, normalizedName: normalized(name), ordinal,
|
key: cameraKey, name, normalizedName: normalized(name), ordinal,
|
||||||
parentKey: group.key, site, group, hierarchyStatus, source: safe,
|
parentKey: group.key, site, group, hierarchyStatus, source,
|
||||||
};
|
};
|
||||||
camera.searchCorpus = normalized([
|
camera.searchCorpus = normalized([
|
||||||
name, identity.canonicalId, safe.model, safe.type, safe.address, site.name, group.name,
|
name, identity.canonicalId, safe.model, safe.type, safe.address, site.name, group.name,
|
||||||
|
|||||||
@@ -108,6 +108,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="well-formed-string.js"></script>
|
||||||
<script src="renderer-controller.js"></script>
|
<script src="renderer-controller.js"></script>
|
||||||
<script src="device-tree.js"></script>
|
<script src="device-tree.js"></script>
|
||||||
<script src="sidebar-controller.js"></script>
|
<script src="sidebar-controller.js"></script>
|
||||||
|
|||||||
@@ -118,7 +118,10 @@ function startBridgeServer() {
|
|||||||
const handler = createBridgeHandler({
|
const handler = createBridgeHandler({
|
||||||
bridgeAuth: pairingController.bridgeAuth,
|
bridgeAuth: pairingController.bridgeAuth,
|
||||||
sessionStore: runtime.sessionStore,
|
sessionStore: runtime.sessionStore,
|
||||||
onConnectionStateChanged: sendConnectionState,
|
onConnectionStateChanged: () => {
|
||||||
|
runtime.onSessionChanged();
|
||||||
|
sendConnectionState();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
bridgeServer = http.createServer((request, response) => {
|
bridgeServer = http.createServer((request, response) => {
|
||||||
handler(request, response).catch(() => {
|
handler(request, response).catch(() => {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"preload.js",
|
"preload.js",
|
||||||
"renderer.js",
|
"renderer.js",
|
||||||
"renderer-controller.js",
|
"renderer-controller.js",
|
||||||
|
"well-formed-string.js",
|
||||||
"device-tree.js",
|
"device-tree.js",
|
||||||
"sidebar-controller.js",
|
"sidebar-controller.js",
|
||||||
"sidebar-view.js",
|
"sidebar-view.js",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const { validateDeviceId } = require('./proxy-launch');
|
const { validateDeviceId } = require('./proxy-launch');
|
||||||
|
const { toWellFormedString } = require('../well-formed-string');
|
||||||
|
|
||||||
const MAX_PROJECTED_PAYLOAD_BYTES = 8 * 1024 * 1024;
|
const MAX_PROJECTED_PAYLOAD_BYTES = 8 * 1024 * 1024;
|
||||||
|
|
||||||
@@ -11,11 +12,11 @@ function projectionError(code, message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function nullableString(value) {
|
function nullableString(value) {
|
||||||
return typeof value === 'string' ? value : null;
|
return typeof value === 'string' ? toWellFormedString(value) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function nullableId(value) {
|
function nullableId(value) {
|
||||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
return typeof value === 'string' && value.length > 0 ? toWellFormedString(value) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function nullableBoolean(value) {
|
function nullableBoolean(value) {
|
||||||
@@ -78,7 +79,9 @@ function projectArray(values, projector) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function safeMetadataError(value) {
|
function safeMetadataError(value) {
|
||||||
return typeof value === 'string' && value.length > 0 ? value.slice(0, 512) : null;
|
return typeof value === 'string' && value.length > 0
|
||||||
|
? Array.from(toWellFormedString(value)).slice(0, 512).join('')
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectDeviceHierarchy({ devices = [], sites = [], groups = [], metadataErrors = {} } = {}) {
|
function projectDeviceHierarchy({ devices = [], sites = [], groups = [], metadataErrors = {} } = {}) {
|
||||||
|
|||||||
+50
-4
@@ -214,6 +214,8 @@ class AppRuntime {
|
|||||||
this.openExternal = openExternal;
|
this.openExternal = openExternal;
|
||||||
this.discoveryTimeoutMs = discoveryTimeoutMs;
|
this.discoveryTimeoutMs = discoveryTimeoutMs;
|
||||||
this.allowedDeviceIds = new Set();
|
this.allowedDeviceIds = new Set();
|
||||||
|
this.allowedDeviceDiscovery = null;
|
||||||
|
this.discoveryGeneration = 0;
|
||||||
this.proxyByDevice = new Map();
|
this.proxyByDevice = new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,16 +235,55 @@ class AppRuntime {
|
|||||||
return tracked.filter((proxy) => this.proxyByDevice.get(proxy.deviceId) === proxy.processId);
|
return tracked.filter((proxy) => this.proxyByDevice.get(proxy.deviceId) === proxy.processId);
|
||||||
}
|
}
|
||||||
|
|
||||||
_setAllowedDevices(devices) {
|
_setAllowedDevices(devices, discovery) {
|
||||||
this.allowedDeviceIds = new Set(devices.map((device) => device.id));
|
this.allowedDeviceIds = new Set(devices.map((device) => device.id));
|
||||||
|
this.allowedDeviceDiscovery = discovery;
|
||||||
|
}
|
||||||
|
|
||||||
|
_beginDiscovery() {
|
||||||
|
const state = this.sessionStore.describe();
|
||||||
|
this.allowedDeviceIds.clear();
|
||||||
|
this.allowedDeviceDiscovery = null;
|
||||||
|
return Object.freeze({
|
||||||
|
generation: ++this.discoveryGeneration,
|
||||||
|
connected: state.connected,
|
||||||
|
origin: state.origin,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_isCurrentDiscovery(discovery) {
|
||||||
|
const state = this.sessionStore.describe();
|
||||||
|
return discovery.generation === this.discoveryGeneration &&
|
||||||
|
discovery.connected === state.connected && discovery.origin === state.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
_staleDiscoveryResult() {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
stale: true,
|
||||||
|
hierarchy: { devices: [], sites: [], groups: [] },
|
||||||
|
message: 'Alta device discovery result is stale',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidateDiscovery() {
|
||||||
|
this.discoveryGeneration += 1;
|
||||||
|
this.allowedDeviceIds.clear();
|
||||||
|
this.allowedDeviceDiscovery = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSessionChanged() {
|
||||||
|
this.invalidateDiscovery();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDevices() {
|
async getDevices() {
|
||||||
|
const discovery = this._beginDiscovery();
|
||||||
try {
|
try {
|
||||||
const hierarchy = projectDeviceHierarchy({
|
const hierarchy = projectDeviceHierarchy({
|
||||||
devices: await this.altaClient.getDevices(), sites: [], groups: [],
|
devices: await this.altaClient.getDevices(), sites: [], groups: [],
|
||||||
});
|
});
|
||||||
this._setAllowedDevices(hierarchy.devices);
|
if (!this._isCurrentDiscovery(discovery)) return this._staleDiscoveryResult();
|
||||||
|
this._setAllowedDevices(hierarchy.devices, discovery);
|
||||||
return { success: true, devices: hierarchy.devices };
|
return { success: true, devices: hierarchy.devices };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, devices: [], message: safeErrorMessage(error, 'Failed to get devices') };
|
return { success: false, devices: [], message: safeErrorMessage(error, 'Failed to get devices') };
|
||||||
@@ -268,6 +309,7 @@ class AppRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getDeviceHierarchy() {
|
async getDeviceHierarchy() {
|
||||||
|
const discoveryState = this._beginDiscovery();
|
||||||
let timer;
|
let timer;
|
||||||
try {
|
try {
|
||||||
const discovery = Promise.allSettled([
|
const discovery = Promise.allSettled([
|
||||||
@@ -298,9 +340,11 @@ class AppRuntime {
|
|||||||
groups: groupResult.status === 'fulfilled' ? groupResult.value : [],
|
groups: groupResult.status === 'fulfilled' ? groupResult.value : [],
|
||||||
metadataErrors,
|
metadataErrors,
|
||||||
});
|
});
|
||||||
this._setAllowedDevices(hierarchy.devices);
|
if (!this._isCurrentDiscovery(discoveryState)) return this._staleDiscoveryResult();
|
||||||
|
this._setAllowedDevices(hierarchy.devices, discoveryState);
|
||||||
return { success: true, hierarchy };
|
return { success: true, hierarchy };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!this._isCurrentDiscovery(discoveryState)) return this._staleDiscoveryResult();
|
||||||
this.allowedDeviceIds.clear();
|
this.allowedDeviceIds.clear();
|
||||||
const message = error && error.code === 'DISCOVERY_TIMEOUT'
|
const message = error && error.code === 'DISCOVERY_TIMEOUT'
|
||||||
? 'Alta device discovery timed out'
|
? 'Alta device discovery timed out'
|
||||||
@@ -324,7 +368,8 @@ class AppRuntime {
|
|||||||
const validatedId = validateDeviceId(deviceId);
|
const validatedId = validateDeviceId(deviceId);
|
||||||
const validatedUsername = validateUsername(username);
|
const validatedUsername = validateUsername(username);
|
||||||
this._reconcileProxies();
|
this._reconcileProxies();
|
||||||
if (!this.allowedDeviceIds.has(validatedId)) {
|
if (!this.allowedDeviceDiscovery || !this._isCurrentDiscovery(this.allowedDeviceDiscovery) ||
|
||||||
|
!this.allowedDeviceIds.has(validatedId)) {
|
||||||
return { success: false, message: 'Select a device from the current Alta device list.' };
|
return { success: false, message: 'Select a device from the current Alta device list.' };
|
||||||
}
|
}
|
||||||
if (this.proxyByDevice.has(validatedId)) {
|
if (this.proxyByDevice.has(validatedId)) {
|
||||||
@@ -364,6 +409,7 @@ class AppRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async disconnect() {
|
async disconnect() {
|
||||||
|
this.invalidateDiscovery();
|
||||||
const tracked = this._reconcileProxies();
|
const tracked = this._reconcileProxies();
|
||||||
await Promise.all(tracked.map(async (proxy) => {
|
await Promise.all(tracked.map(async (proxy) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -72,6 +72,32 @@ test('malformed hierarchy metadata does not hide valid cameras', () => {
|
|||||||
assert.equal(payload.diagnostics.sites.error, 'Unavailable');
|
assert.equal(payload.diagnostics.sites.error, 'Unavailable');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('repairs lone UTF-16 surrogates without changing valid Unicode or hiding cameras', () => {
|
||||||
|
const payload = projectDeviceHierarchy({
|
||||||
|
devices: [rawDevice(8, {
|
||||||
|
name: 'Front \ud800 camera',
|
||||||
|
model: 'Model \udc00',
|
||||||
|
address: 'Hall \ud800\udc00 / \udc00',
|
||||||
|
server_group_id: 'site-\ud800',
|
||||||
|
device_group_id: 'group-\udc00',
|
||||||
|
})],
|
||||||
|
sites: [{ id: 'site-\ud800', name: 'Valid \ud83d\udcf7 \udc00', pending_deletion_start: '\ud800' }],
|
||||||
|
groups: [{ id: 'group-\udc00', name: 'Group \ud83d\udcf7 \ud800', parent_id: 'site-\ud800' }],
|
||||||
|
metadataErrors: { sites: 'warning \ud800', groups: 'warning \udc00' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(payload.devices.length, 1);
|
||||||
|
assert.equal(payload.devices[0].name, 'Front \ufffd camera');
|
||||||
|
assert.equal(payload.devices[0].model, 'Model \ufffd');
|
||||||
|
assert.equal(payload.devices[0].address, 'Hall \ud800\udc00 / \ufffd');
|
||||||
|
assert.equal(payload.devices[0].siteId, 'site-\ufffd');
|
||||||
|
assert.equal(payload.devices[0].deviceGroupId, 'group-\ufffd');
|
||||||
|
assert.deepEqual(payload.sites, [{ id: 'site-\ufffd', name: 'Valid \ud83d\udcf7 \ufffd', pendingDeletionStart: '\ufffd' }]);
|
||||||
|
assert.deepEqual(payload.groups, [{ id: 'group-\ufffd', name: 'Group \ud83d\udcf7 \ufffd', parentId: 'site-\ufffd', pendingDeletionStart: null }]);
|
||||||
|
assert.equal(payload.diagnostics.sites.error, 'warning \ufffd');
|
||||||
|
assert.doesNotMatch(JSON.stringify(payload), /[\ud800-\udfff]/u);
|
||||||
|
});
|
||||||
|
|
||||||
test('rejects a projected hierarchy payload above 8 MiB', () => {
|
test('rejects a projected hierarchy payload above 8 MiB', () => {
|
||||||
const devices = Array.from({ length: 10_000 }, (_, index) => rawDevice(index, {
|
const devices = Array.from({ length: 10_000 }, (_, index) => rawDevice(index, {
|
||||||
name: `Camera ${index} ${'x'.repeat(900)}`,
|
name: `Camera ${index} ${'x'.repeat(900)}`,
|
||||||
|
|||||||
@@ -194,6 +194,33 @@ test('tenant and synthetic namespaces stay distinct for reserved and ambiguous I
|
|||||||
assert.ok([...model.rowByKey.keys()].every((key) => !/\s/.test(key)));
|
assert.ok([...model.rowByKey.keys()].every((key) => !/\s/.test(key)));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('malformed Unicode metadata builds deterministically and remains searchable and visible', async () => {
|
||||||
|
const fixture = {
|
||||||
|
sites: [{ id: 'site-\ud800', name: 'Site \ud83d\udcf7 \udc00' }],
|
||||||
|
groups: [{ id: 'group-\udc00', name: 'Group \ud83d\udcf7 \ud800', parentId: 'site-\ud800' }],
|
||||||
|
devices: [{
|
||||||
|
id: 'camera-valid', name: 'Lobby \ud800 camera', siteId: 'site-\ud800',
|
||||||
|
deviceGroupId: 'group-\udc00', address: 'Floor \udc00',
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = buildDeviceTree(fixture);
|
||||||
|
const second = buildDeviceTree(fixture);
|
||||||
|
assert.equal(first.cameraCount, 1);
|
||||||
|
assert.deepEqual([...first.rowByKey.keys()], [...second.rowByKey.keys()]);
|
||||||
|
assert.doesNotMatch([...first.rowByKey.keys()].join(''), /[\ud800-\udfff]/u);
|
||||||
|
const camera = [...first.cameraByKey.values()][0];
|
||||||
|
assert.equal(camera.name, 'Lobby \ufffd camera');
|
||||||
|
assert.equal(camera.site.name, 'Site \ud83d\udcf7 \ufffd');
|
||||||
|
assert.equal(camera.group.name, 'Group \ud83d\udcf7 \ufffd');
|
||||||
|
assert.equal(camera.source.address, 'Floor \ufffd');
|
||||||
|
assert.doesNotMatch(JSON.stringify(camera.source), /[\ud800-\udfff]/u);
|
||||||
|
assert.match(camera.searchCorpus, /floor \ufffd/);
|
||||||
|
|
||||||
|
const result = await createSearchRunner().search(first, 'lobby \ufffd');
|
||||||
|
assert.deepEqual([...result.cameraKeys], [camera.key]);
|
||||||
|
});
|
||||||
|
|
||||||
test('virtual window clamps an obsolete scroll offset after rows shrink', () => {
|
test('virtual window clamps an obsolete scroll offset after rows shrink', () => {
|
||||||
const rows = [{ key: 'only-row' }];
|
const rows = [{ key: 'only-row' }];
|
||||||
const window = calculateVirtualWindow(rows, {
|
const window = calculateVirtualWindow(rows, {
|
||||||
|
|||||||
@@ -162,6 +162,96 @@ test('runtime enforces a bounded whole-discovery deadline', async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('newest hierarchy discovery owns the launch allowlist when completions arrive out of order', async () => {
|
||||||
|
const sessionStore = createSessionStore();
|
||||||
|
sessionStore.establish('https://customer.avasecurity.com', 'synthetic-cookie');
|
||||||
|
const deviceA = '550e8400-e29b-41d4-a716-44665544000a';
|
||||||
|
const deviceB = '550e8400-e29b-41d4-a716-44665544000b';
|
||||||
|
const pending = [];
|
||||||
|
const launches = [];
|
||||||
|
const runtime = new AppRuntime({
|
||||||
|
sessionStore,
|
||||||
|
altaClient: {
|
||||||
|
getDevices: () => new Promise((resolve) => pending.push(resolve)),
|
||||||
|
getDeviceSites: async () => [],
|
||||||
|
getDeviceGroups: async () => [],
|
||||||
|
},
|
||||||
|
proxyManager: {
|
||||||
|
launchProxy(request) {
|
||||||
|
launches.push(request.deviceId);
|
||||||
|
return { processId: 5000 + launches.length, deviceId: request.deviceId, status: 'running' };
|
||||||
|
},
|
||||||
|
listTrackedProxies: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const staleA = runtime.getDeviceHierarchy();
|
||||||
|
const freshB = runtime.getDeviceHierarchy();
|
||||||
|
pending[1]([{ guid: deviceB }]);
|
||||||
|
assert.equal((await freshB).success, true);
|
||||||
|
assert.equal((await runtime.launchProxy(deviceB, 'operator@example.com')).success, true);
|
||||||
|
pending[0]([{ guid: deviceA }]);
|
||||||
|
assert.deepEqual(await staleA, {
|
||||||
|
success: false,
|
||||||
|
stale: true,
|
||||||
|
hierarchy: { devices: [], sites: [], groups: [] },
|
||||||
|
message: 'Alta device discovery result is stale',
|
||||||
|
});
|
||||||
|
assert.equal((await runtime.launchProxy(deviceA, 'operator@example.com')).success, false);
|
||||||
|
assert.deepEqual(launches, [deviceB]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disconnect and session changes invalidate pending discovery without repopulating the allowlist', async () => {
|
||||||
|
const deviceId = '550e8400-e29b-41d4-a716-44665544000c';
|
||||||
|
for (const invalidate of ['disconnect', 'session-change']) {
|
||||||
|
const sessionStore = createSessionStore();
|
||||||
|
sessionStore.establish('https://first.avasecurity.com', 'synthetic-cookie');
|
||||||
|
let resolveDevices;
|
||||||
|
const runtime = new AppRuntime({
|
||||||
|
sessionStore,
|
||||||
|
altaClient: {
|
||||||
|
getDevices: () => new Promise((resolve) => { resolveDevices = resolve; }),
|
||||||
|
getDeviceSites: async () => [],
|
||||||
|
getDeviceGroups: async () => [],
|
||||||
|
},
|
||||||
|
proxyManager: { listTrackedProxies: () => [] },
|
||||||
|
});
|
||||||
|
const discovery = runtime.getDeviceHierarchy();
|
||||||
|
if (invalidate === 'disconnect') {
|
||||||
|
assert.equal((await runtime.disconnect()).success, true);
|
||||||
|
} else {
|
||||||
|
sessionStore.establish('https://second.avasecurity.com', 'replacement-cookie');
|
||||||
|
runtime.onSessionChanged();
|
||||||
|
}
|
||||||
|
resolveDevices([{ guid: deviceId }]);
|
||||||
|
assert.equal((await discovery).stale, true, invalidate);
|
||||||
|
assert.equal(runtime.allowedDeviceIds.size, 0, invalidate);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('launch allowlist remains bound to the session origin that produced it', async () => {
|
||||||
|
const deviceId = '550e8400-e29b-41d4-a716-44665544000d';
|
||||||
|
const sessionStore = createSessionStore();
|
||||||
|
sessionStore.establish('https://first.avasecurity.com', 'synthetic-cookie');
|
||||||
|
let launches = 0;
|
||||||
|
const runtime = new AppRuntime({
|
||||||
|
sessionStore,
|
||||||
|
altaClient: {
|
||||||
|
getDevices: async () => [{ guid: deviceId }],
|
||||||
|
getDeviceSites: async () => [],
|
||||||
|
getDeviceGroups: async () => [],
|
||||||
|
},
|
||||||
|
proxyManager: {
|
||||||
|
listTrackedProxies: () => [],
|
||||||
|
launchProxy() { launches += 1; },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal((await runtime.getDeviceHierarchy()).success, true);
|
||||||
|
sessionStore.establish('https://second.avasecurity.com', 'replacement-cookie');
|
||||||
|
assert.equal((await runtime.launchProxy(deviceId, 'operator@example.com')).success, false);
|
||||||
|
assert.equal(launches, 0);
|
||||||
|
});
|
||||||
|
|
||||||
test('runtime rejects discovery deadlines above 60 seconds', () => {
|
test('runtime rejects discovery deadlines above 60 seconds', () => {
|
||||||
assert.throws(() => new AppRuntime({ discoveryTimeoutMs: 60_001 }), /discovery timeout/i);
|
assert.throws(() => new AppRuntime({ discoveryTimeoutMs: 60_001 }), /discovery timeout/i);
|
||||||
});
|
});
|
||||||
@@ -439,6 +529,7 @@ test('preload and renderer expose only narrow, credential-free contracts', () =>
|
|||||||
assert.doesNotMatch(preload, /launchProxy:\s*\([^)]*(?:cookie|origin)/i);
|
assert.doesNotMatch(preload, /launchProxy:\s*\([^)]*(?:cookie|origin)/i);
|
||||||
assert.doesNotMatch(renderer, /cookieValue|sessionData\.cookies|cookies\s*:/);
|
assert.doesNotMatch(renderer, /cookieValue|sessionData\.cookies|cookies\s*:/);
|
||||||
assert.match(renderer, /state\.connected\s*&&\s*\(!wasConnected\s*\|\|\s*state\.origin\s*!==\s*previousOrigin\)/);
|
assert.match(renderer, /state\.connected\s*&&\s*\(!wasConnected\s*\|\|\s*state\.origin\s*!==\s*previousOrigin\)/);
|
||||||
|
assert.match(read('main.js'), /onConnectionStateChanged:\s*\(\)\s*=>\s*\{\s*runtime\.onSessionChanged\(\)/);
|
||||||
assert.doesNotMatch(html, /id="cookieKey"|updateProgress|Install Update/);
|
assert.doesNotMatch(html, /id="cookieKey"|updateProgress|Install Update/);
|
||||||
assert.match(html, /id="altaUsername"/);
|
assert.match(html, /id="altaUsername"/);
|
||||||
assert.match(renderer, /openFixedReleasesPage/);
|
assert.match(renderer, /openFixedReleasesPage/);
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
(function exposeWellFormedString(root, factory) {
|
||||||
|
const api = factory();
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||||
|
if (root) root.AptWellFormedString = api;
|
||||||
|
}(typeof window !== 'undefined' ? window : undefined, function wellFormedStringFactory() {
|
||||||
|
function manualToWellFormed(value) {
|
||||||
|
let result = '';
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
const codeUnit = value.charCodeAt(index);
|
||||||
|
if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
|
||||||
|
const next = value.charCodeAt(index + 1);
|
||||||
|
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||||
|
result += value[index] + value[index + 1];
|
||||||
|
index += 1;
|
||||||
|
} else {
|
||||||
|
result += '\ufffd';
|
||||||
|
}
|
||||||
|
} else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
|
||||||
|
result += '\ufffd';
|
||||||
|
} else {
|
||||||
|
result += value[index];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toWellFormedString(value, fallback = '') {
|
||||||
|
let string;
|
||||||
|
try {
|
||||||
|
string = String(value);
|
||||||
|
} catch {
|
||||||
|
string = String(fallback);
|
||||||
|
}
|
||||||
|
return typeof string.toWellFormed === 'function'
|
||||||
|
? string.toWellFormed()
|
||||||
|
: manualToWellFormed(string);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({ toWellFormedString });
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user