Stabilize setup admin onboarding journeys

This commit is contained in:
master
2026-03-15 03:38:48 +02:00
parent 2661bfefa4
commit 4a5185121d
6 changed files with 392 additions and 22 deletions

View File

@@ -161,15 +161,72 @@ async function main() {
snapshot: brandingAfter,
});
await gotoRoute(page, '/setup/notifications');
await page.getByRole('button', { name: 'Create Rule', exact: true }).click({ timeout: 10_000 });
const qaChannelName = `qa-email-${Date.now()}`;
const qaSecretRef = `ref://notify/channels/email/${qaChannelName}`;
await gotoRoute(page, '/setup/notifications/channels/new');
const channelNameInput = page.locator('input[formcontrolname="name"]');
const fromAddressInput = page.locator('input[formcontrolname="fromAddress"]');
const toAddressesInput = page.locator('textarea[formcontrolname="toAddresses"]');
const secretRefInput = page.locator('input[formcontrolname="secretRef"]');
const createChannelButton = page.getByRole('button', { name: 'Create Channel', exact: true });
const channelRouteOk = page.url().includes('/setup/notifications/channels/new')
&& await channelNameInput.isVisible().catch(() => false)
&& await createChannelButton.isVisible().catch(() => false);
await channelNameInput.fill(qaChannelName);
await fromAddressInput.fill('qa@stella-ops.local');
await toAddressesInput.fill('ops@stella-ops.local');
await page.waitForTimeout(300);
const createDisabledWithoutSecret = await createChannelButton.isDisabled().catch(() => true);
await secretRefInput.fill(qaSecretRef);
await page.waitForTimeout(300);
const createDisabledWithSecret = await createChannelButton.isDisabled().catch(() => true);
await createChannelButton.click({ timeout: 10_000 });
await page.waitForURL(/\/setup\/notifications\/channels(?:\?|$)/, { timeout: 10_000 }).catch(() => {});
await page.waitForTimeout(2_000);
const createdChannelCard = page.locator('.channel-card').filter({ hasText: qaChannelName }).first();
const channelListed = await createdChannelCard.isVisible().catch(() => false);
results.push({
action: 'notifications-create-channel-route',
ok: channelRouteOk && createDisabledWithoutSecret && !createDisabledWithSecret && channelListed,
channelRouteOk,
createDisabledWithoutSecret,
createDisabledWithSecret,
snapshot: await captureSnapshot(page, 'notifications-create-channel-route'),
});
await gotoRoute(page, '/setup/notifications/rules/new');
const channelOptions = await page
.locator('select[formcontrolname="channelId"] option')
.allTextContents()
.catch(() => []);
results.push({
action: 'notifications-create-rule',
ok: page.url().includes('/setup/notifications/rules/new'),
ok: page.url().includes('/setup/notifications/rules/new') && channelOptions.some((option) => option.includes(qaChannelName)),
channelOptions,
snapshot: await captureSnapshot(page, 'notifications-create-rule'),
});
if (channelListed) {
await gotoRoute(page, '/setup/notifications/channels');
const deleteCard = page.locator('.channel-card').filter({ hasText: qaChannelName }).first();
page.once('dialog', (dialog) => {
void dialog.accept();
});
await deleteCard.getByRole('button', { name: 'Delete', exact: true }).click({ timeout: 10_000 });
await page.waitForTimeout(2_000);
const deleted = await page.locator('.channel-card').filter({ hasText: qaChannelName }).count() === 0;
results.push({
action: 'notifications-delete-created-channel',
ok: deleted,
snapshot: await captureSnapshot(page, 'notifications-delete-created-channel'),
});
}
await gotoRoute(page, '/setup/usage');
await page.getByRole('link', { name: 'Configure Quotas', exact: true }).click({ timeout: 10_000 });
await page.waitForTimeout(2_000);

View File

