Stabilize setup admin onboarding journeys
This commit is contained in:
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user