diff --git a/cypress/e2e/fixtures/api/eventlog.ts b/cypress/e2e/fixtures/api/eventlog.ts index 77eafb2bf..b46854a55 100644 --- a/cypress/e2e/fixtures/api/eventlog.ts +++ b/cypress/e2e/fixtures/api/eventlog.ts @@ -167,6 +167,73 @@ const eventLogs = { IDER: false } } + }, + remotePlatformErase: { + notSupported: { + response: { + userConsent: 'none', + optInState: 0, + redirection: true, + KVM: true, + SOL: true, + IDER: false, + kvmAvailable: false, + ocr: false, + httpsBootSupported: false, + winREBootSupported: false, + localPBABootSupported: false, + rpe: false, + rpeSupported: false, + pbaBootFilesPath: [], + winREBootFilesPath: { instanceID: '', biosBootString: '', bootString: '' } + } + }, + supportedDisabled: { + response: { + userConsent: 'none', + optInState: 0, + redirection: true, + KVM: true, + SOL: true, + IDER: false, + kvmAvailable: false, + ocr: false, + httpsBootSupported: false, + winREBootSupported: false, + localPBABootSupported: false, + rpe: false, + rpeSupported: true, + pbaBootFilesPath: [], + winREBootFilesPath: { instanceID: '', biosBootString: '', bootString: '' } + } + }, + supportedEnabled: { + response: { + userConsent: 'none', + optInState: 0, + redirection: true, + KVM: true, + SOL: true, + IDER: false, + kvmAvailable: false, + ocr: false, + httpsBootSupported: false, + winREBootSupported: false, + localPBABootSupported: false, + rpe: true, + rpeSupported: true, + pbaBootFilesPath: [], + winREBootFilesPath: { instanceID: '', biosBootString: '', bootString: '' } + } + }, + capabilities: { + response: { + secureEraseAllSSDs: true, + tpmClear: false, + restoreBIOSToEOM: true, + unconfigureCSME: false + } + } } } diff --git a/cypress/e2e/integration/device/remote-platform-erase.spec.ts b/cypress/e2e/integration/device/remote-platform-erase.spec.ts new file mode 100644 index 000000000..8e0d386ee --- /dev/null +++ b/cypress/e2e/integration/device/remote-platform-erase.spec.ts @@ -0,0 +1,258 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2022 + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +import { httpCodes } from '../../fixtures/api/httpCodes' +import { eventLogs } from '../../fixtures/api/eventlog' + +const describeWhenNotCloud = Cypress.env('CLOUD') ? describe.skip : describe + +const navigateToRemotePlatformErase = ( + featuresBody: object = eventLogs.remotePlatformErase.supportedDisabled.response +): void => { + cy.myIntercept('GET', /.*amt\/features.*/, { + statusCode: httpCodes.SUCCESS, + body: featuresBody + }).as('get-features') + + cy.myIntercept('GET', 'devices?$top=25&$skip=0&$count=true', { + statusCode: httpCodes.SUCCESS, + body: eventLogs.devices.success.response + }).as('get-devices') + + cy.myIntercept('GET', /devices\/.*$/, { + statusCode: httpCodes.SUCCESS, + body: eventLogs.devices.success.response + }).as('get-device-by-id') + + cy.myIntercept('GET', /devices\/tags/, { + statusCode: httpCodes.SUCCESS, + body: [] + }).as('get-tags') + + cy.myIntercept('GET', /.*power.*/, { + statusCode: httpCodes.SUCCESS, + body: { powerstate: 2 } + }).as('get-powerstate') + + cy.myIntercept('GET', /.*general.*/, { + statusCode: httpCodes.SUCCESS, + body: eventLogs.general.success.response + }).as('get-general') + + cy.myIntercept('GET', /.*version.*/, { + statusCode: httpCodes.SUCCESS, + body: eventLogs.version.success.response + }).as('get-version') + + cy.myIntercept('GET', /.*amt\/boot\/remoteErase.*/, { + statusCode: httpCodes.SUCCESS, + body: eventLogs.remotePlatformErase.capabilities.response + }).as('get-capabilities') + + cy.myIntercept('GET', /.*alarmOccurrences.*/, { + statusCode: httpCodes.SUCCESS, + body: eventLogs.alarmOccurrences.success.response + }).as('get-alarms') + + cy.goToPage('Devices') + cy.wait('@get-devices').its('response.statusCode').should('eq', 200) + + cy.get('mat-row').first().click() + cy.wait('@get-device-by-id').its('response.statusCode').should('eq', 200) + + cy.get('.mat-mdc-list-item-title').contains('Remote Platform Erase').click() + cy.wait('@get-capabilities') + // Wait for Angular to finish loading (isLoading signal → mat-progress-bar disappears) + cy.get('mat-progress-bar').should('not.exist') +} + +describeWhenNotCloud('Remote Platform Erase', () => { + beforeEach(() => { + cy.setup() + }) + + it('should show "not supported" message when remoteEraseSupported is false', () => { + navigateToRemotePlatformErase(eventLogs.remotePlatformErase.notSupported.response) + cy.wait('@get-features') + + cy.get('[data-cy="remoteEraseCheckbox"]').should('not.exist') + cy.get('mat-icon').contains('info').should('exist') + }) + + it('should show checkbox when supported but not enabled', () => { + navigateToRemotePlatformErase(eventLogs.remotePlatformErase.supportedDisabled.response) + cy.wait('@get-features') + + cy.get('[data-cy="remoteEraseCheckbox"]').should('exist') + cy.get('[data-cy="remoteEraseCheckbox"] button[role="switch"]').should('have.attr', 'aria-checked', 'false') + cy.get('[data-cy="initiateEraseButton"]').should('not.exist') + }) + + it('should show checkbox checked when rpe is true in API response', () => { + navigateToRemotePlatformErase(eventLogs.remotePlatformErase.supportedEnabled.response) + cy.wait('@get-features') + + cy.get('[data-cy="remoteEraseCheckbox"] button[role="switch"]').should('have.attr', 'aria-checked', 'true') + cy.get('[data-cy="initiateEraseButton"]').should('be.visible') + }) + + it('should enable the feature and show initiate erase button after toggling on', () => { + cy.myIntercept('POST', /.*amt\/features.*/, { + statusCode: httpCodes.SUCCESS, + body: eventLogs.remotePlatformErase.supportedEnabled.response + }).as('post-features') + + navigateToRemotePlatformErase() + cy.wait('@get-features') + + cy.get('[data-cy="remoteEraseCheckbox"] button[role="switch"]') + .should('not.have.attr', 'aria-disabled', 'true') + .click() + cy.wait('@post-features').its('request.body.rpe').should('eq', true) + + cy.get('[data-cy="initiateEraseButton"]').should('be.visible') + }) + + it('should disable the feature after toggling off', () => { + // Register disable handler first (LIFO: last registered = first matched) + // so the second POST (disable click) hits the disable response + cy.intercept( + { method: 'POST', url: /.*amt\/features.*/, times: 1 }, + { + statusCode: httpCodes.SUCCESS, + body: eventLogs.remotePlatformErase.supportedDisabled.response + } + ).as('post-features-disable') + + // Register enable handler second so it matches the first POST (enable click) + cy.intercept( + { method: 'POST', url: /.*amt\/features.*/, times: 1 }, + { + statusCode: httpCodes.SUCCESS, + body: eventLogs.remotePlatformErase.supportedEnabled.response + } + ).as('post-features-enable') + + navigateToRemotePlatformErase() + cy.wait('@get-features') + + // Enable first + cy.get('[data-cy="remoteEraseCheckbox"] button[role="switch"]') + .should('not.have.attr', 'aria-disabled', 'true') + .click() + cy.wait('@post-features-enable').its('request.body.rpe').should('eq', true) + + // Then disable + cy.get('[data-cy="remoteEraseCheckbox"] button[role="switch"]') + .should('not.have.attr', 'aria-disabled', 'true') + .click() + cy.wait('@post-features-disable').its('request.body.rpe').should('eq', false) + + cy.get('[data-cy="initiateEraseButton"]').should('not.exist') + }) + + it('should call the remoteErase API when erase is confirmed in the dialog', () => { + cy.myIntercept('POST', /.*amt\/features.*/, { + statusCode: httpCodes.SUCCESS, + body: eventLogs.remotePlatformErase.supportedEnabled.response + }).as('post-features') + + cy.myIntercept('POST', /.*amt\/boot\/remoteErase.*/, { + statusCode: httpCodes.SUCCESS, + body: {} + }).as('post-erase') + + navigateToRemotePlatformErase() + cy.wait('@get-features') + + // Enable the feature first + cy.get('[data-cy="remoteEraseCheckbox"] button[role="switch"]') + .should('not.have.attr', 'aria-disabled', 'true') + .click() + cy.wait('@post-features') + + cy.get('[data-cy="eraseCapCheckbox"]').first().find('input[type="checkbox"]').check({ force: true }) + cy.get('[data-cy="initiateEraseButton"]').click() + + cy.get('mat-dialog-container').should('be.visible') + cy.get('mat-dialog-container').contains('button', 'Yes').click() + + cy.wait('@post-erase').its('response.statusCode').should('eq', 200) + }) + + it('should not call the remoteErase API when erase is cancelled', () => { + cy.myIntercept('POST', /.*amt\/features.*/, { + statusCode: httpCodes.SUCCESS, + body: eventLogs.remotePlatformErase.supportedEnabled.response + }).as('post-features') + + cy.myIntercept('POST', /.*amt\/boot\/remoteErase.*/, { + statusCode: httpCodes.SUCCESS, + body: {} + }).as('post-erase') + + navigateToRemotePlatformErase() + cy.wait('@get-features') + + // Enable the feature first + cy.get('[data-cy="remoteEraseCheckbox"] button[role="switch"]') + .should('not.have.attr', 'aria-disabled', 'true') + .click() + cy.wait('@post-features') + + cy.get('[data-cy="eraseCapCheckbox"]').first().find('input[type="checkbox"]').check({ force: true }) + cy.get('[data-cy="initiateEraseButton"]').click() + + cy.get('mat-dialog-container').should('be.visible') + cy.get('mat-dialog-container').contains('button', 'No').click() + + cy.get('@post-erase.all').should('have.length', 0) + }) + + it('should show error snackbar when toggling feature fails', () => { + cy.myIntercept('POST', /.*amt\/features.*/, { + statusCode: httpCodes.INTERNAL_SERVER_ERROR, + body: {} + }).as('post-features-error') + + navigateToRemotePlatformErase() + cy.wait('@get-features') + + cy.get('[data-cy="remoteEraseCheckbox"] button[role="switch"]') + .should('not.have.attr', 'aria-disabled', 'true') + .click() + cy.wait('@post-features-error') + + cy.get('mat-snack-bar-container').should('be.visible') + }) + + it('should show error snackbar when erase API fails', () => { + cy.myIntercept('POST', /.*amt\/features.*/, { + statusCode: httpCodes.SUCCESS, + body: eventLogs.remotePlatformErase.supportedEnabled.response + }).as('post-features') + + cy.myIntercept('POST', /.*amt\/boot\/remoteErase.*/, { + statusCode: httpCodes.INTERNAL_SERVER_ERROR, + body: {} + }).as('post-erase-error') + + navigateToRemotePlatformErase() + cy.wait('@get-features') + + // Enable the feature first + cy.get('[data-cy="remoteEraseCheckbox"] button[role="switch"]') + .should('not.have.attr', 'aria-disabled', 'true') + .click() + cy.wait('@post-features') + + cy.get('[data-cy="eraseCapCheckbox"]').first().find('input[type="checkbox"]').check({ force: true }) + cy.get('[data-cy="initiateEraseButton"]').click() + cy.get('mat-dialog-container').contains('button', 'Yes').click() + + cy.wait('@post-erase-error') + cy.get('mat-snack-bar-container').should('be.visible') + }) +}) diff --git a/src/app/devices/device-detail/device-detail.component.html b/src/app/devices/device-detail/device-detail.component.html index 28f60dbad..4670936b8 100644 --- a/src/app/devices/device-detail/device-detail.component.html +++ b/src/app/devices/device-detail/device-detail.component.html @@ -68,6 +68,9 @@

{{ item.name | translate }}

@case ('tls') { } + @case ('remote-platform-erase') { + + } } diff --git a/src/app/devices/device-detail/device-detail.component.ts b/src/app/devices/device-detail/device-detail.component.ts index c1a605f45..bb94b971e 100644 --- a/src/app/devices/device-detail/device-detail.component.ts +++ b/src/app/devices/device-detail/device-detail.component.ts @@ -25,6 +25,7 @@ import { GeneralComponent } from '../general/general.component' import { NetworkSettingsComponent } from '../network-settings/network-settings.component' import { environment } from '../../../environments/environment' import { TLSComponent } from '../tls/tls.component' +import { RemotePlatformEraseComponent } from '../remote-platform-erase/remote-platform-erase.component' import { TranslatePipe } from '@ngx-translate/core' @Component({ @@ -58,6 +59,7 @@ import { TranslatePipe } from '@ngx-translate/core' RouterLinkActive, NetworkSettingsComponent, TLSComponent, + RemotePlatformEraseComponent, TranslatePipe ] }) @@ -117,6 +119,12 @@ export class DeviceDetailComponent implements OnInit, OnDestroy { description: 'deviceDetail.certificatesDescription.value', component: 'certificates', icon: 'verified' + }, + { + name: 'deviceDetail.remotePlatformErase.value', + description: 'deviceDetail.remotePlatformEraseDescription.value', + component: 'remote-platform-erase', + icon: 'computer_cancel' } ] diff --git a/src/app/devices/device-toolbar/device-toolbar.component.spec.ts b/src/app/devices/device-toolbar/device-toolbar.component.spec.ts index 714f3c95d..6642e6582 100644 --- a/src/app/devices/device-toolbar/device-toolbar.component.spec.ts +++ b/src/app/devices/device-toolbar/device-toolbar.component.spec.ts @@ -69,7 +69,8 @@ describe('DeviceToolbarComponent', () => { kvmAvailable: true, winREBootSupported: true, localPBABootSupported: true, - remoteErase: true, + rpe: true, + rpeSupported: true, pbaBootFilesPath: [], winREBootFilesPath: { instanceID: '', diff --git a/src/app/devices/devices.service.spec.ts b/src/app/devices/devices.service.spec.ts index e903849ab..8592eb2fa 100644 --- a/src/app/devices/devices.service.spec.ts +++ b/src/app/devices/devices.service.spec.ts @@ -92,7 +92,8 @@ describe('DevicesService', () => { httpsBootSupported: false, winREBootSupported: false, localPBABootSupported: false, - remoteErase: false, + rpe: false, + rpeSupported: true, pbaBootFilesPath: [], winREBootFilesPath: { instanceID: '', @@ -559,7 +560,8 @@ describe('DevicesService', () => { httpsBootSupported: false, winREBootSupported: false, localPBABootSupported: false, - remoteErase: false, + rpe: true, + rpeSupported: true, pbaBootFilesPath: [], winREBootFilesPath: { instanceID: '', @@ -576,7 +578,7 @@ describe('DevicesService', () => { ocr: true, winREBootSupported: true, localPBABootSupported: true, - remoteErase: true + rpe: true } service.setAmtFeatures('device1', payload).subscribe((response) => { @@ -585,11 +587,18 @@ describe('DevicesService', () => { const req = httpMock.expectOne(`${mockEnvironment.mpsServer}/api/v1/amt/features/device1`) expect(req.request.method).toBe('POST') - expect(req.request.body).toEqual(payload) + expect(req.request.body).toEqual({ + userConsent: 'none', + enableKVM: true, + enableSOL: true, + enableIDER: true, + ocr: true, + platformEraseEnabled: true + }) req.flush(mockResponse) }) - it('reflects the chosen settings in the featuresChanges stream without caching the POST response', () => { + it('merges server response onto the cached features after setAmtFeatures', () => { const seeded: AMTFeaturesResponse = { userConsent: 'none', optInState: 0, @@ -602,7 +611,8 @@ describe('DevicesService', () => { httpsBootSupported: true, winREBootSupported: true, localPBABootSupported: true, - remoteErase: false, + rpe: false, + rpeSupported: true, pbaBootFilesPath: [], winREBootFilesPath: { instanceID: '', biosBootString: '', bootString: '' } } @@ -620,11 +630,19 @@ describe('DevicesService', () => { enableSOL: true, enableIDER: true, ocr: true, - remoteErase: true + rpe: true } service.setAmtFeatures('device1', payload).subscribe() - // cloud-style POST response: a status object, not a full features payload - httpMock.expectOne(`${mockEnvironment.mpsServer}/api/v1/amt/features/device1`).flush({ status: 'SUCCESS' } as any) + // server returns the full applied features + httpMock.expectOne(`${mockEnvironment.mpsServer}/api/v1/amt/features/device1`).flush({ + userConsent: 'all', + KVM: false, + SOL: true, + IDER: true, + ocr: true, + rpe: true, + redirection: true + } as any) // no refetch is triggered httpMock.expectNone(`${mockEnvironment.mpsServer}/api/v1/amt/features/device1`) @@ -634,13 +652,63 @@ describe('DevicesService', () => { expect(latest.IDER).toBe(true) expect(latest.ocr).toBe(true) expect(latest.userConsent).toBe('all') - expect(latest.remoteErase).toBe(true) + expect(latest.rpe).toBe(true) // untouched fields are preserved from the cached features expect(latest.kvmAvailable).toBe(true) expect(latest.httpsBootSupported).toBe(true) expect((latest as any).status).toBeUndefined() }) + it('keeps the cached rpe value when a stale GET response arrives after a save', () => { + const seeded: AMTFeaturesResponse = { + userConsent: 'none', + optInState: 0, + redirection: true, + kvmAvailable: true, + KVM: true, + SOL: false, + IDER: false, + ocr: false, + httpsBootSupported: true, + winREBootSupported: true, + localPBABootSupported: true, + rpe: true, + rpeSupported: true, + pbaBootFilesPath: [], + winREBootFilesPath: { instanceID: '', biosBootString: '', bootString: '' } + } + const emitted: AMTFeaturesResponse[] = [] + service.featuresChanges('device1').subscribe((v) => { + if (v) emitted.push(v) + }) + service.getAMTFeatures('device1').subscribe() + httpMock.expectOne(`${mockEnvironment.mpsServer}/api/v1/amt/features/device1`).flush(seeded) + + service + .setAmtFeatures('device1', { + userConsent: 'all', + enableKVM: true, + enableSOL: true, + enableIDER: true, + ocr: true, + rpe: false + }) + .subscribe() + httpMock.expectOne(`${mockEnvironment.mpsServer}/api/v1/amt/features/device1`).flush({ status: 'SUCCESS' } as any) + + let latestGet: AMTFeaturesResponse | undefined + service.getAMTFeatures('device1').subscribe((r) => { + latestGet = r + }) + httpMock.expectOne(`${mockEnvironment.mpsServer}/api/v1/amt/features/device1`).flush({ + ...seeded, + rpe: true + }) + + expect(emitted[emitted.length - 1].rpe).toBe(false) + expect(latestGet?.rpe).toBe(false) + }) + it('derives redirection from the chosen features rather than keeping the stale cached value', () => { const seeded: AMTFeaturesResponse = { userConsent: 'none', @@ -654,7 +722,8 @@ describe('DevicesService', () => { httpsBootSupported: false, winREBootSupported: false, localPBABootSupported: false, - remoteErase: false, + rpeSupported: false, + rpe: false, pbaBootFilesPath: [], winREBootFilesPath: { instanceID: '', biosBootString: '', bootString: '' } } @@ -673,7 +742,7 @@ describe('DevicesService', () => { enableSOL: false, enableIDER: false, ocr: false, - remoteErase: false + rpe: false }) .subscribe() httpMock.expectOne(`${mockEnvironment.mpsServer}/api/v1/amt/features/device1`).flush({ status: 'SUCCESS' } as any) @@ -694,7 +763,8 @@ describe('DevicesService', () => { httpsBootSupported: false, winREBootSupported: false, localPBABootSupported: false, - remoteErase: false, + rpeSupported: false, + rpe: false, pbaBootFilesPath: [], winREBootFilesPath: { instanceID: '', biosBootString: '', bootString: '' } } @@ -712,7 +782,7 @@ describe('DevicesService', () => { enableSOL: false, enableIDER: false, ocr: false, - remoteErase: false + rpe: false }) .subscribe() httpMock.expectOne(`${mockEnvironment.mpsServer}/api/v1/amt/features/device1`).flush({ status: 'SUCCESS' } as any) @@ -730,7 +800,7 @@ describe('DevicesService', () => { enableSOL: true, enableIDER: true, ocr: true, - remoteErase: true + rpe: true }) .subscribe() httpMock.expectOne(`${mockEnvironment.mpsServer}/api/v1/amt/features/device1`).flush({ status: 'SUCCESS' } as any) diff --git a/src/app/devices/devices.service.ts b/src/app/devices/devices.service.ts index cf29ee363..815f12a52 100644 --- a/src/app/devices/devices.service.ts +++ b/src/app/devices/devices.service.ts @@ -31,7 +31,9 @@ import { BootDetails, BootSource, DisplaySelectionResponse, - DisplaySelectionRequest + DisplaySelectionRequest, + RemoteEraseRequest, + BootCapabilities } from '../../models/models' import { caseInsensitiveCompare } from '../../utils' import { TranslateService } from '@ngx-translate/core' @@ -43,6 +45,9 @@ export class DevicesService { private readonly http = inject(HttpClient) private readonly amtFeaturesStreams = new Map>() private readonly powerStateStreams = new Map>() + // After successful platform erase we may temporarily get stale feature payloads + // while the device reboots. Keep RPE forced off until a user explicitly re-enables it. + private readonly rpeDisabledAfterErase = new Set() // In-flight HTTP requests, shared so simultaneous callers (e.g. toolbar and // general components whose ngOnInit fires together) hit the same response. private readonly featuresInflight = new Map>() @@ -278,7 +283,14 @@ export class DevicesService { getAMTFeatures(guid: string): Observable { return this.http.get(`${environment.mpsServer}/api/v1/amt/features/${guid}`).pipe( - tap((features) => this.getOrCreateFeaturesStream(guid).next(features)), + map((features) => { + const stream = this.getOrCreateFeaturesStream(guid) + const current = stream.value + const merged = current === null ? features : { ...features, rpe: current.rpe } + const nextFeatures = this.applyRpeOverride(guid, merged) + stream.next(nextFeatures) + return nextFeatures + }), catchError((err) => { throw err }) @@ -375,6 +387,22 @@ export class DevicesService { } } + setRemoteEraseOptions(deviceId: string, req: RemoteEraseRequest): Observable { + return this.http.post(`${environment.mpsServer}/api/v1/amt/boot/remoteErase/${deviceId}`, req).pipe( + catchError((err) => { + throw err + }) + ) + } + + getRemoteEraseCapabilities(deviceId: string): Observable { + return this.http.get(`${environment.mpsServer}/api/v1/amt/boot/remoteErase/${deviceId}`).pipe( + catchError((err) => { + throw err + }) + ) + } + getTags(): Observable { return this.http.get(`${environment.mpsServer}/api/v1/devices/tags`).pipe( map((tags) => tags.sort(caseInsensitiveCompare)), @@ -439,11 +467,22 @@ export class DevicesService { enableSOL: true, enableIDER: true, ocr: true, - remoteErase: true + rpe: true } ): Observable { + if (payload.rpe) { + this.rpeDisabledAfterErase.delete(deviceId) + } + const requestBody = { + userConsent: payload.userConsent, + enableKVM: payload.enableKVM, + enableSOL: payload.enableSOL, + enableIDER: payload.enableIDER, + ocr: payload.ocr, + platformEraseEnabled: payload.rpe + } return this.http - .post(`${environment.mpsServer}/api/v1/amt/features/${deviceId}`, payload) + .post(`${environment.mpsServer}/api/v1/amt/features/${deviceId}`, requestBody) .pipe( tap(() => this.applyFeaturesSelection(deviceId, payload)), catchError((err) => { @@ -459,16 +498,42 @@ export class DevicesService { if (current === null) { return } - stream.next({ - ...current, - userConsent: payload.userConsent, - KVM: payload.enableKVM, - SOL: payload.enableSOL, - IDER: payload.enableIDER, - redirection: payload.enableKVM || payload.enableSOL || payload.enableIDER, - ocr: payload.ocr, - remoteErase: payload.remoteErase - }) + stream.next( + this.applyRpeOverride(deviceId, { + ...current, + userConsent: payload.userConsent, + KVM: payload.enableKVM, + SOL: payload.enableSOL, + IDER: payload.enableIDER, + redirection: payload.enableKVM || payload.enableSOL || payload.enableIDER, + ocr: payload.ocr, + rpe: payload.rpe + }) + ) + } + + updateAmtFeaturesCache(deviceId: string, patch: Partial): void { + const stream = this.getOrCreateFeaturesStream(deviceId) + const current = stream.value + if (current === null) { + return + } + stream.next( + this.applyRpeOverride(deviceId, { + ...current, + ...patch + }) + ) + } + + private applyRpeOverride(deviceId: string, features: AMTFeaturesResponse): AMTFeaturesResponse { + if (!this.rpeDisabledAfterErase.has(deviceId)) { + return features + } + return { + ...features, + rpe: false + } } getPowerState(deviceId: string): Observable { diff --git a/src/app/devices/general/general.component.html b/src/app/devices/general/general.component.html index 5f67c3613..bb467d9a9 100644 --- a/src/app/devices/general/general.component.html +++ b/src/app/devices/general/general.component.html @@ -156,6 +156,16 @@ }} } +
+

{{ 'general.remotePlatformErase.value' | translate }}

+
+
+ +

{{ 'general.ideRedirection.value' | translate }}

diff --git a/src/app/devices/general/general.component.spec.ts b/src/app/devices/general/general.component.spec.ts index 1a051a6b4..008fac883 100644 --- a/src/app/devices/general/general.component.spec.ts +++ b/src/app/devices/general/general.component.spec.ts @@ -32,7 +32,8 @@ describe('GeneralComponent', () => { 'bulkPowerAction', 'sendDeactivate', 'sendBulkDeactivate', - 'getWsmanOperations' + 'getWsmanOperations', + 'featuresChanges' ]) const amtFeaturesResponse = { userConsent: 'ALL', @@ -46,7 +47,8 @@ describe('GeneralComponent', () => { ocr: true, winREBootSupported: true, localPBABootSupported: true, - remoteErase: true, + rpe: true, + rpeSupported: true, pbaBootFilesPath: [], winREBootFilesPath: { instanceID: '', @@ -56,6 +58,7 @@ describe('GeneralComponent', () => { } devicesServiceSpy.getAMTFeatures.and.returnValue(of(amtFeaturesResponse)) devicesServiceSpy.getAMTFeaturesCached.and.returnValue(of(amtFeaturesResponse)) + devicesServiceSpy.featuresChanges.and.returnValue(of(null)) devicesServiceSpy.getGeneralSettings.and.returnValue(of({})) devicesServiceSpy.getAMTVersion.and.returnValue(of([''])) TestBed.configureTestingModule({ @@ -135,12 +138,12 @@ describe('GeneralComponent', () => { expect(devicesServiceSpy.setAmtFeatures).toHaveBeenCalled() }) - it('sends remoteErase from the loaded features, not the default', () => { + it('sends rpe from the loaded features, not the default', () => { devicesServiceSpy.setAmtFeatures = jasmine.createSpy().and.returnValue(of({})) component.setAmtFeatures() expect(devicesServiceSpy.setAmtFeatures).toHaveBeenCalledWith( jasmine.any(String), - jasmine.objectContaining({ remoteErase: true }) + jasmine.objectContaining({ rpe: true }) ) }) }) diff --git a/src/app/devices/general/general.component.ts b/src/app/devices/general/general.component.ts index d175ee048..75d492525 100644 --- a/src/app/devices/general/general.component.ts +++ b/src/app/devices/general/general.component.ts @@ -57,7 +57,8 @@ export class GeneralComponent implements OnInit, OnDestroy { httpsBootSupported: false, winREBootSupported: false, localPBABootSupported: false, - remoteErase: false, + rpeSupported: false, + rpe: false, pbaBootFilesPath: [], winREBootFilesPath: { instanceID: '', biosBootString: '', bootString: '' } } @@ -74,10 +75,13 @@ export class GeneralComponent implements OnInit, OnDestroy { httpsBootSupported: false, winREBootSupported: false, localPBABootSupported: false, - ocr: false + ocr: false, + rpe: false, + rpeSupported: false }) public isLoading = signal(true) + public isDataLoaded = signal(false) public amtDHCPDNSSuffix: string | null = null public amtTrustedDNSSuffix: string | null = null public amtVersion: string | null = null @@ -159,10 +163,16 @@ export class GeneralComponent implements OnInit, OnDestroy { !results.amtFeatures.localPBABootSupported } ], + httpsBootSupported: [{ value: results.amtFeatures.httpsBootSupported, disabled: true }], winREBootSupported: [{ value: results.amtFeatures.winREBootSupported, disabled: true }], - localPBABootSupported: [{ value: results.amtFeatures.localPBABootSupported, disabled: true }] + localPBABootSupported: [{ value: results.amtFeatures.localPBABootSupported, disabled: true }], + rpe: [ + { value: results.amtFeatures.rpe, disabled: !(results.amtFeatures.rpeSupported ?? false) } + ], + rpeSupported: [{ value: results.amtFeatures.rpeSupported ?? false, disabled: true }] }) + this.isDataLoaded.set(true) }) } @@ -179,13 +189,14 @@ export class GeneralComponent implements OnInit, OnDestroy { return !!(enableKVM || enableSOL || enableIDER) } - setAmtFeatures(): void { + setAmtFeatures(override: Partial = {}): void { this.isLoading.set(true) + const payload = { + ...this.amtEnabledFeatures.getRawValue(), + ...override + } as AMTFeaturesRequest this.devicesService - .setAmtFeatures(this.deviceId(), { - ...this.amtEnabledFeatures.getRawValue(), - remoteErase: this.amtFeatures.remoteErase - } as AMTFeaturesRequest) + .setAmtFeatures(this.deviceId(), payload) .pipe( finalize(() => { this.isLoading.set(false) diff --git a/src/app/devices/hardware-information/hardware-information.component.spec.ts b/src/app/devices/hardware-information/hardware-information.component.spec.ts index d7f58727b..4884924d3 100644 --- a/src/app/devices/hardware-information/hardware-information.component.spec.ts +++ b/src/app/devices/hardware-information/hardware-information.component.spec.ts @@ -46,7 +46,8 @@ describe('HardwareInformationComponent', () => { ocr: true, winREBootSupported: true, localPBABootSupported: true, - remoteErase: true, + rpeSupported: true, + rpe: true, pbaBootFilesPath: [], winREBootFilesPath: { instanceID: '', diff --git a/src/app/devices/kvm/kvm.component.spec.ts b/src/app/devices/kvm/kvm.component.spec.ts index 70e1c5069..61c8f4958 100644 --- a/src/app/devices/kvm/kvm.component.spec.ts +++ b/src/app/devices/kvm/kvm.component.spec.ts @@ -74,7 +74,54 @@ describe('KvmComponent', () => { ocr: true, winREBootSupported: true, localPBABootSupported: true, - remoteErase: true, + rpeSupported: true, + rpe: true, + pbaBootFilesPath: [], + winREBootFilesPath: { + instanceID: '', + biosBootString: '', + bootString: '' + } + }) + ) + getAMTFeaturesSpy = devicesService.getAMTFeatures.and.returnValue( + of({ + userConsent: 'none', + KVM: true, + SOL: true, + IDER: true, + redirection: true, + kvmAvailable: true, + optInState: 0, + httpsBootSupported: true, + ocr: true, + winREBootSupported: true, + localPBABootSupported: true, + rpeSupported: true, + rpe: true, + pbaBootFilesPath: [], + winREBootFilesPath: { + instanceID: '', + biosBootString: '', + bootString: '' + } + }) + ) + getAMTFeaturesCachedSpy = devicesService.getAMTFeaturesCached.and.returnValue( + of({ + userConsent: 'none', + KVM: true, + SOL: true, + IDER: true, + redirection: true, + kvmAvailable: true, + optInState: 0, + httpsBootSupported: true, + ocr: true, + winREBootSupported: true, + localPBABootSupported: true, + rpeSupported: true, + rpe: true, pbaBootFilesPath: [], winREBootFilesPath: { instanceID: '', @@ -83,28 +130,6 @@ describe('KvmComponent', () => { } }) ) - const amtFeaturesResponse = { - userConsent: 'none', - KVM: true, - SOL: true, - IDER: true, - redirection: true, - kvmAvailable: true, - optInState: 0, - httpsBootSupported: true, - ocr: true, - winREBootSupported: true, - localPBABootSupported: true, - remoteErase: true, - pbaBootFilesPath: [], - winREBootFilesPath: { - instanceID: '', - biosBootString: '', - bootString: '' - } - } - getAMTFeaturesSpy = devicesService.getAMTFeatures.and.returnValue(of(amtFeaturesResponse)) - getAMTFeaturesCachedSpy = devicesService.getAMTFeaturesCached.and.returnValue(of(amtFeaturesResponse)) devicesService.getDevice.and.returnValue( of({ hostname: 'test-hostname', @@ -423,7 +448,8 @@ describe('KvmComponent', () => { ocr: true, winREBootSupported: true, localPBABootSupported: true, - remoteErase: true, + rpeSupported: true, + rpe: true, pbaBootFilesPath: [], winREBootFilesPath: { instanceID: '', @@ -524,7 +550,8 @@ describe('KvmComponent', () => { ocr: true, winREBootSupported: true, localPBABootSupported: true, - remoteErase: true, + rpeSupported: true, + rpe: true, pbaBootFilesPath: [], winREBootFilesPath: { instanceID: '', @@ -553,7 +580,8 @@ describe('KvmComponent', () => { ocr: true, winREBootSupported: true, localPBABootSupported: true, - remoteErase: true, + rpeSupported: true, + rpe: true, pbaBootFilesPath: [], winREBootFilesPath: { instanceID: '', @@ -598,7 +626,8 @@ describe('KvmComponent', () => { ocr: true, winREBootSupported: true, localPBABootSupported: true, - remoteErase: true, + rpeSupported: true, + rpe: true, pbaBootFilesPath: [], winREBootFilesPath: { instanceID: '', @@ -626,7 +655,8 @@ describe('KvmComponent', () => { ocr: true, winREBootSupported: true, localPBABootSupported: true, - remoteErase: true, + rpeSupported: true, + rpe: true, pbaBootFilesPath: [], winREBootFilesPath: { instanceID: '', @@ -656,7 +686,8 @@ describe('KvmComponent', () => { ocr: true, winREBootSupported: true, localPBABootSupported: true, - remoteErase: true, + rpeSupported: true, + rpe: true, pbaBootFilesPath: [], winREBootFilesPath: { instanceID: '', @@ -685,7 +716,8 @@ describe('KvmComponent', () => { ocr: true, winREBootSupported: true, localPBABootSupported: true, - remoteErase: true, + rpeSupported: true, + rpe: true, pbaBootFilesPath: [], winREBootFilesPath: { instanceID: '', @@ -714,7 +746,8 @@ describe('KvmComponent', () => { ocr: true, winREBootSupported: true, localPBABootSupported: true, - remoteErase: true, + rpeSupported: true, + rpe: true, pbaBootFilesPath: [], winREBootFilesPath: { instanceID: '', @@ -743,7 +776,8 @@ describe('KvmComponent', () => { ocr: true, winREBootSupported: true, localPBABootSupported: true, - remoteErase: true, + rpeSupported: true, + rpe: true, pbaBootFilesPath: [], winREBootFilesPath: { instanceID: '', diff --git a/src/app/devices/kvm/kvm.component.ts b/src/app/devices/kvm/kvm.component.ts index 9b2741d1c..06f1a8e89 100644 --- a/src/app/devices/kvm/kvm.component.ts +++ b/src/app/devices/kvm/kvm.component.ts @@ -476,7 +476,7 @@ export class KvmComponent implements OnInit, OnDestroy { enableSOL: this.amtFeatures()?.SOL ?? false, enableIDER: this.amtFeatures()?.IDER ?? false, ocr: this.amtFeatures()?.ocr ?? false, - remoteErase: this.amtFeatures()?.remoteErase ?? false + rpe: this.amtFeatures()?.rpe ?? false } return this.devicesService.setAmtFeatures(this.deviceId(), payload) } diff --git a/src/app/devices/remote-platform-erase/remote-platform-erase.component.html b/src/app/devices/remote-platform-erase/remote-platform-erase.component.html new file mode 100644 index 000000000..ba569cce0 --- /dev/null +++ b/src/app/devices/remote-platform-erase/remote-platform-erase.component.html @@ -0,0 +1,126 @@ +@if (isLoading()) { + +} + + + {{ 'remotePlatformErase.title.value' | translate }} + @if (isPlatformEraseSupported() && platformEraseEnabled()) { + + } + + +

{{ 'remotePlatformErase.description.value' | translate }}

+ @if (isPlatformEraseSupported()) { +
+ + {{ 'remotePlatformErase.initiateWarning.value' | translate }} +
+ +
+ + {{ 'remotePlatformErase.featureEnabled.value' | translate }} + +
+ {{ 'remotePlatformErase.featureEnabledHint.value' | translate }} +
+
+ + @if (platformEraseEnabled()) { + + +

+ + {{ 'remotePlatformErase.caps.title.value' | translate }} +

+

{{ 'remotePlatformErase.caps.description.value' | translate }}

+ + @for (cap of eraseCaps(); track cap.key; let i = $index) { + + @if (cap.key !== 'csmeUnconfigure') { +
+ +
+
{{ 'remotePlatformErase.cap.' + cap.key + '.label.value' | translate }}
+ + {{ 'remotePlatformErase.cap.' + cap.key + '.desc.value' | translate }} + + @if (!cap.supported) { +
+ {{ 'remotePlatformErase.capUnsupported.value' | translate }} +
+ } +
+
+ @if (cap.key === 'secureEraseSsds' && isSsdSelected()) { +
+ + {{ 'remotePlatformErase.ssdEncrypted.label.value' | translate }} + + @if (isSsdEncrypted()) { + + {{ 'remotePlatformErase.ssdPassword.label.value' | translate }} + + + } +
+ } +
+ } + } + + @if (isCsmeExclusiveSelected()) { +
+ + {{ 'remotePlatformErase.csmeUnconfigureExclusive.value' | translate }} +
+ } + +

+ + {{ + 'remotePlatformErase.selectionCount.value' + | translate: { selected: selectedCapsCount(), total: supportedCapsCount() } + }} + +

+ } + } @else if (!isLoading()) { +
+ + {{ 'remotePlatformErase.notSupported.value' | translate }} +
+ } +
+
diff --git a/src/app/devices/remote-platform-erase/remote-platform-erase.component.scss b/src/app/devices/remote-platform-erase/remote-platform-erase.component.scss new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/src/app/devices/remote-platform-erase/remote-platform-erase.component.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/src/app/devices/remote-platform-erase/remote-platform-erase.component.spec.ts b/src/app/devices/remote-platform-erase/remote-platform-erase.component.spec.ts new file mode 100644 index 000000000..244dca8e5 --- /dev/null +++ b/src/app/devices/remote-platform-erase/remote-platform-erase.component.spec.ts @@ -0,0 +1,1021 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2022 + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +import { ComponentFixture, TestBed } from '@angular/core/testing' +import { RemotePlatformEraseComponent } from './remote-platform-erase.component' +import { DevicesService } from '../devices.service' +import { MatDialog } from '@angular/material/dialog' +import { MatSnackBar } from '@angular/material/snack-bar' +import { of, throwError } from 'rxjs' +import { provideTranslateService } from '@ngx-translate/core' +import { AMTFeaturesResponse } from '../../../models/models' +import { AreYouSureDialogComponent } from '../../shared/are-you-sure/are-you-sure.component' +import { HttpErrorResponse } from '@angular/common/http' +import { Router } from '@angular/router' +import { UserConsentService } from '../user-consent.service' + +// Temporary set to 3 until CSME unconfigure is supported in the UI. The 4th capability is hidden in the template and not tested here. +const VISIBLE_PLATFORM_ERASE_CAPABILITIES = 3 //PLATFORM_ERASE_CAPABILITIES.filter((cap) => cap.key !== 'csmeUnconfigure') + +const mockAMTFeatures: AMTFeaturesResponse = { + userConsent: 'none', + KVM: true, + SOL: true, + IDER: true, + redirection: true, + optInState: 1, + kvmAvailable: true, + httpsBootSupported: false, + ocr: false, + winREBootSupported: false, + localPBABootSupported: false, + rpe: false, + rpeSupported: true, + pbaBootFilesPath: [], + winREBootFilesPath: { instanceID: '', biosBootString: '', bootString: '' } +} + +describe('RemotePlatformEraseComponent', () => { + let component: RemotePlatformEraseComponent + let fixture: ComponentFixture + let devicesServiceSpy: jasmine.SpyObj + let matDialogSpy: jasmine.SpyObj + let snackBarSpy: jasmine.SpyObj + let routerSpy: jasmine.SpyObj + let userConsentServiceSpy: jasmine.SpyObj + + beforeEach(async () => { + devicesServiceSpy = jasmine.createSpyObj('DevicesService', [ + 'getAMTFeatures', + 'getAMTFeaturesCached', + 'featuresChanges', + 'updateAmtFeaturesCache', + 'markRpeDisabledAfterErase', + 'setAmtFeatures', + 'getDevice', + 'getRemoteEraseCapabilities', + 'setRemoteEraseOptions', + 'sendDeactivate' + ]) + devicesServiceSpy.getDevice.and.returnValue(of({ guid: '', hostname: 'host-1', friendlyName: 'my-laptop' } as any)) + devicesServiceSpy.getRemoteEraseCapabilities.and.returnValue( + of({ secureEraseAllSSDs: true, tpmClear: false, restoreBIOSToEOM: true, unconfigureCSME: false }) + ) + matDialogSpy = jasmine.createSpyObj('MatDialog', ['open']) + snackBarSpy = jasmine.createSpyObj('MatSnackBar', ['open']) + routerSpy = jasmine.createSpyObj('Router', ['navigate']) + routerSpy.navigate.and.returnValue(Promise.resolve(true)) + userConsentServiceSpy = jasmine.createSpyObj('UserConsentService', [ + 'handleUserConsentDecision', + 'handleUserConsentResponse' + ]) + userConsentServiceSpy.handleUserConsentDecision.and.returnValue(of(null)) + userConsentServiceSpy.handleUserConsentResponse.and.returnValue(of(null)) + + devicesServiceSpy.getAMTFeatures.and.returnValue(of({ ...mockAMTFeatures })) + // Delegate getAMTFeaturesCached to getAMTFeatures so per-test returnValue configs apply to both + devicesServiceSpy.getAMTFeaturesCached.and.callFake((guid: string) => devicesServiceSpy.getAMTFeatures(guid)) + devicesServiceSpy.featuresChanges.and.returnValue(of(null)) + devicesServiceSpy.setAmtFeatures.and.returnValue(of({ ...mockAMTFeatures })) + devicesServiceSpy.setRemoteEraseOptions.and.returnValue(of({})) + devicesServiceSpy.sendDeactivate.and.returnValue(of({})) + + await TestBed.configureTestingModule({ + imports: [RemotePlatformEraseComponent], + providers: [ + provideTranslateService(), + { provide: DevicesService, useValue: devicesServiceSpy }, + { provide: MatDialog, useValue: matDialogSpy }, + { provide: MatSnackBar, useValue: snackBarSpy }, + { provide: Router, useValue: routerSpy }, + { provide: UserConsentService, useValue: userConsentServiceSpy } + ] + }).compileComponents() + + fixture = TestBed.createComponent(RemotePlatformEraseComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) + + it('should call getAMTFeatures on init', () => { + expect(devicesServiceSpy.getAMTFeatures).toHaveBeenCalled() + }) + + it('should set isLoading to false after init completes', () => { + expect(component.isLoading()).toBeFalse() + }) + + it('should show toggle as checked on init when rpe is true', () => { + devicesServiceSpy.getAMTFeatures.and.returnValue(of({ ...mockAMTFeatures, rpe: true })) + component.ngOnInit() + fixture.detectChanges() + const toggle = fixture.nativeElement.querySelector('[data-cy="remoteEraseCheckbox"] button[role="switch"]') + expect(toggle).not.toBeNull() + expect(toggle.getAttribute('aria-checked')).toBe('true') + }) + + it('should show feature-disabled status when remoteEraseEnabled is false', () => { + fixture.detectChanges() + const toggle = fixture.nativeElement.querySelector('[data-cy="remoteEraseCheckbox"] button[role="switch"]') + expect(toggle).not.toBeNull() + expect(toggle.getAttribute('aria-checked')).toBe('false') + }) + + it('should show error snackbar when getAMTFeatures fails', () => { + devicesServiceSpy.getAMTFeatures.and.returnValue(throwError(() => new Error('error'))) + component.ngOnInit() + expect(snackBarSpy.open).toHaveBeenCalled() + }) + + it('should show server message in snackbar when setRemoteEraseOptions fails with it', () => { + const serverError = new HttpErrorResponse({ error: { message: 'AMT device unreachable' }, status: 500 }) + devicesServiceSpy.getAMTFeatures.and.returnValue(of({ ...mockAMTFeatures, rpe: true })) + component.ngOnInit() + component.toggleFeature(true) + component.eraseCapControl(0).setValue(true) + component.onCapChange() + devicesServiceSpy.setRemoteEraseOptions.and.returnValue(throwError(() => serverError)) + matDialogSpy.open.and.returnValue({ afterClosed: () => of(true) } as any) + component.initiateErase() + expect(snackBarSpy.open).toHaveBeenCalledWith('AMT device unreachable', undefined, jasmine.any(Object)) + }) + + it('should show OS erase success message when secureEraseAllSSDs is selected', () => { + component.eraseCaps.set([ + { key: 'secureEraseSsds', supported: true }, + { key: 'tpmClear', supported: false }, + { key: 'biosRestore', supported: false }, + { key: 'csmeUnconfigure', supported: false } + ]) + component.eraseCapsArray.at(0).enable() + component.eraseCapsArray.at(0).setValue(true) + component.selectedCapsCount.set(1) + component.platformEraseEnabled.set(true) + + devicesServiceSpy.setRemoteEraseOptions.and.returnValue(of({})) + matDialogSpy.open.and.returnValue({ afterClosed: () => of(true) } as any) + + component.initiateErase() + + expect(snackBarSpy.open).toHaveBeenCalledWith( + 'remotePlatformErase.osEraseSuccess.value', + undefined, + jasmine.anything() + ) + }) + + it('should show fallback message in snackbar when setRemoteEraseOptions fails without message', () => { + const serverError = new HttpErrorResponse({ error: {}, status: 500 }) + devicesServiceSpy.getAMTFeatures.and.returnValue(of({ ...mockAMTFeatures, rpe: true })) + component.ngOnInit() + component.toggleFeature(true) + component.eraseCapControl(0).setValue(true) + component.onCapChange() + devicesServiceSpy.setRemoteEraseOptions.and.returnValue(throwError(() => serverError)) + matDialogSpy.open.and.returnValue({ afterClosed: () => of(true) } as any) + component.initiateErase() + expect(snackBarSpy.open).toHaveBeenCalledWith( + 'remotePlatformErase.eraseError.value', + undefined, + jasmine.any(Object) + ) + }) + + it('should open AreYouSureDialog with the erase confirmation message', () => { + devicesServiceSpy.getAMTFeatures.and.returnValue(of({ ...mockAMTFeatures, rpe: true })) + component.ngOnInit() + component.toggleFeature(true) + component.eraseCapControl(0).setValue(true) + component.onCapChange() + matDialogSpy.open.and.returnValue({ afterClosed: () => of(false) } as any) + component.initiateErase() + expect(matDialogSpy.open).toHaveBeenCalledWith(AreYouSureDialogComponent, jasmine.any(Object)) + const config = matDialogSpy.open.calls.mostRecent().args[1] as any + expect(config.data.message).toBe('remotePlatformErase.confirmMessage') + expect(config.data.params.operations).toBeDefined() + expect(config.data.params.device).toBeDefined() + }) + + it('should pass selected capability labels in the dialog params', () => { + devicesServiceSpy.getAMTFeatures.and.returnValue( + of({ + ...mockAMTFeatures, + rpe: true + }) + ) + component.ngOnInit() + component.toggleFeature(true) + component.eraseCapControl(0).setValue(true) + component.eraseCapControl(2).setValue(true) + component.onCapChange() + matDialogSpy.open.and.returnValue({ afterClosed: () => of(false) } as any) + component.initiateErase() + const config = matDialogSpy.open.calls.mostRecent().args[1] as any + expect(config.data.params.operations.split(', ').length).toBe(2) + }) + + it('should not open confirmation dialog when feature is disabled', () => { + // mockAMTFeatures has rpe: false + matDialogSpy.open.and.returnValue({ afterClosed: () => of(false) } as any) + component.initiateErase() + expect(matDialogSpy.open).not.toHaveBeenCalled() + }) + + it('should not open confirmation dialog when no caps are selected', () => { + devicesServiceSpy.getAMTFeatures.and.returnValue(of({ ...mockAMTFeatures, rpe: true })) + component.ngOnInit() + component.toggleFeature(true) + component.eraseCapControl(0).setValue(false) + component.eraseCapControl(2).setValue(false) + matDialogSpy.open.and.returnValue({ afterClosed: () => of(false) } as any) + component.initiateErase() + expect(matDialogSpy.open).not.toHaveBeenCalled() + }) + + it('should call sendRemotePlatformErase when erase is confirmed', () => { + devicesServiceSpy.getAMTFeatures.and.returnValue(of({ ...mockAMTFeatures, rpe: true })) + component.ngOnInit() + component.toggleFeature(true) + // check secureEraseSsds (0x04, bit 2) and biosRestore (0x4000000, bit 26) → mask 0x4000004 + component.eraseCapControl(0).setValue(true) + component.eraseCapControl(2).setValue(true) + component.onCapChange() + matDialogSpy.open.and.returnValue({ afterClosed: () => of(true) } as any) + component.initiateErase() + expect(devicesServiceSpy.setRemoteEraseOptions).toHaveBeenCalledWith('', { + secureEraseAllSSDs: true, + tpmClear: false, + restoreBIOSToEOM: true, + unconfigureCSME: false + }) + }) + + it('should not call setRemoteEraseOptions when erase is cancelled', () => { + devicesServiceSpy.getAMTFeatures.and.returnValue(of({ ...mockAMTFeatures, rpe: true })) + component.ngOnInit() + component.toggleFeature(true) + component.eraseCapControl(0).setValue(true) + component.onCapChange() + matDialogSpy.open.and.returnValue({ afterClosed: () => of(false) } as any) + component.initiateErase() + expect(devicesServiceSpy.setRemoteEraseOptions).not.toHaveBeenCalled() + }) + + it('should pass deselected capability bitmask to setRemoteEraseOptions', () => { + devicesServiceSpy.getAMTFeatures.and.returnValue(of({ ...mockAMTFeatures, rpe: true })) + component.ngOnInit() + component.toggleFeature(true) + // check only biosRestore (index 2, bit 26 = 0x4000000) — secureEraseSsds left unchecked + component.eraseCapControl(2).setValue(true) + component.onCapChange() + matDialogSpy.open.and.returnValue({ afterClosed: () => of(true) } as any) + component.initiateErase() + expect(devicesServiceSpy.setRemoteEraseOptions).toHaveBeenCalledWith('', { + secureEraseAllSSDs: false, + tpmClear: false, + restoreBIOSToEOM: true, + unconfigureCSME: false + }) + }) + + it('should show success snackbar after erase succeeds', () => { + devicesServiceSpy.getAMTFeatures.and.returnValue(of({ ...mockAMTFeatures, rpe: true })) + component.ngOnInit() + component.toggleFeature(true) + component.eraseCapControl(0).setValue(true) + component.onCapChange() + matDialogSpy.open.and.returnValue({ afterClosed: () => of(true) } as any) + component.initiateErase() + expect(snackBarSpy.open).toHaveBeenCalled() + }) + + it('should show error snackbar when sendRemotePlatformErase fails', () => { + devicesServiceSpy.getAMTFeatures.and.returnValue(of({ ...mockAMTFeatures, rpe: true })) + component.ngOnInit() + component.toggleFeature(true) + component.eraseCapControl(0).setValue(true) + component.onCapChange() + devicesServiceSpy.setRemoteEraseOptions.and.returnValue(throwError(() => new Error('error'))) + matDialogSpy.open.and.returnValue({ afterClosed: () => of(true) } as any) + component.initiateErase() + expect(snackBarSpy.open).toHaveBeenCalled() + }) + + it('should set isRemoteEraseSupported to true when remoteEraseSupported is true', () => { + expect(component.isPlatformEraseSupported()).toBeTrue() + }) + + it('should set isRemoteEraseSupported to false when remoteEraseSupported is false', () => { + devicesServiceSpy.getAMTFeatures.and.returnValue(of({ ...mockAMTFeatures, rpeSupported: false })) + component.ngOnInit() + fixture.detectChanges() + expect(component.isPlatformEraseSupported()).toBeFalse() + }) + + it('should set isRemoteEraseSupported to false when remoteEraseSupported is absent', () => { + const featuresWithoutSupported = { ...mockAMTFeatures } as Partial + delete featuresWithoutSupported.rpeSupported + devicesServiceSpy.getAMTFeatures.and.returnValue(of(featuresWithoutSupported as AMTFeaturesResponse)) + component.ngOnInit() + fixture.detectChanges() + expect(component.isPlatformEraseSupported()).toBeFalse() + }) + + it('should set isLoading to true during initiateErase', () => { + devicesServiceSpy.getAMTFeatures.and.returnValue(of({ ...mockAMTFeatures, rpe: true })) + component.ngOnInit() + component.toggleFeature(true) + component.eraseCapControl(0).setValue(true) + component.onCapChange() + devicesServiceSpy.setRemoteEraseOptions.and.returnValue(of({})) + matDialogSpy.open.and.returnValue({ afterClosed: () => of(true) } as any) + component.initiateErase() + // isLoading is set true before the observable completes + expect(devicesServiceSpy.setRemoteEraseOptions).toHaveBeenCalled() + }) + + it('should set isLoading to false after initiateErase fails', () => { + devicesServiceSpy.getAMTFeatures.and.returnValue(of({ ...mockAMTFeatures, rpe: true })) + component.ngOnInit() + component.toggleFeature(true) + component.eraseCapControl(0).setValue(true) + component.onCapChange() + devicesServiceSpy.setRemoteEraseOptions.and.returnValue(throwError(() => new Error('erase failed'))) + matDialogSpy.open.and.returnValue({ afterClosed: () => of(true) } as any) + component.initiateErase() + expect(component.isLoading()).toBeFalse() + }) + + it('should show initiate erase button when feature is enabled', () => { + devicesServiceSpy.getAMTFeatures.and.returnValue(of({ ...mockAMTFeatures, rpe: true })) + component.ngOnInit() + component.toggleFeature(true) + component.eraseCapControl(0).setValue(true) + component.onCapChange() + fixture.detectChanges() + const button = fixture.nativeElement.querySelector('[data-cy="initiateEraseButton"]') + expect(button).not.toBeNull() + expect(button.disabled).toBeFalse() + }) + + it('should not show initiate erase button when feature is disabled', () => { + // mockAMTFeatures has rpe: false but rpeSupported: true + fixture.detectChanges() + const button = fixture.nativeElement.querySelector('[data-cy="initiateEraseButton"]') + expect(button).toBeNull() + }) + + it('should disable initiate erase button when all selected caps are unchecked', () => { + devicesServiceSpy.getAMTFeatures.and.returnValue(of({ ...mockAMTFeatures, rpe: true })) + component.ngOnInit() + component.toggleFeature(true) + fixture.detectChanges() + // Uncheck all supported caps (secureEraseSsds index 0, biosRestore index 2) + component.eraseCapControl(0).setValue(false) + component.eraseCapControl(2).setValue(false) + component.onCapChange() + fixture.detectChanges() + const button = fixture.nativeElement.querySelector('[data-cy="initiateEraseButton"]') + expect(button).not.toBeNull() + expect(button.disabled).toBeTrue() + }) + + it('should enable initiate erase button when feature is enabled and at least one cap is checked', () => { + devicesServiceSpy.getAMTFeatures.and.returnValue(of({ ...mockAMTFeatures, rpe: true })) + component.ngOnInit() + component.toggleFeature(true) + fixture.detectChanges() + // Caps start unchecked — check a supported one first + component.eraseCapControl(0).setValue(true) + component.onCapChange() + fixture.detectChanges() + const button = fixture.nativeElement.querySelector('[data-cy="initiateEraseButton"]') + expect(button).not.toBeNull() + expect(button.disabled).toBeFalse() + }) + + it('should show capabilities card when remoteEraseSupported is true', () => { + devicesServiceSpy.getAMTFeatures.and.returnValue(of({ ...mockAMTFeatures, rpe: true })) + component.ngOnInit() + component.toggleFeature(true) + fixture.detectChanges() + const capItems = fixture.nativeElement.querySelectorAll('[data-cy="eraseCapItem"]') + // Only 3 capabilities visible (CSME unconfigure is hidden in template) + expect(capItems.length).toBe(VISIBLE_PLATFORM_ERASE_CAPABILITIES) + }) + + it('should show a checkbox for each capability, disabled for unsupported ones', () => { + devicesServiceSpy.getAMTFeatures.and.returnValue(of({ ...mockAMTFeatures, rpe: true })) + component.ngOnInit() + component.toggleFeature(true) + fixture.detectChanges() + const checkboxes = fixture.nativeElement.querySelectorAll('[data-cy="eraseCapCheckbox"]') + // Only 3 capabilities visible (CSME unconfigure is hidden in template) + expect(checkboxes.length).toBe(VISIBLE_PLATFORM_ERASE_CAPABILITIES) + // tpmClear (index 1) and csmeUnconfigure (index 3) not supported — always disabled + expect(component.eraseCapControl(1).disabled).toBeTrue() + expect(component.eraseCapControl(3).disabled).toBeTrue() + // secureEraseSsds (index 0) and biosRestore (index 2) supported — always enabled + expect(component.eraseCapControl(0).disabled).toBeFalse() + expect(component.eraseCapControl(2).disabled).toBeFalse() + }) + + it('should disable all capability checkboxes when featureEnabled is false', () => { + // mockAMTFeatures: rpe=false — all caps disabled regardless of support + fixture.detectChanges() + expect(component.eraseCapControl(0).disabled).toBeTrue() // supported but feature off → disabled + expect(component.eraseCapControl(1).disabled).toBeTrue() // not supported → disabled + expect(component.eraseCapControl(2).disabled).toBeTrue() // supported but feature off → disabled + expect(component.eraseCapControl(3).disabled).toBeTrue() // not supported → disabled + }) + + it('should keep supported capability checkboxes enabled when featureEnabled becomes true', () => { + component.toggleFeature(true) + // 0x05: secureErase(0) + storageDrives(2) supported → always enabled + expect(component.eraseCapControl(0).disabled).toBeFalse() + expect(component.eraseCapControl(1).disabled).toBeTrue() // tpmClear not supported + expect(component.eraseCapControl(2).disabled).toBeFalse() + expect(component.eraseCapControl(3).disabled).toBeTrue() // meRegion not supported + }) + + it('should default eraseCaps to all-supported when getRemoteEraseCapabilities returns all supported', () => { + devicesServiceSpy.getRemoteEraseCapabilities.and.returnValue( + of({ secureEraseAllSSDs: true, tpmClear: true, restoreBIOSToEOM: true, unconfigureCSME: true }) + ) + component.ngOnInit() + expect(component.eraseCaps().every((c) => c.supported)).toBeTrue() + }) + + it('should default eraseCaps to all-unsupported when getRemoteEraseCapabilities returns all unsupported', () => { + devicesServiceSpy.getRemoteEraseCapabilities.and.returnValue( + of({ secureEraseAllSSDs: false, tpmClear: false, restoreBIOSToEOM: false, unconfigureCSME: false }) + ) + component.ngOnInit() + expect(component.eraseCaps().every((c) => !c.supported)).toBeTrue() + }) + + describe('toggleFeature', () => { + it('should call setAmtFeatures with rpe: true when toggling on', () => { + component.toggleFeature(true) + expect(devicesServiceSpy.setAmtFeatures).toHaveBeenCalledWith('', jasmine.objectContaining({ rpe: true })) + }) + + it('should call setAmtFeatures with rpe: false when toggling off', () => { + component.toggleFeature(false) + expect(devicesServiceSpy.setAmtFeatures).toHaveBeenCalledWith('', jasmine.objectContaining({ rpe: false })) + }) + + it('should set rpe immediately before API call completes', () => { + devicesServiceSpy.setAmtFeatures.and.returnValue(of({ ...mockAMTFeatures, rpe: true })) + expect(component.platformEraseEnabled()).toBeFalse() + component.toggleFeature(true) + expect(component.platformEraseEnabled()).toBeTrue() + }) + + it('should revert platformEraseEnabled when setAmtFeatures fails', () => { + devicesServiceSpy.setAmtFeatures.and.returnValue(throwError(() => new Error('error'))) + component.toggleFeature(true) + expect(component.platformEraseEnabled()).toBeFalse() + }) + + it('should show error snackbar when setAmtFeatures fails', () => { + devicesServiceSpy.setAmtFeatures.and.returnValue(throwError(() => new Error('error'))) + component.toggleFeature(true) + expect(snackBarSpy.open).toHaveBeenCalled() + }) + + it('should disable caps immediately when toggling off', () => { + component.toggleFeature(true) + // caps should be enabled for supported ones with feature on + expect(component.eraseCapControl(0).disabled).toBeFalse() + component.toggleFeature(false) + // caps should be disabled immediately + expect(component.eraseCapControl(0).disabled).toBeTrue() + }) + + it('should enable supported caps immediately when toggling on', () => { + // feature starts off (mockAMTFeatures.rpe = false) — caps disabled + expect(component.eraseCapControl(0).disabled).toBeTrue() + component.toggleFeature(true) + expect(component.eraseCapControl(0).disabled).toBeFalse() + }) + + it('should not corrupt amtFeatures after successful toggle', () => { + // The API returns { status: '...' }, not a full AMTFeaturesResponse. + // Verify that amtFeatures fields are preserved so subsequent toggles send valid payloads. + devicesServiceSpy.setAmtFeatures.and.returnValue(of({ status: 'SUCCESS' } as any)) + component.toggleFeature(true) + component.toggleFeature(false) + const calls = devicesServiceSpy.setAmtFeatures.calls.all() + const secondPayload = calls[1]?.args[1] + expect(secondPayload?.userConsent).toBe(mockAMTFeatures.userConsent) + expect(secondPayload?.enableKVM).toBe(mockAMTFeatures.KVM) + expect(secondPayload?.enableSOL).toBe(mockAMTFeatures.SOL) + expect(secondPayload?.enableIDER).toBe(mockAMTFeatures.IDER) + expect(secondPayload?.rpe).toBe(mockAMTFeatures.rpe) + }) + }) + + describe('after successful erase', () => { + beforeEach(() => { + devicesServiceSpy.getAMTFeatures.and.returnValue(of({ ...mockAMTFeatures, rpe: true })) + devicesServiceSpy.setAmtFeatures.and.returnValue(of({ ...mockAMTFeatures, rpe: false })) + devicesServiceSpy.setRemoteEraseOptions.and.returnValue(of({})) + devicesServiceSpy.sendDeactivate.and.returnValue(of({})) + userConsentServiceSpy.handleUserConsentDecision.and.returnValue(of(null)) + userConsentServiceSpy.handleUserConsentResponse.and.returnValue(of(null)) + component.ngOnInit() + component.toggleFeature(true) + component.eraseCapControl(0).setValue(true) + component.onCapChange() + matDialogSpy.open.and.returnValue({ afterClosed: () => of(true) } as any) + }) + + it('should complete without errors', () => { + component.initiateErase() + expect(devicesServiceSpy.setRemoteEraseOptions).toHaveBeenCalled() + }) + }) + + describe('CSME exclusivity', () => { + beforeEach(() => { + devicesServiceSpy.getAMTFeatures.and.returnValue( + of({ + ...mockAMTFeatures, + rpe: true + }) + ) + devicesServiceSpy.getRemoteEraseCapabilities.and.returnValue( + of({ secureEraseAllSSDs: true, tpmClear: false, restoreBIOSToEOM: true, unconfigureCSME: true }) + ) + component.ngOnInit() + component.toggleFeature(true) + }) + + it('should call sendDeactivate after CSME erase succeeds', () => { + component.eraseCapControl(3).setValue(true) + component.onCapChange() + matDialogSpy.open.and.returnValue({ afterClosed: () => of(true) } as any) + component.initiateErase() + expect(devicesServiceSpy.sendDeactivate).toHaveBeenCalledWith('') + }) + + it('should navigate to /devices after CSME erase succeeds', () => { + const router = TestBed.inject(Router) + component.eraseCapControl(3).setValue(true) + component.onCapChange() + matDialogSpy.open.and.returnValue({ afterClosed: () => of(true) } as any) + component.initiateErase() + expect(router.navigate).toHaveBeenCalledWith(['/devices']) + }) + + it('should signal isCsmeExclusiveSelected when CSME is checked', () => { + expect(component.isCsmeExclusiveSelected()).toBeFalse() + component.eraseCapControl(3).setValue(true) // csmeUnconfigure + component.onCapChange() + expect(component.isCsmeExclusiveSelected()).toBeTrue() + }) + + it('should uncheck other caps when CSME is checked', () => { + component.eraseCapControl(0).setValue(true) + component.eraseCapControl(2).setValue(true) + component.onCapChange() + expect(component.selectedCapsCount()).toBe(2) + + component.eraseCapControl(3).setValue(true) + component.onCapChange() + expect(component.eraseCapControl(0).value).toBeFalse() + expect(component.eraseCapControl(2).value).toBeFalse() + expect(component.selectedCapsCount()).toBe(1) + }) + + it('should disable other caps while CSME is selected', () => { + component.eraseCapControl(3).setValue(true) + component.onCapChange() + expect(component.eraseCapControl(0).disabled).toBeTrue() + expect(component.eraseCapControl(2).disabled).toBeTrue() + expect(component.eraseCapControl(3).disabled).toBeFalse() + }) + + it('should re-enable other caps when CSME is unchecked', () => { + component.eraseCapControl(3).setValue(true) + component.onCapChange() + component.eraseCapControl(3).setValue(false) + component.onCapChange() + expect(component.eraseCapControl(0).disabled).toBeFalse() + expect(component.eraseCapControl(2).disabled).toBeFalse() + expect(component.isCsmeExclusiveSelected()).toBeFalse() + }) + + it('should send only the CSME bit in the erase mask', () => { + component.eraseCapControl(3).setValue(true) + component.onCapChange() + matDialogSpy.open.and.returnValue({ afterClosed: () => of(true) } as any) + component.initiateErase() + expect(devicesServiceSpy.setRemoteEraseOptions).toHaveBeenCalledWith('', { + secureEraseAllSSDs: false, + tpmClear: false, + restoreBIOSToEOM: false, + unconfigureCSME: true + }) + }) + }) + describe('SSD encrypted password', () => { + beforeEach(() => { + devicesServiceSpy.getAMTFeatures.and.returnValue(of({ ...mockAMTFeatures, rpe: true })) + component.ngOnInit() + component.toggleFeature(true) + }) + + it('should show encrypted checkbox when SSD cap is selected', () => { + component.eraseCapControl(0).setValue(true) + component.onCapChange() + expect(component.isSsdSelected()).toBeTrue() + }) + + it('should hide encrypted checkbox when SSD cap is deselected', () => { + component.eraseCapControl(0).setValue(true) + component.onCapChange() + component.eraseCapControl(0).setValue(false) + component.onCapChange() + expect(component.isSsdSelected()).toBeFalse() + }) + + it('should show password input when encrypted checkbox is checked', () => { + component.eraseCapControl(0).setValue(true) + component.onCapChange() + component.onSsdEncryptedChange(true) + expect(component.isSsdEncrypted()).toBeTrue() + }) + + it('should hide password input and clear it when encrypted checkbox is unchecked', () => { + component.eraseCapControl(0).setValue(true) + component.onCapChange() + component.onSsdEncryptedChange(true) + component.ssdPasswordControl.setValue('secret') + component.onSsdEncryptedChange(false) + expect(component.isSsdEncrypted()).toBeFalse() + expect(component.ssdPasswordControl.value).toBe('') + }) + + it('should include ssdPassword in request when encrypted and SSD selected', () => { + component.eraseCapControl(0).setValue(true) + component.onCapChange() + component.onSsdEncryptedChange(true) + component.ssdPasswordControl.setValue('mypassword') + matDialogSpy.open.and.returnValue({ afterClosed: () => of(true) } as any) + component.initiateErase() + expect(devicesServiceSpy.setRemoteEraseOptions).toHaveBeenCalledWith( + '', + jasmine.objectContaining({ secureEraseAllSSDs: true, ssdPassword: 'mypassword' }) + ) + }) + + it('should not include ssdPassword when encrypted checkbox is not checked', () => { + component.eraseCapControl(0).setValue(true) + component.onCapChange() + matDialogSpy.open.and.returnValue({ afterClosed: () => of(true) } as any) + component.initiateErase() + const call = devicesServiceSpy.setRemoteEraseOptions.calls.mostRecent() + expect(call.args[1].ssdPassword).toBeUndefined() + }) + + it('should reset SSD controls when feature is toggled off', () => { + component.eraseCapControl(0).setValue(true) + component.onCapChange() + component.onSsdEncryptedChange(true) + component.ssdPasswordControl.setValue('secret') + component.toggleFeature(false) + expect(component.isSsdSelected()).toBeFalse() + expect(component.isSsdEncrypted()).toBeFalse() + expect(component.ssdPasswordControl.value).toBe('') + }) + + it('should keep SSD controls as they are after successful erase', () => { + component.eraseCapControl(0).setValue(true) + component.onCapChange() + component.onSsdEncryptedChange(true) + component.ssdPasswordControl.setValue('secret') + matDialogSpy.open.and.returnValue({ afterClosed: () => of(true) } as any) + component.initiateErase() + // State is preserved after erase + expect(component.isSsdSelected()).toBeTrue() + expect(component.isSsdEncrypted()).toBeTrue() + expect(component.ssdPasswordControl.value).toBe('secret') + }) + + it('should reset SSD controls when CSME is selected', () => { + devicesServiceSpy.getRemoteEraseCapabilities.and.returnValue( + of({ secureEraseAllSSDs: true, tpmClear: false, restoreBIOSToEOM: true, unconfigureCSME: true }) + ) + component.ngOnInit() + component.toggleFeature(true) + component.eraseCapControl(0).setValue(true) + component.onCapChange() + component.onSsdEncryptedChange(true) + // Now select CSME (index 3) — deselects SSD + component.eraseCapControl(3).setValue(true) + component.onCapChange() + expect(component.isSsdSelected()).toBeFalse() + expect(component.isSsdEncrypted()).toBeFalse() + }) + }) + + describe('supportedCapsCount', () => { + it('should count only supported capabilities', () => { + expect(component.supportedCapsCount()).toBe(2) + }) + }) + + describe('template visibility', () => { + it('should hide not-supported message while loading', () => { + component.isLoading.set(true) + component.isPlatformEraseSupported.set(false) + fixture.detectChanges() + const notSupported = fixture.nativeElement.querySelector('[data-cy="notSupportedMessage"]') + expect(notSupported).toBeNull() + }) + + it('should show not-supported message after loading completes when not supported', () => { + component.isLoading.set(false) + component.isPlatformEraseSupported.set(false) + fixture.detectChanges() + const info = fixture.nativeElement.querySelector('mat-icon[color="accent"]') + expect(info).not.toBeNull() + }) + + it('should show warning when isPlatformEraseSupported is true', () => { + fixture.detectChanges() + const warn = fixture.nativeElement.querySelector('mat-icon[color="warn"]') + expect(warn).not.toBeNull() + }) + + it('should not show warning when isPlatformEraseSupported is false', () => { + devicesServiceSpy.getAMTFeatures.and.returnValue(of({ ...mockAMTFeatures, rpeSupported: false })) + component.ngOnInit() + fixture.detectChanges() + const warn = fixture.nativeElement.querySelector('mat-icon[color="warn"]') + expect(warn).toBeNull() + }) + + it('should show selectHint when platformEraseEnabled is true', () => { + component.toggleFeature(true) + fixture.detectChanges() + const hint = fixture.nativeElement.querySelector('mat-icon[color="primary"]') + expect(hint).not.toBeNull() + }) + + it('should hide selectHint when platformEraseEnabled is false', () => { + fixture.detectChanges() + const hint = fixture.nativeElement.querySelector('mat-icon[color="primary"]') + expect(hint).toBeNull() + }) + }) + + describe('checkUserConsent', () => { + it('should return true and set readyToErase when userConsent is none', (done) => { + component.amtFeatures.set({ ...mockAMTFeatures, userConsent: 'none' }) + component.checkUserConsent().subscribe((result) => { + expect(result).toBeTrue() + expect(component.readyToErase).toBeTrue() + done() + }) + }) + + it('should return true and set readyToErase when optInState is 3', (done) => { + component.amtFeatures.set({ ...mockAMTFeatures, userConsent: 'all', optInState: 3 }) + component.checkUserConsent().subscribe((result) => { + expect(result).toBeTrue() + expect(component.readyToErase).toBeTrue() + done() + }) + }) + + it('should return true and set readyToErase when optInState is 4', (done) => { + component.amtFeatures.set({ ...mockAMTFeatures, userConsent: 'all', optInState: 4 }) + component.checkUserConsent().subscribe((result) => { + expect(result).toBeTrue() + expect(component.readyToErase).toBeTrue() + done() + }) + }) + + it('should return false and not set readyToErase when userConsent is all and consent is required', (done) => { + component.amtFeatures.set({ ...mockAMTFeatures, userConsent: 'all', optInState: 1 }) + component.checkUserConsent().subscribe((result) => { + expect(result).toBeFalse() + expect(component.readyToErase).toBeFalse() + done() + }) + }) + }) + + describe('postUserConsentDecision', () => { + const operations = 'SSD Erase' + + it('should open AreYouSureDialog when result is null (consent not required)', () => { + matDialogSpy.open.and.returnValue({ afterClosed: () => of(false) } as any) + component.postUserConsentDecision(null, operations).subscribe() + expect(matDialogSpy.open).toHaveBeenCalledWith(AreYouSureDialogComponent, jasmine.any(Object)) + }) + + it('should open AreYouSureDialog when result is true (consent granted)', () => { + matDialogSpy.open.and.returnValue({ afterClosed: () => of(false) } as any) + component.postUserConsentDecision(true, operations).subscribe() + expect(matDialogSpy.open).toHaveBeenCalledWith(AreYouSureDialogComponent, jasmine.any(Object)) + }) + + it('should not open AreYouSureDialog when result is false (consent denied)', () => { + component.postUserConsentDecision(false, operations).subscribe() + expect(matDialogSpy.open).not.toHaveBeenCalled() + }) + + it('should call executeErase when AreYouSure dialog is confirmed', () => { + component.platformEraseEnabled.set(true) + component.eraseCaps.set([ + { key: 'secureEraseSsds', supported: true }, + { key: 'tpmClear', supported: false }, + { key: 'biosRestore', supported: false }, + { key: 'csmeUnconfigure', supported: false } + ]) + component.eraseCapsArray.at(0).enable() + component.eraseCapsArray.at(0).setValue(true) + component.selectedCapsCount.set(1) + matDialogSpy.open.and.returnValue({ afterClosed: () => of(true) } as any) + component.postUserConsentDecision(null, operations).subscribe() + expect(devicesServiceSpy.setRemoteEraseOptions).toHaveBeenCalled() + }) + + it('should not call executeErase when AreYouSure dialog is cancelled', () => { + matDialogSpy.open.and.returnValue({ afterClosed: () => of(false) } as any) + component.postUserConsentDecision(null, operations).subscribe() + expect(devicesServiceSpy.setRemoteEraseOptions).not.toHaveBeenCalled() + }) + + it('should pass device label and operations in the dialog data', () => { + component.deviceLabel.set('my-device') + matDialogSpy.open.and.returnValue({ afterClosed: () => of(false) } as any) + component.postUserConsentDecision(null, operations).subscribe() + const config = matDialogSpy.open.calls.mostRecent().args[1] as any + expect(config.data.params.device).toBe('my-device') + expect(config.data.params.operations).toBe(operations) + }) + }) + + describe('initiateErase user consent flow', () => { + beforeEach(() => { + devicesServiceSpy.getAMTFeatures.and.returnValue(of({ ...mockAMTFeatures, rpe: true })) + component.ngOnInit() + component.toggleFeature(true) + component.eraseCapControl(0).setValue(true) + component.onCapChange() + }) + + it('should call handleUserConsentDecision via UserConsentService', () => { + matDialogSpy.open.and.returnValue({ afterClosed: () => of(false) } as any) + component.initiateErase() + expect(userConsentServiceSpy.handleUserConsentDecision).toHaveBeenCalled() + }) + + it('should call handleUserConsentDecision with deviceId and amtFeatures', () => { + matDialogSpy.open.and.returnValue({ afterClosed: () => of(false) } as any) + component.initiateErase() + expect(userConsentServiceSpy.handleUserConsentDecision).toHaveBeenCalledWith( + jasmine.anything(), + '', + component.amtFeatures() ?? undefined + ) + }) + + it('should call handleUserConsentResponse with RPE as feature name', () => { + matDialogSpy.open.and.returnValue({ afterClosed: () => of(false) } as any) + component.initiateErase() + expect(userConsentServiceSpy.handleUserConsentResponse).toHaveBeenCalledWith('', null, 'RPE') + }) + + it('should open AreYouSure dialog when handleUserConsentResponse returns null (consent not required)', () => { + userConsentServiceSpy.handleUserConsentResponse.and.returnValue(of(null)) + matDialogSpy.open.and.returnValue({ afterClosed: () => of(false) } as any) + component.initiateErase() + expect(matDialogSpy.open).toHaveBeenCalledWith(AreYouSureDialogComponent, jasmine.any(Object)) + }) + + it('should open AreYouSure dialog when handleUserConsentResponse returns true (consent granted)', () => { + userConsentServiceSpy.handleUserConsentResponse.and.returnValue(of(true)) + matDialogSpy.open.and.returnValue({ afterClosed: () => of(false) } as any) + component.initiateErase() + expect(matDialogSpy.open).toHaveBeenCalledWith(AreYouSureDialogComponent, jasmine.any(Object)) + }) + + it('should not open AreYouSure dialog when consent is denied', () => { + userConsentServiceSpy.handleUserConsentResponse.and.returnValue(of(false)) + component.initiateErase() + expect(matDialogSpy.open).not.toHaveBeenCalled() + }) + + it('should not call setRemoteEraseOptions when consent is denied', () => { + userConsentServiceSpy.handleUserConsentResponse.and.returnValue(of(false)) + component.initiateErase() + expect(devicesServiceSpy.setRemoteEraseOptions).not.toHaveBeenCalled() + }) + }) + + describe('SSD Password Validation', () => { + it('should validate SSD password is within 64-byte limit', () => { + const validPassword = 'password123' + component.ssdPasswordControl.setValue(validPassword) + expect(component.ssdPasswordControl.valid).toBe(true) + }) + + it('should reject SSD password exceeding 64 bytes', () => { + // Create a password that is 65 bytes (65 ASCII characters) + const tooLongPassword = 'a'.repeat(65) + component.ssdPasswordControl.setValue(tooLongPassword) + expect(component.ssdPasswordControl.valid).toBe(false) + expect(component.ssdPasswordControl.errors?.['ssdPasswordTooLong']).toBeDefined() + }) + + it('should allow SSD password exactly 64 bytes', () => { + // Exactly 64 bytes + const maxPassword = 'a'.repeat(64) + component.ssdPasswordControl.setValue(maxPassword) + expect(component.ssdPasswordControl.valid).toBe(true) + expect(component.ssdPasswordControl.errors).toBeNull() + }) + + it('should accept empty SSD password', () => { + component.ssdPasswordControl.setValue('') + expect(component.ssdPasswordControl.valid).toBe(true) + }) + + it('should reject SSD password with multibyte UTF-8 characters exceeding 64 bytes', () => { + // Chinese characters take 3 bytes each in UTF-8 + // 22 characters × 3 bytes = 66 bytes (exceeds limit) + const multibytePassword = '中'.repeat(22) + component.ssdPasswordControl.setValue(multibytePassword) + expect(component.ssdPasswordControl.valid).toBe(false) + expect(component.ssdPasswordControl.errors?.['ssdPasswordTooLong']).toBeDefined() + }) + + it('should allow SSD password with multibyte UTF-8 characters within 64 bytes', () => { + // 21 Chinese characters × 3 bytes each = 63 bytes (within limit) + const multibytePassword = '中'.repeat(21) + component.ssdPasswordControl.setValue(multibytePassword) + expect(component.ssdPasswordControl.valid).toBe(true) + }) + + it('initiateErase should prevent submission if SSD password exceeds 64 bytes', () => { + component.platformEraseEnabled.set(true) + component.selectedCapsCount.set(1) + component.isSsdSelected.set(true) + component.isSsdEncrypted.set(true) + component.ssdPasswordControl.setValue('a'.repeat(65)) + + component.initiateErase() + + expect(snackBarSpy.open).toHaveBeenCalledWith( + jasmine.stringContaining('remotePlatformErase.ssdPasswordTooLong'), + undefined, + jasmine.any(Object) + ) + expect(devicesServiceSpy.setRemoteEraseOptions).not.toHaveBeenCalled() + }) + + it('initiateErase should allow submission if SSD password is within limit', () => { + userConsentServiceSpy.handleUserConsentResponse.and.returnValue(of(null)) + matDialogSpy.open.and.returnValue({ afterClosed: () => of(true) } as any) + devicesServiceSpy.setRemoteEraseOptions.and.returnValue(of({})) + + component.platformEraseEnabled.set(true) + component.selectedCapsCount.set(1) + component.isSsdSelected.set(true) + component.isSsdEncrypted.set(true) + component.ssdPasswordControl.setValue('a'.repeat(32)) + + component.initiateErase() + + expect(devicesServiceSpy.setRemoteEraseOptions).toHaveBeenCalled() + }) + + it('initiateErase should allow submission if SSD is selected but not encrypted', () => { + userConsentServiceSpy.handleUserConsentResponse.and.returnValue(of(null)) + matDialogSpy.open.and.returnValue({ afterClosed: () => of(true) } as any) + devicesServiceSpy.setRemoteEraseOptions.and.returnValue(of({})) + + component.platformEraseEnabled.set(true) + component.selectedCapsCount.set(1) + component.isSsdSelected.set(true) + component.isSsdEncrypted.set(false) + + component.initiateErase() + + expect(devicesServiceSpy.setRemoteEraseOptions).toHaveBeenCalled() + }) + }) +}) diff --git a/src/app/devices/remote-platform-erase/remote-platform-erase.component.ts b/src/app/devices/remote-platform-erase/remote-platform-erase.component.ts new file mode 100644 index 000000000..f79248d27 --- /dev/null +++ b/src/app/devices/remote-platform-erase/remote-platform-erase.component.ts @@ -0,0 +1,435 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2022 + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +import { Component, DestroyRef, OnInit, inject, signal, input, computed } from '@angular/core' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' +import { Router } from '@angular/router' +import { catchError, switchMap } from 'rxjs/operators' +import { + FormArray, + FormBuilder, + FormControl, + ReactiveFormsModule, + AbstractControl, + ValidationErrors +} from '@angular/forms' +import { MatCardModule } from '@angular/material/card' +import { MatCheckboxModule } from '@angular/material/checkbox' +import { MatDivider } from '@angular/material/divider' +import { MatProgressBar } from '@angular/material/progress-bar' +import { MatIcon } from '@angular/material/icon' +import { MatButton } from '@angular/material/button' +import { MatSlideToggleModule } from '@angular/material/slide-toggle' +import { MatFormFieldModule } from '@angular/material/form-field' +import { MatInputModule } from '@angular/material/input' +import { MatTooltipModule } from '@angular/material/tooltip' +import { MatDialog } from '@angular/material/dialog' +import { MatSnackBar } from '@angular/material/snack-bar' +import { filter, finalize, forkJoin, Observable, of, EMPTY } from 'rxjs' +import { DevicesService } from '../devices.service' +import { + AMTFeaturesRequest, + AMTFeaturesResponse, + PowerState, + BootCapabilities, + RemoteEraseRequest, + UserConsentResponse +} from '../../../models/models' +import { ParsedPlatformEraseCapability } from './remote-platform-erase.constants' +import SnackbarDefaults from '../../shared/config/snackBarDefault' +import { AreYouSureDialogComponent } from '../../shared/are-you-sure/are-you-sure.component' +import { TranslatePipe, TranslateService } from '@ngx-translate/core' +import { PowerUpAlertComponent } from '../../shared/power-up-alert/power-up-alert.component' +import { UserConsentService } from '../user-consent.service' + +const CSME_UNCONFIGURE_KEY = 'csmeUnconfigure' +const SSD_ERASE_KEY = 'secureEraseSsds' +const SSD_PASSWORD_MAX_BYTES = 64 + +@Component({ + selector: 'app-remote-platform-erase', + templateUrl: './remote-platform-erase.component.html', + styleUrl: './remote-platform-erase.component.scss', + imports: [ + MatProgressBar, + MatCardModule, + MatCheckboxModule, + MatDivider, + MatSlideToggleModule, + ReactiveFormsModule, + MatIcon, + MatButton, + MatTooltipModule, + MatFormFieldModule, + MatInputModule, + TranslatePipe + ] +}) +export class RemotePlatformEraseComponent implements OnInit { + private readonly devicesService = inject(DevicesService) + private readonly snackBar = inject(MatSnackBar) + private readonly destroyRef = inject(DestroyRef) + private readonly matDialog = inject(MatDialog) + private readonly fb = inject(FormBuilder) + private readonly translate = inject(TranslateService) + private readonly router = inject(Router) + private readonly userConsentService = inject(UserConsentService) + + public deviceConnection = signal(false) + + public readonly deviceId = input('') + public deviceLabel = signal('') + public isLoading = signal(false) + public powerState: PowerState = { powerstate: 0 } + public isPlatformEraseSupported = signal(false) + public platformEraseEnabled = signal(false) + public eraseCaps = signal([]) + public eraseCapsArray: FormArray> = this.fb.array([]) + public selectedCapsCount = signal(0) + public isCsmeExclusiveSelected = signal(false) + public hasSelectedCaps = computed(() => this.selectedCapsCount() > 0) + public supportedCapsCount = computed(() => this.eraseCaps().filter((c) => c.supported).length) + public amtFeatures = signal(null) + private csmeIndex = -1 + private ssdIndex = -1 + public isSsdSelected = signal(false) + public readyToErase = false + public isSsdEncrypted = signal(false) + public ssdEncryptedControl = this.fb.control(false) + public ssdPasswordControl = this.fb.control('', [this.validateSsdPasswordBytes.bind(this)]) + + ngOnInit(): void { + this.devicesService.getDevice(this.deviceId()).subscribe({ + next: (device) => { + this.deviceLabel.set(device.friendlyName || device.hostname || this.deviceId()) + } + }) + forkJoin({ + features: this.devicesService.getAMTFeatures(this.deviceId()), + capabilities: this.devicesService.getRemoteEraseCapabilities(this.deviceId()) + }) + .pipe( + finalize(() => this.isLoading.set(false)), + catchError((err) => { + const msg: string = err.error?.message || this.t('remotePlatformErase.errorLoad') + this.displayError(msg) + return EMPTY + }) + ) + .subscribe({ + next: ({ features, capabilities }) => this.applyFeatures(features, capabilities) + }) + + // Reactively update the slider whenever features change (e.g. after General tab saves) + this.devicesService + .featuresChanges(this.deviceId()) + .pipe(filter(Boolean), takeUntilDestroyed(this.destroyRef)) + .subscribe((features) => { + this.isPlatformEraseSupported.set(features.rpeSupported ?? false) + this.platformEraseEnabled.set(features.rpe ?? false) + this.amtFeatures.set(features) + this.updateCapControlStates() + }) + } + + init(): void { + this.isLoading.set(true) + // device needs to be powered on in order to perform RPE + this.getPowerState(this.deviceId()) + .pipe(switchMap((powerState) => this.handlePowerState(powerState))) + .subscribe() + .add(() => this.isLoading.set(false)) + } + + handlePowerState(powerState: PowerState): Observable { + this.powerState = powerState + // If device is not powered on, shows alert to power up device + if (this.powerState.powerstate !== 2) { + return this.showPowerUpAlert().pipe( + switchMap((result) => { + // if they said yes, power on the device + if (result) { + return this.devicesService.sendPowerAction(this.deviceId(), 2) + } + return of(null) + }) + ) + } + return of(true) + } + + getPowerState(guid: string): Observable { + return this.devicesService.getPowerState(guid).pipe( + catchError((err) => { + this.isLoading.set(false) + const msg: string = err.error?.message || this.t('remotePlatformErase.errorRetrievingPower') + this.displayError(msg) + return EMPTY + }) + ) + } + + showPowerUpAlert(): Observable { + const dialog = this.matDialog.open(PowerUpAlertComponent) + return dialog.afterClosed() + } + + onCapChange(): void { + const csmeSelected = this.csmeIndex >= 0 && this.eraseCapsArray.at(this.csmeIndex)?.value === true + if (csmeSelected) { + this.eraseCapsArray.controls.forEach((ctrl, i) => { + if (i !== this.csmeIndex && ctrl.value) { + ctrl.setValue(false, { emitEvent: false }) + } + }) + } + this.isCsmeExclusiveSelected.set(csmeSelected) + this.selectedCapsCount.set(this.eraseCapsArray.getRawValue().filter((v) => v === true).length) + const ssdSelected = this.ssdIndex >= 0 && this.eraseCapsArray.at(this.ssdIndex)?.value === true + this.isSsdSelected.set(ssdSelected) + if (!ssdSelected) { + this.resetSsdControls() + } + this.updateCapControlStates() + } + + onSsdEncryptedChange(checked: boolean): void { + this.isSsdEncrypted.set(checked) + if (!checked) { + this.ssdPasswordControl.setValue('') + } + } + + eraseCapControl(index: number): FormControl { + return this.eraseCapsArray.at(index) as FormControl + } + + toggleFeature(enabled: boolean): void { + this.platformEraseEnabled.set(enabled) + this.updateCapControlStates() + const payload: AMTFeaturesRequest = { + userConsent: this.amtFeatures()?.userConsent ?? '', + enableKVM: this.amtFeatures()?.KVM ?? false, + enableSOL: this.amtFeatures()?.SOL ?? false, + enableIDER: this.amtFeatures()?.IDER ?? false, + ocr: this.amtFeatures()?.ocr ?? false, + rpe: enabled + } + this.isLoading.set(true) + this.devicesService + .setAmtFeatures(this.deviceId(), payload) + .pipe( + finalize(() => this.isLoading.set(false)), + catchError((err) => { + this.platformEraseEnabled.set(!enabled) + this.updateCapControlStates() + const msg: string = err.error?.message || this.t('remotePlatformErase.toggleFeatureError') + this.displayError(msg) + return EMPTY + }) + ) + .subscribe({ + next: () => { + this.amtFeatures.update((f) => (f ? { ...f, rpe: enabled } : f)) + this.updateCapControlStates() + } + }) + } + + initiateErase(): void { + if (!this.platformEraseEnabled() || !this.hasSelectedCaps()) { + return + } + // Validate SSD password if encrypted SSD erase is selected + if (this.isSsdSelected() && this.isSsdEncrypted() && this.ssdPasswordControl.invalid) { + const msg: string = this.t('remotePlatformErase.ssdPasswordTooLong') + this.displayError(msg) + return + } + const operations = this.eraseCaps() + .filter((_, i) => this.eraseCapsArray.at(i)?.value === true) + .map((cap) => this.t(`remotePlatformErase.cap.${cap.key}.label`)) + .join(', ') + + this.checkUserConsent() + .pipe( + switchMap((result: any) => + // safely convert null to undefined for type compatibility + this.userConsentService.handleUserConsentDecision(result, this.deviceId(), this.amtFeatures() ?? undefined) + ), + switchMap((result: any | UserConsentResponse) => + this.userConsentService.handleUserConsentResponse(this.deviceId(), result, 'RPE') + ), + switchMap((result: any) => this.postUserConsentDecision(result, operations)) + ) + .subscribe() + } + + postUserConsentDecision(result: any, operations: string): Observable { + if (result === false) { + return of(null) + } + return this.matDialog + .open(AreYouSureDialogComponent, { + data: { + message: 'remotePlatformErase.confirmMessage', + params: { device: this.deviceLabel() || this.deviceId(), operations } + } + }) + .afterClosed() + .pipe( + switchMap((confirmed: boolean) => { + if (confirmed === true) { + this.executeErase() + } + return of(null) + }) + ) + } + + private applyFeatures(features: AMTFeaturesResponse, capabilities: BootCapabilities): void { + this.amtFeatures.update((f) => (f ? { ...f, ...features } : features)) + const supported = features.rpeSupported ?? false + this.isPlatformEraseSupported.set(supported) + this.platformEraseEnabled.set(features.rpe ?? false) + const caps: ParsedPlatformEraseCapability[] = [ + { key: 'secureEraseSsds', supported: capabilities.secureEraseAllSSDs }, + { key: 'tpmClear', supported: capabilities.tpmClear }, + { key: 'biosRestore', supported: capabilities.restoreBIOSToEOM }, + { key: 'csmeUnconfigure', supported: capabilities.unconfigureCSME } + ] + this.eraseCaps.set(caps) + this.csmeIndex = caps.findIndex((c) => c.key === CSME_UNCONFIGURE_KEY) + this.ssdIndex = caps.findIndex((c) => c.key === SSD_ERASE_KEY) + this.eraseCapsArray = this.fb.array( + caps.map((cap) => this.fb.control({ value: false, disabled: !cap.supported })) + ) + this.selectedCapsCount.set(0) + this.isCsmeExclusiveSelected.set(false) + this.resetSsdControls() + this.updateCapControlStates() + } + + private executeErase(): void { + const caps = this.eraseCaps() + const req: RemoteEraseRequest = { + secureEraseAllSSDs: caps.some((c, i) => c.key === 'secureEraseSsds' && this.eraseCapsArray.at(i)?.value === true), + tpmClear: caps.some((c, i) => c.key === 'tpmClear' && this.eraseCapsArray.at(i)?.value === true), + restoreBIOSToEOM: caps.some((c, i) => c.key === 'biosRestore' && this.eraseCapsArray.at(i)?.value === true), + unconfigureCSME: caps.some((c, i) => c.key === 'csmeUnconfigure' && this.eraseCapsArray.at(i)?.value === true) + } + if (req.secureEraseAllSSDs && this.isSsdEncrypted()) { + req.ssdPassword = this.ssdPasswordControl.value ?? '' + } + this.isLoading.set(true) + this.devicesService + .setRemoteEraseOptions(this.deviceId(), req) + .pipe( + finalize(() => this.isLoading.set(false)), + catchError((err) => { + const msg: string = err.error?.message || this.t('remotePlatformErase.eraseError') + this.displayError(msg) + return EMPTY + }) + ) + .subscribe({ + next: () => { + if (req.unconfigureCSME) { + this.devicesService + .sendDeactivate(this.deviceId()) + .pipe(catchError(() => EMPTY)) + .subscribe(() => { + this.snackBar.open( + this.t('remotePlatformErase.csmeEraseSuccess'), + undefined, + SnackbarDefaults.defaultSuccess + ) + void this.router.navigate(['/devices']) + }) + } else { + const successMsg = req.secureEraseAllSSDs + ? this.t('remotePlatformErase.osEraseSuccess') + : this.t('remotePlatformErase.eraseSuccess') + this.snackBar.open(successMsg, undefined, SnackbarDefaults.defaultSuccess) + const payload: AMTFeaturesRequest = { + userConsent: this.amtFeatures()?.userConsent ?? '', + enableKVM: this.amtFeatures()?.KVM ?? false, + enableSOL: this.amtFeatures()?.SOL ?? false, + enableIDER: this.amtFeatures()?.IDER ?? false, + ocr: this.amtFeatures()?.ocr ?? false, + rpe: true + } + this.devicesService + .setAmtFeatures(this.deviceId(), payload) + .pipe(catchError(() => EMPTY)) + .subscribe() + + // Preserve selected operations after completion and refresh computed UI state. + this.onCapChange() + } + } + }) + } + + private updateCapControlStates(): void { + const featureEnabled = this.platformEraseEnabled() + const csmeSelected = this.isCsmeExclusiveSelected() + this.eraseCaps().forEach((cap, i) => { + const ctrl = this.eraseCapsArray.at(i) + const blockedByCsme = csmeSelected && i !== this.csmeIndex + const shouldEnable = cap.supported && featureEnabled && !blockedByCsme + if (shouldEnable) { + ctrl.enable({ emitEvent: false }) + } else { + ctrl.disable({ emitEvent: false }) + } + }) + if (!featureEnabled && this.isSsdSelected()) { + this.resetSsdControls() + } + } + + checkUserConsent(): Observable { + if ( + this.amtFeatures()?.userConsent === 'none' || + this.amtFeatures()?.optInState === 3 || + this.amtFeatures()?.optInState === 4 + ) { + this.readyToErase = true + // Auto-connect when user consent is not required + this.deviceConnection.set(true) + return of(true) + } + this.readyToErase = false + return of(false) + } + + private resetSsdControls(): void { + this.isSsdSelected.set(false) + this.isSsdEncrypted.set(false) + this.ssdEncryptedControl.setValue(false) + this.ssdPasswordControl.setValue('') + } + + private validateSsdPasswordBytes(control: AbstractControl): ValidationErrors | null { + const value = control.value as string | null + if (!value) { + return null + } + const byteLength = new TextEncoder().encode(value).length + if (byteLength > SSD_PASSWORD_MAX_BYTES) { + return { ssdPasswordTooLong: { maxBytes: SSD_PASSWORD_MAX_BYTES, actualBytes: byteLength } } + } + return null + } + + displayError(message: string): void { + this.snackBar.open(message, undefined, SnackbarDefaults.defaultError) + } + + private t(key: string): string { + return this.translate.instant(`${key}.value`) as string + } +} diff --git a/src/app/devices/remote-platform-erase/remote-platform-erase.constants.ts b/src/app/devices/remote-platform-erase/remote-platform-erase.constants.ts new file mode 100644 index 000000000..affd2ff3a --- /dev/null +++ b/src/app/devices/remote-platform-erase/remote-platform-erase.constants.ts @@ -0,0 +1,26 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2022 + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +/** + * Bitmask definitions for AMT_BootCapabilities.PlatformErase. + * Capabilities vary by AMT/CSME version. + * Reference: https://software.intel.com/sites/manageability/AMT_Implementation_and_Reference_Guide/ + * default.htm?turl=HTMLDocuments%2FWS-Management_Class_Reference%2FAMT_BootCapabilities.htm%23PlatformErase + */ +export interface PlatformEraseCapability { + key: string +} + +export const PLATFORM_ERASE_CAPABILITIES: PlatformEraseCapability[] = [ + { key: 'secureEraseSsds' }, + { key: 'tpmClear' }, + { key: 'biosRestore' }, + { key: 'csmeUnconfigure' } +] + +export interface ParsedPlatformEraseCapability { + key: string + supported: boolean +} diff --git a/src/app/devices/sol/sol.component.spec.ts b/src/app/devices/sol/sol.component.spec.ts index 8f1c3cc9c..4cd642a4f 100644 --- a/src/app/devices/sol/sol.component.spec.ts +++ b/src/app/devices/sol/sol.component.spec.ts @@ -63,7 +63,8 @@ describe('SolComponent', () => { ocr: true, winREBootSupported: true, localPBABootSupported: true, - remoteErase: true, + rpeSupported: true, + rpe: true, pbaBootFilesPath: [], winREBootFilesPath: { instanceID: '', @@ -85,7 +86,8 @@ describe('SolComponent', () => { ocr: true, winREBootSupported: true, localPBABootSupported: true, - remoteErase: true, + rpeSupported: true, + rpe: true, pbaBootFilesPath: [], winREBootFilesPath: { instanceID: '', @@ -273,7 +275,8 @@ describe('SolComponent', () => { ocr: true, winREBootSupported: true, localPBABootSupported: true, - remoteErase: true, + rpeSupported: true, + rpe: true, pbaBootFilesPath: [], winREBootFilesPath: { instanceID: '', @@ -320,7 +323,8 @@ describe('SolComponent', () => { ocr: true, winREBootSupported: true, localPBABootSupported: true, - remoteErase: true, + rpeSupported: true, + rpe: true, pbaBootFilesPath: [], winREBootFilesPath: { instanceID: '', @@ -361,7 +365,8 @@ describe('SolComponent', () => { ocr: true, winREBootSupported: true, localPBABootSupported: true, - remoteErase: true, + rpeSupported: true, + rpe: true, pbaBootFilesPath: [], winREBootFilesPath: { instanceID: '', @@ -388,7 +393,8 @@ describe('SolComponent', () => { ocr: true, winREBootSupported: true, localPBABootSupported: true, - remoteErase: true, + rpeSupported: true, + rpe: true, pbaBootFilesPath: [], winREBootFilesPath: { instanceID: '', @@ -417,7 +423,8 @@ describe('SolComponent', () => { ocr: true, winREBootSupported: true, localPBABootSupported: true, - remoteErase: true, + rpeSupported: true, + rpe: true, pbaBootFilesPath: [], winREBootFilesPath: { instanceID: '', @@ -447,7 +454,8 @@ describe('SolComponent', () => { ocr: true, winREBootSupported: true, localPBABootSupported: true, - remoteErase: true, + rpeSupported: true, + rpe: true, pbaBootFilesPath: [], winREBootFilesPath: { instanceID: '', @@ -476,7 +484,8 @@ describe('SolComponent', () => { ocr: true, winREBootSupported: true, localPBABootSupported: true, - remoteErase: true, + rpeSupported: true, + rpe: true, pbaBootFilesPath: [], winREBootFilesPath: { instanceID: '', @@ -505,7 +514,8 @@ describe('SolComponent', () => { ocr: true, winREBootSupported: true, localPBABootSupported: true, - remoteErase: true, + rpeSupported: true, + rpe: true, pbaBootFilesPath: [], winREBootFilesPath: { instanceID: '', diff --git a/src/app/devices/sol/sol.component.ts b/src/app/devices/sol/sol.component.ts index 22921b929..76c1eac02 100644 --- a/src/app/devices/sol/sol.component.ts +++ b/src/app/devices/sol/sol.component.ts @@ -202,7 +202,7 @@ export class SolComponent implements OnInit, OnDestroy { enableSOL: true, enableIDER: this.amtFeatures()?.IDER ?? false, ocr: this.amtFeatures()?.ocr ?? false, - remoteErase: this.amtFeatures()?.remoteErase ?? false + rpe: this.amtFeatures()?.rpe ?? false } return this.devicesService.setAmtFeatures(this.deviceId(), payload) } diff --git a/src/app/devices/user-consent.service.spec.ts b/src/app/devices/user-consent.service.spec.ts index f876dd030..0a58b19c6 100644 --- a/src/app/devices/user-consent.service.spec.ts +++ b/src/app/devices/user-consent.service.spec.ts @@ -80,7 +80,8 @@ describe('UserConsentService', () => { httpsBootSupported: false, winREBootSupported: false, localPBABootSupported: false, - remoteErase: false, + rpeSupported: true, + rpe: false, pbaBootFilesPath: [], winREBootFilesPath: { instanceID: '', diff --git a/src/app/shared/are-you-sure/are-you-sure.component.html b/src/app/shared/are-you-sure/are-you-sure.component.html index 4b7f176df..6f17d47cf 100644 --- a/src/app/shared/are-you-sure/are-you-sure.component.html +++ b/src/app/shared/are-you-sure/are-you-sure.component.html @@ -1,5 +1,7 @@

{{ 'delete.question.value' | translate }}

- {{ 'delete.questionRecord.value' | translate }} + + {{ (data?.message ?? 'delete.questionRecord') + '.value' | translate: data?.params }} +