@@ -76,15 +76,69 @@ function boxesOverlap(left, right) {
);
}
function getTrustTabResolutionSelectors(tab) {
switch (tab) {
case 'Signing Keys':
return [
'.key-table tbody tr',
'.key-dashboard .state',
'.key-dashboard .state--error',
];
case 'Trusted Issuers':
return [
'.issuer-trust tbody tr',
'.issuer-trust__empty',
'.issuer-trust__loading',
'.issuer-trust__error',
];
case 'Certificates':
return [
'.certificate-inventory tbody tr',
'.certificate-inventory .state',
'.certificate-inventory__empty',
'.certificate-inventory__loading',
'.certificate-inventory__error',
];
case 'Audit Log':
return [
'.event-card',
'.audit-log__empty',
'.audit-log__loading',
'.audit-log__error',
];
default:
return ['tbody tr', '.empty-state', '.loading-text', '.state'];
}
}
async function waitForTrustTabResolution(page, tab) {
const selectors = getTrustTabResolutionSelectors(tab);
await page.waitForFunction(
(candidateSelectors) => candidateSelectors.some((selector) => document.querySelector(selector)),
selectors,
{ timeout: 10_000 },
).catch(() => {});
await settle(page, 500);
}
async function collectTrustTabState(page, tab) {
const tableRowCount = await page.locator('tbody tr').count().catch(() => 0);
await waitForTrustTabResolution(page, tab);
const tableRowSelector = tab === 'Signing Keys'
? '.key-table tbody tr'
: tab === 'Trusted Issuers'
? '.issuer-trust tbody tr, tbody tr'
: tab === 'Certificates'
? '.certificate-inventory tbody tr, tbody tr'
: 'tbody tr';
const tableRowCount = await page.locator(tableRowSelector).count().catch(() => 0);
const eventCardCount = await page.locator('.event-card').count().catch(() => 0);
const emptyTexts = await page
.locator('.key-dashboard__empty, .issuer-trust__empty, .certificate-inventory__empty, .trust-audit-log__empty, .empty-state')
.locator('.key-dashboard .state, .issuer-trust__empty, .certificate-inventory .state, .audit-log__empty, .empty-state')
.evaluateAll((nodes) => nodes.map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, 6))
.catch(() => []);
const loadingTexts = await page
.locator('.key-dashboard__loading, .trust-admin__loading, .issuer-trust__loading, .certificate-inventory__loading, .trust-audit-log__loading, .loading-text')
.locator('.key-dashboard .state--loading, .trust-admin__loading, .issuer-trust__loading, .certificate-inventory .state--loading, .audit-log__loading, .loading-text')
.evaluateAll((nodes) => nodes.map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, 6))
.catch(() => []);
const primaryButtons = await page
@@ -104,6 +158,7 @@ async function collectTrustTabState(page, tab) {
emptyTexts,
loadingTexts,
primaryButtons,
mainTextPreview: await page.locator('main').innerText().then((text) => text.replace(/\s+/g, ' ').trim().slice(0, 240)).catch(() => ''),
};
}
@@ -155,6 +210,37 @@ async function createTenantCheck(page) {
};
}
async function createUserCheck(page) {
const uniqueSuffix = Date.now();
const username = `qa-user-${uniqueSuffix}`;
const email = `${username}@stella-ops.local`;
const displayName = `QA User ${uniqueSuffix}`;
const roleSelect = page.locator('select').first();
const selectedRole = await roleSelect.inputValue().catch(() => 'admin');
await page.locator('input[placeholder="e.g. jane.doe"]').fill(username);
await page.locator('input[type="email"]').first().fill(email);
await page.locator('input[placeholder="Jane Doe"]').fill(displayName);
await settle(page, 250);
await page.getByRole('button', { name: 'Create User' }).click();
await settle(page, 1_250);
const successText = await page.locator('.success-banner').first().textContent().then((text) => text?.trim() || '').catch(() => '');
const tableContainsUser = await page.locator('tbody tr').evaluateAll(
(rows, expectedEmail) => rows.some((row) => (row.textContent || '').replace(/\s+/g, ' ').includes(expectedEmail)),
email,
).catch(() => false);
return {
username,
email,
displayName,
selectedRole,
successText,
tableContainsUser,
};
}
async function collectReportsTabState(page, tab) {
await page.getByRole('tab', { name: tab }).click();
await settle(page, 1000);
@@ -290,6 +376,14 @@ async function main() {
snapshot: await snapshot(page, 'identity-access:create-user-invalid-email'),
});
console.log('[live-user-reported-admin-trust-check] valid-user');
const userCreate = await createUserCheck(page);
results.push({
action: 'identity-access:create-user-valid',
userCreate,
snapshot: await snapshot(page, 'identity-access:create-user-valid'),
});
console.log('[live-user-reported-admin-trust-check] roles');
await page.getByRole('button', { name: 'Roles' }).click();
await settle(page, 1000);
@@ -463,6 +557,10 @@ async function main() {
failures.push('Identity & Access user creation did not reject an invalid email address.');
}
if (!byAction.get('identity-access:create-user-valid')?.userCreate?.tableContainsUser) {
failures.push('Identity & Access valid user creation did not persist a new user in the table.');
}
if ((byAction.get('identity-access:roles-tab')?.roleNames?.length ?? 0) === 0) {
failures.push('Identity & Access roles table still shows empty role names.');
}

