netanalyzer/tests/merakiMatcher.test.js
Joseph McQueen b3c37bd7df feat: st command suite, Webex phone + Atlas AV integrations, dockerized remote agent
Rebrand NetAnalyzer -> StoreHealthAnalyzer and consolidate the store
reporting surface into a single `st [number]` command with focused
sub-modes.

Commands
- st [number]                - general info (SIW + brands + Meraki net link)
- st [number] network        - switches, APs, store server
- st [number] pos            - registers, payment terminals, customer display
- st [number] ios            - MDM-tracked iOS hardware
- st [number] phone          - wired 78xx + DECT basestations/handsets with
                               registration state, extensions and main DID
- st [number] av             - Atlas AMPs + MDM-tracked Apple TVs, video
                               walls, music players, LED displays
- Removed `analyze` in favor of the unified `st` surface

Integrations
- integrations/webex: Service App OAuth with rotating refresh tokens,
  seed + cleanup scripts, tokens/ storage (git-ignored)
- integrations/atlas: Xyte client + cached device discovery keyed on
  zero-padded 6-digit store numbers, cold-cache failure -> unavailable
  banner instead of a misleading empty result
- services/webexPhone, services/webexService, services/avService: shape
  raw upstream data into the report layer's contract
- utils/merakiMatcher: FQDN hostname extraction so payment terminals
  match Meraki descriptions; case-insensitive lookup
- utils/chunkReport: split long markdown replies at 7000-char boundaries

Reliability / ops
- server.js: awaited framework.stop() + 8s hard-kill timer so nodemon /
  Docker restarts don't leak WDM device registrations ("excessive device
  registrations")
- nodemon.json: SIGINT so the graceful path always runs
- scripts/cleanupWebexDevices.js: one-shot WDM cleanup utility
- Group-space routing: hears() regexes tolerate the leading @BotName
  prefix Webex prepends to mentions
- Replaced HTML-unsafe <number> placeholders with [number] in all help
  strings

Remote agent containerization
- docker/remote-agent/: multi-stage node:22-alpine image, non-root user,
  tini for signal handling, minimal deps (ws/axios/dotenv)
- docker/remote-agent/package.sh: docker buildx build defaulting to
  linux/amd64 (with override), saves image + assembles deploy/ + writes
  SHA256 + zips for offline transfer
- docker/remote-agent/deploy/: runtime docker-compose.yml, install.sh
  with platform sanity check, remote-host README
- .dockerignore + .gitignore updates for build artifacts and dist bundles
- npm run agent:package convenience script

Cleanup
- Dropped storeHealth.js / HealthReport.js and their tests/mocks in favor
  of the shared storeDetail pipeline
- Store model handles null SIW records gracefully; toSummary always
  ends with a newline so the Meraki link sits on its own line

Tests
- 144 tests across 14 suites passing; new coverage for atlasClient,
  atlasDevices, avService, avCategory classification, webexPhone,
  webexServiceAppAuth, storeDetail integration, siw, chunkReport and
  the updated meraki matcher

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 09:54:41 -04:00

186 lines
6.6 KiB
JavaScript

