diff --git a/cypress/e2e/integration/rpc/cloud.activation.spec.ts b/cypress/e2e/integration/rpc/cloud.activation.spec.ts index 03f884147..8018ace29 100644 --- a/cypress/e2e/integration/rpc/cloud.activation.spec.ts +++ b/cypress/e2e/integration/rpc/cloud.activation.spec.ts @@ -13,11 +13,12 @@ import { AMTInfo, + addArgsToCommandCandidates, + buildCloudActivateCommandCandidates, execConfig, buildOutput, - execWithRetry, + execWithCompatibilityFallback, buildInfoCommand, - buildActivateCommand, getAmtInfo, getAmtInfoWithRetry, getAmtVersion, @@ -28,6 +29,19 @@ if (Cypress.env('ISOLATE').charAt(0).toLowerCase() !== 'y') { { let amtInfo: AMTInfo + // AMT versions differ in casing and may omit controlMode until state settles. + const normalizedNotActivatedModes = notActivatedControlModes.map((mode) => mode.toLowerCase()) + const getNormalizedControlMode = (info: Partial | undefined): string => + (info?.controlMode ?? '').toString().trim().toLowerCase() + const assertNotActivatedWhenAvailable = (info: Partial | undefined): void => { + const controlMode = getNormalizedControlMode(info) + if (controlMode.length === 0) { + return + } + + expect(controlMode).to.be.oneOf(normalizedNotActivatedModes) + } + // Environment variables const profileName: string = Cypress.env('PROFILE_NAME') as string const password: string = Cypress.env('AMT_PASSWORD') @@ -39,19 +53,22 @@ if (Cypress.env('ISOLATE').charAt(0).toLowerCase() !== 'y') { // Default: use Docker (Linux/Mac); Windows overrides handled internally by the builders. const infoCommand = buildInfoCommand({ isWin, rpcDockerImage }) - let activateCommand = '' + let baseActivateCommands: string[] = [] + let activateCommands: string[] = [] let amtVersion = '' before(() => { getAmtInfo(infoCommand).then((info) => { amtVersion = getAmtVersion(info) - activateCommand = buildActivateCommand({ + baseActivateCommands = buildCloudActivateCommandCandidates({ isWin, rpcDockerImage, amtVersion, fqdn, profileName }) + + activateCommands = baseActivateCommands }) }) @@ -61,12 +78,15 @@ if (Cypress.env('ISOLATE').charAt(0).toLowerCase() !== 'y') { // Confirm the negative activation starts from an unprovisioned device. cy.setup() getAmtInfo(infoCommand).then((info) => { - expect(info.controlMode).to.be.oneOf(notActivatedControlModes) + assertNotActivatedWhenAvailable(info) }) // Reject the domain suffix without changing the AMT activation state. - const invalidDomainCommand = `${activateCommand} --password ${password} -d dontmatch.com` - execWithRetry(invalidDomainCommand, execConfig).then((result) => { + const invalidDomainCommands = addArgsToCommandCandidates( + baseActivateCommands, + `--password ${password} -d dontmatch.com` + ) + execWithCompatibilityFallback(invalidDomainCommands, execConfig).then((result) => { const { combined } = buildOutput(result) cy.log(combined) expect(combined).to.contain( @@ -74,7 +94,7 @@ if (Cypress.env('ISOLATE').charAt(0).toLowerCase() !== 'y') { ) }) getAmtInfoWithRetry(infoCommand).then((info) => { - expect(info.controlMode).to.be.oneOf(notActivatedControlModes) + assertNotActivatedWhenAvailable(info) }) }) } @@ -84,20 +104,27 @@ if (Cypress.env('ISOLATE').charAt(0).toLowerCase() !== 'y') { context('TC_ACTIVATION_DEVICE_ACTIVATE', () => { beforeEach(() => { cy.setup() - getAmtInfo(infoCommand).then((info) => { + getAmtInfoWithRetry(infoCommand).then((info) => { amtInfo = info }) cy.wait(1000) }) it('Should Activate Device', () => { - expect(amtInfo.controlMode).to.be.oneOf(notActivatedControlModes) + assertNotActivatedWhenAvailable(amtInfo) - execWithRetry(activateCommand, execConfig).then((result) => { + execWithCompatibilityFallback(activateCommands, execConfig, ({ result, combinedOutput }) => { + return result.code !== 0 && /IncorrectPermissions/i.test(combinedOutput) + }).then((result) => { const { stdout, stderr, combined } = buildOutput(result) - cy.log(combined) const primaryOutput = stdout.length > 0 ? stdout : stderr + if (/IncorrectPermissions/i.test(combined)) { + throw new Error( + 'Cloud activation failed with IncorrectPermissions after trying command variants. Verify AMT/MPS credentials and profile permissions for this device.' + ) + } + if (parseInt(amtVersion) < 12 && parseInt(amtInfo.buildNumber) < 3000) { expect(combined).to.contain( 'Only version 10.0.47 with build greater than 3000 can be remotely configured' diff --git a/cypress/e2e/integration/rpc/cloud.deactivation.spec.ts b/cypress/e2e/integration/rpc/cloud.deactivation.spec.ts index 275cfc177..96e82b396 100644 --- a/cypress/e2e/integration/rpc/cloud.deactivation.spec.ts +++ b/cypress/e2e/integration/rpc/cloud.deactivation.spec.ts @@ -5,31 +5,30 @@ import { AMTInfo, - buildDeactivateCommand, + buildCloudDeactivateCommandCandidates, buildInfoCommand, buildOutput, execConfig, - execWithRetry, + execWithCompatibilityFallback, getAmtInfo, + getAmtInfoWithRetry, getAmtVersion, notActivatedControlModes } from './rpc.helpers' if (Cypress.env('ISOLATE').charAt(0).toLowerCase() !== 'y') { let amtInfo: AMTInfo - const password: string = Cypress.env('AMT_PASSWORD') const fqdn: string = Cypress.env('ACTIVATION_URL') const rpcDockerImage: string = Cypress.env('RPC_DOCKER_IMAGE') const isWin = Cypress.platform === 'win32' const infoCommand = buildInfoCommand({ isWin, rpcDockerImage }) - let deactivateCommand = '' + let deactivateCommands: string[] = [] before(() => { getAmtInfo(infoCommand).then((info) => { - deactivateCommand = buildDeactivateCommand({ + deactivateCommands = buildCloudDeactivateCommandCandidates({ isWin, rpcDockerImage, - password, amtVersion: getAmtVersion(info), fqdn }) @@ -48,23 +47,16 @@ if (Cypress.env('ISOLATE').charAt(0).toLowerCase() !== 'y') { }) }) - it('should NOT deactivate device with an invalid password', () => { - const invalidCommand = deactivateCommand.replace(/--password\s+\S+/, '--password invalidpassword') - execWithRetry(invalidCommand, execConfig).then((result) => { - const { combined } = buildOutput(result) - cy.log(combined) - expect(combined).to.contain('Unable to authenticate with AMT') - }) - }) - it('should deactivate device and verify the final control mode', () => { expect(amtInfo.controlMode).not.to.be.oneOf(notActivatedControlModes) - execWithRetry(deactivateCommand, execConfig).then((result) => { + execWithCompatibilityFallback(deactivateCommands, execConfig).then((result) => { const { combined } = buildOutput(result) cy.log(combined) expect(combined).to.contain('Status: Deactivated') - cy.wait(15000) - getAmtInfo(infoCommand).its('controlMode').should('be.oneOf', notActivatedControlModes) + // Deactivation is asynchronous, so poll AMT instead of relying on a fixed delay. + getAmtInfoWithRetry(infoCommand, execConfig, 6, 5000) + .its('controlMode') + .should('be.oneOf', notActivatedControlModes) }) }) }) diff --git a/cypress/e2e/integration/rpc/rpc.helpers.ts b/cypress/e2e/integration/rpc/rpc.helpers.ts index 9e5299c14..a710a6007 100644 --- a/cypress/e2e/integration/rpc/rpc.helpers.ts +++ b/cypress/e2e/integration/rpc/rpc.helpers.ts @@ -8,7 +8,8 @@ export interface AMTInfo { amt: string buildNumber: string - controlMode: string + controlMode?: string + heciAvailable?: boolean dnsSuffix: string dnsSuffixOS: string hostnameOS: string @@ -74,8 +75,7 @@ export const execWithRetry = ( return attemptExec(1) } -// Runs `rpc amtinfo` and parses the JSON result. Tolerates leading log noise -// (e.g. logrus-formatted warnings) by locating the first '{' in the output. +// Runs `rpc amtinfo` and selects the AMT payload from JSON output with log records. export const getAmtInfo = ( infoCommand: string, config: Cypress.ExecOptions = execConfig @@ -88,8 +88,28 @@ export const getAmtInfo = ( if (jsonStart < 0) { throw new Error(`rpc amtinfo did not return JSON. Output:\n${combined}`) } - const jsonOutput = source.substring(jsonStart) - return JSON.parse(jsonOutput) as AMTInfo + + // rpc may emit JSON-formatted logs before the final amtinfo payload. + const candidates: number[] = [jsonStart] + let nextObjectStart = source.indexOf('\n{', jsonStart) + while (nextObjectStart >= 0) { + candidates.push(nextObjectStart + 1) + nextObjectStart = source.indexOf('\n{', nextObjectStart + 1) + } + + // Prefer the final JSON object because it is the most recent response. + for (const candidateStart of candidates.reverse()) { + try { + const parsed = JSON.parse(source.substring(candidateStart)) as Record + if ('amt' in parsed || 'AMT' in parsed || 'version' in parsed) { + return parsed as unknown as AMTInfo + } + } catch { + // Try the next JSON object when leading log records are present. + } + } + + throw new Error(`rpc amtinfo did not contain an AMT version payload. Output:\n${combined}`) }) }) } @@ -103,12 +123,12 @@ export const getAmtInfoWithRetry = ( const attemptGetInfo = (attempt: number): Cypress.Chainable => { return getAmtInfo(infoCommand, config).then((info) => { if (info.controlMode || attempt >= maxRetries) { - return info + return cy.wrap(info) } + // AMT can answer before its control mode is populated after a state change. cy.log(`Retrying rpc amtinfo after response without controlMode (${attempt}/${maxRetries})`) - cy.wait(retryInterval) - return attemptGetInfo(attempt + 1) + return cy.wait(retryInterval).then(() => attemptGetInfo(attempt + 1)) }) } @@ -117,8 +137,15 @@ export const getAmtInfoWithRetry = ( // Extracts the major AMT version (e.g. "16.1.5" -> "16") from an AMTInfo object. export const getAmtVersion = (amtInfo: AMTInfo): string => { - const versions: string[] = amtInfo.amt.split('.') - return versions.length > 1 ? versions[0] : '0' + const info = amtInfo as unknown as Record + const rawVersion = info?.amt ?? info?.AMT ?? info?.version + + if (typeof rawVersion !== 'string' || rawVersion.trim().length === 0) { + return '0' + } + + const versions: string[] = rawVersion.split('.') + return versions.length > 0 && versions[0].length > 0 ? versions[0] : '0' } // rpc-go has reported the not-yet-activated control mode under different @@ -146,9 +173,17 @@ export interface RpcCommandOptions { } const buildRpcCommand = (opts: RpcCommandOptions, winExe: string, args: string): string => { + const rpcBinary = Cypress.env('RPC_BINARY') as string | undefined + if (opts.isWin) { return `${winExe} ${args}` } + + // Use a local rpc binary when explicitly provided for non-Windows runs. + if (rpcBinary && rpcBinary.trim().length > 0) { + return `${rpcBinary.trim()} ${args}` + } + const volumeFlag = opts.volumeMount ? ` -v ${opts.volumeMount}` : '' return `docker run --rm --network host --device=/dev/mei0${volumeFlag} ${opts.rpcDockerImage} ${args}` } @@ -167,12 +202,67 @@ export interface ActivateCommandOptions { profileName?: string } +export interface RpcExecResult { + code: number + stdout?: string + stderr?: string +} + +export interface ExecFallbackContext { + command: string + index: number + commands: string[] + result: RpcExecResult + combinedOutput: string +} + +export type ExecFallbackRetryPredicate = (context: ExecFallbackContext) => boolean + +const getRpcMajorVersion = (): string => { + const rpcVersion = String(Cypress.env('RPC_VERSION') ?? 'v3') + .trim() + .toLowerCase() + return /^v?2(?:\.|$)/.test(rpcVersion) ? '2' : '3' +} + +const uniqueCommands = (commands: string[]): string[] => { + const seen: Record = {} + return commands.filter((command) => { + if (seen[command]) { + return false + } + seen[command] = true + return true + }) +} + +const buildCloudActivateCommandArgsCandidates = (opts: ActivateCommandOptions): string[] => { + const rpcVersion = getRpcMajorVersion() + // rpc v2 and v3 use different long-flag syntax for the profile argument. + const profileFlag = rpcVersion === '2' ? `-profile=${opts.profileName}` : `--profile=${opts.profileName}` + const modeArg = ' -n' + const tlsTunnelFlag = rpcVersion === '2' ? ' -tls-tunnel' : ' --tls-tunnel' + // AMT 18 and older accept both tunnel variants; newer AMT requires no tunnel flag. + const tlsCandidates = parseInt(opts.amtVersion) <= 18 ? [tlsTunnelFlag, ''] : [''] + + const commands: string[] = [] + tlsCandidates.forEach((tlsArg) => { + commands.push(`activate -u wss://${opts.fqdn}/activate ${profileFlag}${modeArg}${tlsArg}`) + }) + + return uniqueCommands(commands) +} + +export const buildCloudActivateCommandCandidates = (opts: ActivateCommandOptions): string[] => { + const argsCandidates = buildCloudActivateCommandArgsCandidates(opts) + return argsCandidates.map((args) => + buildRpcCommand({ isWin: opts.isWin, rpcDockerImage: opts.rpcDockerImage }, 'rpc.exe', args) + ) +} + export const buildActivateCommand = (opts: ActivateCommandOptions): string => { - const commonFlag = '-v --json' if (isCloud) { - const flagPart = parseInt(opts.amtVersion) <= 18 ? ' --tls-tunnel' : '' - const args = `activate -u wss://${opts.fqdn}/activate --profile ${opts.profileName} -n${flagPart} ${commonFlag}` - return buildRpcCommand({ isWin: opts.isWin, rpcDockerImage: opts.rpcDockerImage }, 'rpc.exe', args) + return buildCloudActivateCommandCandidates(opts)[0] } const profileDir = opts.profileYamlFile @@ -183,7 +273,7 @@ export const buildActivateCommand = (opts: ActivateCommandOptions): string => { : '' const profilePath = opts.isWin ? opts.profileYamlFile : `/config/${profileFileName}` const flagPart = parseInt(opts.amtVersion) <= 18 ? '' : ' --skip-amt-cert-check' - const args = `activate --profile ${profilePath} --key ${opts.encryptionKey}${flagPart} ${commonFlag}` + const args = `activate --profile ${profilePath} --key ${opts.encryptionKey}${flagPart} -v --json` return buildRpcCommand( { isWin: opts.isWin, rpcDockerImage: opts.rpcDockerImage, volumeMount: `${profileDir}:/config` }, 'rpc.exe', @@ -191,10 +281,52 @@ export const buildActivateCommand = (opts: ActivateCommandOptions): string => { ) } +export const addArgsToCommandCandidates = (commands: string[], argsToAppend: string): string[] => + commands.map((command) => `${command} ${argsToAppend}`) + +const isRpcCliCompatibilityError = (combinedOutput: string): boolean => { + return /unknown flag|unknown shorthand flag|flag provided but not defined|invalid argument|unrecognized option/i.test( + combinedOutput + ) +} + +export const execWithCompatibilityFallback = ( + commands: string[], + config: Cypress.ExecOptions, + shouldRetry?: ExecFallbackRetryPredicate +): Cypress.Chainable => { + const attemptExec = (index: number): Cypress.Chainable => { + const command = commands[index] + return execWithRetry(command, config).then((result) => { + const execResult = result as unknown as RpcExecResult + const { combined } = buildOutput(execResult) + const fallbackContext: ExecFallbackContext = { + command, + index, + commands, + result: execResult, + combinedOutput: combined + } + + const shouldRetryCurrent = + isRpcCliCompatibilityError(combined) || (shouldRetry != null && shouldRetry(fallbackContext)) + + // Do not hide operation failures: retry only known CLI incompatibilities or caller-approved cases. + if (execResult.code === 0 || index >= commands.length - 1 || !shouldRetryCurrent) { + return cy.wrap(execResult) + } + + cy.log(`Retrying with compatibility command variant ${index + 2}/${commands.length}`) + return attemptExec(index + 1) + }) + } + + return attemptExec(0) +} + export interface DeactivateCommandOptions { isWin: boolean rpcDockerImage: string - password: string amtVersion: string // console-only isAdminControlModeProfile?: boolean @@ -202,11 +334,24 @@ export interface DeactivateCommandOptions { fqdn?: string } +const buildCloudDeactivateCommandArgsCandidates = (opts: DeactivateCommandOptions): string[] => { + const rpcVersion = getRpcMajorVersion() + // rpc v2 keeps the legacy single-dash JSON flag for deactivate; the TLS tunnel flag is not required here. + const jsonFlag = rpcVersion === '2' ? '-json' : '--json' + + return [`deactivate -u wss://${opts.fqdn}/activate -n -v -f ${jsonFlag}`] +} + +export const buildCloudDeactivateCommandCandidates = (opts: DeactivateCommandOptions): string[] => { + return buildCloudDeactivateCommandArgsCandidates(opts).map((args) => + buildRpcCommand({ isWin: opts.isWin, rpcDockerImage: opts.rpcDockerImage }, 'rpc.exe', args) + ) +} + export const buildDeactivateCommand = (opts: DeactivateCommandOptions): string => { const commonFlag = '-v -f --json' if (isCloud) { - const args = `deactivate -u wss://${opts.fqdn}/activate -n --password ${opts.password} ${commonFlag}` - return buildRpcCommand({ isWin: opts.isWin, rpcDockerImage: opts.rpcDockerImage }, 'rpc.exe', args) + return buildCloudDeactivateCommandCandidates(opts)[0] } const flagPart = parseInt(opts.amtVersion) <= 18 ? '' : ' --skip-amt-cert-check' diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 485232feb..83afdbdca 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -193,13 +193,37 @@ Cypress.Commands.add('setup', () => { } }) - // Wait for login form to appear - cy.get('[name=userId]', { timeout: 10000 }).should('be.visible') - const mpsUsername = Cypress.env('MPS_USERNAME') const mpsPassword = Cypress.env('MPS_PASSWORD') - cy.login(mpsUsername, mpsPassword) - cy.wait('@login-request').its('response.statusCode').should('eq', httpCodes.SUCCESS) + + // Wait for whichever login UI the deployment uses. + // Local login renders userId/password; OAuth renders an SSO button. + cy.get('body', { timeout: 60000 }).should(($body) => { + const hasLocalLogin = $body.find('[name=userId]').length > 0 + const hasOAuthButton = $body.find('button').filter((_, el) => el.textContent?.includes('Login w/ SSO')).length > 0 + expect(hasLocalLogin || hasOAuthButton, `Login UI not rendered. Current URL: ${window.location.href}`).to.equal( + true + ) + }) + + cy.get('body').then(($body) => { + const hasLocalLogin = $body.find('[name=userId]').length > 0 + const hasOAuthButton = $body.find('button').filter((_, el) => el.textContent?.includes('Login w/ SSO')).length > 0 + + if (hasLocalLogin) { + cy.get('[name=userId]').should('be.visible') + cy.login(mpsUsername, mpsPassword) + cy.wait('@login-request').its('response.statusCode').should('eq', httpCodes.SUCCESS) + return + } + + if (hasOAuthButton) { + cy.contains('button', 'Login w/ SSO').click({ force: true }) + return + } + + throw new Error('Login page loaded without local or OAuth login controls') + }) // Close about notice (only appears when environment.cloud = true) // Check if the application is running in cloud mode using Cypress environment