collabSupport/services/voiceDiag/checks/port/portVlan.js
jmcqueen d12723d010 Voicediag: store voice standards + port-hygiene checks + apply-all card
Refactor every /voicediag check to declare a top-level `standards`
object so the desired state is legible without reading run() logic
and can drive a documented reference table. Upgrade callForwarding
to error severity, tighten voicemail with three send-to-VM error
paths + a `stop_sending_to_voicemail` remediation, and add a
`disable_hoteling` remediation.

Add a port-hygiene check bucket under services/voiceDiag/checks/port
(portType, portVlan, portPoe, portEnabled) that reuses the phone-
status snapshot to enforce switchport standards. Configurable via
VOICE_STANDARD_PHONE_VLAN (default 102) and VOICE_STANDARD_ENABLED
(kill-switch). Preserve Meraki `portType`/`voiceVlan`/`dataVlan`
through the enrichment chain so the checks have clean data to read.

Add an "apply all N fixes" combined card that shows up when 2+
remediations are available. New confirm_voicediag_all /
cancel_voicediag_all actions run each fix in sequence (readable
audit trail, no per-person write-throttle stacking), accumulate
individual failures into a summary rather than aborting.

Adds regression tests asserting every check exposes .standards,
plus coverage for port checks, kill-switch, and combined-card
iteration. 63 tests in the checks file, 188 total, all green.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 14:14:38 -04:00

121 lines
4.1 KiB
JavaScript

// src/services/voiceDiag/checks/port/portVlan.js
//
// Store phone standard: every desk phone / DECT base is tagged into
// a specific VLAN (currently 102 for AE, on the data-side of the
// port; may migrate to voice VLAN in the future — hence the env-
// configurable target and the "either side" comparison below).
//
// The check compares `client.vlan` (the actual VLAN the phone is
// seeing) against the standard rather than the port's `voiceVlan`
// or `dataVlan` configuration alone — this makes it correct today
// (data VLAN carries voice) and correct tomorrow if we move phones
// onto a proper voice VLAN, without touching the check.
//
// Env override:
// VOICE_STANDARD_PHONE_VLAN=102 (default 102)
//
// Skip rules:
// - Wireless: skipped.
// - Trunk uplink: skipped (portType check owns the trunk warning).
// - No VLAN info at all: reported as unknown.
//
// No remediation — VLAN misassignment is a Meraki port-config
// change out of scope for this round.
import { collectPortDevices, isWireless, portLabelFor, maybeSkippedByKillSwitch } from './_helpers.js';
/** Configurable per env — falls back to 102 if unset or non-numeric. */
function getExpectedVlan() {
const raw = process.env.VOICE_STANDARD_PHONE_VLAN;
const parsed = raw ? Number(raw) : 102;
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 4095) return 102;
return parsed;
}
// Exported for the standards reference table + tests. Read at call
// time so tests can mutate process.env without needing to reset
// module state.
export const PORT_VLAN_STANDARDS = Object.freeze({
get vlan() { return getExpectedVlan(); },
envKey: 'VOICE_STANDARD_PHONE_VLAN',
});
export const portVlanCheck = {
id: 'portVlan',
label: 'Switchport VLAN',
requires: ['phoneStatus'],
scope: null,
standards: PORT_VLAN_STANDARDS,
async run(ctx) {
const skip = maybeSkippedByKillSwitch(portVlanCheck);
if (skip) return skip;
const expected = getExpectedVlan();
const devices = collectPortDevices(ctx.phoneStatus);
if (devices.length === 0) {
return {
status: 'skipped',
message: 'No wired devices with Meraki port data to inspect.',
details: null,
remediation: null,
};
}
const perDevice = devices.map((d) => {
if (isWireless(d)) {
return { ...portLabelFor(d), vlan: null, verdict: 'wireless' };
}
const portType = d.meraki?.portType;
if (portType && String(portType).toLowerCase() === 'trunk') {
return { ...portLabelFor(d), vlan: d.meraki?.vlan ?? null, verdict: 'trunk-skip' };
}
const vlan = d.meraki?.vlan;
if (vlan === undefined || vlan === null || vlan === '') {
return { ...portLabelFor(d), vlan: null, verdict: 'unknown' };
}
const num = Number(vlan);
const verdict = num === expected ? 'compliant' : 'wrong';
return { ...portLabelFor(d), vlan: num, verdict };
});
const wrong = perDevice.filter((r) => r.verdict === 'wrong');
const unknown = perDevice.filter((r) => r.verdict === 'unknown');
const details = {
expectedVlan: expected,
total: perDevice.length,
compliant: perDevice.filter((r) => r.verdict === 'compliant').length,
wrong: wrong.length,
unknown: unknown.length,
wireless: perDevice.filter((r) => r.verdict === 'wireless').length,
trunkSkipped: perDevice.filter((r) => r.verdict === 'trunk-skip').length,
offenders: wrong,
};
if (wrong.length > 0) {
const worstLine = wrong.map((w) => `${w.deviceLabel}@${w.portLabel} (VLAN ${w.vlan})`).join(', ');
return {
status: 'warn',
message: `${wrong.length} device(s) on the wrong VLAN (expected ${expected}): ${worstLine}.`,
details,
remediation: null,
};
}
if (unknown.length > 0) {
return {
status: 'warn',
message: `${unknown.length} device(s) have no VLAN info from Meraki.`,
details,
remediation: null,
};
}
return {
status: 'ok',
message: `All ${details.compliant} wired device(s) on VLAN ${expected}.`,
details,
remediation: null,
};
},
};