Detect and inline-remediate Meraki IGMP snooping in /phonestatus

DECT basestations rely on multicast for handset discovery/registration.
When Meraki switches have IGMP snooping enabled without a querier —
or per-switch overrides that deviate from a DECT-safe policy — those
frames are pruned and DECT handsets silently fail to register.

- integrations/meraki/switches.js: getSwitchMulticastSettings,
  setSwitchMulticastSettings, and pure summarizeMulticast verdict fn.
- services/phoneService.js: fires the multicast fetch as soon as the
  network id is known, overlapping DECT enrichment; attaches
  data.multicast summary to the collector output. Non-fatal on error.
- services/renderers/phoneStatusRenderer.js: emits a single warning
  line inside the DECT Basestations section only when needsFix is
  true, itemizing which parts deviate (default snoop, default flood,
  N overrides).
- commands/igmpFix.js: frozen DECT_SAFE_MULTICAST_PAYLOAD constant
  ({snoop:false, flood:true, overrides:[]}), adaptive-card builder,
  and confirm/cancel handlers.
- commands/phoneStatus.js: appends the adaptive card when needsFix
  and the trigger came from chat (skipped on HTTP path).
- index.js: IGMP_FIX_ACTIONS set + dispatch branch mirroring the
  HOST_ASSIGN pattern (one-shot pending lookup, censorActionCard,
  domain call).
- utils/pendingIgmpFixes.js: 15-min TTL pending-card store.
- tests: 11 summarizer cases (all deviation permutations + malformed
  input), 4 renderer cases (each warning-line shape + silence when
  needsFix false or no DECT), and a frozen-constant regression guard
  on the PUT payload. Full suite: 47/47 passing.
This commit is contained in:
jmcqueen 2026-07-02 14:10:33 -04:00
parent c15a471959
commit d112e45eb6
10 changed files with 755 additions and 1 deletions

176
commands/igmpFix.js Normal file
View file

@ -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)}`,
);
}

View file

@ -6,8 +6,12 @@
// stays thin: parse args, call the collector, hand data to the renderer, // stays thin: parse args, call the collector, hand data to the renderer,
// respond. // respond.
import { randomUUID } from 'node:crypto';
import { collectPhoneStatus } from '../services/phoneService.js'; import { collectPhoneStatus } from '../services/phoneService.js';
import { renderPhoneStatusMarkdown } from '../services/renderers/phoneStatusRenderer.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'; import { logger } from '../utils/logger.js';
export async function handlePhoneStatus(bot, trigger) { export async function handlePhoneStatus(bot, trigger) {
@ -57,6 +61,50 @@ export async function handlePhoneStatus(bot, trigger) {
}); });
await bot.say('markdown', reply || 'No data available.'); 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) { } catch (err) {
logger('phone:status', `Error collecting phone status for store ${storeNum}: ${err.message}`, 'error'); logger('phone:status', `Error collecting phone status for store ${storeNum}: ${err.message}`, 'error');
await bot.say('markdown', `Error collecting phone status: ${err.message}`); await bot.say('markdown', `Error collecting phone status: ${err.message}`);

View file

@ -29,6 +29,11 @@ import {
cancelHostAssignCard, cancelHostAssignCard,
} from './commands/webexHost.js'; } from './commands/webexHost.js';
import { pendingHostAssigns } from './utils/pendingHostAssigns.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 { extractRequester } from './utils/requester.js';
import { import {
getCommand, getCommand,
@ -291,6 +296,7 @@ const DECT_ACTIONS = new Set([
]); ]);
const OFFBOARD_ACTIONS = new Set(['confirm_offboard', 'cancel_offboard']); const OFFBOARD_ACTIONS = new Set(['confirm_offboard', 'cancel_offboard']);
const HOST_ASSIGN_ACTIONS = new Set(['confirm_host_assign', 'cancel_host_assign']); 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. // Best-effort delete of the adaptive-card message that fired this action.
// Removing the card prevents users from clicking Confirm/Cancel a second time // Removing the card prevents users from clicking Confirm/Cancel a second time
@ -430,6 +436,52 @@ framework.on('attachmentAction', async (bot, trigger) => {
return; 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'); logger('action', `Ignoring unhandled attachmentAction: ${actionType}`, 'debug');
}); });

View file

@ -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,
};
}

View file

@ -5,6 +5,10 @@ import {
getClientsForStore, getClientsForStore,
getPortsForStore getPortsForStore
} from '../integrations/meraki/clients.js'; } from '../integrations/meraki/clients.js';
import {
getSwitchMulticastSettings,
summarizeMulticast,
} from '../integrations/meraki/switches.js';
import { logger } from '../utils/logger.js'; import { logger } from '../utils/logger.js';
import { normalizeMac } from './enrichment/normalizers.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 clients = Array.isArray(clientsData) ? clientsData : (clientsData.clients || []);
const networkUrl = clientsData.network?.url || ''; 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 personDetails = personDetailsRes.status === 'fulfilled' ? personDetailsRes.value : null;
const telephonyProfile = telephonyProfileRes.status === 'fulfilled' ? telephonyProfileRes.value : {}; 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 { return {
phones: { phones: {
status: phonesRes.status === 'fulfilled' ? 'success' : 'failed', status: phonesRes.status === 'fulfilled' ? 'success' : 'failed',
@ -233,7 +261,8 @@ export async function collectPhoneStatus(storeNumber) {
data: clients || [], data: clients || [],
network: clientsData.network || null, network: clientsData.network || null,
networkId: clientsData.networkId || (clientsData.network && clientsData.network.id) || null networkId: clientsData.networkId || (clientsData.network && clientsData.network.id) || null
} },
multicast
}; };
} }

View file

@ -125,6 +125,28 @@ export function renderPhoneStatusMarkdown(data, opts = {}) {
// DECT Basestations // DECT Basestations
if (dectBasestations.length > 0) { if (dectBasestations.length > 0) {
reply += '**DECT Basestations:**\n'; 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) { if (dectNet) {
reply += `**Network:** ${dectNet.name || '—'} (assigned handsets: ${dectNet.handsetsCount || 0})\n`; reply += `**Network:** ${dectNet.name || '—'} (assigned handsets: ${dectNet.handsetsCount || 0})\n`;
} }

View file

@ -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);
});

View file

@ -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);
});

View file

@ -96,6 +96,89 @@ test('phone renderer: detailed mode reveals SIP details', () => {
assert.match(detailed, /Alt SIPs: sip:a@x, sip:b@x…/); 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)', () => { test('av renderer: header always says (Mode: detailed)', () => {
const md = renderAvStatusMarkdown({}, { storeNum: '782', footer: false }); const md = renderAvStatusMarkdown({}, { storeNum: '782', footer: false });
assert.match(md, /^\*\*Device Status - Store 782\*\* \(Mode: detailed\)/); assert.match(md, /^\*\*Device Status - Store 782\*\* \(Mode: detailed\)/);

67
utils/pendingIgmpFixes.js Normal file
View file

@ -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?.();