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()) {
+
+ warning
+ {{ 'remotePlatformErase.initiateWarning.value' | translate }}
+
+
+
+
+ {{ 'remotePlatformErase.featureEnabled.value' | translate }}
+
+
+ {{ 'remotePlatformErase.featureEnabledHint.value' | translate }}
+
+
+
+ @if (platformEraseEnabled()) {
+
+
+
+ checklist
+ {{ '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()) {
+
+ info
+ {{ 'remotePlatformErase.csmeUnconfigureExclusive.value' | translate }}
+
+ }
+
+
+
+ {{
+ 'remotePlatformErase.selectionCount.value'
+ | translate: { selected: selectedCapsCount(), total: supportedCapsCount() }
+ }}
+
+
+ }
+ } @else if (!isLoading()) {
+
+ info
+ {{ '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 }}
+