const {
findMatchingClient,
getClientStatus,
formatLastSeen,
buildMerakiClientLink,
extractHostname,
normalizeMac,
} = require('../utils/merakiMatcher');
describe('merakiMatcher', () => {
const sampleClients = [
{ id: 'c1', description: 'Register 305', status: 'Online', lastSeen: '2025-01-01T10:00:00Z' },
{
id: 'c2',
description: 'Printer Front',
status: 'Offline',
lastSeen: Date.now() - 5 * 60 * 1000,
},
{ id: 'c3', description: '192.168.10.45 - Terminal', status: 'Online', lastSeen: null },
{ id: 'c4', description: 'SRV-042', status: 'Online' },
];
describe('findMatchingClient', () => {
it('matches by exact or substring on description', () => {
expect(findMatchingClient(sampleClients, { name: 'Register 305' })).toBe(sampleClients[0]);
expect(findMatchingClient(sampleClients, { name: 'printer' })).toBe(sampleClients[1]);
});
it('matches using multiple identifier fields', () => {
const match = findMatchingClient(sampleClients, {
deviceName: 'Terminal',
ip_address: '192.168.10.45',
});
expect(match).toBe(sampleClients[2]);
});
it('matches MDM style UserName / DeviceFriendlyName', () => {
const match = findMatchingClient(sampleClients, {
UserName: 'SRV-042',
DeviceFriendlyName: 'Server 042',
});
expect(match).toBe(sampleClients[3]);
});
it('returns null when no clients or no match', () => {
expect(findMatchingClient([], { name: 'foo' })).toBeNull();
expect(findMatchingClient(sampleClients, { name: 'nonexistent' })).toBeNull();
});
// Payment-terminal regression: SIW reports the FQDN in `ip_address`
// (e.g. "VFI-807-005-168.us000782.stores.ae.com") and Meraki advertises
// only the lowercase hostname before the first dot. Before the fix the
// matcher compared an upper-case ipPrefix against a lower-cased desc and
// missed every payment terminal.
it('matches a Meraki client when SIW gives an FQDN ip_address (case-insensitive)', () => {
const clients = [
{ id: 'm1', description: 'vfi-807-005-168', status: 'Online', lastSeen: null },
];
const match = findMatchingClient(clients, {
deviceName: 'Terminal 11',
adyenName: 'P400Plus-807005168',
ip_address: 'VFI-807-005-168.us000782.stores.ae.com',
});
expect(match).toBe(clients[0]);
});
});
describe('normalizeMac', () => {
it('strips common separators and lowercases', () => {
expect(normalizeMac('AA:BB:CC:11:22:33')).toBe('aabbcc112233');
expect(normalizeMac('aa-bb-cc-11-22-33')).toBe('aabbcc112233');
expect(normalizeMac('aabb.cc11.2233')).toBe('aabbcc112233');
expect(normalizeMac('AABBCC112233')).toBe('aabbcc112233');
});
it('returns null for non-12-hex input', () => {
expect(normalizeMac('')).toBeNull();
expect(normalizeMac(null)).toBeNull();
expect(normalizeMac('aa:bb:cc')).toBeNull();
expect(normalizeMac('not a mac at all')).toBeNull();
expect(normalizeMac('aabbcc1122334455')).toBeNull();
});
});
describe('findMatchingClient — MAC strategy', () => {
const clients = [
{
id: 'client-name-match',
description: 'VFI-807-005-168',
mac: '11:22:33:44:55:66',
status: 'Offline',
},
{
id: 'client-mac-match',
description: 'something-totally-different',
mac: 'AA-BB-CC-DD-EE-FF',
status: 'Online',
},
];
it('matches by MAC when present (deterministic, case/separator insensitive)', () => {
const match = findMatchingClient(clients, { mac: 'aabb.ccdd.eeff' });
expect(match?.id).toBe('client-mac-match');
});
it('lets MAC win even when a name strategy could also match', () => {
// The first client has a description that would match by name, but we
// pass a MAC for the second one — MAC should take priority.
const match = findMatchingClient(clients, {
mac: 'aabbccddeeff',
name: 'VFI-807-005-168',
});
expect(match?.id).toBe('client-mac-match');
});
it('falls back to name strategy when MAC is missing or unmatched', () => {
const match = findMatchingClient(clients, {
mac: 'ffffffffffff',
name: 'VFI-807-005-168',
});
expect(match?.id).toBe('client-name-match');
});
it('returns null when nothing matches and MAC is invalid', () => {
const match = findMatchingClient(clients, { mac: 'not-a-mac' });
expect(match).toBeNull();
});
});
describe('extractHostname', () => {
it('returns the lowercase short hostname from an FQDN', () => {
expect(extractHostname('VFI-807-005-168.us000782.stores.ae.com')).toBe('vfi-807-005-168');
});
it('passes a bare IP through (no dot-split semantics for our matching)', () => {
// Bare IPs still split on the first dot — that's fine because the
// matcher only uses this as a "starts-with" hint anyway.
expect(extractHostname('192.168.10.45')).toBe('192');
});
it('returns null for falsy input', () => {
expect(extractHostname(null)).toBeNull();
expect(extractHostname(undefined)).toBeNull();
expect(extractHostname('')).toBeNull();
});
});
describe('getClientStatus', () => {
it('returns correct emojis and fallback', () => {
expect(getClientStatus({ status: 'Online' })).toBe('✅ Online');
expect(getClientStatus({ status: 'Offline' })).toBe('❌ Offline');
expect(getClientStatus(null)).toBe('❓ Unknown');
expect(getClientStatus(undefined)).toBe('❓ Unknown');
});
});
describe('formatLastSeen', () => {
it('handles missing value', () => {
expect(formatLastSeen(null)).toBe('N/A');
expect(formatLastSeen(undefined)).toBe('N/A');
});
it('formats recent times', () => {
const justNow = new Date();
expect(formatLastSeen(justNow)).toBe('Just now');
const tenMin = new Date(Date.now() - 10 * 60 * 1000);
expect(formatLastSeen(tenMin)).toMatch(/10 min ago/);
});
});
describe('buildMerakiClientLink', () => {
const network = { id: 'N_123', name: 'Store 305', url: 'https://example.com/n/ABC123' };
it('builds a client link when possible', () => {
const client = { id: 'c99' };
const link = buildMerakiClientLink(network, client);
expect(link).toContain('/manage/clients/c99/overview');
});
it('returns empty string on bad input', () => {
expect(buildMerakiClientLink(null, {})).toBe('');
expect(buildMerakiClientLink(network, null)).toBe('');
});
});
});