diff --git a/package-lock.json b/package-lock.json index f14a5ab3c..4a53130e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -321,29 +321,6 @@ "integrity": "sha512-c5mZdiQmw9ePRIpABWxC5JvNx2R7c1WYfNmFVv3CyWD7pUzTbB6vd/OIiz47ZM8oDuElyEcqhBl039Da466lvg==", "license": "Apache-2.0" }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -410,9 +387,9 @@ } }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -1023,6 +1000,7 @@ "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", "devOptional": true, + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", @@ -1072,6 +1050,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } @@ -1187,6 +1166,7 @@ "integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.60.1", "@typescript-eslint/types": "8.60.1", @@ -1613,6 +1593,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2506,6 +2487,7 @@ "integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -2627,9 +2609,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -2799,6 +2781,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -4404,9 +4387,9 @@ } }, "node_modules/nodemon/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -4657,6 +4640,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.13.0", "pg-pool": "^3.14.0", @@ -5527,6 +5511,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5719,6 +5704,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5835,6 +5821,7 @@ "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -5926,6 +5913,7 @@ "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.8", "@vitest/mocker": "4.1.8", diff --git a/src/models/RCS.Config.ts b/src/models/RCS.Config.ts index 242b8c2b8..c0050f98f 100644 --- a/src/models/RCS.Config.ts +++ b/src/models/RCS.Config.ts @@ -88,11 +88,49 @@ export interface RemoteConfig { AMTDomains: AMTDomain[] } +/** + * Per-component activation outcome. Additive structured detail that rides alongside the + * legacy flat status strings in the WebSocket `message` field (see issue #2665). Older + * clients that do not understand `Components` simply ignore it. + */ +export type ComponentResultStatus = 'Success' | 'Failure' | 'NotApplicable' + +/** Clean machine-readable mode enum for a component result. Prose belongs in Details. */ +export type ComponentMode = 'ACM' | 'CCM' | 'LocalProfileSync' + +export interface ComponentResult { + Result: ComponentResultStatus + Mode?: ComponentMode + /** Human-readable, sentence-case note (no trailing period). On failure this carries the reason. */ + Details?: string +} + +export interface ComponentResults { + Activation?: ComponentResult + WiredNetwork?: ComponentResult + WirelessNetwork?: ComponentResult + TLS?: ComponentResult + CIRAProxy?: ComponentResult + CIRAConnection?: ComponentResult +} + +/** + * Schema version for the structured `Components` block (issue #2665). Bump when the set of + * reported components changes (e.g. when 802.1x reporting lands) so clients can reason about coverage. + */ +export const COMPONENTS_VERSION = 1 + export interface Status { Status?: string Network?: string CIRAConnection?: string TLSConfiguration?: string + // Additive structured per-component results (issue #2665). Legacy flat fields above are + // always populated for backwards compatibility; the fields below carry the granular breakdown. + // Result is the overall roll-up; ComponentsVersion marks the Components schema version. + Result?: ComponentResultStatus + ComponentsVersion?: number + Components?: ComponentResults } export interface ClientObject { ClientId: string diff --git a/src/stateMachines/activation.test.ts b/src/stateMachines/activation.test.ts index bc9791690..f0456c358 100644 --- a/src/stateMachines/activation.test.ts +++ b/src/stateMachines/activation.test.ts @@ -36,6 +36,9 @@ vi.mock('./common.js', async () => { invokeWsmanCall: invokeWsmanCallSpy, invokeEnterpriseAssistantCall: vi.fn(), processTLSTunnelResponse: vi.fn(), + recordComponentResult: actual.recordComponentResult, + deriveControlMode: actual.deriveControlMode, + finalizeComponentResults: actual.finalizeComponentResults, HttpResponseError, isDigestRealmValid, coalesceMessage @@ -828,6 +831,11 @@ describe('Activation State Machine', () => { devices[context.clientId].status.Status = 'Admin control mode.' activation.setActivationStatus({ context }) expect(devices[clientId].activationStatus).toBeTruthy() + expect(devices[clientId].status.Components?.Activation).toEqual({ + Result: 'Success', + Mode: 'ACM', + Details: 'Admin control mode' + }) }) }) @@ -2081,8 +2089,13 @@ describe('Activation State Machine', () => { })) it('should send success message to device', () => { + context.status = 'success' + devices[clientId].status = { Status: 'Admin control mode.' } activation.sendMessageToDevice({ context }) expect(sendSpy).toHaveBeenCalled() + expect(devices[clientId].status.Result).toEqual('Success') + expect(devices[clientId].status.ComponentsVersion).toEqual(1) + expect(devices[clientId].status.Components?.Activation?.Result).toEqual('Success') }) it('should update Credentials', () => { activation.updateCredentials({ context }) @@ -2092,6 +2105,8 @@ describe('Activation State Machine', () => { it('should send error message to device', () => { context.status = 'error' context.message = null + // Device never reached an activated control mode, so Activation is recorded as a Failure. + devices[clientId].activationStatus = false activation.sendMessageToDevice({ context }) expect(responseMessageSpy).toHaveBeenCalledWith( context.clientId, @@ -2100,6 +2115,7 @@ describe('Activation State Machine', () => { 'failed', JSON.stringify(devices[clientId].status) ) + expect(devices[clientId].status.Components?.Activation?.Result).toEqual('Failure') expect(sendSpy).toHaveBeenCalled() }) }) diff --git a/src/stateMachines/activation.ts b/src/stateMachines/activation.ts index e6c4259dd..03f145975 100644 --- a/src/stateMachines/activation.ts +++ b/src/stateMachines/activation.ts @@ -25,7 +25,14 @@ import { DbCreatorFactory } from '../factories/DbCreatorFactory.js' import { AMTUserName, GATEWAY_TIMEOUT_ERROR, asArray } from '../utils/constants.js' import { CIRAConfiguration } from './ciraConfiguration.js' import { TLS } from './tls.js' -import { type CommonContext, invokeWsmanCall, processTLSTunnelResponse } from './common.js' +import { + type CommonContext, + invokeWsmanCall, + processTLSTunnelResponse, + recordComponentResult, + deriveControlMode, + finalizeComponentResults +} from './common.js' import ClientResponseMsg from '../utils/ClientResponseMsg.js' import { Unconfiguration } from './unconfiguration.js' import { type DeviceCredentials } from '../interfaces/ISecretManagerService.js' @@ -139,7 +146,17 @@ export class Activation { } else { clientObj.status.Status = context.errorMessage !== '' ? context.errorMessage : 'Failed' method = 'failed' + // Record the Activation component as a failure when the device never reached an + // activated control mode (issue #2665). If it activated but a later component failed, + // 'Set activation status' already recorded Activation as a success — don't overwrite it. + if (clientObj.activationStatus !== true) { + recordComponentResult(clientId, 'Activation', { + Result: 'Failure', + Details: clientObj.status.Status + }) + } } + finalizeComponentResults(clientId, status === 'success') const responseMessage = ClientResponseMsg.get(clientId, null, status, method, JSON.stringify(clientObj.status)) this.logger.info(JSON.stringify(responseMessage, null, '\t')) devices[clientId].ClientSocket?.send(JSON.stringify(responseMessage)) @@ -426,6 +443,11 @@ export class Activation { const clientObj = devices[context.clientId] this.logger.debug(`Device ${clientObj.uuid} activated in ${clientObj.status.Status}.`) clientObj.activationStatus = true + recordComponentResult(context.clientId, 'Activation', { + Result: 'Success', + Mode: deriveControlMode(clientObj.status.Status), + Details: clientObj.status.Status + }) MqttProvider.publishEvent( 'success', ['Activator', 'execute'], diff --git a/src/stateMachines/ciraConfiguration.test.ts b/src/stateMachines/ciraConfiguration.test.ts index 66922d574..e878e7517 100644 --- a/src/stateMachines/ciraConfiguration.test.ts +++ b/src/stateMachines/ciraConfiguration.test.ts @@ -29,6 +29,7 @@ vi.mock('./common.js', async () => { invokeWsmanCall: invokeWsmanCallSpy, invokeEnterpriseAssistantCall: vi.fn(), processTLSTunnelResponse: vi.fn(), + recordComponentResult: actual.recordComponentResult, HttpResponseError, isDigestRealmValid, coalesceMessage @@ -183,6 +184,10 @@ describe('CIRA Configuration State Machine', () => { if (state.matches('SUCCESS') || state.matches('FAILURE') || currentStateIndex === flowStates.length) { const status = devices[clientId].status.CIRAConnection expect(status).toEqual('Configured') + expect(devices[clientId].status.Components?.CIRAConnection).toEqual({ + Result: 'Success', + Details: 'Configured' + }) resolve() } } @@ -212,6 +217,7 @@ describe('CIRA Configuration State Machine', () => { try { if (state.matches('SUCCESS') || state.matches('FAILURE')) { expect(state.matches('FAILURE')).toBeTruthy() + expect(devices[clientId].status.Components?.CIRAConnection?.Result).toEqual('Failure') resolve() } } catch (err) { diff --git a/src/stateMachines/ciraConfiguration.ts b/src/stateMachines/ciraConfiguration.ts index 7e01031ee..1dffcd233 100644 --- a/src/stateMachines/ciraConfiguration.ts +++ b/src/stateMachines/ciraConfiguration.ts @@ -22,7 +22,7 @@ import { type DeviceCredentials } from '../interfaces/ISecretManagerService.js' import { type AMTConfiguration } from '../models/index.js' import { randomUUID } from 'node:crypto' import { Error } from './error.js' -import { type CommonContext, invokeWsmanCall } from './common.js' +import { type CommonContext, invokeWsmanCall, recordComponentResult } from './common.js' export interface CIRAConfigContext extends CommonContext { status: 'success' | 'error' | 'wsman' | 'heartbeat_request' @@ -315,6 +315,11 @@ export class CIRAConfiguration { actions: { 'Update Configuration Status': ({ context, event }) => { devices[context.clientId].status.CIRAConnection = context.statusMessage + const success = context.statusMessage === 'Configured' + recordComponentResult(context.clientId, 'CIRAConnection', { + Result: success ? 'Success' : 'Failure', + Details: context.statusMessage + }) }, 'Reset Unauth Count': ({ context, event }) => { devices[context.clientId].unauthCount = 0 diff --git a/src/stateMachines/common.test.ts b/src/stateMachines/common.test.ts index 7fd393738..817b9ed2a 100644 --- a/src/stateMachines/common.test.ts +++ b/src/stateMachines/common.test.ts @@ -18,8 +18,14 @@ import { invokeEnterpriseAssistantCall, invokeEnterpriseAssistantCallInternal, invokeWsmanCall, - coalesceMessage + coalesceMessage, + recordComponentResult, + normalizeDetails, + deriveControlMode, + rollUpResult, + finalizeComponentResults } from './common.js' +import { COMPONENTS_VERSION } from '../models/RCS.Config.js' Environment.Config = config describe('Common', () => { @@ -195,4 +201,126 @@ describe('Common', () => { expect(msg).toContain('Bad Request') expect(msg).toContain('400') }) + + describe('recordComponentResult', () => { + it('should initialize Components and record a result (issue #2665)', () => { + devices[clientId].status = { Status: 'Admin control mode.' } + recordComponentResult(clientId, 'Activation', { + Result: 'Success', + Mode: 'ACM' + }) + expect(devices[clientId].status.Components?.Activation).toEqual({ + Result: 'Success', + Mode: 'ACM' + }) + }) + + it('should preserve previously recorded components when adding another', () => { + devices[clientId].status = { Components: { Activation: { Result: 'Success' } } } + recordComponentResult(clientId, 'WirelessNetwork', { + Result: 'Failure', + Details: 'Failed to add 1' + }) + expect(devices[clientId].status.Components?.Activation).toEqual({ Result: 'Success' }) + expect(devices[clientId].status.Components?.WirelessNetwork).toEqual({ + Result: 'Failure', + Details: 'Failed to add 1' + }) + }) + + it('should normalize the failure detail and carry the reason in Details', () => { + devices[clientId].status = {} + recordComponentResult(clientId, 'TLS', { Result: 'Failure', Details: 'cert add rejected.' }) + expect(devices[clientId].status.Components?.TLS).toEqual({ + Result: 'Failure', + Details: 'Cert add rejected' + }) + }) + + it('should no-op when the device has no status object', () => { + delete (devices[clientId] as any).status + expect(() => { + recordComponentResult(clientId, 'TLS', { Result: 'Success' }) + }).not.toThrow() + expect(devices[clientId].status).toBeUndefined() + }) + }) + + describe('normalizeDetails', () => { + it('strips a single trailing period and capitalizes the first character', () => { + expect(normalizeDetails('already enabled in admin mode.')).toEqual('Already enabled in admin mode') + }) + it('leaves already-clean strings untouched', () => { + expect(normalizeDetails('Wired Network Configured')).toEqual('Wired Network Configured') + }) + it('handles empty/whitespace input', () => { + expect(normalizeDetails(' ')).toEqual('') + }) + }) + + describe('deriveControlMode', () => { + it('maps admin strings to ACM', () => { + expect(deriveControlMode('already enabled in admin mode.')).toEqual('ACM') + }) + it('maps client strings to CCM', () => { + expect(deriveControlMode('Client control mode.')).toEqual('CCM') + }) + it('returns undefined for unrecognized modes', () => { + expect(deriveControlMode('something else')).toBeUndefined() + }) + }) + + describe('rollUpResult', () => { + it('is Failure when any component failed', () => { + expect(rollUpResult({ TLS: { Result: 'Success' }, CIRAProxy: { Result: 'Failure' } })).toEqual('Failure') + }) + it('is Success when at least one succeeded and none failed', () => { + expect(rollUpResult({ TLS: { Result: 'Success' }, CIRAProxy: { Result: 'NotApplicable' } })).toEqual('Success') + }) + it('is NotApplicable when nothing was attempted', () => { + expect(rollUpResult({ TLS: { Result: 'NotApplicable' } })).toEqual('NotApplicable') + }) + }) + + describe('finalizeComponentResults', () => { + it('backfills NotApplicable components, the overall roll-up, and the schema version', () => { + devices[clientId].status = { + Status: 'already enabled in admin mode.', + Components: { TLS: { Result: 'Success' } } + } + finalizeComponentResults(clientId, true) + const status = devices[clientId].status + expect(status.ComponentsVersion).toEqual(COMPONENTS_VERSION) + expect(status.Result).toEqual('Success') + // Already-activated device: Activation is backfilled from the legacy flat status. + expect(status.Components?.Activation).toEqual({ + Result: 'Success', + Mode: 'ACM', + Details: 'Already enabled in admin mode' + }) + // Every remaining component is present as NotApplicable. + expect(status.Components?.CIRAConnection?.Result).toEqual('NotApplicable') + expect(status.Components?.WiredNetwork?.Result).toEqual('NotApplicable') + }) + + it('does not backfill Activation on the failure path', () => { + devices[clientId].status = { Status: 'Failed', Components: {} } + finalizeComponentResults(clientId, false) + expect(devices[clientId].status.Components?.Activation?.Result).toEqual('NotApplicable') + }) + + it("forces Result to 'Failure' on the failure path even when no component recorded a failure", () => { + devices[clientId].status = { Status: 'Admin control mode.', Components: { Activation: { Result: 'Success' } } } + finalizeComponentResults(clientId, false) + expect(devices[clientId].status.Result).toEqual('Failure') + expect(devices[clientId].status.Components?.Activation?.Result).toEqual('Success') + }) + + it('no-ops when the device has no status object', () => { + delete (devices[clientId] as any).status + expect(() => { + finalizeComponentResults(clientId, true) + }).not.toThrow() + }) + }) }) diff --git a/src/stateMachines/common.ts b/src/stateMachines/common.ts index 4c9a7caf9..522858754 100644 --- a/src/stateMachines/common.ts +++ b/src/stateMachines/common.ts @@ -20,6 +20,12 @@ import { } from '../utils/constants.js' import Logger from '../Logger.js' import { type HttpHandler } from '../HttpHandler.js' +import { + type ComponentResult, + type ComponentResults, + type ComponentResultStatus, + COMPONENTS_VERSION +} from '../models/RCS.Config.js' import pkg, { type HttpZResponseModel } from 'http-z' import { parseChunkedMessage } from '../utils/parseChunkedMessage.js' import { TLSTunnelManager } from '../TLSTunnelManager.js' @@ -419,6 +425,137 @@ export function coalesceMessage(prefixMsg: string, err: any): string { return msg } +// Every component RPS can report on. Used to backfill 'NotApplicable' entries so the +// Components block always carries the full, predictable key set (issue #2665). +const ALL_COMPONENTS: (keyof ComponentResults)[] = [ + 'Activation', + 'WiredNetwork', + 'WirelessNetwork', + 'TLS', + 'CIRAProxy', + 'CIRAConnection' +] + +const NOT_APPLICABLE_DETAILS: Record = { + Activation: 'Activation not part of this configuration', + WiredNetwork: 'Wired network not part of this configuration', + WirelessNetwork: 'Wireless network not part of this configuration', + TLS: 'TLS not part of this configuration', + CIRAProxy: 'CIRA proxy not part of this configuration', + CIRAConnection: 'CIRA connection not part of this configuration' +} + +/** + * Normalizes a human-readable detail string to sentence-case with no trailing period, so the + * structured Components block reads consistently regardless of which child machine produced it. + */ +export function normalizeDetails(details: string): string { + const trimmed = details.trim().replace(/\.+$/, '') + if (trimmed.length === 0) { + return trimmed + } + return trimmed.charAt(0).toUpperCase() + trimmed.slice(1) +} + +/** + * Maps a legacy control-mode status string onto a clean machine-readable mode enum. + * Returns undefined when the string is absent or carries no recognizable control mode. + */ +export function deriveControlMode(statusMessage: string | undefined): 'ACM' | 'CCM' | undefined { + if (statusMessage == null) { + return undefined + } + const normalized = statusMessage.toLowerCase() + if (normalized.includes('admin')) { + return 'ACM' + } + if (normalized.includes('client')) { + return 'CCM' + } + return undefined +} + +/** + * Records a structured per-component activation result onto the shared device status object + * (issue #2665). This is additive: it sits alongside the legacy flat status strings and is + * serialized into the final WebSocket `message` payload. Recording a component result never + * alters control flow — a failure is captured for visibility but does not change the + * activation outcome. + * + * Shape invariants enforced here: Details are normalized to sentence-case (the failure reason rides + * in Details), and undefined optional fields are omitted. + */ +export function recordComponentResult( + clientId: string, + component: keyof ComponentResults, + result: ComponentResult +): void { + const clientObj = devices[clientId] + if (clientObj?.status == null) { + return + } + if (clientObj.status.Components == null) { + clientObj.status.Components = {} + } + const normalized: ComponentResult = { Result: result.Result } + if (result.Mode != null) { + normalized.Mode = result.Mode + } + if (result.Details != null) { + normalized.Details = normalizeDetails(result.Details) + } + clientObj.status.Components[component] = normalized +} + +/** + * Computes the overall activation roll-up from the per-component results: any failure makes the + * whole outcome a Failure; otherwise any success makes it a Success; otherwise NotApplicable. + */ +export function rollUpResult(components: ComponentResults): ComponentResultStatus { + const results = Object.values(components).map((c) => c?.Result) + if (results.includes('Failure')) { + return 'Failure' + } + if (results.includes('Success')) { + return 'Success' + } + return 'NotApplicable' +} + +/** + * Finalizes the structured Components block just before it is serialized to the device (issue #2665): + * backfills Activation for already-activated devices that skipped the activation steps, fills every + * un-attempted component with a NotApplicable entry so the key set is always complete, and stamps the + * overall Result roll-up and Components schema version. Additive only — legacy flat fields are untouched. + */ +export function finalizeComponentResults(clientId: string, overallSuccess: boolean): void { + const clientObj = devices[clientId] + if (clientObj?.status == null) { + return + } + const status = clientObj.status + if (status.Components == null) { + status.Components = {} + } + // Already-activated devices (CCM/ACM) skip the activation WSMAN steps, so Activation is never + // recorded by setActivationStatus. Backfill it from the legacy flat status on the success path. + if (overallSuccess && status.Components.Activation == null && status.Status != null) { + recordComponentResult(clientId, 'Activation', { + Result: 'Success', + Mode: deriveControlMode(status.Status), + Details: status.Status + }) + } + for (const component of ALL_COMPONENTS) { + if (status.Components[component] == null) { + status.Components[component] = { Result: 'NotApplicable', Details: NOT_APPLICABLE_DETAILS[component] } + } + } + // A failed outcome must never roll up to 'Success'. + status.Result = overallSuccess ? rollUpResult(status.Components) : 'Failure' + status.ComponentsVersion = COMPONENTS_VERSION +} + const isDigestRealmValid = (realm: string): boolean => { const regex = /[0-9A-Fa-f]{32}/g let isValidRealm = false diff --git a/src/stateMachines/proxyConfiguration.test.ts b/src/stateMachines/proxyConfiguration.test.ts index 789f07bd3..b1825314f 100644 --- a/src/stateMachines/proxyConfiguration.test.ts +++ b/src/stateMachines/proxyConfiguration.test.ts @@ -98,6 +98,7 @@ describe('Proxy Configuration State Machine', () => { if (state.matches('FAILED') && currentStateIndex === flowStates.length) { const status = devices[clientId].status.Network expect(status).toContain('Failed to get proxy config from DB') + expect(devices[clientId].status.Components?.CIRAProxy?.Result).toEqual('Failure') service.stop() resolve() } @@ -145,6 +146,10 @@ describe('Proxy Configuration State Machine', () => { if (state.matches('SUCCESS') && currentStateIndex === flowStates.length) { const status = devices[clientId].status.Network expect(status).toEqual('Initial. Proxy Configured') + expect(devices[clientId].status.Components?.CIRAProxy).toEqual({ + Result: 'Success', + Details: 'Proxy Configured' + }) service.stop() resolve() } @@ -193,6 +198,11 @@ describe('Proxy Configuration State Machine', () => { if (state.matches('SUCCESS') && currentStateIndex === flowStates.length) { const status = devices[clientId].status.Network expect(status).toEqual('Initial. Failed to add proxy1') + // Partial failure (some configs failed) is reported as a CIRAProxy Failure. + expect(devices[clientId].status.Components?.CIRAProxy).toEqual({ + Result: 'Failure', + Details: 'Failed to add proxy1' + }) resolve() } } catch (err) { @@ -236,6 +246,11 @@ describe('Proxy Configuration State Machine', () => { if (state.matches('SUCCESS') && currentStateIndex === flowStates.length) { const status = devices[clientId].status.Network expect(status).toEqual('Initial. Failed to add proxy1') + // Partial failure (some configs failed) is reported as a CIRAProxy Failure. + expect(devices[clientId].status.Components?.CIRAProxy).toEqual({ + Result: 'Failure', + Details: 'Failed to add proxy1' + }) resolve() } } catch (err) { diff --git a/src/stateMachines/proxyConfiguration.ts b/src/stateMachines/proxyConfiguration.ts index 45fbc1ddf..dd4da2ea5 100644 --- a/src/stateMachines/proxyConfiguration.ts +++ b/src/stateMachines/proxyConfiguration.ts @@ -13,7 +13,7 @@ import { devices } from '../devices.js' import { Error } from './error.js' import { Configurator } from '../Configurator.js' import { DbCreatorFactory } from '../factories/DbCreatorFactory.js' -import { type CommonContext, invokeWsmanCall } from './common.js' +import { type CommonContext, invokeWsmanCall, recordComponentResult } from './common.js' import { UNEXPECTED_PARSE_ERROR } from '../utils/constants.js' export interface ProxyConfigContext extends CommonContext { @@ -115,6 +115,11 @@ export class ProxyConfiguration { message = statusMessage } device.status.Network = networkStatus ? `${networkStatus}. ${message}` : message + const failed = !!errorMessage || !!proxyConfigsFailed + recordComponentResult(clientId, 'CIRAProxy', { + Result: failed ? 'Failure' : 'Success', + Details: message + }) }, 'Reset Retry Count': assign({ retryCount: () => 0 }), 'Increment Retry Count': assign({ retryCount: ({ context }) => context.retryCount + 1 }), diff --git a/src/stateMachines/tls.test.ts b/src/stateMachines/tls.test.ts index 70b50ac64..cdcb74cb2 100644 --- a/src/stateMachines/tls.test.ts +++ b/src/stateMachines/tls.test.ts @@ -17,10 +17,14 @@ import { Environment } from '../utils/Environment.js' import { vi } from 'vitest' const invokeWsmanCallSpy = vi.hoisted(() => vi.fn()) const invokeEnterpriseAssistantCallSpy = vi.hoisted(() => vi.fn()) -vi.mock('./common.js', () => ({ - invokeWsmanCall: invokeWsmanCallSpy, - invokeEnterpriseAssistantCall: invokeEnterpriseAssistantCallSpy -})) +vi.mock('./common.js', async () => { + const actual = await vi.importActual('./common.js') + return { + invokeWsmanCall: invokeWsmanCallSpy, + invokeEnterpriseAssistantCall: invokeEnterpriseAssistantCallSpy, + recordComponentResult: actual.recordComponentResult + } +}) const { TLS } = await import('./tls.js') @@ -397,6 +401,10 @@ describe('TLS State Machine', () => { context.statusMessage = 'success status message' tls.updateConfigurationStatus({ context }) expect(devices[context.clientId].status.TLSConfiguration).toEqual('success status message') + expect(devices[context.clientId].status.Components?.TLS).toEqual({ + Result: 'Success', + Details: 'Success status message' + }) expect(invokeWsmanCallSpy).not.toHaveBeenCalled() }) it('should updateConfigurationStatus when failure', async () => { @@ -404,6 +412,10 @@ describe('TLS State Machine', () => { context.errorMessage = 'error status message' tls.updateConfigurationStatus({ context }) expect(devices[context.clientId].status.TLSConfiguration).toEqual('error status message') + expect(devices[context.clientId].status.Components?.TLS).toEqual({ + Result: 'Failure', + Details: 'Error status message' + }) expect(invokeWsmanCallSpy).not.toHaveBeenCalled() }) it('should enumerateTLSData', async () => { diff --git a/src/stateMachines/tls.ts b/src/stateMachines/tls.ts index e6a84fc2d..53a7a6c56 100644 --- a/src/stateMachines/tls.ts +++ b/src/stateMachines/tls.ts @@ -22,7 +22,7 @@ import { initiateCertRequest, sendEnterpriseAssistantKeyPairResponse } from './enterpriseAssistant.js' -import { type CommonContext, invokeWsmanCall } from './common.js' +import { type CommonContext, invokeWsmanCall, recordComponentResult } from './common.js' export interface TLSContext extends CommonContext { amtProfile: AMTConfiguration | null @@ -233,8 +233,17 @@ export class TLS { updateConfigurationStatus({ context }: { context: TLSContext }): void { if (context.status === 'success') { devices[context.clientId].status.TLSConfiguration = context.statusMessage + recordComponentResult(context.clientId, 'TLS', { + Result: 'Success', + Details: context.statusMessage + }) } else if (context.status === 'error') { - devices[context.clientId].status.TLSConfiguration = context.errorMessage !== '' ? context.errorMessage : 'Failed' + const details = context.errorMessage !== '' ? context.errorMessage : 'Failed' + devices[context.clientId].status.TLSConfiguration = details + recordComponentResult(context.clientId, 'TLS', { + Result: 'Failure', + Details: details + }) } } diff --git a/src/stateMachines/wifiNetworkConfiguration.test.ts b/src/stateMachines/wifiNetworkConfiguration.test.ts index 095d91e87..08455f123 100644 --- a/src/stateMachines/wifiNetworkConfiguration.test.ts +++ b/src/stateMachines/wifiNetworkConfiguration.test.ts @@ -29,6 +29,7 @@ vi.mock('./common.js', async () => { invokeWsmanCall: invokeWsmanCallSpy, invokeEnterpriseAssistantCall: invokeEnterpriseAssistantCallSpy, processTLSTunnelResponse: vi.fn(), + recordComponentResult: actual.recordComponentResult, HttpResponseError, isDigestRealmValid, coalesceMessage @@ -273,6 +274,7 @@ describe('WiFi Network Configuration', () => { expect(status).toEqual( 'Wired Network Configured. Failed to put Max Retransmissions to ethernet port settings' ) + expect(devices[clientId].status.Components?.WirelessNetwork?.Result).toEqual('Failure') resolve() } } catch (err) { @@ -881,6 +883,10 @@ describe('WiFi Network Configuration', () => { if (state.matches('SUCCESS') && currentStateIndex === flowStates.length) { const status = devices[clientId].status.Network expect(status).toEqual('Wired Network Configured. Wireless Configured') + expect(devices[clientId].status.Components?.WirelessNetwork).toEqual({ + Result: 'Success', + Details: 'Wireless Configured' + }) resolve() } } catch (err) { @@ -928,6 +934,11 @@ describe('WiFi Network Configuration', () => { if (state.matches('SUCCESS_SYNC_ONLY') && currentStateIndex === flowStates.length) { const status = devices[clientId].status.Network expect(status).toEqual('Wired Network Configured. Wireless Only Local Profile Sync Configured') + expect(devices[clientId].status.Components?.WirelessNetwork).toEqual({ + Result: 'Success', + Mode: 'LocalProfileSync', + Details: 'Wireless Only Local Profile Sync Configured' + }) resolve() } } catch (err) { @@ -1173,6 +1184,10 @@ describe('WiFi Network Configuration', () => { if (state.matches('SUCCESS') && currentStateIndex === flowStates.length) { const status = devices[clientId].status.Network expect(status).toEqual('Wired Network Configured. Wireless Configured') + expect(devices[clientId].status.Components?.WirelessNetwork).toEqual({ + Result: 'Success', + Details: 'Wireless Configured' + }) resolve() } } catch (err) { diff --git a/src/stateMachines/wifiNetworkConfiguration.ts b/src/stateMachines/wifiNetworkConfiguration.ts index 7c583d4eb..ab76d9cd8 100644 --- a/src/stateMachines/wifiNetworkConfiguration.ts +++ b/src/stateMachines/wifiNetworkConfiguration.ts @@ -12,7 +12,7 @@ import { devices } from '../devices.js' import { Error } from './error.js' import { Configurator } from '../Configurator.js' import { DbCreatorFactory } from '../factories/DbCreatorFactory.js' -import { type CommonContext, invokeWsmanCall } from './common.js' +import { type CommonContext, invokeWsmanCall, recordComponentResult } from './common.js' import { type WifiCredentials } from '../interfaces/ISecretManagerService.js' import { UNEXPECTED_PARSE_ERROR, DEFAULT_MAX_TCP_RETRANSMISSIONS } from '../utils/constants.js' import { @@ -344,6 +344,12 @@ export class WiFiConfiguration { message = statusMessage } device.status.Network = networkStatus ? `${networkStatus}. ${message}` : message + const failed = !!errorMessage || !!profilesFailed + recordComponentResult(clientId, 'WirelessNetwork', { + Result: failed ? 'Failure' : 'Success', + Mode: !failed && message === 'Wireless Only Local Profile Sync Configured' ? 'LocalProfileSync' : undefined, + Details: message + }) }, 'Reset Retry Count': assign({ retryCount: () => 0 }), 'Increment Retry Count': assign({ retryCount: ({ context, event }) => context.retryCount + 1 }), diff --git a/src/stateMachines/wiredNetworkConfiguration.test.ts b/src/stateMachines/wiredNetworkConfiguration.test.ts index 8c8210f3f..473336959 100644 --- a/src/stateMachines/wiredNetworkConfiguration.test.ts +++ b/src/stateMachines/wiredNetworkConfiguration.test.ts @@ -30,6 +30,7 @@ vi.mock('./common.js', async () => { invokeWsmanCall: invokeWsmanCallSpy, invokeEnterpriseAssistantCall: invokeEnterpriseAssistantCallSpy, processTLSTunnelResponse: vi.fn(), + recordComponentResult: actual.recordComponentResult, coalesceMessage, isDigestRealmValid, HttpResponseError @@ -452,6 +453,10 @@ describe('Wired Network Configuration', () => { if (state.matches('SUCCESS') && currentStateIndex === flowStates.length) { const status = devices[clientId].status.Network expect(status).toEqual('Wired Network Configured') + expect(devices[clientId].status.Components?.WiredNetwork).toEqual({ + Result: 'Success', + Details: 'Wired Network Configured' + }) resolve() } } catch (err) { @@ -511,6 +516,10 @@ describe('Wired Network Configuration', () => { const expectedState: any = flowStates[currentStateIndex++] expect(state.matches(expectedState)).toBe(true) if (state.matches('FAILED') && currentStateIndex === flowStates.length) { + expect(devices[clientId].status.Components?.WiredNetwork).toEqual({ + Result: 'Failure', + Details: 'Failed to get 8021x wired profile' + }) resolve() } } catch (err) { diff --git a/src/stateMachines/wiredNetworkConfiguration.ts b/src/stateMachines/wiredNetworkConfiguration.ts index 3921861c5..8c7cd23d3 100644 --- a/src/stateMachines/wiredNetworkConfiguration.ts +++ b/src/stateMachines/wiredNetworkConfiguration.ts @@ -11,7 +11,7 @@ import { devices } from '../devices.js' import { Error } from './error.js' import { Configurator } from '../Configurator.js' import { DbCreatorFactory } from '../factories/DbCreatorFactory.js' -import { type CommonContext, invokeWsmanCall } from './common.js' +import { type CommonContext, invokeWsmanCall, recordComponentResult } from './common.js' import { UNEXPECTED_PARSE_ERROR } from '../utils/constants.js' import { Environment } from '../utils/Environment.js' import { @@ -253,6 +253,13 @@ export class WiredConfiguration { 'Update Configuration Status': ({ context }) => { const device = devices[context.clientId] device.status.Network = context.errorMessage ?? context.statusMessage + recordComponentResult( + context.clientId, + 'WiredNetwork', + context.errorMessage + ? { Result: 'Failure', Details: context.errorMessage } + : { Result: 'Success', Details: context.statusMessage } + ) } } }).createMachine({