From 0a46a3c6cefed03c4b34d32ce1dcf3457982c8a8 Mon Sep 17 00:00:00 2001 From: MadhaviLosetty Date: Tue, 2 Jun 2026 19:02:45 -0700 Subject: [PATCH 1/2] feat(activation): structured per-component provisioning result Break the activation outcome into structured per-component results (Activation, WiredNetwork, WirelessNetwork, TLS, CIRAProxy, CIRAConnection) so operators get granular visibility into what succeeded or failed during the multi-minute provisioning window. Each child state machine now records a {Result, Mode, Details, ErrorCode} entry into a new additive Status.Components object via a shared recordComponentResult helper, alongside the existing flat status strings. The result rides in the same WebSocket message field, so older RPC clients that don't understand Components simply ignore it. Recording never alters control flow: a component failure is captured for visibility but does not change the activation outcome. 802.1x reporting is intentionally deferred to a follow-up. Refs #2665 --- src/models/RCS.Config.ts | 27 ++++++++++++ src/stateMachines/activation.test.ts | 1 + src/stateMachines/activation.ts | 18 +++++++- src/stateMachines/ciraConfiguration.test.ts | 1 + src/stateMachines/ciraConfiguration.ts | 8 +++- src/stateMachines/common.test.ts | 42 ++++++++++++++++++- src/stateMachines/common.ts | 23 ++++++++++ src/stateMachines/proxyConfiguration.ts | 8 +++- src/stateMachines/tls.test.ts | 3 +- src/stateMachines/tls.ts | 15 ++++++- .../wifiNetworkConfiguration.test.ts | 1 + src/stateMachines/wifiNetworkConfiguration.ts | 8 +++- .../wiredNetworkConfiguration.test.ts | 1 + .../wiredNetworkConfiguration.ts | 9 +++- 14 files changed, 156 insertions(+), 9 deletions(-) diff --git a/src/models/RCS.Config.ts b/src/models/RCS.Config.ts index 242b8c2b8..d38442e07 100644 --- a/src/models/RCS.Config.ts +++ b/src/models/RCS.Config.ts @@ -88,11 +88,38 @@ 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' + +export interface ComponentResult { + Result: ComponentResultStatus + /** Optional mode/detail e.g. activation control mode, or a human-readable note. */ + Mode?: string + Details?: string + ErrorCode: number +} + +export interface ComponentResults { + Activation?: ComponentResult + WiredNetwork?: ComponentResult + WirelessNetwork?: ComponentResult + TLS?: ComponentResult + CIRAProxy?: ComponentResult + CIRAConnection?: ComponentResult +} + 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; Components carries the granular breakdown. + Components?: ComponentResults } export interface ClientObject { ClientId: string diff --git a/src/stateMachines/activation.test.ts b/src/stateMachines/activation.test.ts index bc9791690..7eb7fb131 100644 --- a/src/stateMachines/activation.test.ts +++ b/src/stateMachines/activation.test.ts @@ -36,6 +36,7 @@ vi.mock('./common.js', async () => { invokeWsmanCall: invokeWsmanCallSpy, invokeEnterpriseAssistantCall: vi.fn(), processTLSTunnelResponse: vi.fn(), + recordComponentResult: vi.fn(), HttpResponseError, isDigestRealmValid, coalesceMessage diff --git a/src/stateMachines/activation.ts b/src/stateMachines/activation.ts index e6c4259dd..1f0472dbb 100644 --- a/src/stateMachines/activation.ts +++ b/src/stateMachines/activation.ts @@ -25,7 +25,7 @@ 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 } from './common.js' import ClientResponseMsg from '../utils/ClientResponseMsg.js' import { Unconfiguration } from './unconfiguration.js' import { type DeviceCredentials } from '../interfaces/ISecretManagerService.js' @@ -139,6 +139,16 @@ 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', + ErrorCode: 1, + Details: clientObj.status.Status + }) + } } const responseMessage = ClientResponseMsg.get(clientId, null, status, method, JSON.stringify(clientObj.status)) this.logger.info(JSON.stringify(responseMessage, null, '\t')) @@ -426,6 +436,12 @@ 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', + ErrorCode: 0, + Mode: 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..bac294a47 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: vi.fn(), HttpResponseError, isDigestRealmValid, coalesceMessage diff --git a/src/stateMachines/ciraConfiguration.ts b/src/stateMachines/ciraConfiguration.ts index 7e01031ee..d0a8172fc 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,12 @@ 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', + ErrorCode: success ? 0 : 1, + 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..7659caa64 100644 --- a/src/stateMachines/common.test.ts +++ b/src/stateMachines/common.test.ts @@ -18,7 +18,8 @@ import { invokeEnterpriseAssistantCall, invokeEnterpriseAssistantCallInternal, invokeWsmanCall, - coalesceMessage + coalesceMessage, + recordComponentResult } from './common.js' Environment.Config = config @@ -195,4 +196,43 @@ 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', + ErrorCode: 0, + Mode: 'Admin control mode.' + }) + expect(devices[clientId].status.Components?.Activation).toEqual({ + Result: 'Success', + ErrorCode: 0, + Mode: 'Admin control mode.' + }) + }) + + it('should preserve previously recorded components when adding another', () => { + devices[clientId].status = { Components: { Activation: { Result: 'Success', ErrorCode: 0 } } } + recordComponentResult(clientId, 'WirelessNetwork', { + Result: 'Failure', + ErrorCode: 1, + Details: 'Failed to add 1' + }) + expect(devices[clientId].status.Components?.Activation).toEqual({ Result: 'Success', ErrorCode: 0 }) + expect(devices[clientId].status.Components?.WirelessNetwork).toEqual({ + Result: 'Failure', + ErrorCode: 1, + Details: 'Failed to add 1' + }) + }) + + it('should no-op when the device has no status object', () => { + delete (devices[clientId] as any).status + expect(() => { + recordComponentResult(clientId, 'TLS', { Result: 'Success', ErrorCode: 0 }) + }).not.toThrow() + expect(devices[clientId].status).toBeUndefined() + }) + }) }) diff --git a/src/stateMachines/common.ts b/src/stateMachines/common.ts index 4c9a7caf9..15992ca40 100644 --- a/src/stateMachines/common.ts +++ b/src/stateMachines/common.ts @@ -20,6 +20,7 @@ import { } from '../utils/constants.js' import Logger from '../Logger.js' import { type HttpHandler } from '../HttpHandler.js' +import { type ComponentResult, type ComponentResults } 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 +420,28 @@ export function coalesceMessage(prefixMsg: string, err: any): string { return msg } +/** + * 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. + */ +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 = {} + } + clientObj.status.Components[component] = result +} + const isDigestRealmValid = (realm: string): boolean => { const regex = /[0-9A-Fa-f]{32}/g let isValidRealm = false diff --git a/src/stateMachines/proxyConfiguration.ts b/src/stateMachines/proxyConfiguration.ts index 45fbc1ddf..4328a3a0b 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,12 @@ export class ProxyConfiguration { message = statusMessage } device.status.Network = networkStatus ? `${networkStatus}. ${message}` : message + const failed = !!errorMessage || !!proxyConfigsFailed + recordComponentResult(clientId, 'CIRAProxy', { + Result: failed ? 'Failure' : 'Success', + ErrorCode: failed ? 1 : 0, + 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..5f4bf4b28 100644 --- a/src/stateMachines/tls.test.ts +++ b/src/stateMachines/tls.test.ts @@ -19,7 +19,8 @@ const invokeWsmanCallSpy = vi.hoisted(() => vi.fn()) const invokeEnterpriseAssistantCallSpy = vi.hoisted(() => vi.fn()) vi.mock('./common.js', () => ({ invokeWsmanCall: invokeWsmanCallSpy, - invokeEnterpriseAssistantCall: invokeEnterpriseAssistantCallSpy + invokeEnterpriseAssistantCall: invokeEnterpriseAssistantCallSpy, + recordComponentResult: vi.fn() })) const { TLS } = await import('./tls.js') diff --git a/src/stateMachines/tls.ts b/src/stateMachines/tls.ts index e6a84fc2d..da80ed16a 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,19 @@ 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', + ErrorCode: 0, + 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', + ErrorCode: 1, + Details: details + }) } } diff --git a/src/stateMachines/wifiNetworkConfiguration.test.ts b/src/stateMachines/wifiNetworkConfiguration.test.ts index 095d91e87..0c2ebc6fc 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: vi.fn(), HttpResponseError, isDigestRealmValid, coalesceMessage diff --git a/src/stateMachines/wifiNetworkConfiguration.ts b/src/stateMachines/wifiNetworkConfiguration.ts index 7c583d4eb..2e7b132a7 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', + ErrorCode: failed ? 1 : 0, + 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..aa98a0f36 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: vi.fn(), coalesceMessage, isDigestRealmValid, HttpResponseError diff --git a/src/stateMachines/wiredNetworkConfiguration.ts b/src/stateMachines/wiredNetworkConfiguration.ts index 3921861c5..ab08ffc19 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', ErrorCode: 1, Details: context.errorMessage } + : { Result: 'Success', ErrorCode: 0, Details: context.statusMessage } + ) } } }).createMachine({ From b113cc99fddc64dd406bab8f6a67e8d4bf579875 Mon Sep 17 00:00:00 2001 From: MadhaviLosetty Date: Tue, 2 Jun 2026 20:08:40 -0700 Subject: [PATCH 2/2] test(activation): assert structured per-component results Each child state machine test now drives both success and failure outcomes and asserts the recorded component result (Activation, WiredNetwork, WirelessNetwork, TLS, CIRAProxy, CIRAConnection) has the expected Result/Details. Refs #2665 --- package-lock.json | 52 +++----- src/models/RCS.Config.ts | 19 ++- src/stateMachines/activation.test.ts | 17 ++- src/stateMachines/activation.ts | 14 ++- src/stateMachines/ciraConfiguration.test.ts | 7 +- src/stateMachines/ciraConfiguration.ts | 1 - src/stateMachines/common.test.ts | 108 ++++++++++++++-- src/stateMachines/common.ts | 118 +++++++++++++++++- src/stateMachines/proxyConfiguration.test.ts | 15 +++ src/stateMachines/proxyConfiguration.ts | 1 - src/stateMachines/tls.test.ts | 21 +++- src/stateMachines/tls.ts | 2 - .../wifiNetworkConfiguration.test.ts | 16 ++- src/stateMachines/wifiNetworkConfiguration.ts | 2 +- .../wiredNetworkConfiguration.test.ts | 10 +- .../wiredNetworkConfiguration.ts | 4 +- 16 files changed, 339 insertions(+), 68 deletions(-) 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 d38442e07..c0050f98f 100644 --- a/src/models/RCS.Config.ts +++ b/src/models/RCS.Config.ts @@ -95,12 +95,14 @@ export interface RemoteConfig { */ 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 - /** Optional mode/detail e.g. activation control mode, or a human-readable note. */ - Mode?: string + Mode?: ComponentMode + /** Human-readable, sentence-case note (no trailing period). On failure this carries the reason. */ Details?: string - ErrorCode: number } export interface ComponentResults { @@ -112,13 +114,22 @@ export interface ComponentResults { 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; Components carries the granular breakdown. + // 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 { diff --git a/src/stateMachines/activation.test.ts b/src/stateMachines/activation.test.ts index 7eb7fb131..f0456c358 100644 --- a/src/stateMachines/activation.test.ts +++ b/src/stateMachines/activation.test.ts @@ -36,7 +36,9 @@ vi.mock('./common.js', async () => { invokeWsmanCall: invokeWsmanCallSpy, invokeEnterpriseAssistantCall: vi.fn(), processTLSTunnelResponse: vi.fn(), - recordComponentResult: vi.fn(), + recordComponentResult: actual.recordComponentResult, + deriveControlMode: actual.deriveControlMode, + finalizeComponentResults: actual.finalizeComponentResults, HttpResponseError, isDigestRealmValid, coalesceMessage @@ -829,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' + }) }) }) @@ -2082,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 }) @@ -2093,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, @@ -2101,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 1f0472dbb..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, recordComponentResult } 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' @@ -145,11 +152,11 @@ export class Activation { if (clientObj.activationStatus !== true) { recordComponentResult(clientId, 'Activation', { Result: 'Failure', - ErrorCode: 1, 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)) @@ -438,8 +445,7 @@ export class Activation { clientObj.activationStatus = true recordComponentResult(context.clientId, 'Activation', { Result: 'Success', - ErrorCode: 0, - Mode: clientObj.status.Status, + Mode: deriveControlMode(clientObj.status.Status), Details: clientObj.status.Status }) MqttProvider.publishEvent( diff --git a/src/stateMachines/ciraConfiguration.test.ts b/src/stateMachines/ciraConfiguration.test.ts index bac294a47..e878e7517 100644 --- a/src/stateMachines/ciraConfiguration.test.ts +++ b/src/stateMachines/ciraConfiguration.test.ts @@ -29,7 +29,7 @@ vi.mock('./common.js', async () => { invokeWsmanCall: invokeWsmanCallSpy, invokeEnterpriseAssistantCall: vi.fn(), processTLSTunnelResponse: vi.fn(), - recordComponentResult: vi.fn(), + recordComponentResult: actual.recordComponentResult, HttpResponseError, isDigestRealmValid, coalesceMessage @@ -184,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() } } @@ -213,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 d0a8172fc..1dffcd233 100644 --- a/src/stateMachines/ciraConfiguration.ts +++ b/src/stateMachines/ciraConfiguration.ts @@ -318,7 +318,6 @@ export class CIRAConfiguration { const success = context.statusMessage === 'Configured' recordComponentResult(context.clientId, 'CIRAConnection', { Result: success ? 'Success' : 'Failure', - ErrorCode: success ? 0 : 1, Details: context.statusMessage }) }, diff --git a/src/stateMachines/common.test.ts b/src/stateMachines/common.test.ts index 7659caa64..817b9ed2a 100644 --- a/src/stateMachines/common.test.ts +++ b/src/stateMachines/common.test.ts @@ -19,8 +19,13 @@ import { invokeEnterpriseAssistantCallInternal, invokeWsmanCall, coalesceMessage, - recordComponentResult + recordComponentResult, + normalizeDetails, + deriveControlMode, + rollUpResult, + finalizeComponentResults } from './common.js' +import { COMPONENTS_VERSION } from '../models/RCS.Config.js' Environment.Config = config describe('Common', () => { @@ -202,37 +207,120 @@ describe('Common', () => { devices[clientId].status = { Status: 'Admin control mode.' } recordComponentResult(clientId, 'Activation', { Result: 'Success', - ErrorCode: 0, - Mode: 'Admin control mode.' + Mode: 'ACM' }) expect(devices[clientId].status.Components?.Activation).toEqual({ Result: 'Success', - ErrorCode: 0, - Mode: 'Admin control mode.' + Mode: 'ACM' }) }) it('should preserve previously recorded components when adding another', () => { - devices[clientId].status = { Components: { Activation: { Result: 'Success', ErrorCode: 0 } } } + devices[clientId].status = { Components: { Activation: { Result: 'Success' } } } recordComponentResult(clientId, 'WirelessNetwork', { Result: 'Failure', - ErrorCode: 1, Details: 'Failed to add 1' }) - expect(devices[clientId].status.Components?.Activation).toEqual({ Result: 'Success', ErrorCode: 0 }) + expect(devices[clientId].status.Components?.Activation).toEqual({ Result: 'Success' }) expect(devices[clientId].status.Components?.WirelessNetwork).toEqual({ Result: 'Failure', - ErrorCode: 1, 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', ErrorCode: 0 }) + 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 15992ca40..522858754 100644 --- a/src/stateMachines/common.ts +++ b/src/stateMachines/common.ts @@ -20,7 +20,12 @@ import { } from '../utils/constants.js' import Logger from '../Logger.js' import { type HttpHandler } from '../HttpHandler.js' -import { type ComponentResult, type ComponentResults } from '../models/RCS.Config.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' @@ -420,12 +425,65 @@ 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, @@ -439,7 +497,63 @@ export function recordComponentResult( if (clientObj.status.Components == null) { clientObj.status.Components = {} } - clientObj.status.Components[component] = result + 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 => { 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 4328a3a0b..dd4da2ea5 100644 --- a/src/stateMachines/proxyConfiguration.ts +++ b/src/stateMachines/proxyConfiguration.ts @@ -118,7 +118,6 @@ export class ProxyConfiguration { const failed = !!errorMessage || !!proxyConfigsFailed recordComponentResult(clientId, 'CIRAProxy', { Result: failed ? 'Failure' : 'Success', - ErrorCode: failed ? 1 : 0, Details: message }) }, diff --git a/src/stateMachines/tls.test.ts b/src/stateMachines/tls.test.ts index 5f4bf4b28..cdcb74cb2 100644 --- a/src/stateMachines/tls.test.ts +++ b/src/stateMachines/tls.test.ts @@ -17,11 +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, - recordComponentResult: vi.fn() -})) +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') @@ -398,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 () => { @@ -405,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 da80ed16a..53a7a6c56 100644 --- a/src/stateMachines/tls.ts +++ b/src/stateMachines/tls.ts @@ -235,7 +235,6 @@ export class TLS { devices[context.clientId].status.TLSConfiguration = context.statusMessage recordComponentResult(context.clientId, 'TLS', { Result: 'Success', - ErrorCode: 0, Details: context.statusMessage }) } else if (context.status === 'error') { @@ -243,7 +242,6 @@ export class TLS { devices[context.clientId].status.TLSConfiguration = details recordComponentResult(context.clientId, 'TLS', { Result: 'Failure', - ErrorCode: 1, Details: details }) } diff --git a/src/stateMachines/wifiNetworkConfiguration.test.ts b/src/stateMachines/wifiNetworkConfiguration.test.ts index 0c2ebc6fc..08455f123 100644 --- a/src/stateMachines/wifiNetworkConfiguration.test.ts +++ b/src/stateMachines/wifiNetworkConfiguration.test.ts @@ -29,7 +29,7 @@ vi.mock('./common.js', async () => { invokeWsmanCall: invokeWsmanCallSpy, invokeEnterpriseAssistantCall: invokeEnterpriseAssistantCallSpy, processTLSTunnelResponse: vi.fn(), - recordComponentResult: vi.fn(), + recordComponentResult: actual.recordComponentResult, HttpResponseError, isDigestRealmValid, coalesceMessage @@ -274,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) { @@ -882,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) { @@ -929,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) { @@ -1174,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 2e7b132a7..ab76d9cd8 100644 --- a/src/stateMachines/wifiNetworkConfiguration.ts +++ b/src/stateMachines/wifiNetworkConfiguration.ts @@ -347,7 +347,7 @@ export class WiFiConfiguration { const failed = !!errorMessage || !!profilesFailed recordComponentResult(clientId, 'WirelessNetwork', { Result: failed ? 'Failure' : 'Success', - ErrorCode: failed ? 1 : 0, + Mode: !failed && message === 'Wireless Only Local Profile Sync Configured' ? 'LocalProfileSync' : undefined, Details: message }) }, diff --git a/src/stateMachines/wiredNetworkConfiguration.test.ts b/src/stateMachines/wiredNetworkConfiguration.test.ts index aa98a0f36..473336959 100644 --- a/src/stateMachines/wiredNetworkConfiguration.test.ts +++ b/src/stateMachines/wiredNetworkConfiguration.test.ts @@ -30,7 +30,7 @@ vi.mock('./common.js', async () => { invokeWsmanCall: invokeWsmanCallSpy, invokeEnterpriseAssistantCall: invokeEnterpriseAssistantCallSpy, processTLSTunnelResponse: vi.fn(), - recordComponentResult: vi.fn(), + recordComponentResult: actual.recordComponentResult, coalesceMessage, isDigestRealmValid, HttpResponseError @@ -453,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) { @@ -512,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 ab08ffc19..8c7cd23d3 100644 --- a/src/stateMachines/wiredNetworkConfiguration.ts +++ b/src/stateMachines/wiredNetworkConfiguration.ts @@ -257,8 +257,8 @@ export class WiredConfiguration { context.clientId, 'WiredNetwork', context.errorMessage - ? { Result: 'Failure', ErrorCode: 1, Details: context.errorMessage } - : { Result: 'Success', ErrorCode: 0, Details: context.statusMessage } + ? { Result: 'Failure', Details: context.errorMessage } + : { Result: 'Success', Details: context.statusMessage } ) } }