View File

@@ -5,6 +5,7 @@
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router } from '@angular/router';
import { of, throwError } from 'rxjs';
import { ChannelManagementComponent } from './channel-management.component';
import { NOTIFIER_API } from '../../../core/api/notifier.client';
@@ -14,6 +15,8 @@ describe('ChannelManagementComponent', () => {
let component: ChannelManagementComponent;
let fixture: ComponentFixture<ChannelManagementComponent>;
let mockApi: jasmine.SpyObj<any>;
let mockRouter: jasmine.SpyObj<Router>;
let mockRoute: { snapshot: { data: Record<string, unknown> } };
const mockChannels: NotifierChannel[] = [
{
@@ -24,7 +27,11 @@ describe('ChannelManagementComponent', () => {
description: 'Slack channel for security alerts',
type: 'Slack',
enabled: true,
config: { channel: '#security-alerts', webhookUrl: 'https://hooks.slack.com/...' },
config: {
channel: '#security-alerts',
webhookUrl: 'https://hooks.slack.com/...',
secretRef: 'ref://notify/channels/slack/security',
},
healthStatus: 'healthy',
createdAt: '2025-01-01T00:00:00Z',
},
@@ -35,7 +42,11 @@ describe('ChannelManagementComponent', () => {
displayName: 'Operations Email',
type: 'Email',
enabled: true,
config: { toAddresses: ['ops@example.com'], fromAddress: 'noreply@example.com' },
config: {
toAddresses: ['ops@example.com'],
fromAddress: 'noreply@example.com',
secretRef: 'ref://notify/channels/email/ops',
},
healthStatus: 'healthy',
createdAt: '2025-01-01T00:00:00Z',
},
@@ -45,7 +56,10 @@ describe('ChannelManagementComponent', () => {
name: 'teams-devops',
type: 'Teams',
enabled: false,
config: { webhookUrl: 'https://outlook.office.com/...' },
config: {
webhookUrl: 'https://outlook.office.com/...',
secretRef: 'ref://notify/channels/teams/devops',
},
healthStatus: 'unknown',
createdAt: '2025-01-01T00:00:00Z',
},
@@ -55,7 +69,10 @@ describe('ChannelManagementComponent', () => {
name: 'webhook-integration',
type: 'Webhook',
enabled: true,
config: { url: 'https://api.example.com/webhook' },
config: {
url: 'https://api.example.com/webhook',
secretRef: 'ref://notify/channels/webhook/integration',
},
healthStatus: 'degraded',
createdAt: '2025-01-01T00:00:00Z',
},
@@ -65,7 +82,10 @@ describe('ChannelManagementComponent', () => {
name: 'pagerduty-oncall',
type: 'PagerDuty',
enabled: true,
config: { routingKey: 'R0123456789ABCDEF' },
config: {
routingKey: 'R0123456789ABCDEF',
secretRef: 'ref://notify/channels/pagerduty/oncall',
},
healthStatus: 'healthy',
createdAt: '2025-01-01T00:00:00Z',
},
@@ -79,6 +99,9 @@ describe('ChannelManagementComponent', () => {
'deleteChannel',
'testChannel',
]);
mockRouter = jasmine.createSpyObj<Router>('Router', ['navigate']);
mockRouter.navigate.and.returnValue(Promise.resolve(true));
mockRoute = { snapshot: { data: {} } };
mockApi.listChannels.and.returnValue(of({ items: mockChannels, total: 5 }));
@@ -86,6 +109,8 @@ describe('ChannelManagementComponent', () => {
imports: [ChannelManagementComponent],
providers: [
{ provide: NOTIFIER_API, useValue: mockApi },
{ provide: Router, useValue: mockRouter },
{ provide: ActivatedRoute, useValue: mockRoute },
],
}).compileComponents();
@@ -140,6 +165,18 @@ describe('ChannelManagementComponent', () => {
expect(component.error()).toBe('Network error');
});
it('should enter create mode when route data requests a new channel', async () => {
mockRoute.snapshot.data['createNew'] = true;
fixture = TestBed.createComponent(ChannelManagementComponent);
component = fixture.componentInstance;
await component.ngOnInit();
expect(component.editMode()).toBe(true);
expect(component.isNewChannel()).toBe(true);
expect(component.selectedType()).toBe('Email');
});
});
describe('filtering', () => {
@@ -272,6 +309,7 @@ describe('ChannelManagementComponent', () => {
expect(component.channelForm.get('enabled')?.value).toBe(true);
expect(component.channelForm.get('smtpPort')?.value).toBe(587);
expect(component.channelForm.get('secretRef')?.value).toBeNull();
});
it('should set selected type to Email', () => {
@@ -339,6 +377,16 @@ describe('ChannelManagementComponent', () => {
expect(component.error()).toBeNull();
});
it('should navigate back to the channel list on routed create pages', () => {
mockRoute.snapshot.data['createNew'] = true;
fixture = TestBed.createComponent(ChannelManagementComponent);
component = fixture.componentInstance;
component.cancelEdit();
expect(mockRouter.navigate).toHaveBeenCalledWith(['..'], { relativeTo: mockRoute as ActivatedRoute });
});
});
describe('saveChannel - create', () => {
@@ -355,6 +403,21 @@ describe('ChannelManagementComponent', () => {
expect(mockApi.createChannel).not.toHaveBeenCalled();
});
it('should require a secret reference before saving', async () => {
component.channelForm.patchValue({
name: 'new-email-channel',
enabled: true,
fromAddress: 'qa@stella-ops.local',
toAddresses: 'ops@stella-ops.local',
secretRef: '',
});
await component.saveChannel();
expect(component.channelForm.get('secretRef')?.hasError('required')).toBe(true);
expect(mockApi.createChannel).not.toHaveBeenCalled();
});
it('should create channel with valid data', async () => {
mockApi.createChannel.and.returnValue(of({ channelId: 'new-chn' }));
@@ -363,6 +426,7 @@ describe('ChannelManagementComponent', () => {
enabled: true,
webhookUrl: 'https://hooks.slack.com/...',
channel: '#new-channel',
secretRef: 'ref://notify/channels/slack/new-channel',
});
component.selectType('Slack');
@@ -377,6 +441,7 @@ describe('ChannelManagementComponent', () => {
component.channelForm.patchValue({
name: 'new-channel',
enabled: true,
secretRef: 'ref://notify/channels/email/new-channel',
});
await component.saveChannel();
@@ -388,7 +453,10 @@ describe('ChannelManagementComponent', () => {
mockApi.createChannel.and.returnValue(of({ channelId: 'new-chn' }));
mockApi.listChannels.calls.reset();
component.channelForm.patchValue({ name: 'new-channel' });
component.channelForm.patchValue({
name: 'new-channel',
secretRef: 'ref://notify/channels/email/new-channel',
});
await component.saveChannel();
@@ -398,7 +466,10 @@ describe('ChannelManagementComponent', () => {
it('should handle create error', async () => {
mockApi.createChannel.and.returnValue(throwError(() => new Error('Create failed')));
component.channelForm.patchValue({ name: 'new-channel' });
component.channelForm.patchValue({
name: 'new-channel',
secretRef: 'ref://notify/channels/email/new-channel',
});
await component.saveChannel();
@@ -415,7 +486,10 @@ describe('ChannelManagementComponent', () => {
it('should update channel with valid data', async () => {
mockApi.updateChannel.and.returnValue(of(mockChannels[0]));
component.channelForm.patchValue({ name: 'updated-channel' });
component.channelForm.patchValue({
name: 'updated-channel',
secretRef: 'ref://notify/channels/slack/security',
});
await component.saveChannel();
@@ -499,7 +573,8 @@ describe('ChannelManagementComponent', () => {
describe('template rendering - list view', () => {
beforeEach(async () => {
await component.ngOnInit();
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
});
@@ -547,7 +622,8 @@ describe('ChannelManagementComponent', () => {
describe('template rendering - editor view', () => {
beforeEach(async () => {
await component.ngOnInit();
fixture.detectChanges();
await fixture.whenStable();
component.startCreate();
fixture.detectChanges();
});

View File

@@ -325,9 +325,15 @@ interface ChannelTypeOption {
</div>
<div class="form-group">
<label>Secret Reference</label>
<input type="text" formControlName="secretRef" placeholder="secret://notify/channel-secret" />
<span class="help-text">Reference to stored credentials in Authority</span>
<label>Secret Reference *</label>
<input
type="text"
formControlName="secretRef"
[placeholder]="getSecretRefPlaceholder(selectedType())" />
<span class="help-text">{{ getSecretRefHelpText(selectedType()) }}</span>
@if (channelForm.get('secretRef')?.touched && channelForm.get('secretRef')?.hasError('required')) {
<span class="field-error">Secret reference is required for notification channels.</span>
}
</div>
</section>
@@ -629,6 +635,13 @@ interface ChannelTypeOption {
color: var(--color-text-secondary);
}
.field-error {
display: block;
margin-top: 0.375rem;
color: var(--color-status-danger-strong, #b42318);
font-size: 0.875rem;
}
.form-footer {
display: flex;
justify-content: flex-end;
@@ -719,6 +732,8 @@ interface ChannelTypeOption {
})
export class ChannelManagementComponent implements OnInit {
private readonly api = inject<NotifierApi>(NOTIFIER_API);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly fb = inject(FormBuilder);
readonly loading = signal(false);
@@ -796,10 +811,14 @@ export class ChannelManagementComponent implements OnInit {
// Advanced
timeout: [30],
retryCount: [3],
secretRef: [''],
secretRef: ['', [Validators.required]],
});
async ngOnInit(): Promise<void> {
if (this.shouldStartInCreateMode()) {
this.startCreate();
}
await this.loadChannels();
}
@@ -850,6 +869,25 @@ export class ChannelManagementComponent implements OnInit {
return this.channelTypes.find(t => t.value === type)?.label || type;
}
getSecretRefPlaceholder(type: NotifierChannelType): string {
return `ref://notify/channels/${type.toLowerCase()}/primary`;
}
getSecretRefHelpText(type: NotifierChannelType): string {
switch (type) {
case 'Email':
return 'Required. Reference the SMTP credential secret stored in Authority or your configured vault.';
case 'Slack':
case 'Teams':
case 'Webhook':
return 'Required. Reference the webhook or token secret stored in Authority or your configured vault.';
case 'PagerDuty':
return 'Required. Reference the PagerDuty routing secret stored in Authority or your configured vault.';
default:
return 'Required. Reference stored credentials in Authority or your configured vault.';
}
}
selectType(type: NotifierChannelType): void {
this.selectedType.set(type);
}
@@ -897,10 +935,17 @@ export class ChannelManagementComponent implements OnInit {
this.isNewChannel.set(false);
this.editingChannel.set(null);
this.error.set(null);
if (this.shouldReturnToListRoute()) {
void this.router.navigate(['..'], { relativeTo: this.route });
}
}
async saveChannel(): Promise<void> {
if (!this.channelForm.valid) return;
if (!this.channelForm.valid) {
this.channelForm.markAllAsTouched();
return;
}
this.saving.set(true);
this.error.set(null);
@@ -927,8 +972,13 @@ export class ChannelManagementComponent implements OnInit {
}
}
this.cancelEdit();
await this.loadChannels();
if (this.shouldReturnToListRoute()) {
await this.router.navigate(['..'], { relativeTo: this.route });
} else {
this.cancelEdit();
}
} catch (err) {
this.error.set(err instanceof Error ? err.message : 'Failed to save channel');
} finally {
@@ -1027,4 +1077,12 @@ export class ChannelManagementComponent implements OnInit {
this.error.set('Failed to delete channel');
}
}
private shouldStartInCreateMode(): boolean {
return this.route.snapshot.data['createNew'] === true;
}
private shouldReturnToListRoute(): boolean {
return this.route.snapshot.data['createNew'] === true;
}
}

View File

@@ -15,6 +15,7 @@
"src/app/features/change-trace/change-trace-viewer.component.spec.ts",
"src/app/features/admin-notifications/components/notification-rule-list.component.spec.ts",
"src/app/features/admin-notifications/components/notification-dashboard.component.spec.ts",
"src/app/features/admin-notifications/components/channel-management.component.spec.ts",
"src/app/features/audit-log/audit-log-dashboard.component.spec.ts",
"src/app/features/deploy-diff/components/deploy-diff-panel/deploy-diff-panel.component.spec.ts",
"src/app/features/deploy-diff/pages/deploy-diff.page.spec.ts",