diff --git a/src/app/pages/data-protection/replication/replication-form/replication-form.component.spec.ts b/src/app/pages/data-protection/replication/replication-form/replication-form.component.spec.ts index 0a0217443e2..c06df2a232d 100644 --- a/src/app/pages/data-protection/replication/replication-form/replication-form.component.spec.ts +++ b/src/app/pages/data-protection/replication/replication-form/replication-form.component.spec.ts @@ -5,10 +5,13 @@ import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { MatButtonHarness } from '@angular/material/button/testing'; import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; import { MockComponents, MockInstance } from 'ng-mocks'; +import { of } from 'rxjs'; import { mockCall, mockWebsocket } from 'app/core/testing/utils/mock-websocket.utils'; import { Direction } from 'app/enums/direction.enum'; import { SnapshotNamingOption } from 'app/enums/snapshot-naming-option.enum'; import { TransportMode } from 'app/enums/transport-mode.enum'; +import helptext from 'app/helptext/data-protection/replication/replication-wizard'; +import { KeychainCredential } from 'app/interfaces/keychain-credential.interface'; import { ReplicationTask } from 'app/interfaces/replication-task.interface'; import { IxSlideInRef } from 'app/modules/ix-forms/components/ix-slide-in/ix-slide-in-ref'; import { SLIDE_IN_DATA } from 'app/modules/ix-forms/components/ix-slide-in/ix-slide-in.token'; @@ -36,6 +39,7 @@ import { ReplicationWizardComponent, } from 'app/pages/data-protection/replication/replication-wizard/replication-wizard.component'; import { DatasetService } from 'app/services/dataset-service/dataset.service'; +import { DialogService } from 'app/services/dialog.service'; import { IxSlideInService } from 'app/services/ix-slide-in.service'; import { ReplicationService } from 'app/services/replication.service'; import { WebSocketService } from 'app/services/ws.service'; @@ -50,6 +54,7 @@ describe('ReplicationFormComponent', () => { name: new FormControl('dataset'), direction: new FormControl(Direction.Pull), transport: new FormControl(TransportMode.Ssh), + sudo: new FormControl(false), }); const transportForm = new FormGroup({ ssh_credentials: new FormControl(5), @@ -119,7 +124,19 @@ describe('ReplicationFormComponent', () => { }), mockCall('replication.create'), mockCall('replication.update'), + mockCall('keychaincredential.query', [ + { + id: 123, + name: 'non-root-ssh-connection', + attributes: { + username: 'user1', + }, + }, + ] as KeychainCredential[]), ]), + mockProvider(DialogService, { + confirm: jest.fn(() => of()), + }), mockProvider(IxSlideInService), mockProvider(SnackbarService), mockProvider(IxSlideInRef), @@ -174,6 +191,7 @@ describe('ReplicationFormComponent', () => { target_dataset: '/tank/target', transport: TransportMode.Ssh, auto: true, + sudo: false, }]); expect(spectator.inject(IxSlideInRef).close).toHaveBeenCalled(); }); @@ -205,6 +223,7 @@ describe('ReplicationFormComponent', () => { target_dataset: '/tank/target', transport: TransportMode.Ssh, auto: true, + sudo: false, }, ]); expect(spectator.inject(IxSlideInRef).close).toHaveBeenCalled(); @@ -253,4 +272,25 @@ describe('ReplicationFormComponent', () => { expect(spectator.query(TargetSectionComponent).nodeProvider).toBe(localNodeProvider); })); }); + + describe('sudo enabled dialog', () => { + beforeEach(fakeAsync(() => { + spectator = createComponent(); + tick(); + loader = TestbedHarnessEnvironment.loader(spectator.fixture); + })); + + it('opens sudo enabled dialog when choosing to existing ssh credential', fakeAsync(() => { + transportForm.controls.ssh_credentials.setValue(123); + tick(); + spectator.detectChanges(); + + expect(spectator.inject(DialogService).confirm).toHaveBeenCalledWith({ + buttonText: 'Use Sudo For ZFS Commands', + hideCheckbox: true, + message: helptext.sudo_warning, + title: 'Sudo Enabled', + }); + })); + }); }); diff --git a/src/app/pages/data-protection/replication/replication-form/replication-form.component.ts b/src/app/pages/data-protection/replication/replication-form/replication-form.component.ts index 32708256431..b9dc125fa03 100644 --- a/src/app/pages/data-protection/replication/replication-form/replication-form.component.ts +++ b/src/app/pages/data-protection/replication/replication-form/replication-form.component.ts @@ -4,11 +4,13 @@ import { import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { TranslateService } from '@ngx-translate/core'; import { merge } from 'rxjs'; -import { debounceTime } from 'rxjs/operators'; +import { debounceTime, switchMap } from 'rxjs/operators'; import { Direction } from 'app/enums/direction.enum'; import { SnapshotNamingOption } from 'app/enums/snapshot-naming-option.enum'; import { TransportMode } from 'app/enums/transport-mode.enum'; +import helptext from 'app/helptext/data-protection/replication/replication-wizard'; import { CountManualSnapshotsParams } from 'app/interfaces/count-manual-snapshots.interface'; +import { KeychainSshCredentials } from 'app/interfaces/keychain-credential.interface'; import { ReplicationCreate, ReplicationTask } from 'app/interfaces/replication-task.interface'; import { WebsocketError } from 'app/interfaces/websocket-error.interface'; import { TreeNodeProvider } from 'app/modules/ix-forms/components/ix-explorer/tree-node-provider.interface'; @@ -38,6 +40,7 @@ import { DatasetService } from 'app/services/dataset-service/dataset.service'; import { DialogService } from 'app/services/dialog.service'; import { ErrorHandlerService } from 'app/services/error-handler.service'; import { IxSlideInService } from 'app/services/ix-slide-in.service'; +import { KeychainCredentialService } from 'app/services/keychain-credential.service'; import { ReplicationService } from 'app/services/replication.service'; import { WebSocketService } from 'app/services/ws.service'; @@ -62,6 +65,8 @@ export class ReplicationFormComponent implements OnInit { eligibleSnapshotsMessage = ''; isEligibleSnapshotsMessageRed = false; + isSudoDialogShown = false; + sshCredentials: KeychainSshCredentials[] = []; constructor( private ws: WebSocketService, @@ -75,6 +80,7 @@ export class ReplicationFormComponent implements OnInit { private replicationService: ReplicationService, private slideInService: IxSlideInService, private slideInRef: IxSlideInRef, + private keychainCredentials: KeychainCredentialService, @Inject(SLIDE_IN_DATA) public existingReplication: ReplicationTask, ) {} @@ -82,6 +88,7 @@ export class ReplicationFormComponent implements OnInit { this.countSnapshotsOnChanges(); this.updateExplorersOnChanges(); this.updateExplorers(); + this.listenForSudoEnabled(); if (this.existingReplication) { this.setForEdit(); @@ -278,4 +285,33 @@ export class ReplicationFormComponent implements OnInit { this.targetNodeProvider = this.isPush && !this.isLocal ? remoteProvider : localProvider; this.cdr.markForCheck(); } + + private listenForSudoEnabled(): void { + this.keychainCredentials.getSshConnections() + .pipe( + switchMap((sshCredentials) => { + this.sshCredentials = sshCredentials; + return this.transportSection.form.controls.ssh_credentials.valueChanges; + }), + untilDestroyed(this), + ) + .subscribe((credentialId: number) => { + const selectedCredential = this.sshCredentials.find((credential) => credential.id === credentialId); + const isRootUser = selectedCredential?.attributes?.username === 'root'; + + if (!selectedCredential || isRootUser || this.isSudoDialogShown) { + return; + } + + this.dialog.confirm({ + title: this.translate.instant('Sudo Enabled'), + message: helptext.sudo_warning, + hideCheckbox: true, + buttonText: this.translate.instant('Use Sudo For ZFS Commands'), + }).pipe(untilDestroyed(this)).subscribe((useSudo) => { + this.generalSection.form.controls.sudo.setValue(useSudo); + this.isSudoDialogShown = true; + }); + }); + } } diff --git a/src/app/pages/data-protection/replication/replication-wizard/steps/replication-what-and-where/replication-what-and-where.component.spec.ts b/src/app/pages/data-protection/replication/replication-wizard/steps/replication-what-and-where/replication-what-and-where.component.spec.ts index 5699a0dfc14..716825e6c23 100644 --- a/src/app/pages/data-protection/replication/replication-wizard/steps/replication-what-and-where/replication-what-and-where.component.spec.ts +++ b/src/app/pages/data-protection/replication/replication-wizard/steps/replication-what-and-where/replication-what-and-where.component.spec.ts @@ -12,6 +12,7 @@ import { EncryptionKeyFormat } from 'app/enums/encryption-key-format.enum'; import { mntPath } from 'app/enums/mnt-path.enum'; import { SnapshotNamingOption } from 'app/enums/snapshot-naming-option.enum'; import { TransportMode } from 'app/enums/transport-mode.enum'; +import helptext from 'app/helptext/data-protection/replication/replication-wizard'; import { KeychainCredential } from 'app/interfaces/keychain-credential.interface'; import { ReplicationTask } from 'app/interfaces/replication-task.interface'; import { IxSlideInRef } from 'app/modules/ix-forms/components/ix-slide-in/ix-slide-in-ref'; @@ -21,6 +22,7 @@ import { IxFormHarness } from 'app/modules/ix-forms/testing/ix-form.harness'; import { ReplicationFormComponent } from 'app/pages/data-protection/replication/replication-form/replication-form.component'; import { ReplicationWhatAndWhereComponent } from 'app/pages/data-protection/replication/replication-wizard/steps/replication-what-and-where/replication-what-and-where.component'; import { DatasetService } from 'app/services/dataset-service/dataset.service'; +import { DialogService } from 'app/services/dialog.service'; import { IxSlideInService } from 'app/services/ix-slide-in.service'; describe('ReplicationWhatAndWhereComponent', () => { @@ -48,7 +50,13 @@ describe('ReplicationWhatAndWhereComponent', () => { }, ] as ReplicationTask[]), mockCall('keychaincredential.query', [ - { id: 123, name: 'test_ssh' }, + { + id: 123, + name: 'non-root-ssh-connection', + attributes: { + username: 'user1', + }, + }, ] as KeychainCredential[]), mockCall('replication.count_eligible_manual_snapshots', { total: 0, eligible: 0 }), ]), @@ -60,6 +68,9 @@ describe('ReplicationWhatAndWhereComponent', () => { })), }), mockProvider(IxSlideInRef), + mockProvider(DialogService, { + confirm: jest.fn(() => of()), + }), { provide: SLIDE_IN_DATA, useValue: undefined }, ], }); @@ -141,6 +152,17 @@ describe('ReplicationWhatAndWhereComponent', () => { expect(matDialog.open).toHaveBeenCalled(); }); + it('opens sudo enabled dialog when choosing to existing ssh credential', async () => { + await form.fillForm({ 'Source Location': 'On a Different System' }); + await form.fillForm({ 'SSH Connection': 'non-root-ssh-connection' }); + expect(spectator.inject(DialogService).confirm).toHaveBeenCalledWith({ + 'buttonText': 'Use Sudo For ZFS Commands', + 'hideCheckbox': true, + 'message': helptext.sudo_warning, + 'title': 'Sudo Enabled', + }); + }); + it('when an existing name is entered, the "Next" button is disabled', async () => { const nextButton = await loader.getHarness(MatButtonHarness.with({ text: 'Next' }));