Default new devices to Direct routing
This commit is contained in:
@@ -809,6 +809,25 @@ test('device policy is independent from pinning, persists, and keeps the last ap
|
||||
});
|
||||
const observedAt = '2026-08-07T12:00:00.000Z';
|
||||
const mac = '00:11:22:33:44:55';
|
||||
store.update((state) => ({
|
||||
...state,
|
||||
revision: state.revision + 1,
|
||||
devices: [{
|
||||
id: deviceId(mac),
|
||||
alias: '',
|
||||
pinned: false,
|
||||
deprioritized: false,
|
||||
hostname: null,
|
||||
manufacturer: null,
|
||||
mac,
|
||||
ip: '192.168.50.7',
|
||||
interface: 'eth0',
|
||||
firstSeenAt: observedAt,
|
||||
lastSeenAt: observedAt,
|
||||
source: 'neighbor',
|
||||
confidence: 'high',
|
||||
}],
|
||||
}));
|
||||
let observations = [{
|
||||
ip: '192.168.50.7',
|
||||
mac,
|
||||
@@ -900,14 +919,9 @@ test('device policy is independent from pinning, persists, and keeps the last ap
|
||||
observedAt,
|
||||
active: true,
|
||||
});
|
||||
failApply = true;
|
||||
snapshot = await service.refresh();
|
||||
const secondId = snapshot.devices.find((device) => device.mac === secondMac).id;
|
||||
failApply = true;
|
||||
await assert.rejects(
|
||||
service.setPolicy(secondId, 'direct', snapshot.revision),
|
||||
(error) => error.code === 'DEVICE_POLICY_APPLY_FAILED',
|
||||
);
|
||||
snapshot = service.snapshot();
|
||||
const byId = new Map(snapshot.devices.map((device) => [device.id, device]));
|
||||
assert.equal(byId.get(id).desiredPolicy, 'direct');
|
||||
assert.equal(byId.get(id).appliedPolicy, 'direct');
|
||||
@@ -931,6 +945,167 @@ test('device policy is independent from pinning, persists, and keeps the last ap
|
||||
);
|
||||
});
|
||||
|
||||
test('new devices default to Direct while known and ambiguous devices keep truthful applied routes', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-new-device-policy-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const observedAt = '2026-08-31T12:00:00.000Z';
|
||||
const knownMac = '00:11:22:33:44:10';
|
||||
const newMac = '00:11:22:33:44:20';
|
||||
const ambiguousMac = '00:11:22:33:44:30';
|
||||
const retainedMac = '00:11:22:33:44:40';
|
||||
const store = createJsonStore({
|
||||
filePath: path.join(directory, 'devices.json'),
|
||||
defaultValue: {},
|
||||
migrate: migrateDeviceInventoryState,
|
||||
initializeMissing: true,
|
||||
});
|
||||
store.update((state) => ({
|
||||
...state,
|
||||
revision: state.revision + 1,
|
||||
devices: [{
|
||||
id: deviceId(knownMac),
|
||||
alias: '',
|
||||
pinned: false,
|
||||
deprioritized: false,
|
||||
hostname: null,
|
||||
manufacturer: null,
|
||||
mac: knownMac,
|
||||
ip: '192.168.50.10',
|
||||
interface: 'eth0',
|
||||
firstSeenAt: '2026-08-30T12:00:00.000Z',
|
||||
lastSeenAt: '2026-08-30T12:00:00.000Z',
|
||||
source: 'neighbor',
|
||||
confidence: 'high',
|
||||
}],
|
||||
policy: {
|
||||
...state.policy,
|
||||
byMac: {
|
||||
[retainedMac]: {
|
||||
desired: 'direct',
|
||||
applied: 'direct',
|
||||
status: 'failed',
|
||||
appliedAt: '2026-08-29T12:00:00.000Z',
|
||||
error: 'cleanup failed',
|
||||
operationId: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
let observations = [
|
||||
{ ip: '192.168.50.10', mac: knownMac, interface: 'eth0', observedAt, active: true },
|
||||
{ ip: '192.168.50.20', mac: newMac, interface: 'eth0', observedAt, active: true },
|
||||
{ ip: '192.168.50.30', mac: ambiguousMac, interface: 'eth0', observedAt, active: true },
|
||||
{ ip: '192.168.50.31', mac: ambiguousMac, interface: 'eth1', observedAt, active: true },
|
||||
{ ip: '192.168.50.40', mac: retainedMac, interface: 'eth0', observedAt, active: true },
|
||||
];
|
||||
let activeDevices = [];
|
||||
let generation = 0;
|
||||
let failApply = false;
|
||||
const appliedSets = [];
|
||||
const policySnapshot = () => ({
|
||||
epoch: 'policy-epoch',
|
||||
generation: `policy-rules-${generation}`,
|
||||
fingerprint: fingerprintDirectDevices(activeDevices),
|
||||
observedAt,
|
||||
appliedIds: activeDevices.map(({ id }) => id),
|
||||
});
|
||||
const createService = () => createDeviceInventoryService({
|
||||
store,
|
||||
observe: () => ({ observedAt, observations, error: null }),
|
||||
observePolicy: policySnapshot,
|
||||
applyPolicies: async (devices) => {
|
||||
appliedSets.push(structuredClone(devices));
|
||||
if (failApply) throw new Error('iptables unavailable');
|
||||
activeDevices = structuredClone(devices);
|
||||
generation += 1;
|
||||
return policySnapshot();
|
||||
},
|
||||
});
|
||||
|
||||
let service = createService();
|
||||
let snapshot = await service.refresh();
|
||||
const byMac = new Map(snapshot.devices.map((device) => [device.mac, device]));
|
||||
assert.equal(byMac.get(knownMac).desiredPolicy, 'vpn');
|
||||
assert.equal(byMac.get(knownMac).appliedPolicy, 'vpn');
|
||||
assert.equal(byMac.get(newMac).desiredPolicy, 'direct');
|
||||
assert.equal(byMac.get(newMac).appliedPolicy, 'direct');
|
||||
assert.equal(byMac.get(ambiguousMac).desiredPolicy, 'direct');
|
||||
assert.equal(byMac.get(ambiguousMac).appliedPolicy, 'vpn');
|
||||
assert.equal(byMac.get(ambiguousMac).policyStatus, 'pending');
|
||||
assert.equal(byMac.get(retainedMac).appliedPolicy, 'direct');
|
||||
assert.deepEqual(appliedSets.at(-1).map(({ mac }) => mac).sort(), [newMac, retainedMac].sort());
|
||||
|
||||
observations = observations.filter(({ mac, interface: deviceInterface }) => (
|
||||
mac !== ambiguousMac || deviceInterface === 'eth0'
|
||||
));
|
||||
snapshot = await service.refresh();
|
||||
assert.equal(snapshot.devices.find(({ mac }) => mac === ambiguousMac).appliedPolicy, 'direct');
|
||||
|
||||
const newId = snapshot.devices.find(({ mac }) => mac === newMac).id;
|
||||
snapshot = await service.setPolicy(newId, 'vpn', snapshot.revision);
|
||||
assert.equal(snapshot.devices.find(({ mac }) => mac === newMac).appliedPolicy, 'vpn');
|
||||
service = createService();
|
||||
snapshot = await service.refresh();
|
||||
assert.equal(snapshot.devices.find(({ mac }) => mac === newMac).appliedPolicy, 'vpn');
|
||||
|
||||
const reappearedMac = '00:11:22:33:44:50';
|
||||
observations.push({ ip: '192.168.50.50', mac: reappearedMac, interface: 'eth0', observedAt, active: true });
|
||||
failApply = true;
|
||||
snapshot = await service.refresh();
|
||||
const failed = snapshot.devices.find(({ mac }) => mac === reappearedMac);
|
||||
assert.equal(failed.desiredPolicy, 'direct');
|
||||
assert.equal(failed.appliedPolicy, 'vpn');
|
||||
assert.equal(failed.policyStatus, 'failed');
|
||||
failApply = false;
|
||||
service = createService();
|
||||
snapshot = await service.refresh();
|
||||
assert.equal(snapshot.devices.find(({ mac }) => mac === reappearedMac).appliedPolicy, 'direct');
|
||||
});
|
||||
|
||||
test('reappearing device preserves retained applied Direct when cleanup acknowledgement failed', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-retained-device-policy-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const observedAt = '2026-08-31T12:00:00.000Z';
|
||||
const mac = '00:11:22:33:44:60';
|
||||
const store = createJsonStore({
|
||||
filePath: path.join(directory, 'devices.json'),
|
||||
defaultValue: {},
|
||||
migrate: migrateDeviceInventoryState,
|
||||
initializeMissing: true,
|
||||
});
|
||||
store.update((state) => ({
|
||||
...state,
|
||||
policy: {
|
||||
...state.policy,
|
||||
byMac: {
|
||||
[mac]: {
|
||||
desired: 'direct',
|
||||
applied: 'direct',
|
||||
status: 'failed',
|
||||
appliedAt: '2026-08-30T12:00:00.000Z',
|
||||
error: 'cleanup failed',
|
||||
operationId: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
const service = createDeviceInventoryService({
|
||||
store,
|
||||
observe: () => ({
|
||||
observedAt,
|
||||
error: null,
|
||||
observations: [{ ip: '192.168.50.60', mac, interface: 'eth0', observedAt, active: true }],
|
||||
}),
|
||||
applyPolicies: async () => { throw new Error('iptables unavailable'); },
|
||||
});
|
||||
|
||||
const snapshot = await service.refresh();
|
||||
assert.equal(snapshot.devices[0].desiredPolicy, 'direct');
|
||||
assert.equal(snapshot.devices[0].appliedPolicy, 'direct');
|
||||
assert.equal(snapshot.devices[0].policyStatus, 'failed');
|
||||
assert.equal(store.read().policy.byMac[mac].appliedAt, '2026-08-30T12:00:00.000Z');
|
||||
});
|
||||
|
||||
test('device inventory v1 migration creates a versioned backup', (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-migration-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
|
||||
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
formatByteString,
|
||||
formatLastSeen,
|
||||
isNewDevice,
|
||||
positiveByteDelta,
|
||||
sortDevicesByTraffic,
|
||||
stabilizeDevicesByTraffic,
|
||||
@@ -54,6 +55,9 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
||||
assert.match(panel, /copyText\(value\)/);
|
||||
assert.match(panel, /const hasName = Boolean\(device\.alias \|\| device\.hostname\)/);
|
||||
assert.match(panel, /const title = device\.alias \|\| device\.hostname \|\| device\.ip \|\| 'Неизвестное устройство'/);
|
||||
assert.match(panel, /const newDevice = isNewDevice\(device\.firstSeenAt\)/);
|
||||
assert.match(panel, /client-device-new-badge[\s\S]*aria-hidden="true"[\s\S]*>NEW<[\s\S]*client-device-new-a11y[\s\S]*Новое устройство/);
|
||||
assert.match(panel, /displayPolicy === 'direct'[\s\S]*newDevice[\s\S]*Новое устройство идёт напрямую\. Нажмите, чтобы разрешить VPN через правила Gateway/);
|
||||
assert.match(panel, /client-device-fallback-name[\s\S]*client-device-identity-details[\s\S]*<b>IP<\/b>[\s\S]*<b>MAC<\/b>[\s\S]*<b>Host<\/b>/);
|
||||
assert.match(panel, /onClick=\{\(\) => startEditing\(device\)\}/);
|
||||
assert.match(panel, /\{!hasName && !editing && <span className="client-device-edit-wrap client-tooltip-anchor">/);
|
||||
@@ -175,6 +179,9 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
||||
assert.match(styles, /\.client-device-policy \{[\s\S]*width: 34px;[\s\S]*border-radius: 50%/);
|
||||
assert.match(styles, /\.client-device-alias-trigger \{[\s\S]*font: var\(--type-item-title\)/);
|
||||
assert.match(styles, /\.client-device-alias-trigger\.is-custom-name \{[\s\S]*font: var\(--type-section-title\)/);
|
||||
assert.match(styles, /\.client-device-new-badge \{[\s\S]*height: 14px;[\s\S]*flex: 0 0 auto;[\s\S]*padding: 0 4px;[\s\S]*border: 1px solid color-mix\(in oklch, var\(--client-accent\)[\s\S]*border-radius: 999px;[\s\S]*background: var\(--client-accent-soft\);[\s\S]*font: var\(--type-micro\)/);
|
||||
assert.match(styles, /\.client-device-new-a11y \{[\s\S]*position: absolute;[\s\S]*width: 1px;[\s\S]*clip-path: inset\(50%\)/);
|
||||
assert.doesNotMatch(styles, /client-device-new-badge[^{]*\{[^}]*(?:transition|animation):/);
|
||||
assert.match(styles, /\.client-device-alias-input \{[\s\S]*width: var\(--alias-width\)[\s\S]*caret-color: var\(--client-accent\)[\s\S]*font: var\(--type-section-title\)[\s\S]*client-device-alias-edit-in 360ms/);
|
||||
assert.match(styles, /@keyframes client-device-alias-edit-in[\s\S]*color: var\(--client-accent\)[\s\S]*filter: blur\(2px\)/);
|
||||
assert.doesNotMatch(styles, /\.client-device-alias \{/);
|
||||
@@ -270,6 +277,14 @@ test('device last-seen copy is compact with precise accessible and relative form
|
||||
);
|
||||
});
|
||||
|
||||
test('new-device marker uses an exact seven-day window', () => {
|
||||
const now = Date.parse('2026-08-31T12:00:00.000Z');
|
||||
assert.equal(isNewDevice(new Date(now - (7 * 24 * 60 * 60 * 1000 - 1_000)).toISOString(), now), true);
|
||||
assert.equal(isNewDevice(new Date(now - 7 * 24 * 60 * 60 * 1000).toISOString(), now), false);
|
||||
assert.equal(isNewDevice(new Date(now + 1_000).toISOString(), now), false);
|
||||
assert.equal(isNewDevice('not-a-date', now), false);
|
||||
});
|
||||
|
||||
test('device traffic formatting and sorting preserve uint64 precision and canonical ties', () => {
|
||||
assert.equal(formatByteString('9007199254740993'), '8,0 ПБ');
|
||||
assert.equal(formatByteString('1536'), '1,5 КБ');
|
||||
|
||||
@@ -51,6 +51,7 @@ test('all unknown inventory results pass one identity-preserving runtime parser'
|
||||
hostname: null,
|
||||
mac: '00:11:22:33:44:55',
|
||||
ip: null,
|
||||
firstSeenAt: observedAt,
|
||||
lastSeenAt: null,
|
||||
status: 'online',
|
||||
pinned: true,
|
||||
@@ -126,6 +127,8 @@ test('all unknown inventory results pass one identity-preserving runtime parser'
|
||||
{ ...valid, devices: [{ ...valid.devices[0], desiredPolicy: 'automatic' }] },
|
||||
{ ...valid, devices: [{ ...valid.devices[0], policyStatus: 'queued' }] },
|
||||
{ ...valid, devices: [{ ...valid.devices[0], confidence: 'unknown' }] },
|
||||
{ ...valid, devices: [{ ...valid.devices[0], firstSeenAt: undefined }] },
|
||||
{ ...valid, devices: [{ ...valid.devices[0], firstSeenAt: 'not-a-date' }] },
|
||||
{ ...valid, devices: [{ ...valid.devices[0], lastSeenAt: 'not-a-date' }] },
|
||||
{ ...valid, devices: [{ ...valid.devices[0], trafficHistory: [{ observedAt, gatewayBytes: '1' }] }] },
|
||||
{ ...valid, devices: [{ ...valid.devices[0], outboundTraffic: { observedAt, vpnBytes: '1' } }] },
|
||||
|
||||
@@ -41,24 +41,24 @@ const acceptedLedger = {
|
||||
counts: {
|
||||
cascadeEdges: 1072,
|
||||
customProperties: 111,
|
||||
declarations: 4151,
|
||||
declarations: 4170,
|
||||
important: 0,
|
||||
keyframes: 50,
|
||||
media: 17,
|
||||
rules: 1124,
|
||||
variableReferences: 1036,
|
||||
rules: 1126,
|
||||
variableReferences: 1042,
|
||||
},
|
||||
hashes: {
|
||||
cascadeEdges: 'd60343b571f5a9bcf809609b4731500587da3bdd9ee9f40e33938ecbd8aec6ce',
|
||||
customProperties: '42f9fa9f2caaab6e5d90ae7d224563355822fa270083ea3496cfe2caaaea50bf',
|
||||
declarations: 'ee6b58dc6ef2edd16b8edf1762bb5c19aaf12d908958110c691f0a42f8e9067c',
|
||||
declarations: '15fc1474c16562886582469a7d240dde36882e682b266374fdca7f47f90c2b13',
|
||||
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
||||
duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848',
|
||||
keyframes: 'af4b9ae18d070fd2462a9b050293f3821bf5017c6e7cc53bbe18dc826f0fe3b9',
|
||||
ruleDeclarationSequences: 'e6a9c7019ab48d5bb15ac8ff0ffa51f8df7f1143ee9f8f562bdc78237a64b4b1',
|
||||
selectors: '4788b9fbc3aef9455ea9bf071f111dde318fbfe1fa96fe78127cc436131c4506',
|
||||
variableReferences: '04c19450f58f80dbe90e6df8988b8fa97f8c963d579d4f8acdb5bccd9d4020e9',
|
||||
witnesses: '7e42ab6ad3901fd68c9328e612eb413308ad8716031cbedd3134bf85224f6b9a',
|
||||
ruleDeclarationSequences: '16ccf3b3b8b6f76e784c69a3f06a3abb6e3b57d5d0710dff4bdad23f87bad6c3',
|
||||
selectors: 'b32eeeff7896fedd9057a953b61a8b149116a5c5ce110fb84bf06e24d22bf8ee',
|
||||
variableReferences: 'c9f64054cfe384f90e5c47844ffbbc7fba60f5045efac5d272f45f1f451e950b',
|
||||
witnesses: '2d952ddef91d3578857d7aeb1f469a9a6f3b7b4bba998ab6bc054d0028cf2785',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -211,7 +211,7 @@ test('client typography uses the shared semantic scale outside the token owner',
|
||||
|
||||
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
|
||||
const witnesses = readStyleWitnesses(root);
|
||||
assert.equal(witnesses.length, 1107);
|
||||
assert.equal(witnesses.length, 1110);
|
||||
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
|
||||
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
|
||||
assert.deepEqual(ledger.counts, acceptedLedger.counts);
|
||||
@@ -408,8 +408,8 @@ test('main owns one public stylesheet and the regrouped production CSS is determ
|
||||
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
|
||||
|
||||
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
|
||||
assert.deepEqual(assets, ['index-oz5Ps7e5.css']);
|
||||
assert.deepEqual(assets, ['index-jhWgOVxh.css']);
|
||||
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
|
||||
assert.equal(built.byteLength, 158205);
|
||||
assert.equal(sha256(built), 'b866b95d9cac46602023a35e0f55656af353e87ff230c7ad47d0aaeb768a7d3e');
|
||||
assert.equal(built.byteLength, 158716);
|
||||
assert.equal(sha256(built), 'b10c9602bdf224599db0262e2e872e3db2b3c75b85410d50c2fe42b69d44e14c');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user