)
)
+ @Endpoint('PUT', '{nqn}/change_key')
+ @UpdatePermission
@EndpointDoc(
"Change subsystem inband authentication key",
parameters={
import { NvmeofGatewayGroupDeleteGuardModalComponent } from './nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component';
import { NvmeofSetupCardsComponent } from './nvmeof-setup-cards/nvmeof-setup-cards.component';
import { NvmeofGatewayGroupFilterComponent } from './nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component';
+import { NvmeofEditAuthenticationComponent } from './nvmeof-edit-authentication/nvmeof-edit-authentication.component';
@NgModule({
imports: [
NvmeofSubsystemOverviewComponent,
NvmeofSubsystemPerformanceComponent,
NvmeofTabsComponent,
- NvmeofGatewayGroupDeleteGuardModalComponent
+ NvmeofGatewayGroupDeleteGuardModalComponent,
+ NvmeofEditAuthenticationComponent
],
exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent]
--- /dev/null
+<cd-tearsheet [steps]="steps"
+ [title]="title"
+ [description]="description"
+ size="md"
+ [isSubmitLoading]="isSubmitLoading"
+ [submitButtonLabel]="'Save changes'"
+ [submitButtonLoadingLabel]="'Saving'"
+ (submitRequested)="onSubmit()"
+ (closeRequested)="closeModal()">
+ <cd-tearsheet-step>
+ @if (showAuthAlert) {
+ <cd-alert-panel type="warning"
+ title="Subsystem DHCHAP Key will be deleted permanently"
+ i18n-title
+ class="cd-nvmeof-edit-auth-downgrade-alert">
+ <span i18n>
+ Switching from bidirectional authentication to unidirectional method will remove the subsystem authentication key,
+ exposing the subsystem to unauthorised access.
+ </span>
+ </cd-alert-panel>
+ }
+ <cd-nvmeof-subsystem-step-three
+ #tearsheetStep
+ [group]="groupName"
+ [initialAuthType]="initialAuthType"
+ [stepTwoValue]="stepTwoValue">
+ </cd-nvmeof-subsystem-step-three>
+ </cd-tearsheet-step>
+</cd-tearsheet>
--- /dev/null
+import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { of, throwError } from 'rxjs';
+
+import { GridModule, InputModule, RadioModule, TagModule } from 'carbon-components-angular';
+import { SharedModule } from '~/app/shared/shared.module';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
+import { AUTHENTICATION } from '~/app/shared/models/nvmeof';
+import {
+ NvmeofEditAuthenticationComponent,
+ SUBSYSTEM_NQN_TOKEN,
+ GROUP_NAME_TOKEN
+} from './nvmeof-edit-authentication.component';
+import { NvmeofSubsystemsStepThreeComponent } from '../nvmeof-subsystems-form/nvmeof-subsystem-step-3/nvmeof-subsystem-step-3.component';
+
+describe('NvmeofEditAuthenticationComponent', () => {
+ let component: NvmeofEditAuthenticationComponent;
+ let fixture: ComponentFixture<NvmeofEditAuthenticationComponent>;
+ let nvmeofService: jest.Mocked<Pick<
+ NvmeofService,
+ 'getInitiators' | 'getSubsystem' | 'updateAuthenticationKey'
+ >>;
+ let notificationService: { show: jest.Mock };
+ let modalService: { dismissAll: jest.Mock };
+
+ const mockSubsystemNQN = 'nqn.2014-08.org.nvmexpress:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6';
+ const mockGroupName = 'default';
+ const mockHosts = [
+ { nqn: 'nqn.2014-08.org.nvmexpress:uuid:host-1', use_dhchap: false },
+ { nqn: 'nqn.2014-08.org.nvmexpress:uuid:host-2', use_dhchap: false }
+ ];
+ const mockHostsWithKey = [{ nqn: 'nqn.2014-08.org.nvmexpress:uuid:host-1', use_dhchap: true }];
+
+ beforeEach(
+ waitForAsync(() => {
+ nvmeofService = {
+ getInitiators: jest.fn().mockReturnValue(of([])),
+ getSubsystem: jest.fn().mockReturnValue(of({ has_dhchap_key: false })),
+ updateAuthenticationKey: jest.fn().mockReturnValue(of(undefined))
+ };
+ notificationService = { show: jest.fn() };
+ modalService = { dismissAll: jest.fn() };
+
+ TestBed.configureTestingModule({
+ declarations: [NvmeofEditAuthenticationComponent, NvmeofSubsystemsStepThreeComponent],
+ imports: [
+ ReactiveFormsModule,
+ HttpClientTestingModule,
+ RouterTestingModule,
+ SharedModule,
+ GridModule,
+ InputModule,
+ RadioModule,
+ TagModule
+ ],
+ providers: [
+ { provide: NvmeofService, useValue: nvmeofService },
+ { provide: NotificationService, useValue: notificationService },
+ { provide: ModalCdsService, useValue: modalService },
+ { provide: SUBSYSTEM_NQN_TOKEN, useValue: mockSubsystemNQN },
+ { provide: GROUP_NAME_TOKEN, useValue: mockGroupName }
+ ]
+ }).compileComponents();
+ })
+ );
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(NvmeofEditAuthenticationComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges(); // runs ngOnInit + ngAfterViewInit
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should inject subsystemNQN and groupName', () => {
+ expect(component.subsystemNQN).toBe(mockSubsystemNQN);
+ expect(component.groupName).toBe(mockGroupName);
+ });
+
+ it('should fetch initiators on init and build stepTwoValue with host NQNs', () => {
+ nvmeofService.getInitiators.mockReturnValue(of(mockHosts));
+ component.ngOnInit();
+ expect(nvmeofService.getInitiators).toHaveBeenCalledWith(mockSubsystemNQN, mockGroupName);
+ expect(component.stepTwoValue?.addedHosts).toEqual([
+ 'nqn.2014-08.org.nvmexpress:uuid:host-1',
+ 'nqn.2014-08.org.nvmexpress:uuid:host-2'
+ ]);
+ });
+
+ it('should handle hosts returned as { hosts: [...] } response shape', () => {
+ nvmeofService.getInitiators.mockReturnValue(of({ hosts: mockHosts }));
+ component.ngOnInit();
+ expect(component.stepTwoValue?.addedHosts.length).toBe(2);
+ });
+
+ it('should handle empty host list', () => {
+ nvmeofService.getInitiators.mockReturnValue(of([]));
+ component.ngOnInit();
+ expect(component.stepTwoValue?.addedHosts).toEqual([]);
+ });
+
+ describe('initialAuthType pre-selection', () => {
+ it('should default to Unidirectional when subsystem has no DHCHAP key', () => {
+ expect(component.initialAuthType).toBe(AUTHENTICATION.Unidirectional);
+ expect(component.authStep.formGroup.get('authType')?.value).toBe(
+ AUTHENTICATION.Unidirectional
+ );
+ });
+
+ it('should patch form control to Bidirectional when subsystem and host both have keys', () => {
+ nvmeofService.getSubsystem.mockReturnValue(of({ has_dhchap_key: true }));
+ nvmeofService.getInitiators.mockReturnValue(of(mockHostsWithKey));
+ component.ngOnInit();
+ expect(component.initialAuthType).toBe(AUTHENTICATION.Bidirectional);
+ component.authStep.initialAuthType = component.initialAuthType;
+ expect(component.authStep.formGroup.get('authType')?.value).toBe(
+ AUTHENTICATION.Bidirectional
+ );
+ });
+
+ it('should set Unidirectional when only host has a key', () => {
+ nvmeofService.getSubsystem.mockReturnValue(of({ has_dhchap_key: false }));
+ nvmeofService.getInitiators.mockReturnValue(of(mockHostsWithKey));
+ component.ngOnInit();
+ expect(component.initialAuthType).toBe(AUTHENTICATION.Unidirectional);
+ expect(component.authStep.formGroup.get('authType')?.value).toBe(
+ AUTHENTICATION.Unidirectional
+ );
+ });
+
+ it('should set Unidirectional when no keys are present', () => {
+ nvmeofService.getSubsystem.mockReturnValue(of({ has_dhchap_key: false }));
+ nvmeofService.getInitiators.mockReturnValue(of([]));
+ component.ngOnInit();
+ expect(component.initialAuthType).toBe(AUTHENTICATION.Unidirectional);
+ });
+ });
+
+ describe('showAuthAlert', () => {
+ it('should be false by default', () => {
+ expect(component.showAuthAlert).toBe(false);
+ });
+
+ it('should remain false when subsystem has no DHCHAP key and radio changes to Unidirectional', () => {
+ nvmeofService.getSubsystem.mockReturnValue(of({ has_dhchap_key: false }));
+ component.ngOnInit();
+ component.authStep.formGroup.get('authType')?.setValue(AUTHENTICATION.Unidirectional);
+ expect(component.showAuthAlert).toBe(false);
+ });
+
+ it('should remain false when subsystem has DHCHAP key and radio changes to Bidirectional', () => {
+ nvmeofService.getSubsystem.mockReturnValue(of({ has_dhchap_key: true }));
+ component.ngOnInit();
+ component.authStep.formGroup.get('authType')?.setValue(AUTHENTICATION.Bidirectional);
+ expect(component.showAuthAlert).toBe(false);
+ });
+
+ it('should become true when subsystem has DHCHAP key and radio changes to Unidirectional', () => {
+ nvmeofService.getSubsystem.mockReturnValue(of({ has_dhchap_key: true }));
+ component.ngOnInit();
+ component.authStep.formGroup.get('authType')?.setValue(AUTHENTICATION.Unidirectional);
+ expect(component.showAuthAlert).toBe(true);
+ });
+ });
+
+ describe('onSubmit', () => {
+ it('should not call updateAuthenticationKey when form is invalid', () => {
+ const form = component.authStep?.formGroup;
+ if (form) {
+ form.markAllAsTouched();
+ form.setErrors({ test: true });
+ }
+ component.onSubmit();
+ expect(nvmeofService.updateAuthenticationKey).not.toHaveBeenCalled();
+ });
+
+ it('should not call updateAuthenticationKey when authStep is absent', () => {
+ component.authStep = undefined as any;
+ component.onSubmit();
+ expect(nvmeofService.updateAuthenticationKey).not.toHaveBeenCalled();
+ });
+
+ it('should extract and submit authentication settings on valid Bidirectional form', () => {
+ const validKey = 'Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY=';
+ const form = component.authStep.formGroup;
+ form.get('authType')?.setValue(AUTHENTICATION.Bidirectional);
+ form.get('subsystemDchapKey')?.setValue(validKey);
+ component.onSubmit();
+ expect(nvmeofService.updateAuthenticationKey).toHaveBeenCalledWith(
+ mockSubsystemNQN,
+ mockGroupName,
+ expect.objectContaining({
+ authType: AUTHENTICATION.Bidirectional,
+ subsystemKey: validKey
+ })
+ );
+ });
+
+ it('should submit Unidirectional authentication without subsystem key', () => {
+ const form = component.authStep?.formGroup;
+ form?.get('authType')?.setValue(AUTHENTICATION.Unidirectional);
+ component.onSubmit();
+ expect(nvmeofService.updateAuthenticationKey).toHaveBeenCalledWith(
+ mockSubsystemNQN,
+ mockGroupName,
+ expect.objectContaining({ authType: AUTHENTICATION.Unidirectional })
+ );
+ });
+
+ it('should show notification and emit closeChange on successful submit', (done) => {
+ // closeChange fires synchronously inside onSubmit's complete handler,
+ // but dismissAll is called after — use setTimeout to assert the full chain.
+ component.closeChange.subscribe(() => {
+ setTimeout(() => {
+ expect(notificationService.show).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.stringContaining('updated'),
+ expect.anything()
+ );
+ expect(modalService.dismissAll).toHaveBeenCalled();
+ done();
+ }, 0);
+ });
+
+ const form = component.authStep?.formGroup;
+ form?.get('authType')?.setValue(AUTHENTICATION.Unidirectional);
+ component.onSubmit();
+ });
+
+ it('should mark form with submission error on API failure', (done) => {
+ const form = component.authStep?.formGroup;
+ form?.get('authType')?.setValue(AUTHENTICATION.Unidirectional);
+ nvmeofService.updateAuthenticationKey.mockReturnValue(
+ throwError(() => ({ status: 500, message: 'Server error' }))
+ );
+
+ component.onSubmit();
+
+ setTimeout(() => {
+ expect(component.isSubmitLoading).toBe(false);
+ expect(form?.hasError('cdSubmitButton')).toBe(true);
+ expect(notificationService.show).not.toHaveBeenCalled();
+ expect(modalService.dismissAll).not.toHaveBeenCalled();
+ done();
+ }, 0);
+ });
+
+ it('should clear isSubmitLoading after error', (done) => {
+ const form = component.authStep?.formGroup;
+ form?.get('authType')?.setValue(AUTHENTICATION.Unidirectional);
+ nvmeofService.updateAuthenticationKey.mockReturnValue(
+ throwError(() => new Error('Network error'))
+ );
+
+ component.onSubmit();
+
+ setTimeout(() => {
+ expect(component.isSubmitLoading).toBe(false);
+ done();
+ }, 0);
+ });
+ });
+});
--- /dev/null
+import {
+ AfterViewInit,
+ ChangeDetectorRef,
+ Component,
+ EventEmitter,
+ Inject,
+ OnInit,
+ Output,
+ ViewChild
+} from '@angular/core';
+import { FormGroup } from '@angular/forms';
+import { forkJoin } from 'rxjs';
+import { finalize } from 'rxjs/operators';
+
+import { BaseModal } from 'carbon-components-angular';
+import { AuthKeyUpdate, NvmeofService } from '~/app/shared/api/nvmeof.service';
+import {
+ AUTHENTICATION,
+ HostStepType,
+ NvmeofSubsystem,
+ NvmeofSubsystemInitiator,
+ getSubsystemAuthStatus,
+ NvmeofSubsystemAuthType
+} from '~/app/shared/models/nvmeof';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
+import { NvmeofSubsystemsStepThreeComponent } from '../nvmeof-subsystems-form/nvmeof-subsystem-step-3/nvmeof-subsystem-step-3.component';
+
+export const SUBSYSTEM_NQN_TOKEN = 'subsystemNQN';
+export const GROUP_NAME_TOKEN = 'groupName';
+
+@Component({
+ selector: 'cd-nvmeof-edit-authentication',
+ templateUrl: './nvmeof-edit-authentication.component.html',
+ styleUrls: ['./nvmeof-edit-authentication.component.scss'],
+ standalone: false
+})
+export class NvmeofEditAuthenticationComponent extends BaseModal implements OnInit, AfterViewInit {
+ @ViewChild(NvmeofSubsystemsStepThreeComponent)
+ authStep!: NvmeofSubsystemsStepThreeComponent;
+
+ @Output() closeChange = new EventEmitter<void>();
+
+ isSubmitLoading = false;
+ stepTwoValue: HostStepType | null = null;
+ subsystemHasDhchapKey = false;
+ initialAuthType: AUTHENTICATION = AUTHENTICATION.Unidirectional;
+ showAuthAlert = false;
+
+ readonly steps = [{ label: $localize`Authentication`, invalid: false }];
+ readonly title = $localize`Edit authentication`;
+ readonly description = $localize`Configure authentication to verify the identity of connecting hosts and protect the subsystem from unauthorized access.`;
+
+ constructor(
+ @Inject(SUBSYSTEM_NQN_TOKEN) public subsystemNQN: string,
+ @Inject(GROUP_NAME_TOKEN) public groupName: string,
+ private nvmeofService: NvmeofService,
+ private notificationService: NotificationService,
+ private modalCdsService: ModalCdsService,
+ private cdr: ChangeDetectorRef
+ ) {
+ super();
+ }
+
+ ngOnInit() {
+ forkJoin({
+ subsystem: this.nvmeofService.getSubsystem(this.subsystemNQN, this.groupName),
+ initiators: this.nvmeofService.getInitiators(this.subsystemNQN, this.groupName)
+ }).subscribe(({ subsystem, initiators }) => {
+ const sub = subsystem as NvmeofSubsystem;
+ this.subsystemHasDhchapKey = sub.has_dhchap_key;
+ this.initialAuthType = this.deriveAuthType(sub, initiators);
+ this.stepTwoValue = this.buildStepTwoValue(initiators);
+ });
+ }
+
+ /**
+ * @ViewChild is guaranteed to be set by ngAfterViewInit.
+ * Subscribing here ensures the authType form control exists before we attach
+ * the valueChanges listener that drives showAuthAlert.
+ */
+ ngAfterViewInit() {
+ this.authStep.formGroup.get('authType')?.valueChanges.subscribe((authType: AUTHENTICATION) => {
+ this.showAuthAlert = this.subsystemHasDhchapKey && authType === AUTHENTICATION.Unidirectional;
+ this.cdr.markForCheck();
+ });
+ }
+
+ /**
+ * Maps the current subsystem auth status string to the AUTHENTICATION enum
+ * so the form radio pre-selects the correct option on open.
+ */
+ private deriveAuthType(subsystem: NvmeofSubsystem, initiators: unknown): AUTHENTICATION {
+ const status = getSubsystemAuthStatus(
+ subsystem,
+ initiators as NvmeofSubsystemInitiator[] | { hosts?: NvmeofSubsystemInitiator[] }
+ );
+ switch (status) {
+ case NvmeofSubsystemAuthType.BIDIRECTIONAL:
+ return AUTHENTICATION.Bidirectional;
+ case NvmeofSubsystemAuthType.UNIDIRECTIONAL:
+ return AUTHENTICATION.Unidirectional;
+ default:
+ return AUTHENTICATION.Unidirectional;
+ }
+ }
+
+ private buildStepTwoValue(initiators: unknown): HostStepType {
+ const hosts = this.extractHostsFromResponse(initiators);
+ return {
+ addedHosts: hosts.map((h) => h.nqn),
+ hostname: '',
+ hostType: 'specific'
+ };
+ }
+
+ /**
+ * Handles both response shapes from getInitiators:
+ * - direct array: `NvmeofSubsystemInitiator[]`
+ * - object wrapper: `{ hosts: NvmeofSubsystemInitiator[] }`
+ */
+ private extractHostsFromResponse(response: unknown): NvmeofSubsystemInitiator[] {
+ if (!response) {
+ return [];
+ }
+ if (Array.isArray(response)) {
+ return response;
+ }
+ const hostWrapper = response as { hosts?: NvmeofSubsystemInitiator[] };
+ return hostWrapper.hosts ?? [];
+ }
+
+ onSubmit() {
+ const form = this.getValidatedForm();
+ if (!form) {
+ return;
+ }
+
+ this.isSubmitLoading = true;
+
+ const update = this.buildAuthenticationUpdate(form);
+
+ this.nvmeofService
+ .updateAuthenticationKey(this.subsystemNQN, this.groupName, update)
+ .pipe(
+ finalize(() => {
+ this.isSubmitLoading = false;
+ })
+ )
+ .subscribe({
+ error: () => {
+ form.setErrors({ cdSubmitButton: true });
+ },
+ complete: () => {
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`Authentication updated`,
+ $localize`Authentication settings for subsystem ${this.subsystemNQN} have been saved.`
+ );
+ this.closeChange.emit();
+ this.modalCdsService.dismissAll();
+ }
+ });
+ }
+
+ private getValidatedForm(): FormGroup | null {
+ const form = this.authStep?.formGroup;
+ return form && !form.invalid ? form : null;
+ }
+
+ private buildAuthenticationUpdate(form: FormGroup): AuthKeyUpdate {
+ return {
+ authType: form.get('authType')?.value,
+ subsystemKey: form.get('subsystemDchapKey')?.value,
+ hostKeyList: form.get('hostDchapKeyList')?.value
+ };
+ }
+}
i18n>Special characters are not allowed.</span>
</div>
</div>
-
<div cdsRow
class="form-item">
<div cdsCol>
<cds-checkbox id="enableCds"
- formControlName="enable_auth"
+ formControlName="enableEncryption"
i18n>Enable encryption
</cds-checkbox>
</div>
</div>
- @if (groupForm.controls.enable_auth.value) {
+ @if (groupForm.controls.enableEncryption.value) {
<div cdsRow
class="form-item">
<div cdsCol>
cdValidate
#encryptionConfigRef="cdValidate"
[invalid]="encryptionConfigRef.isInvalid"
- formControlName="encryptionKey"
+ formControlName="encryptionConfig"
cols="100"
rows="4"></textarea>
</cds-textarea-label>
<span
class="invalid-feedback"
- *ngIf="groupForm.showError('encryptionKey', formDir, 'required')"
+ *ngIf="groupForm.showError('encryptionConfig', formDir, 'required')"
i18n>This field is required.</span>
</div>
</div>
<span class="cds--type-label-01"
i18n>Authentication</span>
<div>
- <cd-icon [type]="getStatusIcon(detail)"></cd-icon>
- <span class="cds--type-label-02">
- {{ detail.value ? 'Bi-directional' : 'No authentication' }}
- </span>
+ <cd-icon [type]="getAuthStatusIcon(detail.value.toString())"
+ class="cds-mr-3"></cd-icon>
+ <span class="cds--type-label-02">{{ detail.value }}</span>
<a cdsLink
- [routerLink]="['../hosts']"
- [queryParams]="{ group: groupName }"
+ class="cds-ml-2"
+ (click)="openEditAuthModal()"
[cdsStack]="'horizontal'"
[gap]="1">
<span i18n>Edit</span>
import { NvmeofSubsystemOverviewComponent } from './nvmeof-subsystem-overview.component';
import { NvmeofService } from '~/app/shared/api/nvmeof.service';
import { SharedModule } from '~/app/shared/shared.module';
+import { NvmeofSubsystem, NvmeofSubsystemInitiator } from '~/app/shared/models/nvmeof';
describe('NvmeofSubsystemOverviewComponent', () => {
let component: NvmeofSubsystemOverviewComponent;
let fixture: ComponentFixture<NvmeofSubsystemOverviewComponent>;
let nvmeofService: NvmeofService;
- const mockSubsystem = {
+ const mockSubsystem: NvmeofSubsystem = {
nqn: 'nqn.2016-06.io.spdk:cnode1',
serial_number: 'Ceph30487186726692',
model_number: 'Ceph bdev Controller',
network_mask: []
};
- beforeEach(async () => {
- await TestBed.configureTestingModule({
+ const defaultActivatedRoute = {
+ parent: { params: of({ subsystem_nqn: 'nqn.2016-06.io.spdk:cnode1' }) },
+ queryParams: of({ group: 'group1' })
+ };
+
+ /**
+ * Creates a TestBed configuration with custom service overrides.
+ * Avoids repeating the full module declaration in tests that need different mock data.
+ */
+ function createTestBed(
+ initiators: NvmeofSubsystemInitiator[],
+ subsystem: NvmeofSubsystem = mockSubsystem,
+ activatedRoute: object = defaultActivatedRoute
+ ): Promise<ComponentFixture<NvmeofSubsystemOverviewComponent>> {
+ return TestBed.configureTestingModule({
declarations: [NvmeofSubsystemOverviewComponent],
imports: [
HttpClientTestingModule,
GridModule
],
providers: [
+ { provide: ActivatedRoute, useValue: activatedRoute },
{
- provide: ActivatedRoute,
+ provide: NvmeofService,
useValue: {
- parent: {
- params: of({ subsystem_nqn: 'nqn.2016-06.io.spdk:cnode1' })
- },
- queryParams: of({ group: 'group1' })
+ getSubsystem: jest.fn().mockReturnValue(of(subsystem)),
+ getInitiators: jest.fn().mockReturnValue(of(initiators))
}
- },
+ }
+ ]
+ })
+ .compileComponents()
+ .then(() => {
+ const f = TestBed.createComponent(NvmeofSubsystemOverviewComponent);
+ f.detectChanges();
+ tick();
+ f.detectChanges();
+ return f;
+ });
+ }
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [NvmeofSubsystemOverviewComponent],
+ imports: [
+ HttpClientTestingModule,
+ RouterTestingModule,
+ SharedModule,
+ NgbTooltipModule,
+ TilesModule,
+ GridModule
+ ],
+ providers: [
+ { provide: ActivatedRoute, useValue: defaultActivatedRoute },
{
provide: NvmeofService,
useValue: {
- getSubsystem: jest.fn().mockReturnValue(of(mockSubsystem))
+ getSubsystem: jest.fn().mockReturnValue(of(mockSubsystem)),
+ getInitiators: jest.fn().mockReturnValue(of([]))
}
}
]
expect(component.subsystem.gw_group).toBe('gateway-prod');
}));
- it('should not fetch when subsystemNQN is missing', fakeAsync(() => {
+ it('should not fetch when subsystemNQN is missing', fakeAsync(async () => {
TestBed.resetTestingModule();
- TestBed.configureTestingModule({
- declarations: [NvmeofSubsystemOverviewComponent],
- imports: [
- HttpClientTestingModule,
- RouterTestingModule,
- SharedModule,
- NgbTooltipModule,
- TilesModule,
- GridModule
- ],
- providers: [
- {
- provide: ActivatedRoute,
- useValue: {
- parent: {
- params: of({})
- },
- queryParams: of({ group: 'group1' })
- }
- },
- {
- provide: NvmeofService,
- useValue: {
- getSubsystem: jest.fn().mockReturnValue(of(mockSubsystem))
- }
- }
- ]
- }).compileComponents();
-
- const newFixture = TestBed.createComponent(NvmeofSubsystemOverviewComponent);
- const newComponent = newFixture.componentInstance;
+ const noNqnRoute = { parent: { params: of({}) }, queryParams: of({ group: 'group1' }) };
+ const f = await createTestBed([], mockSubsystem, noNqnRoute);
const newService = TestBed.inject(NvmeofService);
- newFixture.detectChanges();
- tick();
expect(newService.getSubsystem).not.toHaveBeenCalled();
- expect(newComponent.subsystem).toBeUndefined();
+ expect(f.componentInstance.subsystem).toBeUndefined();
}));
it('should render detail labels in the template', fakeAsync(() => {
const hostAccessText = fixture.nativeElement.textContent;
expect(hostAccessText).toContain('Allow all hosts');
- expect(hostAccessText).toContain('Bi-directional');
+ // has_dhchap_key=true but no initiators with use_dhchap → No authentication
+ expect(hostAccessText).toContain('No authentication');
expect(hostAccessText).toContain('Edit');
}));
+
+ it('should display Bidirectional when subsystem and host both have keys', fakeAsync(async () => {
+ TestBed.resetTestingModule();
+ const f = await createTestBed([{ nqn: 'nqn.host-1', use_dhchap: true }]);
+ expect(f.nativeElement.textContent).toContain('Bi-directional');
+ }));
+
+ it('should display Unidirectional when only host has a key', fakeAsync(async () => {
+ TestBed.resetTestingModule();
+ const f = await createTestBed([{ nqn: 'nqn.host-1', use_dhchap: true }], {
+ ...mockSubsystem,
+ has_dhchap_key: false
+ });
+ expect(f.nativeElement.textContent).toContain('Unidirectional');
+ }));
});
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
+import { forkJoin } from 'rxjs';
+
import { NvmeofService } from '~/app/shared/api/nvmeof.service';
-import { NvmeofSubsystem } from '~/app/shared/models/nvmeof';
+import {
+ NvmeofSubsystem,
+ NvmeofSubsystemInitiator,
+ NO_AUTH,
+ getSubsystemAuthStatus
+} from '~/app/shared/models/nvmeof';
import { ICON_TYPE } from '~/app/shared/enum/icons.enum';
+import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
+import { NvmeofEditAuthenticationComponent } from '../nvmeof-edit-authentication/nvmeof-edit-authentication.component';
export interface SubsystemDetail {
label: string;
subsystem!: NvmeofSubsystem;
details: SubsystemDetail[] = [];
- constructor(private route: ActivatedRoute, private nvmeofService: NvmeofService) {}
+ constructor(
+ private route: ActivatedRoute,
+ private nvmeofService: NvmeofService,
+ private modalService: ModalCdsService
+ ) {}
ngOnInit() {
this.route.parent?.params.subscribe((params) => {
}
fetchSubsystem() {
- this.nvmeofService.getSubsystem(this.subsystemNQN, this.groupName).subscribe((subsystem) => {
+ forkJoin({
+ subsystem: this.nvmeofService.getSubsystem(this.subsystemNQN, this.groupName),
+ initiators: this.nvmeofService.getInitiators(this.subsystemNQN, this.groupName)
+ }).subscribe(({ subsystem, initiators }) => {
this.subsystem = subsystem as NvmeofSubsystem;
- this.buildDetails();
+ const initiatorList = initiators as
+ | NvmeofSubsystemInitiator[]
+ | { hosts?: NvmeofSubsystemInitiator[] };
+ this.buildDetails(getSubsystemAuthStatus(this.subsystem, initiatorList));
});
}
- private buildDetails() {
+ private buildDetails(authStatus: string) {
this.details = [
{
label: $localize`Serial number`,
},
{
label: $localize`Authentication`,
- value: this.subsystem.has_dhchap_key,
+ value: authStatus,
type: 'auth',
row: 2
},
return String(value);
}
+ getAuthStatusIcon(authStatus: string): keyof typeof ICON_TYPE {
+ return authStatus === NO_AUTH ? 'error' : 'success';
+ }
+
getStatusIcon(detail: SubsystemDetail): keyof typeof ICON_TYPE {
return detail.value ? 'success' : 'error';
}
const needed = 3 - this.getDetailsForRow(row).length;
return Array.from({ length: needed });
}
+
+ openEditAuthModal() {
+ const modalRef = this.modalService.show(NvmeofEditAuthenticationComponent, {
+ subsystemNQN: this.subsystemNQN,
+ groupName: this.groupName
+ });
+ if (modalRef?.closeChange) {
+ modalRef.closeChange.subscribe(() => this.fetchSubsystem());
+ }
+ }
}
})
export class NvmeofSubsystemsStepThreeComponent implements OnInit, TearsheetStep {
@Input() group!: string;
+ @Input() set initialAuthType(value: AUTHENTICATION) {
+ this._initialAuthType = value;
+ if (this.formGroup) {
+ this.formGroup.get('authType')?.setValue(value, { emitEvent: false });
+ this.refreshHostKeyValidation();
+ }
+ }
@Input() set stepTwoValue(value: HostStepType | null) {
this._addedHosts = value?.addedHosts ?? [];
if (this.formGroup) {
};
AUTHENTICATION = AUTHENTICATION;
+ _initialAuthType: AUTHENTICATION = AUTHENTICATION.Unidirectional;
_addedHosts: Array<string> = [];
constructor(public actionLabels: ActionLabelsI18n) {}
private createForm() {
this.formGroup = new CdFormGroup({
- authType: new UntypedFormControl(AUTHENTICATION.Unidirectional),
+ authType: new UntypedFormControl(this._initialAuthType),
subsystemDchapKey: new UntypedFormControl(null, [
CdValidators.base64(),
CdValidators.requiredIf({
import { Observable, forkJoin, of as observableOf } from 'rxjs';
import { catchError, map, mapTo, mergeMap, switchMap } from 'rxjs/operators';
import { CephServiceSpec } from '../models/service.interface';
-import { ListenerItem, NvmeofSubsystem, NvmeofSubsystemNamespace } from '../models/nvmeof';
+import {
+ AUTHENTICATION,
+ ListenerItem,
+ NvmeofSubsystem,
+ NvmeofSubsystemNamespace
+} from '../models/nvmeof';
import { HostService } from './host.service';
import { OrchestratorService } from './orchestrator.service';
import { HostStatus } from '../enum/host-status.enum';
subsystem_nqn: string;
};
+export type AuthKeyUpdate = {
+ authType: AUTHENTICATION;
+ subsystemKey: string | null;
+ hostKeyList: Array<{ host_nqn: string; dhchap_key: string | null }>;
+};
+
const API_PATH = 'api/nvmeof';
const UI_API_PATH = 'ui-api/nvmeof';
});
}
+ changeSubsystemKey(subsystemNQN: string, dhchapKey: string, gwGroup: string) {
+ return this.http.put(
+ `${API_PATH}/subsystem/${subsystemNQN}/change_key`,
+ { dhchap_key: dhchapKey, gw_group: gwGroup },
+ { observe: 'response' }
+ );
+ }
+
updateHostKey(subsystemNQN: string, request: InitiatorRequest) {
return this.http.put(
`${API_PATH}/subsystem/${subsystemNQN}/host/${request.host_nqn}/change_key`,
);
}
+ updateAuthenticationKey(
+ subsystemNQN: string,
+ gwGroup: string,
+ update: AuthKeyUpdate
+ ): Observable<void> {
+ const { authType, subsystemKey, hostKeyList } = update;
+
+ const subsystemKeyCall =
+ authType === AUTHENTICATION.Bidirectional && subsystemKey
+ ? this.changeSubsystemKey(subsystemNQN, subsystemKey, gwGroup)
+ : observableOf(null);
+
+ const hostKeyCalls = hostKeyList
+ .filter((item) => !!item.dhchap_key)
+ .map((item) =>
+ this.updateHostKey(subsystemNQN, {
+ host_nqn: item.host_nqn,
+ dhchap_key: item.dhchap_key,
+ gw_group: gwGroup
+ }).pipe(catchError(() => observableOf(null)))
+ );
+
+ return forkJoin([subsystemKeyCall, ...hostKeyCalls]).pipe(map(() => undefined));
+ }
+
removeInitiators(subsystemNQN: string, request: InitiatorRequest) {
return this.http.delete(
`${UI_API_PATH}/subsystem/${subsystemNQN}/host/${request.host_nqn}/${request.gw_group}`,
summary: Get information from a specific NVMeoF subsystem
tags:
- NVMe-oF Subsystem
+ /api/nvmeof/subsystem/{nqn}/change_key:
+ put:
+ parameters:
+ - description: NVMeoF subsystem NQN
+ in: path
+ name: nqn
+ required: true
+ schema:
+ type: string
+ requestBody:
+ content:
+ application/json:
+ schema:
+ properties:
+ dhchap_key:
+ description: Subsystem DH-HMAC-CHAP key
+ type: string
+ gw_group:
+ description: NVMeoF gateway group
+ type: string
+ server_address:
+ description: NVMeoF gateway address
+ type: string
+ traddr:
+ description: NVMeoF gateway address (deprecated)
+ type: string
+ required:
+ - dhchap_key
+ type: object
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ type: object
+ application/vnd.ceph.api.v1.0+json:
+ schema:
+ type: object
+ description: Resource updated.
+ '202':
+ content:
+ application/json:
+ schema:
+ type: object
+ application/vnd.ceph.api.v1.0+json:
+ schema:
+ type: object
+ description: Operation is still executing. Please check the task queue.
+ '400':
+ description: Operation exception. Please check the response body for details.
+ '401':
+ description: Unauthenticated access. Please login first.
+ '403':
+ description: Unauthorized access. Please check your permissions.
+ '500':
+ description: Unexpected error. Please check the response body for the stack
+ trace.
+ security:
+ - jwt: []
+ summary: Change subsystem inband authentication key
+ tags:
+ - NVMe-oF Subsystem
/api/nvmeof/subsystem/{nqn}/connection:
get:
parameters: