Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
7329bd6
LEGLINK-620: record the vendor signing-key UI design
arianamihailescu Aug 3, 2026
4b894e1
LEGLINK-620: associate a Key Vault secret with a vendor in the UI
arianamihailescu Aug 3, 2026
e2422c9
LEGLINK-620: report a failed vendor save once, and clear a key explic…
arianamihailescu Aug 3, 2026
aa3af17
LEGLINK-620: cover the vendor create failure branch
arianamihailescu Aug 4, 2026
1dad1c6
LEGLINK-620: gate vendor editing until an update endpoint exists
arianamihailescu Aug 4, 2026
81a4b7c
LEGLINK-566: design for validating a vendor secret id against Key Vault
arianamihailescu Aug 4, 2026
67ab7bd
LEGLINK-566: implementation plan for vendor secret id validation
arianamihailescu Aug 4, 2026
4896bd2
Merge branch 'dev' of https://github.com/lantanagroup/link-cloud into…
arianamihailescu Aug 4, 2026
b10fcd2
LEGLINK-620: store a vendor's Key Vault signing key secret id
arianamihailescu Aug 5, 2026
888b4d7
LEGLINK-620: point the vendor screens at the Tenant API
arianamihailescu Aug 5, 2026
bef45ed
LEGLINK-620: drop the superpowers design and plan docs
arianamihailescu Aug 5, 2026
a1e0372
LEGLINK-620: Add Secret Key to Vendor screen
arianamihailescu Aug 5, 2026
e695d2e
LEGLINK-620: revert local environment files off the branch
arianamihailescu Aug 5, 2026
6d271b4
LEGLINK-620: validate the signing key secret id before persisting it
arianamihailescu Aug 5, 2026
02af528
LEGLINK-620: check the secret id in the form and show why it was reje…
arianamihailescu Aug 5, 2026
d3a2e07
Merge branch 'dev' of https://github.com/lantanagroup/link-cloud into…
arianamihailescu Aug 5, 2026
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
@@ -0,0 +1,64 @@
using LantanaGroup.Link.Shared.Application.Models.Tenant;
using System.ComponentModel.DataAnnotations;

namespace UnitTests.Tenant;

public class VendorAuthenticationValidationTests
{
private static IList<ValidationResult> Validate(string? signingKeySecretId)
{
var settings = new VendorAuthenticationSettings { SigningKeySecretId = signingKeySecretId };
var results = new List<ValidationResult>();

Validator.TryValidateObject(settings, new ValidationContext(settings), results, validateAllProperties: true);

return results;
}

[Fact]
public void NullSecretId_IsValid_BecauseItClearsTheAssociation()
{
Assert.Empty(Validate(null));
}

[Fact]
public void ValidSecretId_IsAccepted()
{
Assert.Empty(Validate("epic-signing-key"));
}

[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("\t")]
public void BlankSecretId_IsRejected(string signingKeySecretId)
{
var results = Validate(signingKeySecretId);

Assert.NotEmpty(results);
Assert.Contains(nameof(VendorAuthenticationSettings.SigningKeySecretId),
results.SelectMany(result => result.MemberNames));
}

[Theory]
[InlineData("has space")]
[InlineData("has/slash")]
[InlineData("has_underscore")]
[InlineData("has.dot")]
public void SecretIdOutsideKeyVaultsCharacterSet_IsRejected(string signingKeySecretId)
{
Assert.NotEmpty(Validate(signingKeySecretId));
}

[Fact]
public void SecretIdLongerThanKeyVaultAllows_IsRejected()
{
Assert.NotEmpty(Validate(new string('a', 128)));
}

[Fact]
public void SecretIdAtKeyVaultsMaximumLength_IsAccepted()
{
Assert.Empty(Validate(new string('a', 127)));
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,33 @@
using System.ComponentModel.DataAnnotations;
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;

namespace LantanaGroup.Link.Shared.Application.Models.Tenant
{
[DataContract]
public class VendorAuthenticationSettings
public class VendorAuthenticationSettings : IValidatableObject
{
private static readonly Regex KeyVaultSecretName =
new("^[0-9a-zA-Z-]{1,127}$", RegexOptions.Compiled);

[DataMember]
[JsonPropertyName("signingKeySecretId")]
public string? SigningKeySecretId { get; set; }

public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (SigningKeySecretId is null)
{
yield break;
}

if (!KeyVaultSecretName.IsMatch(SigningKeySecretId))
{
yield return new ValidationResult(
"SigningKeySecretId must be a Key Vault secret name: 1 to 127 characters of letters, digits and dashes. Send null to clear the association.",
new[] { nameof(SigningKeySecretId) });
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@
<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>
@if (secretId.hasError('pattern')) {
<mat-error data-testid="vendor-secret-id-error">
Use the secret's name, not its URL: 1 to 127 letters, digits or dashes.
</mat-error>
}
</mat-form-field>
</div>
</mat-expansion-panel>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,89 @@ describe('VendorConfigFormComponent', () => {
expect(bare.componentInstance.keySettingsExpanded).toBeFalse();
});

['has space', 'has/slash', 'has_underscore', 'https://v.vault.azure.net/secrets/k', 'a'.repeat(128)]
.forEach(invalid => {
it(`rejects a secret id Key Vault cannot accept: "${invalid.slice(0, 24)}"`, () => {
initWith(cerner, FormMode.Edit);

component.secretId.setValue(invalid);

expect(component.secretId.valid).toBeFalse();
expect(component.vendorForm.valid).toBeFalse();
});
});

it('marks the form invalid on the keystroke, before the field is blurred', () => {
initWith(cerner, FormMode.Edit);

component.secretId.setValue('has space');

expect(component.secretId.touched).toBeFalse();
expect(component.vendorForm.invalid).toBeTrue();
});

it('shows the inline message once the field is blurred', () => {
initWith(cerner, FormMode.Edit);

component.secretId.setValue('has space');
fixture.detectChanges();
const whileTyping = (fixture.nativeElement as HTMLElement)
.querySelector('[data-testid="vendor-secret-id-error"]');

component.secretId.markAsTouched();
fixture.detectChanges();
const afterBlur = (fixture.nativeElement as HTMLElement)
.querySelector('[data-testid="vendor-secret-id-error"]');

expect(whileTyping).toBeNull();
expect(afterBlur).not.toBeNull();
});

it('accepts a secret id Key Vault can resolve', () => {
initWith(cerner, FormMode.Edit);

component.secretId.setValue('epic-signing-key');

expect(component.secretId.valid).toBeTrue();
});

it('accepts an empty secret id, which clears the association', () => {
initWith(epic, FormMode.Edit);

component.secretId.setValue('');

expect(component.secretId.valid).toBeTrue();
});

it('does not call the service when the secret id is invalid', () => {
initWith(epic, FormMode.Edit);

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

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

it('surfaces the field message when the API rejects the value', () => {
vendorService.updateVendor.and.returnValue(throwError(() => ({
message: 'An error occured in our API. Please use the trace id when requesting assistence. - 00-abc',
error: {
errors: {
'Authentication.SigningKeySecretId': ['SigningKeySecretId must be a Key Vault secret name.']
}
}
})));
initWith(epic, FormMode.Edit);

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

component.submitConfiguration();

expect(outcomes[0].success).toBeFalse();
expect(outcomes[0].message).toContain('SigningKeySecretId must be a Key Vault secret name.');
});

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

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges} from '@angular/core';

import {MatSnackBar, MatSnackBarModule} from '@angular/material/snack-bar';
import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
import {AbstractControl, FormBuilder, FormGroup, ReactiveFormsModule, ValidationErrors, Validators} from '@angular/forms';
import {MatButtonModule} from '@angular/material/button';
import {MatSelectModule} from '@angular/material/select';
import {MatChipsModule} from '@angular/material/chips';
Expand Down Expand Up @@ -61,6 +61,15 @@ export class VendorConfigFormComponent {

vendorForm!: FormGroup;

private static readonly KeyVaultSecretName = /^[0-9a-zA-Z-]{1,127}$/;

static keyVaultSecretNameValidator(control: AbstractControl): ValidationErrors | null {
const value = (control.value ?? '').trim();
return value === '' || VendorConfigFormComponent.KeyVaultSecretName.test(value)
? null
: {pattern: true};
}

/**
* 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
Expand All @@ -71,9 +80,7 @@ export class VendorConfigFormComponent {
constructor(private snackBar: MatSnackBar, private vendorService: VendorService, private dialog: MatDialog, private fb: FormBuilder) {
this.vendorForm = this.fb.group({
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: [""]
secretId: ["", VendorConfigFormComponent.keyVaultSecretNameValidator]
});
}

Expand Down Expand Up @@ -140,6 +147,11 @@ export class VendorConfigFormComponent {
* snackbar while staying open so the admin's input is not thrown away.
*/
private failureMessage(err: any): string {
const fieldMessages = Object.values(err?.error?.errors ?? {}).flat() as string[];
if (fieldMessages.length) {
return fieldMessages.join(' ');
}

return err?.message ?? 'Failed to save the vendor configuration. Please try again.';
}
}
Loading