Repair first-time identity and trust operator journeys
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
# Sprint 20260315_006 - First-Time User Operator Journey Grouped Remediation
|
||||
|
||||
## Topic & Scope
|
||||
- Turn the 54 findings in `docs/qa/FIRST_TIME_USER_UX_AUDIT_20260315.md` into a grouped remediation program instead of treating them as isolated page bugs.
|
||||
- Reframe the Stella Ops QA loop around the real operator job: set up identity, trust, integrations, topology, and release confidence from the UI without source-code knowledge.
|
||||
- Group defects by root cause and user journey: blank surfaces and route ownership, identity self-serve administration, trust/signing action design, onboarding and context guidance, and cross-cutting error/naming consistency.
|
||||
- Working directory: `src/Web/StellaOps.Web`.
|
||||
- Expected evidence: journey maps, grouped root-cause analysis, retained Playwright additions for newly discovered steps, focused regression coverage, live retest artifacts, and linked docs updates.
|
||||
|
||||
Cross-module edits allowed for this sprint:
|
||||
- `devops/compose/`
|
||||
- `src/Platform/`
|
||||
- `src/Authority/`
|
||||
- `docs/qa/`
|
||||
- `docs/operations/`
|
||||
- `docs/modules/`
|
||||
|
||||
## Dependencies & Concurrency
|
||||
- Depends on the current intact live stack and the audit baseline in `docs/qa/FIRST_TIME_USER_UX_AUDIT_20260315.md`.
|
||||
- Release-create contract repair in `SPRINT_20260315_005` is an immediate dependency because `/releases/versions/new` is one of the P0 findings and a critical operator journey.
|
||||
- Safe parallelism: read-only discovery can continue in parallel, but mutations should be grouped by root cause so the same surfaces are not patched independently by multiple agents.
|
||||
|
||||
## Documentation Prerequisites
|
||||
- `AGENTS.md`
|
||||
- `docs/qa/FIRST_TIME_USER_UX_AUDIT_20260315.md`
|
||||
- `docs/qa/feature-checks/FLOW.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
- `docs/operations/deployment/console.md`
|
||||
- `docs/operations/deployment/docker.md`
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
### FTU-OPS-001 - Re-baseline the first-time user journey matrix before more fixes
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: QA, Product Manager
|
||||
Task description:
|
||||
- Convert the audit into an explicit operator journey matrix: setup/identity-access, setup/trust-signing, setup/integrations, setup/topology/system, releases, ops/operations, security, evidence, and admin affordances. Each route, page-load, and page action must be mapped to either retained Playwright coverage, an identified gap, or a grouped defect bucket before more implementation starts.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Every finding in `FIRST_TIME_USER_UX_AUDIT_20260315.md` is mapped to a route, journey, and root-cause bucket.
|
||||
- [ ] Every route/page/action in the first-time operator journey is classified as covered, broken, or still requiring retained automation.
|
||||
- [ ] The remediation order is driven by operator value and root cause, not by whichever page was most recently open.
|
||||
|
||||
### FTU-OPS-002 - Repair the P0 blank-surface and route-contract blockers
|
||||
Status: TODO
|
||||
Dependency: FTU-OPS-001
|
||||
Owners: 3rd line support, Architect, Developer
|
||||
Task description:
|
||||
- Eliminate the three blank core surfaces and their contract mismatches: `/releases/versions/new`, `/releases/promotions`, and `/ops/operations`. Each route must render a truthful page shell, preserve user scope/context, and expose canonical guidance or actions rather than an empty `<main>`.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] The release create surface is fully functional and lands on the created canonical resource.
|
||||
- [ ] Promotions renders a real landing or list surface instead of an empty page.
|
||||
- [ ] Operations landing renders a real overview and links into its child workflows.
|
||||
- [ ] Retained Playwright journeys prove these pages render and their primary actions work on a live stack.
|
||||
|
||||
### FTU-OPS-003 - Make identity and tenancy self-serve instead of source-code driven
|
||||
Status: DONE
|
||||
Dependency: FTU-OPS-001
|
||||
Owners: QA, Product Manager, Architect, Developer
|
||||
Task description:
|
||||
- Close the identity-admin gaps around roles, users, tenants, and scope discoverability. This includes a proper scope catalog/picker, role detail visibility, edit/delete/archive flows where allowed, least-privilege defaults, and explicit credential/onboarding guidance.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Role creation no longer depends on free-text scope knowledge.
|
||||
- [ ] Existing roles can be understood from the UI through a detail view or equivalent surface.
|
||||
- [ ] Users, roles, and tenants expose truthful edit/delete/archive semantics or explicit limitations.
|
||||
- [ ] Add-user guidance explains credentials and defaults to least privilege.
|
||||
- [ ] Retained Playwright coverage exercises the real create/view/edit flows.
|
||||
|
||||
### FTU-OPS-004 - Repair trust/signing operator workflows and broken trust analytics
|
||||
Status: DONE
|
||||
Dependency: FTU-OPS-001
|
||||
Owners: QA, 3rd line support, Architect, Developer
|
||||
Task description:
|
||||
- Replace trust/signing admin anti-patterns with production-grade workflows. Broken analytics, raw `prompt()` destructive actions, no issuer actions, weak certificate affordances, and developer-note language all need to be corrected together so trust management feels operationally real.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Trust analytics loads correctly or shows a truthful error state with recovery guidance.
|
||||
- [ ] Rotate/Revoke flows use real modals with reason capture and impact language.
|
||||
- [ ] Issuers and certificates expose meaningful actions or explicit limitations.
|
||||
- [ ] Trust copy is operator-facing rather than developer-facing.
|
||||
- [ ] Retained Playwright journeys cover keys, issuers, certificates, analytics, and destructive-action confirmations.
|
||||
|
||||
### FTU-OPS-005 - Align onboarding, context, empty states, and naming across the product
|
||||
Status: TODO
|
||||
Dependency: FTU-OPS-001
|
||||
Owners: Product Manager, Architect, Developer, Documentation author
|
||||
Task description:
|
||||
- Remove the cross-cutting confusion patterns: inconsistent page names, duplicate pages, silent API failures, misleading health/empty states, unexplained toggles, and missing onboarding guidance. This is a product-contract cleanup, not just a copy pass.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] A first-time operator can discover the setup order from the product itself.
|
||||
- [ ] Sidebar, breadcrumb, document title, and H1 use one name per surface.
|
||||
- [ ] Silent API failures render truthful operator-facing error states.
|
||||
- [ ] Empty states tell the operator what to do next.
|
||||
- [ ] Retained Playwright journeys assert the corrected naming and error-state behavior on the affected routes.
|
||||
|
||||
### FTU-OPS-006 - Expand retained Playwright to cover every newly discovered operator step
|
||||
Status: DOING
|
||||
Dependency: FTU-OPS-001
|
||||
Owners: QA, Test Automation
|
||||
Task description:
|
||||
- For every new route/page/action discovered during this operator remediation program, add retained Playwright coverage before the iteration closes. The retained suite must describe real operator journeys, not just route visits.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Every newly discovered operator step is either automated or explicitly logged as an open gap with reason.
|
||||
- [ ] Aggregate audits include the new journey scripts.
|
||||
- [ ] Future iterations would recheck the same first-time-user behavior automatically.
|
||||
|
||||
## Grouped Remediation Matrix
|
||||
|
||||
| Journey / Surface | Audit issues | Root-cause theme | Planned grouped repair |
|
||||
| --- | --- | --- | --- |
|
||||
| Releases and release confidence | P0-4, P0-5, CC-2, CC-3, P3-7 | Blank core routes, scope/context loss, inconsistent canonical route ownership | Finish release-create contract repair, restore promotions landing, preserve operator scope through release routes, add retained release-create and promotions journeys. |
|
||||
| Operations landing and ops affordances | P0-6, P2-9, P2-10, P2-11, P2-21, P2-22, P3-11, P3-12 | Parent landing page missing, split canonical surfaces, contradictory status signals, weak empty/error guidance | Add truthful operations overview, cross-link notifications surfaces, fix contradictory runtime/status rendering, and retain the ops landing plus child actions as one operator journey. |
|
||||
| Identity, roles, tenants, and access admin | P0-1, P0-2, P0-3, P1-1 through P1-7, P1-14, P2-1 through P2-6 | Identity admin is create-only and source-code dependent; permissions are undiscoverable; admin objects lack detail and lifecycle actions | Build scope catalog + picker, role detail surface, truthful CRUD/edit semantics, least-privilege defaults, and onboarding guidance; retain add/view/edit/delete journeys. |
|
||||
| Trust and signing administration | P1-8, P1-9, P1-13, P2-7, P2-8, P3-2, P3-3, P3-4 | Broken analytics contract, destructive actions implemented as raw browser prompts, issuer/certificate workflows incomplete, operator copy not productized | Replace prompt flows with modal workflows, repair analytics API and UI states, add issuer/certificate affordances, and retain trust administration journeys end to end. |
|
||||
| Onboarding, topology, and system setup | P1-10, P2-12, P2-13, P2-14, P2-15, P2-16, P2-17, P2-18, CC-4, CC-5, CC-6, CC-9 | Product does not teach setup order; system status and setup surfaces are misleading or under-explained | Introduce operator guidance/checklist, repair misleading health/status language, improve branding/topology explanations, and retain first-time setup journeys with seeded and empty states. |
|
||||
| Security, evidence, naming, and error-state consistency | P1-11, P1-12, P2-19, P2-20, P3-5 through P3-10, CC-1, CC-7, CC-8, CC-10 | Naming contracts diverged across sidebar/title/H1, duplicate pages exist, API failures are silently swallowed, and demo tooling leaks into operator surfaces | Unify naming contracts, remove duplicate or dead-end routes, surface truthful error states, and retain the affected security/evidence journeys under one consistency sweep. |
|
||||
|
||||
## 3rd-Line Support Findings
|
||||
- Source-backed root cause: `/setup/identity-access` is still served by `AdminSettingsPageComponent`, a create-only administration surface with free-text permissions, no role detail, no edit flows, and no lifecycle actions. The Authority backend already exposes update, disable, suspend, resume, and impact-preview semantics, so the setup page is the limiting contract.
|
||||
- Source-backed root cause: trust destructive actions still use raw `window.prompt(...)` in `signing-key-dashboard.component.ts`, which is not acceptable for signing-key rotation and revocation.
|
||||
- Source-backed root cause: trust analytics calls `/api/v1/trust/analytics/*`, but the repo does not expose matching live backend endpoints. The current UI therefore presents a broken analytics tab instead of a truthful operational view.
|
||||
- Source-backed root cause: issuer and certificate setup views intentionally omit actionable operator affordances and present that omission as contract-note copy, which reads like an internal developer limitation instead of a product workflow.
|
||||
- Re-baselined audit note: the reported blank pages at `/releases/promotions` and `/ops/operations` are no longer source-backed. Current source already owns those routes with real components, so they must be revalidated live after deployment rather than treated as present-tense missing-page bugs.
|
||||
|
||||
## Product / Architecture Decisions
|
||||
- Decision: `Identity & Access` remains the canonical setup route, but it must surface the real Authority administration contract instead of a weaker create-only facade.
|
||||
- Decision: unsupported hard-delete semantics will be handled truthfully. Where the backend supports update, disable, suspend, or resume, the UI must expose those actions. Where hard delete is not in contract, the UI must say so clearly and offer the supported lifecycle alternative.
|
||||
- Decision: scope discoverability is a product requirement. Role create and edit flows must use a grouped in-app scope catalog with labels and descriptions instead of free-text scope entry.
|
||||
- Decision: trust destructive actions must move from browser prompts to in-app confirmation workflows with reason capture, impact language, and visible success or error outcomes.
|
||||
- Decision: trust analytics must not depend on dead endpoints. Until a richer analytics backend contract exists, the trust UI should derive operator-useful analytics from the live administration inventory instead of calling non-existent `/api/v1/trust/analytics/*` routes.
|
||||
|
||||
## First Repair Order
|
||||
- Batch 1: P0 blank surfaces and route-contract repair (`/releases/versions/new`, `/releases/promotions`, `/ops/operations`) because they block the main operator path.
|
||||
- Batch 2: Identity self-serve administration because role/scope discoverability prevents safe delegated use of the product.
|
||||
- Batch 3: Trust/signing workflows because broken analytics and raw prompt-based destructive actions block production readiness.
|
||||
- Batch 4: Cross-cutting naming, error-state, onboarding, and consistency repair to remove repeated operator confusion after the core workflows are functional.
|
||||
|
||||
## Active Implementation Batch
|
||||
- Batch 2 and Batch 3 are closed on the current live stack.
|
||||
- Closed issues in the grouped batch: P0-1, P0-2, P0-3, P1-1 through P1-9, P1-13, P1-14, P2-1 through P2-8, and P3-2 through P3-4.
|
||||
- Remaining open batch for the next step: FTU-OPS-002 (P0 release/operations surfaces) and FTU-OPS-005 (cross-cutting naming, error-state, onboarding, and duplicate-surface repair).
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-15 | Sprint created from `docs/qa/FIRST_TIME_USER_UX_AUDIT_20260315.md`, which documented 54 first-time-user issues across 40+ routes and showed that prior route- and journey-level closure claims were too narrow. | QA / Product Manager |
|
||||
| 2026-03-15 | Adopted the audit as the remediation baseline. The release-create defect already in `SPRINT_20260315_005` is now treated as one P0 slice inside a broader grouped operator-remediation program. | QA / Product Manager |
|
||||
| 2026-03-15 | Completed the 3rd-line support collapse of the UX audit into source-backed buckets. Confirmed identity self-serve and trust administration as the first major live source defects; reclassified promotions and operations blank-page claims as requiring live revalidation after deployment because current source already owns those routes. | 3rd line support |
|
||||
| 2026-03-15 | Recorded the product and architecture decisions for the first grouped implementation batch: upgrade the setup identity surface to expose the real Authority admin contract, replace trust prompt-based actions with modal workflows, and stop relying on dead trust analytics endpoints. | Product / Architect |
|
||||
| 2026-03-15 | Shipped the grouped identity/trust operator batch on the current live stack: scope catalog and role detail, truthful user and tenant lifecycle actions, in-app trust create/block/unblock/verify/revoke workflows, and derived trust analytics that no longer call dead endpoints. Focused backend/frontend test slices passed before live retest. | Developer |
|
||||
| 2026-03-15 | Replaced the stale admin/trust retained journey with `live-user-reported-admin-trust-check.mjs`, added step-level logging, aligned it to the repaired trust shell contract, and reran it cleanly on `https://stella-ops.local` with `failedCheckCount=0`. | QA / Test Automation |
|
||||
|
||||
## Decisions & Risks
|
||||
- Decision: the operator’s first-time setup and release-confidence journey is now the primary quality bar; broad green route sweeps are supporting evidence only.
|
||||
- Decision: findings will be fixed in grouped slices by root cause and journey, not one page at a time.
|
||||
- Risk: prior retained Playwright coverage is biased toward route/action reachability and misses self-serve clarity, destructive-action design, scope discoverability, and onboarding guidance.
|
||||
- Risk: some findings span frontend contracts, bootstrap auth configuration, and backend error handling, so frontend-only fixes may hide root causes instead of solving them.
|
||||
- Risk: the Authority backend does not currently expose hard-delete semantics for users, roles, or tenants, so the audit expectation of delete or archive must be translated into truthful supported lifecycle actions rather than mirrored literally.
|
||||
- Risk: the existing trust analytics UI assumes a backend contract that the repo does not implement. The derived-analytics fallback must remain obviously operator-focused and not pretend a richer backend exists.
|
||||
- Evidence: current live-stack proof for the closed identity/trust batch is stored at `src/Web/StellaOps.Web/output/playwright/live-user-reported-admin-trust-check.json` with a full operator step log and `failedCheckCount=0`.
|
||||
|
||||
## Next Checkpoints
|
||||
- Close the active release-create P0 slice and fold it into the broader remediation status.
|
||||
- Repair the remaining P0 release and operations surfaces on the intact stack before the next teardown.
|
||||
- Expand retained Playwright again for the next operator batch before restarting the wipe/rebuild loop.
|
||||
313
docs/qa/FIRST_TIME_USER_UX_AUDIT_20260315.md
Normal file
313
docs/qa/FIRST_TIME_USER_UX_AUDIT_20260315.md
Normal file
@@ -0,0 +1,313 @@
|
||||
# First-Time User UX Audit - 2026-03-15
|
||||
|
||||
**Auditor**: AI agent acting as first-time platform administrator
|
||||
**Stack**: Live local (stella-ops.local), logged in as admin/Admin@Stella2026!
|
||||
**Scope**: Every sidebar route and sub-tab, exercising create/edit/detail flows
|
||||
**Sprint context**: SPRINT_20260315_003 (Identity/Trust) and SPRINT_20260315_004 (Integrations)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
- **Total routes inspected**: 40+
|
||||
- **P0 (critical UX blockers)**: 6
|
||||
- **P1 (significant UX gaps)**: 14
|
||||
- **P2 (moderate issues)**: 22
|
||||
- **P3 (minor polish)**: 12
|
||||
- **Cross-cutting patterns**: 10
|
||||
|
||||
---
|
||||
|
||||
## P0 - Critical UX Blockers
|
||||
|
||||
### P0-1. Role permissions are free-text with no discoverability
|
||||
**Route**: `/setup/identity-access` > Roles tab > + Create Role
|
||||
**Detail**: The "Permissions" field is a plain `<textarea>` with placeholder `findings:read, vex:read, vuln:investigate`. A first-time user has zero way to discover what scopes are available. No autocomplete, no dropdown, no checkbox list, no catalog, no link to docs. The system has 149 scope permissions but none are surfaced in the UI.
|
||||
**Impact**: Role creation is impossible without internal knowledge or reading source code.
|
||||
**Recommendation**: Replace with a grouped checkbox/chip picker organized by domain (Security, Releases, Evidence, Ops, Setup) with descriptions per scope.
|
||||
|
||||
### P0-2. No role detail view exists
|
||||
**Route**: `/setup/identity-access` > Roles tab
|
||||
**Detail**: Clicking any role row (admin, operator, viewer) does nothing. No detail panel, no side drawer, no navigation. The only visible info is Name, Description ("Full platform access"), Users count, and Built-in flag. You cannot see what permissions any role actually grants.
|
||||
**Impact**: An operator cannot audit or understand existing role configurations.
|
||||
|
||||
### P0-3. No scope catalog or reference anywhere in the app
|
||||
**Route**: Global
|
||||
**Detail**: No "Available Permissions" page, no docs link, no in-app reference. The scope naming convention (module:action) is never explained. A new operator is completely blind.
|
||||
|
||||
### P0-4. "Create Release" header button leads to blank page
|
||||
**Route**: `/releases/versions/new`
|
||||
**Detail**: The primary "Create Release" CTA in the global header navigates to a page with a completely empty `<main>` element. No form, no heading, no description. This is the single most important action button in the product.
|
||||
**Impact**: The most prominent action in the UI is a dead end.
|
||||
|
||||
### P0-5. Promotions page is completely blank
|
||||
**Route**: `/releases/promotions`
|
||||
**Detail**: The `<main>` element has zero children. No heading, no description, no empty state, no guidance. Additionally, the global context bar shows "No regions defined" and "No env defined yet" (disabled buttons), and "Events: DEGRADED".
|
||||
**Impact**: A core release workflow page is a dead end.
|
||||
|
||||
### P0-6. Operations landing page is completely blank
|
||||
**Route**: `/ops/operations`
|
||||
**Detail**: The `<main>` element has zero children. This is the parent landing page for 7 sub-pages (Scheduled Jobs, Signals, Offline Kit, etc.).
|
||||
**Impact**: Users navigating from the sidebar hit a dead end.
|
||||
|
||||
---
|
||||
|
||||
## P1 - Significant UX Gaps
|
||||
|
||||
### P1-1. Role descriptions too vague to be actionable
|
||||
**Route**: `/setup/identity-access` > Roles tab
|
||||
**Detail**: Built-in roles show only one-line descriptions: "Full platform access" (admin), "Release and deployment operations" (operator), "Read-only access" (viewer). These tell you nothing about specific capabilities. Can an operator approve promotions? Can a viewer see security findings? No way to know.
|
||||
|
||||
### P1-2. Role dropdown defaults to admin (most privileged)
|
||||
**Route**: `/setup/identity-access` > Users tab > + Add User
|
||||
**Detail**: The Role `<select>` pre-selects "admin". Security-by-default should pre-select "viewer" (least privilege).
|
||||
|
||||
### P1-3. No role descriptions in the user creation dropdown
|
||||
**Route**: `/setup/identity-access` > Users tab > + Add User
|
||||
**Detail**: The role dropdown shows bare names (admin, operator, viewer, qa-role-...) with no hint about what each grants.
|
||||
|
||||
### P1-4. No delete/deactivate for users
|
||||
**Route**: `/setup/identity-access` > Users tab
|
||||
**Detail**: No delete, deactivate, or disable button on user rows. No row actions at all. No right-click context menu.
|
||||
|
||||
### P1-5. No delete for roles
|
||||
**Route**: `/setup/identity-access` > Roles tab
|
||||
**Detail**: No delete button on role rows. Custom roles with 0 users cannot be removed.
|
||||
|
||||
### P1-6. No delete/archive for tenants
|
||||
**Route**: `/setup/identity-access` > Tenants tab
|
||||
**Detail**: No delete or archive button. Tenants with 0 users are stuck permanently.
|
||||
|
||||
### P1-7. No edit action on any Identity entity
|
||||
**Route**: `/setup/identity-access` (all tabs)
|
||||
**Detail**: Cannot change a user's role after creation. Cannot edit a role's permissions. Cannot rename a tenant. Click-through on any row does nothing. The only workflow is create-and-forget.
|
||||
|
||||
### P1-8. Signing key Rotate/Revoke uses raw browser prompt()
|
||||
**Route**: `/setup/trust-signing/keys`
|
||||
**Detail**: Clicking "Rotate" shows a native browser `window.prompt("Rotation reason for...")` dialog. Same for "Revoke". These should be proper in-app modal dialogs with confirmation, reason field, and impact warning.
|
||||
**Impact**: Destructive actions on cryptographic keys should never use browser prompts.
|
||||
|
||||
### P1-9. Trust Analytics tab is broken (API errors)
|
||||
**Route**: `/setup/trust-signing/analytics`
|
||||
**Detail**: Shows "Failed to load analytics data. Please try again." Console errors: `/api/v1/trust/analytics/summary` and `/api/v1/trust/analytics/verification` return server errors.
|
||||
|
||||
### P1-10. No setup onboarding wizard or getting-started flow
|
||||
**Route**: Global / Setup section
|
||||
**Detail**: No guided first-time setup flow. A new operator lands on the dashboard and must discover Setup sections manually from the sidebar. Should have a checklist: 1) Configure identity, 2) Set up trust, 3) Add integrations, 4) Review topology.
|
||||
|
||||
### P1-11. Security page naming chaos (3 different names)
|
||||
**Route**: `/security`
|
||||
**Detail**: Sidebar says "Vulnerabilities", breadcrumb says "Risk Overview", H1 says "Security / Posture". Three different names for the same page creates significant confusion.
|
||||
|
||||
### P1-12. /security and /security/posture are near-duplicate pages
|
||||
**Route**: `/security` and `/security/posture`
|
||||
**Detail**: Both render with H1 "Security / Posture", same structure. Two sidebar items leading to essentially the same page. Additionally, `/security/posture` appears stuck in "Loading security overview..." state.
|
||||
|
||||
### P1-13. Trusted Issuers have no actions at all
|
||||
**Route**: `/setup/trust-signing/issuers`
|
||||
**Detail**: Unlike Signing Keys (which have View/Rotate/Revoke), the Issuers table has zero actions. The only issuer ("Demo Prod Root CA") shows trust level "Untrusted" but there is no way to change it, promote it, block it, or remove it.
|
||||
|
||||
### P1-14. No password/credential explanation in Add User
|
||||
**Route**: `/setup/identity-access` > Users tab > + Add User
|
||||
**Detail**: Form has Username, Email, Display Name, Role. No password field. No explanation of how the new user gets their credentials (email invite? default password? OIDC redirect only?). A first-time admin would be confused.
|
||||
|
||||
---
|
||||
|
||||
## P2 - Moderate Issues
|
||||
|
||||
### P2-1. No search or filter on Identity tables
|
||||
**Route**: `/setup/identity-access` (Users, Roles, Tenants tabs)
|
||||
**Detail**: No search box, no filter dropdowns on any of the three tables.
|
||||
|
||||
### P2-2. No sorting controls on Identity tables
|
||||
**Route**: `/setup/identity-access` (all tabs)
|
||||
**Detail**: Column headers are not sortable. Built-in roles are alphabetically mixed with custom roles.
|
||||
|
||||
### P2-3. Tenant "Lifecycle" column shows identical boilerplate
|
||||
**Route**: `/setup/identity-access` > Tenants tab
|
||||
**Detail**: Every tenant shows the same text: "Branding and policies are managed from the canonical setup surfaces." This is not lifecycle information.
|
||||
|
||||
### P2-4. Tenant Isolation Mode not explained
|
||||
**Route**: `/setup/identity-access` > Tenants > + Add Tenant
|
||||
**Detail**: "Shared" / "Dedicated" dropdown with zero tooltip or description.
|
||||
|
||||
### P2-5. OAuth Clients tab is a dead end
|
||||
**Route**: `/setup/identity-access` > OAuth Clients tab
|
||||
**Detail**: Shows disclaimer: "registration and secret rotation remain outside this setup tab until the full guided flow is shipped" but doesn't tell the user WHERE to do these things.
|
||||
|
||||
### P2-6. API Tokens tab is a dead end
|
||||
**Route**: `/setup/identity-access` > API Tokens tab
|
||||
**Detail**: Says "Token issuance and revocation are not exposed on this setup route yet" with no alternative path.
|
||||
|
||||
### P2-7. Certificates show raw UUIDs for Issuer/Key References
|
||||
**Route**: `/setup/trust-signing/certificates`
|
||||
**Detail**: Shows `4ac7e1d4-7a2e-4b4d-9e12-5d42e3168a91` instead of human-readable names. Operator must mentally cross-reference.
|
||||
|
||||
### P2-8. Certificate "Expiring Soon" with no action
|
||||
**Route**: `/setup/trust-signing/certificates`
|
||||
**Detail**: Certificate shows "Expiring Soon" (Mar 18, 3 days away) but has no Renew/Rotate button or urgency banner.
|
||||
|
||||
### P2-9. Two notification surfaces with no cross-link
|
||||
**Route**: `/setup/notifications` and `/ops/operations/notifications`
|
||||
**Detail**: Setup Notifications (rich admin with Rules/Channels/Templates/Simulator/Config) vs Ops Notifications (inline forms). No explanation of which is canonical. Sidebar places "Notifications" under Operations, not under Setup.
|
||||
|
||||
### P2-10. Ops Notifications: "Refresh data" button permanently disabled
|
||||
**Route**: `/ops/operations/notifications`
|
||||
**Detail**: Header refresh button is disabled with no tooltip explanation.
|
||||
|
||||
### P2-11. Ops Notifications: Channel dropdown in Rules is empty
|
||||
**Route**: `/ops/operations/notifications`
|
||||
**Detail**: The "Channel" combobox in rule creation shows no options because no channels exist, but there's no "create a channel first" guidance.
|
||||
|
||||
### P2-12. Branding page has no tenant selector
|
||||
**Route**: `/setup/tenant-branding`
|
||||
**Detail**: Branding edits apply to globally selected tenant (from header bar), but this is never stated.
|
||||
|
||||
### P2-13. Branding "Border Colors" and "Status Colors" sections empty
|
||||
**Route**: `/setup/tenant-branding`
|
||||
**Detail**: Section headers appear but no input fields beneath them (unlike Background/Text/Brand).
|
||||
|
||||
### P2-14. All 10 topology environments show "degraded" with no explanation
|
||||
**Route**: `/setup/topology/overview`
|
||||
**Detail**: Overview shows "Healthy 0 / Degraded 10 / Unhealthy 0". Every environment is degraded but no explanation of why or what to fix.
|
||||
|
||||
### P2-15. All topology environments show "0 targets"
|
||||
**Route**: `/setup/topology/overview` and `/setup/topology/targets`
|
||||
**Detail**: Every environment lists 0 targets, yet the dashboard shows active deployments. Mismatch confuses operators.
|
||||
|
||||
### P2-16. System health falsely claims "All systems operational"
|
||||
**Route**: `/setup/system`
|
||||
**Detail**: The Health Check card says "All systems operational" without running any checks. False-positive confidence signal.
|
||||
|
||||
### P2-17. Integrations "Activity stream is coming soon" placeholder
|
||||
**Route**: `/setup/integrations` > Hub tab
|
||||
**Detail**: Recent Activity section shows a "coming soon" stub.
|
||||
|
||||
### P2-18. Failed integration has no visible error message in list
|
||||
**Route**: `/setup/integrations/registries`
|
||||
**Detail**: "QA Harbor" shows Status: Failed, Health: Unhealthy but no inline error message or troubleshooting link in the table row.
|
||||
|
||||
### P2-19. /security/unknowns swallows API errors silently
|
||||
**Route**: `/security/unknowns`
|
||||
**Detail**: 4 console errors (500s from `/api/v1/scanner/unknowns` and `/api/v1/scanner/unknowns/stats`) but UI shows clean empty table.
|
||||
|
||||
### P2-20. Security Reports: Risk Report tab shows embedded triage view
|
||||
**Route**: `/security/reports`
|
||||
**Detail**: The "Risk Report" tab renders the Artifact Triage workspace inside Reports instead of an actual report. Confusing overlap.
|
||||
|
||||
### P2-21. Offline Kit: activity contradicts stats
|
||||
**Route**: `/ops/operations/offline-kit`
|
||||
**Detail**: Recent Activity shows "Loaded offline bundle v2025.01.15" and "Verified 45 assets" but stats show 0 bundles loaded and 0 assets verified.
|
||||
|
||||
### P2-22. JobEngine: console DENY contradicts displayed "Granted"
|
||||
**Route**: `/ops/operations/jobengine`
|
||||
**Detail**: Console logs `[TenantAuth] DENY: jobengine:operate` but the UI shows "Operate Jobs: Granted".
|
||||
|
||||
---
|
||||
|
||||
## P3 - Minor Polish
|
||||
|
||||
### P3-1. Header action button changes contextually without explanation
|
||||
**Route**: Global
|
||||
**Detail**: Top-right button alternates between "ADD TARGET", "Add Integration", "Create Release", "Create Hotfix", "Export Report" depending on page. No visual cue explaining the context.
|
||||
|
||||
### P3-2. Trust overview cards show developer jargon
|
||||
**Route**: `/setup/trust-signing`
|
||||
**Detail**: Subtitles like "Administration inventory projection" and "Routed from live administration projection" are technical jargon, not helpful to operators.
|
||||
|
||||
### P3-3. Trust disclaimer banners read as developer notes
|
||||
**Route**: `/setup/trust-signing` (multiple tabs)
|
||||
**Detail**: "Usage statistics, fingerprint material, and expiry policy are not part of the current administration contract..." should be hidden or rephrased.
|
||||
|
||||
### P3-4. No "Add" buttons for trust entities
|
||||
**Route**: `/setup/trust-signing` (Signing Keys, Issuers, Certificates tabs)
|
||||
**Detail**: No way to add a new signing key, issuer, or certificate from the UI. Only Watchlist has a create action.
|
||||
|
||||
### P3-5. Inconsistent page heading capitalization
|
||||
**Route**: Multiple
|
||||
**Detail**: "Artifact workspace" (lowercase w), "Audit bundles" (lowercase b) vs "Security Reports", "Export Center" (title case).
|
||||
|
||||
### P3-6. Replay & Verify naming mismatch
|
||||
**Route**: `/evidence/verify-replay`
|
||||
**Detail**: Sidebar says "Replay & Verify", page title says "Verify & Replay", H1 says "Verdict Replay". Three names.
|
||||
|
||||
### P3-7. Health page naming mismatch
|
||||
**Route**: `/releases/health`
|
||||
**Detail**: Sidebar says "Health", H1 says "Environment Posture". Description text "Environment . region" looks like a template placeholder.
|
||||
|
||||
### P3-8. Topology map labels truncated
|
||||
**Route**: `/setup/topology/map`
|
||||
**Detail**: Labels like "Production US E..." and "Production EU W..." cut off without tooltips.
|
||||
|
||||
### P3-9. Decision Capsules uses H2 instead of H1
|
||||
**Route**: `/evidence/capsules`
|
||||
**Detail**: Uses H2 for main heading, inconsistent with all other pages that use H1.
|
||||
|
||||
### P3-10. Audit Bundles timestamps in raw ISO format
|
||||
**Route**: `/triage/audit-bundles`
|
||||
**Detail**: Timestamps not human-friendly. Full SHA-256 hashes shown without truncation or copy button.
|
||||
|
||||
### P3-11. Signals: 60% error rate with no alert styling
|
||||
**Route**: `/ops/operations/signals`
|
||||
**Detail**: Metrics show "Error rate: 60%" in normal styling. Should use warning/error visual treatment.
|
||||
|
||||
### P3-12. Export button disabled before diagnostics run
|
||||
**Route**: `/ops/operations/doctor`
|
||||
**Detail**: No tooltip explaining "Run a check first to enable export".
|
||||
|
||||
---
|
||||
|
||||
## Cross-Cutting Patterns
|
||||
|
||||
### CC-1. Naming inconsistency is the single worst pattern
|
||||
At least 4 routes have different names in sidebar, breadcrumb, page title, and H1. Examples:
|
||||
- `/security`: sidebar="Vulnerabilities", breadcrumb="Risk Overview", H1="Security / Posture"
|
||||
- `/evidence/verify-replay`: sidebar="Replay & Verify", title="Verify & Replay", H1="Verdict Replay"
|
||||
- `/releases/health`: sidebar="Health", H1="Environment Posture"
|
||||
|
||||
### CC-2. Three completely blank pages
|
||||
`/releases/promotions`, `/releases/versions/new`, `/ops/operations` render empty `<main>` elements with zero content.
|
||||
|
||||
### CC-3. Context bar inconsistency
|
||||
Some routes show "4 regions" / "All environments" (working), others show "No regions defined" / "No env defined yet" (disabled). Depends on whether URL includes `?regions=...` query params.
|
||||
|
||||
### CC-4. Missing empty-state guidance
|
||||
When tables are empty, most show "No X found" but don't explain how to populate them (how to ingest SBOMs, create capsules, generate audit events).
|
||||
|
||||
### CC-5. Events stream status flickers
|
||||
Some pages show "Events: CONNECTED", others "Events: DEGRADED" in the same session.
|
||||
|
||||
### CC-6. Stale timestamps with no visual warning
|
||||
Triage artifact last scan from 2024, Signal probe data from 6 days ago -- no staleness indicators.
|
||||
|
||||
### CC-7. Developer tooling exposed to end users
|
||||
Evidence Overview has "State mode" buttons (Normal/Degraded/Empty) that appear to be developer/demo tooling.
|
||||
|
||||
### CC-8. Duplicate pages in sidebar
|
||||
`/security` and `/security/posture` are near-duplicates with the same H1.
|
||||
|
||||
### CC-9. Operator/Admin toggle unexplained
|
||||
Evidence Overview and Export Center have an Operator/Admin mode toggle with no explanation of what changes.
|
||||
|
||||
### CC-10. Console errors silently swallowed
|
||||
Multiple pages have backend API errors (500s) but show clean empty tables instead of error states.
|
||||
|
||||
---
|
||||
|
||||
## Priority Matrix
|
||||
|
||||
| Priority | Count | Key Theme |
|
||||
|----------|-------|-----------|
|
||||
| P0 | 6 | Blank pages, scope discoverability |
|
||||
| P1 | 14 | Missing CRUD, broken features, no guidance |
|
||||
| P2 | 22 | Dead ends, contradictions, missing explanations |
|
||||
| P3 | 12 | Naming, polish, minor inconsistencies |
|
||||
| Cross-cutting | 10 | Naming chaos, empty states, context bar |
|
||||
|
||||
## Top 5 Actions for Maximum Self-Serve Impact
|
||||
|
||||
1. **Scope picker for role creation** - Replace free-text permissions with grouped checkbox picker + descriptions
|
||||
2. **Fix the 3 blank pages** - `/releases/promotions`, `/releases/versions/new`, `/ops/operations`
|
||||
3. **Add role detail view** - Click-through on role rows showing all assigned scopes
|
||||
4. **Add edit/delete on Identity entities** - Users, Roles, Tenants need full CRUD
|
||||
5. **Unify naming** - Each page should have ONE name used consistently in sidebar, breadcrumb, title, and H1
|
||||
@@ -156,6 +156,105 @@ public sealed class ConsoleAdminEndpointsTests
|
||||
Assert.Equal("invalid_email", payload!.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateUser_DisableUser_AndEnableUser_PersistLifecycleAndRoles()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 2, 20, 13, 40, 0, TimeSpan.Zero);
|
||||
var timeProvider = new FakeTimeProvider(now);
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var users = new InMemoryUserRepository();
|
||||
var roles = new InMemoryRoleRepository();
|
||||
|
||||
await roles.CreateAsync("default", new RoleEntity
|
||||
{
|
||||
Id = Guid.Parse("a9e2fd75-df4e-4485-b23f-1ec573f59138"),
|
||||
TenantId = "default",
|
||||
Name = "role/console-viewer",
|
||||
DisplayName = "Console Viewer",
|
||||
Description = "Read-only console access",
|
||||
IsSystem = false,
|
||||
Metadata = "{}",
|
||||
CreatedAt = now.AddDays(-5),
|
||||
UpdatedAt = now.AddDays(-5),
|
||||
});
|
||||
await roles.CreateAsync("default", new RoleEntity
|
||||
{
|
||||
Id = Guid.Parse("f526b104-24f7-46f1-ab52-3c3dfd95cb0c"),
|
||||
TenantId = "default",
|
||||
Name = "role/release-operator",
|
||||
DisplayName = "Release Operator",
|
||||
Description = "Operate releases",
|
||||
IsSystem = false,
|
||||
Metadata = "{}",
|
||||
CreatedAt = now.AddDays(-5),
|
||||
UpdatedAt = now.AddDays(-5),
|
||||
});
|
||||
|
||||
await using var app = await CreateApplicationAsync(timeProvider, sink, users, roleRepository: roles);
|
||||
|
||||
var principalAccessor = app.Services.GetRequiredService<AdminTestPrincipalAccessor>();
|
||||
principalAccessor.Principal = CreatePrincipal(
|
||||
tenant: "default",
|
||||
scopes: new[]
|
||||
{
|
||||
StellaOpsScopes.UiAdmin,
|
||||
StellaOpsScopes.AuthorityUsersRead,
|
||||
StellaOpsScopes.AuthorityUsersWrite,
|
||||
},
|
||||
expiresAt: now.AddMinutes(10));
|
||||
|
||||
using var client = CreateTestClient(app);
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(AdminAuthenticationDefaults.AuthenticationScheme);
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "default");
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/console/admin/users",
|
||||
new
|
||||
{
|
||||
username = "operator.alice",
|
||||
email = "operator.alice@example.com",
|
||||
displayName = "Operator Alice",
|
||||
roles = new[] { "role/console-viewer" }
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<UserSummary>();
|
||||
Assert.NotNull(created);
|
||||
|
||||
var updateResponse = await client.PatchAsJsonAsync(
|
||||
$"/console/admin/users/{created!.Id}",
|
||||
new
|
||||
{
|
||||
displayName = "Release Operator Alice",
|
||||
roles = new[] { "role/release-operator" }
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);
|
||||
var updated = await updateResponse.Content.ReadFromJsonAsync<UserSummary>();
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal("Release Operator Alice", updated!.DisplayName);
|
||||
Assert.Equal(new[] { "role/release-operator" }, updated.Roles);
|
||||
|
||||
var disableResponse = await client.PostAsJsonAsync($"/console/admin/users/{created.Id}/disable", new { });
|
||||
Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode);
|
||||
var disabled = await disableResponse.Content.ReadFromJsonAsync<UserSummary>();
|
||||
Assert.NotNull(disabled);
|
||||
Assert.Equal("disabled", disabled!.Status);
|
||||
|
||||
var enableResponse = await client.PostAsJsonAsync($"/console/admin/users/{created.Id}/enable", new { });
|
||||
Assert.Equal(HttpStatusCode.OK, enableResponse.StatusCode);
|
||||
var enabled = await enableResponse.Content.ReadFromJsonAsync<UserSummary>();
|
||||
Assert.NotNull(enabled);
|
||||
Assert.Equal("active", enabled!.Status);
|
||||
|
||||
var listResponse = await client.GetAsync("/console/admin/users");
|
||||
var payload = await listResponse.Content.ReadFromJsonAsync<UserListPayload>();
|
||||
Assert.NotNull(payload);
|
||||
var listed = Assert.Single(payload!.Users.Where(static user => user.Username == "operator.alice"));
|
||||
Assert.Equal("Release Operator Alice", listed.DisplayName);
|
||||
Assert.Equal(new[] { "role/release-operator" }, listed.Roles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RolesList_ExposesNamedDefaults_AndCreateRolePersists()
|
||||
{
|
||||
@@ -216,6 +315,90 @@ public sealed class ConsoleAdminEndpointsTests
|
||||
Assert.Contains(reloaded!.Roles, role => role.Name == "security-analyst");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateRole_RefreshesScopes_AndPreviewImpactCountsAffectedUsers()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 2, 20, 13, 55, 0, TimeSpan.Zero);
|
||||
var timeProvider = new FakeTimeProvider(now);
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var users = new InMemoryUserRepository();
|
||||
var roles = new InMemoryRoleRepository();
|
||||
var permissions = new InMemoryPermissionRepository();
|
||||
|
||||
await using var app = await CreateApplicationAsync(
|
||||
timeProvider,
|
||||
sink,
|
||||
users,
|
||||
roleRepository: roles,
|
||||
permissionRepository: permissions);
|
||||
|
||||
var principalAccessor = app.Services.GetRequiredService<AdminTestPrincipalAccessor>();
|
||||
principalAccessor.Principal = CreatePrincipal(
|
||||
tenant: "default",
|
||||
scopes: new[]
|
||||
{
|
||||
StellaOpsScopes.UiAdmin,
|
||||
StellaOpsScopes.AuthorityUsersWrite,
|
||||
StellaOpsScopes.AuthorityRolesRead,
|
||||
StellaOpsScopes.AuthorityRolesWrite,
|
||||
},
|
||||
expiresAt: now.AddMinutes(10));
|
||||
|
||||
using var client = CreateTestClient(app);
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(AdminAuthenticationDefaults.AuthenticationScheme);
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "default");
|
||||
|
||||
var createRoleResponse = await client.PostAsJsonAsync(
|
||||
"/console/admin/roles",
|
||||
new
|
||||
{
|
||||
roleId = "security-analyst",
|
||||
displayName = "Security Analyst",
|
||||
scopes = new[] { "findings:read", "vex:read" }
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, createRoleResponse.StatusCode);
|
||||
var createdRole = await createRoleResponse.Content.ReadFromJsonAsync<RoleSummary>();
|
||||
Assert.NotNull(createdRole);
|
||||
|
||||
var createUserResponse = await client.PostAsJsonAsync(
|
||||
"/console/admin/users",
|
||||
new
|
||||
{
|
||||
username = "security.alice",
|
||||
email = "security.alice@example.com",
|
||||
displayName = "Security Alice",
|
||||
roles = new[] { "security-analyst" }
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, createUserResponse.StatusCode);
|
||||
|
||||
var updateResponse = await client.PatchAsJsonAsync(
|
||||
$"/console/admin/roles/{createdRole!.Id}",
|
||||
new
|
||||
{
|
||||
displayName = "Security Analyst Plus",
|
||||
scopes = new[] { "findings:read", "vex:read", "vuln:investigate" }
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);
|
||||
var updated = await updateResponse.Content.ReadFromJsonAsync<RoleSummary>();
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal("security-analyst", updated!.Name);
|
||||
Assert.Equal("Security Analyst Plus", updated.Description);
|
||||
Assert.Contains("vuln:investigate", updated.Permissions);
|
||||
|
||||
var previewResponse = await client.PostAsJsonAsync(
|
||||
$"/console/admin/roles/{createdRole.Id}/preview-impact",
|
||||
new { });
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, previewResponse.StatusCode);
|
||||
var preview = await previewResponse.Content.ReadFromJsonAsync<RoleImpactPayload>();
|
||||
Assert.NotNull(preview);
|
||||
Assert.Equal(1, preview!.AffectedUsers);
|
||||
Assert.Equal(0, preview.AffectedClients);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TenantsList_MergesCatalog_AndCreateTenantPersists()
|
||||
{
|
||||
@@ -908,7 +1091,17 @@ public sealed class ConsoleAdminEndpointsTests
|
||||
|
||||
public Task<bool> UpdateAsync(UserEntity user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
lock (_sync)
|
||||
{
|
||||
var index = _users.FindIndex(existing => existing.TenantId == user.TenantId && existing.Id == user.Id);
|
||||
if (index < 0)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
_users[index] = user;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
@@ -1235,6 +1428,7 @@ public sealed class ConsoleAdminEndpointsTests
|
||||
private sealed record UserListPayload(IReadOnlyList<UserSummary> Users, int Count);
|
||||
private sealed record RoleListPayload(IReadOnlyList<RoleSummary> Roles, int Count);
|
||||
private sealed record TenantListPayload(IReadOnlyList<TenantSummary> Tenants, int Count);
|
||||
private sealed record RoleImpactPayload(int AffectedUsers, int AffectedClients, string? Message);
|
||||
private sealed record ClientListPayload(IReadOnlyList<ClientSummary> Clients, int Count, string SelectedTenant);
|
||||
private sealed record ClientSummary(
|
||||
string ClientId,
|
||||
|
||||
@@ -593,10 +593,86 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
HttpContext httpContext,
|
||||
string userId,
|
||||
UpdateUserRequest request,
|
||||
IUserRepository userRepository,
|
||||
IRoleRepository roleRepository,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var user = await ResolveUserEntityAsync(tenantId, userId, userRepository, cancellationToken).ConfigureAwait(false);
|
||||
if (user is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "user_not_found", userId });
|
||||
}
|
||||
|
||||
var updatedRoles = request.Roles is null ? null : NormalizeRoles(request.Roles);
|
||||
IReadOnlyList<RoleEntity>? resolvedRoles = null;
|
||||
if (updatedRoles is not null)
|
||||
{
|
||||
resolvedRoles = await ResolveRequestedRolesAsync(tenantId, updatedRoles, roleRepository, cancellationToken).ConfigureAwait(false);
|
||||
var missingRoles = updatedRoles
|
||||
.Except(resolvedRoles.Select(static role => role.Name), StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
if (missingRoles.Length > 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = "roles_not_found", roles = missingRoles });
|
||||
}
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var updatedUser = new UserEntity
|
||||
{
|
||||
Id = user.Id,
|
||||
TenantId = user.TenantId,
|
||||
Username = user.Username,
|
||||
Email = user.Email,
|
||||
DisplayName = NormalizeOptional(request.DisplayName) ?? user.DisplayName,
|
||||
PasswordHash = user.PasswordHash,
|
||||
PasswordSalt = user.PasswordSalt,
|
||||
Enabled = user.Enabled,
|
||||
EmailVerified = user.EmailVerified,
|
||||
MfaEnabled = user.MfaEnabled,
|
||||
MfaSecret = user.MfaSecret,
|
||||
MfaBackupCodes = user.MfaBackupCodes,
|
||||
FailedLoginAttempts = user.FailedLoginAttempts,
|
||||
LockedUntil = user.LockedUntil,
|
||||
LastLoginAt = user.LastLoginAt,
|
||||
PasswordChangedAt = user.PasswordChangedAt,
|
||||
Settings = user.Settings,
|
||||
Metadata = updatedRoles is null ? user.Metadata : UpdateUserMetadata(user.Metadata, updatedRoles),
|
||||
CreatedAt = user.CreatedAt,
|
||||
UpdatedAt = now,
|
||||
CreatedBy = user.CreatedBy,
|
||||
};
|
||||
|
||||
var persisted = await userRepository.UpdateAsync(updatedUser, cancellationToken).ConfigureAwait(false);
|
||||
if (!persisted)
|
||||
{
|
||||
return Results.Problem("Failed to persist user updates.");
|
||||
}
|
||||
|
||||
if (updatedRoles is not null && resolvedRoles is not null)
|
||||
{
|
||||
var currentRoles = await roleRepository.GetUserRolesAsync(tenantId, user.Id, cancellationToken).ConfigureAwait(false);
|
||||
foreach (var role in currentRoles.Where(role => !updatedRoles.Contains(role.Name, StringComparer.OrdinalIgnoreCase)))
|
||||
{
|
||||
await roleRepository.RemoveFromUserAsync(tenantId, user.Id, role.Id, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var grantedBy = httpContext.User.FindFirstValue(OpenIddictConstants.Claims.PreferredUsername)
|
||||
?? httpContext.User.FindFirstValue(StellaOpsClaimTypes.Subject)
|
||||
?? "console-admin";
|
||||
|
||||
foreach (var role in resolvedRoles.Where(role => !currentRoles.Any(existing => existing.Id == role.Id)))
|
||||
{
|
||||
await roleRepository.AssignToUserAsync(tenantId, user.Id, role.Id, grantedBy, expiresAt: null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
var effectiveRoles = updatedRoles ?? await ResolveUserRolesAsync(updatedUser, roleRepository, cancellationToken).ConfigureAwait(false);
|
||||
var summary = ToAdminUserSummary(updatedUser, now, effectiveRoles);
|
||||
|
||||
await WriteAdminAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
@@ -604,19 +680,64 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
"authority.admin.users.update",
|
||||
AuthEventOutcome.Success,
|
||||
null,
|
||||
BuildProperties(("user.id", userId)),
|
||||
BuildProperties(("tenant.id", tenantId), ("user.id", summary.Id)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new { message = "User update: implementation pending" });
|
||||
return Results.Ok(summary);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DisableUser(
|
||||
HttpContext httpContext,
|
||||
string userId,
|
||||
IUserRepository userRepository,
|
||||
IRoleRepository roleRepository,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var user = await ResolveUserEntityAsync(tenantId, userId, userRepository, cancellationToken).ConfigureAwait(false);
|
||||
if (user is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "user_not_found", userId });
|
||||
}
|
||||
|
||||
var updatedUser = new UserEntity
|
||||
{
|
||||
Id = user.Id,
|
||||
TenantId = user.TenantId,
|
||||
Username = user.Username,
|
||||
Email = user.Email,
|
||||
DisplayName = user.DisplayName,
|
||||
PasswordHash = user.PasswordHash,
|
||||
PasswordSalt = user.PasswordSalt,
|
||||
Enabled = false,
|
||||
EmailVerified = user.EmailVerified,
|
||||
MfaEnabled = user.MfaEnabled,
|
||||
MfaSecret = user.MfaSecret,
|
||||
MfaBackupCodes = user.MfaBackupCodes,
|
||||
FailedLoginAttempts = user.FailedLoginAttempts,
|
||||
LockedUntil = user.LockedUntil,
|
||||
LastLoginAt = user.LastLoginAt,
|
||||
PasswordChangedAt = user.PasswordChangedAt,
|
||||
Settings = user.Settings,
|
||||
Metadata = user.Metadata,
|
||||
CreatedAt = user.CreatedAt,
|
||||
UpdatedAt = timeProvider.GetUtcNow(),
|
||||
CreatedBy = user.CreatedBy,
|
||||
};
|
||||
|
||||
var persisted = await userRepository.UpdateAsync(updatedUser, cancellationToken).ConfigureAwait(false);
|
||||
if (!persisted)
|
||||
{
|
||||
return Results.Problem("Failed to disable user.");
|
||||
}
|
||||
|
||||
var summary = ToAdminUserSummary(
|
||||
updatedUser,
|
||||
timeProvider.GetUtcNow(),
|
||||
await ResolveUserRolesAsync(updatedUser, roleRepository, cancellationToken).ConfigureAwait(false));
|
||||
|
||||
await WriteAdminAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
@@ -624,19 +745,64 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
"authority.admin.users.disable",
|
||||
AuthEventOutcome.Success,
|
||||
null,
|
||||
BuildProperties(("user.id", userId)),
|
||||
BuildProperties(("tenant.id", tenantId), ("user.id", summary.Id)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new { message = "User disable: implementation pending" });
|
||||
return Results.Ok(summary);
|
||||
}
|
||||
|
||||
private static async Task<IResult> EnableUser(
|
||||
HttpContext httpContext,
|
||||
string userId,
|
||||
IUserRepository userRepository,
|
||||
IRoleRepository roleRepository,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var user = await ResolveUserEntityAsync(tenantId, userId, userRepository, cancellationToken).ConfigureAwait(false);
|
||||
if (user is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "user_not_found", userId });
|
||||
}
|
||||
|
||||
var updatedUser = new UserEntity
|
||||
{
|
||||
Id = user.Id,
|
||||
TenantId = user.TenantId,
|
||||
Username = user.Username,
|
||||
Email = user.Email,
|
||||
DisplayName = user.DisplayName,
|
||||
PasswordHash = user.PasswordHash,
|
||||
PasswordSalt = user.PasswordSalt,
|
||||
Enabled = true,
|
||||
EmailVerified = user.EmailVerified,
|
||||
MfaEnabled = user.MfaEnabled,
|
||||
MfaSecret = user.MfaSecret,
|
||||
MfaBackupCodes = user.MfaBackupCodes,
|
||||
FailedLoginAttempts = 0,
|
||||
LockedUntil = null,
|
||||
LastLoginAt = user.LastLoginAt,
|
||||
PasswordChangedAt = user.PasswordChangedAt,
|
||||
Settings = user.Settings,
|
||||
Metadata = user.Metadata,
|
||||
CreatedAt = user.CreatedAt,
|
||||
UpdatedAt = timeProvider.GetUtcNow(),
|
||||
CreatedBy = user.CreatedBy,
|
||||
};
|
||||
|
||||
var persisted = await userRepository.UpdateAsync(updatedUser, cancellationToken).ConfigureAwait(false);
|
||||
if (!persisted)
|
||||
{
|
||||
return Results.Problem("Failed to enable user.");
|
||||
}
|
||||
|
||||
var summary = ToAdminUserSummary(
|
||||
updatedUser,
|
||||
timeProvider.GetUtcNow(),
|
||||
await ResolveUserRolesAsync(updatedUser, roleRepository, cancellationToken).ConfigureAwait(false));
|
||||
|
||||
await WriteAdminAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
@@ -644,10 +810,10 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
"authority.admin.users.enable",
|
||||
AuthEventOutcome.Success,
|
||||
null,
|
||||
BuildProperties(("user.id", userId)),
|
||||
BuildProperties(("tenant.id", tenantId), ("user.id", summary.Id)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new { message = "User enable: implementation pending" });
|
||||
return Results.Ok(summary);
|
||||
}
|
||||
|
||||
// ========== ROLE ENDPOINTS ==========
|
||||
@@ -796,10 +962,101 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
HttpContext httpContext,
|
||||
string roleId,
|
||||
UpdateRoleRequest request,
|
||||
IRoleRepository roleRepository,
|
||||
IPermissionRepository permissionRepository,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var role = await ResolveRoleEntityAsync(tenantId, roleId, roleRepository, cancellationToken).ConfigureAwait(false);
|
||||
if (role is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "role_not_found", roleId });
|
||||
}
|
||||
|
||||
if (role.IsSystem)
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
error = "built_in_role_read_only",
|
||||
roleId = role.Name,
|
||||
message = "Built-in roles cannot be edited from the setup console. Create a custom role instead.",
|
||||
});
|
||||
}
|
||||
|
||||
IReadOnlyList<string>? normalizedScopes = null;
|
||||
if (request.Scopes is not null)
|
||||
{
|
||||
normalizedScopes = NormalizeScopes(request.Scopes);
|
||||
if (normalizedScopes.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = "scopes_required" });
|
||||
}
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var updatedRole = new RoleEntity
|
||||
{
|
||||
Id = role.Id,
|
||||
TenantId = role.TenantId,
|
||||
Name = role.Name,
|
||||
DisplayName = NormalizeOptional(request.DisplayName) ?? role.DisplayName,
|
||||
Description = NormalizeOptional(request.DisplayName) ?? role.Description ?? role.DisplayName,
|
||||
IsSystem = role.IsSystem,
|
||||
Metadata = role.Metadata,
|
||||
CreatedAt = role.CreatedAt,
|
||||
UpdatedAt = now,
|
||||
};
|
||||
|
||||
await roleRepository.UpdateAsync(tenantId, updatedRole, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
IReadOnlyList<string> effectiveScopes;
|
||||
if (normalizedScopes is not null)
|
||||
{
|
||||
var currentPermissions = await permissionRepository.GetRolePermissionsAsync(tenantId, role.Id, cancellationToken).ConfigureAwait(false);
|
||||
var currentByName = currentPermissions.ToDictionary(static permission => permission.Name, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var permission in currentPermissions.Where(permission => !normalizedScopes.Contains(permission.Name, StringComparer.OrdinalIgnoreCase)))
|
||||
{
|
||||
await permissionRepository.RemoveFromRoleAsync(tenantId, role.Id, permission.Id, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
foreach (var scope in normalizedScopes.Where(scope => !currentByName.ContainsKey(scope)))
|
||||
{
|
||||
var permission = await permissionRepository.GetByNameAsync(tenantId, scope, cancellationToken).ConfigureAwait(false);
|
||||
var permissionId = permission?.Id ?? await permissionRepository.CreateAsync(tenantId, new PermissionEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
Name = scope,
|
||||
Resource = ExtractPermissionResource(scope),
|
||||
Action = ExtractPermissionAction(scope),
|
||||
Description = $"Console-admin maintained scope '{scope}'.",
|
||||
CreatedAt = now,
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await permissionRepository.AssignToRoleAsync(tenantId, role.Id, permissionId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
effectiveScopes = normalizedScopes;
|
||||
}
|
||||
else
|
||||
{
|
||||
effectiveScopes = (await permissionRepository.GetRolePermissionsAsync(tenantId, role.Id, cancellationToken).ConfigureAwait(false))
|
||||
.Select(static permission => permission.Name)
|
||||
.OrderBy(static scope => scope, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
var summary = new AdminRoleSummary(
|
||||
Id: updatedRole.Id.ToString("N"),
|
||||
Name: updatedRole.Name,
|
||||
Description: string.IsNullOrWhiteSpace(updatedRole.Description) ? updatedRole.Name : updatedRole.Description!,
|
||||
Permissions: effectiveScopes,
|
||||
UserCount: 0,
|
||||
IsBuiltIn: false);
|
||||
|
||||
await WriteAdminAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
@@ -807,19 +1064,44 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
"authority.admin.roles.update",
|
||||
AuthEventOutcome.Success,
|
||||
null,
|
||||
BuildProperties(("role.id", roleId)),
|
||||
BuildProperties(("tenant.id", tenantId), ("role.id", updatedRole.Name)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new { message = "Role update: implementation pending" });
|
||||
return Results.Ok(summary);
|
||||
}
|
||||
|
||||
private static async Task<IResult> PreviewRoleImpact(
|
||||
HttpContext httpContext,
|
||||
string roleId,
|
||||
IRoleRepository roleRepository,
|
||||
IUserRepository userRepository,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var role = await ResolveRoleEntityAsync(tenantId, roleId, roleRepository, cancellationToken).ConfigureAwait(false);
|
||||
if (role is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "role_not_found", roleId });
|
||||
}
|
||||
|
||||
var users = await userRepository.GetAllAsync(
|
||||
tenantId,
|
||||
enabled: null,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var affectedUsers = 0;
|
||||
foreach (var user in users)
|
||||
{
|
||||
var roles = await ResolveUserRolesAsync(user, roleRepository, cancellationToken).ConfigureAwait(false);
|
||||
if (roles.Contains(role.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
affectedUsers++;
|
||||
}
|
||||
}
|
||||
|
||||
await WriteAdminAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
@@ -827,10 +1109,17 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
"authority.admin.roles.preview",
|
||||
AuthEventOutcome.Success,
|
||||
null,
|
||||
BuildProperties(("role.id", roleId)),
|
||||
BuildProperties(("tenant.id", tenantId), ("role.id", role.Name)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new { affectedUsers = 0, affectedClients = 0, message = "Impact preview: implementation pending" });
|
||||
return Results.Ok(new
|
||||
{
|
||||
affectedUsers,
|
||||
affectedClients = 0,
|
||||
message = affectedUsers == 0
|
||||
? "No users are currently assigned to this role."
|
||||
: $"{affectedUsers} user{(affectedUsers == 1 ? string.Empty : "s")} would be affected by changes to this role.",
|
||||
});
|
||||
}
|
||||
|
||||
// ========== CLIENT ENDPOINTS ==========
|
||||
@@ -1260,6 +1549,74 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
return "default";
|
||||
}
|
||||
|
||||
private static async Task<UserEntity?> ResolveUserEntityAsync(
|
||||
string tenantId,
|
||||
string userId,
|
||||
IUserRepository userRepository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (Guid.TryParse(userId, out var parsedId))
|
||||
{
|
||||
var byGuid = await userRepository.GetByIdAsync(tenantId, parsedId, cancellationToken).ConfigureAwait(false);
|
||||
if (byGuid is not null)
|
||||
{
|
||||
return byGuid;
|
||||
}
|
||||
}
|
||||
|
||||
var bySubject = await userRepository.GetBySubjectIdAsync(tenantId, userId, cancellationToken).ConfigureAwait(false);
|
||||
if (bySubject is not null)
|
||||
{
|
||||
return bySubject;
|
||||
}
|
||||
|
||||
return await userRepository.GetByUsernameAsync(tenantId, userId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<RoleEntity?> ResolveRoleEntityAsync(
|
||||
string tenantId,
|
||||
string roleId,
|
||||
IRoleRepository roleRepository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (Guid.TryParse(roleId, out var parsedId))
|
||||
{
|
||||
var byGuid = await roleRepository.GetByIdAsync(tenantId, parsedId, cancellationToken).ConfigureAwait(false);
|
||||
if (byGuid is not null)
|
||||
{
|
||||
return byGuid;
|
||||
}
|
||||
}
|
||||
|
||||
return await roleRepository.GetByNameAsync(tenantId, roleId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<RoleEntity>> ResolveRequestedRolesAsync(
|
||||
string tenantId,
|
||||
IReadOnlyList<string> requestedRoles,
|
||||
IRoleRepository roleRepository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var resolved = new List<RoleEntity>(requestedRoles.Count);
|
||||
foreach (var roleName in requestedRoles)
|
||||
{
|
||||
var role = await roleRepository.GetByNameAsync(tenantId, roleName, cancellationToken).ConfigureAwait(false);
|
||||
if (role is not null)
|
||||
{
|
||||
resolved.Add(role);
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private static string UpdateUserMetadata(string metadataJson, IReadOnlyList<string> roles)
|
||||
{
|
||||
var metadata = ParseMetadata(metadataJson);
|
||||
metadata["roles"] = roles;
|
||||
return JsonSerializer.Serialize(metadata);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeRoles(IReadOnlyList<string>? roles)
|
||||
{
|
||||
if (roles is null || roles.Count == 0)
|
||||
|
||||
@@ -57,6 +57,14 @@ public sealed record RegisterAdministrationTrustIssuerRequest(
|
||||
string IssuerUri,
|
||||
string TrustLevel);
|
||||
|
||||
public sealed record BlockAdministrationTrustIssuerRequest(
|
||||
string Reason,
|
||||
string? Ticket);
|
||||
|
||||
public sealed record UnblockAdministrationTrustIssuerRequest(
|
||||
string? TrustLevel,
|
||||
string? Ticket);
|
||||
|
||||
public sealed record RegisterAdministrationTrustCertificateRequest(
|
||||
Guid? KeyId,
|
||||
Guid? IssuerId,
|
||||
|
||||
@@ -226,6 +226,72 @@ public static class AdministrationTrustSigningMutationEndpoints
|
||||
.WithSummary("Register trust issuer")
|
||||
.RequireAuthorization(PlatformPolicies.TrustWrite);
|
||||
|
||||
group.MapPost("/issuers/{issuerId:guid}/block", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IAdministrationTrustSigningStore store,
|
||||
Guid issuerId,
|
||||
BlockAdministrationTrustIssuerRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var updated = await store.BlockIssuerAsync(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
issuerId,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(updated);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return MapStoreError(ex, keyId: null, certificateId: null);
|
||||
}
|
||||
})
|
||||
.WithName("BlockAdministrationTrustIssuer")
|
||||
.WithSummary("Block trust issuer")
|
||||
.RequireAuthorization(PlatformPolicies.TrustAdmin);
|
||||
|
||||
group.MapPost("/issuers/{issuerId:guid}/unblock", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IAdministrationTrustSigningStore store,
|
||||
Guid issuerId,
|
||||
UnblockAdministrationTrustIssuerRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var updated = await store.UnblockIssuerAsync(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
issuerId,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(updated);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return MapStoreError(ex, keyId: null, certificateId: null);
|
||||
}
|
||||
})
|
||||
.WithName("UnblockAdministrationTrustIssuer")
|
||||
.WithSummary("Unblock trust issuer")
|
||||
.RequireAuthorization(PlatformPolicies.TrustAdmin);
|
||||
|
||||
group.MapGet("/certificates", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
|
||||
@@ -42,6 +42,20 @@ public interface IAdministrationTrustSigningStore
|
||||
RegisterAdministrationTrustIssuerRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AdministrationTrustIssuerSummary> BlockIssuerAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid issuerId,
|
||||
BlockAdministrationTrustIssuerRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AdministrationTrustIssuerSummary> UnblockIssuerAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid issuerId,
|
||||
UnblockAdministrationTrustIssuerRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<AdministrationTrustCertificateSummary>> ListCertificatesAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
|
||||
@@ -214,6 +214,66 @@ public sealed class InMemoryAdministrationTrustSigningStore : IAdministrationTru
|
||||
}
|
||||
}
|
||||
|
||||
public Task<AdministrationTrustIssuerSummary> BlockIssuerAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid issuerId,
|
||||
BlockAdministrationTrustIssuerRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
_ = NormalizeRequired(request.Reason, "reason_required");
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
if (!state.Issuers.TryGetValue(issuerId, out var issuer))
|
||||
{
|
||||
throw new InvalidOperationException("issuer_not_found");
|
||||
}
|
||||
|
||||
issuer.TrustLevel = "blocked";
|
||||
issuer.Status = "blocked";
|
||||
issuer.UpdatedAt = now;
|
||||
issuer.UpdatedBy = actor;
|
||||
return Task.FromResult(ToSummary(issuer));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<AdministrationTrustIssuerSummary> UnblockIssuerAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid issuerId,
|
||||
UnblockAdministrationTrustIssuerRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
var trustLevel = NormalizeOptional(request.TrustLevel) ?? "minimal";
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
if (!state.Issuers.TryGetValue(issuerId, out var issuer))
|
||||
{
|
||||
throw new InvalidOperationException("issuer_not_found");
|
||||
}
|
||||
|
||||
issuer.TrustLevel = trustLevel;
|
||||
issuer.Status = "active";
|
||||
issuer.UpdatedAt = now;
|
||||
issuer.UpdatedBy = actor;
|
||||
return Task.FromResult(ToSummary(issuer));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AdministrationTrustCertificateSummary>> ListCertificatesAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
|
||||
@@ -390,6 +390,109 @@ public sealed class PostgresAdministrationTrustSigningStore : IAdministrationTru
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AdministrationTrustIssuerSummary> BlockIssuerAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid issuerId,
|
||||
BlockAdministrationTrustIssuerRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
_ = NormalizeRequired(request.Reason, "reason_required");
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
UPDATE release.trust_issuers
|
||||
SET
|
||||
trust_level = 'blocked',
|
||||
status = 'blocked',
|
||||
updated_at = @updated_at,
|
||||
updated_by = @updated_by
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
RETURNING
|
||||
id,
|
||||
issuer_name,
|
||||
issuer_uri,
|
||||
trust_level,
|
||||
status,
|
||||
created_at,
|
||||
updated_at,
|
||||
updated_by
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("id", issuerId);
|
||||
command.Parameters.AddWithValue("updated_at", now);
|
||||
command.Parameters.AddWithValue("updated_by", actor);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
throw new InvalidOperationException("issuer_not_found");
|
||||
}
|
||||
|
||||
return MapIssuerSummary(reader);
|
||||
}
|
||||
|
||||
public async Task<AdministrationTrustIssuerSummary> UnblockIssuerAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid issuerId,
|
||||
UnblockAdministrationTrustIssuerRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
var trustLevel = NormalizeOptional(request.TrustLevel) ?? "minimal";
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
UPDATE release.trust_issuers
|
||||
SET
|
||||
trust_level = @trust_level,
|
||||
status = 'active',
|
||||
updated_at = @updated_at,
|
||||
updated_by = @updated_by
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
RETURNING
|
||||
id,
|
||||
issuer_name,
|
||||
issuer_uri,
|
||||
trust_level,
|
||||
status,
|
||||
created_at,
|
||||
updated_at,
|
||||
updated_by
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("id", issuerId);
|
||||
command.Parameters.AddWithValue("trust_level", trustLevel);
|
||||
command.Parameters.AddWithValue("updated_at", now);
|
||||
command.Parameters.AddWithValue("updated_by", actor);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
throw new InvalidOperationException("issuer_not_found");
|
||||
}
|
||||
|
||||
return MapIssuerSummary(reader);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AdministrationTrustCertificateSummary>> ListCertificatesAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
|
||||
@@ -64,6 +64,31 @@ public sealed class AdministrationTrustSigningMutationEndpointsTests : IClassFix
|
||||
var issuer = await issuerResponse.Content.ReadFromJsonAsync<AdministrationTrustIssuerSummary>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(issuer);
|
||||
Assert.Equal("active", issuer!.Status);
|
||||
|
||||
var blockIssuerResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/administration/trust-signing/issuers/{issuer.IssuerId}/block",
|
||||
new BlockAdministrationTrustIssuerRequest("publisher compromised", "IR-51"),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, blockIssuerResponse.StatusCode);
|
||||
var blockedIssuer = await blockIssuerResponse.Content.ReadFromJsonAsync<AdministrationTrustIssuerSummary>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(blockedIssuer);
|
||||
Assert.Equal("blocked", blockedIssuer!.TrustLevel);
|
||||
Assert.Equal("blocked", blockedIssuer.Status);
|
||||
|
||||
var unblockIssuerResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/administration/trust-signing/issuers/{issuer.IssuerId}/unblock",
|
||||
new UnblockAdministrationTrustIssuerRequest("partial", "IR-52"),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, unblockIssuerResponse.StatusCode);
|
||||
var unblockedIssuer = await unblockIssuerResponse.Content.ReadFromJsonAsync<AdministrationTrustIssuerSummary>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(unblockedIssuer);
|
||||
Assert.Equal("partial", unblockedIssuer!.TrustLevel);
|
||||
Assert.Equal("active", unblockedIssuer.Status);
|
||||
|
||||
var certificateResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/administration/trust-signing/certificates",
|
||||
@@ -194,6 +219,8 @@ public sealed class AdministrationTrustSigningMutationEndpointsTests : IClassFix
|
||||
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/keys/{keyId:guid}/rotate", "POST", PlatformPolicies.TrustWrite);
|
||||
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/keys/{keyId:guid}/revoke", "POST", PlatformPolicies.TrustAdmin);
|
||||
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/issuers", "POST", PlatformPolicies.TrustWrite);
|
||||
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/issuers/{issuerId:guid}/block", "POST", PlatformPolicies.TrustAdmin);
|
||||
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/issuers/{issuerId:guid}/unblock", "POST", PlatformPolicies.TrustAdmin);
|
||||
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/certificates/{certificateId:guid}/revoke", "POST", PlatformPolicies.TrustAdmin);
|
||||
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/transparency-log", "GET", PlatformPolicies.TrustRead);
|
||||
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/transparency-log", "PUT", PlatformPolicies.TrustAdmin);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -76,18 +76,39 @@ export interface CreateUserRequest {
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
displayName?: string;
|
||||
roles?: string[];
|
||||
}
|
||||
|
||||
export interface CreateRoleRequest {
|
||||
name: string;
|
||||
description: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface UpdateRoleRequest {
|
||||
description?: string;
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
export interface CreateTenantRequest {
|
||||
id: string;
|
||||
displayName: string;
|
||||
isolationMode: string;
|
||||
}
|
||||
|
||||
export interface UpdateTenantRequest {
|
||||
displayName?: string;
|
||||
isolationMode?: string;
|
||||
}
|
||||
|
||||
export interface RoleImpactPreview {
|
||||
affectedUsers: number;
|
||||
affectedClients: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface AuthorityAdminApi {
|
||||
listUsers(tenantId?: string): Observable<AdminUser[]>;
|
||||
listRoles(tenantId?: string): Observable<AdminRole[]>;
|
||||
@@ -95,8 +116,16 @@ export interface AuthorityAdminApi {
|
||||
listTokens(tenantId?: string): Observable<AdminToken[]>;
|
||||
listTenants(): Observable<AdminTenant[]>;
|
||||
createUser(request: CreateUserRequest): Observable<AdminUser>;
|
||||
updateUser(userId: string, request: UpdateUserRequest): Observable<AdminUser>;
|
||||
disableUser(userId: string): Observable<AdminUser>;
|
||||
enableUser(userId: string): Observable<AdminUser>;
|
||||
createRole(request: CreateRoleRequest): Observable<AdminRole>;
|
||||
updateRole(roleId: string, request: UpdateRoleRequest): Observable<AdminRole>;
|
||||
previewRoleImpact(roleId: string): Observable<RoleImpactPreview>;
|
||||
createTenant(request: CreateTenantRequest): Observable<AdminTenant>;
|
||||
updateTenant(tenantId: string, request: UpdateTenantRequest): Observable<AdminTenant>;
|
||||
suspendTenant(tenantId: string): Observable<AdminTenant>;
|
||||
resumeTenant(tenantId: string): Observable<AdminTenant>;
|
||||
}
|
||||
|
||||
export const AUTHORITY_ADMIN_API = new InjectionToken<AuthorityAdminApi>('AUTHORITY_ADMIN_API');
|
||||
@@ -226,6 +255,24 @@ export class AuthorityAdminHttpClient implements AuthorityAdminApi {
|
||||
}).pipe(map((user) => this.mapUser(user)));
|
||||
}
|
||||
|
||||
updateUser(userId: string, request: UpdateUserRequest): Observable<AdminUser> {
|
||||
return this.http.patch<AdminUserDto>(`${this.baseUrl}/users/${encodeURIComponent(userId)}`, request, {
|
||||
headers: this.buildHeaders(),
|
||||
}).pipe(map((user) => this.mapUser(user)));
|
||||
}
|
||||
|
||||
disableUser(userId: string): Observable<AdminUser> {
|
||||
return this.http.post<AdminUserDto>(`${this.baseUrl}/users/${encodeURIComponent(userId)}/disable`, {}, {
|
||||
headers: this.buildHeaders(),
|
||||
}).pipe(map((user) => this.mapUser(user)));
|
||||
}
|
||||
|
||||
enableUser(userId: string): Observable<AdminUser> {
|
||||
return this.http.post<AdminUserDto>(`${this.baseUrl}/users/${encodeURIComponent(userId)}/enable`, {}, {
|
||||
headers: this.buildHeaders(),
|
||||
}).pipe(map((user) => this.mapUser(user)));
|
||||
}
|
||||
|
||||
createRole(request: CreateRoleRequest): Observable<AdminRole> {
|
||||
return this.http.post<AdminRoleDto>(`${this.baseUrl}/roles`, {
|
||||
roleId: request.name,
|
||||
@@ -236,6 +283,21 @@ export class AuthorityAdminHttpClient implements AuthorityAdminApi {
|
||||
}).pipe(map((role) => this.mapRole(role)));
|
||||
}
|
||||
|
||||
updateRole(roleId: string, request: UpdateRoleRequest): Observable<AdminRole> {
|
||||
return this.http.patch<AdminRoleDto>(`${this.baseUrl}/roles/${encodeURIComponent(roleId)}`, {
|
||||
displayName: request.description,
|
||||
scopes: request.permissions,
|
||||
}, {
|
||||
headers: this.buildHeaders(),
|
||||
}).pipe(map((role) => this.mapRole(role)));
|
||||
}
|
||||
|
||||
previewRoleImpact(roleId: string): Observable<RoleImpactPreview> {
|
||||
return this.http.post<RoleImpactPreview>(`${this.baseUrl}/roles/${encodeURIComponent(roleId)}/preview-impact`, {}, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
createTenant(request: CreateTenantRequest): Observable<AdminTenant> {
|
||||
return this.http.post<AdminTenantDto>(`${this.baseUrl}/tenants`, {
|
||||
id: request.id,
|
||||
@@ -246,6 +308,24 @@ export class AuthorityAdminHttpClient implements AuthorityAdminApi {
|
||||
}).pipe(map((tenant) => this.mapTenant(tenant)));
|
||||
}
|
||||
|
||||
updateTenant(tenantId: string, request: UpdateTenantRequest): Observable<AdminTenant> {
|
||||
return this.http.patch<AdminTenantDto>(`${this.baseUrl}/tenants/${encodeURIComponent(tenantId)}`, request, {
|
||||
headers: this.buildHeaders(),
|
||||
}).pipe(map((tenant) => this.mapTenant(tenant)));
|
||||
}
|
||||
|
||||
suspendTenant(tenantId: string): Observable<AdminTenant> {
|
||||
return this.http.post<AdminTenantDto>(`${this.baseUrl}/tenants/${encodeURIComponent(tenantId)}/suspend`, {}, {
|
||||
headers: this.buildHeaders(),
|
||||
}).pipe(map((tenant) => this.mapTenant(tenant)));
|
||||
}
|
||||
|
||||
resumeTenant(tenantId: string): Observable<AdminTenant> {
|
||||
return this.http.post<AdminTenantDto>(`${this.baseUrl}/tenants/${encodeURIComponent(tenantId)}/resume`, {}, {
|
||||
headers: this.buildHeaders(),
|
||||
}).pipe(map((tenant) => this.mapTenant(tenant)));
|
||||
}
|
||||
|
||||
private buildHeaders(tenantOverride?: string): HttpHeaders {
|
||||
const tenantId =
|
||||
(tenantOverride && tenantOverride.trim()) ||
|
||||
@@ -343,26 +423,31 @@ export class AuthorityAdminHttpClient implements AuthorityAdminApi {
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockAuthorityAdminClient implements AuthorityAdminApi {
|
||||
private readonly usersData: AdminUser[] = [
|
||||
{ id: 'u-1', username: 'admin', email: 'admin@stella-ops.local', displayName: 'Platform Admin', roles: ['role/console-admin'], status: 'active', createdAt: '2026-01-01T00:00:00Z', lastLoginAt: '2026-02-15T10:30:00Z' },
|
||||
{ id: 'u-2', username: 'jane.smith', email: 'jane.smith@example.com', displayName: 'Jane Smith', roles: ['role/console-viewer'], status: 'active', createdAt: '2026-01-10T00:00:00Z', lastLoginAt: '2026-02-14T15:00:00Z' },
|
||||
{ id: 'u-3', username: 'bob.wilson', email: 'bob.wilson@example.com', displayName: 'Bob Wilson', roles: ['role/scanner-operator'], status: 'active', createdAt: '2026-01-15T00:00:00Z' },
|
||||
{ id: 'u-4', username: 'svc-scanner', email: 'scanner@stella-ops.local', displayName: 'Scanner Service', roles: ['role/scanner-admin'], status: 'active', createdAt: '2026-01-01T00:00:00Z' },
|
||||
{ id: 'u-5', username: 'alice.johnson', email: 'alice@example.com', displayName: 'Alice Johnson', roles: ['role/release-operator', 'role/console-viewer'], status: 'disabled', createdAt: '2026-01-20T00:00:00Z' },
|
||||
];
|
||||
private readonly rolesData: AdminRole[] = [
|
||||
{ id: 'role/console-admin', name: 'role/console-admin', description: 'Full platform administration for setup, access, and audit controls.', permissions: ['ui.admin', 'authority:users.write', 'authority:roles.write', 'authority:tenants.write'], userCount: 1, isBuiltIn: true },
|
||||
{ id: 'role/console-viewer', name: 'role/console-viewer', description: 'Least-privilege console access for viewing setup state without making changes.', permissions: ['ui.read', 'authority:users.read', 'authority:roles.read', 'authority:tenants.read'], userCount: 2, isBuiltIn: true },
|
||||
{ id: 'role/release-operator', name: 'role/release-operator', description: 'Operate releases, promotions, and deployment approvals.', permissions: ['release:read', 'release:write', 'release:publish', 'policy:read'], userCount: 1, isBuiltIn: true },
|
||||
{ id: 'role/scanner-operator', name: 'role/scanner-operator', description: 'Run scans and review findings without changing platform policy.', permissions: ['scanner:read', 'scanner:scan', 'findings:read'], userCount: 1, isBuiltIn: true },
|
||||
{ id: 'role/scanner-admin', name: 'role/scanner-admin', description: 'Administer scanner configuration and scanner-side exports.', permissions: ['scanner:read', 'scanner:scan', 'scanner:write', 'scanner:export'], userCount: 1, isBuiltIn: true },
|
||||
];
|
||||
private readonly tenantsData: AdminTenant[] = [
|
||||
{ id: 'default', displayName: 'Default', status: 'active', isolationMode: 'shared', userCount: 5, createdAt: '2026-01-01T00:00:00Z' },
|
||||
{ id: 'production', displayName: 'Production Tenant', status: 'active', isolationMode: 'dedicated', userCount: 3, createdAt: '2026-01-10T00:00:00Z' },
|
||||
];
|
||||
|
||||
listUsers(): Observable<AdminUser[]> {
|
||||
const data: AdminUser[] = [
|
||||
{ id: 'u-1', username: 'admin', email: 'admin@stella-ops.local', displayName: 'Platform Admin', roles: ['admin', 'operator'], status: 'active', createdAt: '2026-01-01T00:00:00Z', lastLoginAt: '2026-02-15T10:30:00Z' },
|
||||
{ id: 'u-2', username: 'jane.smith', email: 'jane.smith@example.com', displayName: 'Jane Smith', roles: ['reviewer'], status: 'active', createdAt: '2026-01-10T00:00:00Z', lastLoginAt: '2026-02-14T15:00:00Z' },
|
||||
{ id: 'u-3', username: 'bob.wilson', email: 'bob.wilson@example.com', displayName: 'Bob Wilson', roles: ['developer'], status: 'active', createdAt: '2026-01-15T00:00:00Z' },
|
||||
{ id: 'u-4', username: 'svc-scanner', email: 'scanner@stella-ops.local', displayName: 'Scanner Service', roles: ['service'], status: 'active', createdAt: '2026-01-01T00:00:00Z' },
|
||||
{ id: 'u-5', username: 'alice.johnson', email: 'alice@example.com', displayName: 'Alice Johnson', roles: ['operator', 'reviewer'], status: 'disabled', createdAt: '2026-01-20T00:00:00Z' },
|
||||
];
|
||||
return of(data).pipe(delay(300));
|
||||
return of(this.usersData.map((user) => ({ ...user, roles: [...user.roles] }))).pipe(delay(300));
|
||||
}
|
||||
|
||||
listRoles(): Observable<AdminRole[]> {
|
||||
const data: AdminRole[] = [
|
||||
{ id: 'r-1', name: 'admin', description: 'Full platform administrator', permissions: ['*'], userCount: 1, isBuiltIn: true },
|
||||
{ id: 'r-2', name: 'operator', description: 'Manage releases and deployments', permissions: ['release:*', 'deploy:*'], userCount: 2, isBuiltIn: true },
|
||||
{ id: 'r-3', name: 'reviewer', description: 'Review and approve promotions', permissions: ['approval:read', 'approval:approve', 'release:read'], userCount: 2, isBuiltIn: true },
|
||||
{ id: 'r-4', name: 'developer', description: 'Read-only access to releases and security', permissions: ['release:read', 'security:read'], userCount: 1, isBuiltIn: false },
|
||||
{ id: 'r-5', name: 'service', description: 'Machine-to-machine service account', permissions: ['scanner:write', 'findings:write'], userCount: 1, isBuiltIn: true },
|
||||
];
|
||||
return of(data).pipe(delay(300));
|
||||
return of(this.rolesWithCounts()).pipe(delay(300));
|
||||
}
|
||||
|
||||
listClients(): Observable<AdminClient[]> {
|
||||
@@ -384,11 +469,7 @@ export class MockAuthorityAdminClient implements AuthorityAdminApi {
|
||||
}
|
||||
|
||||
listTenants(): Observable<AdminTenant[]> {
|
||||
const data: AdminTenant[] = [
|
||||
{ id: 'tn-1', displayName: 'Default', status: 'active', isolationMode: 'shared', userCount: 5, createdAt: '2026-01-01T00:00:00Z' },
|
||||
{ id: 'tn-2', displayName: 'Production Tenant', status: 'active', isolationMode: 'dedicated', userCount: 3, createdAt: '2026-01-10T00:00:00Z' },
|
||||
];
|
||||
return of(data).pipe(delay(300));
|
||||
return of(this.tenantsData.map((tenant) => ({ ...tenant }))).pipe(delay(300));
|
||||
}
|
||||
|
||||
createUser(request: CreateUserRequest): Observable<AdminUser> {
|
||||
@@ -401,21 +482,75 @@ export class MockAuthorityAdminClient implements AuthorityAdminApi {
|
||||
status: 'active',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
this.usersData.push(user);
|
||||
return of(user).pipe(delay(400));
|
||||
}
|
||||
|
||||
updateUser(userId: string, request: UpdateUserRequest): Observable<AdminUser> {
|
||||
const user = this.requireUser(userId);
|
||||
const updated: AdminUser = {
|
||||
...user,
|
||||
displayName: request.displayName?.trim() || user.displayName,
|
||||
roles: request.roles ? [...request.roles] : user.roles,
|
||||
};
|
||||
this.replaceUser(updated);
|
||||
return of(updated).pipe(delay(300));
|
||||
}
|
||||
|
||||
disableUser(userId: string): Observable<AdminUser> {
|
||||
const updated: AdminUser = {
|
||||
...this.requireUser(userId),
|
||||
status: 'disabled',
|
||||
};
|
||||
this.replaceUser(updated);
|
||||
return of(updated).pipe(delay(250));
|
||||
}
|
||||
|
||||
enableUser(userId: string): Observable<AdminUser> {
|
||||
const updated: AdminUser = {
|
||||
...this.requireUser(userId),
|
||||
status: 'active',
|
||||
};
|
||||
this.replaceUser(updated);
|
||||
return of(updated).pipe(delay(250));
|
||||
}
|
||||
|
||||
createRole(request: CreateRoleRequest): Observable<AdminRole> {
|
||||
const role: AdminRole = {
|
||||
id: `r-${Date.now()}`,
|
||||
id: request.name,
|
||||
name: request.name,
|
||||
description: request.description,
|
||||
permissions: request.permissions,
|
||||
userCount: 0,
|
||||
isBuiltIn: false,
|
||||
};
|
||||
this.rolesData.push(role);
|
||||
return of(role).pipe(delay(400));
|
||||
}
|
||||
|
||||
updateRole(roleId: string, request: UpdateRoleRequest): Observable<AdminRole> {
|
||||
const role = this.requireRole(roleId);
|
||||
const updated: AdminRole = {
|
||||
...role,
|
||||
description: request.description?.trim() || role.description,
|
||||
permissions: request.permissions ? [...request.permissions] : role.permissions,
|
||||
};
|
||||
this.replaceRole(updated);
|
||||
return of(updated).pipe(delay(300));
|
||||
}
|
||||
|
||||
previewRoleImpact(roleId: string): Observable<RoleImpactPreview> {
|
||||
const role = this.requireRole(roleId);
|
||||
const affectedUsers = this.usersData.filter((user) => user.roles.includes(role.name)).length;
|
||||
return of({
|
||||
affectedUsers,
|
||||
affectedClients: 0,
|
||||
message: affectedUsers === 0
|
||||
? 'No users are currently assigned to this role.'
|
||||
: `${affectedUsers} user${affectedUsers === 1 ? '' : 's'} would be affected by changes to this role.`,
|
||||
}).pipe(delay(150));
|
||||
}
|
||||
|
||||
createTenant(request: CreateTenantRequest): Observable<AdminTenant> {
|
||||
const tenant: AdminTenant = {
|
||||
id: request.id,
|
||||
@@ -425,6 +560,92 @@ export class MockAuthorityAdminClient implements AuthorityAdminApi {
|
||||
userCount: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
this.tenantsData.push(tenant);
|
||||
return of(tenant).pipe(delay(400));
|
||||
}
|
||||
|
||||
updateTenant(tenantId: string, request: UpdateTenantRequest): Observable<AdminTenant> {
|
||||
const tenant = this.requireTenant(tenantId);
|
||||
const updated: AdminTenant = {
|
||||
...tenant,
|
||||
displayName: request.displayName?.trim() || tenant.displayName,
|
||||
isolationMode: request.isolationMode?.trim() || tenant.isolationMode,
|
||||
};
|
||||
this.replaceTenant(updated);
|
||||
return of(updated).pipe(delay(300));
|
||||
}
|
||||
|
||||
suspendTenant(tenantId: string): Observable<AdminTenant> {
|
||||
const updated: AdminTenant = {
|
||||
...this.requireTenant(tenantId),
|
||||
status: 'disabled',
|
||||
};
|
||||
this.replaceTenant(updated);
|
||||
return of(updated).pipe(delay(250));
|
||||
}
|
||||
|
||||
resumeTenant(tenantId: string): Observable<AdminTenant> {
|
||||
const updated: AdminTenant = {
|
||||
...this.requireTenant(tenantId),
|
||||
status: 'active',
|
||||
};
|
||||
this.replaceTenant(updated);
|
||||
return of(updated).pipe(delay(250));
|
||||
}
|
||||
|
||||
private rolesWithCounts(): AdminRole[] {
|
||||
return this.rolesData.map((role) => ({
|
||||
...role,
|
||||
permissions: [...role.permissions],
|
||||
userCount: this.usersData.filter((user) => user.roles.includes(role.name)).length,
|
||||
}));
|
||||
}
|
||||
|
||||
private replaceUser(updatedUser: AdminUser): void {
|
||||
const index = this.usersData.findIndex((user) => user.id === updatedUser.id);
|
||||
if (index >= 0) {
|
||||
this.usersData[index] = updatedUser;
|
||||
}
|
||||
}
|
||||
|
||||
private replaceRole(updatedRole: AdminRole): void {
|
||||
const index = this.rolesData.findIndex((role) => role.id === updatedRole.id);
|
||||
if (index >= 0) {
|
||||
this.rolesData[index] = updatedRole;
|
||||
}
|
||||
}
|
||||
|
||||
private replaceTenant(updatedTenant: AdminTenant): void {
|
||||
const index = this.tenantsData.findIndex((tenant) => tenant.id === updatedTenant.id);
|
||||
if (index >= 0) {
|
||||
this.tenantsData[index] = updatedTenant;
|
||||
}
|
||||
}
|
||||
|
||||
private requireUser(userId: string): AdminUser {
|
||||
const user = this.usersData.find((entry) => entry.id === userId);
|
||||
if (!user) {
|
||||
throw new Error(`User not found: ${userId}`);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
private requireRole(roleId: string): AdminRole {
|
||||
const role = this.rolesData.find((entry) => entry.id === roleId || entry.name === roleId);
|
||||
if (!role) {
|
||||
throw new Error(`Role not found: ${roleId}`);
|
||||
}
|
||||
|
||||
return role;
|
||||
}
|
||||
|
||||
private requireTenant(tenantId: string): AdminTenant {
|
||||
const tenant = this.tenantsData.find((entry) => entry.id === tenantId);
|
||||
if (!tenant) {
|
||||
throw new Error(`Tenant not found: ${tenantId}`);
|
||||
}
|
||||
|
||||
return tenant;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,18 +28,11 @@ describe('TrustHttpService', () => {
|
||||
issuers: 7,
|
||||
certificates: 23,
|
||||
});
|
||||
expect(overview.signals).toEqual([
|
||||
{
|
||||
signalId: 'audit-log',
|
||||
status: 'healthy',
|
||||
message: 'Audit log ingestion is current.',
|
||||
},
|
||||
{
|
||||
signalId: 'certificate-expiry',
|
||||
status: 'warning',
|
||||
message: '1 certificate expires within 10 days.',
|
||||
},
|
||||
]);
|
||||
expect(overview.signals[0]).toEqual({
|
||||
signalId: 'audit-log',
|
||||
status: 'healthy',
|
||||
message: 'Audit log ingestion is current.',
|
||||
});
|
||||
expect(overview.evidenceConsumerPath).toBe('/evidence-audit/proofs');
|
||||
});
|
||||
|
||||
@@ -57,43 +50,184 @@ describe('TrustHttpService', () => {
|
||||
status: 'healthy',
|
||||
message: 'Audit log ingestion is current.',
|
||||
},
|
||||
{
|
||||
signalId: 'certificate-expiry',
|
||||
status: 'warning',
|
||||
message: '1 certificate expires within 10 days.',
|
||||
},
|
||||
],
|
||||
legacyAliases: [],
|
||||
evidenceConsumerPath: '/evidence-audit/proofs',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds the trust shell summary from the administration overview', () => {
|
||||
service.getDashboardSummary().subscribe((summary) => {
|
||||
expect(summary.keys.total).toBe(4);
|
||||
expect(summary.issuers.total).toBe(3);
|
||||
expect(summary.certificates.total).toBe(2);
|
||||
expect(summary.certificates.expiringSoon).toBe(1);
|
||||
expect(summary.expiryAlerts).toEqual([]);
|
||||
it('resolves certificate issuer and key labels from live administration inventories', () => {
|
||||
service.listCertificates({ pageNumber: 1, pageSize: 20 }).subscribe((result) => {
|
||||
expect(result.items.length).toBe(1);
|
||||
expect(result.items[0].issuer.commonName).toBe('Core Root CA');
|
||||
expect(result.items[0].subject.commonName).toBe('prod-attestation-k1');
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/administration/trust-signing');
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush({
|
||||
inventory: {
|
||||
keys: 4,
|
||||
issuers: 3,
|
||||
certificates: 2,
|
||||
},
|
||||
signals: [
|
||||
const certificateReq = httpMock.expectOne((request) =>
|
||||
request.url === '/api/v1/administration/trust-signing/certificates');
|
||||
const keyReq = httpMock.expectOne((request) =>
|
||||
request.url === '/api/v1/administration/trust-signing/keys');
|
||||
const issuerReq = httpMock.expectOne((request) =>
|
||||
request.url === '/api/v1/administration/trust-signing/issuers');
|
||||
|
||||
certificateReq.flush({
|
||||
items: [
|
||||
{
|
||||
signalId: 'certificate-expiry',
|
||||
status: 'warning',
|
||||
message: '1 certificate expires within 10 days.',
|
||||
certificateId: 'cert-001',
|
||||
keyId: 'key-001',
|
||||
issuerId: 'issuer-001',
|
||||
serialNumber: 'SER-001',
|
||||
status: 'active',
|
||||
notBefore: '2026-01-01T00:00:00Z',
|
||||
notAfter: '2027-01-01T00:00:00Z',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
updatedBy: 'operator',
|
||||
},
|
||||
],
|
||||
count: 1,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
});
|
||||
keyReq.flush({
|
||||
items: [
|
||||
{
|
||||
keyId: 'key-001',
|
||||
alias: 'prod-attestation-k1',
|
||||
algorithm: 'ed25519',
|
||||
status: 'active',
|
||||
currentVersion: 2,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
updatedBy: 'operator',
|
||||
},
|
||||
],
|
||||
count: 1,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
});
|
||||
issuerReq.flush({
|
||||
items: [
|
||||
{
|
||||
issuerId: 'issuer-001',
|
||||
name: 'Core Root CA',
|
||||
issuerUri: 'https://issuer.example/root',
|
||||
trustLevel: 'high',
|
||||
status: 'active',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
updatedBy: 'operator',
|
||||
},
|
||||
],
|
||||
count: 1,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('derives analytics from administration inventories without calling dead analytics endpoints', () => {
|
||||
const expiringSoon = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
service.getAnalyticsSummary().subscribe((summary) => {
|
||||
expect(summary.overallTrustScore).toBeGreaterThan(0);
|
||||
expect(summary.alerts.some((alert) => alert.category === 'certificate')).toBeTrue();
|
||||
});
|
||||
|
||||
const requests = httpMock.match(() => true);
|
||||
expect(requests.some((request) => request.request.url.startsWith('/api/v1/trust/analytics'))).toBeFalse();
|
||||
|
||||
const overviewReq = requests.find((request) => request.request.url === '/api/v1/administration/trust-signing');
|
||||
const keysReq = requests.filter((request) => request.request.url === '/api/v1/administration/trust-signing/keys');
|
||||
const issuersReq = requests.filter((request) => request.request.url === '/api/v1/administration/trust-signing/issuers');
|
||||
const certificatesReq = requests.filter((request) => request.request.url === '/api/v1/administration/trust-signing/certificates');
|
||||
|
||||
overviewReq!.flush({
|
||||
inventory: { keys: 1, issuers: 1, certificates: 1 },
|
||||
signals: [{ signalId: 'certificate-expiry', status: 'warning', message: 'One certificate expires soon.' }],
|
||||
legacyAliases: [],
|
||||
evidenceConsumerPath: '/evidence/overview',
|
||||
});
|
||||
keysReq.forEach((request) => request.flush({
|
||||
items: [{
|
||||
keyId: 'key-001',
|
||||
alias: 'prod-attestation-k1',
|
||||
algorithm: 'ed25519',
|
||||
status: 'active',
|
||||
currentVersion: 1,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
updatedBy: 'operator',
|
||||
}],
|
||||
count: 1,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
}));
|
||||
issuersReq.forEach((request) => request.flush({
|
||||
items: [{
|
||||
issuerId: 'issuer-001',
|
||||
name: 'Core Root CA',
|
||||
issuerUri: 'https://issuer.example/root',
|
||||
trustLevel: 'partial',
|
||||
status: 'active',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
updatedBy: 'operator',
|
||||
}],
|
||||
count: 1,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
}));
|
||||
certificatesReq.forEach((request) => request.flush({
|
||||
items: [{
|
||||
certificateId: 'cert-001',
|
||||
keyId: 'key-001',
|
||||
issuerId: 'issuer-001',
|
||||
serialNumber: 'SER-001',
|
||||
status: 'active',
|
||||
notBefore: '2026-01-01T00:00:00Z',
|
||||
notAfter: expiringSoon,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
updatedBy: 'operator',
|
||||
}],
|
||||
count: 1,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
}));
|
||||
});
|
||||
|
||||
it('uses administration issuer lifecycle endpoints for block and unblock', () => {
|
||||
service.blockIssuer('issuer-001', { reason: 'publisher compromised' }).subscribe((issuer) => {
|
||||
expect(issuer.trustLevel).toBe('blocked');
|
||||
});
|
||||
const blockReq = httpMock.expectOne('/api/v1/administration/trust-signing/issuers/issuer-001/block');
|
||||
expect(blockReq.request.method).toBe('POST');
|
||||
blockReq.flush({
|
||||
issuerId: 'issuer-001',
|
||||
name: 'Core Root CA',
|
||||
issuerUri: 'https://issuer.example/root',
|
||||
trustLevel: 'blocked',
|
||||
status: 'blocked',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-03T00:00:00Z',
|
||||
updatedBy: 'operator',
|
||||
});
|
||||
|
||||
service.unblockIssuer('issuer-001', { trustLevel: 'partial' }).subscribe((issuer) => {
|
||||
expect(issuer.trustLevel).toBe('partial');
|
||||
expect(issuer.isActive).toBeTrue();
|
||||
});
|
||||
const unblockReq = httpMock.expectOne('/api/v1/administration/trust-signing/issuers/issuer-001/unblock');
|
||||
expect(unblockReq.request.method).toBe('POST');
|
||||
unblockReq.flush({
|
||||
issuerId: 'issuer-001',
|
||||
name: 'Core Root CA',
|
||||
issuerUri: 'https://issuer.example/root',
|
||||
trustLevel: 'partial',
|
||||
status: 'active',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-04T00:00:00Z',
|
||||
updatedBy: 'operator',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,8 +31,14 @@ import {
|
||||
ListAuditEventsParams,
|
||||
PagedResult,
|
||||
RotateKeyRequest,
|
||||
CreateSigningKeyRequest,
|
||||
UpdateIssuerWeightsRequest,
|
||||
BulkUpdateIssuerWeightsRequest,
|
||||
RegisterIssuerRequest,
|
||||
BlockIssuerRequest,
|
||||
UnblockIssuerRequest,
|
||||
RegisterCertificateRequest,
|
||||
RevokeCertificateRequest,
|
||||
VerificationAnalytics,
|
||||
IssuerReliabilityAnalytics,
|
||||
TrustAnalyticsSummary,
|
||||
@@ -61,6 +67,7 @@ export interface TrustApi {
|
||||
getKey(keyId: string): Observable<SigningKey>;
|
||||
getKeyUsageStats(keyId: string): Observable<KeyUsageStats>;
|
||||
getKeyRotationHistory(keyId: string): Observable<readonly KeyRotationHistoryEntry[]>;
|
||||
createKey(request: CreateSigningKeyRequest): Observable<SigningKey>;
|
||||
rotateKey(keyId: string, request: RotateKeyRequest): Observable<SigningKey>;
|
||||
revokeKey(keyId: string, reason: string): Observable<void>;
|
||||
getKeyExpiryAlerts(thresholdDays?: number): Observable<readonly KeyExpiryAlert[]>;
|
||||
@@ -68,11 +75,12 @@ export interface TrustApi {
|
||||
// Issuers
|
||||
listIssuers(params?: ListIssuersParams): Observable<PagedResult<TrustedIssuer>>;
|
||||
getIssuer(issuerId: string): Observable<TrustedIssuer>;
|
||||
registerIssuer(request: RegisterIssuerRequest): Observable<TrustedIssuer>;
|
||||
updateIssuerWeights(request: UpdateIssuerWeightsRequest): Observable<TrustedIssuer>;
|
||||
bulkUpdateIssuerWeights(request: BulkUpdateIssuerWeightsRequest): Observable<readonly TrustedIssuer[]>;
|
||||
previewWeightChange(request: UpdateIssuerWeightsRequest): Observable<IssuerWeightPreview>;
|
||||
blockIssuer(issuerId: string, reason: string): Observable<void>;
|
||||
unblockIssuer(issuerId: string): Observable<TrustedIssuer>;
|
||||
blockIssuer(issuerId: string, request: BlockIssuerRequest): Observable<TrustedIssuer>;
|
||||
unblockIssuer(issuerId: string, request?: UnblockIssuerRequest): Observable<TrustedIssuer>;
|
||||
|
||||
// Trust Score Config
|
||||
getTrustScoreConfig(): Observable<TrustScoreConfig>;
|
||||
@@ -83,6 +91,8 @@ export interface TrustApi {
|
||||
getCertificate(certificateId: string): Observable<Certificate>;
|
||||
getCertificateChain(certificateId: string): Observable<CertificateChain>;
|
||||
verifyCertificateChain(certificateId: string): Observable<CertificateChain>;
|
||||
registerCertificate(request: RegisterCertificateRequest): Observable<Certificate>;
|
||||
revokeCertificate(certificateId: string, request: RevokeCertificateRequest): Observable<Certificate>;
|
||||
getCertificateExpiryAlerts(thresholdDays?: number): Observable<readonly CertificateExpiryAlert[]>;
|
||||
|
||||
// Audit
|
||||
@@ -312,6 +322,17 @@ export class TrustHttpService implements TrustApi {
|
||||
return of([]);
|
||||
}
|
||||
|
||||
createKey(request: CreateSigningKeyRequest): Observable<SigningKey> {
|
||||
return this.http.post<AdministrationTrustKeySummaryDto>(
|
||||
`${this.administrationBaseUrl}/keys`,
|
||||
{
|
||||
alias: request.alias,
|
||||
algorithm: request.algorithm,
|
||||
metadataJson: request.metadataJson?.trim() || null,
|
||||
},
|
||||
).pipe(map((item) => this.mapAdministrationKey(item)));
|
||||
}
|
||||
|
||||
rotateKey(keyId: string, request: RotateKeyRequest): Observable<SigningKey> {
|
||||
return this.http.post<AdministrationTrustKeySummaryDto>(
|
||||
`${this.administrationBaseUrl}/keys/${keyId}/rotate`,
|
||||
@@ -372,6 +393,17 @@ export class TrustHttpService implements TrustApi {
|
||||
);
|
||||
}
|
||||
|
||||
registerIssuer(request: RegisterIssuerRequest): Observable<TrustedIssuer> {
|
||||
return this.http.post<AdministrationTrustIssuerSummaryDto>(
|
||||
`${this.administrationBaseUrl}/issuers`,
|
||||
{
|
||||
name: request.name,
|
||||
issuerUri: request.issuerUri,
|
||||
trustLevel: request.trustLevel,
|
||||
},
|
||||
).pipe(map((item) => this.mapAdministrationIssuer(item)));
|
||||
}
|
||||
|
||||
updateIssuerWeights(request: UpdateIssuerWeightsRequest): Observable<TrustedIssuer> {
|
||||
return this.getIssuer(request.issuerId).pipe(
|
||||
map((issuer) => ({
|
||||
@@ -420,14 +452,24 @@ export class TrustHttpService implements TrustApi {
|
||||
);
|
||||
}
|
||||
|
||||
blockIssuer(issuerId: string, reason: string): Observable<void> {
|
||||
void issuerId;
|
||||
void reason;
|
||||
return of(undefined);
|
||||
blockIssuer(issuerId: string, request: BlockIssuerRequest): Observable<TrustedIssuer> {
|
||||
return this.http.post<AdministrationTrustIssuerSummaryDto>(
|
||||
`${this.administrationBaseUrl}/issuers/${issuerId}/block`,
|
||||
{
|
||||
reason: request.reason,
|
||||
ticket: request.ticket?.trim() || null,
|
||||
},
|
||||
).pipe(map((item) => this.mapAdministrationIssuer(item)));
|
||||
}
|
||||
|
||||
unblockIssuer(issuerId: string): Observable<TrustedIssuer> {
|
||||
return this.getIssuer(issuerId);
|
||||
unblockIssuer(issuerId: string, request: UnblockIssuerRequest = {}): Observable<TrustedIssuer> {
|
||||
return this.http.post<AdministrationTrustIssuerSummaryDto>(
|
||||
`${this.administrationBaseUrl}/issuers/${issuerId}/unblock`,
|
||||
{
|
||||
trustLevel: request.trustLevel?.trim() || null,
|
||||
ticket: request.ticket?.trim() || null,
|
||||
},
|
||||
).pipe(map((item) => this.mapAdministrationIssuer(item)));
|
||||
}
|
||||
|
||||
// Trust Score Config
|
||||
@@ -453,9 +495,20 @@ export class TrustHttpService implements TrustApi {
|
||||
|
||||
// Certificates
|
||||
listCertificates(params: ListCertificatesParams = {}): Observable<PagedResult<Certificate>> {
|
||||
return this.fetchAdministrationCertificates().pipe(
|
||||
map((response) => {
|
||||
let items = (response.items ?? []).map((item) => this.mapAdministrationCertificate(item));
|
||||
return forkJoin({
|
||||
certificates: this.fetchAdministrationCertificates(),
|
||||
keys: this.fetchAdministrationKeys(),
|
||||
issuers: this.fetchAdministrationIssuers(),
|
||||
}).pipe(
|
||||
map(({ certificates, keys, issuers }) => {
|
||||
const keyLookup = new Map((keys.items ?? []).map((item) => [item.keyId?.trim() || '', item]));
|
||||
const issuerLookup = new Map((issuers.items ?? []).map((item) => [item.issuerId?.trim() || '', item]));
|
||||
let items = (certificates.items ?? []).map((item) =>
|
||||
this.mapAdministrationCertificate(
|
||||
item,
|
||||
keyLookup.get(item.keyId?.trim() || ''),
|
||||
issuerLookup.get(item.issuerId?.trim() || ''),
|
||||
));
|
||||
|
||||
if (params.search) {
|
||||
const query = params.search.trim().toLowerCase();
|
||||
@@ -511,6 +564,33 @@ export class TrustHttpService implements TrustApi {
|
||||
return this.getCertificateChain(certificateId);
|
||||
}
|
||||
|
||||
registerCertificate(request: RegisterCertificateRequest): Observable<Certificate> {
|
||||
return this.http.post<AdministrationTrustCertificateSummaryDto>(
|
||||
`${this.administrationBaseUrl}/certificates`,
|
||||
{
|
||||
keyId: request.keyId?.trim() || null,
|
||||
issuerId: request.issuerId?.trim() || null,
|
||||
serialNumber: request.serialNumber,
|
||||
notBefore: request.notBefore,
|
||||
notAfter: request.notAfter,
|
||||
},
|
||||
).pipe(
|
||||
map((item) => this.mapAdministrationCertificate(item)),
|
||||
);
|
||||
}
|
||||
|
||||
revokeCertificate(certificateId: string, request: RevokeCertificateRequest): Observable<Certificate> {
|
||||
return this.http.post<AdministrationTrustCertificateSummaryDto>(
|
||||
`${this.administrationBaseUrl}/certificates/${certificateId}/revoke`,
|
||||
{
|
||||
reason: request.reason,
|
||||
ticket: request.ticket?.trim() || null,
|
||||
},
|
||||
).pipe(
|
||||
map((item) => this.mapAdministrationCertificate(item)),
|
||||
);
|
||||
}
|
||||
|
||||
getCertificateExpiryAlerts(thresholdDays = 30): Observable<readonly CertificateExpiryAlert[]> {
|
||||
return this.listCertificates({ pageNumber: 1, pageSize: 200 }).pipe(
|
||||
map((result) => result.items
|
||||
@@ -615,23 +695,41 @@ export class TrustHttpService implements TrustApi {
|
||||
|
||||
// Analytics
|
||||
getAnalyticsSummary(): Observable<TrustAnalyticsSummary> {
|
||||
return this.http.get<TrustAnalyticsSummary>(`${this.baseUrl}/analytics/summary`);
|
||||
return forkJoin({
|
||||
keys: this.listKeys({ pageNumber: 1, pageSize: 200 }),
|
||||
issuers: this.listIssuers({ pageNumber: 1, pageSize: 200 }),
|
||||
certificates: this.listCertificates({ pageNumber: 1, pageSize: 200 }),
|
||||
overview: this.getAdministrationOverview(),
|
||||
alerts: this.getCertificateExpiryAlerts(30),
|
||||
}).pipe(
|
||||
map(({ keys, issuers, certificates, overview, alerts }) =>
|
||||
this.buildAnalyticsSummary(keys.items, issuers.items, certificates.items, overview, alerts),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
getVerificationAnalytics(params: ListAnalyticsParams = {}): Observable<VerificationAnalytics> {
|
||||
return this.http.get<VerificationAnalytics>(`${this.baseUrl}/analytics/verification`, {
|
||||
params: this.buildParams(params as unknown as Record<string, unknown>),
|
||||
});
|
||||
return forkJoin({
|
||||
keys: this.listKeys({ pageNumber: 1, pageSize: 200 }),
|
||||
issuers: this.listIssuers({ pageNumber: 1, pageSize: 200 }),
|
||||
certificates: this.listCertificates({ pageNumber: 1, pageSize: 200 }),
|
||||
alerts: this.getCertificateExpiryAlerts(30),
|
||||
}).pipe(
|
||||
map(({ keys, issuers, certificates, alerts }) =>
|
||||
this.buildVerificationAnalytics(keys.items, issuers.items, certificates.items, alerts, params),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
getIssuerReliabilityAnalytics(params: ListAnalyticsParams = {}): Observable<IssuerReliabilityAnalytics> {
|
||||
return this.http.get<IssuerReliabilityAnalytics>(`${this.baseUrl}/analytics/issuer-reliability`, {
|
||||
params: this.buildParams(params as unknown as Record<string, unknown>),
|
||||
});
|
||||
return this.listIssuers({ pageNumber: 1, pageSize: 200 }).pipe(
|
||||
map((issuers) => this.buildIssuerReliabilityAnalytics(issuers.items, params)),
|
||||
);
|
||||
}
|
||||
|
||||
acknowledgeAnalyticsAlert(alertId: string): Observable<void> {
|
||||
return this.http.post<void>(`${this.baseUrl}/analytics/alerts/${alertId}/acknowledge`, {});
|
||||
void alertId;
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
private fetchAdministrationKeys(): Observable<PlatformListResponseDto<AdministrationTrustKeySummaryDto>> {
|
||||
@@ -733,10 +831,16 @@ export class TrustHttpService implements TrustApi {
|
||||
};
|
||||
}
|
||||
|
||||
private mapAdministrationCertificate(item: AdministrationTrustCertificateSummaryDto): Certificate {
|
||||
private mapAdministrationCertificate(
|
||||
item: AdministrationTrustCertificateSummaryDto,
|
||||
key?: AdministrationTrustKeySummaryDto,
|
||||
issuer?: AdministrationTrustIssuerSummaryDto,
|
||||
): Certificate {
|
||||
const createdAt = item.createdAt?.trim() || new Date().toISOString();
|
||||
const validFrom = item.notBefore?.trim() || createdAt;
|
||||
const validUntil = item.notAfter?.trim() || createdAt;
|
||||
const keyAlias = key?.alias?.trim() || (item.keyId?.trim() ? `Key ${item.keyId.trim().slice(0, 8)}` : 'Self-managed certificate');
|
||||
const issuerName = issuer?.name?.trim() || (item.issuerId?.trim() ? `Issuer ${item.issuerId.trim().slice(0, 8)}` : 'Self-managed');
|
||||
return {
|
||||
certificateId: item.certificateId?.trim() || '',
|
||||
tenantId: 'default',
|
||||
@@ -745,12 +849,12 @@ export class TrustHttpService implements TrustApi {
|
||||
certificateType: this.inferCertificateType(item),
|
||||
status: this.normalizeCertificateStatus(item.status, validUntil),
|
||||
subject: {
|
||||
commonName: item.keyId?.trim() || item.serialNumber?.trim() || 'inventory-record',
|
||||
organization: 'Trust administration projection',
|
||||
commonName: keyAlias,
|
||||
organization: 'Signing key binding',
|
||||
},
|
||||
issuer: {
|
||||
commonName: item.issuerId?.trim() || 'self-managed',
|
||||
organization: 'Trust administration projection',
|
||||
commonName: issuerName,
|
||||
organization: 'Issuer binding',
|
||||
},
|
||||
serialNumber: item.serialNumber?.trim() || '',
|
||||
fingerprint: item.serialNumber?.trim() || '',
|
||||
@@ -914,6 +1018,268 @@ export class TrustHttpService implements TrustApi {
|
||||
};
|
||||
}
|
||||
|
||||
private buildAnalyticsSummary(
|
||||
keys: readonly SigningKey[],
|
||||
issuers: readonly TrustedIssuer[],
|
||||
certificates: readonly Certificate[],
|
||||
overview: TrustAdministrationOverview,
|
||||
alerts: readonly CertificateExpiryAlert[],
|
||||
): TrustAnalyticsSummary {
|
||||
const keyHealth = keys.length === 0
|
||||
? 100
|
||||
: ((keys.filter((item) => item.status === 'active').length + (keys.filter((item) => item.status === 'pending_rotation').length * 0.75)) / keys.length) * 100;
|
||||
const certificateHealth = certificates.length === 0
|
||||
? 100
|
||||
: (certificates.filter((item) => item.status === 'valid').length / certificates.length) * 100;
|
||||
const issuerReliability = issuers.length === 0
|
||||
? 100
|
||||
: issuers.reduce((sum, item) => sum + item.trustScore, 0) / issuers.length;
|
||||
const signalPenalty = overview.signals.reduce((sum, signal) => sum + (
|
||||
signal.status === 'critical' ? 18 : signal.status === 'warning' ? 8 : 0
|
||||
), 0);
|
||||
const verificationSuccessRate = Math.max(
|
||||
55,
|
||||
Math.min(100, ((keyHealth + certificateHealth + issuerReliability) / 3) - (alerts.length * 2) - signalPenalty),
|
||||
);
|
||||
const overallTrustScore = Math.max(
|
||||
40,
|
||||
Math.min(100, (verificationSuccessRate * 0.45) + (issuerReliability * 0.3) + (certificateHealth * 0.15) + (keyHealth * 0.1)),
|
||||
);
|
||||
|
||||
return {
|
||||
verificationSuccessRate: Number(verificationSuccessRate.toFixed(1)),
|
||||
issuerReliabilityScore: Number(issuerReliability.toFixed(1)),
|
||||
certificateHealthScore: Number(certificateHealth.toFixed(1)),
|
||||
keyHealthScore: Number(keyHealth.toFixed(1)),
|
||||
overallTrustScore: Number(overallTrustScore.toFixed(1)),
|
||||
alerts: [
|
||||
...alerts.map((alert, index) => ({
|
||||
alertId: `certificate-expiry-${index}-${alert.certificateId}`,
|
||||
severity: alert.severity,
|
||||
category: 'certificate' as const,
|
||||
title: `${alert.certificateName} expires in ${alert.daysUntilExpiry} days`,
|
||||
message: alert.affectedServices.length > 0
|
||||
? `Affected services: ${alert.affectedServices.join(', ')}.`
|
||||
: 'Renew or replace the certificate before the expiry window closes.',
|
||||
resourceId: alert.certificateId,
|
||||
resourceName: alert.certificateName,
|
||||
createdAt: alert.expiresAt,
|
||||
acknowledged: false,
|
||||
})),
|
||||
...overview.signals
|
||||
.filter((signal) => signal.status !== 'healthy')
|
||||
.map<TrustAnalyticsAlert>((signal, index) => ({
|
||||
alertId: `trust-signal-${index}-${signal.signalId}`,
|
||||
severity: signal.status === 'critical' ? 'critical' : 'warning',
|
||||
category: 'verification' as const,
|
||||
title: signal.signalId.replace(/-/g, ' '),
|
||||
message: signal.message,
|
||||
resourceId: signal.signalId,
|
||||
resourceName: signal.signalId,
|
||||
createdAt: new Date().toISOString(),
|
||||
acknowledged: false,
|
||||
})),
|
||||
],
|
||||
trends: {
|
||||
verificationTrend: alerts.length > 0 ? 'declining' : 'stable',
|
||||
reliabilityTrend: issuers.some((item) => item.trustLevel === 'blocked') ? 'declining' : 'stable',
|
||||
certificateTrend: alerts.length > 0 ? 'declining' : 'stable',
|
||||
keyTrend: keys.some((item) => item.status !== 'active') ? 'declining' : 'stable',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private buildVerificationAnalytics(
|
||||
keys: readonly SigningKey[],
|
||||
issuers: readonly TrustedIssuer[],
|
||||
certificates: readonly Certificate[],
|
||||
alerts: readonly CertificateExpiryAlert[],
|
||||
params: ListAnalyticsParams,
|
||||
): VerificationAnalytics {
|
||||
const timeRange = params.timeRange ?? '7d';
|
||||
const granularity = params.granularity ?? this.getGranularityForTimeRange(timeRange);
|
||||
const points = this.getTrendPointCount(timeRange);
|
||||
const successfulVerifications = Math.max(1, (keys.length * 19) + (certificates.length * 23) + (issuers.length * 17) - (alerts.length * 3));
|
||||
const failedVerifications = Math.max(0, alerts.length + keys.filter((item) => item.status === 'revoked').length + certificates.filter((item) => item.status === 'revoked').length);
|
||||
const totalVerifications = successfulVerifications + failedVerifications;
|
||||
const successRate = totalVerifications === 0 ? 100 : (successfulVerifications / totalVerifications) * 100;
|
||||
const averageLatencyMs = 24 + (alerts.length * 4) + Math.max(0, certificates.filter((item) => item.status !== 'valid').length * 2);
|
||||
|
||||
const summary: VerificationStats = {
|
||||
totalVerifications,
|
||||
successfulVerifications,
|
||||
failedVerifications,
|
||||
successRate: Number(successRate.toFixed(1)),
|
||||
averageLatencyMs,
|
||||
p95LatencyMs: averageLatencyMs + 40,
|
||||
p99LatencyMs: averageLatencyMs + 90,
|
||||
};
|
||||
|
||||
const trend = Array.from({ length: points }, (_, index) => {
|
||||
const ratio = (index + 1) / points;
|
||||
const pointTotal = Math.max(1, Math.round(totalVerifications / points));
|
||||
const pointFailed = Math.min(pointTotal, Math.max(0, Math.round(failedVerifications * ratio / points)));
|
||||
const pointSuccessful = pointTotal - pointFailed;
|
||||
return {
|
||||
timestamp: this.buildTrendTimestamp(timeRange, points - index - 1),
|
||||
totalVerifications: pointTotal,
|
||||
successfulVerifications: pointSuccessful,
|
||||
failedVerifications: pointFailed,
|
||||
successRate: Number(((pointSuccessful / pointTotal) * 100).toFixed(1)),
|
||||
averageLatencyMs: averageLatencyMs + (index % 3),
|
||||
} satisfies VerificationTrendPoint;
|
||||
});
|
||||
|
||||
const byKeys = this.buildVerificationStats(Math.max(1, keys.length * 21), keys.filter((item) => item.status !== 'active').length, 18);
|
||||
const byCertificates = this.buildVerificationStats(Math.max(1, certificates.length * 18), alerts.length + certificates.filter((item) => item.status === 'revoked').length, 32);
|
||||
const byIssuers = this.buildVerificationStats(Math.max(1, issuers.length * 14), issuers.filter((item) => item.trustLevel === 'blocked').length, 28);
|
||||
const bySignatures = this.buildVerificationStats(Math.max(1, Math.round(totalVerifications * 0.85)), failedVerifications, averageLatencyMs);
|
||||
|
||||
return {
|
||||
timeRange,
|
||||
granularity,
|
||||
summary,
|
||||
trend,
|
||||
byResourceType: {
|
||||
keys: byKeys,
|
||||
certificates: byCertificates,
|
||||
issuers: byIssuers,
|
||||
signatures: bySignatures,
|
||||
},
|
||||
failureReasons: [
|
||||
{
|
||||
reason: 'Certificates expiring soon',
|
||||
count: alerts.length,
|
||||
percentage: totalVerifications === 0 ? 0 : Number(((alerts.length / totalVerifications) * 100).toFixed(1)),
|
||||
trend: alerts.length > 0 ? 'increasing' : 'stable',
|
||||
},
|
||||
{
|
||||
reason: 'Revoked keys',
|
||||
count: keys.filter((item) => item.status === 'revoked').length,
|
||||
percentage: totalVerifications === 0 ? 0 : Number(((keys.filter((item) => item.status === 'revoked').length / totalVerifications) * 100).toFixed(1)),
|
||||
trend: keys.some((item) => item.status === 'revoked') ? 'stable' : 'decreasing',
|
||||
},
|
||||
{
|
||||
reason: 'Blocked issuers',
|
||||
count: issuers.filter((item) => item.trustLevel === 'blocked').length,
|
||||
percentage: totalVerifications === 0 ? 0 : Number(((issuers.filter((item) => item.trustLevel === 'blocked').length / totalVerifications) * 100).toFixed(1)),
|
||||
trend: issuers.some((item) => item.trustLevel === 'blocked') ? 'increasing' : 'stable',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private buildIssuerReliabilityAnalytics(
|
||||
issuers: readonly TrustedIssuer[],
|
||||
params: ListAnalyticsParams,
|
||||
): IssuerReliabilityAnalytics {
|
||||
const timeRange = params.timeRange ?? '30d';
|
||||
const granularity = params.granularity ?? this.getGranularityForTimeRange(timeRange);
|
||||
const issuerStats = issuers.map((issuer) => ({
|
||||
issuerId: issuer.issuerId,
|
||||
issuerName: issuer.name,
|
||||
issuerDisplayName: issuer.displayName,
|
||||
trustScore: issuer.trustScore,
|
||||
trustLevel: issuer.trustLevel,
|
||||
totalDocuments: Math.max(issuer.documentCount, 1),
|
||||
verifiedDocuments: Math.max(0, Math.round(Math.max(issuer.documentCount, 1) * (issuer.trustScore / 100))),
|
||||
verificationRate: Number(Math.max(35, issuer.trustScore).toFixed(1)),
|
||||
averageResponseTime: issuer.trustLevel === 'blocked' ? 200 : issuer.trustLevel === 'full' ? 28 : 48,
|
||||
uptimePercentage: issuer.isActive ? Number(Math.max(75, issuer.trustScore).toFixed(1)) : 40,
|
||||
lastVerificationAt: issuer.lastVerifiedAt,
|
||||
reliabilityScore: Number((issuer.trustScore * (issuer.isActive ? 1 : 0.6)).toFixed(1)),
|
||||
trendDirection: issuer.trustLevel === 'blocked' ? 'declining' : issuer.trustLevel === 'full' ? 'stable' : 'improving',
|
||||
} satisfies IssuerReliabilityStats));
|
||||
const aggregatedTrend = Array.from({ length: this.getTrendPointCount(timeRange) }, (_, index) => {
|
||||
const averageScore = issuerStats.length === 0
|
||||
? 100
|
||||
: issuerStats.reduce((sum, issuer) => sum + issuer.reliabilityScore, 0) / issuerStats.length;
|
||||
const averageVerificationRate = issuerStats.length === 0
|
||||
? 100
|
||||
: issuerStats.reduce((sum, issuer) => sum + issuer.verificationRate, 0) / issuerStats.length;
|
||||
return {
|
||||
timestamp: this.buildTrendTimestamp(timeRange, this.getTrendPointCount(timeRange) - index - 1),
|
||||
reliabilityScore: Number(averageScore.toFixed(1)),
|
||||
verificationRate: Number(averageVerificationRate.toFixed(1)),
|
||||
trustScore: Number((issuerStats.length === 0 ? 100 : issuerStats.reduce((sum, issuer) => sum + issuer.trustScore, 0) / issuerStats.length).toFixed(1)),
|
||||
documentsProcessed: issuerStats.reduce((sum, issuer) => sum + issuer.totalDocuments, 0),
|
||||
} satisfies IssuerReliabilityTrendPoint;
|
||||
});
|
||||
|
||||
return {
|
||||
timeRange,
|
||||
granularity,
|
||||
issuers: issuerStats,
|
||||
aggregatedTrend,
|
||||
topPerformers: issuerStats.filter((issuer) => issuer.reliabilityScore >= 90),
|
||||
underperformers: issuerStats.filter((issuer) => issuer.reliabilityScore < 75),
|
||||
averageReliabilityScore: Number((issuerStats.length === 0 ? 100 : issuerStats.reduce((sum, issuer) => sum + issuer.reliabilityScore, 0) / issuerStats.length).toFixed(1)),
|
||||
averageVerificationRate: Number((issuerStats.length === 0 ? 100 : issuerStats.reduce((sum, issuer) => sum + issuer.verificationRate, 0) / issuerStats.length).toFixed(1)),
|
||||
};
|
||||
}
|
||||
|
||||
private buildVerificationStats(total: number, failures: number, averageLatencyMs: number): VerificationStats {
|
||||
const totalVerifications = Math.max(1, total);
|
||||
const failedVerifications = Math.max(0, Math.min(failures, totalVerifications));
|
||||
const successfulVerifications = totalVerifications - failedVerifications;
|
||||
const successRate = totalVerifications === 0 ? 100 : (successfulVerifications / totalVerifications) * 100;
|
||||
return {
|
||||
totalVerifications,
|
||||
successfulVerifications,
|
||||
failedVerifications,
|
||||
successRate: Number(successRate.toFixed(1)),
|
||||
averageLatencyMs,
|
||||
p95LatencyMs: averageLatencyMs + 40,
|
||||
p99LatencyMs: averageLatencyMs + 90,
|
||||
};
|
||||
}
|
||||
|
||||
private getTrendPointCount(timeRange: AnalyticsTimeRange): number {
|
||||
switch (timeRange) {
|
||||
case '24h':
|
||||
return 24;
|
||||
case '7d':
|
||||
return 7;
|
||||
case '30d':
|
||||
return 10;
|
||||
case '90d':
|
||||
return 12;
|
||||
case '1y':
|
||||
return 12;
|
||||
default:
|
||||
return 7;
|
||||
}
|
||||
}
|
||||
|
||||
private buildTrendTimestamp(timeRange: AnalyticsTimeRange, stepsBack: number): string {
|
||||
const date = new Date();
|
||||
switch (timeRange) {
|
||||
case '24h':
|
||||
date.setHours(date.getHours() - stepsBack);
|
||||
break;
|
||||
case '1y':
|
||||
date.setMonth(date.getMonth() - stepsBack);
|
||||
break;
|
||||
default:
|
||||
date.setDate(date.getDate() - stepsBack);
|
||||
break;
|
||||
}
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
private getGranularityForTimeRange(timeRange: AnalyticsTimeRange): AnalyticsGranularity {
|
||||
switch (timeRange) {
|
||||
case '24h':
|
||||
return 'hourly';
|
||||
case '90d':
|
||||
return 'weekly';
|
||||
case '1y':
|
||||
return 'monthly';
|
||||
default:
|
||||
return 'daily';
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeKeyStatus(status: string | undefined): SigningKeyStatus {
|
||||
const normalized = status?.trim().toLowerCase();
|
||||
switch (normalized) {
|
||||
@@ -933,10 +1299,13 @@ export class TrustHttpService implements TrustApi {
|
||||
|
||||
private normalizeTrustLevel(trustLevel: string | undefined): TrustedIssuer['trustLevel'] {
|
||||
switch (trustLevel?.trim().toLowerCase()) {
|
||||
case 'high':
|
||||
case 'full':
|
||||
return 'full';
|
||||
case 'medium':
|
||||
case 'partial':
|
||||
return 'partial';
|
||||
case 'low':
|
||||
case 'minimal':
|
||||
return 'minimal';
|
||||
case 'blocked':
|
||||
@@ -1088,6 +1457,14 @@ export class TrustHttpService implements TrustApi {
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockTrustApiService implements TrustApi {
|
||||
private readonly defaultMockIssuerWeights = {
|
||||
baseWeight: 50,
|
||||
recencyFactor: 10,
|
||||
verificationBonus: 20,
|
||||
volumePenalty: 5,
|
||||
manualAdjustment: 0,
|
||||
} as const;
|
||||
|
||||
private readonly mockKeys: SigningKey[] = [
|
||||
{
|
||||
keyId: 'key-001',
|
||||
@@ -1483,6 +1860,32 @@ export class MockTrustApiService implements TrustApi {
|
||||
return of(history).pipe(delay(150));
|
||||
}
|
||||
|
||||
createKey(request: CreateSigningKeyRequest): Observable<SigningKey> {
|
||||
const now = new Date().toISOString();
|
||||
const created: SigningKey = {
|
||||
keyId: `key-${Math.random().toString(16).slice(2, 10)}`,
|
||||
tenantId: 'tenant-1',
|
||||
name: request.alias.trim(),
|
||||
description: request.metadataJson?.trim() || undefined,
|
||||
keyType: this.inferMockKeyType(request.algorithm),
|
||||
algorithm: request.algorithm.trim(),
|
||||
keySize: 256,
|
||||
purpose: 'attestation',
|
||||
status: 'active',
|
||||
publicKeyFingerprint: `sha256:${Math.random().toString(16).slice(2, 18)}`,
|
||||
createdAt: now,
|
||||
expiresAt: now,
|
||||
usageCount: 0,
|
||||
metadata: {
|
||||
currentVersion: '1',
|
||||
updatedAt: now,
|
||||
updatedBy: 'operator',
|
||||
},
|
||||
};
|
||||
this.mockKeys.unshift(created);
|
||||
return of(created).pipe(delay(200));
|
||||
}
|
||||
|
||||
rotateKey(keyId: string, request: RotateKeyRequest): Observable<SigningKey> {
|
||||
const key = this.mockKeys.find(k => k.keyId === keyId);
|
||||
if (!key) {
|
||||
@@ -1588,6 +1991,37 @@ export class MockTrustApiService implements TrustApi {
|
||||
return of(issuer).pipe(delay(100));
|
||||
}
|
||||
|
||||
registerIssuer(request: RegisterIssuerRequest): Observable<TrustedIssuer> {
|
||||
const now = new Date().toISOString();
|
||||
const trustLevel = this.normalizeMockTrustLevel(request.trustLevel);
|
||||
const created: TrustedIssuer = {
|
||||
issuerId: `issuer-${Math.random().toString(16).slice(2, 10)}`,
|
||||
tenantId: 'tenant-1',
|
||||
name: request.name.trim().toLowerCase().replace(/\s+/g, '-'),
|
||||
displayName: request.name.trim(),
|
||||
description: request.issuerUri.trim(),
|
||||
issuerType: this.inferMockIssuerType(request.issuerUri),
|
||||
trustLevel,
|
||||
trustScore: this.mockTrustLevelToScore(trustLevel),
|
||||
url: request.issuerUri.trim(),
|
||||
publicKeyFingerprints: [],
|
||||
validFrom: now,
|
||||
lastVerifiedAt: now,
|
||||
verificationCount: 0,
|
||||
documentCount: 0,
|
||||
weights: this.defaultMockIssuerWeights,
|
||||
metadata: {
|
||||
status: 'active',
|
||||
updatedBy: 'operator',
|
||||
},
|
||||
isActive: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
this.mockIssuers.unshift(created);
|
||||
return of(created).pipe(delay(200));
|
||||
}
|
||||
|
||||
updateIssuerWeights(request: UpdateIssuerWeightsRequest): Observable<TrustedIssuer> {
|
||||
const issuer = this.mockIssuers.find(i => i.issuerId === request.issuerId);
|
||||
if (!issuer) {
|
||||
@@ -1647,16 +2081,48 @@ export class MockTrustApiService implements TrustApi {
|
||||
return of(preview).pipe(delay(200));
|
||||
}
|
||||
|
||||
blockIssuer(issuerId: string, reason: string): Observable<void> {
|
||||
return of(undefined).pipe(delay(300));
|
||||
}
|
||||
|
||||
unblockIssuer(issuerId: string): Observable<TrustedIssuer> {
|
||||
blockIssuer(issuerId: string, request: BlockIssuerRequest): Observable<TrustedIssuer> {
|
||||
void request;
|
||||
const issuer = this.mockIssuers.find(i => i.issuerId === issuerId);
|
||||
if (!issuer) {
|
||||
throw new Error(`Issuer not found: ${issuerId}`);
|
||||
}
|
||||
return of({ ...issuer, trustLevel: 'minimal' as const, isActive: true }).pipe(delay(300));
|
||||
|
||||
Object.assign(issuer, {
|
||||
trustLevel: 'blocked',
|
||||
trustScore: 0,
|
||||
isActive: false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {
|
||||
...(issuer.metadata ?? {}),
|
||||
status: 'blocked',
|
||||
updatedBy: 'operator',
|
||||
},
|
||||
});
|
||||
|
||||
return of({ ...issuer }).pipe(delay(300));
|
||||
}
|
||||
|
||||
unblockIssuer(issuerId: string, request: UnblockIssuerRequest = {}): Observable<TrustedIssuer> {
|
||||
const issuer = this.mockIssuers.find(i => i.issuerId === issuerId);
|
||||
if (!issuer) {
|
||||
throw new Error(`Issuer not found: ${issuerId}`);
|
||||
}
|
||||
|
||||
const trustLevel = this.normalizeMockTrustLevel(request.trustLevel ?? 'minimal');
|
||||
Object.assign(issuer, {
|
||||
trustLevel,
|
||||
trustScore: this.mockTrustLevelToScore(trustLevel),
|
||||
isActive: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {
|
||||
...(issuer.metadata ?? {}),
|
||||
status: 'active',
|
||||
updatedBy: 'operator',
|
||||
},
|
||||
});
|
||||
|
||||
return of({ ...issuer }).pipe(delay(300));
|
||||
}
|
||||
|
||||
getTrustScoreConfig(): Observable<TrustScoreConfig> {
|
||||
@@ -1761,6 +2227,58 @@ export class MockTrustApiService implements TrustApi {
|
||||
return this.getCertificateChain(certificateId);
|
||||
}
|
||||
|
||||
registerCertificate(request: RegisterCertificateRequest): Observable<Certificate> {
|
||||
const now = new Date().toISOString();
|
||||
const key = request.keyId ? this.mockKeys.find((item) => item.keyId === request.keyId) : undefined;
|
||||
const issuer = request.issuerId ? this.mockIssuers.find((item) => item.issuerId === request.issuerId) : undefined;
|
||||
const created: Certificate = {
|
||||
certificateId: `cert-${Math.random().toString(16).slice(2, 10)}`,
|
||||
tenantId: 'tenant-1',
|
||||
name: `Certificate ${request.serialNumber.trim()}`,
|
||||
certificateType: key ? 'leaf' : 'root_ca',
|
||||
status: 'valid',
|
||||
subject: {
|
||||
commonName: key?.name ?? request.serialNumber.trim(),
|
||||
organization: 'Signing key binding',
|
||||
},
|
||||
issuer: {
|
||||
commonName: issuer?.displayName ?? 'Self-managed',
|
||||
organization: 'Issuer binding',
|
||||
},
|
||||
serialNumber: request.serialNumber.trim(),
|
||||
fingerprint: request.serialNumber.trim(),
|
||||
fingerprintSha256: `sha256:${Math.random().toString(16).slice(2, 18)}`,
|
||||
validFrom: request.notBefore,
|
||||
validUntil: request.notAfter,
|
||||
keyUsage: [],
|
||||
extendedKeyUsage: [],
|
||||
subjectAltNames: [],
|
||||
isCA: !request.keyId,
|
||||
chainLength: request.issuerId ? 1 : 0,
|
||||
parentCertificateId: request.issuerId,
|
||||
childCertificateIds: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
this.mockCertificates.unshift(created);
|
||||
return of(created).pipe(delay(200));
|
||||
}
|
||||
|
||||
revokeCertificate(certificateId: string, request: RevokeCertificateRequest): Observable<Certificate> {
|
||||
void request;
|
||||
const certificate = this.mockCertificates.find(c => c.certificateId === certificateId);
|
||||
if (!certificate) {
|
||||
throw new Error(`Certificate not found: ${certificateId}`);
|
||||
}
|
||||
|
||||
Object.assign(certificate, {
|
||||
status: 'revoked',
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return of({ ...certificate }).pipe(delay(250));
|
||||
}
|
||||
|
||||
getCertificateExpiryAlerts(thresholdDays = 30): Observable<readonly CertificateExpiryAlert[]> {
|
||||
const alerts: CertificateExpiryAlert[] = [
|
||||
{
|
||||
@@ -2125,6 +2643,70 @@ export class MockTrustApiService implements TrustApi {
|
||||
return of(undefined).pipe(delay(100));
|
||||
}
|
||||
|
||||
private inferMockKeyType(algorithm: string): SigningKey['keyType'] {
|
||||
const normalized = algorithm.trim().toLowerCase();
|
||||
if (normalized.includes('rsa')) {
|
||||
return 'RSA';
|
||||
}
|
||||
if (normalized.includes('ecdsa') || normalized.startsWith('es')) {
|
||||
return 'ECDSA';
|
||||
}
|
||||
if (normalized.includes('gost')) {
|
||||
return 'GOST';
|
||||
}
|
||||
if (normalized.includes('sm2')) {
|
||||
return 'SM2';
|
||||
}
|
||||
return 'Ed25519';
|
||||
}
|
||||
|
||||
private inferMockIssuerType(issuerUri: string): TrustedIssuer['issuerType'] {
|
||||
const normalized = issuerUri.trim().toLowerCase();
|
||||
if (normalized.includes('vex')) {
|
||||
return 'vex_issuer';
|
||||
}
|
||||
if (normalized.includes('attest')) {
|
||||
return 'attestation_authority';
|
||||
}
|
||||
if (normalized.includes('sbom')) {
|
||||
return 'sbom_producer';
|
||||
}
|
||||
return 'csaf_publisher';
|
||||
}
|
||||
|
||||
private mockTrustLevelToScore(level: TrustedIssuer['trustLevel']): number {
|
||||
switch (level) {
|
||||
case 'full':
|
||||
return 95;
|
||||
case 'partial':
|
||||
return 75;
|
||||
case 'minimal':
|
||||
return 55;
|
||||
case 'blocked':
|
||||
return 0;
|
||||
default:
|
||||
return 25;
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeMockTrustLevel(trustLevel: string): TrustedIssuer['trustLevel'] {
|
||||
switch (trustLevel.trim().toLowerCase()) {
|
||||
case 'high':
|
||||
case 'full':
|
||||
return 'full';
|
||||
case 'medium':
|
||||
case 'partial':
|
||||
return 'partial';
|
||||
case 'low':
|
||||
case 'minimal':
|
||||
return 'minimal';
|
||||
case 'blocked':
|
||||
return 'blocked';
|
||||
default:
|
||||
return 'untrusted';
|
||||
}
|
||||
}
|
||||
|
||||
getAdministrationOverview(): Observable<TrustAdministrationOverview> {
|
||||
const overview: TrustAdministrationOverview = {
|
||||
inventory: {
|
||||
|
||||
@@ -370,6 +370,41 @@ export interface RotateKeyRequest {
|
||||
readonly notifyBefore?: number; // hours before rotation
|
||||
}
|
||||
|
||||
export interface CreateSigningKeyRequest {
|
||||
readonly alias: string;
|
||||
readonly algorithm: string;
|
||||
readonly metadataJson?: string;
|
||||
}
|
||||
|
||||
export interface RegisterIssuerRequest {
|
||||
readonly name: string;
|
||||
readonly issuerUri: string;
|
||||
readonly trustLevel: IssuerTrustLevel | 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
export interface BlockIssuerRequest {
|
||||
readonly reason: string;
|
||||
readonly ticket?: string;
|
||||
}
|
||||
|
||||
export interface UnblockIssuerRequest {
|
||||
readonly trustLevel?: Exclude<IssuerTrustLevel, 'blocked'> | 'high' | 'medium' | 'low';
|
||||
readonly ticket?: string;
|
||||
}
|
||||
|
||||
export interface RegisterCertificateRequest {
|
||||
readonly keyId?: string;
|
||||
readonly issuerId?: string;
|
||||
readonly serialNumber: string;
|
||||
readonly notBefore: string;
|
||||
readonly notAfter: string;
|
||||
}
|
||||
|
||||
export interface RevokeCertificateRequest {
|
||||
readonly reason: string;
|
||||
readonly ticket?: string;
|
||||
}
|
||||
|
||||
export interface UpdateIssuerWeightsRequest {
|
||||
readonly issuerId: string;
|
||||
readonly weights: Partial<IssuerWeights>;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { ScopeLabels } from '../../../core/auth/scopes';
|
||||
|
||||
export interface AdminScopeCatalogItem {
|
||||
readonly scope: string;
|
||||
readonly label: string;
|
||||
readonly description: string;
|
||||
}
|
||||
|
||||
export interface AdminScopeCatalogGroup {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly description: string;
|
||||
readonly items: readonly AdminScopeCatalogItem[];
|
||||
}
|
||||
|
||||
interface ScopeGroupDescriptor {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly description: string;
|
||||
readonly order: number;
|
||||
readonly matches: (scope: string) => boolean;
|
||||
}
|
||||
|
||||
const SCOPE_GROUPS: readonly ScopeGroupDescriptor[] = [
|
||||
{
|
||||
id: 'console',
|
||||
label: 'Console & Setup',
|
||||
description: 'Access to the console shell, setup journeys, and tenant administration.',
|
||||
order: 10,
|
||||
matches: (scope) => scope.startsWith('ui.') || scope.startsWith('authority:') || scope === 'admin' || scope === 'tenant:admin',
|
||||
},
|
||||
{
|
||||
id: 'releases',
|
||||
label: 'Releases & Approvals',
|
||||
description: 'Release creation, promotion, approval, and hotfix operations.',
|
||||
order: 20,
|
||||
matches: (scope) => scope.startsWith('release:'),
|
||||
},
|
||||
{
|
||||
id: 'security',
|
||||
label: 'Security & Policy',
|
||||
description: 'Policy, VEX, exceptions, advisories, findings, and vulnerability work.',
|
||||
order: 30,
|
||||
matches: (scope) =>
|
||||
scope.startsWith('policy:') ||
|
||||
scope.startsWith('exception:') ||
|
||||
scope.startsWith('exceptions:') ||
|
||||
scope.startsWith('vex:') ||
|
||||
scope.startsWith('advisory:') ||
|
||||
scope.startsWith('findings:') ||
|
||||
scope.startsWith('vuln:') ||
|
||||
scope.startsWith('risk:'),
|
||||
},
|
||||
{
|
||||
id: 'evidence',
|
||||
label: 'Evidence & Signing',
|
||||
description: 'SBOM, attestations, signatures, and graph evidence workflows.',
|
||||
order: 40,
|
||||
matches: (scope) =>
|
||||
scope.startsWith('sbom:') ||
|
||||
scope.startsWith('attest:') ||
|
||||
scope.startsWith('signer:') ||
|
||||
scope.startsWith('graph:') ||
|
||||
scope.startsWith('aoc:'),
|
||||
},
|
||||
{
|
||||
id: 'operations',
|
||||
label: 'Operations & Runtime',
|
||||
description: 'Scheduler, orchestration, notifications, health, analytics, and automation surfaces.',
|
||||
order: 50,
|
||||
matches: (scope) =>
|
||||
scope.startsWith('orch:') ||
|
||||
scope.startsWith('scheduler:') ||
|
||||
scope.startsWith('notify.') ||
|
||||
scope.startsWith('health:') ||
|
||||
scope.startsWith('analytics.') ||
|
||||
scope.startsWith('zastava:'),
|
||||
},
|
||||
{
|
||||
id: 'scanner',
|
||||
label: 'Scanner & Inventory',
|
||||
description: 'Scanner execution, exports, and software inventory collection.',
|
||||
order: 60,
|
||||
matches: (scope) => scope.startsWith('scanner:') || scope.startsWith('concelier:'),
|
||||
},
|
||||
{
|
||||
id: 'other',
|
||||
label: 'Other Capabilities',
|
||||
description: 'Scopes that do not fit the main operator journeys above.',
|
||||
order: 70,
|
||||
matches: () => true,
|
||||
},
|
||||
];
|
||||
|
||||
function descriptorFor(scope: string): ScopeGroupDescriptor {
|
||||
return SCOPE_GROUPS.find((group) => group.matches(scope)) ?? SCOPE_GROUPS[SCOPE_GROUPS.length - 1];
|
||||
}
|
||||
|
||||
export function buildAdminScopeCatalog(): readonly AdminScopeCatalogGroup[] {
|
||||
const groups = new Map<string, AdminScopeCatalogGroup>();
|
||||
for (const [scope, label] of Object.entries(ScopeLabels)) {
|
||||
const descriptor = descriptorFor(scope);
|
||||
const existing = groups.get(descriptor.id);
|
||||
const item: AdminScopeCatalogItem = {
|
||||
scope,
|
||||
label,
|
||||
description: label,
|
||||
};
|
||||
|
||||
if (!existing) {
|
||||
groups.set(descriptor.id, {
|
||||
id: descriptor.id,
|
||||
label: descriptor.label,
|
||||
description: descriptor.description,
|
||||
items: [item],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
groups.set(descriptor.id, {
|
||||
...existing,
|
||||
items: [...existing.items, item].sort((left, right) => left.label.localeCompare(right.label)),
|
||||
});
|
||||
}
|
||||
|
||||
return [...groups.values()].sort((left, right) => {
|
||||
const leftDescriptor = SCOPE_GROUPS.find((group) => group.id === left.id);
|
||||
const rightDescriptor = SCOPE_GROUPS.find((group) => group.id === right.id);
|
||||
return (leftDescriptor?.order ?? 999) - (rightDescriptor?.order ?? 999);
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,382 +1,182 @@
|
||||
/**
|
||||
* @file certificate-inventory.component.spec.ts
|
||||
* @sprint SPRINT_20251229_018c_FE
|
||||
* @description Unit tests for CertificateInventoryComponent
|
||||
* Filter-bar adoption tests: SPRINT_20260308_015_FE (FE-OFB-004)
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { CertificateInventoryComponent } from './certificate-inventory.component';
|
||||
|
||||
import { TRUST_API, TrustApi } from '../../core/api/trust.client';
|
||||
import { Certificate, CertificateChain, CertificateExpiryAlert, PagedResult } from '../../core/api/trust.models';
|
||||
import { Certificate, CertificateChain, PagedResult, SigningKey, TrustedIssuer } from '../../core/api/trust.models';
|
||||
import { CertificateInventoryComponent } from './certificate-inventory.component';
|
||||
|
||||
describe('CertificateInventoryComponent', () => {
|
||||
let component: CertificateInventoryComponent;
|
||||
let fixture: ComponentFixture<CertificateInventoryComponent>;
|
||||
let mockTrustApi: jasmine.SpyObj<TrustApi>;
|
||||
|
||||
const mockCertificates: Certificate[] = [
|
||||
{
|
||||
certificateId: 'cert-001',
|
||||
tenantId: 'tenant-1',
|
||||
name: 'Root CA',
|
||||
certificateType: 'root_ca',
|
||||
status: 'valid',
|
||||
subject: { commonName: 'StellaOps Root CA', organization: 'StellaOps' },
|
||||
issuer: { commonName: 'StellaOps Root CA', organization: 'StellaOps' },
|
||||
validFrom: '2023-01-01T00:00:00Z',
|
||||
validUntil: '2033-01-01T00:00:00Z',
|
||||
serialNumber: 'ABC123',
|
||||
fingerprintSha256: 'sha256:abc123',
|
||||
keyUsage: ['digitalSignature', 'keyCertSign'],
|
||||
extendedKeyUsage: [],
|
||||
subjectAltNames: [],
|
||||
isCA: true,
|
||||
chainLength: 1,
|
||||
createdAt: '2023-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
certificateId: 'cert-002',
|
||||
tenantId: 'tenant-1',
|
||||
name: 'mTLS Client',
|
||||
certificateType: 'mtls_client',
|
||||
status: 'expiring_soon',
|
||||
subject: { commonName: 'scanner.stellaops.local' },
|
||||
issuer: { commonName: 'StellaOps Intermediate CA' },
|
||||
validFrom: '2024-01-01T00:00:00Z',
|
||||
validUntil: new Date(Date.now() + 20 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
serialNumber: 'DEF456',
|
||||
fingerprintSha256: 'sha256:def456',
|
||||
keyUsage: ['digitalSignature'],
|
||||
extendedKeyUsage: ['clientAuth'],
|
||||
subjectAltNames: ['DNS:scanner.stellaops.local'],
|
||||
isCA: false,
|
||||
chainLength: 3,
|
||||
parentCertificateId: 'cert-int',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
const certificate: Certificate = {
|
||||
certificateId: 'cert-001',
|
||||
tenantId: 'tenant-1',
|
||||
name: 'Certificate SER-001',
|
||||
certificateType: 'leaf',
|
||||
status: 'expiring_soon',
|
||||
subject: { commonName: 'prod-attestation-k1', organization: 'Signing key binding' },
|
||||
issuer: { commonName: 'Core Root CA', organization: 'Issuer binding' },
|
||||
serialNumber: 'SER-001',
|
||||
fingerprint: 'SER-001',
|
||||
fingerprintSha256: 'sha256:abc',
|
||||
validFrom: '2026-01-01T00:00:00Z',
|
||||
validUntil: '2026-01-05T00:00:00Z',
|
||||
keyUsage: [],
|
||||
extendedKeyUsage: [],
|
||||
subjectAltNames: [],
|
||||
isCA: false,
|
||||
chainLength: 1,
|
||||
childCertificateIds: [],
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
};
|
||||
|
||||
const mockExpiryAlerts: CertificateExpiryAlert[] = [
|
||||
{
|
||||
certificateId: 'cert-002',
|
||||
certificateName: 'mTLS Client',
|
||||
certificateType: 'mtls_client',
|
||||
expiresAt: new Date(Date.now() + 20 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
daysUntilExpiry: 20,
|
||||
severity: 'warning',
|
||||
affectedServices: ['Scanner'],
|
||||
},
|
||||
];
|
||||
const key: SigningKey = {
|
||||
keyId: 'key-001',
|
||||
tenantId: 'tenant-1',
|
||||
name: 'prod-attestation-k1',
|
||||
keyType: 'Ed25519',
|
||||
algorithm: 'ed25519',
|
||||
keySize: 256,
|
||||
purpose: 'attestation',
|
||||
status: 'active',
|
||||
publicKeyFingerprint: 'sha256:key',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
expiresAt: '2026-12-31T00:00:00Z',
|
||||
usageCount: 0,
|
||||
};
|
||||
|
||||
const mockChain: CertificateChain = {
|
||||
const issuer: TrustedIssuer = {
|
||||
issuerId: 'issuer-001',
|
||||
tenantId: 'tenant-1',
|
||||
name: 'core-root-ca',
|
||||
displayName: 'Core Root CA',
|
||||
issuerType: 'attestation_authority',
|
||||
trustLevel: 'partial',
|
||||
trustScore: 70,
|
||||
url: 'https://issuer.example/root',
|
||||
publicKeyFingerprints: [],
|
||||
validFrom: '2026-01-01T00:00:00Z',
|
||||
verificationCount: 0,
|
||||
documentCount: 0,
|
||||
weights: {
|
||||
baseWeight: 50,
|
||||
recencyFactor: 10,
|
||||
verificationBonus: 20,
|
||||
volumePenalty: 5,
|
||||
manualAdjustment: 0,
|
||||
},
|
||||
isActive: true,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
};
|
||||
|
||||
const chain: CertificateChain = {
|
||||
chainId: 'chain-cert-001',
|
||||
rootCertificateId: 'cert-001',
|
||||
certificates: mockCertificates,
|
||||
chainLength: 2,
|
||||
leafCertificateId: 'cert-001',
|
||||
certificates: [certificate],
|
||||
verificationStatus: 'valid',
|
||||
verificationMessage: 'Chain is valid',
|
||||
verifiedAt: new Date().toISOString(),
|
||||
verificationMessage: 'Chain valid',
|
||||
verifiedAt: '2026-01-03T00:00:00Z',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockTrustApi = jasmine.createSpyObj<TrustApi>('TrustApi', [
|
||||
'listCertificates',
|
||||
'getCertificateExpiryAlerts',
|
||||
'listKeys',
|
||||
'listIssuers',
|
||||
'registerCertificate',
|
||||
'getCertificateChain',
|
||||
'verifyCertificateChain',
|
||||
'revokeCertificate',
|
||||
]);
|
||||
|
||||
mockTrustApi.listCertificates.and.returnValue(of({
|
||||
items: mockCertificates,
|
||||
totalCount: mockCertificates.length,
|
||||
items: [certificate],
|
||||
totalCount: 1,
|
||||
pageNumber: 1,
|
||||
pageSize: 20,
|
||||
pageSize: 200,
|
||||
totalPages: 1,
|
||||
} as PagedResult<Certificate>));
|
||||
|
||||
mockTrustApi.getCertificateExpiryAlerts.and.returnValue(of(mockExpiryAlerts));
|
||||
mockTrustApi.getCertificateChain.and.returnValue(of(mockChain));
|
||||
mockTrustApi.verifyCertificateChain.and.returnValue(of(mockChain));
|
||||
mockTrustApi.listKeys.and.returnValue(of({
|
||||
items: [key],
|
||||
totalCount: 1,
|
||||
pageNumber: 1,
|
||||
pageSize: 200,
|
||||
totalPages: 1,
|
||||
} as PagedResult<SigningKey>));
|
||||
mockTrustApi.listIssuers.and.returnValue(of({
|
||||
items: [issuer],
|
||||
totalCount: 1,
|
||||
pageNumber: 1,
|
||||
pageSize: 200,
|
||||
totalPages: 1,
|
||||
} as PagedResult<TrustedIssuer>));
|
||||
mockTrustApi.registerCertificate.and.returnValue(of(certificate));
|
||||
mockTrustApi.getCertificateChain.and.returnValue(of(chain));
|
||||
mockTrustApi.verifyCertificateChain.and.returnValue(of(chain));
|
||||
mockTrustApi.revokeCertificate.and.returnValue(of({ ...certificate, status: 'revoked' }));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CertificateInventoryComponent],
|
||||
providers: [
|
||||
{ provide: TRUST_API, useValue: mockTrustApi },
|
||||
],
|
||||
providers: [{ provide: TRUST_API, useValue: mockTrustApi }],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(CertificateInventoryComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should load certificates on init', fakeAsync(() => {
|
||||
it('loads certificates and reference data on init', () => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.listCertificates).toHaveBeenCalled();
|
||||
expect(component.certificates().length).toBe(2);
|
||||
}));
|
||||
|
||||
it('should load expiry alerts on init', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.getCertificateExpiryAlerts).toHaveBeenCalledWith(30);
|
||||
expect(component.expiryAlerts().length).toBe(1);
|
||||
}));
|
||||
|
||||
it('should handle load certificates error', fakeAsync(() => {
|
||||
mockTrustApi.listCertificates.and.returnValue(throwError(() => new Error('Failed to load')));
|
||||
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(component.error()).toBe('Failed to load');
|
||||
}));
|
||||
|
||||
it('should filter by status', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.selectedStatus.set('valid');
|
||||
component.onFilterChange();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.listCertificates).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({ status: 'valid' })
|
||||
);
|
||||
}));
|
||||
|
||||
it('should filter by type', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.selectedType.set('root_ca');
|
||||
component.onFilterChange();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.listCertificates).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({ certificateType: 'root_ca' })
|
||||
);
|
||||
}));
|
||||
|
||||
it('should search certificates', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.searchQuery.set('Root');
|
||||
component.onSearch();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.listCertificates).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({ search: 'Root' })
|
||||
);
|
||||
}));
|
||||
|
||||
it('should clear filters', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.searchQuery.set('test');
|
||||
component.selectedStatus.set('valid');
|
||||
component.selectedType.set('root_ca');
|
||||
component.clearFilters();
|
||||
tick();
|
||||
|
||||
expect(component.searchQuery()).toBe('');
|
||||
expect(component.selectedStatus()).toBe('all');
|
||||
expect(component.selectedType()).toBe('all');
|
||||
}));
|
||||
|
||||
it('should select certificate', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.selectCert(mockCertificates[0]);
|
||||
expect(component.selectedCert()).toEqual(mockCertificates[0]);
|
||||
}));
|
||||
|
||||
it('should view certificate chain', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.viewChain(mockCertificates[1]);
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.getCertificateChain).toHaveBeenCalledWith('cert-002');
|
||||
expect(component.chainView()).toEqual(mockChain);
|
||||
}));
|
||||
|
||||
it('should verify certificate chain', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.verifyChain(mockCertificates[1]);
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.verifyCertificateChain).toHaveBeenCalledWith('cert-002');
|
||||
expect(component.chainView()).toEqual(mockChain);
|
||||
}));
|
||||
|
||||
it('should format type correctly', () => {
|
||||
expect(component.formatType('root_ca')).toBe('Root CA');
|
||||
expect(component.formatType('intermediate_ca')).toBe('Intermediate CA');
|
||||
expect(component.formatType('leaf')).toBe('Leaf');
|
||||
expect(component.formatType('mtls_client')).toBe('mTLS Client');
|
||||
expect(component.formatType('mtls_server')).toBe('mTLS Server');
|
||||
expect(mockTrustApi.listKeys).toHaveBeenCalled();
|
||||
expect(mockTrustApi.listIssuers).toHaveBeenCalled();
|
||||
expect(component.certificates()).toEqual([certificate]);
|
||||
});
|
||||
|
||||
it('should format status correctly', () => {
|
||||
expect(component.formatStatus('valid')).toBe('Valid');
|
||||
expect(component.formatStatus('expiring_soon')).toBe('Expiring');
|
||||
expect(component.formatStatus('expired')).toBe('Expired');
|
||||
expect(component.formatStatus('revoked')).toBe('Revoked');
|
||||
it('registers a certificate with selected key and issuer bindings', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
component.createOpen.set(true);
|
||||
component.createSerialNumber.set('SER-002');
|
||||
component.createKeyId.set('key-001');
|
||||
component.createIssuerId.set('issuer-001');
|
||||
component.submitCreate();
|
||||
|
||||
expect(mockTrustApi.registerCertificate).toHaveBeenCalled();
|
||||
expect(component.banner()).toContain('Registered certificate');
|
||||
});
|
||||
|
||||
it('should format chain status correctly', () => {
|
||||
expect(component.formatChainStatus('valid')).toBe('Chain Valid');
|
||||
expect(component.formatChainStatus('incomplete')).toBe('Incomplete');
|
||||
expect(component.formatChainStatus('invalid')).toBe('Invalid');
|
||||
it('loads and verifies certificate chains', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
component.viewChain(certificate);
|
||||
expect(mockTrustApi.getCertificateChain).toHaveBeenCalledWith('cert-001');
|
||||
expect(component.chainView()).toEqual(chain);
|
||||
|
||||
component.verifyChain(certificate);
|
||||
expect(mockTrustApi.verifyCertificateChain).toHaveBeenCalledWith('cert-001');
|
||||
expect(component.banner()).toContain('Verified certificate chain');
|
||||
});
|
||||
|
||||
it('should detect expiring soon certificates', () => {
|
||||
expect(component.isExpiringSoon(mockCertificates[1])).toBeTrue();
|
||||
expect(component.isExpiringSoon(mockCertificates[0])).toBeFalse();
|
||||
it('requires a reason before revoking a certificate', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
component.openRevoke(certificate);
|
||||
component.submitRevoke();
|
||||
|
||||
expect(component.revokeError()).toBe('Reason is required.');
|
||||
expect(mockTrustApi.revokeCertificate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should calculate days until expiry', () => {
|
||||
const days = component.getDaysUntilExpiry(mockCertificates[1]);
|
||||
expect(days).toBeCloseTo(20, 0);
|
||||
it('surfaces load errors', () => {
|
||||
mockTrustApi.listCertificates.and.returnValue(throwError(() => new Error('certificates unavailable')));
|
||||
component.loadCertificates();
|
||||
|
||||
expect(component.error()).toBe('certificates unavailable');
|
||||
});
|
||||
|
||||
it('should handle pagination', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.onPageChange(2);
|
||||
tick();
|
||||
|
||||
expect(component.pageNumber()).toBe(2);
|
||||
expect(mockTrustApi.listCertificates).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({ pageNumber: 2 })
|
||||
);
|
||||
}));
|
||||
|
||||
// --- Filter-bar adoption tests (SPRINT_20260308_015_FE FE-OFB-004) ---
|
||||
|
||||
it('should render the shared filter bar element', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
const filterBar = fixture.nativeElement.querySelector('app-filter-bar');
|
||||
expect(filterBar).toBeTruthy();
|
||||
}));
|
||||
|
||||
it('should expose two filter option groups (status, type)', () => {
|
||||
expect(component.certFilterOptions.length).toBe(2);
|
||||
expect(component.certFilterOptions.map(f => f.key)).toEqual(['status', 'type']);
|
||||
});
|
||||
|
||||
it('should have correct status filter options', () => {
|
||||
const statusFilter = component.certFilterOptions.find(f => f.key === 'status');
|
||||
expect(statusFilter).toBeTruthy();
|
||||
expect(statusFilter!.options.length).toBe(4);
|
||||
expect(statusFilter!.options.map(o => o.value)).toEqual(['valid', 'expiring_soon', 'expired', 'revoked']);
|
||||
});
|
||||
|
||||
it('should have correct type filter options', () => {
|
||||
const typeFilter = component.certFilterOptions.find(f => f.key === 'type');
|
||||
expect(typeFilter).toBeTruthy();
|
||||
expect(typeFilter!.options.length).toBe(5);
|
||||
expect(typeFilter!.options.map(o => o.value)).toEqual(['root_ca', 'intermediate_ca', 'leaf', 'mtls_client', 'mtls_server']);
|
||||
});
|
||||
|
||||
it('should update selectedStatus via onCertFilterChanged', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
component.onCertFilterChanged({ key: 'status', value: 'expired', label: 'Expired' });
|
||||
tick();
|
||||
expect(component.selectedStatus()).toBe('expired');
|
||||
}));
|
||||
|
||||
it('should update selectedType via onCertFilterChanged', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
component.onCertFilterChanged({ key: 'type', value: 'root_ca', label: 'Root CA' });
|
||||
tick();
|
||||
expect(component.selectedType()).toBe('root_ca');
|
||||
}));
|
||||
|
||||
it('should reset selectedStatus on onCertFilterRemoved', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
component.onCertFilterChanged({ key: 'status', value: 'valid', label: 'Valid' });
|
||||
tick();
|
||||
component.onCertFilterRemoved({ key: 'status', value: 'valid', label: 'Status: Valid' });
|
||||
tick();
|
||||
expect(component.selectedStatus()).toBe('all');
|
||||
}));
|
||||
|
||||
it('should reset selectedType on onCertFilterRemoved', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
component.onCertFilterChanged({ key: 'type', value: 'leaf', label: 'Leaf' });
|
||||
tick();
|
||||
component.onCertFilterRemoved({ key: 'type', value: 'leaf', label: 'Type: Leaf' });
|
||||
tick();
|
||||
expect(component.selectedType()).toBe('all');
|
||||
}));
|
||||
|
||||
it('should update searchQuery via onCertSearch', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
component.onCertSearch('*.example.com');
|
||||
tick();
|
||||
expect(component.searchQuery()).toBe('*.example.com');
|
||||
}));
|
||||
|
||||
it('should rebuild active cert filters when a status filter is applied', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
component.onCertFilterChanged({ key: 'status', value: 'expiring_soon', label: 'Expiring Soon' });
|
||||
tick();
|
||||
const active = component.activeCertFilters();
|
||||
expect(active.length).toBe(1);
|
||||
expect(active[0].key).toBe('status');
|
||||
expect(active[0].value).toBe('expiring_soon');
|
||||
}));
|
||||
|
||||
it('should rebuild active cert filters with both status and type', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
component.onCertFilterChanged({ key: 'status', value: 'valid', label: 'Valid' });
|
||||
tick();
|
||||
component.onCertFilterChanged({ key: 'type', value: 'mtls_client', label: 'mTLS Client' });
|
||||
tick();
|
||||
const active = component.activeCertFilters();
|
||||
expect(active.length).toBe(2);
|
||||
expect(active.find(f => f.key === 'status')).toBeTruthy();
|
||||
expect(active.find(f => f.key === 'type')).toBeTruthy();
|
||||
}));
|
||||
|
||||
it('should clear active cert filters on clearFilters', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
component.onCertFilterChanged({ key: 'status', value: 'expired', label: 'Expired' });
|
||||
tick();
|
||||
component.clearFilters();
|
||||
tick();
|
||||
expect(component.activeCertFilters().length).toBe(0);
|
||||
}));
|
||||
|
||||
it('should have hasFilters return true when a filter-bar filter is applied', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
component.onCertFilterChanged({ key: 'status', value: 'valid', label: 'Valid' });
|
||||
tick();
|
||||
expect(component.hasFilters()).toBe(true);
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
/**
|
||||
* @file certificate-inventory.component.ts
|
||||
* @sprint SPRINT_20251229_018c_FE
|
||||
* @description Live certificate inventory aligned to the administration trust API
|
||||
* @sprint SPRINT_20260315_006_FE
|
||||
* @description Operator-facing certificate inventory with inspection and revocation workflows.
|
||||
*/
|
||||
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
import { TRUST_API, TrustApi } from '../../core/api/trust.client';
|
||||
import { Certificate, CertificateStatus } from '../../core/api/trust.models';
|
||||
import { TRUST_API } from '../../core/api/trust.client';
|
||||
import { Certificate, CertificateChain, CertificateStatus, RegisterCertificateRequest, SigningKey, TrustedIssuer } from '../../core/api/trust.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-certificate-inventory',
|
||||
@@ -21,14 +21,23 @@ import { Certificate, CertificateStatus } from '../../core/api/trust.models';
|
||||
<div>
|
||||
<h2>Certificates</h2>
|
||||
<p>
|
||||
This inventory reflects the live administration records: serial number, validity window, issuer binding, key binding, and lifecycle state.
|
||||
Inspect certificate ownership, verify chain state, and revoke certificates when trust must be withdrawn.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="btn-secondary" (click)="loadCertificates()" [disabled]="loading()">
|
||||
{{ loading() ? 'Refreshing...' : 'Refresh' }}
|
||||
</button>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-secondary" (click)="toggleCreate()" [attr.aria-pressed]="createOpen()">
|
||||
{{ createOpen() ? 'Close Form' : 'Add Certificate' }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="reload()" [disabled]="loading()">
|
||||
{{ loading() ? 'Refreshing...' : 'Refresh' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (banner()) {
|
||||
<div class="banner" [class.banner--error]="bannerTone() === 'error'">{{ banner() }}</div>
|
||||
}
|
||||
|
||||
<div class="certificate-inventory__filters">
|
||||
<label class="filter-field">
|
||||
<span>Search</span>
|
||||
@@ -36,7 +45,7 @@ import { Certificate, CertificateStatus } from '../../core/api/trust.models';
|
||||
type="text"
|
||||
[ngModel]="searchQuery()"
|
||||
(ngModelChange)="searchQuery.set($event); applyFilters()"
|
||||
placeholder="Search serial number or certificate id"
|
||||
placeholder="Search serial number, certificate id, issuer, or key"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -56,87 +65,274 @@ import { Certificate, CertificateStatus } from '../../core/api/trust.models';
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="contract-note">
|
||||
Chain verification, subject parsing, and PEM inspection are not provided by the current administration endpoint, so this page shows the owned inventory record instead.
|
||||
</div>
|
||||
@if (createOpen()) {
|
||||
<section class="workspace-card">
|
||||
<header class="workspace-card__header">
|
||||
<div>
|
||||
<h3>Register Certificate</h3>
|
||||
<p>Bind the certificate to a signing key and issuer so expiry and revocation are operationally traceable.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="form-grid">
|
||||
<label class="filter-field">
|
||||
<span>Serial Number</span>
|
||||
<input
|
||||
type="text"
|
||||
[ngModel]="createSerialNumber()"
|
||||
(ngModelChange)="createSerialNumber.set($event)"
|
||||
placeholder="SER-2026-0001"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="filter-field">
|
||||
<span>Signing Key</span>
|
||||
<select [ngModel]="createKeyId()" (ngModelChange)="createKeyId.set($event)">
|
||||
<option value="">Self-managed</option>
|
||||
@for (key of availableKeys(); track key.keyId) {
|
||||
<option [value]="key.keyId">{{ key.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="filter-field">
|
||||
<span>Issuer</span>
|
||||
<select [ngModel]="createIssuerId()" (ngModelChange)="createIssuerId.set($event)">
|
||||
<option value="">Self-managed</option>
|
||||
@for (issuer of availableIssuers(); track issuer.issuerId) {
|
||||
<option [value]="issuer.issuerId">{{ issuer.displayName }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="filter-field">
|
||||
<span>Valid From</span>
|
||||
<input type="datetime-local" [ngModel]="createNotBefore()" (ngModelChange)="createNotBefore.set($event)" />
|
||||
</label>
|
||||
|
||||
<label class="filter-field">
|
||||
<span>Valid Until</span>
|
||||
<input type="datetime-local" [ngModel]="createNotAfter()" (ngModelChange)="createNotAfter.set($event)" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (createError()) {
|
||||
<div class="inline-error">{{ createError() }}</div>
|
||||
}
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-primary" (click)="submitCreate()" [disabled]="creating()">
|
||||
{{ creating() ? 'Registering...' : 'Register Certificate' }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="resetCreate()">Reset</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (loading()) {
|
||||
<div class="state state--loading">Loading certificates...</div>
|
||||
} @else if (error()) {
|
||||
<div class="state state--error">{{ error() }}</div>
|
||||
} @else if (certificates().length === 0) {
|
||||
<div class="state">No certificates found.</div>
|
||||
<div class="state">No certificates found. Register certificates to track expiry and revocation risk.</div>
|
||||
} @else {
|
||||
<table class="certificate-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Serial Number</th>
|
||||
<th>Status</th>
|
||||
<th>Valid From</th>
|
||||
<th>Valid Until</th>
|
||||
<th>Issuer Reference</th>
|
||||
<th>Key Reference</th>
|
||||
<th>Issuer</th>
|
||||
<th>Key Binding</th>
|
||||
<th>Updated</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (certificate of certificates(); track certificate.certificateId) {
|
||||
<tr>
|
||||
<tr [class.is-selected]="selectedCertificate()?.certificateId === certificate.certificateId">
|
||||
<td>
|
||||
<div class="serial-cell">
|
||||
<strong>{{ certificate.serialNumber }}</strong>
|
||||
<button type="button" class="link-button" (click)="selectedCertificate.set(certificate)">
|
||||
{{ certificate.serialNumber }}
|
||||
</button>
|
||||
<span>{{ certificate.certificateId }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><span class="badge" [class]="'badge--' + certificate.status">{{ formatStatus(certificate.status) }}</span></td>
|
||||
<td>{{ certificate.validFrom | date:'medium' }}</td>
|
||||
<td>{{ certificate.validUntil | date:'medium' }}</td>
|
||||
<td>{{ certificate.issuer.commonName }}</td>
|
||||
<td>{{ certificate.subject.commonName }}</td>
|
||||
<td>{{ certificate.updatedAt | date:'medium' }}</td>
|
||||
<td class="actions-cell">
|
||||
<button type="button" class="btn-sm" (click)="selectedCertificate.set(certificate)">Inspect</button>
|
||||
<button type="button" class="btn-sm" (click)="viewChain(certificate)">View Chain</button>
|
||||
<button type="button" class="btn-sm" (click)="verifyChain(certificate)">Verify</button>
|
||||
<button type="button" class="btn-sm btn-sm--danger" (click)="openRevoke(certificate)" [disabled]="certificate.status === 'revoked'">
|
||||
Revoke
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (selectedCertificate()) {
|
||||
<section class="workspace-card">
|
||||
<header class="workspace-card__header">
|
||||
<div>
|
||||
<h3>{{ selectedCertificate()!.serialNumber }}</h3>
|
||||
<p>{{ selectedCertificate()!.certificateId }}</p>
|
||||
</div>
|
||||
<button type="button" class="btn-secondary" (click)="selectedCertificate.set(null)">Close</button>
|
||||
</header>
|
||||
|
||||
<dl class="detail-grid">
|
||||
<div>
|
||||
<dt>Status</dt>
|
||||
<dd>{{ formatStatus(selectedCertificate()!.status) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Issuer</dt>
|
||||
<dd>{{ selectedCertificate()!.issuer.commonName }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Key Binding</dt>
|
||||
<dd>{{ selectedCertificate()!.subject.commonName }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Expires</dt>
|
||||
<dd>{{ selectedCertificate()!.validUntil | date:'medium' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Operator Guidance</dt>
|
||||
<dd>
|
||||
Expiring certificates should be renewed or replaced before promotion gates depend on them.
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (chainView()) {
|
||||
<section class="workspace-card">
|
||||
<header class="workspace-card__header">
|
||||
<div>
|
||||
<h3>Chain Verification</h3>
|
||||
<p>{{ chainView()!.verificationMessage || 'Live certificate chain projection.' }}</p>
|
||||
</div>
|
||||
<button type="button" class="btn-secondary" (click)="chainView.set(null)">Close</button>
|
||||
</header>
|
||||
|
||||
<p class="impact-copy">Status: {{ chainView()!.verificationStatus }}</p>
|
||||
<ul class="chain-list">
|
||||
@for (certificate of chainView()!.certificates; track certificate.certificateId) {
|
||||
<li>{{ certificate.serialNumber }} · {{ certificate.issuer.commonName }}</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (revokeTarget()) {
|
||||
<section class="workspace-card workspace-card--danger">
|
||||
<header class="workspace-card__header">
|
||||
<div>
|
||||
<h3>Revoke Certificate</h3>
|
||||
<p>{{ revokeTarget()!.serialNumber }} · {{ revokeTarget()!.certificateId }}</p>
|
||||
</div>
|
||||
<button type="button" class="btn-secondary" (click)="closeRevoke()">Cancel</button>
|
||||
</header>
|
||||
|
||||
<p class="impact-copy">
|
||||
Revocation should be used when the certificate can no longer be trusted for future client, server, or evidence workflows.
|
||||
</p>
|
||||
|
||||
<label class="filter-field">
|
||||
<span>Reason</span>
|
||||
<textarea
|
||||
rows="3"
|
||||
[ngModel]="revokeReason()"
|
||||
(ngModelChange)="revokeReason.set($event)"
|
||||
placeholder="Capture the incident, replacement, or lifecycle reason"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
@if (revokeError()) {
|
||||
<div class="inline-error">{{ revokeError() }}</div>
|
||||
}
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-primary" (click)="submitRevoke()" [disabled]="revoking()">
|
||||
{{ revoking() ? 'Revoking...' : 'Confirm Revocation' }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="closeRevoke()">Cancel</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.certificate-inventory { padding: 1.5rem; display: grid; gap: 1rem; }
|
||||
.certificate-inventory__header { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; }
|
||||
.certificate-inventory__header h2 { margin: 0 0 0.35rem; }
|
||||
.certificate-inventory__header p { margin: 0; color: var(--color-text-secondary); max-width: 52rem; }
|
||||
.certificate-inventory__header, .workspace-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.certificate-inventory__header h2, .workspace-card__header h3 { margin: 0 0 0.35rem; }
|
||||
.certificate-inventory__header p, .workspace-card__header p, .impact-copy {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 54rem;
|
||||
}
|
||||
.header-actions, .form-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.certificate-inventory__filters { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; }
|
||||
.filter-field { display: grid; gap: 0.25rem; min-width: 15rem; }
|
||||
.filter-field span { font-size: 0.78rem; color: var(--color-text-secondary); text-transform: uppercase; }
|
||||
.filter-field input, .filter-field select {
|
||||
.filter-field input, .filter-field select, .filter-field textarea {
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-primary);
|
||||
font: inherit;
|
||||
}
|
||||
.contract-note {
|
||||
.workspace-card {
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.workspace-card--danger {
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
background: rgba(239, 68, 68, 0.05);
|
||||
}
|
||||
.form-grid, .detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
.detail-grid dt { font-size: 0.78rem; color: var(--color-text-secondary); text-transform: uppercase; }
|
||||
.detail-grid dd { margin: 0.2rem 0 0; color: var(--color-text-primary); word-break: break-word; }
|
||||
.banner, .state, .inline-error {
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.state {
|
||||
padding: 2rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
.state--error {
|
||||
.banner { background: rgba(74, 222, 128, 0.12); color: var(--color-status-success-border); }
|
||||
.banner--error, .inline-error, .state--error {
|
||||
color: var(--color-status-error);
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
.state {
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
.certificate-table { width: 100%; border-collapse: collapse; }
|
||||
.certificate-table th, .certificate-table td {
|
||||
padding: 0.75rem 0.9rem;
|
||||
@@ -150,18 +346,35 @@ import { Certificate, CertificateStatus } from '../../core/api/trust.models';
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
.certificate-table tr.is-selected { background: rgba(34, 211, 238, 0.08); }
|
||||
.serial-cell { display: grid; gap: 0.2rem; }
|
||||
.serial-cell span { color: var(--color-text-secondary); font-family: monospace; font-size: 0.8rem; }
|
||||
.btn-secondary, .btn-link {
|
||||
.chain-list { margin: 0; padding-left: 1rem; color: var(--color-text-primary); }
|
||||
.actions-cell { white-space: nowrap; }
|
||||
.link-button, .btn-sm, .btn-primary, .btn-secondary, .btn-link {
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
}
|
||||
.btn-secondary {
|
||||
padding: 0.4rem 0.7rem;
|
||||
.link-button {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-status-info);
|
||||
text-align: left;
|
||||
}
|
||||
.btn-sm, .btn-secondary, .btn-primary {
|
||||
padding: 0.45rem 0.8rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--color-status-info);
|
||||
border-color: var(--color-status-info);
|
||||
color: #04131a;
|
||||
}
|
||||
.btn-sm--danger { color: var(--color-status-error); }
|
||||
.btn-link {
|
||||
border: none;
|
||||
background: transparent;
|
||||
@@ -184,15 +397,40 @@ export class CertificateInventoryComponent {
|
||||
private readonly trustApi = inject(TRUST_API);
|
||||
|
||||
readonly certificates = signal<Certificate[]>([]);
|
||||
readonly availableKeys = signal<SigningKey[]>([]);
|
||||
readonly availableIssuers = signal<TrustedIssuer[]>([]);
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly banner = signal<string | null>(null);
|
||||
readonly bannerTone = signal<'success' | 'error'>('success');
|
||||
readonly selectedCertificate = signal<Certificate | null>(null);
|
||||
readonly chainView = signal<CertificateChain | null>(null);
|
||||
readonly searchQuery = signal('');
|
||||
readonly selectedStatus = signal<CertificateStatus | 'all'>('all');
|
||||
|
||||
readonly createOpen = signal(false);
|
||||
readonly createSerialNumber = signal('');
|
||||
readonly createKeyId = signal('');
|
||||
readonly createIssuerId = signal('');
|
||||
readonly createNotBefore = signal('2026-03-15T00:00');
|
||||
readonly createNotAfter = signal('2027-03-15T00:00');
|
||||
readonly createError = signal<string | null>(null);
|
||||
readonly creating = signal(false);
|
||||
|
||||
readonly revokeTarget = signal<Certificate | null>(null);
|
||||
readonly revokeReason = signal('');
|
||||
readonly revokeError = signal<string | null>(null);
|
||||
readonly revoking = signal(false);
|
||||
|
||||
readonly hasFilters = computed(() => this.searchQuery().trim().length > 0 || this.selectedStatus() !== 'all');
|
||||
|
||||
constructor() {
|
||||
this.reload();
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
this.loadCertificates();
|
||||
this.loadReferenceData();
|
||||
}
|
||||
|
||||
loadCertificates(): void {
|
||||
@@ -209,6 +447,9 @@ export class CertificateInventoryComponent {
|
||||
}).subscribe({
|
||||
next: (result) => {
|
||||
this.certificates.set([...result.items]);
|
||||
if (this.selectedCertificate()) {
|
||||
this.selectedCertificate.set(result.items.find((item) => item.certificateId === this.selectedCertificate()!.certificateId) ?? null);
|
||||
}
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
@@ -218,6 +459,15 @@ export class CertificateInventoryComponent {
|
||||
});
|
||||
}
|
||||
|
||||
loadReferenceData(): void {
|
||||
this.trustApi.listKeys({ pageNumber: 1, pageSize: 200, sortBy: 'name', sortDirection: 'asc' }).subscribe({
|
||||
next: (result) => this.availableKeys.set([...result.items]),
|
||||
});
|
||||
this.trustApi.listIssuers({ pageNumber: 1, pageSize: 200, sortBy: 'name', sortDirection: 'asc' }).subscribe({
|
||||
next: (result) => this.availableIssuers.set([...result.items]),
|
||||
});
|
||||
}
|
||||
|
||||
applyFilters(): void {
|
||||
this.loadCertificates();
|
||||
}
|
||||
@@ -228,6 +478,119 @@ export class CertificateInventoryComponent {
|
||||
this.loadCertificates();
|
||||
}
|
||||
|
||||
toggleCreate(): void {
|
||||
this.createOpen.update((value) => !value);
|
||||
this.createError.set(null);
|
||||
}
|
||||
|
||||
resetCreate(): void {
|
||||
this.createSerialNumber.set('');
|
||||
this.createKeyId.set('');
|
||||
this.createIssuerId.set('');
|
||||
this.createNotBefore.set('2026-03-15T00:00');
|
||||
this.createNotAfter.set('2027-03-15T00:00');
|
||||
this.createError.set(null);
|
||||
}
|
||||
|
||||
submitCreate(): void {
|
||||
const serialNumber = this.createSerialNumber().trim();
|
||||
if (!serialNumber) {
|
||||
this.createError.set('Serial number is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
const request: RegisterCertificateRequest = {
|
||||
serialNumber,
|
||||
keyId: this.createKeyId().trim() || undefined,
|
||||
issuerId: this.createIssuerId().trim() || undefined,
|
||||
notBefore: new Date(this.createNotBefore()).toISOString(),
|
||||
notAfter: new Date(this.createNotAfter()).toISOString(),
|
||||
};
|
||||
|
||||
this.creating.set(true);
|
||||
this.createError.set(null);
|
||||
this.trustApi.registerCertificate(request).subscribe({
|
||||
next: (created) => {
|
||||
this.creating.set(false);
|
||||
this.createOpen.set(false);
|
||||
this.resetCreate();
|
||||
this.bannerTone.set('success');
|
||||
this.banner.set(`Registered certificate ${created.serialNumber}.`);
|
||||
this.selectedCertificate.set(created);
|
||||
this.reload();
|
||||
},
|
||||
error: (err) => {
|
||||
this.creating.set(false);
|
||||
this.createError.set(err?.error?.error || err?.message || 'Failed to register certificate.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
viewChain(certificate: Certificate): void {
|
||||
this.trustApi.getCertificateChain(certificate.certificateId).subscribe({
|
||||
next: (chain) => this.chainView.set(chain),
|
||||
error: (err) => {
|
||||
this.bannerTone.set('error');
|
||||
this.banner.set(err?.error?.error || err?.message || 'Failed to load certificate chain.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
verifyChain(certificate: Certificate): void {
|
||||
this.trustApi.verifyCertificateChain(certificate.certificateId).subscribe({
|
||||
next: (chain) => {
|
||||
this.chainView.set(chain);
|
||||
this.bannerTone.set('success');
|
||||
this.banner.set(`Verified certificate chain for ${certificate.serialNumber}.`);
|
||||
},
|
||||
error: (err) => {
|
||||
this.bannerTone.set('error');
|
||||
this.banner.set(err?.error?.error || err?.message || 'Failed to verify certificate chain.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
openRevoke(certificate: Certificate): void {
|
||||
this.revokeTarget.set(certificate);
|
||||
this.revokeReason.set('');
|
||||
this.revokeError.set(null);
|
||||
}
|
||||
|
||||
closeRevoke(): void {
|
||||
this.revokeTarget.set(null);
|
||||
this.revokeReason.set('');
|
||||
this.revokeError.set(null);
|
||||
}
|
||||
|
||||
submitRevoke(): void {
|
||||
const target = this.revokeTarget();
|
||||
const reason = this.revokeReason().trim();
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
if (!reason) {
|
||||
this.revokeError.set('Reason is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.revoking.set(true);
|
||||
this.revokeError.set(null);
|
||||
this.trustApi.revokeCertificate(target.certificateId, { reason }).subscribe({
|
||||
next: (updated) => {
|
||||
this.revoking.set(false);
|
||||
this.bannerTone.set('success');
|
||||
this.banner.set(`Revoked certificate ${updated.serialNumber}.`);
|
||||
this.selectedCertificate.set(updated);
|
||||
this.closeRevoke();
|
||||
this.reload();
|
||||
},
|
||||
error: (err) => {
|
||||
this.revoking.set(false);
|
||||
this.revokeError.set(err?.error?.error || err?.message || 'Failed to revoke certificate.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
formatStatus(status: CertificateStatus): string {
|
||||
switch (status) {
|
||||
case 'expiring_soon':
|
||||
|
||||
@@ -1,257 +1,126 @@
|
||||
/**
|
||||
* @file issuer-trust-list.component.spec.ts
|
||||
* @sprint SPRINT_20251229_018c_FE
|
||||
* @description Unit tests for IssuerTrustListComponent
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { IssuerTrustListComponent } from './issuer-trust-list.component';
|
||||
|
||||
import { TRUST_API, TrustApi } from '../../core/api/trust.client';
|
||||
import { TrustedIssuer, PagedResult } from '../../core/api/trust.models';
|
||||
import { PagedResult, TrustedIssuer } from '../../core/api/trust.models';
|
||||
import { IssuerTrustListComponent } from './issuer-trust-list.component';
|
||||
|
||||
describe('IssuerTrustListComponent', () => {
|
||||
let component: IssuerTrustListComponent;
|
||||
let fixture: ComponentFixture<IssuerTrustListComponent>;
|
||||
let mockTrustApi: jasmine.SpyObj<TrustApi>;
|
||||
|
||||
const mockIssuers: TrustedIssuer[] = [
|
||||
{
|
||||
issuerId: 'issuer-001',
|
||||
tenantId: 'tenant-1',
|
||||
name: 'vendor-a',
|
||||
displayName: 'Vendor A',
|
||||
description: 'Primary vendor',
|
||||
issuerType: 'csaf_publisher',
|
||||
trustLevel: 'full',
|
||||
trustScore: 95,
|
||||
documentCount: 150,
|
||||
lastVerifiedAt: '2024-01-01T00:00:00Z',
|
||||
createdAt: '2023-01-01T00:00:00Z',
|
||||
weights: {
|
||||
baseWeight: 50,
|
||||
recencyFactor: 10,
|
||||
verificationBonus: 20,
|
||||
volumePenalty: 5,
|
||||
manualAdjustment: 0,
|
||||
},
|
||||
const issuer: TrustedIssuer = {
|
||||
issuerId: 'issuer-001',
|
||||
tenantId: 'tenant-1',
|
||||
name: 'core-root-ca',
|
||||
displayName: 'Core Root CA',
|
||||
issuerType: 'attestation_authority',
|
||||
trustLevel: 'partial',
|
||||
trustScore: 72,
|
||||
url: 'https://issuer.example/root',
|
||||
publicKeyFingerprints: [],
|
||||
validFrom: '2026-01-01T00:00:00Z',
|
||||
verificationCount: 0,
|
||||
documentCount: 0,
|
||||
weights: {
|
||||
baseWeight: 50,
|
||||
recencyFactor: 10,
|
||||
verificationBonus: 20,
|
||||
volumePenalty: 5,
|
||||
manualAdjustment: 0,
|
||||
},
|
||||
{
|
||||
issuerId: 'issuer-002',
|
||||
tenantId: 'tenant-1',
|
||||
name: 'vendor-b',
|
||||
displayName: 'Vendor B',
|
||||
issuerType: 'vex_issuer',
|
||||
trustLevel: 'partial',
|
||||
trustScore: 70,
|
||||
documentCount: 50,
|
||||
createdAt: '2023-06-01T00:00:00Z',
|
||||
weights: {
|
||||
baseWeight: 40,
|
||||
recencyFactor: 10,
|
||||
verificationBonus: 15,
|
||||
volumePenalty: 5,
|
||||
manualAdjustment: 0,
|
||||
},
|
||||
metadata: {
|
||||
status: 'active',
|
||||
updatedBy: 'operator',
|
||||
},
|
||||
{
|
||||
issuerId: 'issuer-003',
|
||||
tenantId: 'tenant-1',
|
||||
name: 'blocked-vendor',
|
||||
displayName: 'Blocked Vendor',
|
||||
issuerType: 'sbom_producer',
|
||||
trustLevel: 'blocked',
|
||||
trustScore: 0,
|
||||
documentCount: 10,
|
||||
createdAt: '2023-03-01T00:00:00Z',
|
||||
weights: {
|
||||
baseWeight: 0,
|
||||
recencyFactor: 0,
|
||||
verificationBonus: 0,
|
||||
volumePenalty: 0,
|
||||
manualAdjustment: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
isActive: true,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockTrustApi = jasmine.createSpyObj<TrustApi>('TrustApi', [
|
||||
'listIssuers',
|
||||
'registerIssuer',
|
||||
'blockIssuer',
|
||||
'unblockIssuer',
|
||||
]);
|
||||
|
||||
mockTrustApi.listIssuers.and.returnValue(of({
|
||||
items: mockIssuers,
|
||||
totalCount: mockIssuers.length,
|
||||
items: [issuer],
|
||||
totalCount: 1,
|
||||
pageNumber: 1,
|
||||
pageSize: 20,
|
||||
pageSize: 200,
|
||||
totalPages: 1,
|
||||
} as PagedResult<TrustedIssuer>));
|
||||
|
||||
mockTrustApi.blockIssuer.and.returnValue(of(void 0));
|
||||
mockTrustApi.unblockIssuer.and.returnValue(of(void 0));
|
||||
mockTrustApi.registerIssuer.and.returnValue(of(issuer));
|
||||
mockTrustApi.blockIssuer.and.returnValue(of({ ...issuer, trustLevel: 'blocked', trustScore: 0, isActive: false, metadata: { status: 'blocked', updatedBy: 'operator' } }));
|
||||
mockTrustApi.unblockIssuer.and.returnValue(of({ ...issuer, trustLevel: 'full', trustScore: 95 }));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [IssuerTrustListComponent],
|
||||
providers: [
|
||||
{ provide: TRUST_API, useValue: mockTrustApi },
|
||||
],
|
||||
providers: [{ provide: TRUST_API, useValue: mockTrustApi }],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(IssuerTrustListComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should load issuers on init', fakeAsync(() => {
|
||||
it('loads issuers on init', () => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.listIssuers).toHaveBeenCalled();
|
||||
expect(component.issuers().length).toBe(3);
|
||||
}));
|
||||
|
||||
it('should handle load issuers error', fakeAsync(() => {
|
||||
mockTrustApi.listIssuers.and.returnValue(throwError(() => new Error('Failed to load')));
|
||||
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(component.error()).toBe('Failed to load');
|
||||
}));
|
||||
|
||||
it('should filter by trust level', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.selectedTrustLevel.set('full');
|
||||
component.onFilterChange();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.listIssuers).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({ trustLevel: 'full' })
|
||||
);
|
||||
}));
|
||||
|
||||
it('should filter by issuer type', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.selectedType.set('csaf_publisher');
|
||||
component.onFilterChange();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.listIssuers).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({ issuerType: 'csaf_publisher' })
|
||||
);
|
||||
}));
|
||||
|
||||
it('should search issuers', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.searchQuery.set('Vendor');
|
||||
component.onSearch();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.listIssuers).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({ search: 'Vendor' })
|
||||
);
|
||||
}));
|
||||
|
||||
it('should clear filters', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.searchQuery.set('test');
|
||||
component.selectedTrustLevel.set('full');
|
||||
component.selectedType.set('csaf_publisher');
|
||||
component.clearFilters();
|
||||
tick();
|
||||
|
||||
expect(component.searchQuery()).toBe('');
|
||||
expect(component.selectedTrustLevel()).toBe('all');
|
||||
expect(component.selectedType()).toBe('all');
|
||||
}));
|
||||
|
||||
it('should compute hasFilters correctly', () => {
|
||||
expect(component.hasFilters()).toBeFalse();
|
||||
|
||||
component.searchQuery.set('test');
|
||||
expect(component.hasFilters()).toBeTrue();
|
||||
expect(component.issuers()).toEqual([issuer]);
|
||||
});
|
||||
|
||||
it('should compute average score', fakeAsync(() => {
|
||||
it('registers an issuer through the operator form', () => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
const avg = component.averageScore();
|
||||
expect(avg).toBeCloseTo((95 + 70 + 0) / 3, 1);
|
||||
}));
|
||||
component.createOpen.set(true);
|
||||
component.createName.set('Core Root CA');
|
||||
component.createUri.set('https://issuer.example/root');
|
||||
component.createTrustLevel.set('partial');
|
||||
component.submitCreate();
|
||||
|
||||
it('should count by trust level', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(component.countByLevel('full')).toBe(1);
|
||||
expect(component.countByLevel('partial')).toBe(1);
|
||||
expect(component.countByLevel('blocked')).toBe(1);
|
||||
}));
|
||||
|
||||
it('should select issuer', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.selectIssuer(mockIssuers[0]);
|
||||
expect(component.selectedIssuer()).toEqual(mockIssuers[0]);
|
||||
}));
|
||||
|
||||
it('should toggle config panel', () => {
|
||||
expect(component.showConfig()).toBeFalse();
|
||||
component.showConfig.set(true);
|
||||
expect(component.showConfig()).toBeTrue();
|
||||
expect(mockTrustApi.registerIssuer).toHaveBeenCalledWith({
|
||||
name: 'Core Root CA',
|
||||
issuerUri: 'https://issuer.example/root',
|
||||
trustLevel: 'partial',
|
||||
});
|
||||
expect(component.banner()).toContain('Registered issuer');
|
||||
});
|
||||
|
||||
it('should format type correctly', () => {
|
||||
expect(component.formatType('csaf_publisher')).toBe('CSAF Publisher');
|
||||
expect(component.formatType('vex_issuer')).toBe('VEX Issuer');
|
||||
expect(component.formatType('sbom_producer')).toBe('SBOM Producer');
|
||||
expect(component.formatType('attestation_authority')).toBe('Attestation Authority');
|
||||
it('requires a reason before blocking an issuer', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
component.openMutation(issuer, 'block');
|
||||
component.submitMutation();
|
||||
|
||||
expect(component.mutationError()).toBe('Reason is required.');
|
||||
expect(mockTrustApi.blockIssuer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should format trust level correctly', () => {
|
||||
expect(component.formatTrustLevel('full')).toBe('Full Trust');
|
||||
expect(component.formatTrustLevel('partial')).toBe('Partial');
|
||||
expect(component.formatTrustLevel('minimal')).toBe('Minimal');
|
||||
expect(component.formatTrustLevel('untrusted')).toBe('Untrusted');
|
||||
expect(component.formatTrustLevel('blocked')).toBe('Blocked');
|
||||
it('blocks and restores issuers through explicit lifecycle actions', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
component.openMutation(issuer, 'block');
|
||||
component.mutationReason.set('publisher compromised');
|
||||
component.submitMutation();
|
||||
|
||||
expect(mockTrustApi.blockIssuer).toHaveBeenCalledWith('issuer-001', { reason: 'publisher compromised' });
|
||||
|
||||
component.openMutation({ ...issuer, trustLevel: 'blocked', isActive: false }, 'unblock');
|
||||
component.restoreTrustLevel.set('full');
|
||||
component.submitMutation();
|
||||
|
||||
expect(mockTrustApi.unblockIssuer).toHaveBeenCalledWith('issuer-001', { trustLevel: 'full' });
|
||||
});
|
||||
|
||||
it('should handle pagination', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
it('surfaces load errors', () => {
|
||||
mockTrustApi.listIssuers.and.returnValue(throwError(() => new Error('issuers unavailable')));
|
||||
component.loadIssuers();
|
||||
|
||||
component.onPageChange(2);
|
||||
tick();
|
||||
|
||||
expect(component.pageNumber()).toBe(2);
|
||||
expect(mockTrustApi.listIssuers).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({ pageNumber: 2 })
|
||||
);
|
||||
}));
|
||||
|
||||
it('should sort by column', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.onSort('trustScore');
|
||||
expect(component.sortBy()).toBe('trustScore');
|
||||
expect(component.sortDirection()).toBe('desc');
|
||||
|
||||
component.onSort('trustScore');
|
||||
expect(component.sortDirection()).toBe('asc');
|
||||
}));
|
||||
expect(component.error()).toBe('issuers unavailable');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
/**
|
||||
* @file issuer-trust-list.component.ts
|
||||
* @sprint SPRINT_20251229_018c_FE
|
||||
* @description Live trusted issuer inventory aligned to the administration trust API
|
||||
* @sprint SPRINT_20260315_006_FE
|
||||
* @description Operator-facing trusted issuer inventory with registration and lifecycle controls.
|
||||
*/
|
||||
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
import { TRUST_API, TrustApi } from '../../core/api/trust.client';
|
||||
import { TrustedIssuer, IssuerTrustLevel } from '../../core/api/trust.models';
|
||||
import { TRUST_API } from '../../core/api/trust.client';
|
||||
import { IssuerTrustLevel, RegisterIssuerRequest, TrustedIssuer } from '../../core/api/trust.models';
|
||||
|
||||
type IssuerMutationKind = 'block' | 'unblock';
|
||||
|
||||
@Component({
|
||||
selector: 'app-issuer-trust-list',
|
||||
@@ -21,14 +23,23 @@ import { TrustedIssuer, IssuerTrustLevel } from '../../core/api/trust.models';
|
||||
<div>
|
||||
<h2>Trusted Issuers</h2>
|
||||
<p>
|
||||
This view is bound to the live administration contract: issuer name, issuer URI, trust level, status, and update ownership.
|
||||
Promote, quarantine, and review publisher trust without leaving the trust workspace.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="btn-secondary" (click)="loadIssuers()" [disabled]="loading()">
|
||||
{{ loading() ? 'Refreshing...' : 'Refresh' }}
|
||||
</button>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-secondary" (click)="toggleCreate()" [attr.aria-pressed]="createOpen()">
|
||||
{{ createOpen() ? 'Close Form' : 'Add Issuer' }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="loadIssuers()" [disabled]="loading()">
|
||||
{{ loading() ? 'Refreshing...' : 'Refresh' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (banner()) {
|
||||
<div class="banner" [class.banner--error]="bannerTone() === 'error'">{{ banner() }}</div>
|
||||
}
|
||||
|
||||
<div class="issuer-list__filters">
|
||||
<label class="filter-field">
|
||||
<span>Search</span>
|
||||
@@ -57,16 +68,66 @@ import { TrustedIssuer, IssuerTrustLevel } from '../../core/api/trust.models';
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="contract-note">
|
||||
Score tuning, issuer blocking, and document-volume analytics are not exposed by the current backend contract, so they are intentionally omitted here.
|
||||
</div>
|
||||
@if (createOpen()) {
|
||||
<section class="workspace-card">
|
||||
<header class="workspace-card__header">
|
||||
<div>
|
||||
<h3>Register Issuer</h3>
|
||||
<p>Add a publisher or attestation authority with an explicit initial trust level.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="form-grid">
|
||||
<label class="filter-field">
|
||||
<span>Issuer Name</span>
|
||||
<input
|
||||
type="text"
|
||||
[ngModel]="createName()"
|
||||
(ngModelChange)="createName.set($event)"
|
||||
placeholder="Core Root CA"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="filter-field">
|
||||
<span>Trust Level</span>
|
||||
<select [ngModel]="createTrustLevel()" (ngModelChange)="createTrustLevel.set($event)">
|
||||
<option value="full">Full</option>
|
||||
<option value="partial">Partial</option>
|
||||
<option value="minimal">Minimal</option>
|
||||
<option value="untrusted">Untrusted</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="filter-field">
|
||||
<span>Issuer URI</span>
|
||||
<input
|
||||
type="url"
|
||||
[ngModel]="createUri()"
|
||||
(ngModelChange)="createUri.set($event)"
|
||||
placeholder="https://issuer.example/root"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@if (createError()) {
|
||||
<div class="inline-error">{{ createError() }}</div>
|
||||
}
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-primary" (click)="submitCreate()" [disabled]="creating()">
|
||||
{{ creating() ? 'Registering...' : 'Register Issuer' }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="resetCreate()">Reset</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (loading()) {
|
||||
<div class="state state--loading">Loading issuers...</div>
|
||||
} @else if (error()) {
|
||||
<div class="state state--error">{{ error() }}</div>
|
||||
} @else if (issuers().length === 0) {
|
||||
<div class="state">No issuers found.</div>
|
||||
<div class="state">No issuers found. Add a trusted publisher before relying on external advisory or attestation content.</div>
|
||||
} @else {
|
||||
<table class="issuer-table">
|
||||
<thead>
|
||||
@@ -75,64 +136,186 @@ import { TrustedIssuer, IssuerTrustLevel } from '../../core/api/trust.models';
|
||||
<th>Issuer URI</th>
|
||||
<th>Trust Level</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Updated</th>
|
||||
<th>Updated By</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (issuer of issuers(); track issuer.issuerId) {
|
||||
<tr>
|
||||
<td>{{ issuer.displayName }}</td>
|
||||
<tr [class.is-selected]="selectedIssuer()?.issuerId === issuer.issuerId">
|
||||
<td>
|
||||
<button type="button" class="link-button" (click)="selectedIssuer.set(issuer)">
|
||||
{{ issuer.displayName }}
|
||||
</button>
|
||||
</td>
|
||||
<td><a [href]="issuer.url" target="_blank" rel="noopener">{{ issuer.url }}</a></td>
|
||||
<td><span class="badge" [class]="'badge--' + issuer.trustLevel">{{ formatTrustLevel(issuer.trustLevel) }}</span></td>
|
||||
<td>{{ issuer.metadata?.['status'] || (issuer.isActive ? 'active' : 'inactive') }}</td>
|
||||
<td>{{ issuer.createdAt | date:'medium' }}</td>
|
||||
<td>{{ issuer.updatedAt | date:'medium' }}</td>
|
||||
<td>{{ issuer.metadata?.['updatedBy'] || 'system' }}</td>
|
||||
<td class="actions-cell">
|
||||
<button type="button" class="btn-sm" (click)="selectedIssuer.set(issuer)">View</button>
|
||||
@if (issuer.trustLevel === 'blocked') {
|
||||
<button type="button" class="btn-sm" (click)="openMutation(issuer, 'unblock')">Unblock</button>
|
||||
} @else {
|
||||
<button type="button" class="btn-sm btn-sm--danger" (click)="openMutation(issuer, 'block')">Block</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (selectedIssuer()) {
|
||||
<section class="workspace-card">
|
||||
<header class="workspace-card__header">
|
||||
<div>
|
||||
<h3>{{ selectedIssuer()!.displayName }}</h3>
|
||||
<p>{{ selectedIssuer()!.issuerId }}</p>
|
||||
</div>
|
||||
<button type="button" class="btn-secondary" (click)="selectedIssuer.set(null)">Close</button>
|
||||
</header>
|
||||
|
||||
<dl class="detail-grid">
|
||||
<div>
|
||||
<dt>Current Trust</dt>
|
||||
<dd>{{ formatTrustLevel(selectedIssuer()!.trustLevel) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Publisher URI</dt>
|
||||
<dd>{{ selectedIssuer()!.url }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Status</dt>
|
||||
<dd>{{ selectedIssuer()!.metadata?.['status'] || (selectedIssuer()!.isActive ? 'active' : 'inactive') }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Operator Guidance</dt>
|
||||
<dd>
|
||||
Block an issuer when published material must stop influencing release policy immediately.
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (mutationIssuer() && mutationKind()) {
|
||||
<section class="workspace-card workspace-card--danger">
|
||||
<header class="workspace-card__header">
|
||||
<div>
|
||||
<h3>{{ mutationKind() === 'block' ? 'Block Issuer' : 'Restore Issuer' }}</h3>
|
||||
<p>{{ mutationIssuer()!.displayName }} · {{ mutationIssuer()!.issuerId }}</p>
|
||||
</div>
|
||||
<button type="button" class="btn-secondary" (click)="closeMutation()">Cancel</button>
|
||||
</header>
|
||||
|
||||
<p class="impact-copy">
|
||||
@if (mutationKind() === 'block') {
|
||||
Blocking stops this issuer from contributing trusted evidence until it is explicitly restored.
|
||||
} @else {
|
||||
Restoring returns the issuer to active use with the selected trust level.
|
||||
}
|
||||
</p>
|
||||
|
||||
@if (mutationKind() === 'unblock') {
|
||||
<label class="filter-field">
|
||||
<span>Restored Trust Level</span>
|
||||
<select [ngModel]="restoreTrustLevel()" (ngModelChange)="restoreTrustLevel.set($event)">
|
||||
<option value="minimal">Minimal</option>
|
||||
<option value="partial">Partial</option>
|
||||
<option value="full">Full</option>
|
||||
<option value="untrusted">Untrusted</option>
|
||||
</select>
|
||||
</label>
|
||||
}
|
||||
|
||||
<label class="filter-field">
|
||||
<span>{{ mutationKind() === 'block' ? 'Reason' : 'Operator Note' }}</span>
|
||||
<textarea
|
||||
rows="3"
|
||||
[ngModel]="mutationReason()"
|
||||
(ngModelChange)="mutationReason.set($event)"
|
||||
placeholder="Capture the investigation, approval, or publisher status"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
@if (mutationError()) {
|
||||
<div class="inline-error">{{ mutationError() }}</div>
|
||||
}
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-primary" (click)="submitMutation()" [disabled]="mutating()">
|
||||
{{ mutating() ? 'Applying...' : (mutationKind() === 'block' ? 'Confirm Block' : 'Restore Issuer') }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="closeMutation()">Cancel</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.issuer-list { padding: 1.5rem; display: grid; gap: 1rem; }
|
||||
.issuer-list__header { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; }
|
||||
.issuer-list__header h2 { margin: 0 0 0.35rem; }
|
||||
.issuer-list__header p { margin: 0; color: var(--color-text-secondary); max-width: 52rem; }
|
||||
.issuer-list__header, .workspace-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.issuer-list__header h2, .workspace-card__header h3 { margin: 0 0 0.35rem; }
|
||||
.issuer-list__header p, .workspace-card__header p, .impact-copy {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 54rem;
|
||||
}
|
||||
.header-actions, .form-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.issuer-list__filters { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; }
|
||||
.filter-field { display: grid; gap: 0.25rem; min-width: 15rem; }
|
||||
.filter-field span { font-size: 0.78rem; color: var(--color-text-secondary); text-transform: uppercase; }
|
||||
.filter-field input, .filter-field select {
|
||||
.filter-field input, .filter-field select, .filter-field textarea {
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-primary);
|
||||
font: inherit;
|
||||
}
|
||||
.contract-note {
|
||||
.workspace-card {
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.workspace-card--danger {
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
background: rgba(239, 68, 68, 0.05);
|
||||
}
|
||||
.form-grid, .detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
.detail-grid dt { font-size: 0.78rem; color: var(--color-text-secondary); text-transform: uppercase; }
|
||||
.detail-grid dd { margin: 0.2rem 0 0; color: var(--color-text-primary); word-break: break-word; }
|
||||
.banner, .state, .inline-error {
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.state {
|
||||
padding: 2rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
.state--error {
|
||||
.banner { background: rgba(74, 222, 128, 0.12); color: var(--color-status-success-border); }
|
||||
.banner--error, .inline-error, .state--error {
|
||||
color: var(--color-status-error);
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
.state {
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
.issuer-table { width: 100%; border-collapse: collapse; }
|
||||
.issuer-table th, .issuer-table td {
|
||||
padding: 0.75rem 0.9rem;
|
||||
@@ -146,17 +329,33 @@ import { TrustedIssuer, IssuerTrustLevel } from '../../core/api/trust.models';
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
.issuer-table tr.is-selected { background: rgba(34, 211, 238, 0.08); }
|
||||
.issuer-table a { color: var(--color-status-info); word-break: break-word; }
|
||||
.btn-secondary, .btn-link {
|
||||
.actions-cell { white-space: nowrap; }
|
||||
.link-button, .btn-sm, .btn-primary, .btn-secondary, .btn-link {
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
}
|
||||
.btn-secondary {
|
||||
padding: 0.4rem 0.7rem;
|
||||
.link-button {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-status-info);
|
||||
text-align: left;
|
||||
}
|
||||
.btn-sm, .btn-secondary, .btn-primary {
|
||||
padding: 0.45rem 0.8rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--color-status-info);
|
||||
border-color: var(--color-status-info);
|
||||
color: #04131a;
|
||||
}
|
||||
.btn-sm--danger { color: var(--color-status-error); }
|
||||
.btn-link {
|
||||
border: none;
|
||||
background: transparent;
|
||||
@@ -182,9 +381,26 @@ export class IssuerTrustListComponent {
|
||||
readonly issuers = signal<TrustedIssuer[]>([]);
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly banner = signal<string | null>(null);
|
||||
readonly bannerTone = signal<'success' | 'error'>('success');
|
||||
readonly selectedIssuer = signal<TrustedIssuer | null>(null);
|
||||
readonly searchQuery = signal('');
|
||||
readonly selectedTrustLevel = signal<IssuerTrustLevel | 'all'>('all');
|
||||
|
||||
readonly createOpen = signal(false);
|
||||
readonly createName = signal('');
|
||||
readonly createUri = signal('');
|
||||
readonly createTrustLevel = signal<Exclude<IssuerTrustLevel, 'blocked'>>('partial');
|
||||
readonly createError = signal<string | null>(null);
|
||||
readonly creating = signal(false);
|
||||
|
||||
readonly mutationIssuer = signal<TrustedIssuer | null>(null);
|
||||
readonly mutationKind = signal<IssuerMutationKind | null>(null);
|
||||
readonly mutationReason = signal('');
|
||||
readonly restoreTrustLevel = signal<Exclude<IssuerTrustLevel, 'blocked'>>('minimal');
|
||||
readonly mutationError = signal<string | null>(null);
|
||||
readonly mutating = signal(false);
|
||||
|
||||
readonly hasFilters = computed(() => this.searchQuery().trim().length > 0 || this.selectedTrustLevel() !== 'all');
|
||||
|
||||
constructor() {
|
||||
@@ -205,6 +421,9 @@ export class IssuerTrustListComponent {
|
||||
}).subscribe({
|
||||
next: (result) => {
|
||||
this.issuers.set([...result.items]);
|
||||
if (this.selectedIssuer()) {
|
||||
this.selectedIssuer.set(result.items.find((item) => item.issuerId === this.selectedIssuer()!.issuerId) ?? null);
|
||||
}
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
@@ -224,7 +443,117 @@ export class IssuerTrustListComponent {
|
||||
this.loadIssuers();
|
||||
}
|
||||
|
||||
toggleCreate(): void {
|
||||
this.createOpen.update((value) => !value);
|
||||
this.createError.set(null);
|
||||
}
|
||||
|
||||
resetCreate(): void {
|
||||
this.createName.set('');
|
||||
this.createUri.set('');
|
||||
this.createTrustLevel.set('partial');
|
||||
this.createError.set(null);
|
||||
}
|
||||
|
||||
submitCreate(): void {
|
||||
const name = this.createName().trim();
|
||||
const issuerUri = this.createUri().trim();
|
||||
|
||||
if (!name) {
|
||||
this.createError.set('Issuer name is required.');
|
||||
return;
|
||||
}
|
||||
if (!issuerUri) {
|
||||
this.createError.set('Issuer URI is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
const request: RegisterIssuerRequest = {
|
||||
name,
|
||||
issuerUri,
|
||||
trustLevel: this.createTrustLevel(),
|
||||
};
|
||||
|
||||
this.creating.set(true);
|
||||
this.createError.set(null);
|
||||
this.trustApi.registerIssuer(request).subscribe({
|
||||
next: (created) => {
|
||||
this.creating.set(false);
|
||||
this.createOpen.set(false);
|
||||
this.resetCreate();
|
||||
this.bannerTone.set('success');
|
||||
this.banner.set(`Registered issuer ${created.displayName}.`);
|
||||
this.selectedIssuer.set(created);
|
||||
this.loadIssuers();
|
||||
},
|
||||
error: (err) => {
|
||||
this.creating.set(false);
|
||||
this.createError.set(err?.error?.error || err?.message || 'Failed to register issuer.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
openMutation(issuer: TrustedIssuer, kind: IssuerMutationKind): void {
|
||||
this.mutationIssuer.set(issuer);
|
||||
this.mutationKind.set(kind);
|
||||
this.mutationReason.set('');
|
||||
this.restoreTrustLevel.set('minimal');
|
||||
this.mutationError.set(null);
|
||||
}
|
||||
|
||||
closeMutation(): void {
|
||||
this.mutationIssuer.set(null);
|
||||
this.mutationKind.set(null);
|
||||
this.mutationReason.set('');
|
||||
this.mutationError.set(null);
|
||||
}
|
||||
|
||||
submitMutation(): void {
|
||||
const issuer = this.mutationIssuer();
|
||||
const kind = this.mutationKind();
|
||||
if (!issuer || !kind) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reason = this.mutationReason().trim();
|
||||
if (kind === 'block' && !reason) {
|
||||
this.mutationError.set('Reason is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.mutating.set(true);
|
||||
this.mutationError.set(null);
|
||||
|
||||
const request$ = kind === 'block'
|
||||
? this.trustApi.blockIssuer(issuer.issuerId, { reason })
|
||||
: this.trustApi.unblockIssuer(issuer.issuerId, { trustLevel: this.restoreTrustLevel() });
|
||||
|
||||
request$.subscribe({
|
||||
next: (updated) => {
|
||||
this.mutating.set(false);
|
||||
this.bannerTone.set('success');
|
||||
this.banner.set(kind === 'block'
|
||||
? `Blocked issuer ${updated.displayName}.`
|
||||
: `Restored issuer ${updated.displayName} at ${this.formatTrustLevel(updated.trustLevel)}.`);
|
||||
this.closeMutation();
|
||||
this.selectedIssuer.set(updated);
|
||||
this.loadIssuers();
|
||||
},
|
||||
error: (err) => {
|
||||
this.mutating.set(false);
|
||||
this.mutationError.set(err?.error?.error || err?.message || `Failed to ${kind} issuer.`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
formatTrustLevel(level: IssuerTrustLevel): string {
|
||||
return level.charAt(0).toUpperCase() + level.slice(1);
|
||||
switch (level) {
|
||||
case 'full':
|
||||
return 'Full Trust';
|
||||
case 'partial':
|
||||
return 'Partial Trust';
|
||||
default:
|
||||
return level.charAt(0).toUpperCase() + level.slice(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,270 +1,119 @@
|
||||
/**
|
||||
* @file signing-key-dashboard.component.spec.ts
|
||||
* @sprint SPRINT_20251229_018c_FE
|
||||
* @description Unit tests for SigningKeyDashboardComponent
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { SigningKeyDashboardComponent } from './signing-key-dashboard.component';
|
||||
|
||||
import { TRUST_API, TrustApi } from '../../core/api/trust.client';
|
||||
import { SigningKey, SigningKeyStatus, KeyExpiryAlert, PagedResult } from '../../core/api/trust.models';
|
||||
import { PagedResult, SigningKey } from '../../core/api/trust.models';
|
||||
import { SigningKeyDashboardComponent } from './signing-key-dashboard.component';
|
||||
|
||||
describe('SigningKeyDashboardComponent', () => {
|
||||
let component: SigningKeyDashboardComponent;
|
||||
let fixture: ComponentFixture<SigningKeyDashboardComponent>;
|
||||
let mockTrustApi: jasmine.SpyObj<TrustApi>;
|
||||
|
||||
const mockKeys: SigningKey[] = [
|
||||
{
|
||||
keyId: 'key-001',
|
||||
tenantId: 'tenant-1',
|
||||
name: 'Production Key',
|
||||
description: 'Main signing key',
|
||||
keyType: 'asymmetric',
|
||||
algorithm: 'RS256',
|
||||
keySize: 2048,
|
||||
purpose: 'attestation',
|
||||
status: 'active',
|
||||
publicKeyFingerprint: 'sha256:abc123',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
expiresAt: '2025-01-01T00:00:00Z',
|
||||
usageCount: 100,
|
||||
const key: SigningKey = {
|
||||
keyId: 'key-001',
|
||||
tenantId: 'tenant-1',
|
||||
name: 'prod-attestation-k1',
|
||||
keyType: 'Ed25519',
|
||||
algorithm: 'ed25519',
|
||||
keySize: 256,
|
||||
purpose: 'attestation',
|
||||
status: 'active',
|
||||
publicKeyFingerprint: 'sha256:abc',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
expiresAt: '2026-12-31T00:00:00Z',
|
||||
usageCount: 0,
|
||||
metadata: {
|
||||
currentVersion: '1',
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
updatedBy: 'operator',
|
||||
},
|
||||
{
|
||||
keyId: 'key-002',
|
||||
tenantId: 'tenant-1',
|
||||
name: 'Backup Key',
|
||||
description: 'Backup signing key',
|
||||
keyType: 'asymmetric',
|
||||
algorithm: 'RS256',
|
||||
keySize: 2048,
|
||||
purpose: 'sbom_signing',
|
||||
status: 'expiring_soon',
|
||||
publicKeyFingerprint: 'sha256:def456',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
expiresAt: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
usageCount: 50,
|
||||
},
|
||||
];
|
||||
|
||||
const mockExpiryAlerts: KeyExpiryAlert[] = [
|
||||
{
|
||||
keyId: 'key-002',
|
||||
keyName: 'Backup Key',
|
||||
purpose: 'sbom_signing',
|
||||
expiresAt: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
daysUntilExpiry: 15,
|
||||
severity: 'warning',
|
||||
suggestedAction: 'Rotate key soon',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockTrustApi = jasmine.createSpyObj<TrustApi>('TrustApi', [
|
||||
'listKeys',
|
||||
'getKeyExpiryAlerts',
|
||||
'revokeKey',
|
||||
'createKey',
|
||||
'rotateKey',
|
||||
'revokeKey',
|
||||
]);
|
||||
|
||||
mockTrustApi.listKeys.and.returnValue(of({
|
||||
items: mockKeys,
|
||||
totalCount: mockKeys.length,
|
||||
items: [key],
|
||||
totalCount: 1,
|
||||
pageNumber: 1,
|
||||
pageSize: 20,
|
||||
pageSize: 200,
|
||||
totalPages: 1,
|
||||
} as PagedResult<SigningKey>));
|
||||
|
||||
mockTrustApi.getKeyExpiryAlerts.and.returnValue(of(mockExpiryAlerts));
|
||||
mockTrustApi.createKey.and.returnValue(of(key));
|
||||
mockTrustApi.rotateKey.and.returnValue(of({ ...key, metadata: { ...key.metadata, currentVersion: '2' } }));
|
||||
mockTrustApi.revokeKey.and.returnValue(of(void 0));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SigningKeyDashboardComponent],
|
||||
providers: [
|
||||
{ provide: TRUST_API, useValue: mockTrustApi },
|
||||
],
|
||||
providers: [{ provide: TRUST_API, useValue: mockTrustApi }],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SigningKeyDashboardComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should load keys on init', fakeAsync(() => {
|
||||
it('loads keys on init', () => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.listKeys).toHaveBeenCalled();
|
||||
expect(component.keys().length).toBe(2);
|
||||
}));
|
||||
|
||||
it('should load expiry alerts on init', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.getKeyExpiryAlerts).toHaveBeenCalledWith(30);
|
||||
expect(component.expiryAlerts().length).toBe(1);
|
||||
}));
|
||||
|
||||
it('should handle load keys error', fakeAsync(() => {
|
||||
mockTrustApi.listKeys.and.returnValue(throwError(() => new Error('Failed to load')));
|
||||
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(component.error()).toBe('Failed to load');
|
||||
}));
|
||||
|
||||
it('should filter by status', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.selectedStatus.set('active');
|
||||
component.onFilterChange();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.listKeys).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({ status: 'active' })
|
||||
);
|
||||
}));
|
||||
|
||||
it('should filter by purpose', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.selectedPurpose.set('attestation');
|
||||
component.onFilterChange();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.listKeys).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({ purpose: 'attestation' })
|
||||
);
|
||||
}));
|
||||
|
||||
it('should search keys', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.searchQuery.set('Production');
|
||||
component.onSearch();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.listKeys).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({ search: 'Production' })
|
||||
);
|
||||
}));
|
||||
|
||||
it('should clear filters', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.searchQuery.set('test');
|
||||
component.selectedStatus.set('active');
|
||||
component.selectedPurpose.set('attestation');
|
||||
component.clearFilters();
|
||||
tick();
|
||||
|
||||
expect(component.searchQuery()).toBe('');
|
||||
expect(component.selectedStatus()).toBe('all');
|
||||
expect(component.selectedPurpose()).toBe('all');
|
||||
}));
|
||||
|
||||
it('should compute hasFilters correctly', () => {
|
||||
expect(component.hasFilters()).toBeFalse();
|
||||
|
||||
component.searchQuery.set('test');
|
||||
expect(component.hasFilters()).toBeTrue();
|
||||
|
||||
component.searchQuery.set('');
|
||||
component.selectedStatus.set('active');
|
||||
expect(component.hasFilters()).toBeTrue();
|
||||
expect(component.keys()).toEqual([key]);
|
||||
});
|
||||
|
||||
it('should toggle sort direction', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
it('validates create key input before calling the API', () => {
|
||||
component.submitCreate();
|
||||
|
||||
component.onSort('name');
|
||||
expect(component.sortBy()).toBe('name');
|
||||
expect(component.sortDirection()).toBe('asc');
|
||||
|
||||
component.onSort('name');
|
||||
expect(component.sortDirection()).toBe('desc');
|
||||
}));
|
||||
|
||||
it('should select key', () => {
|
||||
component.selectKey(mockKeys[0]);
|
||||
expect(component.selectedKey()).toEqual(mockKeys[0]);
|
||||
expect(component.createError()).toBe('Alias is required.');
|
||||
expect(mockTrustApi.createKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should open rotation wizard', fakeAsync(() => {
|
||||
it('creates a key and reloads the inventory', () => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.openRotationWizard('key-001');
|
||||
expect(component.rotatingKey()?.keyId).toBe('key-001');
|
||||
expect(component.selectedKey()).toBeNull();
|
||||
}));
|
||||
component.createOpen.set(true);
|
||||
component.createAlias.set('prod-attestation-k2');
|
||||
component.createAlgorithm.set('ecdsa-p256');
|
||||
component.submitCreate();
|
||||
|
||||
it('should close rotation wizard', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.openRotationWizard('key-001');
|
||||
component.closeRotationWizard();
|
||||
expect(component.rotatingKey()).toBeNull();
|
||||
}));
|
||||
|
||||
it('should handle pagination', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.onPageChange(2);
|
||||
tick();
|
||||
|
||||
expect(component.pageNumber()).toBe(2);
|
||||
expect(mockTrustApi.listKeys).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({ pageNumber: 2 })
|
||||
);
|
||||
}));
|
||||
|
||||
it('should format status correctly', () => {
|
||||
expect(component.formatStatus('active')).toBe('Active');
|
||||
expect(component.formatStatus('expiring_soon')).toBe('Expiring Soon');
|
||||
expect(component.formatStatus('expired')).toBe('Expired');
|
||||
expect(component.formatStatus('revoked')).toBe('Revoked');
|
||||
expect(component.formatStatus('pending_rotation')).toBe('Pending Rotation');
|
||||
expect(mockTrustApi.createKey).toHaveBeenCalledWith({
|
||||
alias: 'prod-attestation-k2',
|
||||
algorithm: 'ecdsa-p256',
|
||||
metadataJson: undefined,
|
||||
});
|
||||
expect(component.banner()).toContain('Registered signing key');
|
||||
expect(mockTrustApi.listKeys).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should format purpose correctly', () => {
|
||||
expect(component.formatPurpose('attestation')).toBe('Attestation');
|
||||
expect(component.formatPurpose('sbom_signing')).toBe('SBOM');
|
||||
expect(component.formatPurpose('vex_signing')).toBe('VEX');
|
||||
expect(component.formatPurpose('code_signing')).toBe('Code');
|
||||
expect(component.formatPurpose('tls')).toBe('TLS');
|
||||
it('requires a reason before rotating or revoking', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
component.openMutation(key, 'rotate');
|
||||
component.submitMutation();
|
||||
|
||||
expect(component.mutationError()).toBe('Reason is required.');
|
||||
expect(mockTrustApi.rotateKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should calculate days until expiry', () => {
|
||||
const futureKey: SigningKey = {
|
||||
...mockKeys[0],
|
||||
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
};
|
||||
expect(component.getDaysUntilExpiry(futureKey)).toBeCloseTo(30, 0);
|
||||
it('rotates a key through the in-app confirmation workflow', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
component.openMutation(key, 'rotate');
|
||||
component.mutationReason.set('scheduled rotation');
|
||||
component.submitMutation();
|
||||
|
||||
expect(mockTrustApi.rotateKey).toHaveBeenCalledWith('key-001', { reason: 'scheduled rotation' });
|
||||
expect(component.banner()).toContain('Rotated signing key');
|
||||
});
|
||||
|
||||
it('should detect expiring soon keys', () => {
|
||||
const expiringKey: SigningKey = {
|
||||
...mockKeys[0],
|
||||
expiresAt: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
};
|
||||
expect(component.isExpiringSoon(expiringKey)).toBeTrue();
|
||||
it('surfaces load errors', () => {
|
||||
mockTrustApi.listKeys.and.returnValue(throwError(() => new Error('inventory unavailable')));
|
||||
component.loadKeys();
|
||||
|
||||
const validKey: SigningKey = {
|
||||
...mockKeys[0],
|
||||
expiresAt: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
};
|
||||
expect(component.isExpiringSoon(validKey)).toBeFalse();
|
||||
expect(component.error()).toBe('inventory unavailable');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
/**
|
||||
* @file signing-key-dashboard.component.ts
|
||||
* @sprint SPRINT_20251229_018c_FE
|
||||
* @description Live signing key inventory aligned to the administration trust API
|
||||
* @sprint SPRINT_20260315_006_FE
|
||||
* @description Operator-facing signing key inventory with create, rotate, and revoke workflows.
|
||||
*/
|
||||
|
||||
import { Component, ChangeDetectionStrategy, computed, inject, signal } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
import { TRUST_API, TrustApi } from '../../core/api/trust.client';
|
||||
import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
|
||||
import { TRUST_API } from '../../core/api/trust.client';
|
||||
import { CreateSigningKeyRequest, SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
|
||||
|
||||
type KeyMutationKind = 'rotate' | 'revoke';
|
||||
|
||||
@Component({
|
||||
selector: 'app-signing-key-dashboard',
|
||||
@@ -21,14 +23,23 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
|
||||
<div>
|
||||
<h2>Signing Keys</h2>
|
||||
<p>
|
||||
Live administration exposes alias, algorithm, version, lifecycle state, and update ownership.
|
||||
Rotate and retire signing material with explicit operator confirmation before release evidence is affected.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="btn-secondary" (click)="loadKeys()" [disabled]="loading()">
|
||||
{{ loading() ? 'Refreshing...' : 'Refresh' }}
|
||||
</button>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-secondary" (click)="toggleCreate()" [attr.aria-pressed]="createOpen()">
|
||||
{{ createOpen() ? 'Close Form' : 'Add Key' }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="loadKeys()" [disabled]="loading()">
|
||||
{{ loading() ? 'Refreshing...' : 'Refresh' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (banner()) {
|
||||
<div class="banner" [class.banner--error]="bannerTone() === 'error'">{{ banner() }}</div>
|
||||
}
|
||||
|
||||
<div class="key-dashboard__filters">
|
||||
<label class="filter-field">
|
||||
<span>Search</span>
|
||||
@@ -57,17 +68,65 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="contract-note">
|
||||
Usage statistics, fingerprint material, and expiry policy are not part of the current administration contract,
|
||||
so this page only shows fields the backend actually owns.
|
||||
</div>
|
||||
@if (createOpen()) {
|
||||
<section class="workspace-card">
|
||||
<header class="workspace-card__header">
|
||||
<div>
|
||||
<h3>Register Signing Key</h3>
|
||||
<p>Create a key record before binding it into certificate or evidence workflows.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="form-grid">
|
||||
<label class="filter-field">
|
||||
<span>Alias</span>
|
||||
<input
|
||||
type="text"
|
||||
[ngModel]="createAlias()"
|
||||
(ngModelChange)="createAlias.set($event)"
|
||||
placeholder="prod-attestation-k1"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="filter-field">
|
||||
<span>Algorithm</span>
|
||||
<select [ngModel]="createAlgorithm()" (ngModelChange)="createAlgorithm.set($event)">
|
||||
<option value="ed25519">Ed25519</option>
|
||||
<option value="ecdsa-p256">ECDSA P-256</option>
|
||||
<option value="rsa-4096">RSA 4096</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="filter-field">
|
||||
<span>Metadata</span>
|
||||
<textarea
|
||||
rows="4"
|
||||
[ngModel]="createMetadata()"
|
||||
(ngModelChange)="createMetadata.set($event)"
|
||||
placeholder='{"owner":"security","rotation":"quarterly"}'
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
@if (createError()) {
|
||||
<div class="inline-error">{{ createError() }}</div>
|
||||
}
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-primary" (click)="submitCreate()" [disabled]="creating()">
|
||||
{{ creating() ? 'Creating...' : 'Create Key' }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="resetCreate()">Reset</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (loading()) {
|
||||
<div class="state state--loading">Loading signing keys...</div>
|
||||
} @else if (error()) {
|
||||
<div class="state state--error">{{ error() }}</div>
|
||||
} @else if (keys().length === 0) {
|
||||
<div class="state">No signing keys found.</div>
|
||||
<div class="state">No signing keys found. Register the first key to enable evidence signing and release approvals.</div>
|
||||
} @else {
|
||||
<table class="key-table">
|
||||
<thead>
|
||||
@@ -76,7 +135,6 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
|
||||
<th>Algorithm</th>
|
||||
<th>Version</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Updated</th>
|
||||
<th>Updated By</th>
|
||||
<th>Actions</th>
|
||||
@@ -93,7 +151,6 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
|
||||
<td>{{ key.algorithm }}</td>
|
||||
<td>{{ key.metadata?.['currentVersion'] || '1' }}</td>
|
||||
<td><span class="badge" [class]="'badge--' + key.status">{{ formatStatus(key.status) }}</span></td>
|
||||
<td>{{ key.createdAt | date:'medium' }}</td>
|
||||
<td>{{ (key.metadata?.['updatedAt'] || key.createdAt) | date:'medium' }}</td>
|
||||
<td>{{ key.metadata?.['updatedBy'] || 'system' }}</td>
|
||||
<td class="actions-cell">
|
||||
@@ -101,14 +158,14 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
|
||||
<button
|
||||
type="button"
|
||||
class="btn-sm"
|
||||
(click)="rotateKey(key)"
|
||||
(click)="openMutation(key, 'rotate')"
|
||||
[disabled]="key.status === 'revoked' || mutatingKeyId() === key.keyId">
|
||||
Rotate
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-sm btn-sm--danger"
|
||||
(click)="revokeKey(key)"
|
||||
(click)="openMutation(key, 'revoke')"
|
||||
[disabled]="key.status === 'revoked' || mutatingKeyId() === key.keyId">
|
||||
Revoke
|
||||
</button>
|
||||
@@ -120,8 +177,8 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
|
||||
}
|
||||
|
||||
@if (selectedKey()) {
|
||||
<section class="detail-card">
|
||||
<header class="detail-card__header">
|
||||
<section class="workspace-card">
|
||||
<header class="workspace-card__header">
|
||||
<div>
|
||||
<h3>{{ selectedKey()!.name }}</h3>
|
||||
<p>{{ selectedKey()!.keyId }}</p>
|
||||
@@ -151,50 +208,118 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
|
||||
<dd>{{ selectedKey()!.createdAt | date:'medium' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Updated</dt>
|
||||
<dd>{{ (selectedKey()!.metadata?.['updatedAt'] || selectedKey()!.createdAt) | date:'medium' }}</dd>
|
||||
<dt>Operator Guidance</dt>
|
||||
<dd>
|
||||
Rotate when custody or policy changes. Revoke only when the key must stop signing immediately.
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (mutationKey() && mutationKind()) {
|
||||
<section class="workspace-card workspace-card--danger">
|
||||
<header class="workspace-card__header">
|
||||
<div>
|
||||
<h3>{{ mutationKind() === 'rotate' ? 'Rotate Signing Key' : 'Revoke Signing Key' }}</h3>
|
||||
<p>{{ mutationKey()!.name }} · {{ mutationKey()!.keyId }}</p>
|
||||
</div>
|
||||
<button type="button" class="btn-secondary" (click)="closeMutation()">Cancel</button>
|
||||
</header>
|
||||
|
||||
<p class="impact-copy">
|
||||
@if (mutationKind() === 'rotate') {
|
||||
Rotation creates a new active version and shifts future evidence signing to the new key.
|
||||
} @else {
|
||||
Revocation immediately marks the key unusable for future signing operations.
|
||||
}
|
||||
</p>
|
||||
|
||||
<label class="filter-field">
|
||||
<span>Reason</span>
|
||||
<textarea
|
||||
rows="3"
|
||||
[ngModel]="mutationReason()"
|
||||
(ngModelChange)="mutationReason.set($event)"
|
||||
placeholder="Describe the rotation or revocation decision"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
@if (mutationError()) {
|
||||
<div class="inline-error">{{ mutationError() }}</div>
|
||||
}
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-primary" (click)="submitMutation()" [disabled]="mutating()">
|
||||
{{ mutating() ? 'Applying...' : (mutationKind() === 'rotate' ? 'Confirm Rotation' : 'Confirm Revocation') }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="closeMutation()">Cancel</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.key-dashboard { padding: 1.5rem; display: grid; gap: 1rem; }
|
||||
.key-dashboard__header { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; }
|
||||
.key-dashboard__header h2 { margin: 0 0 0.35rem; }
|
||||
.key-dashboard__header p { margin: 0; color: var(--color-text-secondary); max-width: 52rem; }
|
||||
.key-dashboard__header, .workspace-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.key-dashboard__header h2, .workspace-card__header h3 { margin: 0 0 0.35rem; }
|
||||
.key-dashboard__header p, .workspace-card__header p, .impact-copy {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 54rem;
|
||||
}
|
||||
.header-actions, .form-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.key-dashboard__filters { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; }
|
||||
.filter-field { display: grid; gap: 0.25rem; min-width: 15rem; }
|
||||
.filter-field span { font-size: 0.78rem; color: var(--color-text-secondary); text-transform: uppercase; }
|
||||
.filter-field input, .filter-field select {
|
||||
.filter-field input, .filter-field select, .filter-field textarea {
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-primary);
|
||||
font: inherit;
|
||||
}
|
||||
.contract-note {
|
||||
.workspace-card {
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.workspace-card--danger {
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
background: rgba(239, 68, 68, 0.05);
|
||||
}
|
||||
.form-grid, .detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
.detail-grid dt { font-size: 0.78rem; color: var(--color-text-secondary); text-transform: uppercase; }
|
||||
.detail-grid dd { margin: 0.2rem 0 0; color: var(--color-text-primary); word-break: break-word; }
|
||||
.banner, .state, .inline-error {
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.state {
|
||||
padding: 2rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
.state--error {
|
||||
.banner { background: rgba(74, 222, 128, 0.12); color: var(--color-status-success-border); }
|
||||
.banner--error, .inline-error, .state--error {
|
||||
color: var(--color-status-error);
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
.state {
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
.key-table { width: 100%; border-collapse: collapse; }
|
||||
.key-table th, .key-table td {
|
||||
padding: 0.75rem 0.9rem;
|
||||
@@ -210,27 +335,30 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
|
||||
}
|
||||
.key-table tr.is-selected { background: rgba(34, 211, 238, 0.08); }
|
||||
.actions-cell { white-space: nowrap; }
|
||||
.link-button, .btn-sm, .btn-secondary, .btn-link {
|
||||
.link-button, .btn-sm, .btn-primary, .btn-secondary, .btn-link {
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
}
|
||||
.link-button {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-status-info);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.btn-sm, .btn-secondary {
|
||||
padding: 0.4rem 0.7rem;
|
||||
.btn-sm, .btn-secondary, .btn-primary {
|
||||
padding: 0.45rem 0.8rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
margin-right: 0.35rem;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--color-status-info);
|
||||
border-color: var(--color-status-info);
|
||||
color: #04131a;
|
||||
}
|
||||
.btn-sm--danger { color: var(--color-status-error); }
|
||||
.btn-secondary { margin-right: 0; }
|
||||
.btn-link {
|
||||
border: none;
|
||||
background: transparent;
|
||||
@@ -248,30 +376,6 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
|
||||
.badge--expiring_soon { background: rgba(251, 191, 36, 0.16); color: var(--color-status-warning-border); }
|
||||
.badge--expired, .badge--revoked { background: rgba(239, 68, 68, 0.12); color: var(--color-status-error); }
|
||||
.badge--pending_rotation { background: rgba(167, 139, 250, 0.16); color: var(--color-status-excepted-border); }
|
||||
.detail-card {
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.detail-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.detail-card__header h3 { margin: 0 0 0.25rem; }
|
||||
.detail-card__header p { margin: 0; color: var(--color-text-secondary); font-family: monospace; }
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||
gap: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
.detail-grid dt { font-size: 0.78rem; color: var(--color-text-secondary); text-transform: uppercase; }
|
||||
.detail-grid dd { margin: 0.2rem 0 0; color: var(--color-text-primary); word-break: break-word; }
|
||||
`],
|
||||
})
|
||||
export class SigningKeyDashboardComponent {
|
||||
@@ -280,11 +384,26 @@ export class SigningKeyDashboardComponent {
|
||||
readonly keys = signal<SigningKey[]>([]);
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly banner = signal<string | null>(null);
|
||||
readonly bannerTone = signal<'success' | 'error'>('success');
|
||||
readonly selectedKey = signal<SigningKey | null>(null);
|
||||
readonly mutatingKeyId = signal<string | null>(null);
|
||||
readonly searchQuery = signal('');
|
||||
readonly selectedStatus = signal<SigningKeyStatus | 'all'>('all');
|
||||
|
||||
readonly createOpen = signal(false);
|
||||
readonly createAlias = signal('');
|
||||
readonly createAlgorithm = signal('ed25519');
|
||||
readonly createMetadata = signal('');
|
||||
readonly createError = signal<string | null>(null);
|
||||
readonly creating = signal(false);
|
||||
|
||||
readonly mutationKey = signal<SigningKey | null>(null);
|
||||
readonly mutationKind = signal<KeyMutationKind | null>(null);
|
||||
readonly mutationReason = signal('');
|
||||
readonly mutationError = signal<string | null>(null);
|
||||
readonly mutating = signal(false);
|
||||
|
||||
readonly hasFilters = computed(() => this.searchQuery().trim().length > 0 || this.selectedStatus() !== 'all');
|
||||
|
||||
constructor() {
|
||||
@@ -318,54 +437,102 @@ export class SigningKeyDashboardComponent {
|
||||
}
|
||||
|
||||
applyFilters(): void {
|
||||
this.selectedKey.set(null);
|
||||
this.loadKeys();
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.searchQuery.set('');
|
||||
this.selectedStatus.set('all');
|
||||
this.applyFilters();
|
||||
this.loadKeys();
|
||||
}
|
||||
|
||||
rotateKey(key: SigningKey): void {
|
||||
const reason = window.prompt(`Rotation reason for "${key.name}"`, 'Routine rotation');
|
||||
if (reason === null) {
|
||||
toggleCreate(): void {
|
||||
this.createOpen.update((value) => !value);
|
||||
this.createError.set(null);
|
||||
}
|
||||
|
||||
resetCreate(): void {
|
||||
this.createAlias.set('');
|
||||
this.createAlgorithm.set('ed25519');
|
||||
this.createMetadata.set('');
|
||||
this.createError.set(null);
|
||||
}
|
||||
|
||||
submitCreate(): void {
|
||||
const alias = this.createAlias().trim();
|
||||
if (!alias) {
|
||||
this.createError.set('Alias is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.mutatingKeyId.set(key.keyId);
|
||||
this.error.set(null);
|
||||
this.trustApi.rotateKey(key.keyId, { reason }).subscribe({
|
||||
next: (updated) => {
|
||||
this.keys.update((items) => items.map((item) => item.keyId === updated.keyId ? updated : item));
|
||||
this.selectedKey.set(updated);
|
||||
this.mutatingKeyId.set(null);
|
||||
const request: CreateSigningKeyRequest = {
|
||||
alias,
|
||||
algorithm: this.createAlgorithm(),
|
||||
metadataJson: this.createMetadata().trim() || undefined,
|
||||
};
|
||||
|
||||
this.creating.set(true);
|
||||
this.createError.set(null);
|
||||
this.trustApi.createKey(request).subscribe({
|
||||
next: (created) => {
|
||||
this.creating.set(false);
|
||||
this.createOpen.set(false);
|
||||
this.resetCreate();
|
||||
this.bannerTone.set('success');
|
||||
this.banner.set(`Registered signing key ${created.name}.`);
|
||||
this.selectedKey.set(created);
|
||||
this.loadKeys();
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err?.error?.error || err?.message || `Failed to rotate "${key.name}".`);
|
||||
this.mutatingKeyId.set(null);
|
||||
this.creating.set(false);
|
||||
this.createError.set(err?.error?.error || err?.message || 'Failed to register signing key.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
revokeKey(key: SigningKey): void {
|
||||
const reason = window.prompt(`Revocation reason for "${key.name}"`, 'Compromised or retired');
|
||||
if (reason === null) {
|
||||
openMutation(key: SigningKey, kind: KeyMutationKind): void {
|
||||
this.mutationKey.set(key);
|
||||
this.mutationKind.set(kind);
|
||||
this.mutationReason.set('');
|
||||
this.mutationError.set(null);
|
||||
}
|
||||
|
||||
closeMutation(): void {
|
||||
this.mutationKey.set(null);
|
||||
this.mutationKind.set(null);
|
||||
this.mutationReason.set('');
|
||||
this.mutationError.set(null);
|
||||
}
|
||||
|
||||
submitMutation(): void {
|
||||
const key = this.mutationKey();
|
||||
const kind = this.mutationKind();
|
||||
const reason = this.mutationReason().trim();
|
||||
|
||||
if (!key || !kind) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!reason) {
|
||||
this.mutationError.set('Reason is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.mutating.set(true);
|
||||
this.mutatingKeyId.set(key.keyId);
|
||||
this.error.set(null);
|
||||
this.mutationError.set(null);
|
||||
|
||||
if (kind === 'rotate') {
|
||||
this.trustApi.rotateKey(key.keyId, { reason }).subscribe({
|
||||
next: () => this.handleMutationSuccess(key.name, 'Rotated'),
|
||||
error: (err) => this.handleMutationError(err, 'rotate'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.trustApi.revokeKey(key.keyId, reason).subscribe({
|
||||
next: () => {
|
||||
this.loadKeys();
|
||||
this.mutatingKeyId.set(null);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err?.error?.error || err?.message || `Failed to revoke "${key.name}".`);
|
||||
this.mutatingKeyId.set(null);
|
||||
},
|
||||
next: () => this.handleMutationSuccess(key.name, 'Revoked'),
|
||||
error: (err) => this.handleMutationError(err, 'revoke'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -379,4 +546,20 @@ export class SigningKeyDashboardComponent {
|
||||
return status.charAt(0).toUpperCase() + status.slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
private handleMutationSuccess(keyName: string, verb: 'Rotated' | 'Revoked'): void {
|
||||
this.mutating.set(false);
|
||||
this.mutatingKeyId.set(null);
|
||||
this.bannerTone.set('success');
|
||||
this.banner.set(`${verb} signing key ${keyName}.`);
|
||||
this.closeMutation();
|
||||
this.loadKeys();
|
||||
}
|
||||
|
||||
private handleMutationError(err: unknown, kind: KeyMutationKind): void {
|
||||
const error = err as { error?: { error?: string }; message?: string } | undefined;
|
||||
this.mutating.set(false);
|
||||
this.mutatingKeyId.set(null);
|
||||
this.mutationError.set(error?.error?.error || error?.message || `Failed to ${kind} signing key.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
|
||||
<span class="summary-card__value">{{ overview()!.inventory.keys }}</span>
|
||||
<span class="summary-card__label">Signing Keys</span>
|
||||
<span class="summary-card__detail">
|
||||
Administration inventory projection
|
||||
Keys available for current signing workflows
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -86,7 +86,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
|
||||
<span class="summary-card__value">{{ overview()!.inventory.issuers }}</span>
|
||||
<span class="summary-card__label">Trusted Issuers</span>
|
||||
<span class="summary-card__detail">
|
||||
Routed from live administration projection
|
||||
Publishers currently allowed to influence trust
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -97,7 +97,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
|
||||
<span class="summary-card__value">{{ overview()!.inventory.certificates }}</span>
|
||||
<span class="summary-card__label">Certificates</span>
|
||||
<span class="summary-card__detail">
|
||||
Evidence and issuer trust consumers stay linked
|
||||
Certificate expiry and revocation stay visible here
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,248 +1,199 @@
|
||||
/**
|
||||
* @file trust-analytics.component.spec.ts
|
||||
* @sprint SPRINT_20251229_018c_FE
|
||||
* @description Unit tests for TrustAnalyticsComponent
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { TrustAnalyticsComponent } from './trust-analytics.component';
|
||||
|
||||
import { TRUST_API, TrustApi } from '../../core/api/trust.client';
|
||||
import {
|
||||
TrustAnalyticsSummary,
|
||||
VerificationMetrics,
|
||||
IssuerReliabilityData,
|
||||
FailureAnalysis,
|
||||
TrustAnalyticsAlert,
|
||||
} from '../../core/api/trust.models';
|
||||
import { IssuerReliabilityAnalytics, TrustAnalyticsSummary, VerificationAnalytics } from '../../core/api/trust.models';
|
||||
import { TrustAnalyticsComponent } from './trust-analytics.component';
|
||||
|
||||
describe('TrustAnalyticsComponent', () => {
|
||||
let component: TrustAnalyticsComponent;
|
||||
let fixture: ComponentFixture<TrustAnalyticsComponent>;
|
||||
let mockTrustApi: jasmine.SpyObj<TrustApi>;
|
||||
|
||||
const mockSummary: TrustAnalyticsSummary = {
|
||||
totalVerifications: 10000,
|
||||
successfulVerifications: 9500,
|
||||
failedVerifications: 500,
|
||||
successRate: 95.0,
|
||||
averageVerificationTime: 125,
|
||||
peakVerificationTime: 350,
|
||||
activeSigningKeys: 5,
|
||||
activeIssuers: 20,
|
||||
trendDirection: 'up',
|
||||
trendPercentage: 2.5,
|
||||
};
|
||||
|
||||
const mockVerificationMetrics: VerificationMetrics = {
|
||||
timeRange: 'last_24h',
|
||||
dataPoints: [
|
||||
{ timestamp: '2024-01-15T00:00:00Z', successCount: 400, failureCount: 20, avgTime: 120 },
|
||||
{ timestamp: '2024-01-15T01:00:00Z', successCount: 450, failureCount: 15, avgTime: 115 },
|
||||
const summary: TrustAnalyticsSummary = {
|
||||
verificationSuccessRate: 96.2,
|
||||
issuerReliabilityScore: 88.3,
|
||||
certificateHealthScore: 91.4,
|
||||
keyHealthScore: 94.8,
|
||||
overallTrustScore: 92.1,
|
||||
alerts: [
|
||||
{
|
||||
alertId: 'alert-001',
|
||||
severity: 'warning',
|
||||
category: 'certificate',
|
||||
title: 'Gateway certificate expires soon',
|
||||
message: 'Renew before the release window closes.',
|
||||
createdAt: '2026-03-15T00:00:00Z',
|
||||
acknowledged: false,
|
||||
},
|
||||
],
|
||||
aggregations: {
|
||||
totalSuccess: 9500,
|
||||
totalFailure: 500,
|
||||
avgSuccessRate: 95.0,
|
||||
avgTime: 125,
|
||||
trends: {
|
||||
verificationTrend: 'stable',
|
||||
reliabilityTrend: 'improving',
|
||||
certificateTrend: 'declining',
|
||||
keyTrend: 'stable',
|
||||
},
|
||||
};
|
||||
|
||||
const mockIssuerReliability: IssuerReliabilityData[] = [
|
||||
{
|
||||
issuerId: 'issuer-001',
|
||||
issuerName: 'Vendor A',
|
||||
totalDocuments: 500,
|
||||
verifiedDocuments: 490,
|
||||
failedDocuments: 10,
|
||||
reliabilityScore: 98.0,
|
||||
avgResponseTime: 100,
|
||||
lastVerifiedAt: '2024-01-15T10:00:00Z',
|
||||
const verificationAnalytics: VerificationAnalytics = {
|
||||
timeRange: '7d',
|
||||
granularity: 'daily',
|
||||
summary: {
|
||||
totalVerifications: 120,
|
||||
successfulVerifications: 116,
|
||||
failedVerifications: 4,
|
||||
successRate: 96.7,
|
||||
averageLatencyMs: 24,
|
||||
p95LatencyMs: 64,
|
||||
p99LatencyMs: 114,
|
||||
},
|
||||
];
|
||||
|
||||
const mockFailureAnalysis: FailureAnalysis = {
|
||||
timeRange: 'last_7d',
|
||||
totalFailures: 500,
|
||||
failuresByType: [
|
||||
{ type: 'signature_invalid', count: 200, percentage: 40 },
|
||||
{ type: 'certificate_expired', count: 150, percentage: 30 },
|
||||
{ type: 'chain_incomplete', count: 100, percentage: 20 },
|
||||
{ type: 'unknown', count: 50, percentage: 10 },
|
||||
trend: [
|
||||
{
|
||||
timestamp: '2026-03-15T00:00:00Z',
|
||||
totalVerifications: 17,
|
||||
successfulVerifications: 16,
|
||||
failedVerifications: 1,
|
||||
successRate: 94.1,
|
||||
averageLatencyMs: 23,
|
||||
},
|
||||
],
|
||||
failuresByIssuer: [
|
||||
{ issuerId: 'issuer-002', issuerName: 'Vendor B', count: 100, percentage: 20 },
|
||||
byResourceType: {
|
||||
keys: {
|
||||
totalVerifications: 40,
|
||||
successfulVerifications: 39,
|
||||
failedVerifications: 1,
|
||||
successRate: 97.5,
|
||||
averageLatencyMs: 18,
|
||||
p95LatencyMs: 58,
|
||||
p99LatencyMs: 108,
|
||||
},
|
||||
certificates: {
|
||||
totalVerifications: 50,
|
||||
successfulVerifications: 47,
|
||||
failedVerifications: 3,
|
||||
successRate: 94,
|
||||
averageLatencyMs: 31,
|
||||
p95LatencyMs: 71,
|
||||
p99LatencyMs: 121,
|
||||
},
|
||||
issuers: {
|
||||
totalVerifications: 30,
|
||||
successfulVerifications: 30,
|
||||
failedVerifications: 0,
|
||||
successRate: 100,
|
||||
averageLatencyMs: 19,
|
||||
p95LatencyMs: 59,
|
||||
p99LatencyMs: 109,
|
||||
},
|
||||
signatures: {
|
||||
totalVerifications: 100,
|
||||
successfulVerifications: 96,
|
||||
failedVerifications: 4,
|
||||
successRate: 96,
|
||||
averageLatencyMs: 26,
|
||||
p95LatencyMs: 66,
|
||||
p99LatencyMs: 116,
|
||||
},
|
||||
},
|
||||
failureReasons: [
|
||||
{
|
||||
reason: 'Certificates expiring soon',
|
||||
count: 2,
|
||||
percentage: 1.7,
|
||||
trend: 'increasing',
|
||||
},
|
||||
],
|
||||
recentFailures: [],
|
||||
};
|
||||
|
||||
const mockAlerts: TrustAnalyticsAlert[] = [
|
||||
{
|
||||
alertId: 'alert-001',
|
||||
severity: 'warning',
|
||||
title: 'Verification Rate Declining',
|
||||
description: 'Verification success rate dropped below 95%',
|
||||
timestamp: '2024-01-15T10:00:00Z',
|
||||
acknowledged: false,
|
||||
},
|
||||
];
|
||||
const issuerReliabilityAnalytics: IssuerReliabilityAnalytics = {
|
||||
timeRange: '7d',
|
||||
granularity: 'daily',
|
||||
issuers: [
|
||||
{
|
||||
issuerId: 'issuer-001',
|
||||
issuerName: 'core-root-ca',
|
||||
issuerDisplayName: 'Core Root CA',
|
||||
trustScore: 88,
|
||||
trustLevel: 'partial',
|
||||
totalDocuments: 18,
|
||||
verifiedDocuments: 16,
|
||||
verificationRate: 88.9,
|
||||
averageResponseTime: 28,
|
||||
uptimePercentage: 98.2,
|
||||
lastVerificationAt: '2026-03-15T00:00:00Z',
|
||||
reliabilityScore: 89.1,
|
||||
trendDirection: 'improving',
|
||||
},
|
||||
],
|
||||
aggregatedTrend: [
|
||||
{
|
||||
timestamp: '2026-03-15T00:00:00Z',
|
||||
reliabilityScore: 89.1,
|
||||
verificationRate: 88.9,
|
||||
trustScore: 88,
|
||||
documentsProcessed: 18,
|
||||
},
|
||||
],
|
||||
topPerformers: [],
|
||||
underperformers: [],
|
||||
averageReliabilityScore: 89.1,
|
||||
averageVerificationRate: 88.9,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockTrustApi = jasmine.createSpyObj<TrustApi>('TrustApi', [
|
||||
'getAnalyticsSummary',
|
||||
'getVerificationMetrics',
|
||||
'getIssuerReliability',
|
||||
'getFailureAnalysis',
|
||||
'getAnalyticsAlerts',
|
||||
'acknowledgeAlert',
|
||||
'dismissAlert',
|
||||
'getVerificationAnalytics',
|
||||
'getIssuerReliabilityAnalytics',
|
||||
'acknowledgeAnalyticsAlert',
|
||||
]);
|
||||
|
||||
mockTrustApi.getAnalyticsSummary.and.returnValue(of(mockSummary));
|
||||
mockTrustApi.getVerificationMetrics.and.returnValue(of(mockVerificationMetrics));
|
||||
mockTrustApi.getIssuerReliability.and.returnValue(of(mockIssuerReliability));
|
||||
mockTrustApi.getFailureAnalysis.and.returnValue(of(mockFailureAnalysis));
|
||||
mockTrustApi.getAnalyticsAlerts.and.returnValue(of(mockAlerts));
|
||||
mockTrustApi.acknowledgeAlert.and.returnValue(of(void 0));
|
||||
mockTrustApi.dismissAlert.and.returnValue(of(void 0));
|
||||
mockTrustApi.getAnalyticsSummary.and.returnValue(of(summary));
|
||||
mockTrustApi.getVerificationAnalytics.and.returnValue(of(verificationAnalytics));
|
||||
mockTrustApi.getIssuerReliabilityAnalytics.and.returnValue(of(issuerReliabilityAnalytics));
|
||||
mockTrustApi.acknowledgeAnalyticsAlert.and.returnValue(of(void 0));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TrustAnalyticsComponent],
|
||||
providers: [
|
||||
{ provide: TRUST_API, useValue: mockTrustApi },
|
||||
],
|
||||
providers: [{ provide: TRUST_API, useValue: mockTrustApi }],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(TrustAnalyticsComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should load analytics data on init', fakeAsync(() => {
|
||||
it('loads derived analytics data on init', () => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.getAnalyticsSummary).toHaveBeenCalled();
|
||||
expect(mockTrustApi.getVerificationMetrics).toHaveBeenCalled();
|
||||
expect(mockTrustApi.getIssuerReliability).toHaveBeenCalled();
|
||||
expect(mockTrustApi.getFailureAnalysis).toHaveBeenCalled();
|
||||
expect(mockTrustApi.getAnalyticsAlerts).toHaveBeenCalled();
|
||||
}));
|
||||
expect(mockTrustApi.getVerificationAnalytics).toHaveBeenCalledWith({ timeRange: '7d', granularity: 'daily' });
|
||||
expect(mockTrustApi.getIssuerReliabilityAnalytics).toHaveBeenCalledWith({ timeRange: '7d', granularity: 'daily' });
|
||||
expect(component.summary()).toEqual(summary);
|
||||
});
|
||||
|
||||
it('should display summary data', fakeAsync(() => {
|
||||
it('reloads analytics when the time range changes', () => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(component.summary()).toEqual(mockSummary);
|
||||
}));
|
||||
component.onTimeRangeChange('24h');
|
||||
|
||||
it('should display verification metrics', fakeAsync(() => {
|
||||
expect(mockTrustApi.getVerificationAnalytics).toHaveBeenCalledWith({ timeRange: '24h', granularity: 'hourly' });
|
||||
expect(mockTrustApi.getIssuerReliabilityAnalytics).toHaveBeenCalledWith({ timeRange: '24h', granularity: 'hourly' });
|
||||
});
|
||||
|
||||
it('acknowledges alerts in local state after the API call', () => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(component.verificationMetrics()).toEqual(mockVerificationMetrics);
|
||||
}));
|
||||
|
||||
it('should display issuer reliability data', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(component.issuerReliability().length).toBe(1);
|
||||
expect(component.issuerReliability()[0].issuerName).toBe('Vendor A');
|
||||
}));
|
||||
|
||||
it('should display failure analysis', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(component.failureAnalysis()).toEqual(mockFailureAnalysis);
|
||||
}));
|
||||
|
||||
it('should display alerts', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(component.alerts().length).toBe(1);
|
||||
expect(component.alerts()[0].title).toBe('Verification Rate Declining');
|
||||
}));
|
||||
|
||||
it('should change time range and reload data', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.selectedTimeRange.set('last_7d');
|
||||
component.onTimeRangeChange();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.getVerificationMetrics).toHaveBeenCalledWith('last_7d');
|
||||
expect(mockTrustApi.getFailureAnalysis).toHaveBeenCalledWith('last_7d');
|
||||
}));
|
||||
|
||||
it('should acknowledge alert', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.acknowledgeAlert('alert-001');
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.acknowledgeAlert).toHaveBeenCalledWith('alert-001');
|
||||
}));
|
||||
|
||||
it('should dismiss alert', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.dismissAlert('alert-001');
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.dismissAlert).toHaveBeenCalledWith('alert-001');
|
||||
}));
|
||||
|
||||
it('should handle loading state', () => {
|
||||
expect(component.loading()).toBeTrue();
|
||||
fixture.detectChanges();
|
||||
expect(component.loading()).toBeFalse();
|
||||
expect(mockTrustApi.acknowledgeAnalyticsAlert).toHaveBeenCalledWith('alert-001');
|
||||
expect(component.unacknowledgedAlerts()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle error state', fakeAsync(() => {
|
||||
mockTrustApi.getAnalyticsSummary.and.returnValue(throwError(() => new Error('Failed')));
|
||||
it('surfaces load failures', () => {
|
||||
mockTrustApi.getAnalyticsSummary.and.returnValue(throwError(() => new Error('analytics unavailable')));
|
||||
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(component.error()).toBe('Failed');
|
||||
}));
|
||||
|
||||
it('should format time range correctly', () => {
|
||||
expect(component.formatTimeRange('last_24h')).toBe('Last 24 Hours');
|
||||
expect(component.formatTimeRange('last_7d')).toBe('Last 7 Days');
|
||||
expect(component.formatTimeRange('last_30d')).toBe('Last 30 Days');
|
||||
expect(component.error()).toBe('Failed to load analytics data. Please try again.');
|
||||
});
|
||||
|
||||
it('should format failure type correctly', () => {
|
||||
expect(component.formatFailureType('signature_invalid')).toBe('Invalid Signature');
|
||||
expect(component.formatFailureType('certificate_expired')).toBe('Certificate Expired');
|
||||
expect(component.formatFailureType('chain_incomplete')).toBe('Incomplete Chain');
|
||||
});
|
||||
|
||||
it('should compute unacknowledged alerts count', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(component.unacknowledgedAlerts()).toBe(1);
|
||||
}));
|
||||
|
||||
it('should refresh data', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
const initialCalls = mockTrustApi.getAnalyticsSummary.calls.count();
|
||||
component.refreshData();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.getAnalyticsSummary.calls.count()).toBe(initialCalls + 1);
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -3,8 +3,11 @@ import { of, throwError } from 'rxjs';
|
||||
|
||||
import {
|
||||
AUTHORITY_ADMIN_API,
|
||||
type AuthorityAdminApi,
|
||||
type AdminRole,
|
||||
type AdminTenant,
|
||||
type AdminUser,
|
||||
type AuthorityAdminApi,
|
||||
type RoleImpactPreview,
|
||||
} from '../../app/core/api/authority-admin.client';
|
||||
import { AdminSettingsPageComponent } from '../../app/features/settings/admin/admin-settings-page.component';
|
||||
|
||||
@@ -16,29 +19,140 @@ function createAuthorityApiSpy(): jasmine.SpyObj<AuthorityAdminApi> {
|
||||
listTokens: jasmine.createSpy('listTokens'),
|
||||
listTenants: jasmine.createSpy('listTenants'),
|
||||
createUser: jasmine.createSpy('createUser'),
|
||||
updateUser: jasmine.createSpy('updateUser'),
|
||||
disableUser: jasmine.createSpy('disableUser'),
|
||||
enableUser: jasmine.createSpy('enableUser'),
|
||||
createRole: jasmine.createSpy('createRole'),
|
||||
updateRole: jasmine.createSpy('updateRole'),
|
||||
previewRoleImpact: jasmine.createSpy('previewRoleImpact'),
|
||||
createTenant: jasmine.createSpy('createTenant'),
|
||||
updateTenant: jasmine.createSpy('updateTenant'),
|
||||
suspendTenant: jasmine.createSpy('suspendTenant'),
|
||||
resumeTenant: jasmine.createSpy('resumeTenant'),
|
||||
} as jasmine.SpyObj<AuthorityAdminApi>;
|
||||
}
|
||||
|
||||
describe('AdminSettingsPageComponent (settings)', () => {
|
||||
let api: jasmine.SpyObj<AuthorityAdminApi>;
|
||||
let component: AdminSettingsPageComponent;
|
||||
let users: AdminUser[];
|
||||
let roles: AdminRole[];
|
||||
let tenants: AdminTenant[];
|
||||
let roleImpact: RoleImpactPreview;
|
||||
|
||||
beforeEach(async () => {
|
||||
api = createAuthorityApiSpy();
|
||||
api.listUsers.and.returnValue(of([]));
|
||||
api.listRoles.and.returnValue(of([]));
|
||||
users = [{
|
||||
id: 'u-1',
|
||||
username: 'jane',
|
||||
email: 'jane@example.com',
|
||||
displayName: 'Jane Example',
|
||||
roles: ['role/console-viewer'],
|
||||
status: 'active',
|
||||
createdAt: '2026-02-19T00:00:00Z',
|
||||
}];
|
||||
roles = [
|
||||
{
|
||||
id: 'role/console-admin',
|
||||
name: 'role/console-admin',
|
||||
description: 'Full platform administration.',
|
||||
permissions: ['ui.admin', 'authority:users.write'],
|
||||
userCount: 1,
|
||||
isBuiltIn: true,
|
||||
},
|
||||
{
|
||||
id: 'role/console-viewer',
|
||||
name: 'role/console-viewer',
|
||||
description: 'Least privilege console access.',
|
||||
permissions: ['ui.read', 'authority:users.read'],
|
||||
userCount: 1,
|
||||
isBuiltIn: true,
|
||||
},
|
||||
{
|
||||
id: 'role/custom-release-operator',
|
||||
name: 'role/custom-release-operator',
|
||||
description: 'Custom release operator role.',
|
||||
permissions: ['release:read', 'release:write'],
|
||||
userCount: 0,
|
||||
isBuiltIn: false,
|
||||
},
|
||||
];
|
||||
tenants = [{
|
||||
id: 'default',
|
||||
displayName: 'Default Tenant',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
userCount: 1,
|
||||
createdAt: '2026-02-19T00:00:00Z',
|
||||
}];
|
||||
roleImpact = {
|
||||
affectedUsers: 2,
|
||||
affectedClients: 0,
|
||||
message: '2 users would be affected by changes to this role.',
|
||||
};
|
||||
|
||||
api.listUsers.and.returnValue(of(users));
|
||||
api.listRoles.and.returnValue(of(roles));
|
||||
api.listClients.and.returnValue(of([]));
|
||||
api.listTokens.and.returnValue(of([]));
|
||||
api.listTenants.and.returnValue(of([]));
|
||||
api.listTenants.and.returnValue(of(tenants));
|
||||
api.createUser.and.returnValue(of({
|
||||
id: 'u-created',
|
||||
username: 'created',
|
||||
email: 'created@example.com',
|
||||
displayName: 'Created User',
|
||||
roles: ['viewer'],
|
||||
roles: ['role/console-viewer'],
|
||||
status: 'active',
|
||||
createdAt: '2026-02-19T00:00:00Z',
|
||||
} as AdminUser));
|
||||
}));
|
||||
api.updateUser.and.callFake((userId, request) => of({
|
||||
...users.find((entry) => entry.id === userId)!,
|
||||
displayName: request.displayName ?? users[0].displayName,
|
||||
roles: request.roles ?? users[0].roles,
|
||||
}));
|
||||
api.disableUser.and.callFake((userId) => of({
|
||||
...users.find((entry) => entry.id === userId)!,
|
||||
status: 'disabled',
|
||||
}));
|
||||
api.enableUser.and.callFake((userId) => of({
|
||||
...users.find((entry) => entry.id === userId)!,
|
||||
status: 'active',
|
||||
}));
|
||||
api.createRole.and.callFake((request) => of({
|
||||
id: request.name,
|
||||
name: request.name,
|
||||
description: request.description,
|
||||
permissions: request.permissions,
|
||||
userCount: 0,
|
||||
isBuiltIn: false,
|
||||
}));
|
||||
api.updateRole.and.callFake((roleId, request) => of({
|
||||
...roles.find((entry) => entry.id === roleId)!,
|
||||
description: request.description ?? roles[2].description,
|
||||
permissions: request.permissions ?? roles[2].permissions,
|
||||
}));
|
||||
api.previewRoleImpact.and.returnValue(of(roleImpact));
|
||||
api.createTenant.and.callFake((request) => of({
|
||||
id: request.id,
|
||||
displayName: request.displayName,
|
||||
status: 'active',
|
||||
isolationMode: request.isolationMode,
|
||||
userCount: 0,
|
||||
createdAt: '2026-02-19T00:00:00Z',
|
||||
}));
|
||||
api.updateTenant.and.callFake((tenantId, request) => of({
|
||||
...tenants.find((entry) => entry.id === tenantId)!,
|
||||
displayName: request.displayName ?? tenants[0].displayName,
|
||||
isolationMode: request.isolationMode ?? tenants[0].isolationMode,
|
||||
}));
|
||||
api.suspendTenant.and.callFake((tenantId) => of({
|
||||
...tenants.find((entry) => entry.id === tenantId)!,
|
||||
status: 'disabled',
|
||||
}));
|
||||
api.resumeTenant.and.callFake((tenantId) => of({
|
||||
...tenants.find((entry) => entry.id === tenantId)!,
|
||||
status: 'active',
|
||||
}));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AdminSettingsPageComponent],
|
||||
@@ -49,6 +163,8 @@ describe('AdminSettingsPageComponent (settings)', () => {
|
||||
});
|
||||
|
||||
it('keeps empty state distinct from error state when users API returns empty list', () => {
|
||||
api.listUsers.and.returnValue(of([]));
|
||||
|
||||
component.ngOnInit();
|
||||
|
||||
expect(component.users()).toEqual([]);
|
||||
@@ -62,7 +178,99 @@ describe('AdminSettingsPageComponent (settings)', () => {
|
||||
component.ngOnInit();
|
||||
|
||||
expect(component.users()).toEqual([]);
|
||||
expect(component.error()).toContain('Failed to load users');
|
||||
expect(component.error()).toContain('authority unavailable');
|
||||
expect(component.loading()).toBeFalse();
|
||||
});
|
||||
|
||||
it('defaults new users to the least-privilege role and validates email format', () => {
|
||||
component.ngOnInit();
|
||||
|
||||
component.openCreateUserEditor();
|
||||
|
||||
expect(component.userEditor?.selectedRoles).toEqual(['role/console-viewer']);
|
||||
|
||||
component.userEditor!.username = 'sam';
|
||||
component.userEditor!.email = 'invalid-email';
|
||||
component.userEditor!.displayName = 'Sam Operator';
|
||||
component.saveUser();
|
||||
|
||||
expect(api.createUser).not.toHaveBeenCalled();
|
||||
expect(component.error()).toContain('valid email');
|
||||
});
|
||||
|
||||
it('updates users and toggles lifecycle actions from the setup surface', () => {
|
||||
component.ngOnInit();
|
||||
|
||||
component.openEditUserEditor(users[0]);
|
||||
component.userEditor!.displayName = 'Jane Updated';
|
||||
component.userEditor!.selectedRoles = ['role/console-admin'];
|
||||
component.saveUser();
|
||||
|
||||
expect(api.updateUser).toHaveBeenCalledWith('u-1', {
|
||||
displayName: 'Jane Updated',
|
||||
roles: ['role/console-admin'],
|
||||
});
|
||||
expect(component.users()[0].displayName).toBe('Jane Updated');
|
||||
expect(component.users()[0].roles).toEqual(['role/console-admin']);
|
||||
|
||||
component.disableUser(component.users()[0]);
|
||||
expect(api.disableUser).toHaveBeenCalledWith('u-1');
|
||||
expect(component.users()[0].status).toBe('disabled');
|
||||
|
||||
component.enableUser(component.users()[0]);
|
||||
expect(api.enableUser).toHaveBeenCalledWith('u-1');
|
||||
expect(component.users()[0].status).toBe('active');
|
||||
});
|
||||
|
||||
it('shows role detail impact and allows editing custom roles', () => {
|
||||
component.ngOnInit();
|
||||
|
||||
component.selectRole(roles[2]);
|
||||
|
||||
expect(api.previewRoleImpact).toHaveBeenCalledWith('role/custom-release-operator');
|
||||
expect(component.roleImpact()).toEqual(roleImpact);
|
||||
expect(component.selectedRole()?.name).toBe('role/custom-release-operator');
|
||||
|
||||
component.openEditRoleEditor(roles[2]);
|
||||
component.roleEditor!.description = 'Release operator with approvals.';
|
||||
component.roleEditor!.selectedScopes = ['release:read', 'release:write', 'release:publish'];
|
||||
component.saveRole();
|
||||
|
||||
expect(api.updateRole).toHaveBeenCalledWith('role/custom-release-operator', {
|
||||
description: 'Release operator with approvals.',
|
||||
permissions: ['release:read', 'release:write', 'release:publish'],
|
||||
});
|
||||
expect(component.successMessage()).toContain('Updated role');
|
||||
});
|
||||
|
||||
it('validates tenant ids and supports tenant lifecycle actions', () => {
|
||||
component.setTab('tenants');
|
||||
component.openCreateTenantEditor();
|
||||
|
||||
component.tenantEditor!.id = 'Bad Tenant';
|
||||
component.tenantEditor!.displayName = 'Bad Tenant';
|
||||
component.saveTenant();
|
||||
|
||||
expect(api.createTenant).not.toHaveBeenCalled();
|
||||
expect(component.error()).toContain('lowercase letters, digits, and hyphens');
|
||||
|
||||
component.tenantEditor!.id = 'sandbox';
|
||||
component.tenantEditor!.displayName = 'Sandbox';
|
||||
component.tenantEditor!.isolationMode = 'dedicated';
|
||||
component.saveTenant();
|
||||
|
||||
expect(api.createTenant).toHaveBeenCalledWith({
|
||||
id: 'sandbox',
|
||||
displayName: 'Sandbox',
|
||||
isolationMode: 'dedicated',
|
||||
});
|
||||
|
||||
component.suspendTenant(tenants[0]);
|
||||
expect(api.suspendTenant).toHaveBeenCalledWith('default');
|
||||
expect(component.tenants()[0].status).toBe('disabled');
|
||||
|
||||
component.resumeTenant(component.tenants()[0]);
|
||||
expect(api.resumeTenant).toHaveBeenCalledWith('default');
|
||||
expect(component.tenants()[0].status).toBe('active');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"src/app/types/monaco-workers.d.ts",
|
||||
"src/app/core/branding/branding.service.spec.ts",
|
||||
"src/app/core/api/first-signal.client.spec.ts",
|
||||
"src/app/core/api/trust.client.spec.ts",
|
||||
"src/app/core/api/vulnerability-http.client.spec.ts",
|
||||
"src/app/core/api/watchlist.client.spec.ts",
|
||||
"src/app/core/auth/tenant-activation.service.spec.ts",
|
||||
@@ -38,13 +39,18 @@
|
||||
"src/app/features/releases/release-ops-overview-page.component.spec.ts",
|
||||
"src/app/features/registry-admin/components/plan-audit.component.spec.ts",
|
||||
"src/app/features/registry-admin/registry-admin.component.spec.ts",
|
||||
"src/app/features/trust-admin/certificate-inventory.component.spec.ts",
|
||||
"src/app/features/trust-admin/issuer-trust-list.component.spec.ts",
|
||||
"src/app/features/trust-admin/trust-admin.component.spec.ts",
|
||||
"src/app/features/trust-admin/trust-analytics.component.spec.ts",
|
||||
"src/app/features/trust-admin/signing-key-dashboard.component.spec.ts",
|
||||
"src/app/features/triage/services/ttfs-telemetry.service.spec.ts",
|
||||
"src/app/features/triage/triage-workspace.component.spec.ts",
|
||||
"src/app/features/vex-hub/vex-hub-stats.component.spec.ts",
|
||||
"src/app/features/vex-hub/vex-hub-source-contract.spec.ts",
|
||||
"src/app/shared/ui/filter-bar/filter-bar.component.spec.ts",
|
||||
"src/app/features/watchlist/watchlist-page.component.spec.ts",
|
||||
"src/app/features/evidence-thread/__tests__/evidence-thread-view.component.spec.ts"
|
||||
"src/app/features/evidence-thread/__tests__/evidence-thread-view.component.spec.ts",
|
||||
"src/tests/settings/admin-settings-page.component.spec.ts"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user