From 4a5185121dd16270154e263a690ec262b382e34f Mon Sep 17 00:00:00 2001 From: master <> Date: Sun, 15 Mar 2026 03:38:48 +0200 Subject: [PATCH] Stabilize setup admin onboarding journeys --- ...form_setup_admin_operator_journey_audit.md | 80 ++++++++++++++ .../scripts/live-setup-admin-action-sweep.mjs | 63 ++++++++++- .../live-user-reported-admin-trust-check.mjs | 104 +++++++++++++++++- .../channel-management.component.spec.ts | 96 ++++++++++++++-- .../channel-management.component.ts | 70 +++++++++++- .../StellaOps.Web/tsconfig.spec.features.json | 1 + 6 files changed, 392 insertions(+), 22 deletions(-) create mode 100644 docs/implplan/SPRINT_20260315_001_Platform_setup_admin_operator_journey_audit.md diff --git a/docs/implplan/SPRINT_20260315_001_Platform_setup_admin_operator_journey_audit.md b/docs/implplan/SPRINT_20260315_001_Platform_setup_admin_operator_journey_audit.md new file mode 100644 index 000000000..37c8558d2 --- /dev/null +++ b/docs/implplan/SPRINT_20260315_001_Platform_setup_admin_operator_journey_audit.md @@ -0,0 +1,80 @@ +# Sprint 20260315_001 - Platform Setup Administration Operator Journey Audit + +## Topic & Scope +- Use Stella Ops as a first-time platform administrator setting up the product for real organizational use after installation. +- Drive end-user admin journeys first: identity and access, tenant and branding, notifications administration, trust/signing administration, setup integrations management, and adjacent setup surfaces that an operator would use during onboarding. +- Treat retained Playwright as evidence and regression coverage, not as a substitute for discovery; every newly discovered manual setup/admin defect must become retained coverage afterward. +- Group fixes by root cause so the iteration closes full setup/admin behavior slices instead of isolated page patches. +- Working directory: `.`. +- Expected evidence: operator journey notes, retained Playwright additions or hardening, grouped defect analysis, focused tests where code changes land, rebuilt-stack retest results, and live aggregate evidence. + +## Dependencies & Concurrency +- Depends on local commit `2661bfefa` as the closed baseline from scratch iteration 013. +- Safe parallelism: avoid environment resets while live setup/admin journeys are running because the stack is shared. + +## Documentation Prerequisites +- `AGENTS.md` +- `docs/INSTALL_GUIDE.md` +- `docs/dev/DEV_ENVIRONMENT_SETUP.md` +- `docs/qa/feature-checks/FLOW.md` + +## Delivery Tracker + +### PLATFORM-SETUP-ADMIN-001 - Define and execute setup/admin operator journeys +Status: DONE +Dependency: none +Owners: QA, Product Manager +Task description: +- Act as a platform administrator onboarding Stella Ops for real use. Cover identity and access management, tenant and branding changes, notification administration, trust/signing inventory and workflows, setup integrations management, and any adjacent setup surfaces encountered during the journey. + +Completion criteria: +- [x] The primary setup/admin operator journeys are explicitly listed before fixes begin. +- [x] Playwright is used to execute those journeys as an operator would, not only as route sweeps. +- [x] Every broken route, page-load, data-load, validation rule, or action encountered on the operator path is recorded before any fix starts. + +### PLATFORM-SETUP-ADMIN-002 - Convert newly discovered admin steps into retained coverage +Status: DONE +Dependency: PLATFORM-SETUP-ADMIN-001 +Owners: QA, Test Automation +Task description: +- Add or deepen retained Playwright coverage for every newly discovered setup/admin step so future iterations recheck the same operator behavior automatically. + +Completion criteria: +- [x] Every newly discovered operator/admin step is mapped to retained Playwright coverage or an explicit backlog gap. +- [x] Retained coverage additions are organized by user journey, not only by route. +- [x] The next aggregate run would exercise the newly discovered setup/admin path automatically. + +### PLATFORM-SETUP-ADMIN-003 - Repair grouped setup/admin defects and retest +Status: DONE +Dependency: PLATFORM-SETUP-ADMIN-002 +Owners: 3rd line support, Architect, Developer +Task description: +- Diagnose the grouped failures exposed by the setup/admin journey, choose the clean product/architecture-conformant fix, implement it, add retained Playwright coverage for the new behavior when needed, and rerun the affected journeys plus the aggregate audit before committing. + +Completion criteria: +- [x] Root causes are recorded for the grouped failures. +- [x] Fixes land with focused regression coverage and retained Playwright scenario updates where practical. +- [x] The live stack is retested through the same setup/admin journeys before the iteration commit. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-15 | Sprint created immediately after local commit `2661bfefa` closed the release-confidence operator iteration cleanly at `25/25` suites with `0` retries. | QA | +| 2026-03-15 | Defined the setup/admin operator path as: identity and access (`/setup/identity-access` users, roles, tenants), tenant and branding (`/setup/tenant-branding`), notifications administration (`/setup/notifications/channels/new`, `/setup/notifications/rules/new`), trust and signing (`/setup/trust-signing/*`), setup integrations management, direct docs navigation, global search from the operator shell, and security reports embedding under the setup-adjacent admin workflow. | QA | +| 2026-03-15 | Discovery before fixes found one real setup/admin defect and one retained-coverage defect. Real product defect: canonical `/setup/notifications/channels/new` did not enter create mode and the UI allowed empty `secretRef` even though the notifier contract requires a non-empty secret reference. Retained defect: the user-reported trust/admin probe misclassified `Signing Keys` and `Audit Log` as blank because it used the wrong selectors and did not wait for routed tab resolution. | QA | +| 2026-03-15 | Repaired notifications onboarding in `channel-management.component.ts`, added focused Angular regression coverage in `channel-management.component.spec.ts`, added the spec file to `tsconfig.spec.features.json`, and deepened `live-setup-admin-action-sweep.mjs` so setup/admin retained coverage now proves routed channel creation, required `secretRef`, rule-channel visibility, and cleanup. | Developer | +| 2026-03-15 | Hardened `live-user-reported-admin-trust-check.mjs` to wait for tab-specific resolution, inspect the real key/audit selectors used by trust management, and prove a successful valid user-create path in addition to invalid-email rejection. | Test Automation | +| 2026-03-15 | Verification: focused Angular `channel-management.component.spec.ts` passed `71/71`; `npm run build` passed; `live-setup-admin-action-sweep.mjs` passed with `failedActionCount=0` and `runtimeIssueCount=0`; `live-user-reported-admin-trust-check.mjs` passed with `failedCheckCount=0`, including valid user creation plus role and tenant creation persistence. | QA | +| 2026-03-15 | Direct live reruns of the adjacent setup/admin probes confirmed the remaining aggregate noise was not a reproducible product defect: `live-user-reported-admin-trust-check.mjs` resolved all trust tabs cleanly, and `live-setup-topology-action-sweep.mjs` finished `failedActionCount=0` with `runtimeIssueCount=0`. | QA | + +## Decisions & Risks +- Decision: this iteration prioritizes first-time administrator behavior over broad route counts. +- Risk: some setup/admin surfaces are currently covered only through shared checks, so behavior gaps may still exist even when route and aggregate summaries are green. +- Root cause: the notifications channel create route advertised a canonical create page, but the component ignored route data and stayed in list mode; the same surface also presented `Secret Reference` as optional even though the notifier domain requires it. +- Root cause: the retained trust/admin probe used generic selectors (`.key-dashboard__loading`, `.trust-audit-log__empty`) that do not exist in the live components and only waited a fixed 1.5 seconds after tab routing, producing false failures on `Signing Keys` and `Audit Log`. +- Decision: retained Playwright checks for setup/admin surfaces must wait for route-specific resolution selectors rather than infer success from headings or generic shell-level alerts. +- Investigation note: direct reruns on the live stack showed `Certificates`, `Audit Log`, and the adjacent setup topology journey all converging cleanly, so this iteration did not justify a product-side trust/topology change beyond the retained-check hardening already captured above. + +## Next Checkpoints +- Let the in-flight `live-full-core-audit.mjs` consume the hardened user-reported admin/trust script and confirm the broader aggregate remains clean. +- Start the next operator-first iteration from a fresh user journey outside setup/admin, carrying forward every newly retained step as regression coverage. diff --git a/src/Web/StellaOps.Web/scripts/live-setup-admin-action-sweep.mjs b/src/Web/StellaOps.Web/scripts/live-setup-admin-action-sweep.mjs index e029241a8..5e0b2f8a9 100644 --- a/src/Web/StellaOps.Web/scripts/live-setup-admin-action-sweep.mjs +++ b/src/Web/StellaOps.Web/scripts/live-setup-admin-action-sweep.mjs @@ -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); diff --git a/src/Web/StellaOps.Web/scripts/live-user-reported-admin-trust-check.mjs b/src/Web/StellaOps.Web/scripts/live-user-reported-admin-trust-check.mjs index 7c289315e..2e87ee089 100644 --- a/src/Web/StellaOps.Web/scripts/live-user-reported-admin-trust-check.mjs +++ b/src/Web/StellaOps.Web/scripts/live-user-reported-admin-trust-check.mjs @@ -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.'); } diff --git a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/channel-management.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/channel-management.component.spec.ts index 42ae118e0..f5d39c354 100644 --- a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/channel-management.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/channel-management.component.spec.ts @@ -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; let mockApi: jasmine.SpyObj; + let mockRouter: jasmine.SpyObj; + let mockRoute: { snapshot: { data: Record } }; 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', ['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(); }); diff --git a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/channel-management.component.ts b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/channel-management.component.ts index ca6614d87..46e48fbb7 100644 --- a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/channel-management.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/channel-management.component.ts @@ -325,9 +325,15 @@ interface ChannelTypeOption {
- - - Reference to stored credentials in Authority + + + {{ getSecretRefHelpText(selectedType()) }} + @if (channelForm.get('secretRef')?.touched && channelForm.get('secretRef')?.hasError('required')) { + Secret reference is required for notification channels. + }
@@ -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(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 { + 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 { - 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; + } } diff --git a/src/Web/StellaOps.Web/tsconfig.spec.features.json b/src/Web/StellaOps.Web/tsconfig.spec.features.json index 905606800..0f0ec460c 100644 --- a/src/Web/StellaOps.Web/tsconfig.spec.features.json +++ b/src/Web/StellaOps.Web/tsconfig.spec.features.json @@ -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",