diff --git a/commands/igmpFix.js b/commands/igmpFix.js new file mode 100644 index 0000000..0d1a4f6 --- /dev/null +++ b/commands/igmpFix.js @@ -0,0 +1,176 @@ +// src/commands/igmpFix.js +// +// Adaptive-card inline remediation for Meraki switch multicast policy +// on a store's network. Not a standalone chat command — the card is +// emitted by `/phonestatus` (see commands/phoneStatus.js) whenever the +// collector reports `data.multicast.needsFix`. Confirm and Cancel +// flow through the framework's `attachmentAction` event dispatched +// from index.js, which follows the same pending-card / one-shot / +// censor pattern as /webexhost and /offboarduser. +// +// End-state target (per plan): every store network's switch multicast +// settings pinned to: +// defaultSettings.igmpSnoopingEnabled = false +// defaultSettings.floodUnknownMulticastTrafficEnabled = true +// overrides = [] +// +// The target is a hardcoded constant. We do NOT read-then-mutate the +// current snapshot at PUT time — the multicast object schema has only +// these two fields, so there's nothing else to preserve, and a +// constant PUT sidesteps read/write races. + +import { logger } from '../utils/logger.js'; +import { describeRequester } from '../utils/requester.js'; +import { setSwitchMulticastSettings } from '../integrations/meraki/switches.js'; + +// Exported so tests can regression-guard the payload shape and so the +// applyDectSafeMulticast function has a single source of truth. +export const DECT_SAFE_MULTICAST_PAYLOAD = Object.freeze({ + defaultSettings: { + igmpSnoopingEnabled: false, + floodUnknownMulticastTrafficEnabled: true, + }, + overrides: [], +}); + +/** + * Build the adaptive-card object shown in-chat under a /phonestatus + * reply when multicast policy deviates from DECT-safe defaults. The + * card renders whatever the summarizer flagged as deviating so the + * clicker knows exactly what will change. + * + * @param {object} args + * @param {string} args.storeNum + * @param {string} args.networkName + * @param {object} args.summary data.multicast summary shape + * @param {string} args.cardId uuid stashed in pendingIgmpFixes + * @returns {object} Adaptive Card 1.3 JSON. + */ +export function buildIgmpFixCard({ storeNum, networkName, summary, cardId }) { + const facts = []; + if (summary.defaultSnoopOn) { + facts.push({ title: 'Default IGMP snooping', value: 'ON → will change to OFF' }); + } else { + facts.push({ title: 'Default IGMP snooping', value: 'OFF (no change)' }); + } + if (summary.defaultFloodOff) { + facts.push({ title: 'Default flood-unknown multicast', value: 'OFF → will change to ON' }); + } else { + facts.push({ title: 'Default flood-unknown multicast', value: 'ON (no change)' }); + } + const overrideCount = summary.deviatingOverrides?.length || 0; + facts.push({ + title: 'Switch/stack overrides', + value: overrideCount > 0 + ? `${overrideCount} deviating → all overrides cleared` + : 'none (no change)', + }); + + return { + type: 'AdaptiveCard', + $schema: 'http://adaptivecards.io/schemas/adaptive-card.json', + version: '1.3', + body: [ + { + type: 'TextBlock', + size: 'Medium', + weight: 'Bolder', + text: `Apply DECT-safe multicast on ${networkName || `store ${storeNum}`}?`, + wrap: true, + }, + { + type: 'TextBlock', + text: + `This will PUT the Meraki switch multicast settings for this network so IGMP snooping is OFF, ` + + `flood-unknown-multicast is ON, and all per-switch overrides are cleared. Intended to unblock DECT ` + + `handset registration for store ${storeNum}.`, + wrap: true, + spacing: 'Small', + }, + { + type: 'FactSet', + spacing: 'Medium', + facts, + }, + ], + actions: [ + { + type: 'Action.Submit', + title: '✅ Apply DECT-safe defaults', + data: { action: 'confirm_igmp_fix', cardId }, + }, + { + type: 'Action.Submit', + title: '❌ Cancel', + data: { action: 'cancel_igmp_fix', cardId }, + }, + ], + }; +} + +/** + * Confirm handler — PUTs the constant DECT-safe payload to the + * network's multicast endpoint. Called from index.js after the + * pending-card lookup + one-shot delete + card censor. + * + * Signature matches applyHostAssignConfirmation from /webexhost so the + * dispatch in index.js stays uniform across all inline-card actions. + * + * @param {object} bot bot-framework per-room bot + * @param {object} data pending-card payload (see pendingIgmpFixes) + * @param {string} _roomId unused; bot is already room-scoped + * @param {object} requester from extractRequester(trigger) + */ +export async function applyDectSafeMulticast(bot, data, _roomId, requester) { + const { networkId, networkName, storeNum, summary } = data; + + logger( + 'igmp:audit', + `CONFIRMED DECT-safe multicast on ${networkName} (${networkId}) for store ${storeNum} ` + + `by ${describeRequester(requester)} — was: snoop=${summary?.defaultSnoopOn ? 'on' : 'off'}, ` + + `flood=${summary?.defaultFloodOff ? 'off' : 'on'}, overrides=${summary?.deviatingOverrides?.length ?? 0}`, + ); + + try { + await setSwitchMulticastSettings(networkId, DECT_SAFE_MULTICAST_PAYLOAD); + } catch (err) { + logger( + 'igmp:audit', + `FAILED DECT-safe multicast on ${networkName} for store ${storeNum}: ${err.message}`, + 'error', + ); + await bot.say( + 'markdown', + `❌ Failed to update multicast config on **${networkName || `store ${storeNum}`}**: ${err.message}`, + ); + return; + } + + await bot.say( + 'markdown', + `✅ Multicast pinned to DECT-safe defaults on **${networkName || `store ${storeNum}`}**: ` + + `IGMP snoop=OFF, flood-unknown=ON, all switch overrides cleared. ` + + `Re-run \`/phonestatus ${storeNum}\` to verify.`, + ); + logger( + 'igmp:audit', + `COMPLETED DECT-safe multicast on ${networkName} for store ${storeNum}`, + ); +} + +/** + * Cancel handler — logs the cancel and posts a friendly line. No + * mutation. Signature matches cancelHostAssignCard for uniform + * dispatch in index.js. + */ +export async function cancelIgmpFixCard(bot, data, _roomId, requester) { + const { networkName, storeNum } = data; + await bot.say( + 'markdown', + `❌ Multicast change cancelled for **${networkName || `store ${storeNum}`}**. No changes were made.`, + ); + logger( + 'igmp:audit', + `CANCELLED DECT-safe multicast on ${networkName} for store ${storeNum} by ${describeRequester(requester)}`, + ); +} diff --git a/commands/phoneStatus.js b/commands/phoneStatus.js index 43964a6..ddcd3c7 100644 --- a/commands/phoneStatus.js +++ b/commands/phoneStatus.js @@ -6,8 +6,12 @@ // stays thin: parse args, call the collector, hand data to the renderer, // respond. +import { randomUUID } from 'node:crypto'; import { collectPhoneStatus } from '../services/phoneService.js'; import { renderPhoneStatusMarkdown } from '../services/renderers/phoneStatusRenderer.js'; +import { buildIgmpFixCard } from './igmpFix.js'; +import { pendingIgmpFixes } from '../utils/pendingIgmpFixes.js'; +import { extractRequester } from '../utils/requester.js'; import { logger } from '../utils/logger.js'; export async function handlePhoneStatus(bot, trigger) { @@ -57,6 +61,50 @@ export async function handlePhoneStatus(bot, trigger) { }); await bot.say('markdown', reply || 'No data available.'); + // IGMP-snooping remediation card — only when (a) the multicast + // summary flagged deviation AND (b) we know the networkId (can't + // fix what we can't address) AND (c) the invocation came from + // chat, not HTTP. HTTP callers don't have adaptive-card UX; the + // remediation surface for them is a future gated POST endpoint. + // `trigger.person` is populated by the framework for chat triggers + // and absent for HTTP triggers (see index.js command dispatch). + if (data.multicast?.needsFix && data.multicast.networkId && trigger.person) { + const cardId = randomUUID(); + const requester = extractRequester(trigger); + pendingIgmpFixes.set(cardId, { + networkId: data.multicast.networkId, + networkName: data.multicast.networkName, + storeNum, + requester, + // Only the summary is stashed. The fix payload is a constant, + // so we don't need the raw snapshot — keeps the pending-store + // memory footprint tiny and avoids the temptation to + // read-then-mutate at PUT time. + summary: { + defaultSnoopOn: data.multicast.defaultSnoopOn, + defaultFloodOff: data.multicast.defaultFloodOff, + deviatingOverrides: data.multicast.deviatingOverrides || [], + }, + }); + const card = buildIgmpFixCard({ + storeNum, + networkName: data.multicast.networkName, + summary: { + defaultSnoopOn: data.multicast.defaultSnoopOn, + defaultFloodOff: data.multicast.defaultFloodOff, + deviatingOverrides: data.multicast.deviatingOverrides || [], + }, + cardId, + }); + await bot.say({ + markdown: `Multicast policy on store ${storeNum}'s network deviates from DECT-safe defaults. Review and confirm:`, + attachments: [{ + contentType: 'application/vnd.microsoft.card.adaptive', + content: card, + }], + }); + } + } catch (err) { logger('phone:status', `Error collecting phone status for store ${storeNum}: ${err.message}`, 'error'); await bot.say('markdown', `Error collecting phone status: ${err.message}`); diff --git a/index.js b/index.js index faefc84..1b7b1ec 100644 --- a/index.js +++ b/index.js @@ -29,6 +29,11 @@ import { cancelHostAssignCard, } from './commands/webexHost.js'; import { pendingHostAssigns } from './utils/pendingHostAssigns.js'; +import { + applyDectSafeMulticast, + cancelIgmpFixCard, +} from './commands/igmpFix.js'; +import { pendingIgmpFixes } from './utils/pendingIgmpFixes.js'; import { extractRequester } from './utils/requester.js'; import { getCommand, @@ -291,6 +296,7 @@ const DECT_ACTIONS = new Set([ ]); const OFFBOARD_ACTIONS = new Set(['confirm_offboard', 'cancel_offboard']); const HOST_ASSIGN_ACTIONS = new Set(['confirm_host_assign', 'cancel_host_assign']); +const IGMP_FIX_ACTIONS = new Set(['confirm_igmp_fix', 'cancel_igmp_fix']); // Best-effort delete of the adaptive-card message that fired this action. // Removing the card prevents users from clicking Confirm/Cancel a second time @@ -430,6 +436,52 @@ framework.on('attachmentAction', async (bot, trigger) => { return; } + // ── IGMP-snooping / DECT-safe multicast confirm / cancel ── + // Mirrors the HOST_ASSIGN branch above: look up the pending card, + // one-shot delete, censor the card in the room, then dispatch to + // the domain function with the pending payload + resolved requester. + if (IGMP_FIX_ACTIONS.has(actionType)) { + const { cardId } = action.inputs; + const roomId = trigger.roomId || action.roomId; + + if (!cardId) { + logger('igmp:action', `Missing cardId on ${actionType} — ignoring`); + return; + } + if (!pendingIgmpFixes.has(cardId)) { + logger('igmp:action', `Card ${cardId} is expired or unknown`); + return; + } + + const igmpData = pendingIgmpFixes.get(cardId); + pendingIgmpFixes.delete(cardId); // one-shot + logger( + 'igmp:action', + `Received ${actionType} for card ${cardId} (network: ${igmpData.networkName}, store: ${igmpData.storeNum})`, + ); + + await censorActionCard(bot, trigger, 'igmp:action'); + + const requester = extractRequester(trigger); + + try { + if (actionType === 'confirm_igmp_fix') { + await applyDectSafeMulticast(bot, igmpData, roomId, requester); + } else { + await cancelIgmpFixCard(bot, igmpData, roomId, requester); + } + } catch (err) { + logger( + 'igmp:action', + `Error processing ${actionType} for network ${igmpData.networkName}: ${err.message}`, + 'error', + ); + // bot is already room-scoped — do not pass roomId as a 3rd positional arg. + await bot.say('markdown', `⚠️ Error during multicast update: ${err.message}`); + } + return; + } + logger('action', `Ignoring unhandled attachmentAction: ${actionType}`, 'debug'); }); diff --git a/integrations/meraki/switches.js b/integrations/meraki/switches.js new file mode 100644 index 0000000..3f46219 --- /dev/null +++ b/integrations/meraki/switches.js @@ -0,0 +1,115 @@ +// src/integrations/meraki/switches.js +// +// Switch-level Meraki calls. Currently scoped to the network-wide +// multicast (IGMP snooping / flood-unknown-multicast) settings — +// added to support DECT troubleshooting where snooping-without-a- +// querier prunes multicast traffic and breaks handset registration. +// +// The Meraki endpoint is: +// GET/PUT /networks/{networkId}/switch/routing/multicast +// +// Payload shape: +// { +// defaultSettings: { +// igmpSnoopingEnabled: boolean, +// floodUnknownMulticastTrafficEnabled: boolean, +// }, +// overrides: [ +// { switches?: [serial, ...], stacks?: [stackId, ...], igmpSnoopingEnabled, floodUnknownMulticastTrafficEnabled }, +// ... +// ], +// } +// +// Target end-state for DECT stores (from the plan): network default +// snoop=false + flood=true, and NO overrides at all (network-level +// policy, every switch agrees by convention). That target is exported +// as a constant from commands/igmpFix.js and used verbatim by the fix +// path — this module intentionally stays policy-free. + +import { merakiAxios } from './client.js'; +import { logger } from '../../utils/logger.js'; + +/** + * Fetch the current switch multicast settings for a network. + * Returns the raw API response on success, or throws on network / + * auth failure so the caller can decide how to degrade. + */ +export async function getSwitchMulticastSettings(networkId) { + if (!networkId) { + throw new Error('getSwitchMulticastSettings: networkId is required'); + } + const res = await merakiAxios.get(`/networks/${networkId}/switch/routing/multicast`); + return res.data; +} + +/** + * PUT the full multicast payload for a network. The Meraki endpoint + * treats this as a full replacement — whatever `overrides` array you + * send becomes the new authoritative list. That's why the fix path + * sends `overrides: []` to wipe everything rather than trying to + * patch individual entries. + */ +export async function setSwitchMulticastSettings(networkId, payload) { + if (!networkId) { + throw new Error('setSwitchMulticastSettings: networkId is required'); + } + const res = await merakiAxios.put( + `/networks/${networkId}/switch/routing/multicast`, + payload, + ); + logger('meraki:switches', `Updated multicast settings for network ${networkId}`, 'debug'); + return res.data; +} + +/** + * Pure summarizer — turns a raw multicast settings payload into a + * "needs fix?" verdict for DECT-safe policy. Kept side-effect-free + * so the unit test can call it with fixture data (no Meraki mock). + * + * "Needs fix" is true when: + * - Network default `igmpSnoopingEnabled !== false`, OR + * - Network default `floodUnknownMulticastTrafficEnabled !== true`, OR + * - Any override deviates on either setting. + * + * An override that happens to match the target defaults is redundant + * (not deviating) — the multicast endpoint only stores these two + * fields per override, so a matching override serves no purpose but + * doesn't break anything either. We treat it as non-deviating so we + * don't nag operators about harmless historical config. + * + * Malformed / missing input returns `needsFix: false` with both + * boolean flags false — the caller stores `data.multicast.error` in + * that case and the renderer stays silent, per the "diagnostic bot + * shouldn't become a new source of noise" rule. + * + * @param {object} raw Raw response from `getSwitchMulticastSettings`. + * @returns {{ + * defaultOk: boolean, + * defaultSnoopOn: boolean, + * defaultFloodOff: boolean, + * deviatingOverrides: object[], + * needsFix: boolean, + * raw: object, + * }} + */ +export function summarizeMulticast(raw) { + const def = raw?.defaultSettings || {}; + const defaultSnoopOn = def.igmpSnoopingEnabled === true; + const defaultFloodOff = def.floodUnknownMulticastTrafficEnabled === false; + const defaultOk = !defaultSnoopOn && !defaultFloodOff; + + const overrides = Array.isArray(raw?.overrides) ? raw.overrides : []; + const deviatingOverrides = overrides.filter((o) => + o?.igmpSnoopingEnabled !== false || + o?.floodUnknownMulticastTrafficEnabled !== true + ); + + return { + defaultOk, + defaultSnoopOn, + defaultFloodOff, + deviatingOverrides, + needsFix: !defaultOk || deviatingOverrides.length > 0, + raw, + }; +} diff --git a/services/phoneService.js b/services/phoneService.js index be7cf63..8e9e95a 100644 --- a/services/phoneService.js +++ b/services/phoneService.js @@ -5,6 +5,10 @@ import { getClientsForStore, getPortsForStore } from '../integrations/meraki/clients.js'; +import { + getSwitchMulticastSettings, + summarizeMulticast, +} from '../integrations/meraki/switches.js'; import { logger } from '../utils/logger.js'; import { normalizeMac } from './enrichment/normalizers.js'; @@ -57,6 +61,24 @@ export async function collectPhoneStatus(storeNumber) { const clients = Array.isArray(clientsData) ? clientsData : (clientsData.clients || []); const networkUrl = clientsData.network?.url || ''; + // Kick off the switch multicast fetch as soon as we know the network id. + // Overlaps with the DECT detail fetches below so it's essentially free + // wall-clock time. Wrapped in a promise that never rejects — the phone + // report should still render even if multicast lookup fails (missing + // API scope, non-switch network, etc.). Renderer stays silent when + // `needsFix` is false, so an error-only summary produces no noise. + const networkIdForMulticast = clientsData.network?.id + || clientsData.networkId + || null; + const multicastPromise = networkIdForMulticast + ? getSwitchMulticastSettings(networkIdForMulticast) + .then((raw) => ({ ...summarizeMulticast(raw), networkId: networkIdForMulticast, networkName: clientsData.network?.name || null })) + .catch((err) => { + logger('phone:service', `Multicast settings fetch failed for network ${networkIdForMulticast}: ${err.message}`, 'warn'); + return { needsFix: false, error: err.message, networkId: networkIdForMulticast, networkName: clientsData.network?.name || null }; + }) + : Promise.resolve({ needsFix: false, error: 'no networkId available', networkId: null, networkName: null }); + const personDetails = personDetailsRes.status === 'fulfilled' ? personDetailsRes.value : null; const telephonyProfile = telephonyProfileRes.status === 'fulfilled' ? telephonyProfileRes.value : {}; @@ -217,6 +239,12 @@ export async function collectPhoneStatus(storeNumber) { }; }); + // Resolve the multicast promise we fired earlier. Awaited here so it + // overlaps with all the DECT/Meraki enrichment above (essentially + // free wall-clock time). The promise catches its own errors so this + // await never rejects. + const multicast = await multicastPromise; + return { phones: { status: phonesRes.status === 'fulfilled' ? 'success' : 'failed', @@ -233,7 +261,8 @@ export async function collectPhoneStatus(storeNumber) { data: clients || [], network: clientsData.network || null, networkId: clientsData.networkId || (clientsData.network && clientsData.network.id) || null - } + }, + multicast }; } diff --git a/services/renderers/phoneStatusRenderer.js b/services/renderers/phoneStatusRenderer.js index 9cff50c..5130c5a 100644 --- a/services/renderers/phoneStatusRenderer.js +++ b/services/renderers/phoneStatusRenderer.js @@ -125,6 +125,28 @@ export function renderPhoneStatusMarkdown(data, opts = {}) { // DECT Basestations if (dectBasestations.length > 0) { reply += '**DECT Basestations:**\n'; + + // Multicast health signal — DECT relies on multicast for basestation + // discovery / handset registration handshakes. When IGMP snooping is + // on without a corresponding querier, or when flood-unknown is off, + // those frames get dropped and DECT registration silently fails. + // Only emitted when the summarizer says `needsFix` — the healthy + // state is common and would just add noise. Silent on fetch errors + // (summarizer returns needsFix:false + error:...) so a diagnostic + // bot doesn't itself become a new source of noise. + if (data.multicast?.needsFix) { + const mc = data.multicast; + const parts = []; + if (mc.defaultSnoopOn) parts.push('IGMP snoop=ON default'); + if (mc.defaultFloodOff) parts.push('flood-unknown=OFF default'); + const overrideCount = mc.deviatingOverrides?.length || 0; + if (overrideCount > 0) { + parts.push(`${overrideCount} switch override(s) deviate from DECT-safe defaults`); + } + // parts is always non-empty here: needsFix implies at least one deviation + reply += `**Multicast:** ⚠️ ${parts.join(', ')} — may disrupt DECT\n`; + } + if (dectNet) { reply += `**Network:** ${dectNet.name || '—'} (assigned handsets: ${dectNet.handsetsCount || 0})\n`; } diff --git a/tests/igmpFixPayload.test.js b/tests/igmpFixPayload.test.js new file mode 100644 index 0000000..9900825 --- /dev/null +++ b/tests/igmpFixPayload.test.js @@ -0,0 +1,27 @@ +// Regression guard for the DECT-safe multicast payload constant. +// +// The whole IGMP-fix design is predicated on a single hardcoded PUT +// body — no read-then-mutate, no per-store branching. If someone +// accidentally flips one of these booleans (or adds an override to +// the array), every remediation click starts silently corrupting +// production Meraki config. This one test makes that impossible to +// merge without also updating the test, which forces a review. + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { DECT_SAFE_MULTICAST_PAYLOAD } from '../commands/igmpFix.js'; + +test('DECT_SAFE_MULTICAST_PAYLOAD has the exact expected shape', () => { + assert.deepEqual(DECT_SAFE_MULTICAST_PAYLOAD, { + defaultSettings: { + igmpSnoopingEnabled: false, + floodUnknownMulticastTrafficEnabled: true, + }, + overrides: [], + }); +}); + +test('DECT_SAFE_MULTICAST_PAYLOAD is frozen so nothing can mutate it at runtime', () => { + assert.equal(Object.isFrozen(DECT_SAFE_MULTICAST_PAYLOAD), true); +}); diff --git a/tests/igmpSummarizer.test.js b/tests/igmpSummarizer.test.js new file mode 100644 index 0000000..aaaac5b --- /dev/null +++ b/tests/igmpSummarizer.test.js @@ -0,0 +1,135 @@ +// Unit tests for summarizeMulticast — the pure verdict function +// under integrations/meraki/switches.js. No Meraki API needed; the +// summarizer takes a fixture-shaped response and computes flags. +// +// The target end-state (per the plan) is: +// defaultSettings.igmpSnoopingEnabled = false +// defaultSettings.floodUnknownMulticastTrafficEnabled = true +// overrides = [] (no per-switch drift) +// +// `needsFix` is true if any layer deviates from that. + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { summarizeMulticast } from '../integrations/meraki/switches.js'; + +test('target state → needsFix: false', () => { + const s = summarizeMulticast({ + defaultSettings: { igmpSnoopingEnabled: false, floodUnknownMulticastTrafficEnabled: true }, + overrides: [], + }); + assert.equal(s.needsFix, false); + assert.equal(s.defaultOk, true); + assert.equal(s.defaultSnoopOn, false); + assert.equal(s.defaultFloodOff, false); + assert.equal(s.deviatingOverrides.length, 0); +}); + +test('snoop on at default → needsFix: true', () => { + const s = summarizeMulticast({ + defaultSettings: { igmpSnoopingEnabled: true, floodUnknownMulticastTrafficEnabled: true }, + overrides: [], + }); + assert.equal(s.needsFix, true); + assert.equal(s.defaultSnoopOn, true); + assert.equal(s.defaultFloodOff, false); + assert.equal(s.deviatingOverrides.length, 0); +}); + +test('flood off at default → needsFix: true', () => { + const s = summarizeMulticast({ + defaultSettings: { igmpSnoopingEnabled: false, floodUnknownMulticastTrafficEnabled: false }, + overrides: [], + }); + assert.equal(s.needsFix, true); + assert.equal(s.defaultSnoopOn, false); + assert.equal(s.defaultFloodOff, true); +}); + +test('both default settings wrong → both flags true', () => { + const s = summarizeMulticast({ + defaultSettings: { igmpSnoopingEnabled: true, floodUnknownMulticastTrafficEnabled: false }, + overrides: [], + }); + assert.equal(s.needsFix, true); + assert.equal(s.defaultSnoopOn, true); + assert.equal(s.defaultFloodOff, true); +}); + +test('default correct + one override with snoop=true → needsFix: true', () => { + const s = summarizeMulticast({ + defaultSettings: { igmpSnoopingEnabled: false, floodUnknownMulticastTrafficEnabled: true }, + overrides: [ + { switches: ['Q234-ABCD-0001'], igmpSnoopingEnabled: true, floodUnknownMulticastTrafficEnabled: true }, + ], + }); + assert.equal(s.needsFix, true); + assert.equal(s.defaultOk, true); + assert.equal(s.deviatingOverrides.length, 1); +}); + +test('default correct + override that matches target exactly → needsFix: false', () => { + // An override that duplicates the default is redundant but harmless. + // We don't nag the operator about it because there's no user-visible + // impact — the switch behaves identically to no override. + const s = summarizeMulticast({ + defaultSettings: { igmpSnoopingEnabled: false, floodUnknownMulticastTrafficEnabled: true }, + overrides: [ + { switches: ['Q234-ABCD-0001'], igmpSnoopingEnabled: false, floodUnknownMulticastTrafficEnabled: true }, + ], + }); + assert.equal(s.needsFix, false); + assert.equal(s.deviatingOverrides.length, 0); +}); + +test('default correct + override with flood=false only → needsFix: true', () => { + const s = summarizeMulticast({ + defaultSettings: { igmpSnoopingEnabled: false, floodUnknownMulticastTrafficEnabled: true }, + overrides: [ + { switches: ['Q234-ABCD-0001'], igmpSnoopingEnabled: false, floodUnknownMulticastTrafficEnabled: false }, + ], + }); + assert.equal(s.needsFix, true); + assert.equal(s.deviatingOverrides.length, 1); +}); + +test('default correct + multiple overrides mixed → deviatingOverrides only counts the bad ones', () => { + const s = summarizeMulticast({ + defaultSettings: { igmpSnoopingEnabled: false, floodUnknownMulticastTrafficEnabled: true }, + overrides: [ + { switches: ['A'], igmpSnoopingEnabled: false, floodUnknownMulticastTrafficEnabled: true }, // OK + { switches: ['B'], igmpSnoopingEnabled: true, floodUnknownMulticastTrafficEnabled: true }, // bad + { stacks: ['1'], igmpSnoopingEnabled: false, floodUnknownMulticastTrafficEnabled: false }, // bad + ], + }); + assert.equal(s.needsFix, true); + assert.equal(s.deviatingOverrides.length, 2); +}); + +test('malformed / missing defaultSettings → needsFix: false, raw preserved', () => { + // Renderer stays silent on this state — the caller sets + // data.multicast.error separately, so a broken read doesn't produce + // a bogus "everything is fine" warning line. + const s = summarizeMulticast({}); + assert.equal(s.needsFix, false); + assert.equal(s.defaultSnoopOn, false); + assert.equal(s.defaultFloodOff, false); + assert.equal(s.deviatingOverrides.length, 0); + assert.deepEqual(s.raw, {}); +}); + +test('null / undefined input → does not throw, needsFix: false', () => { + assert.doesNotThrow(() => summarizeMulticast(null)); + assert.doesNotThrow(() => summarizeMulticast(undefined)); + assert.equal(summarizeMulticast(null).needsFix, false); + assert.equal(summarizeMulticast(undefined).needsFix, false); +}); + +test('overrides field missing entirely → treated as empty', () => { + const s = summarizeMulticast({ + defaultSettings: { igmpSnoopingEnabled: false, floodUnknownMulticastTrafficEnabled: true }, + }); + assert.equal(s.needsFix, false); + assert.equal(s.deviatingOverrides.length, 0); +}); diff --git a/tests/renderers.test.js b/tests/renderers.test.js index 3c1d66b..7f0bc2d 100644 --- a/tests/renderers.test.js +++ b/tests/renderers.test.js @@ -96,6 +96,89 @@ test('phone renderer: detailed mode reveals SIP details', () => { assert.match(detailed, /Alt SIPs: sip:a@x, sip:b@x…/); }); +// ───────────────────────────────────────────────────────────── +// IGMP snooping / DECT-safe multicast warning line +// ───────────────────────────────────────────────────────────── + +test('phone renderer: multicast warning line includes every deviation kind', () => { + const data = { + dectBasestations: [ + { mac: 'aa:bb:cc:dd:ee:ff', meraki: { status: 'Online' } }, + ], + dectHandsets: [], + multicast: { + needsFix: true, + defaultSnoopOn: true, + defaultFloodOff: true, + deviatingOverrides: [{}, {}], + }, + }; + const md = renderPhoneStatusMarkdown(data, { storeNum: '782', footer: false }); + assert.match(md, /\*\*Multicast:\*\* ⚠️/); + assert.match(md, /IGMP snoop=ON default/); + assert.match(md, /flood-unknown=OFF default/); + assert.match(md, /2 switch override\(s\) deviate/); + assert.match(md, /may disrupt DECT/); +}); + +test('phone renderer: multicast line names only the snoop deviation when that is the only problem', () => { + const data = { + dectBasestations: [ + { mac: 'aa:bb:cc:dd:ee:ff', meraki: { status: 'Online' } }, + ], + dectHandsets: [], + multicast: { + needsFix: true, + defaultSnoopOn: true, + defaultFloodOff: false, + deviatingOverrides: [], + }, + }; + const md = renderPhoneStatusMarkdown(data, { storeNum: '782', footer: false }); + assert.match(md, /\*\*Multicast:\*\* ⚠️ IGMP snoop=ON default — may disrupt DECT/); + assert.doesNotMatch(md, /flood-unknown/); + assert.doesNotMatch(md, /switch override/); +}); + +test('phone renderer: multicast line is ABSENT when needsFix is false', () => { + const data = { + dectBasestations: [ + { mac: 'aa:bb:cc:dd:ee:ff', meraki: { status: 'Online' } }, + ], + dectHandsets: [], + multicast: { + needsFix: false, + defaultSnoopOn: false, + defaultFloodOff: false, + deviatingOverrides: [], + }, + }; + const md = renderPhoneStatusMarkdown(data, { storeNum: '782', footer: false }); + assert.doesNotMatch(md, /\*\*Multicast:\*\*/); +}); + +test('phone renderer: multicast line is ABSENT when there are no DECT basestations', () => { + // Even if needsFix is true, the warning lives INSIDE the DECT section + // which itself is gated on dectBasestations.length > 0. Stores with + // no DECT get no multicast noise — it's simply not their problem. + const data = { + phones: { + data: [{ displayName: 'DESK', status: 'connected', lastSeen: threeHrAgo() }], + }, + dectBasestations: [], + multicast: { + needsFix: true, + defaultSnoopOn: true, + defaultFloodOff: true, + deviatingOverrides: [{}], + }, + }; + const md = renderPhoneStatusMarkdown(data, { storeNum: '782', footer: false }); + assert.doesNotMatch(md, /\*\*Multicast:\*\*/); +}); + +// ───────────────────────────────────────────────────────────── + test('av renderer: header always says (Mode: detailed)', () => { const md = renderAvStatusMarkdown({}, { storeNum: '782', footer: false }); assert.match(md, /^\*\*Device Status - Store 782\*\* \(Mode: detailed\)/); diff --git a/utils/pendingIgmpFixes.js b/utils/pendingIgmpFixes.js new file mode 100644 index 0000000..7b504ad --- /dev/null +++ b/utils/pendingIgmpFixes.js @@ -0,0 +1,67 @@ +// src/utils/pendingIgmpFixes.js +// +// In-memory store for pending IGMP-snooping / multicast-fix +// confirmation cards. Identical shape to pendingHostAssigns — every +// entry is stamped with a `timestamp` on insert, and a background +// sweep expires un-acted cards after TTL_MS so the map never leaks. +// +// Entries typically hold: +// { +// networkId, networkName, storeNum, +// requester, // from extractRequester(trigger) +// summary, // { defaultSnoopOn, defaultFloodOff, deviatingOverrides.length } +// timestamp, // auto-set +// } +// +// TTL is deliberately longer than pendingHostAssigns because a +// multicast change often gets a "hold on, let me check with the +// store first" pause before someone actually clicks. 15 minutes is +// enough for a normal handoff without the card staying live long +// enough for a stale click to fire against a since-fixed network. + +import { logger } from './logger.js'; + +const TTL_MS = 15 * 60 * 1000; // expire un-acted cards after 15 min +const SWEEP_INTERVAL_MS = 60 * 1000; // check once a minute + +const _store = new Map(); + +export const pendingIgmpFixes = { + set(cardId, data) { + _store.set(cardId, { ...data, timestamp: Date.now() }); + return this; + }, + + get(cardId) { + return _store.get(cardId); + }, + + has(cardId) { + return _store.has(cardId); + }, + + delete(cardId) { + return _store.delete(cardId); + }, + + get size() { + return _store.size; + }, + + entries() { + return _store.entries(); + }, +}; + +const sweepHandle = setInterval(() => { + const now = Date.now(); + for (const [cardId, data] of _store.entries()) { + const stamped = typeof data?.timestamp === 'number' ? data.timestamp : 0; + if (now - stamped > TTL_MS) { + logger('igmp:cleanup', `Expired card ${cardId} for store ${data?.storeNum || 'unknown'} network ${data?.networkName || 'unknown'}`); + _store.delete(cardId); + } + } +}, SWEEP_INTERVAL_MS); + +sweepHandle.unref?.();