Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,22 @@
}
</mat-form-field>
</div>

<mat-accordion>
<mat-expansion-panel [expanded]="keySettingsExpanded" data-testid="vendor-key-settings-panel">
<mat-expansion-panel-header>
<mat-panel-title>JWT / Authentication</mat-panel-title>
</mat-expansion-panel-header>

<div class="form-input">
<mat-form-field appearance="outline" class="subject-input form-input ">
<mat-label>Key Vault Secret ID</mat-label>
<input matInput formControlName="secretId" [readonly]="viewOnly"
data-testid="vendor-secret-id-input">
<mat-hint>Name of the Key Vault secret holding the PEM signing key.</mat-hint>
</mat-form-field>
</div>
</mat-expansion-panel>
</mat-accordion>
</form>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatDialogModule } from '@angular/material/dialog';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { of, throwError } from 'rxjs';

import { VendorConfigFormComponent } from './vendor-config-form.component';
import { VendorService } from '../../../services/gateway/vendor/vendor.service';
import { FormMode } from '../../../models/FormMode.enum';
import { IVendorConfigModel } from '../../../interfaces/vendor/vendor-config-model.interface';

describe('VendorConfigFormComponent', () => {
let component: VendorConfigFormComponent;
let fixture: ComponentFixture<VendorConfigFormComponent>;
let vendorService: jasmine.SpyObj<VendorService>;

const epic: IVendorConfigModel = { id: 'vendor-1', name: 'Epic', secretId: 'epic-signing-pem' };
const cerner: IVendorConfigModel = { id: 'vendor-2', name: 'Cerner' };

beforeEach(async () => {
vendorService = jasmine.createSpyObj<VendorService>('VendorService', ['createVendor', 'updateVendor']);

await TestBed.configureTestingModule({
imports: [VendorConfigFormComponent, NoopAnimationsModule, MatDialogModule, MatSnackBarModule],
providers: [{ provide: VendorService, useValue: vendorService }]
}).compileComponents();

fixture = TestBed.createComponent(VendorConfigFormComponent);
component = fixture.componentInstance;
});

/** ngOnInit reads `item`, so the inputs have to be set before the first change detection. */
function initWith(item: IVendorConfigModel | undefined, formMode: FormMode): void {
component.item = item as IVendorConfigModel;
component.formMode = formMode;
fixture.detectChanges();
}

it('populates the secret id from the item in Edit mode', () => {
initWith(epic, FormMode.Edit);

expect(component.name.value).toBe('Epic');
expect(component.secretId.value).toBe('epic-signing-pem');
});

it('expands the key settings panel only when a secret id is already set', () => {
initWith(epic, FormMode.Edit);
expect(component.keySettingsExpanded).toBeTrue();

// A second component, because keySettingsExpanded is decided once in ngOnInit.
const bare = TestBed.createComponent(VendorConfigFormComponent);
bare.componentInstance.item = cerner;
bare.componentInstance.formMode = FormMode.Edit;
bare.detectChanges();

expect(bare.componentInstance.keySettingsExpanded).toBeFalse();
});

it('requires a name but not a secret id', () => {
initWith(cerner, FormMode.Edit);

expect(component.vendorForm.valid).toBeTrue();

component.name.setValue('');
expect(component.vendorForm.valid).toBeFalse();
});

it('sends the secret id when updating', () => {
vendorService.updateVendor.and.returnValue(of({ success: true, message: '' }));
initWith(cerner, FormMode.Edit);

component.secretId.setValue('cerner-signing-pem');
component.submitConfiguration();

expect(vendorService.updateVendor).toHaveBeenCalledWith(
jasmine.objectContaining({ id: 'vendor-2', name: 'Cerner', secretId: 'cerner-signing-pem' })
);
});

it('clears the association by sending an explicit null, not undefined or ""', () => {
vendorService.updateVendor.and.returnValue(of({ success: true, message: '' }));
initWith(epic, FormMode.Edit);

component.secretId.setValue(' ');
component.submitConfiguration();

const sent = vendorService.updateVendor.calls.mostRecent().args[0];
expect(sent.secretId).toBeNull();
// Undefined would be dropped by JSON.stringify, leaving the field absent from the request
// body -- indistinguishable from "leave it alone" for a partial-update endpoint.
expect('secretId' in sent).toBeTrue();
expect(JSON.parse(JSON.stringify(sent)).secretId).toBeNull();
});

it('emits success after a saved update', () => {
vendorService.updateVendor.and.returnValue(of({ success: true, message: '' }));
initWith(epic, FormMode.Edit);

const outcomes: boolean[] = [];
component.submittedConfiguration.subscribe(o => outcomes.push(o.success));

component.submitConfiguration();

expect(outcomes).toEqual([true]);
});

it('emits failure without closing when the update fails', () => {
vendorService.updateVendor.and.returnValue(throwError(() => new Error('boom')));
initWith(epic, FormMode.Edit);

const outcomes: { success: boolean; message: string }[] = [];
component.submittedConfiguration.subscribe(o => outcomes.push(o));

component.submitConfiguration();

expect(outcomes.length).toBe(1);
expect(outcomes[0].success).toBeFalse();
expect(outcomes[0].message).toBe('boom');
});

it('does not call the service while the form is invalid', () => {
initWith(cerner, FormMode.Edit);

component.name.setValue('');
component.submitConfiguration();

expect(vendorService.updateVendor).not.toHaveBeenCalled();
});

it('creates with the name only, leaving the secret id to a later edit', () => {
vendorService.createVendor.and.returnValue(of({ success: true }));
initWith(undefined, FormMode.Create);

component.name.setValue('Veradigm');
component.submitConfiguration();

expect(vendorService.createVendor).toHaveBeenCalledWith('Veradigm');
expect(vendorService.updateVendor).not.toHaveBeenCalled();
});
Comment thread
arianamihailescu marked this conversation as resolved.
});
Original file line number Diff line number Diff line change
Expand Up @@ -61,22 +61,38 @@ export class VendorConfigFormComponent {

vendorForm!: FormGroup;

/**
* Whether the JWT / Authentication panel starts open. Expanded when the vendor already has a
* secret id so existing configuration is visible without hunting for it, collapsed otherwise
* so the common case stays uncluttered.
*/
keySettingsExpanded = false;

constructor(private snackBar: MatSnackBar, private vendorService: VendorService, private dialog: MatDialog, private fb: FormBuilder) {
this.vendorForm = this.fb.group({
name: ["", Validators.required]
name: ["", Validators.required],
// No validator: the secret id is optional, and checking that it resolves in Key Vault is
// LEGLINK-566. A format rule here would reject ids this UI has no way to verify.
secretId: [""]
});
}

get name() {
return this.vendorForm.controls['name'];
}

get secretId() {
return this.vendorForm.controls['secretId'];
}

ngOnInit(): void {
this.vendorForm.reset();

if (this.item) {
//set form values
this.name.setValue(this.item.name);
this.secretId.setValue(this.item.secretId ?? "");
this.keySettingsExpanded = !!this.item.secretId;
}

this.vendorForm.valueChanges.subscribe(() => {
Expand All @@ -85,18 +101,49 @@ export class VendorConfigFormComponent {
}

submitConfiguration(): void {
if (this.vendorForm.status == 'VALID') {
if (this.formMode == FormMode.Create) {
this.vendorService.createVendor(this.name.value).subscribe({
next: (response) => {
if (response) {
this.submittedConfiguration.emit({success: true, message: ""});
}
},
error: (err) => {
if (this.vendorForm.status != 'VALID') {
return;
}

if (this.formMode == FormMode.Create) {
this.vendorService.createVendor(this.name.value).subscribe({
next: (response) => {
if (response) {
this.submittedConfiguration.emit({success: true, message: ""});
}
});
}
},
error: (err) => {
this.submittedConfiguration.emit({success: false, message: this.failureMessage(err)});
}
});
return;
}

// An empty box means "no key associated", sent as an explicit null rather than undefined:
// JSON.stringify omits undefined keys, and an absent field reads as "leave unchanged" to
// any endpoint with partial-update semantics, which would make clearing a key silently
// do nothing. Null says remove it.
const updated: IVendorConfigModel = {
...this.item,
name: this.name.value,
secretId: this.secretId.value?.trim() || null
};

this.vendorService.updateVendor(updated).subscribe({
next: () => {
this.submittedConfiguration.emit({success: true, message: ""});
},
error: (err) => {
this.submittedConfiguration.emit({success: false, message: this.failureMessage(err)});
}
});
}

/**
* ErrorHandlingService already surfaces the detail; this is the text the dialog shows in its
* snackbar while staying open so the admin's input is not thrown away.
*/
private failureMessage(err: any): string {
return err?.message ?? 'Failed to save the vendor configuration. Please try again.';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,38 @@
<th mat-header-cell *matHeaderCellDef>Name</th>
<td mat-cell *matCellDef="let vendor"> {{vendor.name}} </td>
</ng-container>
<!-- Key Vault Secret ID Column -->
<ng-container matColumnDef="secretId">
<th mat-header-cell *matHeaderCellDef>Secret ID</th>
<td mat-cell *matCellDef="let vendor">
@if (vendor.secretId) {
{{vendor.secretId}}
} @else {
<span class="not-set">Not set</span>
}
</td>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="Actions">
<th mat-header-cell *matHeaderCellDef class="actions-column"> Actions </th>
<td mat-cell *matCellDef="let vendor" class="actions-column">
<div class="actions">
<button
mat-icon-button
color="primary"
class="mat-elevation-z2"
(click)="onEdit(vendor)"
aria-label="Edit Vendor"
matTooltip="Edit Vendor"
>
<mat-icon>edit</mat-icon>
</button>
<button
mat-icon-button
color="warn"
class="mat-elevation-z2"
(click)="onDelete(vendor)"
aria-label="Delete Vendor"
matTooltip="Delete Vendor"
>
<mat-icon>delete</mat-icon>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ button[color="warn"] {
text-align: right;
}

// Distinguishes "no key associated" from a real secret id at a glance, without reading it as
// an error -- an unset key is the expected state for a vendor that does not sign requests.
.not-set {
color: rgba(0, 0, 0, 0.54);
font-style: italic;
}

.badge {
background-color: $primary;
color: white;
Expand Down
Loading
Loading