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>
This commit is contained in:
parent
2eb31a2ddc
commit
d12723d010
22 changed files with 1515 additions and 78 deletions
25
.env.example
25
.env.example
|
|
@ -81,6 +81,31 @@ WEBEX_TOKENS_PATH=./config/webex-service-tokens.json
|
||||||
# Optional override for the Webex API base URL (default https://webexapis.com/v1).
|
# Optional override for the Webex API base URL (default https://webexapis.com/v1).
|
||||||
# WEBEX_BASE_URL=https://webexapis.com/v1
|
# WEBEX_BASE_URL=https://webexapis.com/v1
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# /voicediag — Store voice-line standards
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Store phones are held to a fixed configuration standard. Most of the
|
||||||
|
# standard is baked into the per-check modules under
|
||||||
|
# services/voiceDiag/checks/ (see services/voiceDiag/README.md for the
|
||||||
|
# reference table) — the values below only exist for the pieces that
|
||||||
|
# vary per site / per environment.
|
||||||
|
#
|
||||||
|
# Expected VLAN for store phones. Today AE tags phones into the data
|
||||||
|
# VLAN (102); some sites may move onto a proper voice VLAN in the
|
||||||
|
# future. The check compares against whatever value is set here and
|
||||||
|
# emits a warn when a phone shows up on any other VLAN. Default 102
|
||||||
|
# if unset.
|
||||||
|
# VOICE_STANDARD_PHONE_VLAN=102
|
||||||
|
|
||||||
|
# Global kill-switch for /voicediag port-hygiene checks. Set to
|
||||||
|
# `false` to silence portType / portVlan / portPoe / portEnabled while
|
||||||
|
# an underlying Meraki cleanup is in progress and you don't want the
|
||||||
|
# noise. Values: true (default) / false. The feature-config checks
|
||||||
|
# (DND, forwarding, waiting, voicemail, intercept, hoteling, exec-
|
||||||
|
# assistant, outgoingPermission) always run — this only gates the
|
||||||
|
# switchport bucket.
|
||||||
|
# VOICE_STANDARD_ENABLED=true
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# /webexhost — Webex Meetings host license helper
|
# /webexhost — Webex Meetings host license helper
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,20 @@ export async function handleVoiceDiag(bot, trigger) {
|
||||||
requester,
|
requester,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If there are 2+ independent remediations, add a bulk "apply all"
|
||||||
|
// card as a shortcut. The individual cards remain on-screen so an
|
||||||
|
// operator who only wants to fix one thing can still do that; the
|
||||||
|
// combined card just spares them from N confirm clicks when the
|
||||||
|
// whole set looks fine. Single-remediation cases skip this — one
|
||||||
|
// card is already the minimum interaction.
|
||||||
|
if (fixable.length >= 2) {
|
||||||
|
await postCombinedRemediationCard(bot, {
|
||||||
|
storeNum: ctx.storeNum,
|
||||||
|
fixable,
|
||||||
|
requester,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -189,6 +203,109 @@ export async function cancelVoiceDiagRemediation(bot, data, _roomId, requester)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined "apply all N fixes" dispatcher — invoked when an operator
|
||||||
|
* clicks the bulk confirm on the summary card posted after 2+
|
||||||
|
* individually-fixable results. Iterates the queued entries in
|
||||||
|
* *sequence* rather than parallel:
|
||||||
|
*
|
||||||
|
* - Sequential keeps Webex-side audit lines readable (one
|
||||||
|
* "COMPLETED foo" line per fix, in the order the check ran).
|
||||||
|
* - Sequential avoids compounding rate-limit exposure on the
|
||||||
|
* `/v1/people/{id}/features/*` surface (Webex applies a
|
||||||
|
* per-person write throttle that we've hit in bursts before).
|
||||||
|
* - Individual failure isolation: one 500 doesn't abort the rest;
|
||||||
|
* failures accumulate into `errors[]` and get reported at the end.
|
||||||
|
*/
|
||||||
|
export async function applyAllVoiceDiagRemediations(bot, data, _roomId, requester) {
|
||||||
|
const entries = Array.isArray(data?.entries) ? data.entries : [];
|
||||||
|
const { storeNum, personLabel } = data || {};
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
`⚠️ No pending remediations to apply for store ${storeNum || '?'}. The card may have expired.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger(
|
||||||
|
'voicediag:audit',
|
||||||
|
`Dispatching APPLY-ALL (${entries.length} fixes) for store ${storeNum} ` +
|
||||||
|
`requested by ${describeRequester(requester)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const applied = [];
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const registered = REMEDIATION_REGISTRY.get(entry.remediationId);
|
||||||
|
if (!registered) {
|
||||||
|
errors.push({ id: entry.remediationId, reason: 'no handler registered' });
|
||||||
|
logger(
|
||||||
|
'voicediag:action',
|
||||||
|
`No handler registered for remediationId "${entry.remediationId}" in apply-all — skipping`,
|
||||||
|
'warn',
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger(
|
||||||
|
'voicediag:audit',
|
||||||
|
`apply-all → ${entry.remediationId} (check=${registered.check.id}) for store ${storeNum}`,
|
||||||
|
);
|
||||||
|
await registered.handler(bot, entry.remediationPayload || {}, requester);
|
||||||
|
applied.push(entry.remediationId);
|
||||||
|
} catch (err) {
|
||||||
|
errors.push({ id: entry.remediationId, reason: err.message });
|
||||||
|
logger(
|
||||||
|
'voicediag:action',
|
||||||
|
`apply-all: ${entry.remediationId} failed: ${err.message}`,
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post a final summary line so the operator sees the aggregate
|
||||||
|
// outcome in one place — individual handlers already posted their
|
||||||
|
// own ✅ / ❌ per-fix, but a final tally scannable in one glance
|
||||||
|
// is much friendlier than reading N separate lines.
|
||||||
|
const lines = [
|
||||||
|
`**Apply-all complete for store ${storeNum}** (${personLabel || 'store user'})`,
|
||||||
|
`- Applied: ${applied.length} / ${entries.length}`,
|
||||||
|
];
|
||||||
|
if (errors.length > 0) {
|
||||||
|
lines.push(`- Failed: ${errors.length}`);
|
||||||
|
for (const e of errors) {
|
||||||
|
lines.push(` - \`${e.id}\` — ${e.reason}`);
|
||||||
|
}
|
||||||
|
lines.push(`Re-run \`/voicediag ${storeNum}\` to re-inspect and retry any leftovers.`);
|
||||||
|
}
|
||||||
|
await bot.say('markdown', lines.join('\n'));
|
||||||
|
|
||||||
|
logger(
|
||||||
|
'voicediag:audit',
|
||||||
|
`COMPLETED apply-all for store ${storeNum} — applied=${applied.length}/${entries.length}, ` +
|
||||||
|
`errors=${errors.length}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelAllVoiceDiagRemediations(bot, data, _roomId, requester) {
|
||||||
|
const { storeNum, personLabel } = data || {};
|
||||||
|
const entries = Array.isArray(data?.entries) ? data.entries : [];
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
`❌ Cancelled all pending remediations for **${personLabel || `store ${storeNum || '?'}`}** ` +
|
||||||
|
`(${entries.length} fix${entries.length === 1 ? '' : 'es'} not applied).`,
|
||||||
|
);
|
||||||
|
logger(
|
||||||
|
'voicediag:audit',
|
||||||
|
`CANCELLED APPLY-ALL (${entries.length} entries) for store ${storeNum || '?'} ` +
|
||||||
|
`by ${describeRequester(requester)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── internals ────────────────────────────────────────────────────
|
// ─── internals ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function argIncludes(args, needle) {
|
function argIncludes(args, needle) {
|
||||||
|
|
@ -270,6 +387,67 @@ async function postRemediationCard(bot, { storeNum, result, requester }) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a *combined* pending payload holding every fixable
|
||||||
|
* result's remediationId + payload, and posts one adaptive card
|
||||||
|
* offering to apply the whole batch in a single click. Shape of the
|
||||||
|
* stored payload:
|
||||||
|
*
|
||||||
|
* {
|
||||||
|
* combined: true,
|
||||||
|
* storeNum, personLabel,
|
||||||
|
* entries: [{ remediationId, remediationPayload }, ...],
|
||||||
|
* requester,
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Uses the same pendingVoiceFixes map (single sweep, single TTL) —
|
||||||
|
* the `combined: true` flag + the dispatcher's `switch` on
|
||||||
|
* `actionType` are what decides whether to hit the single-fix or
|
||||||
|
* batch handler.
|
||||||
|
*/
|
||||||
|
async function postCombinedRemediationCard(bot, { storeNum, fixable, requester }) {
|
||||||
|
const cardId = randomUUID();
|
||||||
|
// All fixable results in a run share the same person (they're
|
||||||
|
// per-user features on the store line), so grab the label from
|
||||||
|
// the first entry — safer than reaching for ctx here since this
|
||||||
|
// helper only receives what it needs.
|
||||||
|
const personLabel =
|
||||||
|
fixable.find((r) => r.remediation?.payload?.personLabel)?.remediation?.payload?.personLabel ||
|
||||||
|
`store ${storeNum}`;
|
||||||
|
|
||||||
|
const entries = fixable.map((r) => ({
|
||||||
|
remediationId: r.remediation.action,
|
||||||
|
remediationPayload: r.remediation.payload || {},
|
||||||
|
// Kept for the card summary + audit context. Not read by the
|
||||||
|
// dispatcher.
|
||||||
|
label: r.label,
|
||||||
|
title: r.remediation.title,
|
||||||
|
}));
|
||||||
|
|
||||||
|
pendingVoiceFixes.set(cardId, {
|
||||||
|
combined: true,
|
||||||
|
storeNum,
|
||||||
|
personLabel,
|
||||||
|
requester,
|
||||||
|
entries,
|
||||||
|
});
|
||||||
|
|
||||||
|
const card = buildCombinedVoiceDiagCard({
|
||||||
|
storeNum,
|
||||||
|
personLabel,
|
||||||
|
entries,
|
||||||
|
cardId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await bot.say({
|
||||||
|
markdown: `Or apply **all ${entries.length} fixes** at once:`,
|
||||||
|
attachments: [{
|
||||||
|
contentType: 'application/vnd.microsoft.card.adaptive',
|
||||||
|
content: card,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function buildVoiceDiagCard({ storeNum, result, cardId }) {
|
function buildVoiceDiagCard({ storeNum, result, cardId }) {
|
||||||
const { label, message, remediation } = result;
|
const { label, message, remediation } = result;
|
||||||
return {
|
return {
|
||||||
|
|
@ -312,3 +490,53 @@ function buildVoiceDiagCard({ storeNum, result, cardId }) {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildCombinedVoiceDiagCard({ storeNum, personLabel, entries, cardId }) {
|
||||||
|
const bulletBody = entries.map((e) => `• ${e.label}: ${e.title}`).join('\n');
|
||||||
|
return {
|
||||||
|
type: 'AdaptiveCard',
|
||||||
|
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
||||||
|
version: '1.3',
|
||||||
|
body: [
|
||||||
|
{
|
||||||
|
type: 'TextBlock',
|
||||||
|
size: 'Medium',
|
||||||
|
weight: 'Bolder',
|
||||||
|
text: `Apply all ${entries.length} fixes — Store ${storeNum}`,
|
||||||
|
wrap: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'TextBlock',
|
||||||
|
text: `Target: ${personLabel}`,
|
||||||
|
wrap: true,
|
||||||
|
spacing: 'Small',
|
||||||
|
isSubtle: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'TextBlock',
|
||||||
|
text: bulletBody,
|
||||||
|
wrap: true,
|
||||||
|
spacing: 'Small',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'TextBlock',
|
||||||
|
text: 'Fixes will be applied in sequence. Any failures are reported at the end.',
|
||||||
|
wrap: true,
|
||||||
|
spacing: 'Small',
|
||||||
|
isSubtle: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
type: 'Action.Submit',
|
||||||
|
title: `✅ Apply all ${entries.length}`,
|
||||||
|
data: { action: 'confirm_voicediag_all', cardId },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Action.Submit',
|
||||||
|
title: '❌ Cancel',
|
||||||
|
data: { action: 'cancel_voicediag_all', cardId },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
34
index.js
34
index.js
|
|
@ -37,6 +37,8 @@ import { pendingIgmpFixes } from './utils/pendingIgmpFixes.js';
|
||||||
import {
|
import {
|
||||||
applyVoiceDiagRemediation,
|
applyVoiceDiagRemediation,
|
||||||
cancelVoiceDiagRemediation,
|
cancelVoiceDiagRemediation,
|
||||||
|
applyAllVoiceDiagRemediations,
|
||||||
|
cancelAllVoiceDiagRemediations,
|
||||||
} from './commands/voiceDiag.js';
|
} from './commands/voiceDiag.js';
|
||||||
import { pendingVoiceFixes } from './utils/pendingVoiceFixes.js';
|
import { pendingVoiceFixes } from './utils/pendingVoiceFixes.js';
|
||||||
import { extractRequester } from './utils/requester.js';
|
import { extractRequester } from './utils/requester.js';
|
||||||
|
|
@ -303,7 +305,16 @@ 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']);
|
const IGMP_FIX_ACTIONS = new Set(['confirm_igmp_fix', 'cancel_igmp_fix']);
|
||||||
const VOICEDIAG_ACTIONS = new Set(['confirm_voicediag', 'cancel_voicediag']);
|
const VOICEDIAG_ACTIONS = new Set([
|
||||||
|
'confirm_voicediag',
|
||||||
|
'cancel_voicediag',
|
||||||
|
// "Apply all N fixes" combined-card variants — see
|
||||||
|
// commands/voiceDiag.js#postCombinedRemediationCard. Same pending-map
|
||||||
|
// + same dispatcher branch; the payload shape distinguishes the two
|
||||||
|
// (single-remediation vs. `entries` array).
|
||||||
|
'confirm_voicediag_all',
|
||||||
|
'cancel_voicediag_all',
|
||||||
|
]);
|
||||||
|
|
||||||
// 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
|
||||||
|
|
@ -514,7 +525,11 @@ framework.on('attachmentAction', async (bot, trigger) => {
|
||||||
logger(
|
logger(
|
||||||
'voicediag:action',
|
'voicediag:action',
|
||||||
`Received ${actionType} for card ${cardId} ` +
|
`Received ${actionType} for card ${cardId} ` +
|
||||||
`(store: ${voiceData.storeNum}, remediation: ${voiceData.remediationId})`,
|
`(store: ${voiceData.storeNum}, ` +
|
||||||
|
(voiceData.combined
|
||||||
|
? `combined N=${(voiceData.entries || []).length}`
|
||||||
|
: `remediation: ${voiceData.remediationId}`) +
|
||||||
|
`)`,
|
||||||
);
|
);
|
||||||
|
|
||||||
await censorActionCard(bot, trigger, 'voicediag:action');
|
await censorActionCard(bot, trigger, 'voicediag:action');
|
||||||
|
|
@ -522,16 +537,25 @@ framework.on('attachmentAction', async (bot, trigger) => {
|
||||||
const requester = extractRequester(trigger);
|
const requester = extractRequester(trigger);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (actionType === 'confirm_voicediag') {
|
switch (actionType) {
|
||||||
|
case 'confirm_voicediag':
|
||||||
await applyVoiceDiagRemediation(bot, voiceData, roomId, requester);
|
await applyVoiceDiagRemediation(bot, voiceData, roomId, requester);
|
||||||
} else {
|
break;
|
||||||
|
case 'cancel_voicediag':
|
||||||
await cancelVoiceDiagRemediation(bot, voiceData, roomId, requester);
|
await cancelVoiceDiagRemediation(bot, voiceData, roomId, requester);
|
||||||
|
break;
|
||||||
|
case 'confirm_voicediag_all':
|
||||||
|
await applyAllVoiceDiagRemediations(bot, voiceData, roomId, requester);
|
||||||
|
break;
|
||||||
|
case 'cancel_voicediag_all':
|
||||||
|
await cancelAllVoiceDiagRemediations(bot, voiceData, roomId, requester);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger(
|
logger(
|
||||||
'voicediag:action',
|
'voicediag:action',
|
||||||
`Error processing ${actionType} for store ${voiceData.storeNum} ` +
|
`Error processing ${actionType} for store ${voiceData.storeNum} ` +
|
||||||
`remediation ${voiceData.remediationId}: ${err.message}`,
|
`remediation ${voiceData.remediationId || 'combined'}: ${err.message}`,
|
||||||
'error',
|
'error',
|
||||||
);
|
);
|
||||||
await bot.say('markdown', `⚠️ Error during voice diagnostic remediation: ${err.message}`);
|
await bot.say('markdown', `⚠️ Error during voice diagnostic remediation: ${err.message}`);
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,12 @@ export async function getMerakiPorts(networkId) {
|
||||||
accessPolicy: port.accessPolicy,
|
accessPolicy: port.accessPolicy,
|
||||||
stickyMac: port.stickyMac || false,
|
stickyMac: port.stickyMac || false,
|
||||||
allowedMacs: port.allowedMacs || [],
|
allowedMacs: port.allowedMacs || [],
|
||||||
|
// 'access' | 'trunk' | undefined. Preserved so /voicediag
|
||||||
|
// port-hygiene checks can flag phones that end up on a
|
||||||
|
// trunk uplink (downstream through a non-Meraki switch,
|
||||||
|
// typically a Cisco stack) where per-port policy isn't
|
||||||
|
// visible from our side.
|
||||||
|
portType: port.type || null,
|
||||||
voiceVlan: port.voiceVlan,
|
voiceVlan: port.voiceVlan,
|
||||||
dataVlan: port.vlan,
|
dataVlan: port.vlan,
|
||||||
portName: port.name || `Port ${port.number}`,
|
portName: port.name || `Port ${port.number}`,
|
||||||
|
|
|
||||||
|
|
@ -303,6 +303,12 @@ export async function attachPortConfigAndStatus(client, portConfigs = [], portSt
|
||||||
poePower: portConfig?.poePower || 0,
|
poePower: portConfig?.poePower || 0,
|
||||||
accessPolicy: portConfig?.accessPolicyType || portConfig?.accessPolicy || '—',
|
accessPolicy: portConfig?.accessPolicyType || portConfig?.accessPolicy || '—',
|
||||||
allowedMacs: portConfig?.stickyMacAllowList || portConfig?.allowedMacs || [],
|
allowedMacs: portConfig?.stickyMacAllowList || portConfig?.allowedMacs || [],
|
||||||
|
// 'access' | 'trunk' | null. Consumed by /voicediag port-hygiene
|
||||||
|
// checks — trunk means the phone is downstream through a non-
|
||||||
|
// Meraki switch and per-port config isn't visible from here.
|
||||||
|
portType: portConfig?.portType || portConfig?.type || null,
|
||||||
|
voiceVlan: portConfig?.voiceVlan ?? null,
|
||||||
|
dataVlan: portConfig?.dataVlan ?? portConfig?.vlan ?? null,
|
||||||
switchportStatus: portStatus,
|
switchportStatus: portStatus,
|
||||||
switchportConfig: portConfig,
|
switchportConfig: portConfig,
|
||||||
switchSerial: switchSerial || '—',
|
switchSerial: switchSerial || '—',
|
||||||
|
|
|
||||||
|
|
@ -48,20 +48,106 @@ distinguishes routing-404s ("URL moved, update the check") from
|
||||||
"not applicable" 404s ("this person isn't a calling user") using
|
"not applicable" 404s ("this person isn't a calling user") using
|
||||||
the `no static resource` marker in the response body.
|
the `no static resource` marker in the response body.
|
||||||
|
|
||||||
|
## Store voice standards
|
||||||
|
|
||||||
|
The store phone standard is enforced by the checks below. Every
|
||||||
|
check descriptor exports a `standards` object so the desired state
|
||||||
|
is legible from the check file without reading `run()`. Changes
|
||||||
|
here are meant to be a two-step change: update `standards`, then
|
||||||
|
teach `run()` to interpret it — the checks compare the live state
|
||||||
|
against `standards` and emit the severity in the "when non-compliant"
|
||||||
|
column.
|
||||||
|
|
||||||
|
### Per-user Webex Calling standards
|
||||||
|
|
||||||
|
| Check | Standard | Non-compliant severity | Auto-remediation |
|
||||||
|
| -------------------- | -------------------------------------------------------- | ---------------------- | ------------------------------------ |
|
||||||
|
| `dnd` | `enabled: false` | warn | `disable_dnd` |
|
||||||
|
| `callForwarding` | `{always, busy, noAnswer}.enabled: false` | **error** | `clear_call_forwarding` |
|
||||||
|
| `callWaiting` | `enabled: true` | warn | `enable_call_waiting` |
|
||||||
|
| `callIntercept` | `enabled: false` | error | `disable_call_intercept` |
|
||||||
|
| `voicemail` | `enabled: true`, all three `send*Calls.enabled: false`, `mwiEnabled: true` | error on send-to-VM, warn on MWI-off / disabled / off-org email | `stop_sending_to_voicemail` for the send-to-VM path only |
|
||||||
|
| `hoteling` | `enabled: false` | warn | `disable_hoteling` |
|
||||||
|
| `executiveAssistant` | `type: 'UNASSIGNED'` | warn | none — Control Hub cleanup |
|
||||||
|
| `outgoingPermission` | no high-impact call type BLOCKED (LOCAL, NATIONAL, TOLL_FREE, TOLL, INTERNATIONAL) | warn | none — location-scoped fix |
|
||||||
|
|
||||||
|
`callForwarding` is intentionally at `error` severity: forwarding
|
||||||
|
active on a store line silently drops customer calls, and it's the
|
||||||
|
single most common voice ticket. Voicemail's `error` path is
|
||||||
|
narrower — only the three send-* triggers upgrade to error, since
|
||||||
|
that also silently swallows calls. MWI-off, off-org email
|
||||||
|
forwarding, and voicemail-disabled all stay at warn.
|
||||||
|
|
||||||
|
### Switchport / port-hygiene standards
|
||||||
|
|
||||||
|
These reuse the phone-status snapshot from `/phonestatus` — no
|
||||||
|
extra Webex API calls — and cross-reference against the Meraki
|
||||||
|
port config that already flows through `services/enrichment/merakiEnrichment.js`.
|
||||||
|
Trunk uplinks are treated as a visibility boundary: the phone is
|
||||||
|
behind a non-Meraki switch (typically a Cisco stack in a store)
|
||||||
|
and per-port policy isn't ours to enforce, so downstream VLAN /
|
||||||
|
PoE / admin-state checks defer to the operator.
|
||||||
|
|
||||||
|
| Check | Standard | Non-compliant severity | Notes |
|
||||||
|
| ------------- | ---------------------------------------------------- | ---------------------- | ----------------------------------------------- |
|
||||||
|
| `portType` | `portType: 'access'` on every wired phone / DECT base | warn | Trunk uplinks flagged so operator checks the downstream switch |
|
||||||
|
| `portVlan` | `vlan === VOICE_STANDARD_PHONE_VLAN` (default `102`) | warn | Env-configurable — VLAN may move from data-side to a proper voice VLAN in the future |
|
||||||
|
| `portPoe` | `poeEnabled: true` | warn | Skipped for trunk-uplinked devices |
|
||||||
|
| `portEnabled` | `portEnabled: true` | **error** | Admin-disabled port → phone is dead |
|
||||||
|
|
||||||
|
No auto-remediation on any port check — Meraki port-config PUTs
|
||||||
|
are a separate scope of work; the operator handles fixes in the
|
||||||
|
Meraki Dashboard.
|
||||||
|
|
||||||
|
### Environment overrides
|
||||||
|
|
||||||
|
Both port-hygiene knobs live in `.env`:
|
||||||
|
|
||||||
|
| Var | Default | Purpose |
|
||||||
|
| ------------------------------ | ------- | ---------------------------------------------------------------------------------------------- |
|
||||||
|
| `VOICE_STANDARD_PHONE_VLAN` | `102` | Expected VLAN for a store phone. Set per-site if the fleet moves onto a proper voice VLAN. |
|
||||||
|
| `VOICE_STANDARD_ENABLED` | `true` | Global kill-switch for the port-hygiene bucket. `false` silences portType / portVlan / portPoe / portEnabled while Meraki cleanup is in progress. Feature-config checks always run. |
|
||||||
|
|
||||||
|
## Apply-all-N-fixes card
|
||||||
|
|
||||||
|
When two or more checks return fixable results, `/voicediag`
|
||||||
|
posts one extra adaptive card at the bottom offering to apply the
|
||||||
|
whole batch in a single click. The individual per-issue cards stay
|
||||||
|
on-screen so operators can still pick and choose; the combined
|
||||||
|
card is a shortcut for the common "everything looks right, do it
|
||||||
|
all" case. The batch executes each fix in sequence (not parallel)
|
||||||
|
so audit lines stay readable and per-person Webex API write
|
||||||
|
throttling doesn't stack; failures accumulate into a final summary
|
||||||
|
line rather than aborting the run.
|
||||||
|
|
||||||
|
Dispatcher-side, the combined card uses the same
|
||||||
|
`pendingVoiceFixes` map and the same voicediag branch in
|
||||||
|
`index.js` — the payload's `combined: true` flag + the
|
||||||
|
`confirm_voicediag_all` / `cancel_voicediag_all` action ids are
|
||||||
|
what pick the batch handler over the single-fix handler.
|
||||||
|
|
||||||
## Adding a new check
|
## Adding a new check
|
||||||
|
|
||||||
1. Create `services/voiceDiag/checks/<newCheck>.js` and export a
|
1. Create `services/voiceDiag/checks/<newCheck>.js` and export a
|
||||||
descriptor:
|
descriptor:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
|
// The standards block is *the* source of truth for the desired
|
||||||
|
// state. Keep the run() comparison in sync — regression tests
|
||||||
|
// in tests/voiceDiag.checks.test.js assert every check has one.
|
||||||
|
export const MY_NEW_STANDARDS = Object.freeze({ enabled: false });
|
||||||
|
|
||||||
export const myNewCheck = {
|
export const myNewCheck = {
|
||||||
id: 'myNew',
|
id: 'myNew',
|
||||||
label: 'My New Check',
|
label: 'My New Check',
|
||||||
requires: ['personId'], // subset of ['personId','phoneStatus','telephonyProfile']
|
requires: ['personId'], // subset of ['personId','phoneStatus','telephonyProfile']
|
||||||
scope: 'spark-admin:people_read',
|
scope: 'spark-admin:people_read',
|
||||||
|
standards: MY_NEW_STANDARDS,
|
||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
const data = await ctx.webex.request('GET', `people/${ctx.personId}/features/whatever`);
|
const data = await ctx.webex.request('GET', `people/${ctx.personId}/features/whatever`);
|
||||||
// ...evaluate...
|
if (!!data?.enabled === MY_NEW_STANDARDS.enabled) {
|
||||||
|
return { status: 'ok', message: 'Compliant.', details: null, remediation: null };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
status: 'warn', // 'ok' | 'warn' | 'error' | 'skipped'
|
status: 'warn', // 'ok' | 'warn' | 'error' | 'skipped'
|
||||||
message: 'Human sentence for the chat row.',
|
message: 'Human sentence for the chat row.',
|
||||||
|
|
|
||||||
|
|
@ -47,11 +47,22 @@ const VARIANTS = [
|
||||||
{ key: 'noAnswer', label: 'Call Forwarding — No Answer' },
|
{ key: 'noAnswer', label: 'Call Forwarding — No Answer' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Store-line standard: no forwarding of any variant. Forwarding a
|
||||||
|
// store line silently routes calls elsewhere, which is a top cause
|
||||||
|
// of "the store phone isn't ringing" tickets. Hard rule → error
|
||||||
|
// severity (was warn in the initial cut).
|
||||||
|
export const CALL_FORWARDING_STANDARDS = Object.freeze({
|
||||||
|
always: { enabled: false },
|
||||||
|
busy: { enabled: false },
|
||||||
|
noAnswer: { enabled: false },
|
||||||
|
});
|
||||||
|
|
||||||
export const callForwardingCheck = {
|
export const callForwardingCheck = {
|
||||||
id: 'callForwarding',
|
id: 'callForwarding',
|
||||||
label: 'Call Forwarding',
|
label: 'Call Forwarding',
|
||||||
requires: ['personId'],
|
requires: ['personId'],
|
||||||
scope: 'spark-admin:people_read',
|
scope: 'spark-admin:people_read',
|
||||||
|
standards: CALL_FORWARDING_STANDARDS,
|
||||||
|
|
||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId));
|
const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId));
|
||||||
|
|
@ -91,10 +102,10 @@ export const callForwardingCheck = {
|
||||||
.join('; ');
|
.join('; ');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 'warn',
|
status: 'error',
|
||||||
message:
|
message:
|
||||||
`${active.length} forwarding ${active.length === 1 ? 'variant is' : 'variants are'} ` +
|
`${active.length} forwarding ${active.length === 1 ? 'variant is' : 'variants are'} ` +
|
||||||
`active: ${summaryLine}.`,
|
`active: ${summaryLine}. Store phone standard requires all forwarding disabled.`,
|
||||||
details: { perVariant, active: variantsToClear },
|
details: { perVariant, active: variantsToClear },
|
||||||
remediation: {
|
remediation: {
|
||||||
action: 'clear_call_forwarding',
|
action: 'clear_call_forwarding',
|
||||||
|
|
|
||||||
|
|
@ -38,11 +38,18 @@ import { describeRequester } from '../../../utils/requester.js';
|
||||||
const ENDPOINT = (personId) =>
|
const ENDPOINT = (personId) =>
|
||||||
`people/${personId}/features/intercept`;
|
`people/${personId}/features/intercept`;
|
||||||
|
|
||||||
|
// Store-line standard: intercept off. Intercept blocks calls with an
|
||||||
|
// announcement — never wanted on a live store line.
|
||||||
|
export const CALL_INTERCEPT_STANDARDS = Object.freeze({
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
export const callInterceptCheck = {
|
export const callInterceptCheck = {
|
||||||
id: 'callIntercept',
|
id: 'callIntercept',
|
||||||
label: 'Call Intercept',
|
label: 'Call Intercept',
|
||||||
requires: ['personId'],
|
requires: ['personId'],
|
||||||
scope: 'spark-admin:people_read',
|
scope: 'spark-admin:people_read',
|
||||||
|
standards: CALL_INTERCEPT_STANDARDS,
|
||||||
|
|
||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId));
|
const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId));
|
||||||
|
|
@ -50,7 +57,7 @@ export const callInterceptCheck = {
|
||||||
const incomingType = data?.incoming?.type || null;
|
const incomingType = data?.incoming?.type || null;
|
||||||
const outgoingType = data?.outgoing?.type || null;
|
const outgoingType = data?.outgoing?.type || null;
|
||||||
|
|
||||||
if (!enabled) {
|
if (enabled === CALL_INTERCEPT_STANDARDS.enabled) {
|
||||||
return {
|
return {
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
message: 'Call intercept is off.',
|
message: 'Call intercept is off.',
|
||||||
|
|
@ -91,7 +98,7 @@ export const callInterceptCheck = {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { default: webex } = await import('../../../integrations/webex/WebexClient.js');
|
const { default: webex } = await import('../../../integrations/webex/WebexClient.js');
|
||||||
await webex.request('PUT', ENDPOINT(personId), { enabled: false });
|
await webex.request('PUT', ENDPOINT(personId), { ...CALL_INTERCEPT_STANDARDS });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger(
|
logger(
|
||||||
'voicediag:audit',
|
'voicediag:audit',
|
||||||
|
|
|
||||||
|
|
@ -20,17 +20,24 @@ import { describeRequester } from '../../../utils/requester.js';
|
||||||
const ENDPOINT = (personId) =>
|
const ENDPOINT = (personId) =>
|
||||||
`people/${personId}/features/callWaiting`;
|
`people/${personId}/features/callWaiting`;
|
||||||
|
|
||||||
|
// Store-line standard: call waiting on. Second-inbound-call beeping
|
||||||
|
// in is what an operator on a call expects.
|
||||||
|
export const CALL_WAITING_STANDARDS = Object.freeze({
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
export const callWaitingCheck = {
|
export const callWaitingCheck = {
|
||||||
id: 'callWaiting',
|
id: 'callWaiting',
|
||||||
label: 'Call Waiting',
|
label: 'Call Waiting',
|
||||||
requires: ['personId'],
|
requires: ['personId'],
|
||||||
scope: 'spark-admin:people_read',
|
scope: 'spark-admin:people_read',
|
||||||
|
standards: CALL_WAITING_STANDARDS,
|
||||||
|
|
||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId));
|
const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId));
|
||||||
const enabled = !!data?.enabled;
|
const enabled = !!data?.enabled;
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled === CALL_WAITING_STANDARDS.enabled) {
|
||||||
return {
|
return {
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
message: 'Call waiting is on.',
|
message: 'Call waiting is on.',
|
||||||
|
|
@ -70,7 +77,7 @@ export const callWaitingCheck = {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { default: webex } = await import('../../../integrations/webex/WebexClient.js');
|
const { default: webex } = await import('../../../integrations/webex/WebexClient.js');
|
||||||
await webex.request('PUT', ENDPOINT(personId), { enabled: true });
|
await webex.request('PUT', ENDPOINT(personId), { ...CALL_WAITING_STANDARDS });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger(
|
logger(
|
||||||
'voicediag:audit',
|
'voicediag:audit',
|
||||||
|
|
|
||||||
|
|
@ -31,11 +31,21 @@ import { describeRequester } from '../../../utils/requester.js';
|
||||||
const ENDPOINT = (personId) =>
|
const ENDPOINT = (personId) =>
|
||||||
`people/${personId}/features/doNotDisturb`;
|
`people/${personId}/features/doNotDisturb`;
|
||||||
|
|
||||||
|
// The desired-state contract for a store phone user. Exposed as a
|
||||||
|
// top-level field on the check descriptor so the "voice standards"
|
||||||
|
// reference table + regression tests can inspect it without reading
|
||||||
|
// run() logic. Change the values here, not in the run() body.
|
||||||
|
export const DND_STANDARDS = Object.freeze({
|
||||||
|
enabled: false,
|
||||||
|
ringSplashEnabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
export const dndCheck = {
|
export const dndCheck = {
|
||||||
id: 'dnd',
|
id: 'dnd',
|
||||||
label: 'Do Not Disturb',
|
label: 'Do Not Disturb',
|
||||||
requires: ['personId'],
|
requires: ['personId'],
|
||||||
scope: 'spark-admin:people_read',
|
scope: 'spark-admin:people_read',
|
||||||
|
standards: DND_STANDARDS,
|
||||||
|
|
||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId));
|
const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId));
|
||||||
|
|
@ -43,7 +53,13 @@ export const dndCheck = {
|
||||||
const enabled = !!data?.enabled;
|
const enabled = !!data?.enabled;
|
||||||
const ringSplashEnabled = !!data?.ringSplashEnabled;
|
const ringSplashEnabled = !!data?.ringSplashEnabled;
|
||||||
|
|
||||||
if (!enabled) {
|
// Compliance = DND off. ringSplashEnabled only matters when DND
|
||||||
|
// is on (per Webex spec — it's the "visual reminder" toggle for
|
||||||
|
// splash notifications while calls are being silenced), so we
|
||||||
|
// don't count a stale ringSplash=true as a deviation while
|
||||||
|
// DND is already off. The standard's ringSplashEnabled=false is
|
||||||
|
// the value we PUT during remediation, not an independent rule.
|
||||||
|
if (enabled === DND_STANDARDS.enabled) {
|
||||||
return {
|
return {
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
message: 'DND is off.',
|
message: 'DND is off.',
|
||||||
|
|
@ -89,10 +105,7 @@ export const dndCheck = {
|
||||||
// singleton via ctx.webex in run(), while the remediation path
|
// singleton via ctx.webex in run(), while the remediation path
|
||||||
// stays honest about which client it uses in production.
|
// stays honest about which client it uses in production.
|
||||||
const { default: webex } = await import('../../../integrations/webex/WebexClient.js');
|
const { default: webex } = await import('../../../integrations/webex/WebexClient.js');
|
||||||
await webex.request('PUT', ENDPOINT(personId), {
|
await webex.request('PUT', ENDPOINT(personId), { ...DND_STANDARDS });
|
||||||
enabled: false,
|
|
||||||
ringSplashEnabled: false,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger(
|
logger(
|
||||||
'voicediag:audit',
|
'voicediag:audit',
|
||||||
|
|
|
||||||
|
|
@ -23,17 +23,27 @@
|
||||||
const ENDPOINT = (personId) =>
|
const ENDPOINT = (personId) =>
|
||||||
`people/${personId}/features/executiveAssistant`;
|
`people/${personId}/features/executiveAssistant`;
|
||||||
|
|
||||||
|
// Store-line standard: UNASSIGNED. Being an executive or exec-
|
||||||
|
// assistant on a store line routes calls through screening. No
|
||||||
|
// auto-remediation offered — unassigning has downstream implications
|
||||||
|
// (the assistant relationship needs cleanup on both sides), so we
|
||||||
|
// surface as info-only and let the operator handle it in Control Hub.
|
||||||
|
export const EXECUTIVE_ASSISTANT_STANDARDS = Object.freeze({
|
||||||
|
type: 'UNASSIGNED',
|
||||||
|
});
|
||||||
|
|
||||||
export const executiveAssistantCheck = {
|
export const executiveAssistantCheck = {
|
||||||
id: 'executiveAssistant',
|
id: 'executiveAssistant',
|
||||||
label: 'Executive / Assistant',
|
label: 'Executive / Assistant',
|
||||||
requires: ['personId'],
|
requires: ['personId'],
|
||||||
scope: 'spark-admin:people_read',
|
scope: 'spark-admin:people_read',
|
||||||
|
standards: EXECUTIVE_ASSISTANT_STANDARDS,
|
||||||
|
|
||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId));
|
const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId));
|
||||||
const type = data?.type || 'UNASSIGNED';
|
const type = data?.type || 'UNASSIGNED';
|
||||||
|
|
||||||
if (type === 'UNASSIGNED') {
|
if (type === EXECUTIVE_ASSISTANT_STANDARDS.type) {
|
||||||
return {
|
return {
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
message: 'No executive / assistant relationship (normal for a store line).',
|
message: 'No executive / assistant relationship (normal for a store line).',
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,42 @@
|
||||||
// src/services/voiceDiag/checks/hoteling.js
|
// src/services/voiceDiag/checks/hoteling.js
|
||||||
//
|
//
|
||||||
// Info-only check for the Hoteling feature on the store user's line.
|
// Detects whether the store user's line is participating in the
|
||||||
// Hoteling lets a "guest" line temporarily associate with a shared
|
// Hoteling feature as a guest. Hoteling lets a "guest" line
|
||||||
// desk phone — if it's turned on for a store user that shouldn't have
|
// temporarily associate with a shared desk phone — if it's turned on
|
||||||
// it, calls can end up at whatever guest device most recently checked
|
// for a store user that shouldn't have it, calls can end up at
|
||||||
// in, which usually presents as "the phone at the store isn't the
|
// whatever guest device most recently checked in, which usually
|
||||||
// one that rings when we call". No remediation is offered because
|
// presents as "the phone at the store isn't the one that rings when
|
||||||
// the intended state is site-dependent; the operator can flip it via
|
// we call".
|
||||||
// Control Hub if the current state is wrong.
|
|
||||||
//
|
//
|
||||||
// Endpoint: GET /v1/people/{personId}/features/hoteling
|
// Endpoint: GET/PUT /v1/people/{personId}/features/hoteling
|
||||||
// Scope: spark-admin:people_read
|
// Scope: spark-admin:people_read (GET) + spark-admin:people_write (PUT).
|
||||||
// Response shape: { enabled: boolean }
|
// Response shape: { enabled: boolean }
|
||||||
|
//
|
||||||
|
// Standard: enabled=false. Store lines shouldn't be roaming to
|
||||||
|
// shared endpoints. Remediation turns it off.
|
||||||
|
|
||||||
|
import { logger } from '../../../utils/logger.js';
|
||||||
|
import { describeRequester } from '../../../utils/requester.js';
|
||||||
|
|
||||||
const ENDPOINT = (personId) =>
|
const ENDPOINT = (personId) =>
|
||||||
`people/${personId}/features/hoteling`;
|
`people/${personId}/features/hoteling`;
|
||||||
|
|
||||||
|
export const HOTELING_STANDARDS = Object.freeze({
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
export const hotelingCheck = {
|
export const hotelingCheck = {
|
||||||
id: 'hoteling',
|
id: 'hoteling',
|
||||||
label: 'Hoteling',
|
label: 'Hoteling',
|
||||||
requires: ['personId'],
|
requires: ['personId'],
|
||||||
scope: 'spark-admin:people_read',
|
scope: 'spark-admin:people_read',
|
||||||
|
standards: HOTELING_STANDARDS,
|
||||||
|
|
||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId));
|
const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId));
|
||||||
const enabled = !!data?.enabled;
|
const enabled = !!data?.enabled;
|
||||||
|
|
||||||
if (!enabled) {
|
if (enabled === HOTELING_STANDARDS.enabled) {
|
||||||
return {
|
return {
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
message: 'Hoteling is disabled (normal for a store line).',
|
message: 'Hoteling is disabled (normal for a store line).',
|
||||||
|
|
@ -38,9 +48,57 @@ export const hotelingCheck = {
|
||||||
return {
|
return {
|
||||||
status: 'warn',
|
status: 'warn',
|
||||||
message:
|
message:
|
||||||
'Hoteling is ENABLED — this line may be roaming to another desk phone. Verify in Control Hub.',
|
'Hoteling is ENABLED — this line may be roaming to another desk phone.',
|
||||||
details: { enabled },
|
details: { enabled },
|
||||||
remediation: null,
|
remediation: {
|
||||||
};
|
action: 'disable_hoteling',
|
||||||
|
title: 'Disable Hoteling',
|
||||||
|
summary: `Turn hoteling off for ${ctx.personLabel} so this line stops roaming.`,
|
||||||
|
payload: {
|
||||||
|
personId: ctx.personId,
|
||||||
|
personLabel: ctx.personLabel,
|
||||||
|
storeNum: ctx.storeNum,
|
||||||
|
before: { enabled },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
remediations: {
|
||||||
|
async disable_hoteling(bot, data, requester) {
|
||||||
|
const { personId, personLabel, storeNum, before } = data;
|
||||||
|
|
||||||
|
logger(
|
||||||
|
'voicediag:audit',
|
||||||
|
`CONFIRMED disable_hoteling for ${personLabel} (person=${personId}, store=${storeNum}) ` +
|
||||||
|
`by ${describeRequester(requester)} — was enabled=${before?.enabled ?? 'unknown'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { default: webex } = await import('../../../integrations/webex/WebexClient.js');
|
||||||
|
await webex.request('PUT', ENDPOINT(personId), { ...HOTELING_STANDARDS });
|
||||||
|
} catch (err) {
|
||||||
|
logger(
|
||||||
|
'voicediag:audit',
|
||||||
|
`FAILED disable_hoteling for ${personLabel}: ${err.message}`,
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
`❌ Failed to disable hoteling for **${personLabel}**: ${err.message}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
`✅ Hoteling disabled for **${personLabel}** (store ${storeNum}). ` +
|
||||||
|
`Re-run \`/voicediag ${storeNum}\` to verify.`,
|
||||||
|
);
|
||||||
|
logger(
|
||||||
|
'voicediag:audit',
|
||||||
|
`COMPLETED disable_hoteling for ${personLabel} (store ${storeNum})`,
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,15 @@ import { hotelingCheck } from './hoteling.js';
|
||||||
import { executiveAssistantCheck } from './executiveAssistant.js';
|
import { executiveAssistantCheck } from './executiveAssistant.js';
|
||||||
import { outgoingPermissionCheck } from './outgoingPermission.js';
|
import { outgoingPermissionCheck } from './outgoingPermission.js';
|
||||||
import { phoneOnlineCheck } from './phoneOnline.js';
|
import { phoneOnlineCheck } from './phoneOnline.js';
|
||||||
|
import { portTypeCheck } from './port/portType.js';
|
||||||
|
import { portVlanCheck } from './port/portVlan.js';
|
||||||
|
import { portPoeCheck } from './port/portPoe.js';
|
||||||
|
import { portEnabledCheck } from './port/portEnabled.js';
|
||||||
|
|
||||||
|
// Order: user-facing feature signals first (things an operator can
|
||||||
|
// see from the phone UI), then network-side port hygiene, then the
|
||||||
|
// broad-brush online summary last so it acts as a reachability
|
||||||
|
// closer.
|
||||||
export const CHECKS = [
|
export const CHECKS = [
|
||||||
dndCheck,
|
dndCheck,
|
||||||
callForwardingCheck,
|
callForwardingCheck,
|
||||||
|
|
@ -49,6 +57,10 @@ export const CHECKS = [
|
||||||
hotelingCheck,
|
hotelingCheck,
|
||||||
executiveAssistantCheck,
|
executiveAssistantCheck,
|
||||||
outgoingPermissionCheck,
|
outgoingPermissionCheck,
|
||||||
|
portTypeCheck,
|
||||||
|
portVlanCheck,
|
||||||
|
portPoeCheck,
|
||||||
|
portEnabledCheck,
|
||||||
phoneOnlineCheck,
|
phoneOnlineCheck,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,11 +41,21 @@ const HIGH_IMPACT_CALL_TYPES = new Set([
|
||||||
'INTERNATIONAL',
|
'INTERNATIONAL',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Store-line standard: no HIGH_IMPACT_CALL_TYPE may be BLOCKED. Any
|
||||||
|
// other rule (BLOCK on premium/casual/operator, ALLOW on everything)
|
||||||
|
// is fine. Documented as data so the standards reference table +
|
||||||
|
// tests can enumerate what "compliant" means.
|
||||||
|
export const OUTGOING_PERMISSION_STANDARDS = Object.freeze({
|
||||||
|
highImpactCallTypes: Array.from(HIGH_IMPACT_CALL_TYPES),
|
||||||
|
highImpactBlockedAllowed: false,
|
||||||
|
});
|
||||||
|
|
||||||
export const outgoingPermissionCheck = {
|
export const outgoingPermissionCheck = {
|
||||||
id: 'outgoingPermission',
|
id: 'outgoingPermission',
|
||||||
label: 'Outgoing Call Permissions',
|
label: 'Outgoing Call Permissions',
|
||||||
requires: ['personId'],
|
requires: ['personId'],
|
||||||
scope: 'spark-admin:people_read',
|
scope: 'spark-admin:people_read',
|
||||||
|
standards: OUTGOING_PERMISSION_STANDARDS,
|
||||||
|
|
||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId));
|
const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId));
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,13 @@ function isOnline(dev) {
|
||||||
return OK_STATUSES.has(s);
|
return OK_STATUSES.has(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const PHONE_ONLINE_STANDARDS = Object.freeze({
|
||||||
|
// Every registered device must report 'connected'. Anything else
|
||||||
|
// (disconnected, unknown, activating, offline) is treated as
|
||||||
|
// offline.
|
||||||
|
status: 'connected',
|
||||||
|
});
|
||||||
|
|
||||||
export const phoneOnlineCheck = {
|
export const phoneOnlineCheck = {
|
||||||
id: 'phoneOnline',
|
id: 'phoneOnline',
|
||||||
label: 'Phone Online Status',
|
label: 'Phone Online Status',
|
||||||
|
|
@ -38,6 +45,7 @@ export const phoneOnlineCheck = {
|
||||||
// No new API scope required — reuses collectPhoneStatus() output,
|
// No new API scope required — reuses collectPhoneStatus() output,
|
||||||
// which is already gated on the /phonestatus set of scopes.
|
// which is already gated on the /phonestatus set of scopes.
|
||||||
scope: null,
|
scope: null,
|
||||||
|
standards: PHONE_ONLINE_STANDARDS,
|
||||||
|
|
||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
const data = ctx.phoneStatus;
|
const data = ctx.phoneStatus;
|
||||||
|
|
|
||||||
77
services/voiceDiag/checks/port/_helpers.js
Normal file
77
services/voiceDiag/checks/port/_helpers.js
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
// src/services/voiceDiag/checks/port/_helpers.js
|
||||||
|
//
|
||||||
|
// Shared helpers for the port-hygiene check bucket. Each of the port
|
||||||
|
// checks (portType, portVlan, portPoe, portEnabled) walks the same
|
||||||
|
// per-phone / per-DECT-base list out of ctx.phoneStatus and reports
|
||||||
|
// a per-device verdict. Centralising the walk + labelling here keeps
|
||||||
|
// the individual check modules focused on their single signal.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect every device that could plausibly have a wired port. This
|
||||||
|
* is desk phones + DECT basestations. DECT handsets are excluded —
|
||||||
|
* they're radio-connected to their base, they don't have their own
|
||||||
|
* switchport. Wireless desk phones aren't filtered out here; the
|
||||||
|
* per-check `isWireless(d)` guard decides how to render them.
|
||||||
|
*
|
||||||
|
* @param {object} phoneStatus collectPhoneStatus() result
|
||||||
|
* @returns {Array<object>} each entry retains its full shape so
|
||||||
|
* checks can reach `.meraki.*` freely.
|
||||||
|
*/
|
||||||
|
export function collectPortDevices(phoneStatus) {
|
||||||
|
if (!phoneStatus) return [];
|
||||||
|
const phones = Array.isArray(phoneStatus?.phones?.data) ? phoneStatus.phones.data : [];
|
||||||
|
const dectBases = Array.isArray(phoneStatus?.dectBasestations) ? phoneStatus.dectBasestations : [];
|
||||||
|
const tag = (kind) => (d) => ({ ...d, _voiceDiagKind: kind });
|
||||||
|
return [
|
||||||
|
...phones.map(tag('phone')),
|
||||||
|
...dectBases.map(tag('dect-base')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the device is on wireless — no switchport to inspect. */
|
||||||
|
export function isWireless(dev) {
|
||||||
|
const conn = String(dev?.meraki?.connectionType || '').toLowerCase();
|
||||||
|
if (conn === 'wireless') return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global kill-switch for the port-hygiene bucket. Env
|
||||||
|
* `VOICE_STANDARD_ENABLED=false` returns a short "port checks are
|
||||||
|
* currently disabled" skip result that every port check can return
|
||||||
|
* verbatim. Feature-config checks (DND, forwarding, etc.) are
|
||||||
|
* intentionally *not* gated by this — those are unconditional
|
||||||
|
* standards. Only the switchport bucket bows out.
|
||||||
|
*
|
||||||
|
* The check descriptor is passed in so the returned result carries
|
||||||
|
* the caller's id / label without callers repeating themselves.
|
||||||
|
*/
|
||||||
|
export function maybeSkippedByKillSwitch(check) {
|
||||||
|
const raw = String(process.env.VOICE_STANDARD_ENABLED ?? 'true').toLowerCase().trim();
|
||||||
|
const enabled = !(raw === 'false' || raw === '0' || raw === 'no' || raw === 'off');
|
||||||
|
if (enabled) return null;
|
||||||
|
return {
|
||||||
|
status: 'skipped',
|
||||||
|
message:
|
||||||
|
`${check.label} skipped — VOICE_STANDARD_ENABLED=false (port-hygiene checks are silenced).`,
|
||||||
|
details: { killSwitch: 'VOICE_STANDARD_ENABLED', value: raw },
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human labels used in per-device drilldowns. Kept short so
|
||||||
|
* aggregate messages don't blow past Webex's chat readability. */
|
||||||
|
export function portLabelFor(dev) {
|
||||||
|
const kind = dev?._voiceDiagKind || 'device';
|
||||||
|
const deviceLabel = dev?.name || dev?.mac || (kind === 'dect-base' ? 'DECT base' : 'Phone');
|
||||||
|
const switchName = dev?.meraki?.switchName || dev?.meraki?.deviceName || '—';
|
||||||
|
const portName = dev?.meraki?.portName || dev?.meraki?.port || '—';
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
deviceLabel,
|
||||||
|
switchName,
|
||||||
|
portName,
|
||||||
|
portLabel: `${switchName}:${portName}`,
|
||||||
|
mac: dev?.mac || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
99
services/voiceDiag/checks/port/portEnabled.js
Normal file
99
services/voiceDiag/checks/port/portEnabled.js
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
// src/services/voiceDiag/checks/port/portEnabled.js
|
||||||
|
//
|
||||||
|
// Store phone standard: administratively enabled on every wired-
|
||||||
|
// phone switchport. If the port is admin-disabled the phone
|
||||||
|
// obviously won't work — this is a common footgun when a tech
|
||||||
|
// disables the wrong port during troubleshooting.
|
||||||
|
//
|
||||||
|
// Env kill-switch (shared with the other port checks) —
|
||||||
|
// VOICE_STANDARD_ENABLED=false → check reports skipped
|
||||||
|
// so operators can silence all port-hygiene noise while the
|
||||||
|
// underlying Meraki state is being cleaned up.
|
||||||
|
//
|
||||||
|
// Skip rules:
|
||||||
|
// - Wireless: skipped.
|
||||||
|
// - Trunk uplink: skipped (owned by portType check).
|
||||||
|
// - No portEnabled field at all: reported as unknown.
|
||||||
|
|
||||||
|
import { collectPortDevices, isWireless, portLabelFor, maybeSkippedByKillSwitch } from './_helpers.js';
|
||||||
|
|
||||||
|
export const PORT_ENABLED_STANDARDS = Object.freeze({
|
||||||
|
portEnabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const portEnabledCheck = {
|
||||||
|
id: 'portEnabled',
|
||||||
|
label: 'Switchport Admin State',
|
||||||
|
requires: ['phoneStatus'],
|
||||||
|
scope: null,
|
||||||
|
standards: PORT_ENABLED_STANDARDS,
|
||||||
|
|
||||||
|
async run(ctx) {
|
||||||
|
const skip = maybeSkippedByKillSwitch(portEnabledCheck);
|
||||||
|
if (skip) return skip;
|
||||||
|
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), portEnabled: null, verdict: 'wireless' };
|
||||||
|
}
|
||||||
|
const portType = d.meraki?.portType;
|
||||||
|
if (portType && String(portType).toLowerCase() === 'trunk') {
|
||||||
|
return { ...portLabelFor(d), portEnabled: null, verdict: 'trunk-skip' };
|
||||||
|
}
|
||||||
|
const enabled = d.meraki?.portEnabled;
|
||||||
|
if (enabled === undefined || enabled === null) {
|
||||||
|
return { ...portLabelFor(d), portEnabled: null, verdict: 'unknown' };
|
||||||
|
}
|
||||||
|
return { ...portLabelFor(d), portEnabled: !!enabled, verdict: enabled ? 'compliant' : 'disabled' };
|
||||||
|
});
|
||||||
|
|
||||||
|
const disabled = perDevice.filter((r) => r.verdict === 'disabled');
|
||||||
|
const unknown = perDevice.filter((r) => r.verdict === 'unknown');
|
||||||
|
|
||||||
|
const details = {
|
||||||
|
total: perDevice.length,
|
||||||
|
compliant: perDevice.filter((r) => r.verdict === 'compliant').length,
|
||||||
|
disabled: disabled.length,
|
||||||
|
unknown: unknown.length,
|
||||||
|
wireless: perDevice.filter((r) => r.verdict === 'wireless').length,
|
||||||
|
trunkSkipped: perDevice.filter((r) => r.verdict === 'trunk-skip').length,
|
||||||
|
offenders: disabled,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (disabled.length > 0) {
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
message:
|
||||||
|
`${disabled.length} device(s) on ADMIN-DISABLED switchports (phone will not work): ` +
|
||||||
|
disabled.map((r) => `${r.deviceLabel}@${r.portLabel}`).join(', '),
|
||||||
|
details,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unknown.length > 0) {
|
||||||
|
return {
|
||||||
|
status: 'warn',
|
||||||
|
message: `${unknown.length} device(s) have no admin-state info from Meraki.`,
|
||||||
|
details,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
message: `All ${details.compliant} wired switchports admin-enabled.`,
|
||||||
|
details,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
96
services/voiceDiag/checks/port/portPoe.js
Normal file
96
services/voiceDiag/checks/port/portPoe.js
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
// src/services/voiceDiag/checks/port/portPoe.js
|
||||||
|
//
|
||||||
|
// Store phone standard: PoE enabled on every wired-phone switchport.
|
||||||
|
// Store desk phones + DECT basestations are all PoE-powered; PoE
|
||||||
|
// off on the port is either a mis-provisioned port or a phone that
|
||||||
|
// won't come up after the next power cycle.
|
||||||
|
//
|
||||||
|
// Skip rules:
|
||||||
|
// - Wireless: skipped.
|
||||||
|
// - Trunk uplink: skipped (portType check owns that warning; PoE
|
||||||
|
// policy for a downstream Cisco switch isn't ours to evaluate).
|
||||||
|
// - No poeEnabled field at all (Meraki didn't return it or the
|
||||||
|
// switch model doesn't do PoE): reported as unknown.
|
||||||
|
|
||||||
|
import { collectPortDevices, isWireless, portLabelFor, maybeSkippedByKillSwitch } from './_helpers.js';
|
||||||
|
|
||||||
|
export const PORT_POE_STANDARDS = Object.freeze({
|
||||||
|
poeEnabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const portPoeCheck = {
|
||||||
|
id: 'portPoe',
|
||||||
|
label: 'Switchport PoE',
|
||||||
|
requires: ['phoneStatus'],
|
||||||
|
scope: null,
|
||||||
|
standards: PORT_POE_STANDARDS,
|
||||||
|
|
||||||
|
async run(ctx) {
|
||||||
|
const skip = maybeSkippedByKillSwitch(portPoeCheck);
|
||||||
|
if (skip) return skip;
|
||||||
|
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), poeEnabled: null, verdict: 'wireless' };
|
||||||
|
}
|
||||||
|
const portType = d.meraki?.portType;
|
||||||
|
if (portType && String(portType).toLowerCase() === 'trunk') {
|
||||||
|
return { ...portLabelFor(d), poeEnabled: null, verdict: 'trunk-skip' };
|
||||||
|
}
|
||||||
|
const poe = d.meraki?.poeEnabled;
|
||||||
|
if (poe === undefined || poe === null) {
|
||||||
|
return { ...portLabelFor(d), poeEnabled: null, verdict: 'unknown' };
|
||||||
|
}
|
||||||
|
return { ...portLabelFor(d), poeEnabled: !!poe, verdict: poe ? 'compliant' : 'off' };
|
||||||
|
});
|
||||||
|
|
||||||
|
const off = perDevice.filter((r) => r.verdict === 'off');
|
||||||
|
const unknown = perDevice.filter((r) => r.verdict === 'unknown');
|
||||||
|
|
||||||
|
const details = {
|
||||||
|
total: perDevice.length,
|
||||||
|
compliant: perDevice.filter((r) => r.verdict === 'compliant').length,
|
||||||
|
off: off.length,
|
||||||
|
unknown: unknown.length,
|
||||||
|
wireless: perDevice.filter((r) => r.verdict === 'wireless').length,
|
||||||
|
trunkSkipped: perDevice.filter((r) => r.verdict === 'trunk-skip').length,
|
||||||
|
offenders: off,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (off.length > 0) {
|
||||||
|
return {
|
||||||
|
status: 'warn',
|
||||||
|
message:
|
||||||
|
`${off.length} device(s) on switchports with PoE DISABLED: ` +
|
||||||
|
off.map((r) => `${r.deviceLabel}@${r.portLabel}`).join(', '),
|
||||||
|
details,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unknown.length > 0) {
|
||||||
|
return {
|
||||||
|
status: 'warn',
|
||||||
|
message: `${unknown.length} device(s) have no PoE info from Meraki.`,
|
||||||
|
details,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
message: `All ${details.compliant} wired device(s) on PoE-enabled ports.`,
|
||||||
|
details,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
103
services/voiceDiag/checks/port/portType.js
Normal file
103
services/voiceDiag/checks/port/portType.js
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
// src/services/voiceDiag/checks/port/portType.js
|
||||||
|
//
|
||||||
|
// Store phone standard: every desk phone / DECT base is connected to
|
||||||
|
// a Meraki switch on an ACCESS port. When a phone shows up on a
|
||||||
|
// TRUNK port, that means the phone is downstream through a non-
|
||||||
|
// Meraki switch (typically a Cisco stack in a store), and our
|
||||||
|
// per-port config visibility ends at the trunk uplink. We can't
|
||||||
|
// enforce anything past that point — this check surfaces the
|
||||||
|
// visibility gap so the operator can go check the Cisco side manually.
|
||||||
|
//
|
||||||
|
// Data source: `ctx.phoneStatus.phones.data[i].meraki.portType` and
|
||||||
|
// `.dectBasestations[i].meraki.portType`, populated by the Meraki
|
||||||
|
// enrichment layer from `/devices/{serial}/switch/ports.type`.
|
||||||
|
//
|
||||||
|
// No API calls. No remediation — Meraki port-config PUT is a
|
||||||
|
// separate scope of work; the operator handles trunk-side changes
|
||||||
|
// in Meraki Dashboard.
|
||||||
|
//
|
||||||
|
// Skip rules:
|
||||||
|
// - Wireless devices (no switchport): skipped silently.
|
||||||
|
// - Devices with no meraki.portType at all (Meraki didn't match
|
||||||
|
// the client to a port): skipped and mentioned in details.
|
||||||
|
|
||||||
|
import { collectPortDevices, isWireless, portLabelFor, maybeSkippedByKillSwitch } from './_helpers.js';
|
||||||
|
|
||||||
|
export const PORT_TYPE_STANDARDS = Object.freeze({
|
||||||
|
portType: 'access',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const portTypeCheck = {
|
||||||
|
id: 'portType',
|
||||||
|
label: 'Switchport Type',
|
||||||
|
requires: ['phoneStatus'],
|
||||||
|
scope: null,
|
||||||
|
standards: PORT_TYPE_STANDARDS,
|
||||||
|
|
||||||
|
async run(ctx) {
|
||||||
|
const skip = maybeSkippedByKillSwitch(portTypeCheck);
|
||||||
|
if (skip) return skip;
|
||||||
|
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), portType: null, verdict: 'wireless' };
|
||||||
|
}
|
||||||
|
const t = d.meraki?.portType || null;
|
||||||
|
if (!t) {
|
||||||
|
return { ...portLabelFor(d), portType: null, verdict: 'unknown' };
|
||||||
|
}
|
||||||
|
const verdict = t.toLowerCase() === PORT_TYPE_STANDARDS.portType ? 'compliant' : 'trunk';
|
||||||
|
return { ...portLabelFor(d), portType: t, verdict };
|
||||||
|
});
|
||||||
|
|
||||||
|
const trunks = perDevice.filter((r) => r.verdict === 'trunk');
|
||||||
|
const unknown = perDevice.filter((r) => r.verdict === 'unknown');
|
||||||
|
|
||||||
|
const details = {
|
||||||
|
total: perDevice.length,
|
||||||
|
compliant: perDevice.filter((r) => r.verdict === 'compliant').length,
|
||||||
|
trunks: trunks.length,
|
||||||
|
unknown: unknown.length,
|
||||||
|
wireless: perDevice.filter((r) => r.verdict === 'wireless').length,
|
||||||
|
offenders: [...trunks, ...unknown],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (trunks.length > 0) {
|
||||||
|
return {
|
||||||
|
status: 'warn',
|
||||||
|
message:
|
||||||
|
`${trunks.length} device(s) on TRUNK uplinks — downstream config not visible. ` +
|
||||||
|
trunks.map((t) => `${t.deviceLabel}@${t.portLabel}`).join(', ') +
|
||||||
|
`. Verify per-port config on the Cisco side manually.`,
|
||||||
|
details,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unknown.length > 0) {
|
||||||
|
return {
|
||||||
|
status: 'warn',
|
||||||
|
message:
|
||||||
|
`${unknown.length} device(s) have no Meraki port information — either not matched or port not indexed.`,
|
||||||
|
details,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
message: `All ${perDevice.filter((r) => r.verdict === 'compliant').length} wired device(s) on access ports.`,
|
||||||
|
details,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
121
services/voiceDiag/checks/port/portVlan.js
Normal file
121
services/voiceDiag/checks/port/portVlan.js
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -1,22 +1,23 @@
|
||||||
// src/services/voiceDiag/checks/voicemail.js
|
// src/services/voiceDiag/checks/voicemail.js
|
||||||
//
|
//
|
||||||
// Detects voicemail configuration for the store user. Voicemail has
|
// Detects voicemail configuration for the store user. Store-line
|
||||||
// enough sub-facets that we surface the current settings without
|
// standard is:
|
||||||
// offering a one-click remediation — the "right" answer for a store
|
|
||||||
// is site-specific (some sites disable VM entirely and forward busy
|
|
||||||
// to the AA, others rely on it as the noAnswer target). We flag two
|
|
||||||
// classes of finding:
|
|
||||||
//
|
//
|
||||||
// - **error**: enabled=true AND no PIN set — the user cannot pick
|
// - voicemail ENABLED (so the user can access the box directly if
|
||||||
// up messages, and every caller who reaches VM will be dumped
|
// they ever need to), but
|
||||||
// into the "please set your PIN" prompt.
|
// - NO call should ever be routed to voicemail — the three
|
||||||
// - **warn**: enabled=true AND a forward-to-email target is
|
// "send to VM" triggers (sendAllCalls / sendBusyCalls /
|
||||||
// configured that doesn't look like an @ae.com address — mail
|
// sendUnansweredCalls) must all be OFF.
|
||||||
// forwarding of voicemail off-org is worth double-checking.
|
// - MWI (message-waiting indicator) on so any manually-left
|
||||||
// - **ok**: everything else.
|
// messages light up the phone.
|
||||||
//
|
//
|
||||||
// Endpoint: GET /v1/people/{personId}/features/voicemail
|
// This is stricter than the initial cut. The rationale: at AE, a
|
||||||
// Scope: spark-admin:people_read
|
// store phone that goes to VM is a lost customer call. Better for
|
||||||
|
// the phone to keep ringing (or drop) than to silently swallow the
|
||||||
|
// call into a VM box nobody checks.
|
||||||
|
//
|
||||||
|
// Endpoint: GET/PUT /v1/people/{personId}/features/voicemail
|
||||||
|
// Scope: spark-admin:people_read (GET) + spark-admin:people_write (PUT).
|
||||||
// Response shape (relevant fields):
|
// Response shape (relevant fields):
|
||||||
// {
|
// {
|
||||||
// enabled: true,
|
// enabled: true,
|
||||||
|
|
@ -34,23 +35,32 @@
|
||||||
// faxMessage: { ... }
|
// faxMessage: { ... }
|
||||||
// }
|
// }
|
||||||
//
|
//
|
||||||
// Webex doesn't currently expose PIN-set status via the public API,
|
// PUT semantics: partial updates work — you can send just the
|
||||||
// so "PIN set" is inferred pragmatically: we check whether the person
|
// send-*Calls sub-blocks and the other fields are preserved.
|
||||||
// has ever accessed their voicemail (via passcode lastChanged if the
|
// Remediation takes advantage of that to make a minimal-touch PUT.
|
||||||
// endpoint returns it) or, as a fallback, we simply note that PIN
|
|
||||||
// state is unknown and treat it as informational only. See the note
|
import { logger } from '../../../utils/logger.js';
|
||||||
// inline below.
|
import { describeRequester } from '../../../utils/requester.js';
|
||||||
|
|
||||||
const ENDPOINT = (personId) =>
|
const ENDPOINT = (personId) =>
|
||||||
`people/${personId}/features/voicemail`;
|
`people/${personId}/features/voicemail`;
|
||||||
|
|
||||||
const AE_EMAIL_RE = /@ae\.com$/i;
|
const AE_EMAIL_RE = /@ae\.com$/i;
|
||||||
|
|
||||||
|
export const VOICEMAIL_STANDARDS = Object.freeze({
|
||||||
|
enabled: true,
|
||||||
|
sendAllCalls: { enabled: false },
|
||||||
|
sendBusyCalls: { enabled: false },
|
||||||
|
sendUnansweredCalls: { enabled: false },
|
||||||
|
messageStorage: { mwiEnabled: true },
|
||||||
|
});
|
||||||
|
|
||||||
export const voicemailCheck = {
|
export const voicemailCheck = {
|
||||||
id: 'voicemail',
|
id: 'voicemail',
|
||||||
label: 'Voicemail',
|
label: 'Voicemail',
|
||||||
requires: ['personId'],
|
requires: ['personId'],
|
||||||
scope: 'spark-admin:people_read',
|
scope: 'spark-admin:people_read',
|
||||||
|
standards: VOICEMAIL_STANDARDS,
|
||||||
|
|
||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId));
|
const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId));
|
||||||
|
|
@ -63,6 +73,15 @@ export const voicemailCheck = {
|
||||||
const externalEmail = storage?.externalEmail || '';
|
const externalEmail = storage?.externalEmail || '';
|
||||||
const forwardEmailTarget = emailCopy?.enabled ? (emailCopy?.emailId || '') : '';
|
const forwardEmailTarget = emailCopy?.enabled ? (emailCopy?.emailId || '') : '';
|
||||||
|
|
||||||
|
const sendAllCallsEnabled = !!data?.sendAllCalls?.enabled;
|
||||||
|
const sendBusyCallsEnabled = !!data?.sendBusyCalls?.enabled;
|
||||||
|
const sendUnansweredCallsEnabled = !!data?.sendUnansweredCalls?.enabled;
|
||||||
|
|
||||||
|
const activeSendTriggers = [];
|
||||||
|
if (sendAllCallsEnabled) activeSendTriggers.push('sendAllCalls');
|
||||||
|
if (sendBusyCallsEnabled) activeSendTriggers.push('sendBusyCalls');
|
||||||
|
if (sendUnansweredCallsEnabled) activeSendTriggers.push('sendUnansweredCalls');
|
||||||
|
|
||||||
const details = {
|
const details = {
|
||||||
enabled,
|
enabled,
|
||||||
mwiEnabled,
|
mwiEnabled,
|
||||||
|
|
@ -72,31 +91,62 @@ export const voicemailCheck = {
|
||||||
emailCopyTarget: forwardEmailTarget,
|
emailCopyTarget: forwardEmailTarget,
|
||||||
transferToEnabled: !!transferTo?.enabled,
|
transferToEnabled: !!transferTo?.enabled,
|
||||||
transferToDestination: transferTo?.destination || null,
|
transferToDestination: transferTo?.destination || null,
|
||||||
sendAllCallsEnabled: !!data?.sendAllCalls?.enabled,
|
sendAllCallsEnabled,
|
||||||
sendBusyCallsEnabled: !!data?.sendBusyCalls?.enabled,
|
sendBusyCallsEnabled,
|
||||||
sendUnansweredCallsEnabled: !!data?.sendUnansweredCalls?.enabled,
|
sendUnansweredCallsEnabled,
|
||||||
|
activeSendTriggers,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Hard rule #1: VM enabled at all. If VM is disabled we surface
|
||||||
|
// it as a warn rather than an error — some sites intentionally
|
||||||
|
// disable VM and route unanswered calls elsewhere, so we don't
|
||||||
|
// auto-remediate.
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
return {
|
return {
|
||||||
status: 'ok',
|
status: 'warn',
|
||||||
message: 'Voicemail is disabled — callers will not be able to leave messages.',
|
message: 'Voicemail is disabled — callers will not be able to leave messages.',
|
||||||
details,
|
details,
|
||||||
remediation: null,
|
remediation: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendAllCalls silently swallowing every inbound call is almost
|
// Hard rule #2: none of the three send-to-VM triggers may be
|
||||||
// always a mistake — surface it loudly.
|
// active. This is the store-line standard: VM exists so the
|
||||||
if (details.sendAllCallsEnabled) {
|
// operator can check the box manually, but no call ever gets
|
||||||
|
// silently swallowed into it.
|
||||||
|
if (activeSendTriggers.length > 0) {
|
||||||
|
const humanTriggers = activeSendTriggers
|
||||||
|
.map((k) => k.replace(/([A-Z])/g, ' $1').toLowerCase().trim())
|
||||||
|
.join(', ');
|
||||||
return {
|
return {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message: 'Voicemail is enabled AND "send all calls to voicemail" is on — the phone will never ring.',
|
message:
|
||||||
|
`Voicemail is receiving calls: ${humanTriggers} ${activeSendTriggers.length === 1 ? 'trigger is' : 'triggers are'} active. ` +
|
||||||
|
`Store phone standard requires voicemail be reachable manually but never used as a call target.`,
|
||||||
details,
|
details,
|
||||||
remediation: null,
|
remediation: {
|
||||||
|
action: 'stop_sending_to_voicemail',
|
||||||
|
title: 'Stop sending calls to voicemail',
|
||||||
|
summary:
|
||||||
|
`Disable ${activeSendTriggers.length} VM trigger(s) for ${ctx.personLabel} so incoming calls stop being routed to voicemail. ` +
|
||||||
|
`Voicemail itself stays enabled.`,
|
||||||
|
payload: {
|
||||||
|
personId: ctx.personId,
|
||||||
|
personLabel: ctx.personLabel,
|
||||||
|
storeNum: ctx.storeNum,
|
||||||
|
activeSendTriggers,
|
||||||
|
before: {
|
||||||
|
sendAllCallsEnabled,
|
||||||
|
sendBusyCallsEnabled,
|
||||||
|
sendUnansweredCallsEnabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Soft rules below — no auto-remediation, just visibility.
|
||||||
|
|
||||||
if (!mwiEnabled) {
|
if (!mwiEnabled) {
|
||||||
return {
|
return {
|
||||||
status: 'warn',
|
status: 'warn',
|
||||||
|
|
@ -126,13 +176,66 @@ export const voicemailCheck = {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
message: 'Voicemail is enabled with MWI on and no unusual forwarding.',
|
message: 'Voicemail is enabled, no send-to-VM triggers active, MWI on. Compliant.',
|
||||||
details,
|
details,
|
||||||
remediation: null,
|
remediation: null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
// No remediations — voicemail policy is too site-specific for a
|
remediations: {
|
||||||
// one-size-fits-all button. The renderer surfaces the details so
|
async stop_sending_to_voicemail(bot, data, requester) {
|
||||||
// the operator can act via Control Hub if they want.
|
const { personId, personLabel, storeNum, activeSendTriggers, before } = data;
|
||||||
|
|
||||||
|
if (!Array.isArray(activeSendTriggers) || activeSendTriggers.length === 0) {
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
`⚠️ No send-to-VM triggers to clear for **${personLabel}** — nothing to do.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger(
|
||||||
|
'voicediag:audit',
|
||||||
|
`CONFIRMED stop_sending_to_voicemail for ${personLabel} (person=${personId}, store=${storeNum}) ` +
|
||||||
|
`by ${describeRequester(requester)} — clearing=${activeSendTriggers.join(',')}, ` +
|
||||||
|
`before=${JSON.stringify(before)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { default: webex } = await import('../../../integrations/webex/WebexClient.js');
|
||||||
|
// Minimal PUT: only the three send-* sub-blocks. Voicemail
|
||||||
|
// itself stays enabled + all other config (greetings,
|
||||||
|
// storage, notifications) is preserved by Webex's partial-
|
||||||
|
// update semantics on this endpoint.
|
||||||
|
const body = {
|
||||||
|
sendAllCalls: { ...VOICEMAIL_STANDARDS.sendAllCalls },
|
||||||
|
sendBusyCalls: { ...VOICEMAIL_STANDARDS.sendBusyCalls },
|
||||||
|
sendUnansweredCalls: { ...VOICEMAIL_STANDARDS.sendUnansweredCalls },
|
||||||
|
};
|
||||||
|
await webex.request('PUT', ENDPOINT(personId), body);
|
||||||
|
} catch (err) {
|
||||||
|
logger(
|
||||||
|
'voicediag:audit',
|
||||||
|
`FAILED stop_sending_to_voicemail for ${personLabel}: ${err.message}`,
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
`❌ Failed to stop send-to-VM for **${personLabel}**: ${err.message}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
`✅ Voicemail send triggers cleared for **${personLabel}** (store ${storeNum}): ${activeSendTriggers.join(', ')}. ` +
|
||||||
|
`Voicemail is still enabled but no calls will be routed to it. ` +
|
||||||
|
`Re-run \`/voicediag ${storeNum}\` to verify.`,
|
||||||
|
);
|
||||||
|
logger(
|
||||||
|
'voicediag:audit',
|
||||||
|
`COMPLETED stop_sending_to_voicemail for ${personLabel} (store ${storeNum}) — cleared=${activeSendTriggers.join(',')}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,9 @@ test('callForwarding: nothing enabled → ok', async () => {
|
||||||
assert.equal(r.remediation, null);
|
assert.equal(r.remediation, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('callForwarding: always-forwarding enabled → warn + compound remediation', async () => {
|
test('callForwarding: always-forwarding enabled → error + compound remediation', async () => {
|
||||||
|
// Severity upgraded from warn → error per store-line standard
|
||||||
|
// (any forwarding active on a store line = missed customer calls).
|
||||||
const ctx = mkCtx({
|
const ctx = mkCtx({
|
||||||
response: {
|
response: {
|
||||||
callForwarding: {
|
callForwarding: {
|
||||||
|
|
@ -125,10 +127,11 @@ test('callForwarding: always-forwarding enabled → warn + compound remediation'
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const r = await callForwardingCheck.run(ctx);
|
const r = await callForwardingCheck.run(ctx);
|
||||||
assert.equal(r.status, 'warn');
|
assert.equal(r.status, 'error');
|
||||||
assert.equal(r.remediation.action, 'clear_call_forwarding');
|
assert.equal(r.remediation.action, 'clear_call_forwarding');
|
||||||
assert.deepEqual(r.remediation.payload.variantsToClear, ['always']);
|
assert.deepEqual(r.remediation.payload.variantsToClear, ['always']);
|
||||||
assert.match(r.message, /\+18005551212/);
|
assert.match(r.message, /\+18005551212/);
|
||||||
|
assert.match(r.message, /Store phone standard/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('callForwarding: multiple variants active → single compound remediation', async () => {
|
test('callForwarding: multiple variants active → single compound remediation', async () => {
|
||||||
|
|
@ -142,7 +145,7 @@ test('callForwarding: multiple variants active → single compound remediation',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const r = await callForwardingCheck.run(ctx);
|
const r = await callForwardingCheck.run(ctx);
|
||||||
assert.equal(r.status, 'warn');
|
assert.equal(r.status, 'error');
|
||||||
assert.deepEqual(r.remediation.payload.variantsToClear, ['always', 'busy']);
|
assert.deepEqual(r.remediation.payload.variantsToClear, ['always', 'busy']);
|
||||||
assert.match(r.remediation.title, /2 forwarding variants/);
|
assert.match(r.remediation.title, /2 forwarding variants/);
|
||||||
});
|
});
|
||||||
|
|
@ -176,29 +179,60 @@ test('callWaiting: 404 → skipped via runner', async () => {
|
||||||
|
|
||||||
// ─── voicemail ──────────────────────────────────────────────────────
|
// ─── voicemail ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
test('voicemail: disabled → ok', async () => {
|
test('voicemail: disabled → warn (callers can\'t leave messages)', async () => {
|
||||||
|
// Standard is enabled=true; disabled surfaces as warn, no
|
||||||
|
// auto-remediation (some sites intentionally disable it).
|
||||||
const ctx = mkCtx({ response: { enabled: false } });
|
const ctx = mkCtx({ response: { enabled: false } });
|
||||||
const r = await voicemailCheck.run(ctx);
|
const r = await voicemailCheck.run(ctx);
|
||||||
assert.equal(r.status, 'ok');
|
assert.equal(r.status, 'warn');
|
||||||
|
assert.equal(r.remediation, null);
|
||||||
|
assert.match(r.message, /Voicemail is disabled/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('voicemail: sendAllCalls on → error', async () => {
|
test('voicemail: sendAllCalls on → error + stop_sending_to_voicemail remediation', async () => {
|
||||||
const ctx = mkCtx({
|
const ctx = mkCtx({
|
||||||
response: {
|
response: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
sendAllCalls: { enabled: true },
|
sendAllCalls: { enabled: true },
|
||||||
|
sendBusyCalls: { enabled: false },
|
||||||
|
sendUnansweredCalls: { enabled: false },
|
||||||
messageStorage: { mwiEnabled: true },
|
messageStorage: { mwiEnabled: true },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const r = await voicemailCheck.run(ctx);
|
const r = await voicemailCheck.run(ctx);
|
||||||
assert.equal(r.status, 'error');
|
assert.equal(r.status, 'error');
|
||||||
assert.match(r.message, /never ring/);
|
assert.equal(r.remediation.action, 'stop_sending_to_voicemail');
|
||||||
|
assert.deepEqual(r.remediation.payload.activeSendTriggers, ['sendAllCalls']);
|
||||||
|
assert.equal(r.remediation.payload.before.sendAllCallsEnabled, true);
|
||||||
|
assert.match(r.message, /Voicemail is receiving calls/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('voicemail: enabled but MWI off → warn', async () => {
|
test('voicemail: sendBusyCalls + sendUnansweredCalls both on → error, all triggers listed', async () => {
|
||||||
const ctx = mkCtx({
|
const ctx = mkCtx({
|
||||||
response: {
|
response: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
sendAllCalls: { enabled: false },
|
||||||
|
sendBusyCalls: { enabled: true },
|
||||||
|
sendUnansweredCalls: { enabled: true },
|
||||||
|
messageStorage: { mwiEnabled: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const r = await voicemailCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'error');
|
||||||
|
assert.deepEqual(
|
||||||
|
r.remediation.payload.activeSendTriggers.sort(),
|
||||||
|
['sendBusyCalls', 'sendUnansweredCalls'],
|
||||||
|
);
|
||||||
|
assert.match(r.message, /2 triggers are active|triggers are active/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('voicemail: enabled but MWI off → warn (no remediation, still soft signal)', async () => {
|
||||||
|
const ctx = mkCtx({
|
||||||
|
response: {
|
||||||
|
enabled: true,
|
||||||
|
sendAllCalls: { enabled: false },
|
||||||
|
sendBusyCalls: { enabled: false },
|
||||||
|
sendUnansweredCalls: { enabled: false },
|
||||||
messageStorage: { mwiEnabled: false },
|
messageStorage: { mwiEnabled: false },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -211,6 +245,9 @@ test('voicemail: enabled, email forward to off-org → warn', async () => {
|
||||||
const ctx = mkCtx({
|
const ctx = mkCtx({
|
||||||
response: {
|
response: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
sendAllCalls: { enabled: false },
|
||||||
|
sendBusyCalls: { enabled: false },
|
||||||
|
sendUnansweredCalls: { enabled: false },
|
||||||
messageStorage: { mwiEnabled: true },
|
messageStorage: { mwiEnabled: true },
|
||||||
emailCopyOfMessage: { enabled: true, emailId: 'random@gmail.com' },
|
emailCopyOfMessage: { enabled: true, emailId: 'random@gmail.com' },
|
||||||
},
|
},
|
||||||
|
|
@ -220,10 +257,13 @@ test('voicemail: enabled, email forward to off-org → warn', async () => {
|
||||||
assert.match(r.message, /off-org/);
|
assert.match(r.message, /off-org/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('voicemail: enabled + MWI + no unusual forwarding → ok', async () => {
|
test('voicemail: enabled + MWI + no send-to-VM + no unusual forwarding → ok', async () => {
|
||||||
const ctx = mkCtx({
|
const ctx = mkCtx({
|
||||||
response: {
|
response: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
sendAllCalls: { enabled: false },
|
||||||
|
sendBusyCalls: { enabled: false },
|
||||||
|
sendUnansweredCalls: { enabled: false },
|
||||||
messageStorage: { mwiEnabled: true },
|
messageStorage: { mwiEnabled: true },
|
||||||
emailCopyOfMessage: { enabled: false, emailId: '' },
|
emailCopyOfMessage: { enabled: false, emailId: '' },
|
||||||
},
|
},
|
||||||
|
|
@ -274,11 +314,13 @@ test('hoteling: disabled → ok', async () => {
|
||||||
assert.equal(r.remediation, null);
|
assert.equal(r.remediation, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('hoteling: enabled → warn (info only, no remediation)', async () => {
|
test('hoteling: enabled → warn + disable_hoteling remediation', async () => {
|
||||||
const ctx = mkCtx({ response: { enabled: true } });
|
const ctx = mkCtx({ response: { enabled: true } });
|
||||||
const r = await hotelingCheck.run(ctx);
|
const r = await hotelingCheck.run(ctx);
|
||||||
assert.equal(r.status, 'warn');
|
assert.equal(r.status, 'warn');
|
||||||
assert.equal(r.remediation, null);
|
assert.equal(r.remediation.action, 'disable_hoteling');
|
||||||
|
assert.equal(r.remediation.payload.personId, 'PID_TEST');
|
||||||
|
assert.equal(r.remediation.payload.before.enabled, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('hoteling: 404 → skipped via runner', async () => {
|
test('hoteling: 404 → skipped via runner', async () => {
|
||||||
|
|
@ -490,3 +532,288 @@ test('buildRemediationRegistry: contains every declared remediation exactly once
|
||||||
assert.equal(registry.get(rid).check.id, checkId);
|
assert.equal(registry.get(rid).check.id, checkId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── standards regression guards ───────────────────────────────────
|
||||||
|
// The "voice standards" reference table in README.md is generated
|
||||||
|
// off the `standards` field on each check descriptor. If a check
|
||||||
|
// forgets to declare it, we lose visibility of what the desired
|
||||||
|
// state actually is. Fail loud instead of silently dropping the row.
|
||||||
|
|
||||||
|
test('every registered check exposes a standards object', () => {
|
||||||
|
const missing = CHECKS.filter((c) => !c.standards || typeof c.standards !== 'object');
|
||||||
|
assert.deepEqual(
|
||||||
|
missing.map((c) => c.id),
|
||||||
|
[],
|
||||||
|
`checks without .standards: ${missing.map((c) => c.id).join(', ')}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('standards for DND / callWaiting / callIntercept encode the expected boolean shape', () => {
|
||||||
|
const byId = new Map(CHECKS.map((c) => [c.id, c]));
|
||||||
|
assert.equal(byId.get('dnd').standards.enabled, false);
|
||||||
|
assert.equal(byId.get('callWaiting').standards.enabled, true);
|
||||||
|
assert.equal(byId.get('callIntercept').standards.enabled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('standards for callForwarding declare all three variants off', () => {
|
||||||
|
const byId = new Map(CHECKS.map((c) => [c.id, c]));
|
||||||
|
const cf = byId.get('callForwarding').standards;
|
||||||
|
assert.equal(cf.always.enabled, false);
|
||||||
|
assert.equal(cf.busy.enabled, false);
|
||||||
|
assert.equal(cf.noAnswer.enabled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('standards for voicemail encode enabled + no send-to-VM triggers + MWI on', () => {
|
||||||
|
const byId = new Map(CHECKS.map((c) => [c.id, c]));
|
||||||
|
const vm = byId.get('voicemail').standards;
|
||||||
|
assert.equal(vm.enabled, true);
|
||||||
|
assert.equal(vm.sendAllCalls.enabled, false);
|
||||||
|
assert.equal(vm.sendBusyCalls.enabled, false);
|
||||||
|
assert.equal(vm.sendUnansweredCalls.enabled, false);
|
||||||
|
assert.equal(vm.messageStorage.mwiEnabled, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── port-hygiene bucket ───────────────────────────────────────────
|
||||||
|
// Each of these tests injects a phoneStatus snapshot shaped like
|
||||||
|
// collectPhoneStatus() output, focussed on the fields the port check
|
||||||
|
// consumes.
|
||||||
|
|
||||||
|
async function loadPortChecks() {
|
||||||
|
const [
|
||||||
|
{ portTypeCheck, PORT_TYPE_STANDARDS },
|
||||||
|
{ portVlanCheck, PORT_VLAN_STANDARDS },
|
||||||
|
{ portPoeCheck, PORT_POE_STANDARDS },
|
||||||
|
{ portEnabledCheck, PORT_ENABLED_STANDARDS },
|
||||||
|
] = await Promise.all([
|
||||||
|
import('../services/voiceDiag/checks/port/portType.js'),
|
||||||
|
import('../services/voiceDiag/checks/port/portVlan.js'),
|
||||||
|
import('../services/voiceDiag/checks/port/portPoe.js'),
|
||||||
|
import('../services/voiceDiag/checks/port/portEnabled.js'),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
portTypeCheck, PORT_TYPE_STANDARDS,
|
||||||
|
portVlanCheck, PORT_VLAN_STANDARDS,
|
||||||
|
portPoeCheck, PORT_POE_STANDARDS,
|
||||||
|
portEnabledCheck, PORT_ENABLED_STANDARDS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mkPortCtx(phones = [], dectBasestations = []) {
|
||||||
|
return {
|
||||||
|
...mkCtx(),
|
||||||
|
phoneStatus: {
|
||||||
|
phones: { data: phones },
|
||||||
|
dectBasestations,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('portType: all access ports → ok', async () => {
|
||||||
|
const { portTypeCheck } = await loadPortChecks();
|
||||||
|
const ctx = mkPortCtx(
|
||||||
|
[{ mac: 'aa:aa', name: 'P1', meraki: { portType: 'access', switchName: 'sw1', portName: 'p1', connectionType: 'Wired' } }],
|
||||||
|
[{ mac: 'bb:bb', name: 'B1', meraki: { portType: 'access', switchName: 'sw1', portName: 'p3', connectionType: 'Wired' } }],
|
||||||
|
);
|
||||||
|
const r = await portTypeCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'ok');
|
||||||
|
assert.equal(r.details.compliant, 2);
|
||||||
|
assert.equal(r.details.trunks, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('portType: trunk uplink → warn, offender named', async () => {
|
||||||
|
const { portTypeCheck } = await loadPortChecks();
|
||||||
|
const ctx = mkPortCtx([
|
||||||
|
{ mac: 'aa:aa', name: 'P1', meraki: { portType: 'access', switchName: 'sw1', portName: 'p1', connectionType: 'Wired' } },
|
||||||
|
{ mac: 'cc:cc', name: 'P2', meraki: { portType: 'trunk', switchName: 'sw1', portName: 'p2', connectionType: 'Wired' } },
|
||||||
|
]);
|
||||||
|
const r = await portTypeCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'warn');
|
||||||
|
assert.equal(r.details.trunks, 1);
|
||||||
|
assert.match(r.message, /TRUNK/);
|
||||||
|
assert.equal(r.details.offenders[0].deviceLabel, 'P2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('portType: wireless phone → skipped from compliance count (no switchport)', async () => {
|
||||||
|
const { portTypeCheck } = await loadPortChecks();
|
||||||
|
const ctx = mkPortCtx([
|
||||||
|
{ mac: 'aa:aa', name: 'P1', meraki: { connectionType: 'Wireless' } },
|
||||||
|
]);
|
||||||
|
const r = await portTypeCheck.run(ctx);
|
||||||
|
// With one device that's wireless, no compliant / trunks / unknown
|
||||||
|
// → we land in the "all compliant" branch with compliant=0.
|
||||||
|
assert.equal(r.status, 'ok');
|
||||||
|
assert.equal(r.details.wireless, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('portType: no devices at all → skipped', async () => {
|
||||||
|
const { portTypeCheck } = await loadPortChecks();
|
||||||
|
const ctx = mkPortCtx([], []);
|
||||||
|
const r = await portTypeCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'skipped');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('portVlan: expected VLAN from env, mismatch → warn', async () => {
|
||||||
|
const prev = process.env.VOICE_STANDARD_PHONE_VLAN;
|
||||||
|
process.env.VOICE_STANDARD_PHONE_VLAN = '102';
|
||||||
|
try {
|
||||||
|
const { portVlanCheck } = await loadPortChecks();
|
||||||
|
const ctx = mkPortCtx([
|
||||||
|
{ mac: 'aa:aa', name: 'P1', meraki: { portType: 'access', vlan: 102, switchName: 'sw1', portName: 'p1', connectionType: 'Wired' } },
|
||||||
|
{ mac: 'bb:bb', name: 'P2', meraki: { portType: 'access', vlan: 1, switchName: 'sw1', portName: 'p2', connectionType: 'Wired' } },
|
||||||
|
]);
|
||||||
|
const r = await portVlanCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'warn');
|
||||||
|
assert.equal(r.details.expectedVlan, 102);
|
||||||
|
assert.equal(r.details.wrong, 1);
|
||||||
|
assert.match(r.message, /expected 102/);
|
||||||
|
assert.match(r.message, /P2.*VLAN 1/);
|
||||||
|
} finally {
|
||||||
|
if (prev === undefined) delete process.env.VOICE_STANDARD_PHONE_VLAN;
|
||||||
|
else process.env.VOICE_STANDARD_PHONE_VLAN = prev;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('portVlan: env override — VOICE_STANDARD_PHONE_VLAN=200 makes 200 compliant', async () => {
|
||||||
|
const prev = process.env.VOICE_STANDARD_PHONE_VLAN;
|
||||||
|
process.env.VOICE_STANDARD_PHONE_VLAN = '200';
|
||||||
|
try {
|
||||||
|
const { portVlanCheck } = await loadPortChecks();
|
||||||
|
const ctx = mkPortCtx([
|
||||||
|
{ mac: 'aa:aa', name: 'P1', meraki: { portType: 'access', vlan: 200, switchName: 'sw', portName: 'p1', connectionType: 'Wired' } },
|
||||||
|
]);
|
||||||
|
const r = await portVlanCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'ok');
|
||||||
|
assert.equal(r.details.expectedVlan, 200);
|
||||||
|
assert.equal(r.details.compliant, 1);
|
||||||
|
} finally {
|
||||||
|
if (prev === undefined) delete process.env.VOICE_STANDARD_PHONE_VLAN;
|
||||||
|
else process.env.VOICE_STANDARD_PHONE_VLAN = prev;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('portVlan: trunk ports get skipped (owned by portType check)', async () => {
|
||||||
|
const { portVlanCheck } = await loadPortChecks();
|
||||||
|
const ctx = mkPortCtx([
|
||||||
|
{ mac: 'aa:aa', name: 'P1', meraki: { portType: 'access', vlan: 102, switchName: 'sw', portName: 'p1', connectionType: 'Wired' } },
|
||||||
|
{ mac: 'bb:bb', name: 'P2', meraki: { portType: 'trunk', vlan: 999, switchName: 'sw', portName: 'p2', connectionType: 'Wired' } },
|
||||||
|
]);
|
||||||
|
const r = await portVlanCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'ok');
|
||||||
|
assert.equal(r.details.trunkSkipped, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('portPoe: PoE off on an access port → warn', async () => {
|
||||||
|
const { portPoeCheck } = await loadPortChecks();
|
||||||
|
const ctx = mkPortCtx([
|
||||||
|
{ mac: 'aa:aa', name: 'P1', meraki: { portType: 'access', poeEnabled: true, switchName: 'sw', portName: 'p1', connectionType: 'Wired' } },
|
||||||
|
{ mac: 'bb:bb', name: 'P2', meraki: { portType: 'access', poeEnabled: false, switchName: 'sw', portName: 'p2', connectionType: 'Wired' } },
|
||||||
|
]);
|
||||||
|
const r = await portPoeCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'warn');
|
||||||
|
assert.equal(r.details.off, 1);
|
||||||
|
assert.match(r.message, /PoE DISABLED/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('portEnabled: admin-disabled port → error (phone won\'t work)', async () => {
|
||||||
|
const { portEnabledCheck } = await loadPortChecks();
|
||||||
|
const ctx = mkPortCtx([
|
||||||
|
{ mac: 'aa:aa', name: 'P1', meraki: { portType: 'access', portEnabled: true, switchName: 'sw', portName: 'p1', connectionType: 'Wired' } },
|
||||||
|
{ mac: 'bb:bb', name: 'P2', meraki: { portType: 'access', portEnabled: false, switchName: 'sw', portName: 'p2', connectionType: 'Wired' } },
|
||||||
|
]);
|
||||||
|
const r = await portEnabledCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'error');
|
||||||
|
assert.equal(r.details.disabled, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('port kill-switch: VOICE_STANDARD_ENABLED=false → all four port checks return skipped', async () => {
|
||||||
|
const prev = process.env.VOICE_STANDARD_ENABLED;
|
||||||
|
process.env.VOICE_STANDARD_ENABLED = 'false';
|
||||||
|
try {
|
||||||
|
const { portTypeCheck, portVlanCheck, portPoeCheck, portEnabledCheck } = await loadPortChecks();
|
||||||
|
const ctx = mkPortCtx([
|
||||||
|
{ mac: 'aa:aa', meraki: { portType: 'access', vlan: 102, poeEnabled: true, portEnabled: true, switchName: 'sw', portName: 'p1', connectionType: 'Wired' } },
|
||||||
|
]);
|
||||||
|
for (const chk of [portTypeCheck, portVlanCheck, portPoeCheck, portEnabledCheck]) {
|
||||||
|
const r = await chk.run(ctx);
|
||||||
|
assert.equal(r.status, 'skipped', `${chk.id} should be skipped by kill switch`);
|
||||||
|
assert.match(r.message, /VOICE_STANDARD_ENABLED/);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (prev === undefined) delete process.env.VOICE_STANDARD_ENABLED;
|
||||||
|
else process.env.VOICE_STANDARD_ENABLED = prev;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── combined "apply all N fixes" card ─────────────────────────────
|
||||||
|
|
||||||
|
// bot.say has two supported signatures — `bot.say('markdown', md)`
|
||||||
|
// (used by simple text posts) and `bot.say({ markdown, attachments })`
|
||||||
|
// (used by adaptive-card posts). The mock captures the *message
|
||||||
|
// body* from either shape so per-test assertions can match on
|
||||||
|
// content rather than mimicking the framework calling convention.
|
||||||
|
function mkBotMock() {
|
||||||
|
return {
|
||||||
|
messages: [],
|
||||||
|
async say(a, b) {
|
||||||
|
if (typeof a === 'string' && typeof b === 'string') this.messages.push(b);
|
||||||
|
else if (a && typeof a === 'object') this.messages.push(a.markdown || JSON.stringify(a));
|
||||||
|
else this.messages.push(String(a ?? ''));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('applyAllVoiceDiagRemediations: iterates entries in sequence, tolerates individual failure', async () => {
|
||||||
|
const { applyAllVoiceDiagRemediations } = await import('../commands/voiceDiag.js');
|
||||||
|
const bot = mkBotMock();
|
||||||
|
const requester = { displayName: 'Op Test', email: 'op@example.com' };
|
||||||
|
|
||||||
|
// Two entries: one whose handler is real (disable_dnd via the
|
||||||
|
// registry — but that would talk to WebexClient which we can't
|
||||||
|
// in a test). We rely on the fact that the registered handler is
|
||||||
|
// called with a payload we can inspect via a mock — but the actual
|
||||||
|
// handler will throw on WebexClient import. So we test the
|
||||||
|
// "unknown handler" path (safe) and the audit summary.
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
combined: true,
|
||||||
|
storeNum: '782',
|
||||||
|
personLabel: 'Test Store',
|
||||||
|
entries: [
|
||||||
|
{ remediationId: 'nonexistent_handler_1', remediationPayload: {} },
|
||||||
|
{ remediationId: 'nonexistent_handler_2', remediationPayload: {} },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await applyAllVoiceDiagRemediations(bot, data, null, requester);
|
||||||
|
|
||||||
|
// Final summary posted regardless
|
||||||
|
assert.ok(bot.messages.length >= 1, 'should post final summary');
|
||||||
|
const summary = bot.messages[bot.messages.length - 1];
|
||||||
|
assert.match(summary, /Apply-all complete/);
|
||||||
|
assert.match(summary, /Applied: 0 \/ 2/);
|
||||||
|
assert.match(summary, /Failed: 2/);
|
||||||
|
assert.match(summary, /nonexistent_handler_1/);
|
||||||
|
assert.match(summary, /no handler registered/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyAllVoiceDiagRemediations: no entries → posts "no pending" hint', async () => {
|
||||||
|
const { applyAllVoiceDiagRemediations } = await import('../commands/voiceDiag.js');
|
||||||
|
const bot = mkBotMock();
|
||||||
|
await applyAllVoiceDiagRemediations(bot, { combined: true, storeNum: '782', entries: [] }, null, {});
|
||||||
|
assert.equal(bot.messages.length, 1);
|
||||||
|
assert.match(bot.messages[0], /No pending remediations/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancelAllVoiceDiagRemediations: acknowledges the batch was cancelled', async () => {
|
||||||
|
const { cancelAllVoiceDiagRemediations } = await import('../commands/voiceDiag.js');
|
||||||
|
const bot = mkBotMock();
|
||||||
|
await cancelAllVoiceDiagRemediations(bot, {
|
||||||
|
combined: true,
|
||||||
|
storeNum: '782',
|
||||||
|
personLabel: 'Store 782',
|
||||||
|
entries: [{ remediationId: 'a' }, { remediationId: 'b' }, { remediationId: 'c' }],
|
||||||
|
}, null, { displayName: 'Op' });
|
||||||
|
assert.equal(bot.messages.length, 1);
|
||||||
|
assert.match(bot.messages[0], /Cancelled all pending remediations/);
|
||||||
|
assert.match(bot.messages[0], /3 fixes not applied/);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue