From 4f43878ebb2cefb0224b06802dd5a03f814c4d73 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 20 Jul 2026 22:41:19 +0100 Subject: [PATCH] Add focused branch coverage and repair infrastructure source contract Contract-Neutral: test-only coverage and source-contract assertions; no runtime or contract behavior changed --- .../InfrastructureOperationsModel.test.tsx | 10 +- ...pdateCommandsModel.branchcov0720pm.test.ts | 1380 +++++++++++++++++ ...atrolRunAcceptance.branchcov0720pm.test.ts | 205 +++ .../diskPresentation.branchcov0720pm.test.ts | 677 ++++++++ .../config/intent_branchcov0720pm_test.go | 1031 ++++++++++++ ...et_doctor_hostpeer_branchcov0720pm_test.go | 164 ++ .../physicaldiskview_branchcov0720pm_test.go | 195 +++ .../status_clone_branchcov0720pm_test.go | 92 ++ 8 files changed, 3751 insertions(+), 3 deletions(-) create mode 100644 frontend-modern/src/components/Settings/__tests__/infrastructureAgentUpdateCommandsModel.branchcov0720pm.test.ts create mode 100644 frontend-modern/src/features/patrol/__tests__/patrolRunAcceptance.branchcov0720pm.test.ts create mode 100644 frontend-modern/src/features/storageBackups/__tests__/diskPresentation.branchcov0720pm.test.ts create mode 100644 internal/alerts/config/intent_branchcov0720pm_test.go create mode 100644 internal/monitoring/agent_fleet_doctor_hostpeer_branchcov0720pm_test.go create mode 100644 internal/unifiedresources/physicaldiskview_branchcov0720pm_test.go create mode 100644 pkg/diskinventory/status_clone_branchcov0720pm_test.go diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx index 9dd4cfe1c..243bfbc4b 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx @@ -414,10 +414,14 @@ describe('infrastructure operations model', () => { expect(agentUpgradeSource).toContain( '| bash -s -- --update --url ${shellQuoteArg(url)} --non-interactive', ); - expect(operationsStateSource).toContain('getAgentConnectionUpgradeCommandRequiresToken'); - expect(operationsStateSource).toContain( - "getConnectionUpgradePlatform(connection) === 'windows' && installState.requiresToken()", + const requiresTokenEnd = operationsStateSource.indexOf('return {', agentUpgradeEnd); + expect(requiresTokenEnd).toBeGreaterThan(agentUpgradeEnd); + const requiresTokenSource = operationsStateSource.slice(agentUpgradeEnd, requiresTokenEnd); + expect(requiresTokenSource).toContain( + 'platformOverride ?? getConnectionUpgradePlatform(connection)', ); + expect(requiresTokenSource).toContain("=== 'windows'"); + expect(requiresTokenSource).toContain('installState.requiresToken()'); expect(unixUpgradeSource).not.toContain('command += ` --token ${shellQuoteArg(token)}`;'); expect(unixUpgradeSource).not.toContain('--agent-id'); expect(unixUpgradeSource).not.toContain('--hostname'); diff --git a/frontend-modern/src/components/Settings/__tests__/infrastructureAgentUpdateCommandsModel.branchcov0720pm.test.ts b/frontend-modern/src/components/Settings/__tests__/infrastructureAgentUpdateCommandsModel.branchcov0720pm.test.ts new file mode 100644 index 000000000..7e6689df4 --- /dev/null +++ b/frontend-modern/src/components/Settings/__tests__/infrastructureAgentUpdateCommandsModel.branchcov0720pm.test.ts @@ -0,0 +1,1380 @@ +import { describe, expect, it } from 'vitest'; +import type { Connection, ConnectionAgentUpdateStatus } from '@/api/connections'; +import type { AgentFleetAgentDiagnostic } from '@/api/agentDiagnostics'; +import type { + InfrastructureSystemMemberRow, + InfrastructureSystemRow, +} from '../connectionsTableModel'; +import { + collectInfrastructureAgentDoctorTargets, + collectInfrastructureAgentUpdateTargets, + diagnosticConnectionID, + normalizeAgentConnectionID, + resolveKnownAgentCommandPlatform, + summarizeInfrastructureAgentDoctorTargets, +} from '../infrastructureAgentUpdateCommandsModel'; +import type { + InfrastructureAgentDoctorOptions, + InfrastructureAgentDoctorStatus, + InfrastructureAgentDoctorTarget, +} from '../infrastructureAgentUpdateCommandsModel'; + +// ---- Fixtures --------------------------------------------------------------- +// Minimal valid factories. The agent is the only connection type the doctor +// bindings retain, so every fixture defaults to type: 'agent'. + +const agentConnection = (overrides: Partial = {}): Connection => ({ + id: 'agent:host-1', + type: 'agent', + name: 'host-1', + address: 'host-1.lab', + state: 'active', + stateReason: '', + enabled: true, + surfaces: ['host'], + scope: { host: true }, + lastSeen: '2026-07-20T09:00:00Z', + lastError: null, + source: 'agent', + agentVersion: '6.1.0', + expectedAgentVersion: '6.2.0', + agentUpdateAvailable: true, + agentIdentity: { hostname: 'host-1', platform: 'ubuntu', architecture: 'amd64' }, + capabilities: { supportsPause: false, supportsScope: false, supportsTest: false }, + ...overrides, +}); + +const emptyFleetRow = { + fleetSignals: [], + fleetHighlights: [], +} satisfies Pick; + +const emptyFleetMember = { + fleetSignals: [], + fleetHighlights: [], +} satisfies Pick; + +const row = (overrides: Partial = {}): InfrastructureSystemRow => { + const primary = agentConnection(); + return { + id: primary.id, + ownerType: 'agent', + name: primary.name, + subtitle: 'via Pulse Agent', + source: 'agent', + host: primary.address, + coverageLabels: ['Host telemetry'], + statusLabel: 'Active', + statusClassName: 'bg-green-100', + agentUpdateCount: 0, + lastActivityText: '1m ago', + ...emptyFleetRow, + enabled: true, + canEdit: false, + canPause: false, + canRemove: true, + isAgent: true, + isCluster: false, + attachedConnections: [], + members: [], + connection: primary, + ...overrides, + }; +}; + +const member = ( + overrides: Partial = {}, +): InfrastructureSystemMemberRow => ({ + id: 'node-1', + name: 'node-1', + subtitle: 'Primary node', + source: 'both', + host: 'https://node-1:8006', + coverageLabels: ['Host telemetry'], + statusLabel: 'Active', + statusClassName: 'bg-green-100', + lastActivityText: '1m ago', + ...emptyFleetMember, + primary: true, + ...overrides, +}); + +const diagnostic = ( + overrides: Partial = {}, +): AgentFleetAgentDiagnostic => ({ + connectionId: 'agent:host-1', + rowKey: 'host-1', + id: 'host-1', + agentId: 'host-1', + name: 'host-1', + hostname: 'host-1', + types: ['host'], + status: 'warning', + version: '6.1.0', + profileId: 'profile-linux', + profileName: 'Linux servers', + profileVersion: 4, + deployedProfileVersion: 3, + reasons: [ + { + code: 'agent_version_stale', + severity: 'warning', + message: 'Agent is behind the supported server version.', + }, + ], + repairActions: [ + { + code: 'copy_upgrade_command', + label: 'Copy upgrade command', + description: 'Run on the affected host.', + supported: true, + scope: 'local_admin_shell', + }, + ], + ...overrides, +}); + +// Run the doctor collector in the common "ledger fallback" shape: bindings are +// derived purely from the `connections` ledger (no rows), so each binding +// carries contextLabel 'Machine' and installFlags []. +const runDoctor = ( + agents: readonly Connection[], + options: Partial = {}, +) => + collectInfrastructureAgentDoctorTargets({ + rows: [], + connections: agents, + diagnosticsAvailable: false, + ...options, + }); + +// ---- resolveKnownAgentCommandPlatform --------------------------------------- + +describe('resolveKnownAgentCommandPlatform', () => { + it('fails closed (null) for missing input', () => { + expect(resolveKnownAgentCommandPlatform(undefined)).toBeNull(); + expect(resolveKnownAgentCommandPlatform(null)).toBeNull(); + }); + + it('fails closed (null) for blank input', () => { + expect(resolveKnownAgentCommandPlatform('')).toBeNull(); + expect(resolveKnownAgentCommandPlatform(' ')).toBeNull(); + }); + + it('fails closed (null) for an unrecognized caption', () => { + expect(resolveKnownAgentCommandPlatform('Haiku')).toBeNull(); + expect(resolveKnownAgentCommandPlatform('plan9')).toBeNull(); + }); + + it.each([ + ['windows', 'windows'], + ['Windows 11 Pro', 'windows'], + ['Microsoft Windows Server', 'windows'], + ['WINDOWS', 'windows'], + ])('classifies %s as windows', (caption, expected) => { + expect(resolveKnownAgentCommandPlatform(caption)).toBe(expected); + }); + + it.each([ + ['darwin', 'macos'], + ['mac', 'macos'], + ['macos', 'macos'], + ['Mac OS X', 'macos'], + ['OS X Yosemite', 'macos'], + ['mac os sonoma', 'macos'], + ])('classifies %s as macos', (caption, expected) => { + expect(resolveKnownAgentCommandPlatform(caption)).toBe(expected); + }); + + it.each([ + ['freebsd', 'freebsd'], + ['FreeBSD 14.1', 'freebsd'], + ['pfSense', 'freebsd'], + ['pfSense Community Edition', 'freebsd'], + ['OPNsense', 'freebsd'], + ['opnsense 24.7', 'freebsd'], + ])('classifies %s as freebsd', (caption, expected) => { + expect(resolveKnownAgentCommandPlatform(caption)).toBe(expected); + }); + + it.each([ + 'linux', + 'ubuntu', + 'debian', + 'debian 12', + 'almalinux', + 'amazon', + 'arch', + 'centos', + 'fedora', + 'gentoo', + 'manjaro', + 'nixos', + 'openwrt', + 'opensuse', + 'oracle', + 'proxmox', + 'qnap', + 'raspbian', + 'redhat', + 'rhel', + 'rocky', + 'sles', + 'suse', + 'synology', + 'unraid', + 'Ubuntu 22.04.4 LTS', + ])('classifies known linux caption %s as linux', (caption) => { + expect(resolveKnownAgentCommandPlatform(caption)).toBe('linux'); + }); + + it('treats a known distro substring (without trailing space) as unknown', () => { + // "ubuntufork" neither equals a known distro nor starts with `${candidate} `, + // so the strict resolver refuses to guess. + expect(resolveKnownAgentCommandPlatform('ubuntufork')).toBeNull(); + }); +}); + +// ---- normalizeAgentConnectionID --------------------------------------------- + +describe('normalizeAgentConnectionID', () => { + it('returns "" for null/undefined', () => { + expect(normalizeAgentConnectionID(null)).toBe(''); + expect(normalizeAgentConnectionID(undefined)).toBe(''); + }); + + it('returns "" for blank strings', () => { + expect(normalizeAgentConnectionID('')).toBe(''); + expect(normalizeAgentConnectionID(' ')).toBe(''); + }); + + it('keeps an already-prefixed id (after trimming)', () => { + expect(normalizeAgentConnectionID('agent:foo')).toBe('agent:foo'); + expect(normalizeAgentConnectionID(' agent:foo ')).toBe('agent:foo'); + }); + + it('prefixes a bare id with agent:', () => { + expect(normalizeAgentConnectionID('foo')).toBe('agent:foo'); + expect(normalizeAgentConnectionID(' foo ')).toBe('agent:foo'); + }); +}); + +// ---- diagnosticConnectionID ------------------------------------------------- + +describe('diagnosticConnectionID', () => { + it('returns the explicit connectionId when present', () => { + expect(diagnosticConnectionID(diagnostic({ connectionId: 'agent:explicit' }))).toBe( + 'agent:explicit', + ); + }); + + it('trims whitespace around the explicit connectionId', () => { + expect(diagnosticConnectionID(diagnostic({ connectionId: ' agent:trim ' }))).toBe( + 'agent:trim', + ); + }); + + it('falls back through agentId when connectionId is whitespace-only', () => { + expect( + diagnosticConnectionID(diagnostic({ connectionId: ' ', agentId: 'host-9', id: 'legacy' })), + ).toBe('agent:host-9'); + }); + + it('falls back through agentId when connectionId is undefined', () => { + expect( + diagnosticConnectionID( + diagnostic({ connectionId: undefined, agentId: 'host-9', id: 'legacy' }), + ), + ).toBe('agent:host-9'); + }); + + it('falls back through id (prefixed) when both connectionId and agentId are absent', () => { + expect( + diagnosticConnectionID( + diagnostic({ connectionId: undefined, agentId: undefined, id: 'legacy-id' }), + ), + ).toBe('agent:legacy-id'); + }); + + it('prefers agentId over id when both are present and connectionId is absent', () => { + expect( + diagnosticConnectionID( + diagnostic({ connectionId: undefined, agentId: 'agent-1', id: 'row-1' }), + ), + ).toBe('agent:agent-1'); + }); +}); + +// ---- collectInfrastructureAgentUpdateTargets -------------------------------- + +describe('collectInfrastructureAgentUpdateTargets', () => { + // A non-agent (pve) primary so only attached agents become targets. + const pvePrimary = agentConnection({ + id: 'pve:node', + type: 'pve', + name: 'node', + agentUpdateAvailable: false, + }); + const pveRow = (overrides: Partial = {}) => + row({ + id: 'pve:node', + ownerType: 'pve', + isAgent: false, + name: 'node', + connection: pvePrimary, + ...overrides, + }); + + it('returns [] for empty rows', () => { + expect(collectInfrastructureAgentUpdateTargets([])).toEqual([]); + }); + + it('returns [] when rows contain only non-agent connections', () => { + expect(collectInfrastructureAgentUpdateTargets([pveRow()])).toEqual([]); + }); + + it('sorts targets by displayName ascending', () => { + const zeta = agentConnection({ + id: 'agent:zeta', + name: 'zeta', + agentIdentity: { hostname: 'zeta', platform: 'linux' }, + }); + const alpha = agentConnection({ + id: 'agent:alpha', + name: 'alpha', + agentIdentity: { hostname: 'alpha', platform: 'linux' }, + }); + const targets = collectInfrastructureAgentUpdateTargets([ + pveRow({ attachedConnections: [zeta, alpha] }), + ]); + expect(targets.map((target) => target.key)).toEqual(['agent:alpha', 'agent:zeta']); + }); + + it('retains all targets when no scope is provided', () => { + const a = agentConnection({ + id: 'agent:a', + name: 'a', + agentIdentity: { hostname: 'a', platform: 'linux' }, + }); + const b = agentConnection({ + id: 'agent:b', + name: 'b', + agentIdentity: { hostname: 'b', platform: 'linux' }, + }); + const targets = collectInfrastructureAgentUpdateTargets([ + pveRow({ attachedConnections: [a, b] }), + ]); + expect(targets).toHaveLength(2); + }); +}); + +// ---- collectInfrastructureAgentDoctorTargets: top-level shaping ------------- + +describe('collectInfrastructureAgentDoctorTargets (top-level)', () => { + it('returns [] for fully empty inputs using all defaults', () => { + // rows required; connections/diagnostics/scopedAgentIds default to []. + expect( + collectInfrastructureAgentDoctorTargets({ + rows: [], + diagnosticsAvailable: false, + }), + ).toEqual([]); + }); + + it('skips non-agent connections in the ledger (binding type guard)', () => { + const pve = { ...agentConnection(), type: 'pve' as const, id: 'pve:node' }; + expect(runDoctor([pve])).toEqual([]); + }); + + it('dedupes a connection repeated in the ledger (binding has() guard)', () => { + const agent = agentConnection(); + expect(runDoctor([agent, agent])).toHaveLength(1); + }); + + it('binds a ledger-only connection with contextLabel "Machine" and empty flags', () => { + const [target] = runDoctor([agentConnection()]); + expect(target?.contextLabel).toBe('Machine'); + expect(target?.installFlags).toEqual([]); + }); + + it('excludes removed diagnostics from the binding map (no live target built)', () => { + const removed = diagnostic({ + connectionId: 'agent:host-1', + status: 'removed', + }); + // diagnosticsAvailable true + removed diagnostic + matching connection. + // The removed diagnostic is filtered out of the binding map; the connection + // still binds and is classified via the live path (source ledger-fallback + // because no live diagnostic matches). + const [target] = runDoctor([agentConnection({ agentUpdateAvailable: false })], { + diagnostics: [removed], + diagnosticsAvailable: true, + }); + expect(target?.status).not.toBe('removed'); + }); + + it('filters bindings by scopedAgentIds', () => { + const inScope = agentConnection({ id: 'agent:in', name: 'in' }); + const outScope = agentConnection({ id: 'agent:out', name: 'out' }); + const targets = runDoctor([inScope, outScope], { + scopedAgentIds: ['agent:in'], + }); + expect(targets.map((target) => target.connectionId)).toEqual(['agent:in']); + }); + + it('sorts by status rank desc then displayName asc', () => { + const critical = agentConnection({ + id: 'agent:c', + name: 'c-critical', + state: 'unauthorized', + stateReason: 'bad token', + agentUpdateAvailable: false, + }); + const warning = agentConnection({ + id: 'agent:w', + name: 'w-warning', + state: 'paused', + agentUpdateAvailable: false, + }); + const healthy = agentConnection({ + id: 'agent:h', + name: 'h-healthy', + agentUpdateAvailable: false, + agentVersion: '6.2.0', + expectedAgentVersion: '6.2.0', + fleet: { versionDrift: 'current' } as Connection['fleet'], + }); + const targets = runDoctor([healthy, warning, critical]); + expect(targets.map((target) => target.status)).toEqual(['critical', 'warning', 'healthy']); + }); + + it('breaks status-rank ties by displayName ascending', () => { + const zeta = agentConnection({ + id: 'agent:zeta', + name: 'zeta', + state: 'paused', + agentUpdateAvailable: false, + agentIdentity: { hostname: 'zeta', platform: 'linux' }, + }); + const alpha = agentConnection({ + id: 'agent:alpha', + name: 'alpha', + state: 'paused', + agentUpdateAvailable: false, + agentIdentity: { hostname: 'alpha', platform: 'linux' }, + }); + const targets = runDoctor([zeta, alpha]); + expect(targets.map((target) => target.displayName)).toEqual(['alpha', 'zeta']); + }); +}); + +// ---- collectInfrastructureAgentDoctorTargets: removed records --------------- + +describe('collectInfrastructureAgentDoctorTargets (removed records)', () => { + const removedDiag = (overrides: Partial = {}) => + diagnostic({ + connectionId: 'agent:gone', + rowKey: 'gone-gone', + id: 'gone', + name: 'gone', + hostname: 'gone-host', + status: 'removed', + reasons: [], + repairActions: [], + ...overrides, + }); + + it('adds removed targets only when diagnostics are available and unscoped', () => { + const live = agentConnection({ agentUpdateAvailable: false }); + const base = { + rows: [], + connections: [live], + diagnostics: [removedDiag()], + diagnosticsAvailable: true, + } as const; + + expect( + collectInfrastructureAgentDoctorTargets(base) + .map((target) => target.status) + .includes('removed'), + ).toBe(true); + + // Scoped drilldown suppresses removed records. + expect( + collectInfrastructureAgentDoctorTargets({ ...base, scopedAgentIds: ['agent:host-1'] }) + .map((target) => target.status) + .includes('removed'), + ).toBe(false); + }); + + it('suppresses removed targets when diagnostics are unavailable', () => { + const live = agentConnection({ agentUpdateAvailable: false }); + const targets = runDoctor([live], { + diagnostics: [removedDiag()], + diagnosticsAvailable: false, + }); + expect(targets.map((target) => target.status).includes('removed')).toBe(false); + }); + + it('derives displayName from name, then hostname, then id', () => { + const live = agentConnection({ agentUpdateAvailable: false }); + const withName = runDoctor([live], { + diagnostics: [removedDiag({ name: 'gone', hostname: 'ghost', id: 'x' })], + diagnosticsAvailable: true, + }); + expect(withName.find((target) => target.status === 'removed')?.displayName).toBe('gone'); + + const noName = runDoctor([live], { + diagnostics: [removedDiag({ name: '', hostname: 'ghost', id: 'x' })], + diagnosticsAvailable: true, + }); + expect(noName.find((target) => target.status === 'removed')?.displayName).toBe('ghost'); + + const nothing = runDoctor([live], { + diagnostics: [removedDiag({ name: '', hostname: undefined, id: 'only-id' })], + diagnosticsAvailable: true, + }); + expect(nothing.find((target) => target.status === 'removed')?.displayName).toBe('only-id'); + }); + + it('derives contextLabel from types joined by " + ", falling back to "Removed agent"', () => { + const live = agentConnection({ agentUpdateAvailable: false }); + const typed = runDoctor([live], { + diagnostics: [removedDiag({ types: ['host', 'docker'] })], + diagnosticsAvailable: true, + }); + expect(typed.find((target) => target.status === 'removed')?.contextLabel).toBe('host + docker'); + + const untyped = runDoctor([live], { + diagnostics: [removedDiag({ types: [] })], + diagnosticsAvailable: true, + }); + expect(untyped.find((target) => target.status === 'removed')?.contextLabel).toBe( + 'Removed agent', + ); + }); + + it('carries currentVersion only when the diagnostic version is present', () => { + const live = agentConnection({ agentUpdateAvailable: false }); + const withVersion = runDoctor([live], { + diagnostics: [removedDiag({ version: '5.0.0' })], + diagnosticsAvailable: true, + }); + expect(withVersion.find((target) => target.status === 'removed')?.currentVersion).toBe('5.0.0'); + + const noVersion = runDoctor([live], { + diagnostics: [removedDiag({ version: undefined })], + diagnosticsAvailable: true, + }); + expect(noVersion.find((target) => target.status === 'removed')?.currentVersion).toBe(undefined); + }); + + it('omits profileVersionLabel and derives profileLabel for a removed record', () => { + const live = agentConnection({ agentUpdateAvailable: false }); + // profileVersion undefined exercises the `: undefined` arm of the + // removedDoctorTarget profileVersionLabel ternary. + const withProfile = runDoctor([live], { + diagnostics: [removedDiag({ profileName: 'Drifted', profileVersion: undefined })], + diagnosticsAvailable: true, + }); + const target = withProfile.find((candidate) => candidate.status === 'removed'); + expect(target?.profileVersionLabel).toBeUndefined(); + expect(target?.profileLabel).toBe('Drifted'); + + const noProfile = runDoctor([live], { + diagnostics: [removedDiag({ profileName: '', profileId: '', profileVersion: undefined })], + diagnosticsAvailable: true, + }); + expect(noProfile.find((candidate) => candidate.status === 'removed')?.profileLabel).toBe( + undefined, + ); + }); +}); + +// ---- collectInfrastructureAgentDoctorTargets: ledger-fallback status -------- + +describe('collectInfrastructureAgentDoctorTargets (fallbackStatus via ledger)', () => { + it('classifies a clean current agent as healthy', () => { + const [target] = runDoctor([ + agentConnection({ + agentUpdateAvailable: false, + agentVersion: '6.2.0', + expectedAgentVersion: '6.2.0', + fleet: { versionDrift: 'current' } as Connection['fleet'], + }), + ]); + expect(target?.status).toBe('healthy'); + expect(target?.source).toBe('ledger-fallback'); + expect(target?.commandBlockedReason).toBeUndefined(); + }); + + it('classifies an active agent without versionDrift as unknown', () => { + const [target] = runDoctor([ + agentConnection({ + agentUpdateAvailable: false, + agentVersion: '6.2.0', + expectedAgentVersion: '6.2.0', + }), + ]); + expect(target?.status).toBe('unknown'); + }); + + it('classifies an unauthorized agent as critical (state reason w/ evidence)', () => { + const [target] = runDoctor([ + agentConnection({ + state: 'unauthorized', + stateReason: 'token rejected', + agentUpdateAvailable: false, + }), + ]); + expect(target?.status).toBe('critical'); + expect(target?.reasons.map((reason) => reason.code)).toContain('ledger_unauthorized'); + expect(target?.reasons.find((r) => r.code === 'ledger_unauthorized')?.evidence).toEqual([ + 'token rejected', + ]); + }); + + it('classifies an unreachable agent as critical (no state reason -> empty evidence)', () => { + const [target] = runDoctor([ + agentConnection({ + state: 'unreachable', + stateReason: '', + agentUpdateAvailable: false, + }), + ]); + expect(target?.status).toBe('critical'); + const reason = target?.reasons.find((r) => r.code === 'ledger_unreachable'); + expect(reason?.evidence).toEqual([]); + }); +}); + +// ---- collectInfrastructureAgentDoctorTargets: ledgerFallbackReasons --------- + +describe('collectInfrastructureAgentDoctorTargets (ledgerFallbackReasons state arms)', () => { + it('emits ledger_stale with evidence from stateReason', () => { + const [target] = runDoctor([ + agentConnection({ + state: 'stale', + stateReason: 'no heartbeat', + agentUpdateAvailable: false, + }), + ]); + const reason = target?.reasons.find((r) => r.code === 'ledger_stale'); + expect(reason?.severity).toBe('warning'); + expect(reason?.evidence).toEqual(['no heartbeat']); + }); + + it('emits ledger_stale with empty evidence when stateReason is blank', () => { + const [target] = runDoctor([ + agentConnection({ state: 'stale', stateReason: '', agentUpdateAvailable: false }), + ]); + expect(target?.reasons.find((r) => r.code === 'ledger_stale')?.evidence).toEqual([]); + }); + + it('emits ledger_pending (no evidence variant)', () => { + const [target] = runDoctor([ + agentConnection({ state: 'pending', agentUpdateAvailable: false }), + ]); + const reason = target?.reasons.find((r) => r.code === 'ledger_pending'); + expect(reason?.severity).toBe('warning'); + // fallbackReason() defaults evidence to [] (not undefined). + expect(reason?.evidence).toEqual([]); + }); + + it('emits ledger_paused', () => { + const [target] = runDoctor([agentConnection({ state: 'paused', agentUpdateAvailable: false })]); + expect(target?.reasons.map((r) => r.code)).toContain('ledger_paused'); + }); + + it('emits no state reason for an active connection', () => { + const [target] = runDoctor([ + agentConnection({ + state: 'active', + agentUpdateAvailable: false, + agentVersion: '6.2.0', + expectedAgentVersion: '6.2.0', + }), + ]); + const stateReasons = target?.reasons + .filter((r) => r.code.startsWith('ledger_')) + .map((r) => r.code); + expect(stateReasons).toEqual([]); + }); + + it('emits ledger_update_error with lastError evidence when agentUpdate state is error', () => { + const [target] = runDoctor([ + agentConnection({ + agentUpdateAvailable: false, + agentVersion: '6.2.0', + expectedAgentVersion: '6.2.0', + agentUpdate: { + state: 'error', + autoUpdate: false, + lastError: 'install failed', + }, + }), + ]); + const reason = target?.reasons.find((r) => r.code === 'ledger_update_error'); + expect(reason?.evidence).toEqual(['install failed']); + }); + + it('emits ledger_update_error with empty evidence when lastError is absent', () => { + const [target] = runDoctor([ + agentConnection({ + agentUpdateAvailable: false, + agentVersion: '6.2.0', + expectedAgentVersion: '6.2.0', + agentUpdate: { state: 'error', autoUpdate: false }, + }), + ]); + expect(target?.reasons.find((r) => r.code === 'ledger_update_error')?.evidence).toEqual([]); + }); + + it('emits ledger_module_degraded for an enabled, non-running module', () => { + const [target] = runDoctor([ + agentConnection({ + agentUpdateAvailable: false, + agentVersion: '6.2.0', + expectedAgentVersion: '6.2.0', + agentModules: [ + { + name: 'docker', + enabled: true, + state: 'starting', + lastError: 'startup stalled', + updatedAt: '2026-07-20T00:00:00Z', + }, + ], + }), + ]); + const reason = target?.reasons.find((r) => r.code === 'ledger_module_degraded'); + expect(reason?.message).toBe('docker is enabled but starting.'); + expect(reason?.evidence).toEqual(['startup stalled']); + }); + + it('skips modules that are disabled or already running', () => { + const [target] = runDoctor([ + agentConnection({ + agentUpdateAvailable: false, + agentVersion: '6.2.0', + expectedAgentVersion: '6.2.0', + agentModules: [ + { name: 'host', enabled: false, state: 'disabled', updatedAt: '2026-07-20T00:00:00Z' }, + { name: 'docker', enabled: true, state: 'running', updatedAt: '2026-07-20T00:00:00Z' }, + ], + }), + ]); + expect(target?.reasons.find((r) => r.code === 'ledger_module_degraded')).toBeUndefined(); + }); + + it('appends agent_version_stale when the agent needs an update', () => { + const [target] = runDoctor([ + agentConnection({ + state: 'active', + agentUpdateAvailable: true, + expectedAgentVersion: '6.2.0', + }), + ]); + expect(target?.reasons.map((r) => r.code)).toContain('agent_version_stale'); + }); +}); + +// ---- collectInfrastructureAgentDoctorTargets: diagnostics path -------------- + +describe('collectInfrastructureAgentDoctorTargets (diagnostics path)', () => { + it('uses diagnostic.status and diagnostic.reasons (source diagnostics)', () => { + const [target] = runDoctor([agentConnection()], { + diagnostics: [diagnostic({ status: 'critical' })], + diagnosticsAvailable: true, + targetVersion: '6.2.0', + }); + expect(target?.source).toBe('diagnostics'); + expect(target?.status).toBe('critical'); + expect(target?.reasons[0].code).toBe('agent_version_stale'); + }); + + it('upgrades a healthy diagnostic to warning when fallback reasons exist', () => { + const [target] = runDoctor( + [ + agentConnection({ + state: 'stale', + stateReason: 'no heartbeat', + }), + ], + { + diagnostics: [diagnostic({ status: 'healthy' })], + diagnosticsAvailable: true, + targetVersion: '6.2.0', + }, + ); + // diagnostic.status healthy, but ledger fallback reasons (ledger_stale + + // agent_version_stale) are non-empty -> upgraded to warning. + expect(target?.status).toBe('warning'); + }); + + it('reclassifies to waiting when the updater is handling it asynchronously', () => { + const [target] = runDoctor( + [ + agentConnection({ + state: 'active', + agentUpdate: { + state: 'checking', + autoUpdate: true, + lastCheckedAt: '2026-07-20T09:01:00Z', + }, + }), + ], + { + diagnostics: [diagnostic({ status: 'warning' })], + diagnosticsAvailable: true, + targetVersion: '6.2.0', + }, + ); + // reasons only contain agent_version_stale -> nonVersionReasons empty; + // active + updater.waiting + not critical -> waiting. + expect(target?.status).toBe('waiting'); + expect(target?.updaterLabel).toBe('Checking for an automatic update'); + }); + + it('keeps the original status when a non-version reason blocks the waiting reclass', () => { + const [target] = runDoctor( + [ + agentConnection({ + state: 'active', + agentUpdate: { state: 'checking', autoUpdate: true }, + }), + ], + { + diagnostics: [ + diagnostic({ + status: 'warning', + reasons: [ + { code: 'agent_version_stale', severity: 'warning', message: 'stale' }, + { code: 'module_down', severity: 'warning', message: 'module down' }, + ], + }), + ], + diagnosticsAvailable: true, + targetVersion: '6.2.0', + }, + ); + expect(target?.status).toBe('warning'); + }); + + it('falls back to the ledger path when diagnostics are available but no diagnostic matches', () => { + const [target] = runDoctor( + [agentConnection({ id: 'agent:other', agentUpdateAvailable: false })], + { + diagnostics: [diagnostic({ connectionId: 'agent:host-1' })], + diagnosticsAvailable: true, + }, + ); + expect(target?.source).toBe('ledger-fallback'); + }); +}); + +// ---- collectInfrastructureAgentDoctorTargets: commandBlockedReason ---------- + +describe('collectInfrastructureAgentDoctorTargets (commandBlockedReason arms)', () => { + it('blocks when an update is needed but no expected version is available', () => { + const [target] = runDoctor([ + agentConnection({ + agentUpdateAvailable: true, + expectedAgentVersion: undefined, + }), + ]); + // targetVersion undefined -> expectedVersion undefined -> arm 1. + expect(target?.expectedVersion).toBeUndefined(); + expect(target?.commandBlockedReason).toContain('No supported target version'); + }); + + it('blocks when the expected version is not a supported release', () => { + const [target] = runDoctor([ + agentConnection({ + agentUpdateAvailable: true, + expectedAgentVersion: 'not-a-version', + }), + ]); + expect(target?.commandBlockedReason).toContain('not a supported release version'); + }); + + it('blocks when the agent did not report a recognized platform', () => { + const [target] = runDoctor([ + agentConnection({ + agentUpdateAvailable: true, + expectedAgentVersion: '6.2.0', + agentIdentity: { hostname: 'host-1', platform: 'haiku' }, + }), + ]); + expect(target?.commandPlatform).toBeNull(); + expect(target?.commandBlockedReason).toContain('will not guess'); + }); + + it('blocks FreeBSD/pfSense until installer state is proven', () => { + const [target] = runDoctor([ + agentConnection({ + agentUpdateAvailable: true, + expectedAgentVersion: '6.2.0', + agentIdentity: { hostname: 'fw', platform: 'pfSense', architecture: 'amd64' }, + }), + ]); + expect(target?.commandPlatform).toBe('freebsd'); + expect(target?.commandBlockedReason).toContain('cannot verify saved FreeBSD'); + }); + + it('blocks while the eligible v6 updater is handling the update', () => { + const [target] = runDoctor([ + agentConnection({ + state: 'active', + agentUpdateAvailable: true, + expectedAgentVersion: '6.2.0', + agentIdentity: { hostname: 'host-1', platform: 'ubuntu', architecture: 'amd64' }, + agentUpdate: { state: 'checking', autoUpdate: true }, + }), + ]); + expect(target?.commandBlockedReason).toContain('handling the update asynchronously'); + }); + + it('blocks when diagnostics offer no supported structured upgrade action', () => { + const [target] = runDoctor([agentConnection()], { + diagnostics: [ + diagnostic({ + repairActions: [ + { code: 'restart_agent', label: 'Restart', description: '...', supported: true }, + ], + }), + ], + diagnosticsAvailable: true, + targetVersion: '6.2.0', + }); + expect(target?.commandBlockedReason).toContain('did not offer a supported update repair'); + }); + + it('also blocks when the structured upgrade action is present but unsupported', () => { + const [target] = runDoctor([agentConnection()], { + diagnostics: [ + diagnostic({ + repairActions: [ + { + code: 'copy_upgrade_command', + label: 'Copy', + description: '...', + supported: false, + }, + ], + }), + ], + diagnosticsAvailable: true, + targetVersion: '6.2.0', + }); + expect(target?.commandBlockedReason).toContain('did not offer a supported update repair'); + }); + + it('does not block when a supported structured upgrade action is available', () => { + const [target] = runDoctor([agentConnection()], { + diagnostics: [diagnostic()], + diagnosticsAvailable: true, + targetVersion: '6.2.0', + }); + expect(target?.commandPlatform).toBe('linux'); + expect(target?.commandBlockedReason).toBeUndefined(); + }); + + it('does not block when the agent does not need an update', () => { + const [target] = runDoctor([ + agentConnection({ + agentUpdateAvailable: false, + agentVersion: '6.2.0', + expectedAgentVersion: '6.2.0', + }), + ]); + expect(target?.needsUpdate).toBe(false); + expect(target?.commandBlockedReason).toBeUndefined(); + }); +}); + +// ---- collectInfrastructureAgentDoctorTargets: updaterPresentation ----------- + +describe('collectInfrastructureAgentDoctorTargets (updaterPresentation arms)', () => { + const updaterFor = (state: ConnectionAgentUpdateStatus['state'], autoUpdate: boolean) => + runDoctor( + [ + agentConnection({ + agentUpdateAvailable: true, + expectedAgentVersion: '6.2.0', + agentIdentity: { hostname: 'host-1', platform: 'ubuntu', architecture: 'amd64' }, + agentUpdate: { state, autoUpdate }, + }), + ], + // Use diagnostics with a supported upgrade action so commandBlockedReason + // doesn't interfere with status / label assertions. + { + diagnostics: [diagnostic()], + diagnosticsAvailable: true, + targetVersion: '6.2.0', + }, + )[0]?.updaterLabel; + + it('returns no label when the connection has no agentUpdate', () => { + const [target] = runDoctor([ + agentConnection({ + agentUpdateAvailable: false, + agentVersion: '6.2.0', + expectedAgentVersion: '6.2.0', + agentUpdate: undefined, + }), + ]); + expect(target?.updaterLabel).toBeUndefined(); + }); + + it.each([ + ['updating', 'Updating automatically'], + ['disabled', 'Automatic updates disabled'], + ['error', 'Last update attempt failed'], + ] as const)('labels %s state', (state, expected) => { + expect(updaterFor(state, false)).toBe(expected); + }); + + it('labels checking + autoUpdate as "Checking for an automatic update"', () => { + expect(updaterFor('checking', true)).toBe('Checking for an automatic update'); + }); + + it('labels checking + manual as "Checking for an update"', () => { + expect(updaterFor('checking', false)).toBe('Checking for an update'); + }); + + it('labels update-available + autoUpdate as "Update queued automatically"', () => { + expect(updaterFor('update-available', true)).toBe('Update queued automatically'); + }); + + it('labels update-available + manual as "Update available; manual action required"', () => { + expect(updaterFor('update-available', false)).toBe('Update available; manual action required'); + }); + + it('labels idle + needsUpdate + autoUpdate as "Waiting for the next automatic check"', () => { + expect(updaterFor('idle', true)).toBe('Waiting for the next automatic check'); + }); + + it('labels idle + current + autoUpdate as "Automatic updates ready"', () => { + const [target] = runDoctor( + [ + agentConnection({ + agentUpdateAvailable: false, + agentVersion: '6.2.0', + expectedAgentVersion: '6.2.0', + agentIdentity: { hostname: 'host-1', platform: 'ubuntu', architecture: 'amd64' }, + agentUpdate: { state: 'idle', autoUpdate: true }, + }), + ], + { + diagnostics: [diagnostic()], + diagnosticsAvailable: true, + targetVersion: '6.2.0', + }, + ); + expect(target?.updaterLabel).toBe('Automatic updates ready'); + }); + + it('labels idle + manual as "Manual updates only"', () => { + const [target] = runDoctor( + [ + agentConnection({ + agentUpdateAvailable: false, + agentVersion: '6.2.0', + expectedAgentVersion: '6.2.0', + agentIdentity: { hostname: 'host-1', platform: 'ubuntu', architecture: 'amd64' }, + agentUpdate: { state: 'idle', autoUpdate: false }, + }), + ], + { + diagnostics: [diagnostic()], + diagnosticsAvailable: true, + targetVersion: '6.2.0', + }, + ); + expect(target?.updaterLabel).toBe('Manual updates only'); + }); + + it('surfaces an unknown updater state as "Updater state: "', () => { + const [target] = runDoctor( + [ + agentConnection({ + agentUpdateAvailable: false, + agentVersion: '6.2.0', + expectedAgentVersion: '6.2.0', + agentIdentity: { hostname: 'host-1', platform: 'ubuntu', architecture: 'amd64' }, + agentUpdate: { + state: 'syncing', + autoUpdate: false, + } as unknown as ConnectionAgentUpdateStatus, + }), + ], + { + diagnostics: [diagnostic()], + diagnosticsAvailable: true, + targetVersion: '6.2.0', + }, + ); + expect(target?.updaterLabel).toBe('Updater state: syncing'); + }); + + it('returns no label for a blank updater state', () => { + const [target] = runDoctor( + [ + agentConnection({ + agentUpdateAvailable: false, + agentVersion: '6.2.0', + expectedAgentVersion: '6.2.0', + agentIdentity: { hostname: 'host-1', platform: 'ubuntu', architecture: 'amd64' }, + agentUpdate: { + state: ' ', + autoUpdate: false, + } as unknown as ConnectionAgentUpdateStatus, + }), + ], + { + diagnostics: [diagnostic()], + diagnosticsAvailable: true, + targetVersion: '6.2.0', + }, + ); + expect(target?.updaterLabel).toBeUndefined(); + }); +}); + +// ---- collectInfrastructureAgentDoctorTargets: evidence & profile ----------- + +describe('collectInfrastructureAgentDoctorTargets (evidence + profile labels)', () => { + it('aggregates connection, identity, updater, and diagnostic evidence', () => { + const [target] = runDoctor( + [ + agentConnection({ + agentIdentity: { + hostname: 'host-1', + platform: 'ubuntu', + architecture: 'amd64', + reportIp: '10.0.0.5', + }, + agentUpdate: { + state: 'idle', + autoUpdate: true, + lastCheckedAt: '2026-07-20T01:00:00Z', + lastAttemptAt: '2026-07-20T02:00:00Z', + lastSuccessAt: '2026-07-20T03:00:00Z', + }, + }), + ], + { + diagnostics: [ + diagnostic({ + machineIdFingerprint: 'machine-abc', + interfaceAddresses: ['10.0.0.6', '10.0.0.7'], + reasons: [ + { + code: 'agent_version_stale', + severity: 'warning', + message: 'stale', + evidence: ['Reported v6.1.0; target v6.2.0'], + }, + ], + }), + ], + diagnosticsAvailable: true, + targetVersion: '6.2.0', + }, + ); + + expect(target?.evidence).toEqual( + expect.arrayContaining([ + 'Connection: agent:host-1', + 'Hostname: host-1', + 'Platform: ubuntu / amd64', + 'Reported IP: 10.0.0.5', + 'Last updater check: 2026-07-20T01:00:00Z', + 'Last update attempt: 2026-07-20T02:00:00Z', + 'Last successful update: 2026-07-20T03:00:00Z', + 'Machine identity: machine-abc', + 'Reported interface: 10.0.0.6', + 'Reported interface: 10.0.0.7', + 'Reported v6.1.0; target v6.2.0', + ]), + ); + }); + + it('prefers profileName for profileLabel, falling back to profileId then undefined', () => { + const withName = runDoctor([agentConnection()], { + diagnostics: [diagnostic({ profileName: 'Linux servers', profileId: 'pid' })], + diagnosticsAvailable: true, + targetVersion: '6.2.0', + })[0]; + expect(withName?.profileLabel).toBe('Linux servers'); + + const withIdOnly = runDoctor([agentConnection()], { + diagnostics: [diagnostic({ profileName: '', profileId: 'pid' })], + diagnosticsAvailable: true, + targetVersion: '6.2.0', + })[0]; + expect(withIdOnly?.profileLabel).toBe('pid'); + + const none = runDoctor([agentConnection()], { + diagnostics: [diagnostic({ profileName: '', profileId: '' })], + diagnosticsAvailable: true, + targetVersion: '6.2.0', + })[0]; + expect(none?.profileLabel).toBeUndefined(); + }); + + it('builds profileVersionLabel when profileVersion is present (defaulting deployed to 0)', () => { + const withDeployed = runDoctor([agentConnection()], { + diagnostics: [diagnostic({ profileVersion: 4, deployedProfileVersion: 3 })], + diagnosticsAvailable: true, + targetVersion: '6.2.0', + })[0]; + expect(withDeployed?.profileVersionLabel).toBe('Expected v4 · deployed v3'); + + const noDeployed = runDoctor([agentConnection()], { + diagnostics: [diagnostic({ profileVersion: 4, deployedProfileVersion: undefined })], + diagnosticsAvailable: true, + targetVersion: '6.2.0', + })[0]; + expect(noDeployed?.profileVersionLabel).toBe('Expected v4 · deployed v0'); + + const absent = runDoctor([agentConnection()], { + diagnostics: [diagnostic({ profileVersion: undefined })], + diagnosticsAvailable: true, + targetVersion: '6.2.0', + })[0]; + expect(absent?.profileVersionLabel).toBeUndefined(); + }); + + it('uses connection.lastSeen, falling back to diagnostic.lastSeen', () => { + const fromConnection = runDoctor([agentConnection({ lastSeen: '2026-07-20T00:00:00Z' })])[0]; + expect(fromConnection?.lastSeen).toBe('2026-07-20T00:00:00Z'); + + const fromDiagnostic = runDoctor([agentConnection({ lastSeen: null })], { + diagnostics: [diagnostic({ lastSeen: 1700000000 })], + diagnosticsAvailable: true, + targetVersion: '6.2.0', + })[0]; + expect(fromDiagnostic?.lastSeen).toBe(1700000000); + }); + + it('binds install flags from a host row when the agent is attached to it', () => { + const agent = agentConnection({ + id: 'agent:docker-host', + name: 'docker-host', + agentIdentity: { hostname: 'docker-host', platform: 'linux' }, + }); + const targets = collectInfrastructureAgentDoctorTargets({ + rows: [ + row({ + ownerType: 'docker', + isAgent: false, + name: 'docker-host', + connection: agentConnection({ id: 'docker:host', type: 'docker', name: 'docker-host' }), + attachedConnections: [agent], + }), + ], + connections: [agent], + diagnosticsAvailable: false, + }); + const target = targets.find((candidate) => candidate.connectionId === 'agent:docker-host'); + expect(target?.installFlags).toEqual(['--enable-docker']); + }); + + it('binds member agent connections with the host row install flags', () => { + const agent = agentConnection({ + id: 'agent:member', + name: 'member', + agentIdentity: { hostname: 'member', platform: 'linux' }, + }); + const targets = collectInfrastructureAgentDoctorTargets({ + rows: [ + row({ + ownerType: 'pve', + isAgent: false, + name: 'cluster', + connection: agentConnection({ id: 'pve:cluster', type: 'pve', name: 'cluster' }), + attachedConnections: [], + members: [member({ agentConnection: agent })], + }), + ], + connections: [agent], + diagnosticsAvailable: false, + }); + const target = targets.find((candidate) => candidate.connectionId === 'agent:member'); + expect(target?.installFlags).toEqual(['--enable-proxmox', '--proxmox-type pve']); + }); + + it('skips a member whose agentConnection is undefined', () => { + const targets = collectInfrastructureAgentDoctorTargets({ + rows: [ + row({ + ownerType: 'pve', + isAgent: false, + name: 'cluster', + connection: agentConnection({ id: 'pve:cluster', type: 'pve', name: 'cluster' }), + members: [member({ agentConnection: undefined })], + }), + ], + connections: [], + diagnosticsAvailable: false, + }); + expect(targets).toEqual([]); + }); +}); + +// ---- summarizeInfrastructureAgentDoctorTargets ------------------------------ + +describe('summarizeInfrastructureAgentDoctorTargets', () => { + const target = (status: InfrastructureAgentDoctorStatus): InfrastructureAgentDoctorTarget => + ({ status }) as unknown as InfrastructureAgentDoctorTarget; + + it('returns all-zero counts for an empty list', () => { + expect(summarizeInfrastructureAgentDoctorTargets([])).toEqual({ + total: 0, + healthy: 0, + waiting: 0, + warning: 0, + critical: 0, + unknown: 0, + removed: 0, + }); + }); + + it('counts each status arm independently', () => { + const targets: InfrastructureAgentDoctorTarget[] = [ + target('healthy'), + target('healthy'), + target('waiting'), + target('warning'), + target('warning'), + target('warning'), + target('critical'), + target('unknown'), + target('removed'), + ]; + expect(summarizeInfrastructureAgentDoctorTargets(targets)).toEqual({ + total: 9, + healthy: 2, + waiting: 1, + warning: 3, + critical: 1, + unknown: 1, + removed: 1, + }); + }); + + it('reflects the full live+removed pipeline end to end', () => { + const live = agentConnection({ agentUpdateAvailable: false }); + const targets = collectInfrastructureAgentDoctorTargets({ + rows: [], + connections: [live], + diagnostics: [diagnostic({ connectionId: 'agent:gone', status: 'removed', reasons: [] })], + diagnosticsAvailable: true, + }); + // One healthy/unknown ledger target + one removed record. + expect(summarizeInfrastructureAgentDoctorTargets(targets).total).toBeGreaterThanOrEqual(2); + expect(summarizeInfrastructureAgentDoctorTargets(targets).removed).toBe(1); + }); +}); diff --git a/frontend-modern/src/features/patrol/__tests__/patrolRunAcceptance.branchcov0720pm.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolRunAcceptance.branchcov0720pm.test.ts new file mode 100644 index 000000000..19d4090bd --- /dev/null +++ b/frontend-modern/src/features/patrol/__tests__/patrolRunAcceptance.branchcov0720pm.test.ts @@ -0,0 +1,205 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { PatrolRunRecord, PatrolStatus } from '@/api/patrol'; +import { + schedulePatrolRunAcceptanceReconciliation, + type PatrolRunAcceptanceOutcome, +} from '../patrolRunAcceptance'; + +const runningStatus = (currentRunId?: string): PatrolStatus => + ({ + runtime_state: 'running', + running: true, + enabled: true, + ...(currentRunId !== undefined ? { current_run_id: currentRunId } : {}), + }) as PatrolStatus; + +const idleStatus = (): PatrolStatus => + ({ runtime_state: 'active', running: false, enabled: true }) as PatrolStatus; + +const completedRun = (id: string): PatrolRunRecord => + ({ id, status: 'healthy' }) as PatrolRunRecord; + +interface Scenario { + runId?: string; + delayMs?: number; + refreshTimeoutMs?: number; + isCurrent?: () => boolean; + getStatus: () => Promise; + getHistory: () => Promise; +} + +const scheduleScenario = (scenario: Scenario) => { + const onResult = vi.fn<(outcome: PatrolRunAcceptanceOutcome) => void>(); + const cancel = schedulePatrolRunAcceptanceReconciliation({ + runId: scenario.runId ?? 'run-1', + delayMs: scenario.delayMs ?? 1_000, + refreshTimeoutMs: scenario.refreshTimeoutMs ?? 5_000, + getStatus: scenario.getStatus, + getHistory: scenario.getHistory, + isCurrent: scenario.isCurrent ?? (() => true), + onResult, + }); + return { onResult, cancel }; +}; + +describe('schedulePatrolRunAcceptanceReconciliation — branch coverage', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("kind 'running'", () => { + it('accepts when status.current_run_id === runId', async () => { + const { onResult } = scheduleScenario({ + getStatus: async () => runningStatus('run-1'), + getHistory: async () => [], + }); + await vi.advanceTimersByTimeAsync(1_000); + expect(onResult).toHaveBeenCalledTimes(1); + const outcome = onResult.mock.calls[0][0]; + expect(outcome.kind).toBe('running'); + expect((outcome as { status: PatrolStatus }).status.current_run_id).toBe('run-1'); + }); + + it('accepts when runId is empty (no specific run tracked)', async () => { + const { onResult } = scheduleScenario({ + runId: '', + getStatus: async () => runningStatus('run-1'), + getHistory: async () => [], + }); + await vi.advanceTimersByTimeAsync(1_000); + expect(onResult).toHaveBeenCalledTimes(1); + expect(onResult.mock.calls[0][0].kind).toBe('running'); + }); + + it('accepts when status has no current_run_id', async () => { + const { onResult } = scheduleScenario({ + getStatus: async () => runningStatus(undefined), + getHistory: async () => [], + }); + await vi.advanceTimersByTimeAsync(1_000); + expect(onResult).toHaveBeenCalledTimes(1); + expect(onResult.mock.calls[0][0].kind).toBe('running'); + }); + }); + + describe("kind 'recorded'", () => { + it('records when patrol is idle but history contains the accepted run', async () => { + const { onResult } = scheduleScenario({ + getStatus: async () => idleStatus(), + getHistory: async () => [completedRun('run-1')], + }); + await vi.advanceTimersByTimeAsync(1_000); + expect(onResult).toHaveBeenCalledTimes(1); + const outcome = onResult.mock.calls[0][0]; + expect(outcome.kind).toBe('recorded'); + expect((outcome as { run: PatrolRunRecord }).run.id).toBe('run-1'); + }); + + it('records when a newer run has taken over the patrol slot', async () => { + const { onResult } = scheduleScenario({ + getStatus: async () => runningStatus('run-newer'), + getHistory: async () => [completedRun('run-1')], + }); + await vi.advanceTimersByTimeAsync(1_000); + expect(onResult).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'recorded', + run: expect.objectContaining({ id: 'run-1' }), + }), + ); + }); + }); + + describe("kind 'refresh_failed'", () => { + it('reports the status read rejection reason', async () => { + const statusError = new Error('status down'); + const { onResult } = scheduleScenario({ + getStatus: () => Promise.reject(statusError), + getHistory: async () => [], + }); + await vi.advanceTimersByTimeAsync(1_000); + expect(onResult).toHaveBeenCalledTimes(1); + const outcome = onResult.mock.calls[0][0]; + expect(outcome.kind).toBe('refresh_failed'); + expect((outcome as { error: unknown }).error).toBe(statusError); + }); + + it('reports the history read rejection reason', async () => { + const historyError = new Error('history down'); + const { onResult } = scheduleScenario({ + getStatus: async () => idleStatus(), + getHistory: () => Promise.reject(historyError), + }); + await vi.advanceTimersByTimeAsync(1_000); + expect(onResult).toHaveBeenCalledTimes(1); + const outcome = onResult.mock.calls[0][0]; + expect(outcome.kind).toBe('refresh_failed'); + expect((outcome as { error: unknown }).error).toBe(historyError); + }); + + it('bounds hung reads with the refresh timeout', async () => { + const { onResult } = scheduleScenario({ + delayMs: 1_000, + refreshTimeoutMs: 2_000, + getStatus: () => new Promise(() => undefined), + getHistory: () => new Promise(() => undefined), + }); + await vi.advanceTimersByTimeAsync(3_000); + expect(onResult).toHaveBeenCalledTimes(1); + expect(onResult.mock.calls[0][0].kind).toBe('refresh_failed'); + }); + }); + + describe("kind 'missing'", () => { + it('falls through when idle, history lacks the run, and no refresh failed', async () => { + const { onResult } = scheduleScenario({ + getStatus: async () => idleStatus(), + getHistory: async () => [completedRun('run-other')], + }); + await vi.advanceTimersByTimeAsync(1_000); + expect(onResult).toHaveBeenCalledWith({ kind: 'missing' }); + }); + + it('falls through to missing when no runId is tracked and nothing is running', async () => { + const { onResult } = scheduleScenario({ + runId: '', + getStatus: async () => idleStatus(), + getHistory: async () => [completedRun('run-1')], + }); + await vi.advanceTimersByTimeAsync(1_000); + expect(onResult).toHaveBeenCalledWith({ kind: 'missing' }); + }); + }); + + describe('guard branches', () => { + it('suppresses onResult when isCurrent() is false after the delay', async () => { + const getStatus = vi.fn(async () => runningStatus('run-1')); + const getHistory = vi.fn(async () => [] as PatrolRunRecord[]); + const { onResult } = scheduleScenario({ + getStatus, + getHistory, + isCurrent: () => false, + }); + await vi.advanceTimersByTimeAsync(1_000); + expect(getStatus).toHaveBeenCalledTimes(1); + expect(getHistory).toHaveBeenCalledTimes(1); + expect(onResult).not.toHaveBeenCalled(); + }); + + it('cancels before the delay fires: clears the timer and skips the reads', async () => { + const getStatus = vi.fn(async () => runningStatus('run-1')); + const getHistory = vi.fn(async () => [] as PatrolRunRecord[]); + const { onResult, cancel } = scheduleScenario({ getStatus, getHistory }); + cancel(); + await vi.advanceTimersByTimeAsync(10_000); + expect(getStatus).not.toHaveBeenCalled(); + expect(getHistory).not.toHaveBeenCalled(); + expect(onResult).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.branchcov0720pm.test.ts b/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.branchcov0720pm.test.ts new file mode 100644 index 000000000..86f0d39d1 --- /dev/null +++ b/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.branchcov0720pm.test.ts @@ -0,0 +1,677 @@ +import { describe, expect, it } from 'vitest'; +import type { PhysicalDiskFieldStatus, Resource } from '@/types/resource'; +import { + PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS, + buildPhysicalDiskPresentationDataMap, + buildPhysicalDiskGroupFilterOptions, + buildPhysicalDiskRoleFilterOptions, + comparePhysicalDiskPresentation, + extractPhysicalDiskPresentationData, + filterAndSortPhysicalDisks, + getPhysicalDiskCollectionMessages, + getPhysicalDiskEmptyStatePresentation, + getPhysicalDiskFieldStatusMessage, + getPhysicalDiskHealthStatus, + getPhysicalDiskHealthSummary, + getPhysicalDiskHostLabel, + getPhysicalDiskLifeTextClass, + getPhysicalDiskNormalizedHealth, + getPhysicalDiskPlatformLabel, + getPhysicalDiskRoleLabel, + getPhysicalDiskSourceBadgePresentation, + hasPhysicalDiskSmartWarning, + hasUnraidPhysicalDiskFaultSignal, + isUnraidPhysicalDisk, + matchesPhysicalDiskHealthFilter, + matchesPhysicalDiskSearch, + type PhysicalDiskPresentationData, +} from '@/features/storageBackups/diskPresentation'; + +function makeDiskData( + overrides: Partial = {}, +): PhysicalDiskPresentationData { + return { + node: '', + instance: '', + devPath: '', + model: '', + serial: '', + wwn: '', + size: 0, + health: 'UNKNOWN', + riskReasons: [], + wearout: -1, + type: '', + temperature: 0, + rpm: 0, + used: '', + ...overrides, + }; +} + +describe('diskPresentation.branchcov0720pm', () => { + describe('getPhysicalDiskHealthFilterEmptyTitle switch arms', () => { + it('returns the matching empty-state title for the healthy filter', () => { + expect( + getPhysicalDiskEmptyStatePresentation({ + selectedNodeName: null, + searchTerm: '', + diskCount: 2, + hasPVENodes: true, + healthFilter: 'healthy', + }).title, + ).toBe('No healthy disks found'); + }); + + it('returns the matching empty-state title for the warning filter', () => { + expect( + getPhysicalDiskEmptyStatePresentation({ + selectedNodeName: null, + searchTerm: '', + diskCount: 2, + hasPVENodes: true, + healthFilter: 'warning', + }).title, + ).toBe('No warning disks found'); + }); + + it('returns the matching empty-state title for the critical filter', () => { + expect( + getPhysicalDiskEmptyStatePresentation({ + selectedNodeName: null, + searchTerm: '', + diskCount: 2, + hasPVENodes: true, + healthFilter: 'critical', + }).title, + ).toBe('No critical disks found'); + }); + + it('returns the matching empty-state title for the offline filter', () => { + expect( + getPhysicalDiskEmptyStatePresentation({ + selectedNodeName: null, + searchTerm: '', + diskCount: 2, + hasPVENodes: true, + healthFilter: 'offline', + }).title, + ).toBe('No offline disks found'); + }); + + it('returns the matching empty-state title for the unknown filter', () => { + expect( + getPhysicalDiskEmptyStatePresentation({ + selectedNodeName: null, + searchTerm: '', + diskCount: 2, + hasPVENodes: true, + healthFilter: 'unknown', + }).title, + ).toBe('No disks with unknown health'); + }); + + it('falls back to the default-empty title when healthFilter is "all" but disks are present', () => { + expect( + getPhysicalDiskEmptyStatePresentation({ + selectedNodeName: null, + searchTerm: 'zzz', + diskCount: 2, + hasPVENodes: true, + healthFilter: 'all', + }).title, + ).toBe('No disks match these filters'); + }); + }); + + describe('getPhysicalDiskEmptyStatePresentation ternary arms', () => { + it('returns null nodeMessage and searchMessage when nothing is selected', () => { + const presentation = getPhysicalDiskEmptyStatePresentation({ + selectedNodeName: null, + searchTerm: '', + diskCount: 0, + hasPVENodes: false, + }); + expect(presentation.nodeMessage).toBeNull(); + expect(presentation.searchMessage).toBeNull(); + expect(presentation.showRequirements).toBe(false); + }); + + it('returns a searchMessage when a searchTerm is supplied', () => { + const presentation = getPhysicalDiskEmptyStatePresentation({ + selectedNodeName: null, + searchTerm: 'wd20', + diskCount: 0, + hasPVENodes: false, + }); + expect(presentation.searchMessage).toBe('matching "wd20"'); + }); + }); + + describe('getPhysicalDiskFieldStatusMessage default arm', () => { + it('returns empty string for an unrecognized state via the default switch arm', () => { + const malformed = { + state: 'totally-bogus', + source: 'test', + } as unknown as PhysicalDiskFieldStatus; + expect(getPhysicalDiskFieldStatusMessage('Field', malformed)).toBe(''); + }); + + it('returns empty string when status is null or available', () => { + expect(getPhysicalDiskFieldStatusMessage('Field', null)).toBe(''); + expect( + getPhysicalDiskFieldStatusMessage('Field', { state: 'available', source: 'test' }), + ).toBe(''); + }); + }); + + describe('getPhysicalDiskCollectionMessages empty paths', () => { + it('returns an empty array when the disk has no collection evidence', () => { + expect(getPhysicalDiskCollectionMessages(makeDiskData())).toEqual([]); + }); + }); + + describe('getPhysicalDiskPlatformLabel fallback', () => { + it('returns "Unknown" when no fallback label is supplied', () => { + expect(getPhysicalDiskPlatformLabel({} as Resource, '')).toBe('Unknown'); + }); + }); + + describe('getPhysicalDiskSourceBadgePresentation fallback arms', () => { + it('falls back to the platform-derived label and base tone when the presentation is missing', () => { + const badge = getPhysicalDiskSourceBadgePresentation({ + platformType: '', + } as unknown as Resource); + expect(badge.label).toBe('Unknown'); + expect(badge.className).toContain('text-base-content'); + }); + }); + + describe('getPhysicalDiskHostLabel fallback arms', () => { + it('falls back to resource.parentName when disk.node is empty', () => { + expect( + getPhysicalDiskHostLabel(makeDiskData(), { parentName: 'pve-node-1' } as Resource), + ).toBe('pve-node-1'); + }); + + it('returns an empty string when neither node nor parentName is set', () => { + expect(getPhysicalDiskHostLabel(makeDiskData(), {} as Resource)).toBe(''); + }); + }); + + describe('extractPhysicalDiskPresentationData fallback arms', () => { + it('reads physicalDisk from platformData when the top-level field is absent', () => { + const resource = { + name: 'fallback-disk', + platformData: { physicalDisk: { devPath: '/dev/sdz', health: 'PASSED', wearout: 88 } }, + } as unknown as Resource; + const data = extractPhysicalDiskPresentationData(resource); + expect(data.devPath).toBe('/dev/sdz'); + expect(data.health).toBe('PASSED'); + expect(data.wearout).toBe(88); + }); + + it('returns defaults when neither physicalDisk nor platformData.physicalDisk is present', () => { + const data = extractPhysicalDiskPresentationData({ name: 'empty' } as unknown as Resource); + expect(data.devPath).toBe(''); + expect(data.model).toBe('empty'); + expect(data.health).toBe('UNKNOWN'); + expect(data.wearout).toBe(-1); + expect(data.temperature).toBe(0); + expect(data.rpm).toBe(0); + expect(data.riskLevel).toBeUndefined(); + expect(data.smartAttributes).toBeUndefined(); + }); + + it('coerces zero values through the ?? and || branches', () => { + const data = extractPhysicalDiskPresentationData({ + name: 'zeroed', + physicalDisk: { + devPath: '', + model: '', + serial: '', + wwn: '', + diskType: '', + sizeBytes: 0, + health: '', + wearout: 0, + temperature: 0, + rpm: 0, + used: '', + }, + } as unknown as Resource); + expect(data.model).toBe('zeroed'); + expect(data.health).toBe('UNKNOWN'); + expect(data.size).toBe(0); + }); + + it('returns an empty model when neither pd.model nor resource.name is set', () => { + const data = extractPhysicalDiskPresentationData({ + name: '', + physicalDisk: { devPath: '/dev/sda' }, + } as unknown as Resource); + expect(data.model).toBe(''); + }); + + it('reads sources from platformData.sourceStatus object keys', () => { + const resource = { + name: 'src', + platformData: { sourceStatus: { proxmox: { ok: true }, agent: { ok: false } } }, + } as unknown as Resource; + expect(getPhysicalDiskNormalizedHealth(resource, makeDiskData({ health: 'UNKNOWN' }))).toBe( + 'unknown', + ); + }); + + it('treats a non-object sourceStatus as no extra sources', () => { + const resource = { + name: 'srcstr', + platformData: { sourceStatus: 'not-an-object' as unknown as object }, + } as unknown as Resource; + expect(getPhysicalDiskNormalizedHealth(resource, makeDiskData({ health: 'UNKNOWN' }))).toBe( + 'unknown', + ); + }); + }); + + describe('Unraid detection branch arms', () => { + it('detects Unraid disks via storageState + storageRole without the unraid-array group', () => { + const disk = makeDiskData({ + storageRole: 'parity', + storageState: 'online', + storageGroup: 'other-group', + }); + expect(isUnraidPhysicalDisk(disk)).toBe(true); + }); + + it('rejects disks whose role is not a known Unraid role even with a storage state', () => { + const disk = makeDiskData({ + storageRole: 'spare', + storageState: 'online', + storageGroup: 'other-group', + }); + expect(isUnraidPhysicalDisk(disk)).toBe(false); + }); + + it('rejects disks with no storageState and a non-unraid group', () => { + const disk = makeDiskData({ + storageRole: 'data', + storageState: '', + storageGroup: 'other-group', + }); + expect(isUnraidPhysicalDisk(disk)).toBe(false); + }); + + it('returns false from the fault-signal helper for non-Unraid disks', () => { + expect( + hasUnraidPhysicalDiskFaultSignal(makeDiskData({ health: 'FAILED', storageRole: 'spare' })), + ).toBe(false); + }); + + it('flags Unraid disks via bad health states even without errorCount', () => { + const disk = makeDiskData({ + health: 'FAULTED', + storageRole: 'data', + storageGroup: 'unraid-array', + storageState: 'disabled', + }); + expect(hasUnraidPhysicalDiskFaultSignal(disk)).toBe(true); + }); + }); + + describe('hasPhysicalDiskSmartWarning attribute arms', () => { + it('detects warnings from reallocated sectors', () => { + expect( + hasPhysicalDiskSmartWarning({ + ...makeDiskData(), + smartAttributes: { reallocatedSectors: 1 }, + }), + ).toBe(true); + }); + + it('detects warnings from media errors', () => { + expect( + hasPhysicalDiskSmartWarning({ + ...makeDiskData(), + smartAttributes: { mediaErrors: 4 }, + }), + ).toBe(true); + }); + + it('returns false when attrs are present but all counters are zero', () => { + expect( + hasPhysicalDiskSmartWarning({ + ...makeDiskData(), + smartAttributes: { reallocatedSectors: 0, pendingSectors: 0, mediaErrors: 0 }, + }), + ).toBe(false); + }); + }); + + describe('getPhysicalDiskHealthStatus remaining arms', () => { + it('returns Healthy for PASSED health on a non-Unraid disk', () => { + const disk = makeDiskData({ health: 'PASSED', type: 'ssd', wearout: 90 }); + expect(getPhysicalDiskHealthStatus(disk)).toMatchObject({ + label: 'Healthy', + summary: 'No active disk-health issues.', + }); + }); + + it('returns Healthy for GOOD health', () => { + const disk = makeDiskData({ health: 'GOOD', type: 'hdd', wearout: 90 }); + expect(getPhysicalDiskHealthStatus(disk).label).toBe('Healthy'); + }); + + it('returns Unknown for an Unraid disk that is not in the online state', () => { + const disk = makeDiskData({ + health: 'UNKNOWN', + storageRole: 'data', + storageGroup: 'unraid-array', + storageState: 'standby', + }); + expect(getPhysicalDiskHealthStatus(disk).label).toBe('Unknown'); + }); + + it('uses "SMART counters indicate elevated risk." when low life is not the cause', () => { + const disk = makeDiskData({ + health: 'PASSED', + type: 'hdd', + wearout: 60, + smartAttributes: { pendingSectors: 5 }, + }); + expect(getPhysicalDiskHealthStatus(disk).summary).toBe( + 'SMART counters indicate elevated risk.', + ); + }); + + it('uses "SSD life is running low." when wearout is below 10', () => { + const disk = makeDiskData({ + health: 'PASSED', + type: 'ssd', + wearout: 5, + }); + expect(getPhysicalDiskHealthStatus(disk).summary).toBe('SSD life is running low.'); + }); + }); + + describe('getPhysicalDiskNormalizedHealth coverage', () => { + it('classifies an offline resource as offline', () => { + const disk = makeDiskData({ health: 'PASSED', type: 'ssd', wearout: 90 }); + const resource = { status: 'offline' } as Resource; + expect(getPhysicalDiskNormalizedHealth(resource, disk)).toBe('offline'); + }); + + it('classifies a failed disk as critical', () => { + const disk = makeDiskData({ health: 'FAILED', riskLevel: 'critical' }); + expect(getPhysicalDiskNormalizedHealth({} as Resource, disk)).toBe('critical'); + }); + + it('classifies a SMART-warning disk as warning', () => { + const disk = makeDiskData({ + health: 'PASSED', + type: 'hdd', + wearout: 60, + smartAttributes: { pendingSectors: 2 }, + }); + expect(getPhysicalDiskNormalizedHealth({} as Resource, disk)).toBe('warning'); + }); + + it('classifies a healthy disk as healthy', () => { + const disk = makeDiskData({ health: 'PASSED', type: 'ssd', wearout: 90 }); + expect(getPhysicalDiskNormalizedHealth({} as Resource, disk)).toBe('healthy'); + }); + + it('classifies an UNKNOWN-health non-Unraid disk as unknown', () => { + const disk = makeDiskData({ health: 'UNKNOWN' }); + expect(getPhysicalDiskNormalizedHealth({} as Resource, disk)).toBe('unknown'); + }); + + it('treats an empty/undefined health string as Unknown label', () => { + const empty = makeDiskData({ health: '' as unknown as undefined }); + expect(getPhysicalDiskHealthStatus(empty).label).toBe('Unknown'); + const undefinedHealth = makeDiskData({ + health: undefined as unknown as string, + }); + expect(getPhysicalDiskHealthStatus(undefinedHealth).label).toBe('Unknown'); + }); + }); + + describe('matchesPhysicalDiskHealthFilter', () => { + it('returns true for the all filter regardless of health', () => { + expect(matchesPhysicalDiskHealthFilter('unknown', 'all')).toBe(true); + }); + + it('returns true for the attention filter against offline', () => { + expect(matchesPhysicalDiskHealthFilter('offline', 'attention')).toBe(true); + }); + + it('returns false for the attention filter against healthy', () => { + expect(matchesPhysicalDiskHealthFilter('healthy', 'attention')).toBe(false); + }); + + it('returns true for an exact match against the warning filter', () => { + expect(matchesPhysicalDiskHealthFilter('warning', 'warning')).toBe(true); + }); + + it('returns false for a non-match against the critical filter', () => { + expect(matchesPhysicalDiskHealthFilter('warning', 'critical')).toBe(false); + }); + }); + + describe('getPhysicalDiskHealthSummary empty arm', () => { + it('returns an empty string when the summary is whitespace', () => { + expect( + getPhysicalDiskHealthSummary({ + label: 'Healthy', + summary: ' ', + tone: 'text-base-content', + }), + ).toBe(''); + }); + }); + + describe('getPhysicalDiskRoleLabel ssd/hdd arms', () => { + it('returns SSD for type ssd', () => { + expect(getPhysicalDiskRoleLabel(makeDiskData({ type: 'ssd' }))).toBe('SSD'); + }); + + it('returns HDD for type hdd', () => { + expect(getPhysicalDiskRoleLabel(makeDiskData({ type: 'hdd' }))).toBe('HDD'); + }); + + it('returns a Titleized disk label for other types', () => { + expect(getPhysicalDiskRoleLabel(makeDiskData({ type: 'usb-c' }))).toBe('Usb C disk'); + }); + + it('returns empty string when no role or type is set', () => { + expect(getPhysicalDiskRoleLabel(makeDiskData())).toBe(''); + }); + }); + + describe('getPhysicalDiskLifeTextClass muted placeholder arm', () => { + it('returns the muted placeholder class when wearout is 0', () => { + expect(getPhysicalDiskLifeTextClass(makeDiskData({ wearout: 0 }))).toBe( + PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS, + ); + }); + + it('returns the muted placeholder class when wearout is not a number', () => { + expect( + getPhysicalDiskLifeTextClass(makeDiskData({ wearout: 'nan' as unknown as number })), + ).toBe(PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS); + }); + }); + + describe('matchesPhysicalDiskSearch empty freeTerms arm', () => { + it('returns true when only node terms match and there are no free terms', () => { + const disk = makeDiskData({ node: 'tower' }); + const resource = { parentName: 'tower' } as Resource; + expect(matchesPhysicalDiskSearch(resource, disk, 'node:tower')).toBe(true); + }); + + it('returns true when the search term is empty', () => { + const disk = makeDiskData({ node: 'tower' }); + const resource = { parentName: 'tower' } as Resource; + expect(matchesPhysicalDiskSearch(resource, disk, '')).toBe(true); + }); + }); + + describe('comparePhysicalDiskPresentation tie-breaker arms', () => { + it('falls back to node localeCompare when priorities match', () => { + const aDisk = makeDiskData({ node: 'alpha', devPath: '/dev/sda' }); + const bDisk = makeDiskData({ node: 'beta', devPath: '/dev/sda' }); + expect( + comparePhysicalDiskPresentation({} as Resource, aDisk, {} as Resource, bDisk), + ).toBeLessThan(0); + }); + + it('falls back to devPath localeCompare when priority and node match', () => { + const aDisk = makeDiskData({ node: 'tower', devPath: '/dev/sda' }); + const bDisk = makeDiskData({ node: 'tower', devPath: '/dev/sdb' }); + expect( + comparePhysicalDiskPresentation({} as Resource, aDisk, {} as Resource, bDisk), + ).toBeLessThan(0); + }); + + it('falls back to resource.name when devPath is empty', () => { + const aDisk = makeDiskData({ node: 'tower', devPath: '' }); + const bDisk = makeDiskData({ node: 'tower', devPath: '' }); + expect( + comparePhysicalDiskPresentation( + { name: 'aaa' } as Resource, + aDisk, + { name: 'bbb' } as Resource, + bDisk, + ), + ).toBeLessThan(0); + }); + }); + + describe('buildPhysicalDiskPresentationDataMap empty input arm', () => { + it('returns an empty Map when disks is null', () => { + expect(buildPhysicalDiskPresentationDataMap(null as unknown as Resource[]).size).toBe(0); + }); + }); + + describe('filterAndSortPhysicalDisks null/selectedNode arms', () => { + it('returns an empty array when disks is null', () => { + expect( + filterAndSortPhysicalDisks(null as unknown as Resource[], { + selectedNode: null, + searchTerm: '', + getDiskData: () => makeDiskData(), + matchesNode: () => true, + }), + ).toEqual([]); + }); + + it('filters by selected node via the provided matchesNode callback', () => { + const inNode = { + id: 'a', + name: 'a', + physicalDisk: { devPath: '/dev/sda', health: 'PASSED' }, + } as unknown as Resource; + const outNode = { + id: 'b', + name: 'b', + physicalDisk: { devPath: '/dev/sdb', health: 'PASSED' }, + } as unknown as Resource; + const result = filterAndSortPhysicalDisks([inNode, outNode], { + selectedNode: { id: 'node-1', name: 'tower' } as Resource, + searchTerm: '', + getDiskData: (disk) => extractPhysicalDiskPresentationData(disk), + matchesNode: (disk, node) => disk.id === 'a' && node.id === 'node-1', + }); + expect(result.map((d) => d.id)).toEqual(['a']); + }); + + it('exposes the proxmox instance from the selected node to the matchesNode callback', () => { + const inNode = { + id: 'a', + name: 'a', + physicalDisk: { devPath: '/dev/sda', health: 'PASSED' }, + } as unknown as Resource; + const result = filterAndSortPhysicalDisks([inNode], { + selectedNode: { + id: 'node-1', + name: 'tower', + platformData: { proxmox: { instance: 'pve1' } }, + } as unknown as Resource, + searchTerm: '', + getDiskData: (disk) => extractPhysicalDiskPresentationData(disk), + matchesNode: (_disk, node) => node.instance === 'pve1', + }); + expect(result.map((d) => d.id)).toEqual(['a']); + }); + }); + + describe('buildPhysicalDisk*FilterOptions null input arm', () => { + it('returns only the "all" option when disks is null', () => { + expect(buildPhysicalDiskRoleFilterOptions(null as unknown as Resource[])).toHaveLength(1); + expect(buildPhysicalDiskGroupFilterOptions(null as unknown as Resource[])).toHaveLength(1); + }); + }); + + describe('filter state "all" arms', () => { + it('skips source/health/role/group checks when filters are at their defaults', () => { + const resource = { + id: 'd1', + name: 'd1', + physicalDisk: { devPath: '/dev/sda', health: 'PASSED', diskType: 'ssd' }, + } as unknown as Resource; + const resource2 = { + id: 'd2', + name: 'd2', + physicalDisk: { devPath: '/dev/sdb', health: 'PASSED', diskType: 'hdd' }, + } as unknown as Resource; + const result = filterAndSortPhysicalDisks([resource, resource2], { + selectedNode: null, + sourceFilter: 'all', + healthFilter: 'all', + roleFilter: 'all', + groupFilter: 'all', + searchTerm: '', + getDiskData: (d) => extractPhysicalDiskPresentationData(d), + matchesNode: () => true, + }); + expect(result.map((d) => d.id)).toEqual(['d1', 'd2']); + }); + + it('rejects disks whose computed role filter value differs from the selected role', () => { + const resource = { + id: 'd1', + name: 'd1', + physicalDisk: { devPath: '/dev/sda', health: 'PASSED', diskType: 'ssd' }, + } as unknown as Resource; + const result = filterAndSortPhysicalDisks([resource], { + selectedNode: null, + roleFilter: 'parity', + searchTerm: '', + getDiskData: (d) => extractPhysicalDiskPresentationData(d), + matchesNode: () => true, + }); + expect(result).toEqual([]); + }); + + it('rejects disks whose computed group filter value differs from the selected group', () => { + const resource = { + id: 'd1', + name: 'd1', + physicalDisk: { + devPath: '/dev/sda', + health: 'PASSED', + diskType: 'ssd', + storageGroup: 'tank', + }, + } as unknown as Resource; + const result = filterAndSortPhysicalDisks([resource], { + selectedNode: null, + groupFilter: 'other-pool', + searchTerm: '', + getDiskData: (d) => extractPhysicalDiskPresentationData(d), + matchesNode: () => true, + }); + expect(result).toEqual([]); + }); + }); +}); diff --git a/internal/alerts/config/intent_branchcov0720pm_test.go b/internal/alerts/config/intent_branchcov0720pm_test.go new file mode 100644 index 000000000..41af3cabe --- /dev/null +++ b/internal/alerts/config/intent_branchcov0720pm_test.go @@ -0,0 +1,1031 @@ +package config + +import ( + "reflect" + "strings" + "testing" + "time" +) + +// This file exercises every branch of the alert-intent policy helpers in +// internal/alerts/config/intent.go. It deliberately uses the in-package test +// package (package config, not config_test like the sibling files) because the +// target list includes unexported functions (normalizeIntentScopedRules, +// normalizeIntentSignalRules, cloneAlertIntentRule, validateIntentScopedRules, +// validateIntentSignalRules, validAlertIntentSignal) that cannot be referenced +// from config_test. Go compiles in-package and external test files together. +// +// Test function names embed "Intent" so `-run Intent` selects them in +// isolation. Each branch gets a dedicated sub-test that asserts the exact +// error message substring produced by the code under test. + +// intPtr/boolPtr allocate fresh pointers so clone-independence assertions can +// distinguish source values from cloned values. +func intPtr(v int) *int { return &v } +func boolPtr(v bool) *bool { return &v } +func backupPtr(b BackupOfflineIntentPolicy) *BackupOfflineIntentPolicy { return &b } + +// TestIntentNewPolicyDocumentDefaults covers NewAlertIntentPolicyDocument. +func TestIntentNewPolicyDocumentDefaults(t *testing.T) { + doc := NewAlertIntentPolicyDocument() + if doc.SchemaVersion != CurrentAlertIntentPolicySchemaVersion { + t.Fatalf("SchemaVersion = %d, want %d", doc.SchemaVersion, CurrentAlertIntentPolicySchemaVersion) + } + if doc.Revision != 0 { + t.Fatalf("Revision = %d, want 0", doc.Revision) + } + if doc.UpdatedAt != nil { + t.Fatalf("UpdatedAt = %v, want nil", doc.UpdatedAt) + } + if doc.Defaults == nil { + t.Fatal("Defaults is nil, want initialized non-nil map") + } + if len(doc.Defaults) != 0 { + t.Fatalf("len(Default) = %d, want 0", len(doc.Defaults)) + } + if doc.ResourceTypes == nil { + t.Fatal("ResourceTypes is nil, want initialized non-nil map") + } + if len(doc.ResourceTypes) != 0 { + t.Fatalf("len(ResourceTypes) = %d, want 0", len(doc.ResourceTypes)) + } + if doc.Resources == nil { + t.Fatal("Resources is nil, want initialized non-nil map") + } + if len(doc.Resources) != 0 { + t.Fatalf("len(Resources) = %d, want 0", len(doc.Resources)) + } +} + +// TestIntentMetricSignal covers MetricAlertIntentSignal: empty/whitespace +// collapse to "" and the trim+lowercase+"metric." prefix path. +func TestIntentMetricSignal(t *testing.T) { + tests := []struct { + name string + metric string + want string + }{ + {name: "empty returns empty", metric: "", want: ""}, + {name: "spaces only returns empty", metric: " ", want: ""}, + {name: "tabs and newlines only returns empty", metric: "\t \n", want: ""}, + {name: "uppercase lowercased and prefixed", metric: "CPU", want: "metric.cpu"}, + {name: "surrounding whitespace trimmed", metric: " CPU ", want: "metric.cpu"}, + {name: "mixed case and dots preserved except case", metric: "Cpu.Usage", want: "metric.cpu.usage"}, + {name: "inner space preserved through lowercase", metric: "CPU LOAD", want: "metric.cpu load"}, + {name: "leading tab trimmed", metric: "\tmetric Already", want: "metric.metric already"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := MetricAlertIntentSignal(tc.metric) + if got != tc.want { + t.Fatalf("MetricAlertIntentSignal(%q) = %q, want %q", tc.metric, got, tc.want) + } + }) + } +} + +// TestIntentNormalizeDocument covers NormalizeAlertIntentPolicyDocument. +func TestIntentNormalizeDocument(t *testing.T) { + t.Run("zero value input yields defaults", func(t *testing.T) { + out := NormalizeAlertIntentPolicyDocument(AlertIntentPolicyDocument{}) + if out.SchemaVersion != CurrentAlertIntentPolicySchemaVersion { + t.Fatalf("SchemaVersion = %d, want default %d", out.SchemaVersion, CurrentAlertIntentPolicySchemaVersion) + } + if out.Revision != 0 { + t.Fatalf("Revision = %d, want 0", out.Revision) + } + if out.UpdatedAt != nil { + t.Fatalf("UpdatedAt = %v, want nil", out.UpdatedAt) + } + if out.Defaults == nil || len(out.Defaults) != 0 { + t.Fatalf("Defaults = %#v, want non-nil empty map", out.Defaults) + } + if out.ResourceTypes == nil || len(out.ResourceTypes) != 0 { + t.Fatalf("ResourceTypes = %#v, want non-nil empty map", out.ResourceTypes) + } + if out.Resources == nil || len(out.Resources) != 0 { + t.Fatalf("Resources = %#v, want non-nil empty map", out.Resources) + } + }) + + t.Run("positive schema version preserved", func(t *testing.T) { + out := NormalizeAlertIntentPolicyDocument(AlertIntentPolicyDocument{SchemaVersion: 7}) + if out.SchemaVersion != 7 { + t.Fatalf("SchemaVersion = %d, want 7", out.SchemaVersion) + } + }) + + t.Run("zero schema version falls back to default", func(t *testing.T) { + out := NormalizeAlertIntentPolicyDocument(AlertIntentPolicyDocument{SchemaVersion: 0}) + if out.SchemaVersion != CurrentAlertIntentPolicySchemaVersion { + t.Fatalf("SchemaVersion = %d, want default %d", out.SchemaVersion, CurrentAlertIntentPolicySchemaVersion) + } + }) + + t.Run("negative schema version falls back to default", func(t *testing.T) { + out := NormalizeAlertIntentPolicyDocument(AlertIntentPolicyDocument{SchemaVersion: -3}) + if out.SchemaVersion != CurrentAlertIntentPolicySchemaVersion { + t.Fatalf("SchemaVersion = %d, want default %d (negative not > 0)", out.SchemaVersion, CurrentAlertIntentPolicySchemaVersion) + } + }) + + t.Run("revision preserved including negative value", func(t *testing.T) { + out := NormalizeAlertIntentPolicyDocument(AlertIntentPolicyDocument{Revision: 42}) + if out.Revision != 42 { + t.Fatalf("Revision = %d, want 42", out.Revision) + } + outNeg := NormalizeAlertIntentPolicyDocument(AlertIntentPolicyDocument{Revision: -5}) + if outNeg.Revision != -5 { + t.Fatalf("Revision = %d, want -5 (normalizer does not clamp)", outNeg.Revision) + } + }) + + t.Run("nil updatedAt stays nil", func(t *testing.T) { + out := NormalizeAlertIntentPolicyDocument(AlertIntentPolicyDocument{}) + if out.UpdatedAt != nil { + t.Fatalf("UpdatedAt = %v, want nil", out.UpdatedAt) + } + }) + + t.Run("non-nil updatedAt is copied and converted to UTC", func(t *testing.T) { + loc, err := time.LoadLocation("America/New_York") + if err != nil { + t.Fatalf("load location: %v", err) + } + src := time.Date(2024, 6, 15, 12, 0, 0, 0, loc) // EDT, UTC-4 + in := AlertIntentPolicyDocument{UpdatedAt: &src} + out := NormalizeAlertIntentPolicyDocument(in) + if out.UpdatedAt == nil { + t.Fatal("UpdatedAt = nil, want non-nil") + } + wantUTC := src.UTC() + if !out.UpdatedAt.Equal(wantUTC) { + t.Fatalf("UpdatedAt = %v, want %v", out.UpdatedAt, wantUTC) + } + if out.UpdatedAt.Location() != time.UTC { + t.Fatalf("UpdatedAt location = %v, want UTC", out.UpdatedAt.Location()) + } + // Detachment: mutating out.UpdatedAt must not affect the source. + original := src + *out.UpdatedAt = original.Add(time.Hour) + if !src.Equal(original) { + t.Fatalf("mutating out.UpdatedAt affected source time: src=%v want=%v (not detached)", src, original) + } + }) + + t.Run("defaults normalized end to end", func(t *testing.T) { + in := AlertIntentPolicyDocument{ + Defaults: map[string]AlertIntentRule{ + " DEFAULT ": {GraceSeconds: intPtr(30)}, + "": {GraceSeconds: intPtr(40)}, // dropped (empty signal) + "state.offline": {GraceSeconds: intPtr(50)}, + }, + } + out := NormalizeAlertIntentPolicyDocument(in) + if _, ok := out.Defaults["*"]; !ok { + t.Fatalf("Defaults = %#v, want '*' key (from DEFAULT normalization)", out.Defaults) + } + if _, ok := out.Defaults["state.offline"]; !ok { + t.Fatalf("Defaults = %#v, want state.offline key preserved", out.Defaults) + } + if len(out.Defaults) != 2 { + t.Fatalf("len(Default) = %d, want 2 (empty signal dropped)", len(out.Defaults)) + } + }) + + t.Run("resource types canonicalized and empty scopes dropped", func(t *testing.T) { + in := AlertIntentPolicyDocument{ + ResourceTypes: map[string]map[string]AlertIntentRule{ + " VMWARE Host ": {"state.offline": {GraceSeconds: intPtr(15)}}, + " ": {"state.offline": {GraceSeconds: intPtr(20)}}, // whitespace scope dropped + }, + } + out := NormalizeAlertIntentPolicyDocument(in) + if _, ok := out.ResourceTypes["vmware-host"]; !ok { + t.Fatalf("ResourceTypes = %#v, want 'vmware-host' canonical key", out.ResourceTypes) + } + if len(out.ResourceTypes) != 1 { + t.Fatalf("len(ResourceTypes) = %d, want 1 (whitespace scope dropped)", len(out.ResourceTypes)) + } + }) + + t.Run("resources preserve raw trimmed scope without canonicalization", func(t *testing.T) { + in := AlertIntentPolicyDocument{ + Resources: map[string]map[string]AlertIntentRule{ + " node/qemu/100 ": {"state.offline": {GraceSeconds: intPtr(25)}}, + }, + } + out := NormalizeAlertIntentPolicyDocument(in) + if _, ok := out.Resources["node/qemu/100"]; !ok { + t.Fatalf("Resources = %#v, want trimmed-but-not-canonicalized 'node/qemu/100' key", out.Resources) + } + if len(out.Resources) != 1 { + t.Fatalf("len(Resources) = %d, want 1", len(out.Resources)) + } + }) + + t.Run("scope whose rules normalize to empty is dropped", func(t *testing.T) { + in := AlertIntentPolicyDocument{ + ResourceTypes: map[string]map[string]AlertIntentRule{ + "guest": {" ": {GraceSeconds: intPtr(1)}}, // signal empty after trim + }, + } + out := NormalizeAlertIntentPolicyDocument(in) + if len(out.ResourceTypes) != 0 { + t.Fatalf("ResourceTypes = %#v, want empty (normalized rules empty -> scope dropped)", out.ResourceTypes) + } + }) +} + +// TestIntentNormalizeSignalRules covers normalizeIntentSignalRules directly: +// nil/empty input, alias collapsing, whitespace/empty drop, lowercasing, and +// clone independence. +func TestIntentNormalizeSignalRules(t *testing.T) { + t.Run("nil input returns empty non-nil map", func(t *testing.T) { + out := normalizeIntentSignalRules(nil) + if out == nil { + t.Fatal("got nil map, want non-nil empty map") + } + if len(out) != 0 { + t.Fatalf("len = %d, want 0", len(out)) + } + }) + + t.Run("empty input returns empty non-nil map", func(t *testing.T) { + out := normalizeIntentSignalRules(map[string]AlertIntentRule{}) + if out == nil { + t.Fatal("got nil map, want non-nil empty map") + } + if len(out) != 0 { + t.Fatalf("len = %d, want 0", len(out)) + } + }) + + t.Run("already normal keys preserved", func(t *testing.T) { + in := map[string]AlertIntentRule{ + "*": {GraceSeconds: intPtr(1)}, + "state.offline": {GraceSeconds: intPtr(2)}, + "incident.availability": {GraceSeconds: intPtr(3)}, + "metric.cpu": {GraceSeconds: intPtr(4)}, + } + out := normalizeIntentSignalRules(in) + if len(out) != 4 { + t.Fatalf("len = %d, want 4", len(out)) + } + want := map[string]int{"*": 1, "state.offline": 2, "incident.availability": 3, "metric.cpu": 4} + for k, w := range want { + r, ok := out[k] + if !ok { + t.Fatalf("missing key %q in %#v", k, out) + } + if r.GraceSeconds == nil || *r.GraceSeconds != w { + t.Fatalf("out[%q].GraceSeconds = %v, want %d", k, r.GraceSeconds, w) + } + } + }) + + t.Run("default and _default aliases collapse to star", func(t *testing.T) { + in := map[string]AlertIntentRule{ + "default": {GraceSeconds: intPtr(10)}, + "_default": {GraceSeconds: intPtr(20)}, + "DEFAULT": {GraceSeconds: intPtr(30)}, + " _Default ": {GraceSeconds: intPtr(40)}, + } + out := normalizeIntentSignalRules(in) + if len(out) != 1 { + t.Fatalf("len = %d, want 1 (all collapse to '*')", len(out)) + } + if _, ok := out["*"]; !ok { + t.Fatalf("out = %#v, want single '*' key", out) + } + }) + + t.Run("whitespace and empty signals dropped", func(t *testing.T) { + in := map[string]AlertIntentRule{ + "": {GraceSeconds: intPtr(1)}, + " ": {GraceSeconds: intPtr(2)}, + "\t\n": {GraceSeconds: intPtr(3)}, + "metric.x": {GraceSeconds: intPtr(4)}, + } + out := normalizeIntentSignalRules(in) + if len(out) != 1 { + t.Fatalf("len = %d, want 1 (only metric.x survives)", len(out)) + } + if _, ok := out["metric.x"]; !ok { + t.Fatalf("out = %#v, want metric.x", out) + } + }) + + t.Run("uppercase signal keys lowercased", func(t *testing.T) { + in := map[string]AlertIntentRule{ + "State.Offline": {GraceSeconds: intPtr(7)}, + } + out := normalizeIntentSignalRules(in) + if _, ok := out["state.offline"]; !ok { + t.Fatalf("out = %#v, want 'state.offline' (lowercased)", out) + } + }) + + t.Run("cloned rule pointer fields are independent", func(t *testing.T) { + in := map[string]AlertIntentRule{ + "state.offline": {GraceSeconds: intPtr(99)}, + } + out := normalizeIntentSignalRules(in) + r := out["state.offline"] + if r.GraceSeconds == nil { + t.Fatal("cloned GraceSeconds is nil") + } + *r.GraceSeconds = 0 + if *in["state.offline"].GraceSeconds != 99 { + t.Fatalf("mutating clone affected source: source = %d, want 99", *in["state.offline"].GraceSeconds) + } + }) +} + +// TestIntentNormalizeScopedRules covers normalizeIntentScopedRules directly: nil +// and empty inputs, whitespace scope drop (both lowerScope arms), canonical +// vs trim-only scope handling, and drop of scopes whose normalized rules are +// empty. +func TestIntentNormalizeScopedRules(t *testing.T) { + t.Run("nil input returns empty non-nil map", func(t *testing.T) { + out := normalizeIntentScopedRules(nil, true) + if out == nil { + t.Fatal("got nil, want non-nil empty map") + } + if len(out) != 0 { + t.Fatalf("len = %d, want 0", len(out)) + } + }) + + t.Run("empty input returns empty non-nil map", func(t *testing.T) { + out := normalizeIntentScopedRules(map[string]map[string]AlertIntentRule{}, false) + if out == nil { + t.Fatal("got nil, want non-nil empty map") + } + if len(out) != 0 { + t.Fatalf("len = %d, want 0", len(out)) + } + }) + + t.Run("whitespace scope dropped when lowerScope true", func(t *testing.T) { + in := map[string]map[string]AlertIntentRule{ + " ": {"state.offline": {GraceSeconds: intPtr(1)}}, + } + out := normalizeIntentScopedRules(in, true) + if len(out) != 0 { + t.Fatalf("len = %d, want 0 (whitespace scope dropped)", len(out)) + } + }) + + t.Run("whitespace scope dropped when lowerScope false", func(t *testing.T) { + in := map[string]map[string]AlertIntentRule{ + " ": {"state.offline": {GraceSeconds: intPtr(1)}}, + } + out := normalizeIntentScopedRules(in, false) + if len(out) != 0 { + t.Fatalf("len = %d, want 0 (whitespace scope dropped)", len(out)) + } + }) + + t.Run("lowerScope true applies resource type canonicalization", func(t *testing.T) { + in := map[string]map[string]AlertIntentRule{ + " VMWARE Host ": {"state.offline": {GraceSeconds: intPtr(1)}}, + "Guest": {"state.offline": {GraceSeconds: intPtr(2)}}, + } + out := normalizeIntentScopedRules(in, true) + if _, ok := out["vmware-host"]; !ok { + t.Fatalf("out = %#v, want 'vmware-host' canonical key", out) + } + if _, ok := out["guest"]; !ok { + t.Fatalf("out = %#v, want 'guest' (lowercased default arm)", out) + } + }) + + t.Run("lowerScope false only trims without lowercasing", func(t *testing.T) { + in := map[string]map[string]AlertIntentRule{ + " Node/QEMU/100 ": {"state.offline": {GraceSeconds: intPtr(1)}}, + } + out := normalizeIntentScopedRules(in, false) + if _, ok := out["Node/QEMU/100"]; !ok { + t.Fatalf("out = %#v, want trimmed but case-preserved key", out) + } + if _, ok := out["node/qemu/100"]; ok { + t.Fatalf("out = %#v, did not expect lowercased key", out) + } + }) + + t.Run("scope with empty normalized rules is dropped", func(t *testing.T) { + in := map[string]map[string]AlertIntentRule{ + "guest": {" ": {GraceSeconds: intPtr(1)}}, + } + out := normalizeIntentScopedRules(in, true) + if len(out) != 0 { + t.Fatalf("out = %#v, want empty (normalized rules empty -> scope dropped)", out) + } + }) + + t.Run("multiple scopes with mixed survival", func(t *testing.T) { + in := map[string]map[string]AlertIntentRule{ + "Guest": {"state.offline": {GraceSeconds: intPtr(1)}}, + " ": {"state.offline": {GraceSeconds: intPtr(2)}}, + "Empty": {" ": {GraceSeconds: intPtr(3)}}, + } + out := normalizeIntentScopedRules(in, true) + if len(out) != 1 { + t.Fatalf("out = %#v, want only 'guest' to survive", out) + } + if _, ok := out["guest"]; !ok { + t.Fatalf("out = %#v, want 'guest' key", out) + } + }) +} + +// TestIntentCloneRule covers cloneAlertIntentRule: zero-value, fully-populated +// deep equality, mutation-independence for every pointer field, and nil/empty +// handling. +func TestIntentCloneRule(t *testing.T) { + t.Run("zero value rule clones cleanly with nil pointers", func(t *testing.T) { + r := AlertIntentRule{} + c := cloneAlertIntentRule(r) + if !reflect.DeepEqual(c, r) { + t.Fatalf("clone = %#v, want %#v", c, r) + } + if c.GraceSeconds != nil || c.HonorOperatorState != nil || c.BackupOffline != nil { + t.Fatalf("clone pointers non-nil: %+v", c) + } + }) + + t.Run("fully populated rule clones with deep equality", func(t *testing.T) { + r := AlertIntentRule{ + GraceSeconds: intPtr(42), + HonorOperatorState: boolPtr(true), + BackupOffline: backupPtr(BackupOfflineIntentPolicy{ + Enabled: true, + PostGraceSeconds: 10, + MaxDeferralSeconds: 20, + }), + } + c := cloneAlertIntentRule(r) + if !reflect.DeepEqual(c, r) { + t.Fatalf("clone = %#v, want %#v", c, r) + } + }) + + t.Run("mutating cloned GraceSeconds does not affect source", func(t *testing.T) { + r := AlertIntentRule{GraceSeconds: intPtr(5)} + c := cloneAlertIntentRule(r) + *c.GraceSeconds = 999 + if *r.GraceSeconds != 5 { + t.Fatalf("source mutated: %d, want 5", *r.GraceSeconds) + } + }) + + t.Run("mutating cloned HonorOperatorState does not affect source", func(t *testing.T) { + r := AlertIntentRule{HonorOperatorState: boolPtr(false)} + c := cloneAlertIntentRule(r) + *c.HonorOperatorState = true + if *r.HonorOperatorState != false { + t.Fatalf("source mutated: %v, want false", *r.HonorOperatorState) + } + }) + + t.Run("mutating cloned BackupOffline fields does not affect source", func(t *testing.T) { + r := AlertIntentRule{BackupOffline: &BackupOfflineIntentPolicy{ + Enabled: true, PostGraceSeconds: 1, MaxDeferralSeconds: 2, + }} + c := cloneAlertIntentRule(r) + c.BackupOffline.Enabled = false + c.BackupOffline.PostGraceSeconds = 100 + c.BackupOffline.MaxDeferralSeconds = 200 + if !r.BackupOffline.Enabled || r.BackupOffline.PostGraceSeconds != 1 || r.BackupOffline.MaxDeferralSeconds != 2 { + t.Fatalf("source mutated: %+v, want unchanged", r.BackupOffline) + } + }) + + t.Run("partial population only clones set fields", func(t *testing.T) { + r := AlertIntentRule{GraceSeconds: intPtr(7)} + c := cloneAlertIntentRule(r) + if c.GraceSeconds == nil || *c.GraceSeconds != 7 { + t.Fatalf("GraceSeconds = %v, want 7", c.GraceSeconds) + } + if c.HonorOperatorState != nil { + t.Fatalf("HonorOperatorState = %v, want nil", c.HonorOperatorState) + } + if c.BackupOffline != nil { + t.Fatalf("BackupOffline = %v, want nil", c.BackupOffline) + } + }) + + t.Run("only HonorOperatorState set clones correctly", func(t *testing.T) { + r := AlertIntentRule{HonorOperatorState: boolPtr(true)} + c := cloneAlertIntentRule(r) + if c.HonorOperatorState == nil || !*c.HonorOperatorState { + t.Fatalf("HonorOperatorState = %v, want true", c.HonorOperatorState) + } + if c.GraceSeconds != nil || c.BackupOffline != nil { + t.Fatalf("unexpected non-nil pointers: %+v", c) + } + }) + + t.Run("only BackupOffline set clones correctly", func(t *testing.T) { + r := AlertIntentRule{BackupOffline: &BackupOfflineIntentPolicy{Enabled: true, MaxDeferralSeconds: 30}} + c := cloneAlertIntentRule(r) + if c.BackupOffline == nil || !c.BackupOffline.Enabled || c.BackupOffline.MaxDeferralSeconds != 30 { + t.Fatalf("BackupOffline = %+v, want preserved", c.BackupOffline) + } + if c.GraceSeconds != nil || c.HonorOperatorState != nil { + t.Fatalf("unexpected non-nil pointers: %+v", c) + } + }) +} + +// TestIntentValidSignal covers every branch of validAlertIntentSignal: the +// three constant valid signals, the metric.* prefix path (with and without a +// non-empty suffix), and the unsupported default-arm path. +func TestIntentValidSignal(t *testing.T) { + tests := []struct { + name string + signal string + want bool + }{ + {name: "default star is valid", signal: "*", want: true}, + {name: "offline is valid", signal: "state.offline", want: true}, + {name: "availability is valid", signal: "incident.availability", want: true}, + {name: "metric with name is valid", signal: "metric.cpu", want: true}, + {name: "metric with dotted name is valid", signal: "metric.cpu.load", want: true}, + {name: "metric with no name is invalid", signal: "metric.", want: false}, + {name: "metric prefix without dot is invalid", signal: "metric", want: false}, + {name: "empty is invalid", signal: "", want: false}, + {name: "unrelated state is invalid", signal: "state.online", want: false}, + {name: "case sensitive prefix Metric not metric", signal: "Metric.cpu", want: false}, + {name: "arbitrary string is invalid", signal: "foo", want: false}, + {name: "metric- no dot is invalid", signal: "metric-cpu", want: false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := validAlertIntentSignal(tc.signal); got != tc.want { + t.Fatalf("validAlertIntentSignal(%q) = %v, want %v", tc.signal, got, tc.want) + } + }) + } +} + +// TestIntentValidateDocument covers ValidateAlertIntentPolicyDocument end to +// end: the valid path and each distinct error branch. +func TestIntentValidateDocument(t *testing.T) { + t.Run("valid minimal document returns nil", func(t *testing.T) { + doc := NewAlertIntentPolicyDocument() + if err := ValidateAlertIntentPolicyDocument(doc); err != nil { + t.Fatalf("err = %v, want nil", err) + } + }) + + t.Run("valid populated document returns nil", func(t *testing.T) { + doc := NewAlertIntentPolicyDocument() + doc.Defaults["*"] = AlertIntentRule{GraceSeconds: intPtr(30)} + doc.ResourceTypes["vmware-host"] = map[string]AlertIntentRule{ + "state.offline": {BackupOffline: &BackupOfflineIntentPolicy{ + Enabled: true, PostGraceSeconds: 5, MaxDeferralSeconds: 10, + }}, + } + doc.Resources["node/qemu/100"] = map[string]AlertIntentRule{ + "incident.availability": {GraceSeconds: intPtr(60)}, + } + if err := ValidateAlertIntentPolicyDocument(doc); err != nil { + t.Fatalf("err = %v, want nil", err) + } + }) + + t.Run("unsupported schema version returns specific error", func(t *testing.T) { + doc := NewAlertIntentPolicyDocument() + doc.SchemaVersion = 2 + err := ValidateAlertIntentPolicyDocument(doc) + if err == nil { + t.Fatal("err = nil, want non-nil") + } + if !strings.Contains(err.Error(), "unsupported alert intent policy schema version 2") { + t.Fatalf("err = %q, want substring 'unsupported alert intent policy schema version 2'", err.Error()) + } + }) + + t.Run("zero schema version normalizes to current and is valid", func(t *testing.T) { + doc := AlertIntentPolicyDocument{} // SchemaVersion 0 + if err := ValidateAlertIntentPolicyDocument(doc); err != nil { + t.Fatalf("err = %v, want nil (zero schema normalizes to current)", err) + } + }) + + t.Run("negative revision returns specific error", func(t *testing.T) { + doc := NewAlertIntentPolicyDocument() + doc.Revision = -1 + err := ValidateAlertIntentPolicyDocument(doc) + if err == nil { + t.Fatal("err = nil, want non-nil") + } + if !strings.Contains(err.Error(), "alert intent policy revision must be non-negative") { + t.Fatalf("err = %q, want substring 'revision must be non-negative'", err.Error()) + } + }) + + t.Run("invalid defaults signal propagates error", func(t *testing.T) { + doc := NewAlertIntentPolicyDocument() + doc.Defaults["bogus-signal"] = AlertIntentRule{} + err := ValidateAlertIntentPolicyDocument(doc) + if err == nil { + t.Fatal("err = nil, want non-nil") + } + if !strings.Contains(err.Error(), "unsupported alert intent signal") { + t.Fatalf("err = %q, want substring 'unsupported alert intent signal'", err.Error()) + } + if !strings.Contains(err.Error(), "defaults") { + t.Fatalf("err = %q, want substring containing scope 'defaults'", err.Error()) + } + }) + + t.Run("invalid resource types scope propagates error", func(t *testing.T) { + doc := NewAlertIntentPolicyDocument() + doc.ResourceTypes["guest"] = map[string]AlertIntentRule{ + "": {GraceSeconds: intPtr(1)}, + } + err := ValidateAlertIntentPolicyDocument(doc) + if err == nil { + t.Fatal("err = nil, want non-nil") + } + if !strings.Contains(err.Error(), "empty alert intent signal") { + t.Fatalf("err = %q, want substring 'empty alert intent signal'", err.Error()) + } + }) + + t.Run("invalid resources scope propagates error", func(t *testing.T) { + doc := NewAlertIntentPolicyDocument() + doc.Resources["node/1"] = map[string]AlertIntentRule{ + "state.offline": {GraceSeconds: intPtr(-1)}, + } + err := ValidateAlertIntentPolicyDocument(doc) + if err == nil { + t.Fatal("err = nil, want non-nil") + } + if !strings.Contains(err.Error(), "graceSeconds must be between") { + t.Fatalf("err = %q, want substring about graceSeconds range", err.Error()) + } + }) +} + +// TestIntentValidateScopedRules covers validateIntentScopedRules directly: +// nil/empty success, the empty-scope required error (both resourceType arms), +// the duplicate-canonical-collision error, valid distinct scopes, inner-rule +// error propagation, and trim-only behavior when resourceType is false. +func TestIntentValidateScopedRules(t *testing.T) { + t.Run("nil scopes returns nil", func(t *testing.T) { + if err := validateIntentScopedRules("resource type", nil, true); err != nil { + t.Fatalf("err = %v, want nil", err) + } + }) + + t.Run("empty scopes returns nil", func(t *testing.T) { + if err := validateIntentScopedRules("resource", map[string]map[string]AlertIntentRule{}, false); err != nil { + t.Fatalf("err = %v, want nil", err) + } + }) + + t.Run("whitespace scope with resourceType true returns required error", func(t *testing.T) { + scopes := map[string]map[string]AlertIntentRule{ + " ": {"state.offline": {}}, + } + err := validateIntentScopedRules("resource type", scopes, true) + if err == nil { + t.Fatal("err = nil, want non-nil") + } + if !strings.Contains(err.Error(), "alert intent resource type is required") { + t.Fatalf("err = %q, want substring 'alert intent resource type is required'", err.Error()) + } + }) + + t.Run("whitespace scope with resourceType false returns required error", func(t *testing.T) { + scopes := map[string]map[string]AlertIntentRule{ + " ": {"state.offline": {}}, + } + err := validateIntentScopedRules("resource", scopes, false) + if err == nil { + t.Fatal("err = nil, want non-nil") + } + if !strings.Contains(err.Error(), "alert intent resource is required") { + t.Fatalf("err = %q, want substring 'alert intent resource is required'", err.Error()) + } + }) + + t.Run("duplicate canonicalized scopes returns collision error", func(t *testing.T) { + scopes := map[string]map[string]AlertIntentRule{ + "vmware host": {"state.offline": {}}, + "VMWARE-HOST": {"state.offline": {}}, + } + err := validateIntentScopedRules("resource type", scopes, true) + if err == nil { + t.Fatal("err = nil, want non-nil") + } + if !strings.Contains(err.Error(), "normalize to the same value") { + t.Fatalf("err = %q, want substring 'normalize to the same value'", err.Error()) + } + }) + + t.Run("valid distinct canonical scopes returns nil", func(t *testing.T) { + scopes := map[string]map[string]AlertIntentRule{ + "VMWARE Host": {"state.offline": {}}, + "guest": {"*": {}}, + } + if err := validateIntentScopedRules("resource type", scopes, true); err != nil { + t.Fatalf("err = %v, want nil", err) + } + }) + + t.Run("inner rules error propagates with composed scope label", func(t *testing.T) { + scopes := map[string]map[string]AlertIntentRule{ + "guest": {"bogus": {}}, + } + err := validateIntentScopedRules("resource type", scopes, true) + if err == nil { + t.Fatal("err = nil, want non-nil") + } + if !strings.Contains(err.Error(), "resource type guest") { + t.Fatalf("err = %q, want substring 'resource type guest'", err.Error()) + } + if !strings.Contains(err.Error(), "unsupported alert intent signal") { + t.Fatalf("err = %q, want substring 'unsupported alert intent signal'", err.Error()) + } + }) + + t.Run("resourceType false trims but does not lowercase so same-case duplicates collide", func(t *testing.T) { + scopes := map[string]map[string]AlertIntentRule{ + " Node1 ": {"state.offline": {}}, + "Node1": {"*": {}}, + } + err := validateIntentScopedRules("resource", scopes, false) + if err == nil { + t.Fatal("err = nil, want non-nil (trim-only collision)") + } + if !strings.Contains(err.Error(), "normalize to the same value") { + t.Fatalf("err = %q, want substring 'normalize to the same value'", err.Error()) + } + }) + + t.Run("resourceType false treats different casing as distinct scopes", func(t *testing.T) { + scopes := map[string]map[string]AlertIntentRule{ + "Node1": {"state.offline": {}}, + "node1": {"*": {}}, + } + if err := validateIntentScopedRules("resource", scopes, false); err != nil { + t.Fatalf("err = %v, want nil (different casing = distinct without canonicalization)", err) + } + }) +} + +// TestIntentValidateSignalRules covers validateIntentSignalRules directly, +// asserting the valid path and each distinct invalid path with its specific +// error message substring. +func TestIntentValidateSignalRules(t *testing.T) { + t.Run("nil rules returns nil", func(t *testing.T) { + if err := validateIntentSignalRules("defaults", nil); err != nil { + t.Fatalf("err = %v, want nil", err) + } + }) + + t.Run("empty rules returns nil", func(t *testing.T) { + if err := validateIntentSignalRules("defaults", map[string]AlertIntentRule{}); err != nil { + t.Fatalf("err = %v, want nil", err) + } + }) + + t.Run("valid rules across supported signals returns nil", func(t *testing.T) { + rules := map[string]AlertIntentRule{ + "*": {GraceSeconds: intPtr(0)}, + "state.offline": {GraceSeconds: intPtr(maxAlertIntentGraceSeconds)}, + "incident.availability": {}, + "metric.cpu": {}, + } + if err := validateIntentSignalRules("defaults", rules); err != nil { + t.Fatalf("err = %v, want nil", err) + } + }) + + t.Run("empty signal returns empty-signal error", func(t *testing.T) { + rules := map[string]AlertIntentRule{ + " ": {}, + } + err := validateIntentSignalRules("defaults", rules) + if err == nil { + t.Fatal("err = nil, want non-nil") + } + if !strings.Contains(err.Error(), "has an empty alert intent signal") { + t.Fatalf("err = %q, want substring 'has an empty alert intent signal'", err.Error()) + } + }) + + t.Run("duplicate normalized signals returns collision error", func(t *testing.T) { + rules := map[string]AlertIntentRule{ + "default": {}, + "_DEFAULT": {}, + } + err := validateIntentSignalRules("defaults", rules) + if err == nil { + t.Fatal("err = nil, want non-nil") + } + if !strings.Contains(err.Error(), "normalize to the same value") { + t.Fatalf("err = %q, want substring 'normalize to the same value'", err.Error()) + } + }) + + t.Run("unsupported signal returns unsupported error", func(t *testing.T) { + rules := map[string]AlertIntentRule{ + "state.online": {}, + } + err := validateIntentSignalRules("defaults", rules) + if err == nil { + t.Fatal("err = nil, want non-nil") + } + if !strings.Contains(err.Error(), "unsupported alert intent signal") { + t.Fatalf("err = %q, want substring 'unsupported alert intent signal'", err.Error()) + } + }) + + t.Run("metric prefix with no name is unsupported", func(t *testing.T) { + rules := map[string]AlertIntentRule{ + "metric.": {}, + } + err := validateIntentSignalRules("defaults", rules) + if err == nil { + t.Fatal("err = nil, want non-nil") + } + if !strings.Contains(err.Error(), `"metric."`) { + t.Fatalf("err = %q, want substring containing \"metric.\"", err.Error()) + } + }) + + t.Run("negative graceSeconds returns range error", func(t *testing.T) { + rules := map[string]AlertIntentRule{ + "*": {GraceSeconds: intPtr(-1)}, + } + err := validateIntentSignalRules("defaults", rules) + if err == nil { + t.Fatal("err = nil, want non-nil") + } + if !strings.Contains(err.Error(), "graceSeconds must be between 0 and") { + t.Fatalf("err = %q, want substring 'graceSeconds must be between 0 and'", err.Error()) + } + }) + + t.Run("over-max graceSeconds returns range error", func(t *testing.T) { + rules := map[string]AlertIntentRule{ + "*": {GraceSeconds: intPtr(maxAlertIntentGraceSeconds + 1)}, + } + err := validateIntentSignalRules("defaults", rules) + if err == nil { + t.Fatal("err = nil, want non-nil") + } + if !strings.Contains(err.Error(), "graceSeconds must be between 0 and") { + t.Fatalf("err = %q, want substring 'graceSeconds must be between 0 and'", err.Error()) + } + }) + + t.Run("boundary graceSeconds zero and max are valid", func(t *testing.T) { + rules := map[string]AlertIntentRule{ + "state.offline": {GraceSeconds: intPtr(0)}, + "incident.availability": {GraceSeconds: intPtr(maxAlertIntentGraceSeconds)}, + } + if err := validateIntentSignalRules("defaults", rules); err != nil { + t.Fatalf("err = %v, want nil (boundary values valid)", err) + } + }) + + t.Run("backupOffline on availability signal returns may-not-configure error", func(t *testing.T) { + rules := map[string]AlertIntentRule{ + "incident.availability": {BackupOffline: &BackupOfflineIntentPolicy{}}, + } + err := validateIntentSignalRules("defaults", rules) + if err == nil { + t.Fatal("err = nil, want non-nil") + } + if !strings.Contains(err.Error(), "may not configure backupOffline") { + t.Fatalf("err = %q, want substring 'may not configure backupOffline'", err.Error()) + } + }) + + t.Run("backupOffline on metric signal returns may-not-configure error", func(t *testing.T) { + rules := map[string]AlertIntentRule{ + "metric.cpu": {BackupOffline: &BackupOfflineIntentPolicy{}}, + } + err := validateIntentSignalRules("defaults", rules) + if err == nil { + t.Fatal("err = nil, want non-nil") + } + if !strings.Contains(err.Error(), "may not configure backupOffline") { + t.Fatalf("err = %q, want substring 'may not configure backupOffline'", err.Error()) + } + }) + + t.Run("backupOffline negative postGraceSeconds returns range error", func(t *testing.T) { + rules := map[string]AlertIntentRule{ + "state.offline": {BackupOffline: &BackupOfflineIntentPolicy{PostGraceSeconds: -1}}, + } + err := validateIntentSignalRules("defaults", rules) + if err == nil { + t.Fatal("err = nil, want non-nil") + } + if !strings.Contains(err.Error(), "postGraceSeconds must be between 0 and") { + t.Fatalf("err = %q, want substring 'postGraceSeconds must be between 0 and'", err.Error()) + } + }) + + t.Run("backupOffline over-max postGraceSeconds returns range error", func(t *testing.T) { + rules := map[string]AlertIntentRule{ + "state.offline": {BackupOffline: &BackupOfflineIntentPolicy{PostGraceSeconds: maxAlertIntentGraceSeconds + 1}}, + } + err := validateIntentSignalRules("defaults", rules) + if err == nil { + t.Fatal("err = nil, want non-nil") + } + if !strings.Contains(err.Error(), "postGraceSeconds must be between 0 and") { + t.Fatalf("err = %q, want substring 'postGraceSeconds must be between 0 and'", err.Error()) + } + }) + + t.Run("backupOffline negative maxDeferralSeconds returns range error", func(t *testing.T) { + rules := map[string]AlertIntentRule{ + "state.offline": {BackupOffline: &BackupOfflineIntentPolicy{MaxDeferralSeconds: -1}}, + } + err := validateIntentSignalRules("defaults", rules) + if err == nil { + t.Fatal("err = nil, want non-nil") + } + if !strings.Contains(err.Error(), "maxDeferralSeconds must be between 0 and") { + t.Fatalf("err = %q, want substring 'maxDeferralSeconds must be between 0 and'", err.Error()) + } + }) + + t.Run("backupOffline over-max maxDeferralSeconds returns range error", func(t *testing.T) { + rules := map[string]AlertIntentRule{ + "state.offline": {BackupOffline: &BackupOfflineIntentPolicy{MaxDeferralSeconds: maxAlertIntentGraceSeconds + 1}}, + } + err := validateIntentSignalRules("defaults", rules) + if err == nil { + t.Fatal("err = nil, want non-nil") + } + if !strings.Contains(err.Error(), "maxDeferralSeconds must be between 0 and") { + t.Fatalf("err = %q, want substring 'maxDeferralSeconds must be between 0 and'", err.Error()) + } + }) + + t.Run("backupOffline enabled with zero maxDeferral returns must-be-positive error", func(t *testing.T) { + rules := map[string]AlertIntentRule{ + "state.offline": {BackupOffline: &BackupOfflineIntentPolicy{Enabled: true, MaxDeferralSeconds: 0}}, + } + err := validateIntentSignalRules("defaults", rules) + if err == nil { + t.Fatal("err = nil, want non-nil") + } + if !strings.Contains(err.Error(), "maxDeferralSeconds must be positive when enabled") { + t.Fatalf("err = %q, want substring 'maxDeferralSeconds must be positive when enabled'", err.Error()) + } + }) + + t.Run("backupOffline disabled with zero maxDeferral is valid", func(t *testing.T) { + rules := map[string]AlertIntentRule{ + "state.offline": {BackupOffline: &BackupOfflineIntentPolicy{Enabled: false, MaxDeferralSeconds: 0}}, + } + if err := validateIntentSignalRules("defaults", rules); err != nil { + t.Fatalf("err = %v, want nil (disabled backup with zero deferral is OK)", err) + } + }) + + t.Run("backupOffline fully valid on offline signal returns nil", func(t *testing.T) { + rules := map[string]AlertIntentRule{ + "state.offline": {BackupOffline: &BackupOfflineIntentPolicy{ + Enabled: true, PostGraceSeconds: 5, MaxDeferralSeconds: 10, + }}, + } + if err := validateIntentSignalRules("defaults", rules); err != nil { + t.Fatalf("err = %v, want nil", err) + } + }) + + t.Run("backupOffline valid on default star signal returns nil", func(t *testing.T) { + rules := map[string]AlertIntentRule{ + "*": {BackupOffline: &BackupOfflineIntentPolicy{ + Enabled: true, PostGraceSeconds: 0, MaxDeferralSeconds: 1, + }}, + } + if err := validateIntentSignalRules("defaults", rules); err != nil { + t.Fatalf("err = %v, want nil", err) + } + }) + + t.Run("default alias signal also permits backupOffline", func(t *testing.T) { + rules := map[string]AlertIntentRule{ + "default": {BackupOffline: &BackupOfflineIntentPolicy{ + Enabled: true, PostGraceSeconds: 0, MaxDeferralSeconds: 1, + }}, + } + if err := validateIntentSignalRules("defaults", rules); err != nil { + t.Fatalf("err = %v, want nil ('default' normalizes to '*')", err) + } + }) +} diff --git a/internal/monitoring/agent_fleet_doctor_hostpeer_branchcov0720pm_test.go b/internal/monitoring/agent_fleet_doctor_hostpeer_branchcov0720pm_test.go new file mode 100644 index 000000000..a8a63b67d --- /dev/null +++ b/internal/monitoring/agent_fleet_doctor_hostpeer_branchcov0720pm_test.go @@ -0,0 +1,164 @@ +package monitoring + +import ( + "reflect" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/models" +) + +// This file adds branch-coverage tests for findLikelyHostPeer in +// agent_fleet_doctor.go. The function iterates the supplied hosts, SKIPS any +// host whose ID == subject.id (skip-self takes precedence over any identity +// match), and returns the first host for which sameAgentIdentity is true; +// otherwise it returns the zero models.Host and false. +// +// Because findLikelyHostPeer forwards host.ID as both the `id` and `agentID` +// arguments to sameAgentIdentity, an "agent id" match happens when +// subject.agentID == host.ID. Each match case below isolates exactly one +// identity signal so the branch of sameAgentIdentity being exercised is +// unambiguous. Tests are selected by `-run 'HostPeer|FindLikelyHostPeer|Branchcov0720pm'`. + +func TestFindLikelyHostPeerBranchcov0720pm(t *testing.T) { + t.Parallel() + + // A host that is unrelated to the subject on every identity axis; useful as + // a non-matching filler when building multi-host slices. + unrelated := func(id string) models.Host { + return models.Host{ID: id, Hostname: "totally-unrelated", TokenID: "tok-unrelated"} + } + + cases := []struct { + name string + subject agentFleetSubject + hosts []models.Host + wantOK bool + wantHostID string + wantHostHostname string + }{ + // Branch: empty/nil hosts slice -> loop body never runs -> (zero, false). + { + name: "empty hosts slice returns zero and false", + subject: agentFleetSubject{id: "self", agentID: "a1", tokenID: "t1", hostname: "h"}, + hosts: nil, + wantOK: false, + }, + + // Branch: skip-self (host.ID == subject.id) taken, then loop ends -> + // (zero, false). The subject carries identity signals but the only host + // is self, so it is skipped before sameAgentIdentity is ever consulted. + { + name: "only host is self id skipped returns zero and false", + subject: agentFleetSubject{id: "self", agentID: "self", tokenID: "t1", hostname: "h"}, + hosts: []models.Host{{ID: "self", Hostname: "h", TokenID: "t1"}}, + wantOK: false, + }, + + // Branch: match via the agentID signal. subject.agentID == host.ID + // (both id and agentID args are host.ID). subject.tokenID/hostname are + // empty so the match is unambiguously the agentID branch. + { + name: "match by agent id subject agentID equals host ID", + subject: agentFleetSubject{id: "self", agentID: "agent-x"}, + hosts: []models.Host{{ID: "agent-x", Hostname: "unrelated", TokenID: ""}}, + wantOK: true, + wantHostID: "agent-x", + wantHostHostname: "unrelated", + }, + + // Branch: match via the tokenID signal. subject.agentID is empty (so the + // agentID branch is skipped) and subject.hostname is empty (so the + // hostname branch is skipped); only the token branch can fire. + { + name: "match by token id", + subject: agentFleetSubject{id: "self", tokenID: "tok-1"}, + hosts: []models.Host{{ID: "host-a", Hostname: "unrelated", TokenID: "tok-1"}}, + wantOK: true, + wantHostID: "host-a", + wantHostHostname: "unrelated", + }, + + // Branch: match via the hostname signal, case-insensitively + // (subject.hostname "Node1" vs host.Hostname "node1"). subject.agentID + // and subject.tokenID are empty and host.TokenID is empty, so only the + // hostname branch of sameAgentIdentity can match. + { + name: "match by hostname case insensitive", + subject: agentFleetSubject{id: "self", hostname: "Node1"}, + hosts: []models.Host{{ID: "host-b", Hostname: "node1", TokenID: ""}}, + wantOK: true, + wantHostID: "host-b", + wantHostHostname: "node1", + }, + + // Branch: skip-self takes precedence over match. The single host has + // ID == subject.id AND would match on ALL THREE identity signals + // (agentID, tokenID, hostname) — yet it is still skipped, yielding + // (zero, false). This pins the ordering: the self check runs before + // sameAgentIdentity is called. + { + name: "self host that would match is still skipped", + subject: agentFleetSubject{id: "self-id", agentID: "self-id", tokenID: "tok-1", hostname: "node1"}, + hosts: []models.Host{{ID: "self-id", Hostname: "node1", TokenID: "tok-1"}}, + wantOK: false, + }, + + // Branch: first non-self match wins. host[0] neither matches nor is + // self (exercises a sameAgentIdentity==false iteration); host[1] is the + // first match and must be returned; host[2] would also match but must + // never be reached. + { + name: "first non-self match wins among multiple hosts", + subject: agentFleetSubject{id: "self", hostname: "match-host"}, + hosts: []models.Host{ + unrelated("nope-1"), + {ID: "winner", Hostname: "match-host", TokenID: ""}, + {ID: "also-match", Hostname: "match-host", TokenID: ""}, + }, + wantOK: true, + wantHostID: "winner", + wantHostHostname: "match-host", + }, + + // Branch: no host matches. The single candidate is not self but + // sameAgentIdentity returns false on every signal -> loop completes -> + // (zero, false). + { + name: "no host matches returns zero and false", + subject: agentFleetSubject{id: "self", agentID: "a1", tokenID: "t1", hostname: "real"}, + hosts: []models.Host{{ID: "other", Hostname: "different", TokenID: "t2"}}, + wantOK: false, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, ok := findLikelyHostPeer(tc.subject, tc.hosts) + + if ok != tc.wantOK { + t.Fatalf("findLikelyHostPeer ok = %v, want %v (subject=%+v, hosts=%#v)", + ok, tc.wantOK, tc.subject, tc.hosts) + } + + if tc.wantOK { + // Assert the EXACT host was returned so ordering/identity is + // verified, not just "some host". + if got.ID != tc.wantHostID { + t.Fatalf("returned host ID = %q, want %q", got.ID, tc.wantHostID) + } + if got.Hostname != tc.wantHostHostname { + t.Fatalf("returned host Hostname = %q, want %q", got.Hostname, tc.wantHostHostname) + } + return + } + + // Non-match path must return the zero-value models.Host exactly. + if !reflect.DeepEqual(got, models.Host{}) { + t.Fatalf("findLikelyHostPeer returned non-zero host on no-match: %#v", got) + } + }) + } +} diff --git a/internal/unifiedresources/physicaldiskview_branchcov0720pm_test.go b/internal/unifiedresources/physicaldiskview_branchcov0720pm_test.go new file mode 100644 index 000000000..5deb60b7c --- /dev/null +++ b/internal/unifiedresources/physicaldiskview_branchcov0720pm_test.go @@ -0,0 +1,195 @@ +package unifiedresources + +import ( + "reflect" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/pkg/diskinventory" +) + +// TestPhysicalDiskViewBranchcov0720pm covers the five PhysicalDiskView +// accessors that were previously 0% covered: +// - Controller() string +// - Target() string +// - StorageGroup() string +// - IO() *PhysicalDiskIOMeta +// - Collection() *diskinventory.CollectionStatus +// +// Each accessor exercises three arms where they exist: +// +// (a) nil-receiver and nil-backing (v.r == nil, then v.r != nil but +// v.r.PhysicalDisk == nil) — must be nil/""-safe, no panic; +// (b) populated backing but the nested field empty/nil (whitespace-only +// strings trim to ""; IO()/Collection() inner pointers nil → nil); +// (c) fully populated — assert the exact returned value, and for the +// pointer-returning accessors assert the clone is field-equal and +// mutation-independent from the live source. +func TestPhysicalDiskViewBranchcov0720pm(t *testing.T) { + t.Run("Controller", func(t *testing.T) { + // (a) nil receiver. + var zero PhysicalDiskView + if got := zero.Controller(); got != "" { + t.Fatalf("nil receiver: expected %q, got %q", "", got) + } + // (a) populated Resource, nil PhysicalDisk backing. + r := &Resource{ID: "pd-ctrl-1", Type: ResourceTypePhysicalDisk} + if got := NewPhysicalDiskView(r).Controller(); got != "" { + t.Fatalf("nil PhysicalDisk: expected %q, got %q", "", got) + } + // (b) PhysicalDisk present, Controller whitespace-only → trims to "". + r.PhysicalDisk = &PhysicalDiskMeta{Controller: " "} + if got := NewPhysicalDiskView(r).Controller(); got != "" { + t.Fatalf("whitespace Controller: expected trimmed %q, got %q", "", got) + } + // (c) Fully populated → trimmed exact value. + r.PhysicalDisk.Controller = " mega-raid-0 " + if got, want := NewPhysicalDiskView(r).Controller(), "mega-raid-0"; got != want { + t.Fatalf("expected Controller %q, got %q", want, got) + } + }) + + t.Run("Target", func(t *testing.T) { + // (a) nil receiver. + var zero PhysicalDiskView + if got := zero.Target(); got != "" { + t.Fatalf("nil receiver: expected %q, got %q", "", got) + } + // (a) populated Resource, nil PhysicalDisk backing. + r := &Resource{ID: "pd-tgt-1", Type: ResourceTypePhysicalDisk} + if got := NewPhysicalDiskView(r).Target(); got != "" { + t.Fatalf("nil PhysicalDisk: expected %q, got %q", "", got) + } + // (b) PhysicalDisk present, Target whitespace-only → trims to "". + r.PhysicalDisk = &PhysicalDiskMeta{Target: " "} + if got := NewPhysicalDiskView(r).Target(); got != "" { + t.Fatalf("whitespace Target: expected trimmed %q, got %q", "", got) + } + // (c) Fully populated → trimmed exact value. + r.PhysicalDisk.Target = " 0:0:0:0 " + if got, want := NewPhysicalDiskView(r).Target(), "0:0:0:0"; got != want { + t.Fatalf("expected Target %q, got %q", want, got) + } + }) + + t.Run("StorageGroup", func(t *testing.T) { + // (a) nil receiver. + var zero PhysicalDiskView + if got := zero.StorageGroup(); got != "" { + t.Fatalf("nil receiver: expected %q, got %q", "", got) + } + // (a) populated Resource, nil PhysicalDisk backing. + r := &Resource{ID: "pd-sg-1", Type: ResourceTypePhysicalDisk} + if got := NewPhysicalDiskView(r).StorageGroup(); got != "" { + t.Fatalf("nil PhysicalDisk: expected %q, got %q", "", got) + } + // (b) PhysicalDisk present, StorageGroup whitespace-only → trims to "". + r.PhysicalDisk = &PhysicalDiskMeta{StorageGroup: "\t "} + if got := NewPhysicalDiskView(r).StorageGroup(); got != "" { + t.Fatalf("whitespace StorageGroup: expected trimmed %q, got %q", "", got) + } + // (c) Fully populated → trimmed exact value. + r.PhysicalDisk.StorageGroup = " sg-fast " + if got, want := NewPhysicalDiskView(r).StorageGroup(), "sg-fast"; got != want { + t.Fatalf("expected StorageGroup %q, got %q", want, got) + } + }) + + t.Run("IO", func(t *testing.T) { + // (a) nil receiver. + var zero PhysicalDiskView + if got := zero.IO(); got != nil { + t.Fatalf("nil receiver: expected nil, got %+v", got) + } + // (a) populated Resource, nil PhysicalDisk backing. + r := &Resource{ID: "pd-io-1", Type: ResourceTypePhysicalDisk} + if got := NewPhysicalDiskView(r).IO(); got != nil { + t.Fatalf("nil PhysicalDisk: expected nil, got %+v", got) + } + // (b) PhysicalDisk present, IO pointer nil → accessor returns nil. + r.PhysicalDisk = &PhysicalDiskMeta{} // IO is nil + if got := NewPhysicalDiskView(r).IO(); got != nil { + t.Fatalf("nil IO: expected nil, got %+v", got) + } + // (c) Fully populated → field-equal clone on a fresh allocation. + want := &PhysicalDiskIOMeta{ + Device: "sda", + ReadBytes: 100, + WriteBytes: 200, + ReadOps: 1, + WriteOps: 2, + ReadTimeMs: 3, + WriteTimeMs: 4, + IOTimeMs: 5, + } + r.PhysicalDisk.IO = want + got := NewPhysicalDiskView(r).IO() + if got == nil { + t.Fatal("populated: expected non-nil") + } + if !reflect.DeepEqual(*got, *want) { + t.Fatalf("expected field-equal clone, got %+v want %+v", *got, *want) + } + // Returned pointer must be a fresh allocation, not the live source. + if got == r.PhysicalDisk.IO { + t.Fatal("expected IO() to return a fresh pointer, not the source pointer") + } + // Mutating the clone must not leak back to the source. + got.ReadBytes = 9999 + got.Device = "mutated" + if r.PhysicalDisk.IO.ReadBytes != 100 || r.PhysicalDisk.IO.Device != "sda" { + t.Fatalf("mutation leaked to source: got %+v", *r.PhysicalDisk.IO) + } + }) + + t.Run("Collection", func(t *testing.T) { + // (a) nil receiver. + var zero PhysicalDiskView + if got := zero.Collection(); got != nil { + t.Fatalf("nil receiver: expected nil, got %+v", got) + } + // (a) populated Resource, nil PhysicalDisk backing. + r := &Resource{ID: "pd-coll-1", Type: ResourceTypePhysicalDisk} + if got := NewPhysicalDiskView(r).Collection(); got != nil { + t.Fatalf("nil PhysicalDisk: expected nil, got %+v", got) + } + // (b) PhysicalDisk present, Collection pointer nil → accessor returns nil + // (diskinventory.CloneStatus(nil) returns nil). + r.PhysicalDisk = &PhysicalDiskMeta{} // Collection is nil + if got := NewPhysicalDiskView(r).Collection(); got != nil { + t.Fatalf("nil Collection: expected nil, got %+v", got) + } + // (c) Fully populated → field-equal clone on a fresh allocation. + want := &diskinventory.CollectionStatus{ + Serial: diskinventory.Available("smartctl"), + Temperature: diskinventory.Unavailable("smartctl", "no sensor"), + IO: diskinventory.FieldStatus{State: diskinventory.FieldAvailable, Source: "sysfs"}, + Controller: diskinventory.Unsupported("nvme", "n/a"), + Pool: diskinventory.Missing("zfs", "not imported"), + } + r.PhysicalDisk.Collection = want + got := NewPhysicalDiskView(r).Collection() + if got == nil { + t.Fatal("populated: expected non-nil") + } + if !reflect.DeepEqual(*got, *want) { + t.Fatalf("expected field-equal clone, got %+v want %+v", *got, *want) + } + // Returned pointer must be a fresh allocation, not the live source. + if got == r.PhysicalDisk.Collection { + t.Fatal("expected Collection() to return a fresh pointer, not the source pointer") + } + // Mutating the clone (scalar via value field on the cloned struct) must + // not leak back to the source. CollectionStatus is a flat struct of + // FieldStatus values, so mutating a nested FieldStatus proves + // independence. + got.Serial = diskinventory.FieldStatus{State: diskinventory.FieldMissing, Source: "mutated"} + got.IO.Source = "leaked" + if r.PhysicalDisk.Collection.Serial.State != diskinventory.FieldAvailable || + r.PhysicalDisk.Collection.Serial.Source != "smartctl" { + t.Fatalf("Serial mutation leaked to source: %+v", r.PhysicalDisk.Collection.Serial) + } + if r.PhysicalDisk.Collection.IO.Source != "sysfs" { + t.Fatalf("IO.Source mutation leaked to source: %q", r.PhysicalDisk.Collection.IO.Source) + } + }) +} diff --git a/pkg/diskinventory/status_clone_branchcov0720pm_test.go b/pkg/diskinventory/status_clone_branchcov0720pm_test.go new file mode 100644 index 000000000..c878d495a --- /dev/null +++ b/pkg/diskinventory/status_clone_branchcov0720pm_test.go @@ -0,0 +1,92 @@ +package diskinventory + +import ( + "reflect" + "testing" +) + +// TestCloneStatusBranchcov0720pm exercises CloneStatus for nil-safety, +// deep value equality on fully-populated and zero-value inputs, and +// bidirectional deep-copy independence (mutating the clone must not affect +// the original and vice versa). +func TestCloneStatusBranchcov0720pm(t *testing.T) { + t.Run("nil input returns nil", func(t *testing.T) { + got := CloneStatus(nil) + if got != nil { + t.Fatalf("CloneStatus(nil) = %v, want nil", got) + } + }) + + t.Run("zero value clones equal", func(t *testing.T) { + orig := &CollectionStatus{} + clone := CloneStatus(orig) + if clone == nil { + t.Fatalf("CloneStatus returned nil for non-nil zero-value input") + } + if clone == orig { + t.Fatalf("CloneStatus returned identical pointer; want distinct value-equal instance") + } + if !reflect.DeepEqual(*clone, *orig) { + t.Fatalf("zero-value clone not equal to original: clone=%+v orig=%+v", *clone, *orig) + } + }) + + t.Run("fully populated clones deeply equal", func(t *testing.T) { + orig := fullyPopulatedStatus() + clone := CloneStatus(orig) + if clone == orig { + t.Fatalf("CloneStatus returned identical pointer; want distinct value-equal instance") + } + if !reflect.DeepEqual(*clone, *orig) { + t.Fatalf("clone not deeply equal to original: clone=%+v orig=%+v", *clone, *orig) + } + }) + + t.Run("clone mutations leave original unchanged", func(t *testing.T) { + orig := fullyPopulatedStatus() + snapshot := *orig + clone := CloneStatus(orig) + + // Mutate every field on the clone. CollectionStatus contains only + // value-typed FieldStatus fields (no slices/maps/pointers), so this + // exercises each value field's independence directly. + clone.Serial = Unavailable("clone-mut", "serial") + clone.Temperature = Available("clone-mut") + clone.IO = Unsupported("clone-mut", "io") + clone.Controller = Missing("clone-mut", "controller") + clone.Pool = Unavailable("clone-mut", "pool") + + if !reflect.DeepEqual(*orig, snapshot) { + t.Fatalf("original changed after mutating clone: orig=%+v snapshot=%+v", *orig, snapshot) + } + }) + + t.Run("original mutations leave clone unchanged", func(t *testing.T) { + orig := fullyPopulatedStatus() + clone := CloneStatus(orig) + cloneSnapshot := *clone + + orig.Serial = Unavailable("orig-mut", "serial") + orig.Temperature = Available("orig-mut") + orig.IO = Unsupported("orig-mut", "io") + orig.Controller = Missing("orig-mut", "controller") + orig.Pool = Unavailable("orig-mut", "pool") + + if !reflect.DeepEqual(*clone, cloneSnapshot) { + t.Fatalf("clone changed after mutating original: clone=%+v snapshot=%+v", *clone, cloneSnapshot) + } + }) +} + +// fullyPopulatedStatus returns a CollectionStatus with every field set to a +// distinct, non-zero FieldStatus so equality and independence checks are +// sensitive to every field on the struct. +func fullyPopulatedStatus() *CollectionStatus { + return &CollectionStatus{ + Serial: Available("smartctl"), + Temperature: Unavailable("smartctl", "deadline exceeded"), + IO: Missing("kernel", "counter absent"), + Controller: Unsupported("provider", "not exposed"), + Pool: Available("provider"), + } +}