#tearsheetStep
modal-primary-focus
[group]="group"
+ [allowAllHosts]="allowAllHosts"
[existingHosts]="existingHosts"></cd-nvmeof-subsystem-step-two>
</cd-tearsheet-step>
@if(showAuthStep) {
import { ReactiveFormsModule } from '@angular/forms';
import { RouterTestingModule } from '@angular/router/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { ActivatedRoute } from '@angular/router';
+import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { of } from 'rxjs';
{
provide: ActivatedRoute,
useValue: {
- queryParams: of({ group: 'test-group' }),
+ queryParamMap: of(convertToParamMap({ group: 'test-group' })),
params: of({ subsystem_nqn: 'nqn.test' }),
parent: {
params: of({ subsystem_nqn: 'nqn.test' })
expect(component).toBeTruthy();
});
+ it('should set allowAllHosts to true when disableAllowAll is not set', () => {
+ const router = TestBed.inject(Router);
+ spyOn(router, 'getCurrentNavigation').and.returnValue(null);
+ component.ngOnInit();
+ expect(component.allowAllHosts).toBe(true);
+ });
+
+ it('should set allowAllHosts to false when disableAllowAll is true in navigation state', () => {
+ const router = TestBed.inject(Router);
+ spyOn(router, 'getCurrentNavigation').and.returnValue({
+ extras: { state: { disableAllowAll: true } }
+ } as any);
+ component.ngOnInit();
+ expect(component.allowAllHosts).toBe(false);
+ });
+
it('should initialize with two steps (Host access control + Authentication optional)', () => {
expect(component.steps.length).toBe(2);
expect(component.steps[0].label).toBe('Host access control');
NvmeofSubsystemInitiator
} from '~/app/shared/models/nvmeof';
import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
-import { ActivatedRoute, Router } from '@angular/router';
+import { ActivatedRoute, Params, Router } from '@angular/router';
import { TearsheetComponent } from '~/app/shared/components/tearsheet/tearsheet.component';
type InitiatorsFormPayload = Pick<HostStepType, 'hostType' | 'addedHosts'> &
isSubmitLoading = false;
existingHosts: string[] = [];
showAuthStep = true;
+ allowAllHosts = true;
stepTwoValue: HostStepType = null;
@ViewChild(TearsheetComponent) tearsheet!: TearsheetComponent;
) {}
ngOnInit() {
- this.route.queryParams.subscribe((params) => {
- this.group = params?.['group'];
+ this.route.queryParamMap.subscribe((params) => {
+ this.group = params.get('group');
});
- this.route.parent.params.subscribe((params: any) => {
+ this.allowAllHosts = !this.getDisableAllowAllState();
+ this.route.parent.params.subscribe((params: Params) => {
if (params.subsystem_nqn) {
this.subsystemNQN = params.subsystem_nqn;
}
});
- this.route.params.subscribe((params: any) => {
+ this.route.params.subscribe((params: Params) => {
if (!this.subsystemNQN && params.subsystem_nqn) {
this.subsystemNQN = params.subsystem_nqn;
}
this.rebuildSteps();
}
+ private getDisableAllowAllState(): boolean {
+ return this.router.getCurrentNavigation()?.extras?.state?.['disableAllowAll'] === true;
+ }
+
rebuildSteps() {
const steps: Step[] = [{ label: STEP_LABELS.HOSTS, invalid: false }];
-@if (hasAllHostsAllowed()) {
+@if (allowAllHosts) {
<cd-alert-panel
class="cds-mb-4"
- type="warning"
+ type="info"
[showTitle]="false">
- <div cdsStack="vertical"
- gap="1">
- <strong class="cds-mb-1"
- i18n>All hosts allowed</strong>
- <p class="cds-mb-0"
- i18n>
- Allowing all hosts grants access to every initiator on the network. Authentication is not supported in this mode, which may expose the subsystem to unauthorized access.
- </p>
+ <div cdsStack="horizontal"
+ gap="2">
+ <div cdsStack="vertical"
+ gap="2">
+ <strong i18n>Host access: All hosts allowed</strong>
+ <p
+ class="cds-mb-0 cds-mt-1"
+ i18n
+ >
+ Allowing all hosts grants access to every initiator on the network. Authentication is not supported in this mode, which may expose the subsystem to unauthorized access.
+ </p>
+ </div>
+ <a cdsLink
+ class="cds-ml-3"
+ (click)="openAddInitiatorForm(true)"
+ i18n>Edit host access</a>
</div>
</cd-alert-panel>
+} @else {
+ <cd-table [data]="initiators"
+ columnMode="flex"
+ (fetchData)="listInitiators()"
+ [columns]="initiatorColumns"
+ selectionType="multiClick"
+ (updateSelection)="updateSelection($event)">
+ <div class="table-actions">
+ <cd-table-actions [permission]="permission"
+ [selection]="selection"
+ class="btn-group"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+ </div>
+ </cd-table>
}
-<cd-table [data]="initiators"
- columnMode="flex"
- (fetchData)="listInitiators()"
- [columns]="initiatorColumns"
- selectionType="multiClick"
- (updateSelection)="updateSelection($event)">
- <div class="table-actions">
- <cd-table-actions [permission]="permission"
- [selection]="selection"
- class="btn-group"
- [tableActions]="tableActions">
- </cd-table-actions>
- </div>
-</cd-table>
-
<ng-template #hostNqnTpl
let-row="data.row">
{{ getDisplayedHostNqn(row.nqn) }}
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { HttpClientModule } from '@angular/common/http';
+import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
-import { of } from 'rxjs';
+import { of, Subject } from 'rxjs';
import { SharedModule } from '~/app/shared/shared.module';
import { NvmeofService } from '~/app/shared/api/nvmeof.service';
import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
-import { ModalService } from '~/app/shared/services/modal.service';
+import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
import { ALLOW_ALL_HOST } from '~/app/shared/models/nvmeof';
const mockInitiators = [
{
- nqn: ALLOW_ALL_HOST,
+ nqn: 'nqn.2016-06.io.spdk:host1',
use_dhchap: false
}
];
const mockSubsystem = {
nqn: 'nqn.2016-06.io.spdk:cnode1',
serial_number: '12345',
- has_dhchap_key: false
+ has_dhchap_key: false,
+ allow_any_host: false
};
class MockNvmeOfService {
}
}
-class MockModalService {}
+class MockModalCdsService {}
class MockTaskWrapperService {}
describe('NvmeofInitiatorsListComponent', () => {
let component: NvmeofInitiatorsListComponent;
let fixture: ComponentFixture<NvmeofInitiatorsListComponent>;
+ let nvmeofService: NvmeofService;
+ let routeParams$: Subject<any>;
+ let queryParams$: Subject<any>;
+ let routerEvents$: Subject<any>;
beforeEach(async () => {
+ routeParams$ = new Subject<any>();
+ queryParams$ = new Subject<any>();
+ routerEvents$ = new Subject<any>();
+
await TestBed.configureTestingModule({
declarations: [NvmeofInitiatorsListComponent],
imports: [HttpClientModule, RouterTestingModule, SharedModule],
providers: [
{ provide: NvmeofService, useClass: MockNvmeOfService },
{ provide: AuthStorageService, useClass: MockAuthStorageService },
- { provide: ModalService, useClass: MockModalService },
- { provide: TaskWrapperService, useClass: MockTaskWrapperService }
+ { provide: ModalCdsService, useClass: MockModalCdsService },
+ { provide: TaskWrapperService, useClass: MockTaskWrapperService },
+ {
+ provide: ActivatedRoute,
+ useValue: {
+ parent: { params: routeParams$.asObservable() },
+ queryParams: queryParams$.asObservable()
+ }
+ },
+ {
+ provide: Router,
+ useValue: {
+ events: routerEvents$.asObservable(),
+ navigate: jasmine.createSpy('navigate')
+ }
+ }
]
}).compileComponents();
+ });
+ beforeEach(() => {
+ nvmeofService = TestBed.inject(NvmeofService);
fixture = TestBed.createComponent(NvmeofInitiatorsListComponent);
component = fixture.componentInstance;
component.subsystemNQN = 'nqn.2016-06.io.spdk:cnode1';
expect(component.getDisplayedHostNqn(ALLOW_ALL_HOST)).toBe('Any');
}));
+ it('should default allowAllHosts to false', () => {
+ expect(component.allowAllHosts).toBe(false);
+ });
+
+ it('should set allowAllHosts to true when subsystem allows any host and no initiators', fakeAsync(() => {
+ const allowAllSubsystem = { ...mockSubsystem, allow_any_host: true };
+ spyOn(nvmeofService, 'getInitiators').and.returnValue(of([]));
+ spyOn(nvmeofService, 'getSubsystem').and.returnValue(of(allowAllSubsystem));
+ component.listInitiators();
+ component.getSubsystem();
+ tick();
+ expect(component.allowAllHosts).toBe(true);
+ }));
+
it('should update authStatus when initiator has dhchap_key', fakeAsync(() => {
const initiatorsWithKey = [{ nqn: 'nqn1', use_dhchap: true }];
- spyOn(TestBed.inject(NvmeofService), 'getInitiators').and.returnValue(of(initiatorsWithKey));
+ spyOn(nvmeofService, 'getInitiators').and.returnValue(of(initiatorsWithKey));
component.listInitiators();
tick();
expect(component.authStatus).toBe('Unidirectional');
const initiatorsWithKey = [{ nqn: 'nqn1', use_dhchap: true }];
component.initiators = initiatorsWithKey;
const subsystemWithKey = { ...mockSubsystem, has_dhchap_key: true };
- spyOn(TestBed.inject(NvmeofService), 'getSubsystem').and.returnValue(of(subsystemWithKey));
+ spyOn(nvmeofService, 'getSubsystem').and.returnValue(of(subsystemWithKey));
component.getSubsystem();
tick();
expect(component.authStatus).toBe('Bi-directional');
}));
+
+ it('should fetch only when both route and query params are available', () => {
+ const newFixture = TestBed.createComponent(NvmeofInitiatorsListComponent);
+ const newComponent = newFixture.componentInstance;
+ newComponent.subsystemNQN = undefined;
+ newComponent.group = undefined;
+ const listInitiatorsSpy = spyOn(newComponent, 'listInitiators');
+ const getSubsystemSpy = spyOn(newComponent, 'getSubsystem');
+
+ newComponent.ngOnInit();
+
+ routeParams$.next({ subsystem_nqn: 'nqn.from.route' });
+ expect(listInitiatorsSpy).not.toHaveBeenCalled();
+ expect(getSubsystemSpy).not.toHaveBeenCalled();
+
+ queryParams$.next({ group: 'group-from-query' });
+ expect(listInitiatorsSpy).toHaveBeenCalledTimes(1);
+ expect(getSubsystemSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('should refresh on non-modal navigation changes', () => {
+ const newFixture = TestBed.createComponent(NvmeofInitiatorsListComponent);
+ const newComponent = newFixture.componentInstance;
+ newComponent.subsystemNQN = 'nqn.2016-06.io.spdk:cnode1';
+ newComponent.group = 'group1';
+ const listInitiatorsSpy = spyOn(newComponent, 'listInitiators');
+ const getSubsystemSpy = spyOn(newComponent, 'getSubsystem');
+
+ newComponent.ngOnInit();
+ routerEvents$.next(new NavigationEnd(1, '/nvmeof/(modal:add)', '/nvmeof/(modal:add)'));
+ expect(listInitiatorsSpy).toHaveBeenCalledTimes(1);
+ expect(getSubsystemSpy).toHaveBeenCalledTimes(1);
+
+ routerEvents$.next(new NavigationEnd(2, '/nvmeof/subsystem', '/nvmeof/subsystem'));
+ expect(listInitiatorsSpy).toHaveBeenCalledTimes(2);
+ expect(getSubsystemSpy).toHaveBeenCalledTimes(2);
+ });
+
+ it('should filter ALLOW_ALL_HOST from array response', fakeAsync(() => {
+ spyOn(nvmeofService, 'getInitiators').and.returnValue(
+ of([
+ { nqn: ALLOW_ALL_HOST, use_dhchap: false },
+ { nqn: 'nqn.2016-06.io.spdk:host2', use_dhchap: false }
+ ])
+ );
+
+ component.listInitiators();
+ tick();
+
+ expect(component.initiators).toEqual([{ nqn: 'nqn.2016-06.io.spdk:host2', use_dhchap: false }]);
+ }));
+
+ it('should support hosts-wrapper response from getInitiators', fakeAsync(() => {
+ spyOn(nvmeofService, 'getInitiators').and.returnValue(
+ of({
+ hosts: [
+ { nqn: ALLOW_ALL_HOST, use_dhchap: false },
+ { nqn: 'nqn.2016-06.io.spdk:host3', use_dhchap: true }
+ ]
+ })
+ );
+
+ component.listInitiators();
+ tick();
+
+ expect(component.initiators).toEqual([{ nqn: 'nqn.2016-06.io.spdk:host3', use_dhchap: true }]);
+ }));
});
-import { Component, Input, OnInit, TemplateRef, ViewChild } from '@angular/core';
-import { ActivatedRoute, Router } from '@angular/router';
+import { Component, Input, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core';
+import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
+import { Subscription } from 'rxjs';
+import { filter } from 'rxjs/operators';
import { NvmeofService } from '~/app/shared/api/nvmeof.service';
import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component';
import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
styleUrls: ['./nvmeof-initiators-list.component.scss'],
standalone: false
})
-export class NvmeofInitiatorsListComponent implements OnInit {
+export class NvmeofInitiatorsListComponent implements OnInit, OnDestroy {
@Input()
subsystemNQN: string;
@Input()
allowAllHost = ALLOW_ALL_HOST;
yesLabel = $localize`Yes`;
noLabel = $localize`No`;
+ allowAllHosts = false;
+
+ private subscriptions = new Subscription();
constructor(
public actionLabels: ActionLabelsI18n,
this.fetchIfReady();
});
} else {
+ this.listInitiators();
this.getSubsystem();
}
+ this.subscriptions.add(
+ this.router.events
+ .pipe(
+ filter(
+ (event): event is NavigationEnd =>
+ event instanceof NavigationEnd && !event.urlAfterRedirects.includes('(modal:')
+ )
+ )
+ .subscribe(() => {
+ this.fetchIfReady();
+ })
+ );
+
this.initiatorColumns = [
{
name: $localize`Host NQN`,
name: this.actionLabels.ADD,
permission: 'create',
icon: Icons.add,
- click: () =>
- this.router.navigate([{ outlets: { modal: [URLVerbs.ADD, 'initiator'] } }], {
- queryParams: { group: this.group },
- relativeTo: this.route.parent
- }),
- canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
+ click: () => this.openAddInitiatorForm(),
+ canBePrimary: (selection: CdTableSelection) => !selection.hasSelection,
+ disable: () => this.hasAllHostsAllowed()
},
{
name: $localize`Edit host key`,
];
}
+ ngOnDestroy() {
+ this.subscriptions.unsubscribe();
+ }
+
private fetchIfReady() {
if (this.subsystemNQN && this.group) {
this.listInitiators();
}
}
+ openAddInitiatorForm(disableAllowAll = false) {
+ this.router.navigate([{ outlets: { modal: [URLVerbs.ADD, 'initiator'] } }], {
+ queryParams: { group: this.group },
+ state: { disableAllowAll },
+ relativeTo: this.route.parent
+ });
+ }
+
editHostKeyModal() {
const selected = this.selection.selected[0];
if (!selected) return;
- this.modalService.show(NvmeofEditHostKeyModalComponent, {
+ const modalRef = this.modalService.show(NvmeofEditHostKeyModalComponent, {
subsystemNQN: this.subsystemNQN,
hostNQN: selected.nqn,
group: this.group,
dhchapKey: selected.dhchap_key || ''
});
+ if (modalRef?.closeChange) {
+ this.subscriptions.add(
+ modalRef.closeChange.subscribe(() => {
+ this.listInitiators();
+ this.getSubsystem();
+ })
+ );
+ }
}
getAllowAllHostIndex() {
}
hasAllHostsAllowed(): boolean {
- return this.initiators.some((initiator) => initiator.nqn === ALLOW_ALL_HOST);
+ return !!this.subsystem?.allow_any_host && this.initiators.length === 0;
}
updateSelection(selection: CdTableSelection) {
.getInitiators(this.subsystemNQN, this.group)
.subscribe((response: NvmeofSubsystemInitiator[] | { hosts: NvmeofSubsystemInitiator[] }) => {
const initiators = Array.isArray(response) ? response : response?.hosts || [];
- this.initiators = initiators;
+ this.initiators = initiators.filter((i) => i.nqn !== ALLOW_ALL_HOST);
this.updateAuthStatus();
});
}
getSubsystem() {
- this.nvmeofService.getSubsystem(this.subsystemNQN, this.group).subscribe((subsystem: any) => {
- this.subsystem = subsystem;
- this.updateAuthStatus();
- });
+ this.nvmeofService
+ .getSubsystem(this.subsystemNQN, this.group)
+ .subscribe((subsystem: NvmeofSubsystem) => {
+ this.subsystem = subsystem;
+ this.updateAuthStatus();
+ });
}
updateAuthStatus() {
+ this.allowAllHosts = this.hasAllHostsAllowed();
if (this.subsystem && this.initiators) {
this.authStatus = getSubsystemAuthStatus(this.subsystem, this.initiators);
}
itemNames = [...hostNQNs, $localize`Allow any host(*)`];
}
const hostName = itemNames[0];
- this.modalService.show(DeleteConfirmationModalComponent, {
+ const deleteModalRef = this.modalService.show(DeleteConfirmationModalComponent, {
itemDescription: $localize`host`,
impact: DeletionImpact.high,
itemNames,
})
})
});
+ if (deleteModalRef?.closeChange) {
+ this.subscriptions.add(
+ deleteModalRef.closeChange.subscribe(() => {
+ this.listInitiators();
+ this.getSubsystem();
+ })
+ );
+ }
}
}
];
class MockNvmeOfService {
+ gatewayGroupsResponse: any = [[{ id: 'g1' }]];
+ namespacesResponse: any = { namespaces: mockNamespaces };
+
listGatewayGroups() {
- return of([[{ id: 'g1' }]]);
+ return of(this.gatewayGroupsResponse);
}
formatGwGroupsList(_response: any) {
}
listNamespaces(_group?: string) {
- return of({ namespaces: mockNamespaces });
+ return of(this.namespacesResponse);
}
}
let fixture: ComponentFixture<NvmeofNamespacesListComponent>;
let modalService: MockModalCdsService;
+ let nvmeofService: MockNvmeOfService;
beforeEach(async () => {
await TestBed.configureTestingModule({
component.subsystemNQN = 'nqn.2001-07.com.ceph:1721040751436';
fixture.detectChanges();
modalService = TestBed.inject(ModalCdsService) as any;
+ nvmeofService = TestBed.inject(NvmeofService) as any;
});
it('should create', () => {
expect(args.itemDescription).toBeDefined();
expect(typeof args.submitActionObservable).toBe('function');
});
+
+ it('should deduplicate namespaces by nsid and subsystem nqn', (done) => {
+ component.group = 'g1';
+ nvmeofService.namespacesResponse = {
+ namespaces: [
+ { nsid: 1, ns_subsystem_nqn: 'sub1' },
+ { nsid: 1, ns_subsystem_nqn: 'sub1' },
+ { nsid: 1, ns_subsystem_nqn: 'sub2' }
+ ]
+ };
+
+ component.namespaces$.pipe(take(1)).subscribe((namespaces) => {
+ expect(namespaces).toEqual([
+ { nsid: 1, ns_subsystem_nqn: 'sub1', unique_id: '1_sub1' },
+ { nsid: 1, ns_subsystem_nqn: 'sub2', unique_id: '1_sub2' }
+ ]);
+ done();
+ });
+
+ component.listNamespaces();
+ });
+
+ it('should default to first group and keep default placeholder when groups exist', () => {
+ component.group = null;
+ component.gwGroups = [{ content: 'g1', selected: false }] as any;
+
+ component.updateGroupSelectionState();
+
+ expect(component.group).toBe('g1');
+ expect(component.gwGroupsEmpty).toBe(false);
+ expect(component.gwGroupPlaceholder).toBe('Enter group name');
+ });
+
+ it('should set error placeholder and call preventDefault on group fetch failure', () => {
+ const preventDefault = jasmine.createSpy('preventDefault');
+
+ component.handleGatewayGroupsError({ preventDefault });
+
+ expect(component.gwGroups).toEqual([]);
+ expect(component.gwGroupsEmpty).toBe(true);
+ expect(component.gwGroupPlaceholder).toBe('Unable to fetch Gateway groups');
+ expect(preventDefault).toHaveBeenCalled();
+ });
});
orientation="vertical">
<cds-radio
[value]="HOST_TYPE.ALL"
+ [disabled]="!allowAllHosts"
class="cds-mb-2"
i18n>Allow all hosts</cds-radio>
<span class="cds--form__helper-text cds-mb-3"
export class NvmeofSubsystemsStepTwoComponent implements OnInit, TearsheetStep {
@Input() group!: string;
@Input() existingHosts: string[] = [];
+ @Input() allowAllHosts = true;
@ViewChild('rightInfluencer', { static: true })
rightInfluencer?: TemplateRef<any>;
formGroup: CdFormGroup;
csvDropText: string = $localize`Drag and drop files here or click to upload`;
NQN_REGEX = /^nqn\.(19|20)\d\d-(0[1-9]|1[0-2])\.\D{2,3}(\.[A-Za-z0-9-]+)+(:[A-Za-z0-9-\.]+(:[A-Za-z0-9-\.]+)*)$/;
NQN_REGEX_UUID = /^nqn\.2014-08\.org\.nvmexpress:uuid:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
- ALLOW_ALL_HOST = '*';
uploadedHosts = new Set<string>();
constructor(
it('should set first group as default initially', () => {
expect(component.group).toBe(mockGroups[0][0].spec.group);
});
+
+ it('should mark only current group as selected', () => {
+ component.group = 'foo';
+ component.gwGroups = [{ content: 'default' }, { content: 'foo' }] as any;
+
+ component.updateGroupSelectionState();
+
+ expect(component.gwGroups).toEqual([
+ { content: 'default', selected: false },
+ { content: 'foo', selected: true }
+ ]);
+ });
+
+ it('should clear selected group and refresh subsystem list', () => {
+ component.group = 'default';
+ const getSubsystemsSpy = spyOn(component, 'getSubsystems');
+
+ component.onGroupClear();
+
+ expect(component.group).toBeNull();
+ expect(getSubsystemsSpy).toHaveBeenCalled();
+ });
+
+ it('should set error placeholder and prevent default on groups load error', () => {
+ const preventDefault = jasmine.createSpy('preventDefault');
+ component.context = { error: jasmine.createSpy('error') } as any;
+
+ component.handleGatewayGroupsError({ preventDefault });
+
+ expect(component.gwGroups).toEqual([]);
+ expect(component.gwGroupsEmpty).toBe(true);
+ expect(component.gwGroupPlaceholder).toBe('Unable to fetch Gateway groups');
+ expect(preventDefault).toHaveBeenCalled();
+ expect(component.context.error).toHaveBeenCalled();
+ });
});