Close admin trust audit gaps and stabilize live sweeps
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
# Sprint 20260311_014 - Platform Scratch Iteration 003 Full Route Action Audit
|
||||
|
||||
## Topic & Scope
|
||||
- Wipe the Stella-only local runtime again and rerun the documented setup path from zero state.
|
||||
- Re-test the rebuilt stack as a first-time operator with Playwright route and action coverage across the core release, security, ops, integration, and setup surfaces.
|
||||
- If this fresh-stack pass exposes real failures, trace root cause, choose clean fixes, implement them, redeploy, and reverify before the iteration is closed.
|
||||
- Working directory: `.`.
|
||||
- Expected evidence: Stella-only wipe log, documented setup execution proof, fresh Playwright route/action results, root-cause notes for any failures, and a local commit for the iteration.
|
||||
|
||||
## Dependencies & Concurrency
|
||||
- Depends on the clean iteration record in `a00efb7ab`.
|
||||
- Safe parallelism: none during the wipe/rebuild and live sweeps because the environment reset is global to the machine.
|
||||
|
||||
## Documentation Prerequisites
|
||||
- `AGENTS.md`
|
||||
- `docs/INSTALL_GUIDE.md`
|
||||
- `docs/dev/DEV_ENVIRONMENT_SETUP.md`
|
||||
- `docs/qa/feature-checks/FLOW.md`
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
### PLATFORM-SCRATCH-ITER3-001 - Wipe Stella-only runtime state and rerun documented setup
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: QA, 3rd line support
|
||||
Task description:
|
||||
- Remove Stella-only containers, images, volumes, and networks, then rerun the documented setup path from the same first-time operator entrypoint.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Stella-only Docker state is removed without touching unrelated local assets.
|
||||
- [x] `scripts/setup.ps1` is rerun from zero Stella state.
|
||||
- [x] The bootstrap outcome is captured with concrete evidence.
|
||||
|
||||
### PLATFORM-SCRATCH-ITER3-002 - Re-run live route and action sweeps on the fresh stack
|
||||
Status: DONE
|
||||
Dependency: PLATFORM-SCRATCH-ITER3-001
|
||||
Owners: QA
|
||||
Task description:
|
||||
- Re-authenticate on the rebuilt stack and rerun the route and action sweeps needed to validate page loads and user actions on the fresh deployment.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Fresh route sweep evidence is captured.
|
||||
- [x] Fresh action sweep evidence is captured for the covered surface families.
|
||||
- [x] Any newly exposed failures are enumerated before fixes begin.
|
||||
|
||||
### PLATFORM-SCRATCH-ITER3-003 - Root-cause and repair the next live failures
|
||||
Status: DONE
|
||||
Dependency: PLATFORM-SCRATCH-ITER3-002
|
||||
Owners: 3rd line support, Product Manager, Architect, Developer
|
||||
Task description:
|
||||
- Diagnose and fix any fresh-stack defects surfaced by the iteration. If no new defect is exposed, record the clean pass explicitly and close the iteration with a local commit.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Each exposed failure has a documented root cause, or the clean pass is explicitly recorded.
|
||||
- [x] Any required fix favors clean ownership/contracts over temporary fallbacks.
|
||||
- [x] The iteration is committed locally after re-verification.
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-11 | Sprint created to start scratch iteration 003 immediately after the previous clean zero-state pass. | QA |
|
||||
| 2026-03-12 | Cleansed Playwright output, reran the setup-topology and uncovered-surface sweeps, and verified both clean (`0` failed actions, `0` runtime issues) to remove stale harness noise before the aggregate pass. | QA |
|
||||
| 2026-03-12 | Ran `live-full-core-audit.mjs` across all 19 suites. First pass isolated one failing action in `ops-policy-action-sweep` (`/ops/policy/simulation -> button:View Results`) while every other route/page/action suite passed. | QA |
|
||||
| 2026-03-12 | Root-caused the policy simulation miss to a harness defect: multiple shadow-mode enable buttons exist during async load, and the sweep was selecting the first disabled control instead of an enabled action target. | 3rd line support / Architect |
|
||||
| 2026-03-12 | Updated `live-ops-policy-action-sweep.mjs` to wait for an enabled shadow-mode control and for `View Results` to become interactable, then reran the targeted policy sweep cleanly (`0` failed actions, `0` runtime issues). | Developer |
|
||||
| 2026-03-12 | Reran `live-full-core-audit.mjs`; final aggregate result was `19/19` suites passed with `failedSuiteCount=0`, including `111/111` canonical routes and all covered action families. | QA |
|
||||
|
||||
## Decisions & Risks
|
||||
- Decision: keep iterating from true zero Stella state even after a clean pass so regressions that appear only intermittently still have a chance to surface.
|
||||
- Risk: the documented setup path is expensive by design; correctness under wipe-and-rebuild remains the priority over speed.
|
||||
- Decision: treat broad Playwright harness reliability as part of the product verification contract. False negatives that stem from stale readiness assumptions or disabled-control races are fixed before declaring a route family broken.
|
||||
- Decision: the policy simulation `View Results` failure was not a product regression. The clean fix was to make the QA harness wait for the first enabled shadow-mode control rather than clicking the first matching label during async load.
|
||||
|
||||
## Next Checkpoints
|
||||
- Start the next zero-state iteration and repeat the full route/page/action pass before any new fixes.
|
||||
- Expand search-specific user journeys beyond the current 4-route matrix if fresh user reports expose ranking or handoff gaps.
|
||||
@@ -72,7 +72,7 @@ Role bundles are grouped by module and map to existing Authority scopes unless n
|
||||
| Module | Role bundle | Scopes |
|
||||
| --- | --- | --- |
|
||||
| Console | `role/console-viewer` | `ui.read` |
|
||||
| Console | `role/console-admin` | `ui.read`, `ui.admin`, `authority:tenants.read`, `authority:users.read`, `authority:roles.read`, `authority:clients.read`, `authority:tokens.read`, `authority:audit.read`, `authority:branding.read` |
|
||||
| Console | `role/console-admin` | `ui.read`, `ui.admin`, `authority:tenants.read`, `authority:tenants.write`, `authority:users.read`, `authority:users.write`, `authority:roles.read`, `authority:roles.write`, `authority:clients.read`, `authority:clients.write`, `authority:tokens.read`, `authority:tokens.revoke`, `authority.audit.read`, `authority:branding.read`, `authority:branding.write` |
|
||||
| Console | `role/console-superadmin` | `ui.read`, `ui.admin`, `authority:tenants.*`, `authority:users.*`, `authority:roles.*`, `authority:clients.*`, `authority:tokens.*`, `authority:audit.read`, `authority:branding.*` |
|
||||
| Scanner | `role/scanner-viewer` | `scanner:read`, `findings:read`, `aoc:verify` |
|
||||
| Scanner | `role/scanner-operator` | `scanner:read`, `scanner:scan`, `scanner:export`, `findings:read`, `aoc:verify` |
|
||||
@@ -155,7 +155,7 @@ Scanner scopes are not yet defined in Authority. They are proposed as `scanner:r
|
||||
|
||||
Scheduler scopes are not yet defined in Authority. They are proposed as `scheduler:read`, `scheduler:operate`, and `scheduler:admin` and must be added to Authority constants, discovery metadata, and gateway enforcement.
|
||||
|
||||
Authority admin scopes (partial): `authority:tenants.read` exists. Must add: `authority:tenants.write`, `authority:users.read`, `authority:users.write`, `authority:roles.read`, `authority:roles.write`, `authority:clients.read`, `authority:clients.write`, `authority:tokens.read`, `authority:tokens.revoke`, `authority:branding.read`, `authority:branding.write`.
|
||||
Authority admin scopes are now part of the local bootstrap console client and the seeded console-admin bundle: `authority:tenants.read|write`, `authority:users.read|write`, `authority:roles.read|write`, `authority:clients.read|write`, `authority:tokens.read|revoke`, `authority:branding.read|write`, and `authority.audit.read`.
|
||||
|
||||
UI admin scope: `ui.admin` must be added to Authority constants.
|
||||
|
||||
|
||||
@@ -31,6 +31,11 @@ Constraints:
|
||||
- Only `image/svg+xml`, `image/png`, or `image/jpeg` accepted.
|
||||
- Theme tokens restricted to a whitelist (no arbitrary CSS).
|
||||
|
||||
Persistence contract:
|
||||
- Authority persists branding under `authority.tenants.settings.consoleBranding`.
|
||||
- The stored record contains `displayName`, `logoUri`, `faviconUri`, `themeTokens`, `updatedAtUtc`, `updatedBy`, and `hash`.
|
||||
- `GET /console/branding` and `GET /console/admin/branding` must read the persisted record first and only fall back to static defaults when no tenant record exists yet.
|
||||
|
||||
## 4. Configuration Layering
|
||||
1. **Static defaults** from `/config.json`.
|
||||
2. **Tenant branding** from Authority after login.
|
||||
|
||||
@@ -24,6 +24,7 @@ using StellaOps.Authority.Persistence.InMemory.Stores;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Authority.Persistence.Sessions;
|
||||
using StellaOps.Authority.Tenants;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using Xunit;
|
||||
|
||||
@@ -119,6 +120,250 @@ public sealed class ConsoleAdminEndpointsTests
|
||||
Assert.Contains(listed!.Users, static user => user.Username == "alice");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateUser_WithInvalidEmail_ReturnsBadRequest()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 2, 20, 13, 30, 0, TimeSpan.Zero);
|
||||
var timeProvider = new FakeTimeProvider(now);
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var users = new InMemoryUserRepository();
|
||||
|
||||
await using var app = await CreateApplicationAsync(timeProvider, sink, users);
|
||||
|
||||
var principalAccessor = app.Services.GetRequiredService<AdminTestPrincipalAccessor>();
|
||||
principalAccessor.Principal = CreatePrincipal(
|
||||
tenant: "default",
|
||||
scopes: new[] { StellaOpsScopes.UiAdmin, 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 response = await client.PostAsJsonAsync(
|
||||
"/console/admin/users",
|
||||
new
|
||||
{
|
||||
username = "qa-invalid-email",
|
||||
email = "not-an-email",
|
||||
displayName = "QA Invalid Email",
|
||||
roles = new[] { "role/console-admin" }
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var payload = await response.Content.ReadFromJsonAsync<ErrorPayload>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal("invalid_email", payload!.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RolesList_ExposesNamedDefaults_AndCreateRolePersists()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 2, 20, 13, 45, 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.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 listResponse = await client.GetAsync("/console/admin/roles");
|
||||
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
|
||||
var listed = await listResponse.Content.ReadFromJsonAsync<RoleListPayload>();
|
||||
Assert.NotNull(listed);
|
||||
Assert.Contains(listed!.Roles, role => role.Name == "role/console-admin");
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/console/admin/roles",
|
||||
new
|
||||
{
|
||||
roleId = "security-analyst",
|
||||
displayName = "Security Analyst",
|
||||
scopes = new[] { "findings:read", "vex:read" }
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<RoleSummary>();
|
||||
Assert.NotNull(created);
|
||||
Assert.Equal("security-analyst", created!.Name);
|
||||
Assert.Equal(2, created.Permissions.Count);
|
||||
|
||||
var reloadResponse = await client.GetAsync("/console/admin/roles");
|
||||
Assert.Equal(HttpStatusCode.OK, reloadResponse.StatusCode);
|
||||
var reloaded = await reloadResponse.Content.ReadFromJsonAsync<RoleListPayload>();
|
||||
Assert.NotNull(reloaded);
|
||||
Assert.Contains(reloaded!.Roles, role => role.Name == "security-analyst");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TenantsList_MergesCatalog_AndCreateTenantPersists()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 2, 20, 14, 15, 0, TimeSpan.Zero);
|
||||
var timeProvider = new FakeTimeProvider(now);
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var users = new InMemoryUserRepository();
|
||||
var tenants = new InMemoryTenantRepository();
|
||||
var tenantCatalog = new FakeTenantCatalog(
|
||||
[
|
||||
new AuthorityTenantView("default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>())
|
||||
]);
|
||||
|
||||
await using var app = await CreateApplicationAsync(
|
||||
timeProvider,
|
||||
sink,
|
||||
users,
|
||||
tenantRepository: tenants,
|
||||
tenantCatalog: tenantCatalog);
|
||||
|
||||
var principalAccessor = app.Services.GetRequiredService<AdminTestPrincipalAccessor>();
|
||||
principalAccessor.Principal = CreatePrincipal(
|
||||
tenant: "default",
|
||||
scopes: new[]
|
||||
{
|
||||
StellaOpsScopes.UiAdmin,
|
||||
StellaOpsScopes.AuthorityTenantsRead,
|
||||
StellaOpsScopes.AuthorityTenantsWrite,
|
||||
},
|
||||
expiresAt: now.AddMinutes(10));
|
||||
|
||||
using var client = CreateTestClient(app);
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(AdminAuthenticationDefaults.AuthenticationScheme);
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "default");
|
||||
|
||||
var listResponse = await client.GetAsync("/console/admin/tenants");
|
||||
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
|
||||
var listed = await listResponse.Content.ReadFromJsonAsync<TenantListPayload>();
|
||||
Assert.NotNull(listed);
|
||||
Assert.Contains(listed!.Tenants, tenant => tenant.Id == "default");
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/console/admin/tenants",
|
||||
new
|
||||
{
|
||||
id = "customer-stage",
|
||||
displayName = "Customer Stage",
|
||||
isolationMode = "dedicated"
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<TenantSummary>();
|
||||
Assert.NotNull(created);
|
||||
Assert.Equal("customer-stage", created!.Id);
|
||||
Assert.Equal("dedicated", created.IsolationMode);
|
||||
|
||||
var reloadResponse = await client.GetAsync("/console/admin/tenants");
|
||||
Assert.Equal(HttpStatusCode.OK, reloadResponse.StatusCode);
|
||||
var reloaded = await reloadResponse.Content.ReadFromJsonAsync<TenantListPayload>();
|
||||
Assert.NotNull(reloaded);
|
||||
Assert.Contains(reloaded!.Tenants, tenant => tenant.Id == "customer-stage");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BrandingEndpoints_PersistTenantBrandingInTenantSettings()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 2, 20, 14, 45, 0, TimeSpan.Zero);
|
||||
var timeProvider = new FakeTimeProvider(now);
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var users = new InMemoryUserRepository();
|
||||
var tenants = new InMemoryTenantRepository();
|
||||
await tenants.CreateAsync(new TenantEntity
|
||||
{
|
||||
Id = Guid.Parse("3ad551dc-e826-4ee0-81f7-83d77e0d4903"),
|
||||
Slug = "default",
|
||||
Name = "Default",
|
||||
Description = "Default tenant",
|
||||
ContactEmail = "admin@stella-ops.local",
|
||||
Enabled = true,
|
||||
Settings = "{}",
|
||||
Metadata = "{}",
|
||||
CreatedAt = now.AddDays(-10),
|
||||
UpdatedAt = now.AddDays(-1),
|
||||
CreatedBy = "seed",
|
||||
});
|
||||
|
||||
await using var app = await CreateApplicationAsync(
|
||||
timeProvider,
|
||||
sink,
|
||||
users,
|
||||
tenantRepository: tenants,
|
||||
tenantCatalog: new FakeTenantCatalog([
|
||||
new AuthorityTenantView("default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>())
|
||||
]));
|
||||
|
||||
var principalAccessor = app.Services.GetRequiredService<AdminTestPrincipalAccessor>();
|
||||
principalAccessor.Principal = CreatePrincipal(
|
||||
tenant: "default",
|
||||
scopes: new[]
|
||||
{
|
||||
StellaOpsScopes.UiAdmin,
|
||||
StellaOpsScopes.AuthorityBrandingRead,
|
||||
StellaOpsScopes.AuthorityBrandingWrite,
|
||||
},
|
||||
expiresAt: now.AddMinutes(10));
|
||||
|
||||
using var client = CreateTestClient(app);
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(AdminAuthenticationDefaults.AuthenticationScheme);
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "default");
|
||||
|
||||
var updateResponse = await client.PutAsJsonAsync(
|
||||
"/console/admin/branding",
|
||||
new
|
||||
{
|
||||
displayName = "Acme Ops",
|
||||
logoUri = "data:image/svg+xml;base64,PHN2Zy8+",
|
||||
faviconUri = (string?)null,
|
||||
themeTokens = new Dictionary<string, string>
|
||||
{
|
||||
["--theme-brand-primary"] = "#123456"
|
||||
}
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);
|
||||
var updatePayload = await updateResponse.Content.ReadFromJsonAsync<AdminBrandingPayload>();
|
||||
Assert.NotNull(updatePayload);
|
||||
Assert.Equal("Acme Ops", updatePayload!.Branding.DisplayName);
|
||||
Assert.Equal("#123456", updatePayload.Branding.ThemeTokens["--theme-brand-primary"]);
|
||||
Assert.False(string.IsNullOrWhiteSpace(updatePayload.Metadata?.Hash));
|
||||
|
||||
var adminReadResponse = await client.GetAsync("/console/admin/branding");
|
||||
Assert.Equal(HttpStatusCode.OK, adminReadResponse.StatusCode);
|
||||
var adminReadPayload = await adminReadResponse.Content.ReadFromJsonAsync<AdminBrandingPayload>();
|
||||
Assert.NotNull(adminReadPayload);
|
||||
Assert.Equal("Acme Ops", adminReadPayload!.Branding.DisplayName);
|
||||
|
||||
var publicReadResponse = await client.GetAsync("/console/branding?tenantId=default");
|
||||
Assert.Equal(HttpStatusCode.OK, publicReadResponse.StatusCode);
|
||||
var publicBranding = await publicReadResponse.Content.ReadFromJsonAsync<BrandingSummary>();
|
||||
Assert.NotNull(publicBranding);
|
||||
Assert.Equal("Acme Ops", publicBranding!.DisplayName);
|
||||
|
||||
var persistedTenant = await tenants.GetBySlugAsync("default");
|
||||
Assert.NotNull(persistedTenant);
|
||||
Assert.Contains("consoleBranding", persistedTenant!.Settings);
|
||||
Assert.Contains("Acme Ops", persistedTenant.Settings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyApiAlias_UsersListAndCreate_WorkForApiAdminPath()
|
||||
{
|
||||
@@ -410,7 +655,11 @@ public sealed class ConsoleAdminEndpointsTests
|
||||
FakeTimeProvider timeProvider,
|
||||
RecordingAuthEventSink sink,
|
||||
IUserRepository userRepository,
|
||||
IAuthorityClientStore? clientStore = null)
|
||||
IAuthorityClientStore? clientStore = null,
|
||||
IRoleRepository? roleRepository = null,
|
||||
IPermissionRepository? permissionRepository = null,
|
||||
ITenantRepository? tenantRepository = null,
|
||||
IAuthorityTenantCatalog? tenantCatalog = null)
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
|
||||
{
|
||||
@@ -423,6 +672,10 @@ public sealed class ConsoleAdminEndpointsTests
|
||||
builder.Services.AddSingleton<IAuthEventSink>(sink);
|
||||
builder.Services.AddSingleton(userRepository);
|
||||
builder.Services.AddSingleton<IAuthorityClientStore>(clientStore ?? new InMemoryClientStore());
|
||||
builder.Services.AddSingleton<IRoleRepository>(roleRepository ?? new InMemoryRoleRepository());
|
||||
builder.Services.AddSingleton<IPermissionRepository>(permissionRepository ?? new InMemoryPermissionRepository());
|
||||
builder.Services.AddSingleton<ITenantRepository>(tenantRepository ?? new InMemoryTenantRepository());
|
||||
builder.Services.AddSingleton<IAuthorityTenantCatalog>(tenantCatalog ?? new FakeTenantCatalog([]));
|
||||
builder.Services.AddSingleton<AdminTestPrincipalAccessor>();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddSingleton<StellaOpsBypassEvaluator>();
|
||||
@@ -454,6 +707,7 @@ public sealed class ConsoleAdminEndpointsTests
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapConsoleAdminEndpoints();
|
||||
app.MapConsoleBrandingEndpoints();
|
||||
await app.StartAsync();
|
||||
return app;
|
||||
}
|
||||
@@ -691,7 +945,296 @@ public sealed class ConsoleAdminEndpointsTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryRoleRepository : IRoleRepository
|
||||
{
|
||||
private readonly object sync = new();
|
||||
private readonly List<RoleEntity> roles = new();
|
||||
private readonly List<UserRoleEntity> assignments = new();
|
||||
|
||||
public Task<RoleEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
return Task.FromResult(roles.FirstOrDefault(role => role.TenantId == tenantId && role.Id == id));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<RoleEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
return Task.FromResult(roles.FirstOrDefault(role =>
|
||||
role.TenantId == tenantId &&
|
||||
string.Equals(role.Name, name, StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<RoleEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<RoleEntity>>(roles.Where(role => role.TenantId == tenantId).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<RoleEntity>> GetUserRolesAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
var assignedIds = assignments
|
||||
.Where(assignment => assignment.UserId == userId && (!assignment.ExpiresAt.HasValue || assignment.ExpiresAt > DateTimeOffset.UtcNow))
|
||||
.Select(assignment => assignment.RoleId)
|
||||
.ToHashSet();
|
||||
return Task.FromResult<IReadOnlyList<RoleEntity>>(roles
|
||||
.Where(role => role.TenantId == tenantId && assignedIds.Contains(role.Id))
|
||||
.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
public Task<Guid> CreateAsync(string tenantId, RoleEntity role, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
roles.Add(role);
|
||||
return Task.FromResult(role.Id);
|
||||
}
|
||||
}
|
||||
|
||||
public Task UpdateAsync(string tenantId, RoleEntity role, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
var index = roles.FindIndex(existing => existing.TenantId == tenantId && existing.Id == role.Id);
|
||||
if (index >= 0)
|
||||
{
|
||||
roles[index] = role;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
roles.RemoveAll(role => role.TenantId == tenantId && role.Id == id);
|
||||
assignments.RemoveAll(assignment => assignment.RoleId == id);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public Task AssignToUserAsync(string tenantId, Guid userId, Guid roleId, string? grantedBy, DateTimeOffset? expiresAt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
assignments.RemoveAll(assignment => assignment.UserId == userId && assignment.RoleId == roleId);
|
||||
assignments.Add(new UserRoleEntity
|
||||
{
|
||||
UserId = userId,
|
||||
RoleId = roleId,
|
||||
GrantedAt = DateTimeOffset.UtcNow,
|
||||
GrantedBy = grantedBy,
|
||||
ExpiresAt = expiresAt,
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public Task RemoveFromUserAsync(string tenantId, Guid userId, Guid roleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
assignments.RemoveAll(assignment => assignment.UserId == userId && assignment.RoleId == roleId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryPermissionRepository : IPermissionRepository
|
||||
{
|
||||
private readonly object sync = new();
|
||||
private readonly List<PermissionEntity> permissions = new();
|
||||
private readonly List<RolePermissionEntity> assignments = new();
|
||||
|
||||
public Task<PermissionEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
return Task.FromResult(permissions.FirstOrDefault(permission => permission.TenantId == tenantId && permission.Id == id));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<PermissionEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
return Task.FromResult(permissions.FirstOrDefault(permission =>
|
||||
permission.TenantId == tenantId &&
|
||||
string.Equals(permission.Name, name, StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PermissionEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<PermissionEntity>>(permissions.Where(permission => permission.TenantId == tenantId).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PermissionEntity>> GetByResourceAsync(string tenantId, string resource, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<PermissionEntity>>(permissions
|
||||
.Where(permission => permission.TenantId == tenantId && permission.Resource == resource)
|
||||
.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PermissionEntity>> GetRolePermissionsAsync(string tenantId, Guid roleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
var permissionIds = assignments.Where(assignment => assignment.RoleId == roleId).Select(assignment => assignment.PermissionId).ToHashSet();
|
||||
return Task.FromResult<IReadOnlyList<PermissionEntity>>(permissions
|
||||
.Where(permission => permission.TenantId == tenantId && permissionIds.Contains(permission.Id))
|
||||
.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PermissionEntity>> GetUserPermissionsAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<PermissionEntity>>(Array.Empty<PermissionEntity>());
|
||||
|
||||
public Task<Guid> CreateAsync(string tenantId, PermissionEntity permission, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
permissions.Add(permission);
|
||||
return Task.FromResult(permission.Id);
|
||||
}
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
permissions.RemoveAll(permission => permission.TenantId == tenantId && permission.Id == id);
|
||||
assignments.RemoveAll(assignment => assignment.PermissionId == id);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public Task AssignToRoleAsync(string tenantId, Guid roleId, Guid permissionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
assignments.RemoveAll(assignment => assignment.RoleId == roleId && assignment.PermissionId == permissionId);
|
||||
assignments.Add(new RolePermissionEntity
|
||||
{
|
||||
RoleId = roleId,
|
||||
PermissionId = permissionId,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public Task RemoveFromRoleAsync(string tenantId, Guid roleId, Guid permissionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
assignments.RemoveAll(assignment => assignment.RoleId == roleId && assignment.PermissionId == permissionId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryTenantRepository : ITenantRepository
|
||||
{
|
||||
private readonly object sync = new();
|
||||
private readonly List<TenantEntity> tenants = new();
|
||||
|
||||
public Task<TenantEntity> CreateAsync(TenantEntity tenant, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
tenants.Add(tenant);
|
||||
return Task.FromResult(tenant);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<TenantEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
return Task.FromResult(tenants.FirstOrDefault(tenant => tenant.Id == id));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<TenantEntity?> GetBySlugAsync(string slug, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
return Task.FromResult(tenants.FirstOrDefault(tenant => string.Equals(tenant.Slug, slug, StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<TenantEntity>> GetAllAsync(bool? enabled = null, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
IEnumerable<TenantEntity> query = tenants;
|
||||
if (enabled.HasValue)
|
||||
{
|
||||
query = query.Where(tenant => tenant.Enabled == enabled.Value);
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<TenantEntity>>(query.Skip(offset).Take(limit).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> UpdateAsync(TenantEntity tenant, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
var index = tenants.FindIndex(existing => existing.Id == tenant.Id);
|
||||
if (index < 0)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
tenants[index] = tenant;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
return Task.FromResult(tenants.RemoveAll(tenant => tenant.Id == id) > 0);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> SlugExistsAsync(string slug, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
return Task.FromResult(tenants.Any(tenant => string.Equals(tenant.Slug, slug, StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeTenantCatalog(params IReadOnlyList<AuthorityTenantView> tenants) : IAuthorityTenantCatalog
|
||||
{
|
||||
public IReadOnlyList<AuthorityTenantView> GetTenants() => tenants;
|
||||
}
|
||||
|
||||
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 ClientListPayload(IReadOnlyList<ClientSummary> Clients, int Count, string SelectedTenant);
|
||||
private sealed record ClientSummary(
|
||||
string ClientId,
|
||||
@@ -703,6 +1246,34 @@ public sealed class ConsoleAdminEndpointsTests
|
||||
IReadOnlyList<string> AllowedScopes,
|
||||
DateTimeOffset UpdatedAt);
|
||||
private sealed record ErrorPayload(string Error, string? Message);
|
||||
private sealed record RoleSummary(
|
||||
string Id,
|
||||
string Name,
|
||||
string Description,
|
||||
IReadOnlyList<string> Permissions,
|
||||
int UserCount,
|
||||
bool IsBuiltIn);
|
||||
private sealed record TenantSummary(
|
||||
string Id,
|
||||
string DisplayName,
|
||||
string Status,
|
||||
string IsolationMode,
|
||||
int UserCount,
|
||||
DateTimeOffset CreatedAt);
|
||||
private sealed record BrandingSummary(
|
||||
string TenantId,
|
||||
string DisplayName,
|
||||
string? LogoUri,
|
||||
string? FaviconUri,
|
||||
IReadOnlyDictionary<string, string> ThemeTokens);
|
||||
private sealed record BrandingMetadataPayload(
|
||||
string TenantId,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
string UpdatedBy,
|
||||
string Hash);
|
||||
private sealed record AdminBrandingPayload(
|
||||
BrandingSummary Branding,
|
||||
BrandingMetadataPayload? Metadata);
|
||||
|
||||
private sealed record UserSummary(
|
||||
string Id,
|
||||
|
||||
@@ -14,6 +14,7 @@ using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Tenants;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
@@ -26,6 +27,7 @@ namespace StellaOps.Authority.Console.Admin;
|
||||
|
||||
internal static class ConsoleAdminEndpointExtensions
|
||||
{
|
||||
private static readonly EmailAddressAttribute EmailValidator = new();
|
||||
private static readonly Regex TenantIdPattern = new(
|
||||
"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
@@ -202,11 +204,45 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
private static async Task<IResult> ListTenants(
|
||||
HttpContext httpContext,
|
||||
[FromServices] IAuthorityTenantCatalog tenantCatalog,
|
||||
ITenantRepository tenantRepository,
|
||||
IUserRepository userRepository,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenants = tenantCatalog.GetTenants();
|
||||
var configuredTenants = tenantCatalog.GetTenants()
|
||||
.ToDictionary(static tenant => tenant.Id, StringComparer.OrdinalIgnoreCase);
|
||||
var persistedTenants = await tenantRepository.GetAllAsync(
|
||||
enabled: null,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var tenantIds = configuredTenants.Keys
|
||||
.Concat(persistedTenants.Select(static tenant => tenant.Slug))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static tenantId => tenantId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var summaries = new List<AdminTenantSummary>(tenantIds.Length);
|
||||
foreach (var tenantId in tenantIds)
|
||||
{
|
||||
configuredTenants.TryGetValue(tenantId, out var configuredTenant);
|
||||
var persistedTenant = persistedTenants.FirstOrDefault(tenant =>
|
||||
string.Equals(tenant.Slug, tenantId, StringComparison.OrdinalIgnoreCase));
|
||||
var users = await userRepository.GetAllAsync(
|
||||
tenantId,
|
||||
enabled: null,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
summaries.Add(ToAdminTenantSummary(
|
||||
tenantId,
|
||||
configuredTenant,
|
||||
persistedTenant,
|
||||
users.Count));
|
||||
}
|
||||
|
||||
await WriteAdminAuditAsync(
|
||||
httpContext,
|
||||
@@ -215,16 +251,16 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
"authority.admin.tenants.list",
|
||||
AuthEventOutcome.Success,
|
||||
null,
|
||||
BuildProperties(("count", tenants.Count.ToString(CultureInfo.InvariantCulture))),
|
||||
BuildProperties(("count", summaries.Count.ToString(CultureInfo.InvariantCulture))),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new { tenants });
|
||||
return Results.Ok(new { tenants = summaries, count = summaries.Count });
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateTenant(
|
||||
HttpContext httpContext,
|
||||
CreateTenantRequest request,
|
||||
[FromServices] IAuthorityTenantCatalog tenantCatalog,
|
||||
ITenantRepository tenantRepository,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -234,7 +270,43 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
return Results.BadRequest(new { error = "invalid_request", message = "Tenant ID is required." });
|
||||
}
|
||||
|
||||
// Placeholder: actual implementation would create tenant in storage
|
||||
var normalizedTenantId = request.Id.Trim().ToLowerInvariant();
|
||||
if (!TenantIdPattern.IsMatch(normalizedTenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "invalid_tenant_id", message = "Tenant ID must use lowercase letters, digits, and hyphens only." });
|
||||
}
|
||||
|
||||
var existing = await tenantRepository.GetBySlugAsync(normalizedTenantId, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is not null)
|
||||
{
|
||||
return Results.Conflict(new { error = "tenant_already_exists", tenantId = normalizedTenantId });
|
||||
}
|
||||
|
||||
var createdBy = httpContext.User.FindFirstValue(OpenIddictConstants.Claims.PreferredUsername)
|
||||
?? httpContext.User.FindFirstValue(StellaOpsClaimTypes.Subject)
|
||||
?? "console-admin";
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var created = await tenantRepository.CreateAsync(new TenantEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Slug = normalizedTenantId,
|
||||
Name = string.IsNullOrWhiteSpace(request.DisplayName) ? normalizedTenantId : request.DisplayName.Trim(),
|
||||
Description = string.IsNullOrWhiteSpace(request.DisplayName) ? normalizedTenantId : request.DisplayName.Trim(),
|
||||
Enabled = true,
|
||||
Metadata = JsonSerializer.Serialize(new Dictionary<string, object?>
|
||||
{
|
||||
["isolationMode"] = NormalizeIsolationMode(request.IsolationMode),
|
||||
}),
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
CreatedBy = createdBy,
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
var summary = ToAdminTenantSummary(
|
||||
normalizedTenantId,
|
||||
configuredTenant: null,
|
||||
created,
|
||||
userCount: 0);
|
||||
|
||||
await WriteAdminAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
@@ -242,20 +314,44 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
"authority.admin.tenants.create",
|
||||
AuthEventOutcome.Success,
|
||||
null,
|
||||
BuildProperties(("tenant.id", request.Id)),
|
||||
BuildProperties(("tenant.id", normalizedTenantId)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created($"/console/admin/tenants/{request.Id}", new { tenantId = request.Id, message = "Tenant creation: implementation pending" });
|
||||
return Results.Created($"/console/admin/tenants/{normalizedTenantId}", summary);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateTenant(
|
||||
HttpContext httpContext,
|
||||
string tenantId,
|
||||
UpdateTenantRequest request,
|
||||
ITenantRepository tenantRepository,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await tenantRepository.GetBySlugAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "tenant_not_found", tenantId });
|
||||
}
|
||||
|
||||
var updated = new TenantEntity
|
||||
{
|
||||
Id = existing.Id,
|
||||
Slug = existing.Slug,
|
||||
Name = string.IsNullOrWhiteSpace(request.DisplayName) ? existing.Name : request.DisplayName.Trim(),
|
||||
Description = string.IsNullOrWhiteSpace(request.DisplayName) ? existing.Description : request.DisplayName.Trim(),
|
||||
ContactEmail = existing.ContactEmail,
|
||||
Enabled = existing.Enabled,
|
||||
Settings = existing.Settings,
|
||||
Metadata = UpdateTenantMetadata(existing.Metadata, request.IsolationMode),
|
||||
CreatedAt = existing.CreatedAt,
|
||||
UpdatedAt = timeProvider.GetUtcNow(),
|
||||
CreatedBy = existing.CreatedBy,
|
||||
};
|
||||
|
||||
await tenantRepository.UpdateAsync(updated, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await WriteAdminAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
@@ -266,16 +362,39 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
BuildProperties(("tenant.id", tenantId)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new { message = "Tenant update: implementation pending" });
|
||||
return Results.Ok(ToAdminTenantSummary(tenantId, configuredTenant: null, updated, userCount: 0));
|
||||
}
|
||||
|
||||
private static async Task<IResult> SuspendTenant(
|
||||
HttpContext httpContext,
|
||||
string tenantId,
|
||||
ITenantRepository tenantRepository,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await tenantRepository.GetBySlugAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "tenant_not_found", tenantId });
|
||||
}
|
||||
|
||||
var updated = new TenantEntity
|
||||
{
|
||||
Id = existing.Id,
|
||||
Slug = existing.Slug,
|
||||
Name = existing.Name,
|
||||
Description = existing.Description,
|
||||
ContactEmail = existing.ContactEmail,
|
||||
Enabled = false,
|
||||
Settings = existing.Settings,
|
||||
Metadata = existing.Metadata,
|
||||
CreatedAt = existing.CreatedAt,
|
||||
UpdatedAt = timeProvider.GetUtcNow(),
|
||||
CreatedBy = existing.CreatedBy,
|
||||
};
|
||||
await tenantRepository.UpdateAsync(updated, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await WriteAdminAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
@@ -286,16 +405,39 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
BuildProperties(("tenant.id", tenantId)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new { message = "Tenant suspension: implementation pending" });
|
||||
return Results.Ok(ToAdminTenantSummary(tenantId, configuredTenant: null, updated, userCount: 0));
|
||||
}
|
||||
|
||||
private static async Task<IResult> ResumeTenant(
|
||||
HttpContext httpContext,
|
||||
string tenantId,
|
||||
ITenantRepository tenantRepository,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await tenantRepository.GetBySlugAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "tenant_not_found", tenantId });
|
||||
}
|
||||
|
||||
var updated = new TenantEntity
|
||||
{
|
||||
Id = existing.Id,
|
||||
Slug = existing.Slug,
|
||||
Name = existing.Name,
|
||||
Description = existing.Description,
|
||||
ContactEmail = existing.ContactEmail,
|
||||
Enabled = true,
|
||||
Settings = existing.Settings,
|
||||
Metadata = existing.Metadata,
|
||||
CreatedAt = existing.CreatedAt,
|
||||
UpdatedAt = timeProvider.GetUtcNow(),
|
||||
CreatedBy = existing.CreatedBy,
|
||||
};
|
||||
await tenantRepository.UpdateAsync(updated, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await WriteAdminAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
@@ -306,7 +448,7 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
BuildProperties(("tenant.id", tenantId)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new { message = "Tenant resume: implementation pending" });
|
||||
return Results.Ok(ToAdminTenantSummary(tenantId, configuredTenant: null, updated, userCount: 0));
|
||||
}
|
||||
|
||||
// ========== USER ENDPOINTS ==========
|
||||
@@ -314,6 +456,7 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
private static async Task<IResult> ListUsers(
|
||||
HttpContext httpContext,
|
||||
IUserRepository userRepository,
|
||||
IRoleRepository roleRepository,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -326,10 +469,12 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
offset: 0,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var userSummaries = users
|
||||
.OrderBy(static user => user.Username, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(user => ToAdminUserSummary(user, timeProvider.GetUtcNow()))
|
||||
.ToList();
|
||||
var userSummaries = new List<AdminUserSummary>(users.Count);
|
||||
foreach (var user in users.OrderBy(static user => user.Username, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var roles = await ResolveUserRolesAsync(user, roleRepository, cancellationToken).ConfigureAwait(false);
|
||||
userSummaries.Add(ToAdminUserSummary(user, timeProvider.GetUtcNow(), roles));
|
||||
}
|
||||
|
||||
await WriteAdminAuditAsync(
|
||||
httpContext,
|
||||
@@ -348,6 +493,7 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
HttpContext httpContext,
|
||||
CreateUserRequest request,
|
||||
IUserRepository userRepository,
|
||||
IRoleRepository roleRepository,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -363,6 +509,11 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
var normalizedUsername = request.Username.Trim().ToLowerInvariant();
|
||||
var normalizedEmail = request.Email.Trim();
|
||||
|
||||
if (!EmailValidator.IsValid(normalizedEmail))
|
||||
{
|
||||
return Results.BadRequest(new { error = "invalid_email", email = normalizedEmail });
|
||||
}
|
||||
|
||||
var existing = await userRepository.GetByUsernameAsync(tenantId, normalizedUsername, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (existing is not null)
|
||||
@@ -370,6 +521,13 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
return Results.Conflict(new { error = "user_already_exists", username = normalizedUsername });
|
||||
}
|
||||
|
||||
var existingEmail = await userRepository.GetByEmailAsync(tenantId, normalizedEmail, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (existingEmail is not null)
|
||||
{
|
||||
return Results.Conflict(new { error = "email_already_exists", email = normalizedEmail });
|
||||
}
|
||||
|
||||
var normalizedRoles = NormalizeRoles(request.Roles);
|
||||
var metadata = new Dictionary<string, object?>
|
||||
{
|
||||
@@ -399,7 +557,16 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
};
|
||||
|
||||
var created = await userRepository.CreateAsync(newUser, cancellationToken).ConfigureAwait(false);
|
||||
var createdSummary = ToAdminUserSummary(created, timeProvider.GetUtcNow());
|
||||
foreach (var roleName in normalizedRoles)
|
||||
{
|
||||
var role = await roleRepository.GetByNameAsync(tenantId, roleName, cancellationToken).ConfigureAwait(false);
|
||||
if (role is not null)
|
||||
{
|
||||
await roleRepository.AssignToUserAsync(tenantId, created.Id, role.Id, createdBy, expiresAt: null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
var createdSummary = ToAdminUserSummary(created, timeProvider.GetUtcNow(), normalizedRoles);
|
||||
|
||||
await WriteAdminAuditAsync(
|
||||
httpContext,
|
||||
@@ -487,10 +654,50 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
|
||||
private static async Task<IResult> ListRoles(
|
||||
HttpContext httpContext,
|
||||
IRoleRepository roleRepository,
|
||||
IPermissionRepository permissionRepository,
|
||||
IUserRepository userRepository,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var roles = await roleRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var users = await userRepository.GetAllAsync(
|
||||
tenantId,
|
||||
enabled: null,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var roleCounts = await BuildRoleCountMapAsync(tenantId, users, roleRepository, cancellationToken).ConfigureAwait(false);
|
||||
var summaries = new List<AdminRoleSummary>();
|
||||
|
||||
foreach (var role in roles.OrderBy(static role => role.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var permissions = await permissionRepository.GetRolePermissionsAsync(tenantId, role.Id, cancellationToken).ConfigureAwait(false);
|
||||
var roleName = string.IsNullOrWhiteSpace(role.Name)
|
||||
? role.DisplayName ?? role.Id.ToString("N")
|
||||
: role.Name;
|
||||
summaries.Add(new AdminRoleSummary(
|
||||
Id: role.Id.ToString("N"),
|
||||
Name: roleName,
|
||||
Description: string.IsNullOrWhiteSpace(role.Description) ? role.DisplayName ?? roleName : role.Description!,
|
||||
Permissions: permissions.Select(static permission => permission.Name).OrderBy(static scope => scope, StringComparer.OrdinalIgnoreCase).ToArray(),
|
||||
UserCount: roleCounts.TryGetValue(roleName, out var count) ? count : 0,
|
||||
IsBuiltIn: role.IsSystem));
|
||||
}
|
||||
|
||||
if (summaries.Count == 0)
|
||||
{
|
||||
summaries.AddRange(GetDefaultRoles().Select(static role => new AdminRoleSummary(
|
||||
Id: role.RoleId,
|
||||
Name: role.RoleId,
|
||||
Description: role.DisplayName,
|
||||
Permissions: role.Scopes,
|
||||
UserCount: 0,
|
||||
IsBuiltIn: true)));
|
||||
}
|
||||
|
||||
await WriteAdminAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
@@ -498,19 +705,80 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
"authority.admin.roles.list",
|
||||
AuthEventOutcome.Success,
|
||||
null,
|
||||
Array.Empty<AuthEventProperty>(),
|
||||
BuildProperties(("tenant.id", tenantId), ("roles.count", summaries.Count.ToString(CultureInfo.InvariantCulture))),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new { roles = GetDefaultRoles(), message = "Role list: using default catalog" });
|
||||
return Results.Ok(new { roles = summaries, count = summaries.Count });
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateRole(
|
||||
HttpContext httpContext,
|
||||
CreateRoleRequest request,
|
||||
IRoleRepository roleRepository,
|
||||
IPermissionRepository permissionRepository,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request is null || string.IsNullOrWhiteSpace(request.RoleId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "role_id_required" });
|
||||
}
|
||||
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var normalizedRoleId = request.RoleId.Trim().ToLowerInvariant();
|
||||
var existing = await roleRepository.GetByNameAsync(tenantId, normalizedRoleId, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is not null)
|
||||
{
|
||||
return Results.Conflict(new { error = "role_already_exists", roleId = normalizedRoleId });
|
||||
}
|
||||
|
||||
var normalizedScopes = NormalizeScopes(request.Scopes);
|
||||
if (normalizedScopes.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = "scopes_required" });
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var roleId = Guid.NewGuid();
|
||||
await roleRepository.CreateAsync(tenantId, new RoleEntity
|
||||
{
|
||||
Id = roleId,
|
||||
TenantId = tenantId,
|
||||
Name = normalizedRoleId,
|
||||
DisplayName = request.DisplayName?.Trim(),
|
||||
Description = request.DisplayName?.Trim(),
|
||||
IsSystem = false,
|
||||
Metadata = "{}",
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var scope in normalizedScopes)
|
||||
{
|
||||
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 created scope '{scope}'.",
|
||||
CreatedAt = now,
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await permissionRepository.AssignToRoleAsync(tenantId, roleId, permissionId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var summary = new AdminRoleSummary(
|
||||
Id: roleId.ToString("N"),
|
||||
Name: normalizedRoleId,
|
||||
Description: string.IsNullOrWhiteSpace(request.DisplayName) ? normalizedRoleId : request.DisplayName.Trim(),
|
||||
Permissions: normalizedScopes,
|
||||
UserCount: 0,
|
||||
IsBuiltIn: false);
|
||||
|
||||
await WriteAdminAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
@@ -518,10 +786,10 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
"authority.admin.roles.create",
|
||||
AuthEventOutcome.Success,
|
||||
null,
|
||||
BuildProperties(("role.id", request?.RoleId ?? "unknown")),
|
||||
BuildProperties(("tenant.id", tenantId), ("role.id", normalizedRoleId)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created("/console/admin/roles/new", new { message = "Role creation: implementation pending" });
|
||||
return Results.Created($"/console/admin/roles/{normalizedRoleId}", summary);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateRole(
|
||||
@@ -1007,6 +1275,9 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeScopes(IReadOnlyList<string>? scopes)
|
||||
=> NormalizeValues(scopes);
|
||||
|
||||
private static IReadOnlyList<string> NormalizeValues(IReadOnlyList<string>? values)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
@@ -1294,7 +1565,119 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
private static string? NormalizeOptional(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static string NormalizeIsolationMode(string? isolationMode)
|
||||
{
|
||||
var normalized = isolationMode?.Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
null or "" => "shared",
|
||||
"shared" => "shared",
|
||||
"dedicated" => "dedicated",
|
||||
_ => normalized!,
|
||||
};
|
||||
}
|
||||
|
||||
private static string UpdateTenantMetadata(string? existingMetadata, string? isolationMode)
|
||||
{
|
||||
var metadata = ParseMetadata(existingMetadata);
|
||||
if (!string.IsNullOrWhiteSpace(isolationMode))
|
||||
{
|
||||
metadata["isolationMode"] = NormalizeIsolationMode(isolationMode);
|
||||
}
|
||||
else if (!metadata.ContainsKey("isolationMode"))
|
||||
{
|
||||
metadata["isolationMode"] = "shared";
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(metadata);
|
||||
}
|
||||
|
||||
private static AdminTenantSummary ToAdminTenantSummary(
|
||||
string tenantId,
|
||||
AuthorityTenantView? configuredTenant,
|
||||
TenantEntity? persistedTenant,
|
||||
int userCount)
|
||||
{
|
||||
var metadata = ParseMetadata(persistedTenant?.Metadata);
|
||||
var persistedIsolationMode = metadata.TryGetValue("isolationMode", out var rawIsolationMode)
|
||||
? rawIsolationMode?.ToString()
|
||||
: null;
|
||||
var isolationMode = configuredTenant?.IsolationMode
|
||||
?? persistedIsolationMode
|
||||
?? "shared";
|
||||
var normalizedStatus = configuredTenant?.Status;
|
||||
if (string.IsNullOrWhiteSpace(normalizedStatus))
|
||||
{
|
||||
normalizedStatus = persistedTenant is null || persistedTenant.Enabled ? "active" : "disabled";
|
||||
}
|
||||
|
||||
return new AdminTenantSummary(
|
||||
Id: tenantId,
|
||||
DisplayName: configuredTenant?.DisplayName
|
||||
?? persistedTenant?.Name
|
||||
?? tenantId,
|
||||
Status: string.Equals(normalizedStatus, "active", StringComparison.OrdinalIgnoreCase) ? "active" : "disabled",
|
||||
IsolationMode: NormalizeIsolationMode(isolationMode),
|
||||
UserCount: userCount,
|
||||
CreatedAt: persistedTenant?.CreatedAt ?? DateTimeOffset.UnixEpoch);
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<string>> ResolveUserRolesAsync(
|
||||
UserEntity user,
|
||||
IRoleRepository roleRepository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var resolvedRoles = new HashSet<string>(ReadRoles(ParseMetadata(user.Metadata)), StringComparer.OrdinalIgnoreCase);
|
||||
var assignedRoles = await roleRepository.GetUserRolesAsync(user.TenantId, user.Id, cancellationToken).ConfigureAwait(false);
|
||||
foreach (var role in assignedRoles)
|
||||
{
|
||||
var roleName = string.IsNullOrWhiteSpace(role.Name) ? role.DisplayName : role.Name;
|
||||
if (!string.IsNullOrWhiteSpace(roleName))
|
||||
{
|
||||
resolvedRoles.Add(roleName.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedRoles
|
||||
.OrderBy(static role => role, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyDictionary<string, int>> BuildRoleCountMapAsync(
|
||||
string tenantId,
|
||||
IReadOnlyList<UserEntity> users,
|
||||
IRoleRepository roleRepository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var roleCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var user in users.Where(user => string.Equals(user.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var roles = await ResolveUserRolesAsync(user, roleRepository, cancellationToken).ConfigureAwait(false);
|
||||
foreach (var role in roles)
|
||||
{
|
||||
roleCounts[role] = roleCounts.TryGetValue(role, out var count) ? count + 1 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
return roleCounts;
|
||||
}
|
||||
|
||||
private static string ExtractPermissionResource(string scope)
|
||||
{
|
||||
var parts = scope.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
return parts.Length == 0 ? "custom" : parts[0];
|
||||
}
|
||||
|
||||
private static string ExtractPermissionAction(string scope)
|
||||
{
|
||||
var parts = scope.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
return parts.Length <= 1 ? "read" : parts[^1];
|
||||
}
|
||||
|
||||
private static AdminUserSummary ToAdminUserSummary(UserEntity user, DateTimeOffset now)
|
||||
=> ToAdminUserSummary(user, now, ReadRoles(ParseMetadata(user.Metadata)));
|
||||
|
||||
private static AdminUserSummary ToAdminUserSummary(UserEntity user, DateTimeOffset now, IReadOnlyList<string> roles)
|
||||
{
|
||||
var metadata = ParseMetadata(user.Metadata);
|
||||
var subjectId = metadata.TryGetValue("subjectId", out var parsedSubjectId)
|
||||
@@ -1312,7 +1695,7 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
DisplayName: user.DisplayName ?? user.Username,
|
||||
Roles: ReadRoles(metadata),
|
||||
Roles: roles.Count == 0 ? ReadRoles(metadata) : roles,
|
||||
Status: status,
|
||||
CreatedAt: user.CreatedAt,
|
||||
LastLoginAt: user.LastLoginAt);
|
||||
@@ -1390,9 +1773,13 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
new RoleBundle("role/console-admin", "Console Admin", new[]
|
||||
{
|
||||
StellaOpsScopes.UiRead, StellaOpsScopes.UiAdmin,
|
||||
StellaOpsScopes.AuthorityTenantsRead, StellaOpsScopes.AuthorityUsersRead,
|
||||
StellaOpsScopes.AuthorityRolesRead, StellaOpsScopes.AuthorityClientsRead,
|
||||
StellaOpsScopes.AuthorityTokensRead, StellaOpsScopes.AuthorityAuditRead
|
||||
StellaOpsScopes.AuthorityTenantsRead, StellaOpsScopes.AuthorityTenantsWrite,
|
||||
StellaOpsScopes.AuthorityUsersRead, StellaOpsScopes.AuthorityUsersWrite,
|
||||
StellaOpsScopes.AuthorityRolesRead, StellaOpsScopes.AuthorityRolesWrite,
|
||||
StellaOpsScopes.AuthorityClientsRead, StellaOpsScopes.AuthorityClientsWrite,
|
||||
StellaOpsScopes.AuthorityTokensRead, StellaOpsScopes.AuthorityTokensRevoke,
|
||||
StellaOpsScopes.AuthorityBrandingRead, StellaOpsScopes.AuthorityBrandingWrite,
|
||||
StellaOpsScopes.AuthorityAuditRead
|
||||
}),
|
||||
new RoleBundle("role/scanner-viewer", "Scanner Viewer", new[] { StellaOpsScopes.ScannerRead }),
|
||||
new RoleBundle("role/scanner-operator", "Scanner Operator", new[]
|
||||
@@ -1457,6 +1844,20 @@ internal sealed record AdminUserSummary(
|
||||
string Status,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? LastLoginAt);
|
||||
internal sealed record AdminRoleSummary(
|
||||
string Id,
|
||||
string Name,
|
||||
string Description,
|
||||
IReadOnlyList<string> Permissions,
|
||||
int UserCount,
|
||||
bool IsBuiltIn);
|
||||
internal sealed record AdminTenantSummary(
|
||||
string Id,
|
||||
string DisplayName,
|
||||
string Status,
|
||||
string IsolationMode,
|
||||
int UserCount,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
// ========== FILTERS ==========
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using OpenIddict.Abstractions;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Authority.Tenants;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using System;
|
||||
@@ -16,11 +18,14 @@ using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Authority.Console.Admin;
|
||||
|
||||
internal static class ConsoleBrandingEndpointExtensions
|
||||
{
|
||||
private const string BrandingSettingsKey = "consoleBranding";
|
||||
|
||||
public static void MapConsoleBrandingEndpoints(this WebApplication app)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(app);
|
||||
@@ -62,7 +67,7 @@ internal static class ConsoleBrandingEndpointExtensions
|
||||
|
||||
private static async Task<IResult> GetBranding(
|
||||
HttpContext httpContext,
|
||||
[FromServices] IAuthorityTenantCatalog tenantCatalog,
|
||||
ITenantRepository tenantRepository,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -76,8 +81,7 @@ internal static class ConsoleBrandingEndpointExtensions
|
||||
return Results.BadRequest(new { error = "tenant_required", message = "tenantId query parameter is required." });
|
||||
}
|
||||
|
||||
// Placeholder: load from storage
|
||||
var branding = GetDefaultBranding(tenantId);
|
||||
var branding = await ResolveBrandingAsync(tenantId, tenantRepository, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -105,7 +109,7 @@ internal static class ConsoleBrandingEndpointExtensions
|
||||
|
||||
private static async Task<IResult> GetBrandingAdmin(
|
||||
HttpContext httpContext,
|
||||
[FromServices] IAuthorityTenantCatalog tenantCatalog,
|
||||
ITenantRepository tenantRepository,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -116,14 +120,11 @@ internal static class ConsoleBrandingEndpointExtensions
|
||||
return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." });
|
||||
}
|
||||
|
||||
// Placeholder: load from storage with edit metadata
|
||||
var branding = GetDefaultBranding(tenant);
|
||||
var metadata = new BrandingMetadata(
|
||||
var (branding, metadata) = await ResolveBrandingWithMetadataAsync(
|
||||
tenant,
|
||||
DateTimeOffset.UtcNow,
|
||||
"system",
|
||||
ComputeHash(branding)
|
||||
);
|
||||
tenantRepository,
|
||||
timeProvider,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await WriteAuditAsync(
|
||||
httpContext,
|
||||
@@ -141,7 +142,7 @@ internal static class ConsoleBrandingEndpointExtensions
|
||||
private static async Task<IResult> UpdateBranding(
|
||||
HttpContext httpContext,
|
||||
UpdateBrandingRequest request,
|
||||
[FromServices] IAuthorityTenantCatalog tenantCatalog,
|
||||
ITenantRepository tenantRepository,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -177,6 +178,12 @@ internal static class ConsoleBrandingEndpointExtensions
|
||||
// Sanitize theme tokens (whitelist allowed keys)
|
||||
var sanitizedTokens = SanitizeThemeTokens(request.ThemeTokens);
|
||||
|
||||
var existingTenant = await tenantRepository.GetBySlugAsync(tenant, cancellationToken).ConfigureAwait(false);
|
||||
if (existingTenant is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "tenant_not_found", tenant });
|
||||
}
|
||||
|
||||
var branding = new TenantBranding(
|
||||
tenant,
|
||||
request.DisplayName ?? tenant,
|
||||
@@ -185,7 +192,36 @@ internal static class ConsoleBrandingEndpointExtensions
|
||||
sanitizedTokens
|
||||
);
|
||||
|
||||
// Placeholder: persist to storage
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var updatedBy = httpContext.User.FindFirstValue(OpenIddictConstants.Claims.PreferredUsername)
|
||||
?? httpContext.User.FindFirstValue(StellaOpsClaimTypes.Subject)
|
||||
?? "system";
|
||||
var metadata = new BrandingMetadata(
|
||||
tenant,
|
||||
now,
|
||||
updatedBy,
|
||||
ComputeHash(branding));
|
||||
|
||||
var updatedTenant = new TenantEntity
|
||||
{
|
||||
Id = existingTenant.Id,
|
||||
Slug = existingTenant.Slug,
|
||||
Name = existingTenant.Name,
|
||||
Description = existingTenant.Description,
|
||||
ContactEmail = existingTenant.ContactEmail,
|
||||
Enabled = existingTenant.Enabled,
|
||||
Settings = UpdateBrandingSettings(existingTenant.Settings, branding, metadata),
|
||||
Metadata = existingTenant.Metadata,
|
||||
CreatedAt = existingTenant.CreatedAt,
|
||||
UpdatedAt = now,
|
||||
CreatedBy = existingTenant.CreatedBy,
|
||||
};
|
||||
|
||||
var updated = await tenantRepository.UpdateAsync(updatedTenant, cancellationToken).ConfigureAwait(false);
|
||||
if (!updated)
|
||||
{
|
||||
return Results.NotFound(new { error = "tenant_not_found", tenant });
|
||||
}
|
||||
|
||||
await WriteAuditAsync(
|
||||
httpContext,
|
||||
@@ -196,10 +232,10 @@ internal static class ConsoleBrandingEndpointExtensions
|
||||
null,
|
||||
BuildProperties(
|
||||
("tenant.id", tenant),
|
||||
("branding.hash", ComputeHash(branding))),
|
||||
("branding.hash", metadata.Hash)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new { message = "Branding updated successfully", branding });
|
||||
return Results.Ok(new { message = "Branding updated successfully", branding, metadata });
|
||||
}
|
||||
|
||||
private static async Task<IResult> PreviewBranding(
|
||||
@@ -244,6 +280,55 @@ internal static class ConsoleBrandingEndpointExtensions
|
||||
|
||||
// ========== HELPER METHODS ==========
|
||||
|
||||
private static async Task<TenantBranding> ResolveBrandingAsync(
|
||||
string tenantId,
|
||||
ITenantRepository tenantRepository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenant = await tenantRepository.GetBySlugAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
if (tenant is null)
|
||||
{
|
||||
return GetDefaultBranding(tenantId);
|
||||
}
|
||||
|
||||
return ReadStoredBranding(tenant)?.Branding ?? GetDefaultBranding(tenantId);
|
||||
}
|
||||
|
||||
private static async Task<(TenantBranding Branding, BrandingMetadata Metadata)> ResolveBrandingWithMetadataAsync(
|
||||
string tenantId,
|
||||
ITenantRepository tenantRepository,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenant = await tenantRepository.GetBySlugAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
if (tenant is not null)
|
||||
{
|
||||
var stored = ReadStoredBranding(tenant);
|
||||
if (stored is not null)
|
||||
{
|
||||
return stored.Value;
|
||||
}
|
||||
|
||||
var defaultBranding = GetDefaultBranding(tenantId);
|
||||
return (
|
||||
defaultBranding,
|
||||
new BrandingMetadata(
|
||||
tenantId,
|
||||
tenant.UpdatedAt == default ? timeProvider.GetUtcNow() : tenant.UpdatedAt,
|
||||
"system",
|
||||
ComputeHash(defaultBranding)));
|
||||
}
|
||||
|
||||
var fallbackBranding = GetDefaultBranding(tenantId);
|
||||
return (
|
||||
fallbackBranding,
|
||||
new BrandingMetadata(
|
||||
tenantId,
|
||||
timeProvider.GetUtcNow(),
|
||||
"system",
|
||||
ComputeHash(fallbackBranding)));
|
||||
}
|
||||
|
||||
private static TenantBranding GetDefaultBranding(string tenantId)
|
||||
{
|
||||
return new TenantBranding(
|
||||
@@ -260,6 +345,82 @@ internal static class ConsoleBrandingEndpointExtensions
|
||||
);
|
||||
}
|
||||
|
||||
private static (TenantBranding Branding, BrandingMetadata Metadata)? ReadStoredBranding(TenantEntity tenant)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenant.Settings))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var root = JsonNode.Parse(tenant.Settings) as JsonObject;
|
||||
var node = root?[BrandingSettingsKey];
|
||||
if (node is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var stored = node.Deserialize<StoredBrandingSettings>();
|
||||
if (stored is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var branding = new TenantBranding(
|
||||
tenant.Slug,
|
||||
string.IsNullOrWhiteSpace(stored.DisplayName) ? "StellaOps" : stored.DisplayName.Trim(),
|
||||
string.IsNullOrWhiteSpace(stored.LogoUri) ? null : stored.LogoUri,
|
||||
string.IsNullOrWhiteSpace(stored.FaviconUri) ? null : stored.FaviconUri,
|
||||
SanitizeThemeTokens(stored.ThemeTokens));
|
||||
|
||||
var metadata = new BrandingMetadata(
|
||||
tenant.Slug,
|
||||
ParseTimestamp(stored.UpdatedAtUtc) ?? tenant.UpdatedAt,
|
||||
string.IsNullOrWhiteSpace(stored.UpdatedBy) ? "system" : stored.UpdatedBy.Trim(),
|
||||
string.IsNullOrWhiteSpace(stored.Hash) ? ComputeHash(branding) : stored.Hash.Trim());
|
||||
|
||||
return (branding, metadata);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string UpdateBrandingSettings(
|
||||
string existingSettings,
|
||||
TenantBranding branding,
|
||||
BrandingMetadata metadata)
|
||||
{
|
||||
JsonObject root;
|
||||
try
|
||||
{
|
||||
root = JsonNode.Parse(string.IsNullOrWhiteSpace(existingSettings) ? "{}" : existingSettings) as JsonObject
|
||||
?? new JsonObject();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
root = new JsonObject();
|
||||
}
|
||||
|
||||
root[BrandingSettingsKey] = JsonSerializer.SerializeToNode(new StoredBrandingSettings(
|
||||
branding.DisplayName,
|
||||
branding.LogoUri,
|
||||
branding.FaviconUri,
|
||||
new Dictionary<string, string>(branding.ThemeTokens, StringComparer.OrdinalIgnoreCase),
|
||||
metadata.UpdatedAtUtc,
|
||||
metadata.UpdatedBy,
|
||||
metadata.Hash));
|
||||
|
||||
return root.ToJsonString();
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ParseTimestamp(DateTimeOffset? value)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> SanitizeThemeTokens(IReadOnlyDictionary<string, string>? tokens)
|
||||
{
|
||||
if (tokens is null || tokens.Count == 0)
|
||||
@@ -405,7 +566,17 @@ internal sealed record TenantBranding(
|
||||
|
||||
internal sealed record BrandingMetadata(
|
||||
string TenantId,
|
||||
DateTimeOffset UpdatedAt,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
string UpdatedBy,
|
||||
string Hash
|
||||
);
|
||||
|
||||
internal sealed record StoredBrandingSettings(
|
||||
string DisplayName,
|
||||
string? LogoUri,
|
||||
string? FaviconUri,
|
||||
IReadOnlyDictionary<string, string>? ThemeTokens,
|
||||
DateTimeOffset? UpdatedAtUtc,
|
||||
string? UpdatedBy,
|
||||
string? Hash
|
||||
);
|
||||
|
||||
@@ -82,8 +82,12 @@ VALUES
|
||||
ARRAY['https://stella-ops.local/auth/callback', 'https://stella-ops.local/auth/silent-refresh', 'https://127.1.0.1/auth/callback', 'https://127.1.0.1/auth/silent-refresh'],
|
||||
ARRAY['openid', 'profile', 'email', 'offline_access',
|
||||
'ui.read', 'ui.admin',
|
||||
'authority:tenants.read', 'authority:users.read', 'authority:roles.read',
|
||||
'authority:clients.read', 'authority:tokens.read', 'authority:branding.read',
|
||||
'authority:tenants.read', 'authority:tenants.write',
|
||||
'authority:users.read', 'authority:users.write',
|
||||
'authority:roles.read', 'authority:roles.write',
|
||||
'authority:clients.read', 'authority:clients.write',
|
||||
'authority:tokens.read', 'authority:tokens.revoke',
|
||||
'authority:branding.read', 'authority:branding.write',
|
||||
'authority.audit.read',
|
||||
'graph:read', 'sbom:read', 'scanner:read',
|
||||
'policy:read', 'policy:simulate', 'policy:author', 'policy:review', 'policy:approve',
|
||||
|
||||
@@ -223,11 +223,29 @@ public static class PackAdapterEndpoints
|
||||
.WithSummary("Pack v2 administration A5 policy governance projection.")
|
||||
.RequireAuthorization(PlatformPolicies.SetupRead);
|
||||
|
||||
administration.MapGet("/trust-signing", (
|
||||
administration.MapGet("/trust-signing", async (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver) =>
|
||||
PlatformRequestContextResolver resolver,
|
||||
IAdministrationTrustSigningStore trustSigningStore,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
return BuildAdministrationItem(context, resolver, BuildAdministrationTrustSigning);
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var payload = await BuildAdministrationTrustSigningAsync(
|
||||
requestContext!.TenantId,
|
||||
trustSigningStore,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformItemResponse<AdministrationTrustSigningDto>(
|
||||
requestContext.TenantId,
|
||||
requestContext.ActorId,
|
||||
SnapshotAt,
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
payload));
|
||||
})
|
||||
.WithName("GetAdministrationTrustSigning")
|
||||
.WithSummary("Pack v2 administration A6 trust and signing projection.")
|
||||
@@ -540,14 +558,41 @@ public static class PackAdapterEndpoints
|
||||
]);
|
||||
}
|
||||
|
||||
private static AdministrationTrustSigningDto BuildAdministrationTrustSigning()
|
||||
private static async Task<AdministrationTrustSigningDto> BuildAdministrationTrustSigningAsync(
|
||||
string tenantId,
|
||||
IAdministrationTrustSigningStore trustSigningStore,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var keys = await trustSigningStore.ListKeysAsync(tenantId, 500, 0, cancellationToken).ConfigureAwait(false);
|
||||
var issuers = await trustSigningStore.ListIssuersAsync(tenantId, 500, 0, cancellationToken).ConfigureAwait(false);
|
||||
var certificates = await trustSigningStore.ListCertificatesAsync(tenantId, 500, 0, cancellationToken).ConfigureAwait(false);
|
||||
var transparencyConfig = await trustSigningStore.GetTransparencyLogConfigAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var expiringCertificateCount = certificates.Count(certificate =>
|
||||
!string.Equals(certificate.Status, "revoked", StringComparison.OrdinalIgnoreCase)
|
||||
&& certificate.NotAfter <= SnapshotAt.AddDays(10));
|
||||
|
||||
var signals = new[]
|
||||
{
|
||||
new AdministrationTrustSignalDto("audit-log", "healthy", "Audit log ingestion is current."),
|
||||
new AdministrationTrustSignalDto("certificate-expiry", "warning", "1 certificate expires within 10 days."),
|
||||
new AdministrationTrustSignalDto("transparency-log", "healthy", "Rekor witness is reachable."),
|
||||
new AdministrationTrustSignalDto("trust-scoring", "healthy", "Issuer trust score recalculation completed."),
|
||||
new AdministrationTrustSignalDto("audit-log", "healthy", "Trust-signing configuration changes are being recorded."),
|
||||
new AdministrationTrustSignalDto(
|
||||
"certificate-expiry",
|
||||
expiringCertificateCount > 0 ? "warning" : "healthy",
|
||||
expiringCertificateCount > 0
|
||||
? $"{expiringCertificateCount} certificate{(expiringCertificateCount == 1 ? string.Empty : "s")} expire within 10 days."
|
||||
: "No certificate expirations are due in the next 10 days."),
|
||||
new AdministrationTrustSignalDto(
|
||||
"transparency-log",
|
||||
transparencyConfig is null ? "warning" : "healthy",
|
||||
transparencyConfig is null
|
||||
? "Transparency log is not configured for this tenant."
|
||||
: $"Transparency log witness points to {transparencyConfig.LogUrl}."),
|
||||
new AdministrationTrustSignalDto(
|
||||
"trust-scoring",
|
||||
issuers.Count == 0 ? "warning" : "healthy",
|
||||
issuers.Count == 0
|
||||
? "No trusted issuers are registered yet."
|
||||
: "Issuer trust inventory is available for scoring and review."),
|
||||
}.OrderBy(signal => signal.SignalId, StringComparer.Ordinal).ToList();
|
||||
|
||||
var aliases = BuildAdministrationAliases(
|
||||
@@ -558,7 +603,7 @@ public static class PackAdapterEndpoints
|
||||
]);
|
||||
|
||||
return new AdministrationTrustSigningDto(
|
||||
Inventory: new AdministrationTrustInventoryDto(Keys: 14, Issuers: 7, Certificates: 23),
|
||||
Inventory: new AdministrationTrustInventoryDto(Keys: keys.Count, Issuers: issuers.Count, Certificates: certificates.Count),
|
||||
Signals: signals,
|
||||
LegacyAliases: aliases,
|
||||
EvidenceConsumerPath: "/evidence-audit/proofs");
|
||||
|
||||
@@ -32,7 +32,7 @@ public sealed class PostgresAdministrationTrustSigningStore : IAdministrationTru
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
|
||||
@@ -83,7 +83,7 @@ public sealed class PostgresAdministrationTrustSigningStore : IAdministrationTru
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var keyId = Guid.NewGuid();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
@@ -164,7 +164,7 @@ public sealed class PostgresAdministrationTrustSigningStore : IAdministrationTru
|
||||
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
var existingStatus = await GetKeyStatusAsync(connection, tenantGuid, keyId, cancellationToken).ConfigureAwait(false);
|
||||
@@ -226,7 +226,7 @@ public sealed class PostgresAdministrationTrustSigningStore : IAdministrationTru
|
||||
_ = NormalizeRequired(request.Reason, "reason_required");
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(
|
||||
@@ -270,7 +270,7 @@ public sealed class PostgresAdministrationTrustSigningStore : IAdministrationTru
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
|
||||
@@ -323,7 +323,7 @@ public sealed class PostgresAdministrationTrustSigningStore : IAdministrationTru
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var issuerId = Guid.NewGuid();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
@@ -397,7 +397,7 @@ public sealed class PostgresAdministrationTrustSigningStore : IAdministrationTru
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
|
||||
@@ -454,7 +454,7 @@ public sealed class PostgresAdministrationTrustSigningStore : IAdministrationTru
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var certificateId = Guid.NewGuid();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -571,7 +571,7 @@ public sealed class PostgresAdministrationTrustSigningStore : IAdministrationTru
|
||||
_ = NormalizeRequired(request.Reason, "reason_required");
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(
|
||||
@@ -615,7 +615,7 @@ public sealed class PostgresAdministrationTrustSigningStore : IAdministrationTru
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(
|
||||
@@ -660,7 +660,7 @@ public sealed class PostgresAdministrationTrustSigningStore : IAdministrationTru
|
||||
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(
|
||||
@@ -762,21 +762,6 @@ public sealed class PostgresAdministrationTrustSigningStore : IAdministrationTru
|
||||
return connection;
|
||||
}
|
||||
|
||||
private static Guid ParseTenantId(string tenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
throw new InvalidOperationException("tenant_required");
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(tenantId, out var tenantGuid))
|
||||
{
|
||||
throw new InvalidOperationException("tenant_id_invalid");
|
||||
}
|
||||
|
||||
return tenantGuid;
|
||||
}
|
||||
|
||||
private static int NormalizeLimit(int limit) => limit < 1 ? 1 : limit;
|
||||
|
||||
private static int NormalizeOffset(int offset) => offset < 0 ? 0 : offset;
|
||||
|
||||
@@ -70,7 +70,7 @@ public sealed class PostgresReleaseControlBundleStore : IReleaseControlBundleSto
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(
|
||||
@@ -137,7 +137,7 @@ public sealed class PostgresReleaseControlBundleStore : IReleaseControlBundleSto
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(
|
||||
@@ -220,7 +220,7 @@ public sealed class PostgresReleaseControlBundleStore : IReleaseControlBundleSto
|
||||
throw new InvalidOperationException("bundle_name_required");
|
||||
}
|
||||
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var bundleId = Guid.NewGuid();
|
||||
var createdBy = NormalizeActor(actorId);
|
||||
@@ -273,7 +273,7 @@ public sealed class PostgresReleaseControlBundleStore : IReleaseControlBundleSto
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -325,7 +325,7 @@ public sealed class PostgresReleaseControlBundleStore : IReleaseControlBundleSto
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -395,7 +395,7 @@ public sealed class PostgresReleaseControlBundleStore : IReleaseControlBundleSto
|
||||
throw new InvalidOperationException("request_required");
|
||||
}
|
||||
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
|
||||
var createdBy = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var normalizedComponents = ReleaseControlBundleDigest.NormalizeComponents(request.Components);
|
||||
@@ -544,7 +544,7 @@ public sealed class PostgresReleaseControlBundleStore : IReleaseControlBundleSto
|
||||
throw new InvalidOperationException("request_required");
|
||||
}
|
||||
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
|
||||
var requestedBy = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var normalizedIdempotencyKey = NormalizeIdempotencyKey(request.IdempotencyKey);
|
||||
@@ -656,7 +656,7 @@ public sealed class PostgresReleaseControlBundleStore : IReleaseControlBundleSto
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = new NpgsqlCommand(ListMaterializationRunsSql, connection);
|
||||
@@ -682,7 +682,7 @@ public sealed class PostgresReleaseControlBundleStore : IReleaseControlBundleSto
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = new NpgsqlCommand(ListMaterializationRunsByBundleSql, connection);
|
||||
@@ -707,7 +707,7 @@ public sealed class PostgresReleaseControlBundleStore : IReleaseControlBundleSto
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = new NpgsqlCommand(
|
||||
@@ -772,24 +772,6 @@ public sealed class PostgresReleaseControlBundleStore : IReleaseControlBundleSto
|
||||
UpdatedAt: reader.GetFieldValue<DateTimeOffset>(9));
|
||||
}
|
||||
|
||||
private static Guid ParseTenantId(string tenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
throw new InvalidOperationException("tenant_required");
|
||||
}
|
||||
|
||||
if (Guid.TryParse(tenantId, out var tenantGuid))
|
||||
{
|
||||
return tenantGuid;
|
||||
}
|
||||
|
||||
// Derive deterministic GUID from string tenant identifier (e.g. "default", "demo-prod")
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(
|
||||
System.Text.Encoding.UTF8.GetBytes(tenantId));
|
||||
return new Guid(hash.AsSpan(0, 16));
|
||||
}
|
||||
|
||||
private async Task<NpgsqlConnection> OpenTenantConnectionAsync(Guid tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
internal static class TenantStorageKey
|
||||
{
|
||||
public static Guid ParseTenantGuid(string tenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
throw new InvalidOperationException("tenant_required");
|
||||
}
|
||||
|
||||
var normalizedTenantId = tenantId.Trim();
|
||||
if (Guid.TryParse(normalizedTenantId, out var tenantGuid))
|
||||
{
|
||||
return tenantGuid;
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(normalizedTenantId));
|
||||
return new Guid(hash.AsSpan(0, 16));
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,10 @@
|
||||
<EmbeddedResource Include="Translations\*.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="StellaOps.Platform.WebService.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Label="StellaOpsReleaseVersion">
|
||||
<Version>1.0.0-alpha1</Version>
|
||||
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
-- Scratch-install trust-signing seed for canonical demo tenants.
|
||||
-- Tenant GUIDs are derived with the same deterministic SHA-256 -> Guid mapping used in Platform stores.
|
||||
|
||||
INSERT INTO release.trust_keys (
|
||||
id,
|
||||
tenant_id,
|
||||
key_alias,
|
||||
algorithm,
|
||||
status,
|
||||
current_version,
|
||||
metadata_json,
|
||||
created_at,
|
||||
updated_at,
|
||||
created_by,
|
||||
updated_by
|
||||
)
|
||||
SELECT
|
||||
'a9adf36f-2f4d-4f31-9b0b-138e3f6f1f61'::uuid,
|
||||
'3a5e72b6-ae6a-f8a4-2b6a-df2960d63016'::uuid,
|
||||
'demo-prod-core-signing',
|
||||
'ed25519',
|
||||
'active',
|
||||
3,
|
||||
'{"owner":"secops","region":"us-east"}'::jsonb,
|
||||
'2026-03-01T00:00:00Z'::timestamptz,
|
||||
'2026-03-09T00:00:00Z'::timestamptz,
|
||||
'system',
|
||||
'system'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM release.trust_keys
|
||||
WHERE tenant_id = '3a5e72b6-ae6a-f8a4-2b6a-df2960d63016'::uuid
|
||||
AND lower(key_alias) = lower('demo-prod-core-signing')
|
||||
);
|
||||
|
||||
INSERT INTO release.trust_issuers (
|
||||
id,
|
||||
tenant_id,
|
||||
issuer_name,
|
||||
issuer_uri,
|
||||
trust_level,
|
||||
status,
|
||||
created_at,
|
||||
updated_at,
|
||||
created_by,
|
||||
updated_by
|
||||
)
|
||||
SELECT
|
||||
'4ac7e1d4-7a2e-4b4d-9e12-5d42e3168a91'::uuid,
|
||||
'3a5e72b6-ae6a-f8a4-2b6a-df2960d63016'::uuid,
|
||||
'Demo Prod Root CA',
|
||||
'https://issuer.demo-prod.stella-ops.local/root',
|
||||
'high',
|
||||
'active',
|
||||
'2026-03-01T00:00:00Z'::timestamptz,
|
||||
'2026-03-09T00:00:00Z'::timestamptz,
|
||||
'system',
|
||||
'system'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM release.trust_issuers
|
||||
WHERE tenant_id = '3a5e72b6-ae6a-f8a4-2b6a-df2960d63016'::uuid
|
||||
AND lower(issuer_uri) = lower('https://issuer.demo-prod.stella-ops.local/root')
|
||||
);
|
||||
|
||||
INSERT INTO release.trust_certificates (
|
||||
id,
|
||||
tenant_id,
|
||||
key_id,
|
||||
issuer_id,
|
||||
serial_number,
|
||||
status,
|
||||
not_before,
|
||||
not_after,
|
||||
created_at,
|
||||
updated_at,
|
||||
created_by,
|
||||
updated_by
|
||||
)
|
||||
SELECT
|
||||
'8d1f0b75-3d56-4d8f-a40b-52f5e52e7fb1'::uuid,
|
||||
'3a5e72b6-ae6a-f8a4-2b6a-df2960d63016'::uuid,
|
||||
'a9adf36f-2f4d-4f31-9b0b-138e3f6f1f61'::uuid,
|
||||
'4ac7e1d4-7a2e-4b4d-9e12-5d42e3168a91'::uuid,
|
||||
'DEMO-PROD-SER-0001',
|
||||
'active',
|
||||
'2026-03-01T00:00:00Z'::timestamptz,
|
||||
'2026-03-18T00:00:00Z'::timestamptz,
|
||||
'2026-03-01T00:00:00Z'::timestamptz,
|
||||
'2026-03-09T00:00:00Z'::timestamptz,
|
||||
'system',
|
||||
'system'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM release.trust_certificates
|
||||
WHERE tenant_id = '3a5e72b6-ae6a-f8a4-2b6a-df2960d63016'::uuid
|
||||
AND lower(serial_number) = lower('DEMO-PROD-SER-0001')
|
||||
);
|
||||
|
||||
INSERT INTO release.trust_transparency_configs (
|
||||
tenant_id,
|
||||
log_url,
|
||||
witness_url,
|
||||
enforce_inclusion,
|
||||
updated_at,
|
||||
updated_by
|
||||
)
|
||||
VALUES (
|
||||
'3a5e72b6-ae6a-f8a4-2b6a-df2960d63016'::uuid,
|
||||
'https://rekor.demo-prod.stella-ops.local',
|
||||
'https://rekor-witness.demo-prod.stella-ops.local',
|
||||
true,
|
||||
'2026-03-09T00:00:00Z'::timestamptz,
|
||||
'system'
|
||||
)
|
||||
ON CONFLICT (tenant_id) DO UPDATE
|
||||
SET
|
||||
log_url = EXCLUDED.log_url,
|
||||
witness_url = EXCLUDED.witness_url,
|
||||
enforce_inclusion = EXCLUDED.enforce_inclusion,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
updated_by = EXCLUDED.updated_by;
|
||||
|
||||
INSERT INTO release.trust_keys (
|
||||
id,
|
||||
tenant_id,
|
||||
key_alias,
|
||||
algorithm,
|
||||
status,
|
||||
current_version,
|
||||
metadata_json,
|
||||
created_at,
|
||||
updated_at,
|
||||
created_by,
|
||||
updated_by
|
||||
)
|
||||
SELECT
|
||||
'5fdf7b2d-9d32-4b69-874f-9b22a4d22f21'::uuid,
|
||||
'c1eea837-19ce-7d68-132f-e29051dca629'::uuid,
|
||||
'default-core-signing',
|
||||
'ed25519',
|
||||
'active',
|
||||
1,
|
||||
'{"owner":"bootstrap","region":"global"}'::jsonb,
|
||||
'2026-03-01T00:00:00Z'::timestamptz,
|
||||
'2026-03-01T00:00:00Z'::timestamptz,
|
||||
'system',
|
||||
'system'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM release.trust_keys
|
||||
WHERE tenant_id = 'c1eea837-19ce-7d68-132f-e29051dca629'::uuid
|
||||
AND lower(key_alias) = lower('default-core-signing')
|
||||
);
|
||||
|
||||
INSERT INTO release.trust_issuers (
|
||||
id,
|
||||
tenant_id,
|
||||
issuer_name,
|
||||
issuer_uri,
|
||||
trust_level,
|
||||
status,
|
||||
created_at,
|
||||
updated_at,
|
||||
created_by,
|
||||
updated_by
|
||||
)
|
||||
SELECT
|
||||
'f7d0505e-4a94-4688-a046-87f7a9c7cf76'::uuid,
|
||||
'c1eea837-19ce-7d68-132f-e29051dca629'::uuid,
|
||||
'Default Root CA',
|
||||
'https://issuer.default.stella-ops.local/root',
|
||||
'high',
|
||||
'active',
|
||||
'2026-03-01T00:00:00Z'::timestamptz,
|
||||
'2026-03-01T00:00:00Z'::timestamptz,
|
||||
'system',
|
||||
'system'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM release.trust_issuers
|
||||
WHERE tenant_id = 'c1eea837-19ce-7d68-132f-e29051dca629'::uuid
|
||||
AND lower(issuer_uri) = lower('https://issuer.default.stella-ops.local/root')
|
||||
);
|
||||
|
||||
INSERT INTO release.trust_certificates (
|
||||
id,
|
||||
tenant_id,
|
||||
key_id,
|
||||
issuer_id,
|
||||
serial_number,
|
||||
status,
|
||||
not_before,
|
||||
not_after,
|
||||
created_at,
|
||||
updated_at,
|
||||
created_by,
|
||||
updated_by
|
||||
)
|
||||
SELECT
|
||||
'c0d9c7db-a0c8-41e9-a7f4-e8f03e4b31e3'::uuid,
|
||||
'c1eea837-19ce-7d68-132f-e29051dca629'::uuid,
|
||||
'5fdf7b2d-9d32-4b69-874f-9b22a4d22f21'::uuid,
|
||||
'f7d0505e-4a94-4688-a046-87f7a9c7cf76'::uuid,
|
||||
'DEFAULT-SER-0001',
|
||||
'active',
|
||||
'2026-03-01T00:00:00Z'::timestamptz,
|
||||
'2026-09-01T00:00:00Z'::timestamptz,
|
||||
'2026-03-01T00:00:00Z'::timestamptz,
|
||||
'2026-03-01T00:00:00Z'::timestamptz,
|
||||
'system',
|
||||
'system'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM release.trust_certificates
|
||||
WHERE tenant_id = 'c1eea837-19ce-7d68-132f-e29051dca629'::uuid
|
||||
AND lower(serial_number) = lower('DEFAULT-SER-0001')
|
||||
);
|
||||
|
||||
INSERT INTO release.trust_transparency_configs (
|
||||
tenant_id,
|
||||
log_url,
|
||||
witness_url,
|
||||
enforce_inclusion,
|
||||
updated_at,
|
||||
updated_by
|
||||
)
|
||||
VALUES (
|
||||
'c1eea837-19ce-7d68-132f-e29051dca629'::uuid,
|
||||
'https://rekor.default.stella-ops.local',
|
||||
'https://rekor-witness.default.stella-ops.local',
|
||||
true,
|
||||
'2026-03-01T00:00:00Z'::timestamptz,
|
||||
'system'
|
||||
)
|
||||
ON CONFLICT (tenant_id) DO UPDATE
|
||||
SET
|
||||
log_url = EXCLUDED.log_url,
|
||||
witness_url = EXCLUDED.witness_url,
|
||||
enforce_inclusion = EXCLUDED.enforce_inclusion,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
updated_by = EXCLUDED.updated_by;
|
||||
@@ -1,8 +1,10 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.TestKit;
|
||||
@@ -146,6 +148,75 @@ public sealed class PackAdapterEndpointsTests : IClassFixture<PlatformWebApplica
|
||||
Assert.DoesNotContain(PlatformPolicies.SetupRead, policies);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task TrustSigningOverview_Uses_live_inventory_counts_for_selected_tenant()
|
||||
{
|
||||
using var client = CreateTenantClient("demo-prod");
|
||||
|
||||
var keyResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/administration/trust-signing/keys",
|
||||
new CreateAdministrationTrustKeyRequest(
|
||||
Alias: "tenant-live-key",
|
||||
Algorithm: "ed25519",
|
||||
MetadataJson: "{\"owner\":\"secops\"}"),
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Created, keyResponse.StatusCode);
|
||||
var key = await keyResponse.Content.ReadFromJsonAsync<AdministrationTrustKeySummary>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(key);
|
||||
|
||||
var issuerResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/administration/trust-signing/issuers",
|
||||
new RegisterAdministrationTrustIssuerRequest(
|
||||
Name: "Tenant Live Root CA",
|
||||
IssuerUri: "https://issuer.demo-prod.stella-ops.local/live",
|
||||
TrustLevel: "high"),
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Created, issuerResponse.StatusCode);
|
||||
var issuer = await issuerResponse.Content.ReadFromJsonAsync<AdministrationTrustIssuerSummary>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(issuer);
|
||||
|
||||
var certificateResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/administration/trust-signing/certificates",
|
||||
new RegisterAdministrationTrustCertificateRequest(
|
||||
KeyId: key!.KeyId,
|
||||
IssuerId: issuer!.IssuerId,
|
||||
SerialNumber: "TENANT-LIVE-SER-0001",
|
||||
NotBefore: DateTimeOffset.Parse("2026-02-01T00:00:00Z"),
|
||||
NotAfter: DateTimeOffset.Parse("2026-02-25T00:00:00Z")),
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Created, certificateResponse.StatusCode);
|
||||
|
||||
var configureResponse = await client.PutAsJsonAsync(
|
||||
"/api/v1/administration/trust-signing/transparency-log",
|
||||
new ConfigureAdministrationTransparencyLogRequest(
|
||||
LogUrl: "https://rekor.demo-prod.stella-ops.local",
|
||||
WitnessUrl: "https://rekor-witness.demo-prod.stella-ops.local",
|
||||
EnforceInclusion: true),
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, configureResponse.StatusCode);
|
||||
|
||||
var overviewResponse = await client.GetAsync("/api/v1/administration/trust-signing", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, overviewResponse.StatusCode);
|
||||
|
||||
using var document = JsonDocument.Parse(await overviewResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
|
||||
var item = document.RootElement.GetProperty("item");
|
||||
Assert.Equal(1, item.GetProperty("inventory").GetProperty("keys").GetInt32());
|
||||
Assert.Equal(1, item.GetProperty("inventory").GetProperty("issuers").GetInt32());
|
||||
Assert.Equal(1, item.GetProperty("inventory").GetProperty("certificates").GetInt32());
|
||||
|
||||
var signals = item
|
||||
.GetProperty("signals")
|
||||
.EnumerateArray()
|
||||
.ToDictionary(
|
||||
signal => signal.GetProperty("signalId").GetString()!,
|
||||
signal => signal.GetProperty("status").GetString()!,
|
||||
StringComparer.Ordinal);
|
||||
|
||||
Assert.Equal("warning", signals["certificate-expiry"]);
|
||||
Assert.Equal("healthy", signals["transparency-log"]);
|
||||
}
|
||||
|
||||
private HttpClient CreateTenantClient(string tenantId)
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class TenantStorageKeyTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("demo-prod", "3a5e72b6-ae6a-f8a4-2b6a-df2960d63016")]
|
||||
[InlineData("default", "c1eea837-19ce-7d68-132f-e29051dca629")]
|
||||
public void ParseTenantGuid_derives_deterministic_guid_for_slug_tenants(string tenantId, string expectedGuid)
|
||||
{
|
||||
TenantStorageKey.ParseTenantGuid(tenantId).Should().Be(Guid.Parse(expectedGuid));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ParseTenantGuid_returns_existing_guid_without_rehashing()
|
||||
{
|
||||
var tenantGuid = Guid.NewGuid();
|
||||
|
||||
TenantStorageKey.ParseTenantGuid(tenantGuid.ToString("D")).Should().Be(tenantGuid);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ public sealed class RouteDispatchMiddleware
|
||||
// ReverseProxy paths that are legitimate browser navigation targets (e.g. OIDC flows)
|
||||
// and must NOT be redirected to the SPA fallback.
|
||||
private static readonly string[] BrowserProxyPaths = ["/connect", "/.well-known"];
|
||||
private static readonly string[] SpaRoutesWithDocumentExtensions = ["/docs", "/docs/"];
|
||||
|
||||
public RouteDispatchMiddleware(
|
||||
RequestDelegate next,
|
||||
@@ -134,7 +135,7 @@ public sealed class RouteDispatchMiddleware
|
||||
var spaFallback = route.Headers.TryGetValue("x-spa-fallback", out var spaValue) &&
|
||||
string.Equals(spaValue, "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (spaFallback && !System.IO.Path.HasExtension(relativePath))
|
||||
if (spaFallback && ShouldServeSpaFallback(relativePath))
|
||||
{
|
||||
var indexFile = fileProvider.GetFileInfo("/index.html");
|
||||
if (indexFile.Exists && !indexFile.IsDirectory)
|
||||
@@ -646,4 +647,22 @@ public sealed class RouteDispatchMiddleware
|
||||
var accept = request.Headers.Accept.ToString();
|
||||
return accept.Contains("text/html", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool ShouldServeSpaFallback(string relativePath)
|
||||
{
|
||||
if (!System.IO.Path.HasExtension(relativePath))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var prefix in SpaRoutesWithDocumentExtensions)
|
||||
{
|
||||
if (relativePath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,6 +259,59 @@ public sealed class RouteDispatchMiddlewareMicroserviceTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_StaticFilesRoute_DocsMarkdownPath_ServesSpaFallbackIndex()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"stella-router-docs-spa-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(tempDir, "index.html"),
|
||||
"<!DOCTYPE html><html><body><h1>SPA Root</h1></body></html>");
|
||||
|
||||
var resolver = new StellaOpsRouteResolver(
|
||||
[
|
||||
new StellaOpsRoute
|
||||
{
|
||||
Type = StellaOpsRouteType.StaticFiles,
|
||||
Path = "/",
|
||||
TranslatesTo = tempDir,
|
||||
Headers = new Dictionary<string, string> { ["x-spa-fallback"] = "true" }
|
||||
}
|
||||
]);
|
||||
|
||||
var httpClientFactory = new Mock<IHttpClientFactory>();
|
||||
httpClientFactory.Setup(factory => factory.CreateClient(It.IsAny<string>())).Returns(new HttpClient());
|
||||
|
||||
var middleware = new RouteDispatchMiddleware(
|
||||
_ => Task.CompletedTask,
|
||||
resolver,
|
||||
httpClientFactory.Object,
|
||||
NullLogger<RouteDispatchMiddleware>.Instance);
|
||||
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Method = HttpMethods.Get;
|
||||
context.Request.Path = "/docs/modules/platform/architecture-overview.md";
|
||||
context.Response.Body = new MemoryStream();
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
|
||||
context.Response.Body.Position = 0;
|
||||
var body = await new StreamReader(context.Response.Body).ReadToEndAsync();
|
||||
Assert.Contains("SPA Root", body, StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempDir))
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_RegexCatchAll_CaptureGroupSubstitution_ResolvesServiceAndPath()
|
||||
{
|
||||
|
||||
@@ -30,15 +30,20 @@
|
||||
"src/styles"
|
||||
]
|
||||
},
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets",
|
||||
"src/manifest.webmanifest",
|
||||
{
|
||||
"glob": "config.json",
|
||||
"input": "src/config",
|
||||
"output": "."
|
||||
}
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets",
|
||||
"src/manifest.webmanifest",
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "../../../docs",
|
||||
"output": "docs-content"
|
||||
},
|
||||
{
|
||||
"glob": "config.json",
|
||||
"input": "src/config",
|
||||
"output": "."
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
|
||||
252
src/Web/StellaOps.Web/scripts/live-full-core-audit.mjs
Normal file
252
src/Web/StellaOps.Web/scripts/live-full-core-audit.mjs
Normal file
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const webRoot = path.resolve(__dirname, '..');
|
||||
const outputDir = path.join(webRoot, 'output', 'playwright');
|
||||
const resultPath = path.join(outputDir, 'live-full-core-audit.json');
|
||||
|
||||
const suites = [
|
||||
{
|
||||
name: 'frontdoor-canonical-route-sweep',
|
||||
script: 'live-frontdoor-canonical-route-sweep.mjs',
|
||||
reportPath: path.join(outputDir, 'live-frontdoor-canonical-route-sweep.json'),
|
||||
},
|
||||
{
|
||||
name: 'mission-control-action-sweep',
|
||||
script: 'live-mission-control-action-sweep.mjs',
|
||||
reportPath: path.join(outputDir, 'live-mission-control-action-sweep.json'),
|
||||
},
|
||||
{
|
||||
name: 'ops-policy-action-sweep',
|
||||
script: 'live-ops-policy-action-sweep.mjs',
|
||||
reportPath: path.join(outputDir, 'live-ops-policy-action-sweep.json'),
|
||||
},
|
||||
{
|
||||
name: 'integrations-action-sweep',
|
||||
script: 'live-integrations-action-sweep.mjs',
|
||||
reportPath: path.join(outputDir, 'live-integrations-action-sweep.json'),
|
||||
},
|
||||
{
|
||||
name: 'setup-topology-action-sweep',
|
||||
script: 'live-setup-topology-action-sweep.mjs',
|
||||
reportPath: path.join(outputDir, 'live-setup-topology-action-sweep.json'),
|
||||
},
|
||||
{
|
||||
name: 'setup-admin-action-sweep',
|
||||
script: 'live-setup-admin-action-sweep.mjs',
|
||||
reportPath: path.join(outputDir, 'live-setup-admin-action-sweep.json'),
|
||||
},
|
||||
{
|
||||
name: 'user-reported-admin-trust-check',
|
||||
script: 'live-user-reported-admin-trust-check.mjs',
|
||||
reportPath: path.join(outputDir, 'live-user-reported-admin-trust-check.json'),
|
||||
},
|
||||
{
|
||||
name: 'jobs-queues-action-sweep',
|
||||
script: 'live-jobs-queues-action-sweep.mjs',
|
||||
reportPath: path.join(outputDir, 'live-jobs-queues-action-sweep.json'),
|
||||
},
|
||||
{
|
||||
name: 'triage-artifacts-scope-compat',
|
||||
script: 'live-triage-artifacts-scope-compat.mjs',
|
||||
reportPath: path.join(outputDir, 'live-triage-artifacts-scope-compat.json'),
|
||||
},
|
||||
{
|
||||
name: 'releases-deployments-check',
|
||||
script: 'live-releases-deployments-check.mjs',
|
||||
reportPath: path.join(outputDir, 'live-releases-deployments-check.json'),
|
||||
},
|
||||
{
|
||||
name: 'release-promotion-submit-check',
|
||||
script: 'live-release-promotion-submit-check.mjs',
|
||||
reportPath: path.join(outputDir, 'live-release-promotion-submit-check.json'),
|
||||
},
|
||||
{
|
||||
name: 'hotfix-action-check',
|
||||
script: 'live-hotfix-action-check.mjs',
|
||||
reportPath: path.join(outputDir, 'live-hotfix-action-check.json'),
|
||||
},
|
||||
{
|
||||
name: 'registry-admin-audit-check',
|
||||
script: 'live-registry-admin-audit-check.mjs',
|
||||
reportPath: path.join(outputDir, 'live-registry-admin-audit-check.json'),
|
||||
},
|
||||
{
|
||||
name: 'evidence-export-action-sweep',
|
||||
script: 'live-evidence-export-action-sweep.mjs',
|
||||
reportPath: path.join(outputDir, 'live-evidence-export-action-sweep.json'),
|
||||
},
|
||||
{
|
||||
name: 'watchlist-action-sweep',
|
||||
script: 'live-watchlist-action-sweep.mjs',
|
||||
reportPath: path.join(outputDir, 'live-watchlist-action-sweep.json'),
|
||||
},
|
||||
{
|
||||
name: 'notifications-watchlist-recheck',
|
||||
script: 'live-notifications-watchlist-recheck.mjs',
|
||||
reportPath: path.join(outputDir, 'live-notifications-watchlist-recheck.json'),
|
||||
},
|
||||
{
|
||||
name: 'policy-simulation-direct-routes',
|
||||
script: 'live-policy-simulation-direct-routes.mjs',
|
||||
reportPath: path.join(outputDir, 'live-policy-simulation-direct-routes.json'),
|
||||
},
|
||||
{
|
||||
name: 'uncovered-surface-action-sweep',
|
||||
script: 'live-uncovered-surface-action-sweep.mjs',
|
||||
reportPath: path.join(outputDir, 'live-uncovered-surface-action-sweep.json'),
|
||||
},
|
||||
{
|
||||
name: 'unified-search-route-matrix',
|
||||
script: 'live-frontdoor-unified-search-route-matrix.mjs',
|
||||
reportPath: path.join(outputDir, 'live-frontdoor-unified-search-route-matrix.json'),
|
||||
},
|
||||
];
|
||||
|
||||
const failureCountKeys = new Set([
|
||||
'failedRouteCount',
|
||||
'failedCheckCount',
|
||||
'failedChecks',
|
||||
'failedActionCount',
|
||||
'failedCount',
|
||||
'failureCount',
|
||||
'errorCount',
|
||||
'runtimeIssueCount',
|
||||
'issueCount',
|
||||
'unexpectedErrorCount',
|
||||
]);
|
||||
|
||||
const arrayFailureKeys = new Set([
|
||||
'failures',
|
||||
'runtimeIssues',
|
||||
'runtimeErrors',
|
||||
'errors',
|
||||
'warnings',
|
||||
]);
|
||||
|
||||
function collectFailureSignals(value) {
|
||||
const signals = [];
|
||||
|
||||
function visit(node, trail = []) {
|
||||
if (Array.isArray(node)) {
|
||||
if (trail.at(-1) && arrayFailureKeys.has(trail.at(-1)) && node.length > 0) {
|
||||
signals.push({ path: trail.join('.'), count: node.length });
|
||||
}
|
||||
|
||||
const failingEntries = node.filter((entry) => entry && typeof entry === 'object' && entry.ok === false);
|
||||
if (failingEntries.length > 0) {
|
||||
signals.push({ path: trail.join('.'), count: failingEntries.length });
|
||||
}
|
||||
|
||||
node.forEach((entry, index) => visit(entry, [...trail, String(index)]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!node || typeof node !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, child] of Object.entries(node)) {
|
||||
if (typeof child === 'number' && failureCountKeys.has(key) && child > 0) {
|
||||
signals.push({ path: [...trail, key].join('.'), count: child });
|
||||
}
|
||||
|
||||
visit(child, [...trail, key]);
|
||||
}
|
||||
}
|
||||
|
||||
visit(value);
|
||||
return signals;
|
||||
}
|
||||
|
||||
async function readReport(reportPath) {
|
||||
try {
|
||||
const content = await readFile(reportPath, 'utf8');
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
return {
|
||||
reportReadFailed: true,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function runSuite({ name, script }) {
|
||||
return new Promise((resolve) => {
|
||||
const startedAt = Date.now();
|
||||
const child = spawn(process.execPath, [path.join(__dirname, script)], {
|
||||
cwd: webRoot,
|
||||
env: process.env,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
resolve({
|
||||
name,
|
||||
script,
|
||||
exitCode: code ?? null,
|
||||
signal: signal ?? null,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
const summary = {
|
||||
generatedAtUtc: new Date().toISOString(),
|
||||
baseUrl: process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local',
|
||||
suiteCount: suites.length,
|
||||
suites: [],
|
||||
};
|
||||
|
||||
for (const suite of suites) {
|
||||
process.stdout.write(`[live-full-core-audit] START ${suite.name}\n`);
|
||||
const execution = await runSuite(suite);
|
||||
const report = await readReport(suite.reportPath);
|
||||
const failureSignals = collectFailureSignals(report);
|
||||
const ok = execution.exitCode === 0 && failureSignals.length === 0 && !report.reportReadFailed;
|
||||
|
||||
const result = {
|
||||
...execution,
|
||||
reportPath: suite.reportPath,
|
||||
ok,
|
||||
failureSignals,
|
||||
report,
|
||||
};
|
||||
|
||||
summary.suites.push(result);
|
||||
process.stdout.write(
|
||||
`[live-full-core-audit] DONE ${suite.name} ok=${ok} exitCode=${execution.exitCode ?? 'null'} ` +
|
||||
`signals=${failureSignals.length} durationMs=${execution.durationMs}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
summary.failedSuiteCount = summary.suites.filter((suite) => !suite.ok).length;
|
||||
summary.passedSuiteCount = summary.suiteCount - summary.failedSuiteCount;
|
||||
summary.failedSuites = summary.suites
|
||||
.filter((suite) => !suite.ok)
|
||||
.map((suite) => ({
|
||||
name: suite.name,
|
||||
exitCode: suite.exitCode,
|
||||
signal: suite.signal,
|
||||
failureSignals: suite.failureSignals,
|
||||
reportPath: suite.reportPath,
|
||||
}));
|
||||
|
||||
await writeFile(resultPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
|
||||
|
||||
if (summary.failedSuiteCount > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -239,6 +239,28 @@ async function waitForAnyButton(page, names, timeoutMs = ELEMENT_WAIT_MS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function waitForEnabledButton(page, names, timeoutMs = ELEMENT_WAIT_MS) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
for (const name of names) {
|
||||
const locator = page.getByRole('button', { name });
|
||||
const count = await locator.count();
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const candidate = locator.nth(index);
|
||||
const disabled = await candidate.isDisabled().catch(() => true);
|
||||
if (!disabled) {
|
||||
return { name, locator: candidate };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function clickLink(context, page, route, name, index = 0) {
|
||||
await navigate(page, route);
|
||||
const target = await waitForNavigationTarget(page, name, index);
|
||||
@@ -380,8 +402,8 @@ async function exerciseShadowResults(page) {
|
||||
let restoredDisabledState = false;
|
||||
|
||||
if (initiallyDisabled) {
|
||||
const enableButton = page.getByRole('button', { name: 'Enable' }).first();
|
||||
if ((await enableButton.count()) === 0) {
|
||||
const enableTarget = await waitForEnabledButton(page, ['Enable Shadow Mode', /^Enable$/], 12_000);
|
||||
if (!enableTarget) {
|
||||
return {
|
||||
action: 'button:View Results',
|
||||
ok: false,
|
||||
@@ -391,16 +413,13 @@ async function exerciseShadowResults(page) {
|
||||
};
|
||||
}
|
||||
|
||||
await enableButton.click({ timeout: 10_000 });
|
||||
await enableTarget.locator.click({ timeout: 10_000 });
|
||||
enabledInFlow = true;
|
||||
await Promise.race([
|
||||
page.waitForFunction(() => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
const button = buttons.find((candidate) => (candidate.textContent || '').trim() === 'View Results');
|
||||
return button instanceof HTMLButtonElement && !button.disabled;
|
||||
}, null, { timeout: 12_000 }).catch(() => {}),
|
||||
page.waitForTimeout(2_000),
|
||||
]);
|
||||
await page.waitForFunction(() => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
const button = buttons.find((candidate) => (candidate.textContent || '').trim() === 'View Results');
|
||||
return button instanceof HTMLButtonElement && !button.disabled;
|
||||
}, null, { timeout: 12_000 }).catch(() => {});
|
||||
steps.push({
|
||||
step: 'enable-shadow-mode',
|
||||
snapshot: await captureSnapshot(page, 'policy-simulation:enabled-shadow-mode'),
|
||||
|
||||
@@ -20,7 +20,7 @@ const topologyScope = {
|
||||
timeWindow: '7d',
|
||||
};
|
||||
const topologyScopeQuery = new URLSearchParams(topologyScope).toString();
|
||||
const STEP_TIMEOUT_MS = 30_000;
|
||||
const STEP_TIMEOUT_MS = 60_000;
|
||||
const GENERIC_TITLES = new Set(['StellaOps', 'Stella Ops Dashboard']);
|
||||
const ROUTE_READINESS = [
|
||||
{
|
||||
@@ -41,17 +41,17 @@ const ROUTE_READINESS = [
|
||||
{
|
||||
path: '/setup/topology/targets',
|
||||
title: 'Targets - StellaOps',
|
||||
markers: ['No targets for current filters.', 'Select a target row to view its topology mapping details.'],
|
||||
markers: ['Targets', 'Selected Target', 'No targets for current filters.'],
|
||||
},
|
||||
{
|
||||
path: '/setup/topology/hosts',
|
||||
title: 'Hosts - StellaOps',
|
||||
markers: ['No hosts for current filters.', 'Select a host row to inspect runtime drift and impact.'],
|
||||
markers: ['Hosts', 'Selected Host', 'No hosts for current filters.'],
|
||||
},
|
||||
{
|
||||
path: '/setup/topology/agents',
|
||||
title: 'Agent Fleet - StellaOps',
|
||||
markers: ['No groups for current filters.', 'All Agents', 'View Targets'],
|
||||
markers: ['Agent Groups', 'All Agents', 'No groups for current filters.'],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -135,6 +135,10 @@ function routeReadiness(pathname) {
|
||||
return ROUTE_READINESS.find((entry) => entry.path === pathname) ?? null;
|
||||
}
|
||||
|
||||
function normalizeReadyTitle(value) {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, '');
|
||||
}
|
||||
|
||||
async function waitForRouteReady(page, routeOrPath) {
|
||||
const expectedPath = routePath(routeOrPath);
|
||||
const readiness = routeReadiness(expectedPath);
|
||||
@@ -165,7 +169,12 @@ async function waitForRouteReady(page, routeOrPath) {
|
||||
}
|
||||
|
||||
const title = document.title.trim();
|
||||
if (title.length === 0 || genericTitles.includes(title) || title !== expectedTitle) {
|
||||
const normalizedTitle = title.toLowerCase().replace(/[^a-z0-9]+/g, '');
|
||||
if (
|
||||
title.length === 0 ||
|
||||
genericTitles.includes(title) ||
|
||||
!normalizedTitle.includes(expectedTitle)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -174,7 +183,7 @@ async function waitForRouteReady(page, routeOrPath) {
|
||||
},
|
||||
{
|
||||
expectedPathValue: expectedPath,
|
||||
expectedTitle: readiness.title,
|
||||
expectedTitle: normalizeReadyTitle(readiness.title),
|
||||
markers: readiness.markers,
|
||||
genericTitles: [...GENERIC_TITLES],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const webRoot = path.resolve(__dirname, '..');
|
||||
const outputDir = path.join(webRoot, 'output', 'playwright');
|
||||
const outputPath = path.join(outputDir, 'live-uncovered-surface-action-sweep.json');
|
||||
const authStatePath = path.join(outputDir, 'live-uncovered-surface-action-sweep.state.json');
|
||||
const authReportPath = path.join(outputDir, 'live-uncovered-surface-action-sweep.auth.json');
|
||||
|
||||
const baseUrl = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local';
|
||||
const scopeQuery = 'tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d';
|
||||
|
||||
const linkChecks = [
|
||||
['/releases/overview', 'Release Versions', '/releases/versions'],
|
||||
['/releases/overview', 'Release Runs', '/releases/runs'],
|
||||
['/releases/overview', 'Approvals Queue', '/releases/approvals'],
|
||||
['/releases/overview', 'Hotfixes', '/releases/hotfixes'],
|
||||
['/releases/overview', 'Promotions', '/releases/promotions'],
|
||||
['/releases/overview', 'Deployment History', '/releases/deployments'],
|
||||
['/releases/runs', 'Timeline', '/releases/runs?view=timeline'],
|
||||
['/releases/runs', 'Table', '/releases/runs?view=table'],
|
||||
['/releases/runs', 'Correlations', '/releases/runs?view=correlations'],
|
||||
['/releases/approvals', 'Pending', '/releases/approvals?tab=pending'],
|
||||
['/releases/approvals', 'Approved', '/releases/approvals?tab=approved'],
|
||||
['/releases/approvals', 'Rejected', '/releases/approvals?tab=rejected'],
|
||||
['/releases/approvals', 'Expiring', '/releases/approvals?tab=expiring'],
|
||||
['/releases/approvals', 'My Team', '/releases/approvals?tab=my-team'],
|
||||
['/releases/environments', 'Open Environment', '/setup/topology/environments/stage/posture'],
|
||||
['/releases/environments', 'Open Targets', '/setup/topology/targets'],
|
||||
['/releases/environments', 'Open Agents', '/setup/topology/agents'],
|
||||
['/releases/environments', 'Open Runs', '/releases/runs'],
|
||||
['/releases/investigation/deploy-diff', 'Open Deployments', '/releases/deployments'],
|
||||
['/releases/investigation/deploy-diff', 'Open Releases Overview', '/releases/overview'],
|
||||
['/releases/investigation/change-trace', 'Open Deployments', '/releases/deployments'],
|
||||
['/security/posture', 'Open triage', '/security/triage'],
|
||||
['/security/posture', 'Disposition', '/security/disposition'],
|
||||
['/security/posture', 'Configure sources', '/ops/integrations/advisory-vex-sources'],
|
||||
['/security/posture', 'Open reachability coverage board', '/security/reachability'],
|
||||
['/security/advisories-vex', 'Providers', '/security/advisories-vex?tab=providers'],
|
||||
['/security/advisories-vex', 'VEX Library', '/security/advisories-vex?tab=vex-library'],
|
||||
['/security/advisories-vex', 'Issuer Trust', '/security/advisories-vex?tab=issuer-trust'],
|
||||
['/security/disposition', 'Conflicts', '/security/disposition?tab=conflicts'],
|
||||
['/security/supply-chain-data', 'SBOM Graph', '/security/supply-chain-data/graph'],
|
||||
['/security/supply-chain-data', 'Reachability', '/security/reachability'],
|
||||
['/evidence/overview', 'Audit Log', '/evidence/audit-log'],
|
||||
['/evidence/overview', 'Export Center', '/evidence/exports'],
|
||||
['/evidence/overview', 'Replay & Verify', '/evidence/verify-replay'],
|
||||
['/evidence/audit-log', 'View All Events', '/evidence/audit-log/events'],
|
||||
['/evidence/audit-log', 'Export', '/evidence/exports'],
|
||||
['/evidence/audit-log', /Policy Audit/i, '/evidence/audit-log/policy'],
|
||||
['/ops/operations/health-slo', 'View Full Timeline', '/ops/operations/health-slo/incidents'],
|
||||
['/ops/operations/feeds-airgap', 'Configure Sources', '/ops/integrations/advisory-vex-sources'],
|
||||
['/ops/operations/feeds-airgap', 'Open Offline Bundles', '/ops/operations/offline-kit/bundles'],
|
||||
['/ops/operations/feeds-airgap', 'Version Locks', '/ops/operations/feeds-airgap?tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d&tab=version-locks'],
|
||||
['/ops/operations/data-integrity', 'Feeds Freshness WARN Impact: BLOCKING NVD feed stale by 3h 12m', '/ops/operations/data-integrity/feeds-freshness'],
|
||||
['/ops/operations/data-integrity', 'Hotfix 1.2.4', '/releases/approvals?releaseId=rel-hotfix-124'],
|
||||
['/ops/operations/jobengine', 'Scheduler Runs', '/ops/operations/scheduler/runs'],
|
||||
['/ops/operations/jobengine', /Execution Quotas/i, '/ops/operations/jobengine/quotas'],
|
||||
['/ops/operations/offline-kit', 'Bundles', '/ops/operations/offline-kit/bundles'],
|
||||
['/ops/operations/offline-kit', 'JWKS', '/ops/operations/offline-kit/jwks'],
|
||||
];
|
||||
|
||||
const buttonChecks = [
|
||||
['/releases/versions', 'Create Release Version', '/releases/versions/new'],
|
||||
['/releases/versions', 'Create Hotfix Run', '/releases/versions/new'],
|
||||
['/releases/versions', 'Search'],
|
||||
['/releases/versions', 'Clear'],
|
||||
['/releases/investigation/timeline', 'Export'],
|
||||
['/releases/investigation/change-trace', 'Export'],
|
||||
['/security/sbom-lake', 'Refresh'],
|
||||
['/security/sbom-lake', 'Clear'],
|
||||
['/security/reachability', 'Witnesses'],
|
||||
['/security/reachability', /PoE|Proof of Exposure/i],
|
||||
['/evidence/capsules', 'Search'],
|
||||
['/evidence/threads', 'Search'],
|
||||
['/evidence/proofs', 'Search'],
|
||||
['/ops/operations/system-health', 'Services'],
|
||||
['/ops/operations/system-health', 'Incidents'],
|
||||
['/ops/operations/system-health', 'Quick Diagnostics'],
|
||||
['/ops/operations/scheduler', 'Manage Schedules', '/ops/operations/scheduler/schedules'],
|
||||
['/ops/operations/scheduler', 'Worker Fleet', '/ops/operations/scheduler/workers'],
|
||||
['/ops/operations/doctor', 'Quick Check'],
|
||||
['/ops/operations/signals', 'Refresh'],
|
||||
['/ops/operations/packs', 'Refresh'],
|
||||
['/ops/operations/status', 'Refresh'],
|
||||
];
|
||||
|
||||
function buildUrl(route) {
|
||||
const separator = route.includes('?') ? '&' : '?';
|
||||
return `${baseUrl}${route}${separator}${scopeQuery}`;
|
||||
}
|
||||
|
||||
async function settle(page) {
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {});
|
||||
await page.waitForTimeout(1_500);
|
||||
}
|
||||
|
||||
async function captureSnapshot(page, label) {
|
||||
const alerts = await page
|
||||
.locator('[role="alert"], .mat-mdc-snack-bar-container, .toast, .notification, .error-banner, .warning-banner')
|
||||
.evaluateAll((nodes) =>
|
||||
nodes
|
||||
.map((node) => (node.textContent || '').trim().replace(/\s+/g, ' '))
|
||||
.filter(Boolean)
|
||||
.slice(0, 5),
|
||||
)
|
||||
.catch(() => []);
|
||||
|
||||
return {
|
||||
label,
|
||||
url: page.url(),
|
||||
title: await page.title().catch(() => ''),
|
||||
heading: await page.locator('h1, h2, [data-testid="page-title"], .page-title').first().innerText().catch(() => ''),
|
||||
alerts,
|
||||
};
|
||||
}
|
||||
|
||||
async function navigate(page, route) {
|
||||
await page.goto(buildUrl(route), { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
async function findLink(page, name, timeoutMs = 10_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const link = page.getByRole('link', { name }).first();
|
||||
if (await link.count()) {
|
||||
return link;
|
||||
}
|
||||
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function matchesLocatorName(name, text) {
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedText = text.trim();
|
||||
if (!normalizedText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (name instanceof RegExp) {
|
||||
return name.test(normalizedText);
|
||||
}
|
||||
|
||||
return normalizedText.includes(name);
|
||||
}
|
||||
|
||||
async function findScopedLink(page, scopeSelector, name) {
|
||||
const scope = page.locator(scopeSelector).first();
|
||||
if (!(await scope.count())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const descendantLink = scope.getByRole('link', { name }).first();
|
||||
if (await descendantLink.count()) {
|
||||
return descendantLink;
|
||||
}
|
||||
|
||||
const [tagName, href, text] = await Promise.all([
|
||||
scope.evaluate((element) => element.tagName.toLowerCase()).catch(() => ''),
|
||||
scope.getAttribute('href').catch(() => null),
|
||||
scope.textContent().catch(() => ''),
|
||||
]);
|
||||
|
||||
if (tagName === 'a' && href && matchesLocatorName(name, text ?? '')) {
|
||||
return scope;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function findLinkForRoute(page, route, name, timeoutMs = 10_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
let locator = null;
|
||||
|
||||
if (route === '/releases/environments' && name === 'Open Environment') {
|
||||
locator = page.locator('.actions').getByRole('link', { name }).first();
|
||||
} else if (route === '/ops/operations/jobengine' && name instanceof RegExp && String(name) === String(/Execution Quotas/i)) {
|
||||
locator = await findScopedLink(page, '[data-testid="jobengine-quotas-card"]', name);
|
||||
} else if (route === '/ops/operations/offline-kit' && name === 'Bundles') {
|
||||
locator = page.locator('.tab-nav').getByRole('link', { name }).first();
|
||||
} else {
|
||||
locator = page.getByRole('link', { name }).first();
|
||||
}
|
||||
|
||||
if (await locator.count()) {
|
||||
return locator;
|
||||
}
|
||||
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function findButton(page, name, timeoutMs = 10_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
for (const role of ['button', 'tab']) {
|
||||
const button = page.getByRole(role, { name }).first();
|
||||
if (await button.count()) {
|
||||
return button;
|
||||
}
|
||||
}
|
||||
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeUrl(url) {
|
||||
return decodeURIComponent(url);
|
||||
}
|
||||
|
||||
function shouldIgnoreConsoleError(message) {
|
||||
return message === 'Failed to load resource: the server responded with a status of 401 ()';
|
||||
}
|
||||
|
||||
function shouldIgnoreRequestFailure(request) {
|
||||
return request.failure === 'net::ERR_ABORTED';
|
||||
}
|
||||
|
||||
function shouldIgnoreResponseError(response) {
|
||||
return response.status === 401 && /\/doctor\/api\/v1\/doctor\/run\/[^/]+\/stream$/i.test(response.url);
|
||||
}
|
||||
|
||||
async function runLinkCheck(page, route, name, expectedPath) {
|
||||
const action = `${route} -> link:${name}`;
|
||||
try {
|
||||
await navigate(page, route);
|
||||
const link = await findLinkForRoute(page, route, name);
|
||||
if (
|
||||
!link &&
|
||||
route === '/ops/operations/jobengine' &&
|
||||
name instanceof RegExp &&
|
||||
String(name) === String(/Execution Quotas/i)
|
||||
) {
|
||||
const quotasCardText = await page.locator('[data-testid="jobengine-quotas-card"]').textContent().catch(() => '');
|
||||
const snapshot = await captureSnapshot(page, action);
|
||||
return {
|
||||
action,
|
||||
ok: /access required to manage quotas/i.test(quotasCardText ?? ''),
|
||||
expectedPath,
|
||||
finalUrl: normalizeUrl(snapshot.url),
|
||||
snapshot,
|
||||
reason: 'restricted-card',
|
||||
};
|
||||
}
|
||||
|
||||
if (!link) {
|
||||
return { action, ok: false, reason: 'missing-link', snapshot: await captureSnapshot(page, action) };
|
||||
}
|
||||
|
||||
await link.click({ timeout: 10_000 });
|
||||
await settle(page);
|
||||
const snapshot = await captureSnapshot(page, action);
|
||||
const finalUrl = normalizeUrl(snapshot.url);
|
||||
return {
|
||||
action,
|
||||
ok: finalUrl.includes(expectedPath),
|
||||
expectedPath,
|
||||
finalUrl,
|
||||
snapshot,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
action,
|
||||
ok: false,
|
||||
reason: 'exception',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
snapshot: await captureSnapshot(page, action),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function runButtonCheck(page, route, name, expectedPath = null) {
|
||||
const action = `${route} -> button:${name}`;
|
||||
try {
|
||||
await navigate(page, route);
|
||||
const button = await findButton(page, name);
|
||||
if (!button) {
|
||||
return { action, ok: false, reason: 'missing-button', snapshot: await captureSnapshot(page, action) };
|
||||
}
|
||||
|
||||
const disabled = await button.isDisabled().catch(() => false);
|
||||
if (disabled) {
|
||||
const snapshot = await captureSnapshot(page, action);
|
||||
return {
|
||||
action,
|
||||
ok: true,
|
||||
reason: 'disabled-by-design',
|
||||
expectedPath,
|
||||
finalUrl: normalizeUrl(snapshot.url),
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
await button.click({ timeout: 10_000 });
|
||||
await settle(page);
|
||||
const snapshot = await captureSnapshot(page, action);
|
||||
const finalUrl = normalizeUrl(snapshot.url);
|
||||
const hasRuntimeAlert = snapshot.alerts.some((text) => /(error|failed|unable|timed out|unavailable)/i.test(text));
|
||||
return {
|
||||
action,
|
||||
ok: expectedPath ? finalUrl.includes(expectedPath) : snapshot.heading.trim().length > 0 && !hasRuntimeAlert,
|
||||
expectedPath,
|
||||
finalUrl,
|
||||
snapshot,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
action,
|
||||
ok: false,
|
||||
reason: 'exception',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
snapshot: await captureSnapshot(page, action),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: process.env.PLAYWRIGHT_HEADLESS !== 'false',
|
||||
});
|
||||
|
||||
const auth = await authenticateFrontdoor({ statePath: authStatePath, reportPath: authReportPath });
|
||||
const context = await createAuthenticatedContext(browser, auth, { statePath: auth.statePath });
|
||||
const page = await context.newPage();
|
||||
|
||||
const results = [];
|
||||
const runtime = {
|
||||
consoleErrors: [],
|
||||
pageErrors: [],
|
||||
requestFailures: [],
|
||||
responseErrors: [],
|
||||
};
|
||||
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error' && !shouldIgnoreConsoleError(message.text())) {
|
||||
runtime.consoleErrors.push(message.text());
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) => runtime.pageErrors.push(error.message));
|
||||
page.on('requestfailed', (request) => {
|
||||
const failure = request.failure()?.errorText ?? 'unknown';
|
||||
if (!shouldIgnoreRequestFailure({ url: request.url(), failure })) {
|
||||
runtime.requestFailures.push({ url: request.url(), failure });
|
||||
}
|
||||
});
|
||||
page.on('response', (response) => {
|
||||
if (response.status() >= 400) {
|
||||
const candidate = { url: response.url(), status: response.status() };
|
||||
if (!shouldIgnoreResponseError(candidate)) {
|
||||
runtime.responseErrors.push(candidate);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
for (const [route, name, expectedPath] of linkChecks) {
|
||||
process.stdout.write(`[live-uncovered-surface-action-sweep] START ${route} -> link:${name}\n`);
|
||||
const result = await runLinkCheck(page, route, name, expectedPath);
|
||||
process.stdout.write(
|
||||
`[live-uncovered-surface-action-sweep] DONE ${route} -> link:${name} ok=${result.ok}\n`,
|
||||
);
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
for (const [route, name, expectedPath] of buttonChecks) {
|
||||
process.stdout.write(`[live-uncovered-surface-action-sweep] START ${route} -> button:${name}\n`);
|
||||
const result = await runButtonCheck(page, route, name, expectedPath);
|
||||
process.stdout.write(
|
||||
`[live-uncovered-surface-action-sweep] DONE ${route} -> button:${name} ok=${result.ok}\n`,
|
||||
);
|
||||
results.push(result);
|
||||
}
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
|
||||
const failedActions = results.filter((result) => !result.ok);
|
||||
const report = {
|
||||
generatedAtUtc: new Date().toISOString(),
|
||||
baseUrl,
|
||||
actionCount: results.length,
|
||||
passedActionCount: results.length - failedActions.length,
|
||||
failedActionCount: failedActions.length,
|
||||
failedActions,
|
||||
results,
|
||||
runtime,
|
||||
runtimeIssueCount:
|
||||
runtime.consoleErrors.length + runtime.pageErrors.length + runtime.requestFailures.length + runtime.responseErrors.length,
|
||||
};
|
||||
|
||||
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
||||
|
||||
if (failedActions.length > 0 || report.runtimeIssueCount > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,548 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const webRoot = path.resolve(__dirname, '..');
|
||||
const outputDir = path.join(webRoot, 'output', 'playwright');
|
||||
const outputPath = path.join(outputDir, 'live-user-reported-admin-trust-check.json');
|
||||
const authStatePath = path.join(outputDir, 'live-user-reported-admin-trust-check.state.json');
|
||||
const authReportPath = path.join(outputDir, 'live-user-reported-admin-trust-check.auth.json');
|
||||
const baseUrl = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local';
|
||||
const scopeQuery = 'tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d';
|
||||
|
||||
function buildUrl(route) {
|
||||
const separator = route.includes('?') ? '&' : '?';
|
||||
return `${baseUrl}${route}${separator}${scopeQuery}`;
|
||||
}
|
||||
|
||||
async function settle(page, ms = 1500) {
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {});
|
||||
await page.waitForTimeout(ms);
|
||||
}
|
||||
|
||||
async function navigate(page, route) {
|
||||
await page.goto(buildUrl(route), { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
async function snapshot(page, label) {
|
||||
const heading = await page.locator('h1, h2, [data-testid="page-title"], .page-title').first().innerText().catch(() => '');
|
||||
const alerts = await page
|
||||
.locator('[role="alert"], .alert, .error-banner, .success-banner, .loading-text, .trust-admin__error, .trust-admin__loading')
|
||||
.evaluateAll((nodes) => nodes.map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, 8))
|
||||
.catch(() => []);
|
||||
return {
|
||||
label,
|
||||
url: page.url(),
|
||||
title: await page.title().catch(() => ''),
|
||||
heading,
|
||||
alerts,
|
||||
};
|
||||
}
|
||||
|
||||
function boxesOverlap(left, right) {
|
||||
if (!left || !right) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return !(
|
||||
left.x + left.width <= right.x ||
|
||||
right.x + right.width <= left.x ||
|
||||
left.y + left.height <= right.y ||
|
||||
right.y + right.height <= left.y
|
||||
);
|
||||
}
|
||||
|
||||
async function collectTrustTabState(page, tab) {
|
||||
const tableRowCount = await page.locator('tbody tr').count().catch(() => 0);
|
||||
const eventCardCount = await page.locator('.event-card').count().catch(() => 0);
|
||||
const emptyTexts = await page
|
||||
.locator('.key-dashboard__empty, .issuer-trust__empty, .certificate-inventory__empty, .trust-audit-log__empty, .empty-state')
|
||||
.evaluateAll((nodes) => nodes.map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, 6))
|
||||
.catch(() => []);
|
||||
const loadingTexts = await page
|
||||
.locator('.key-dashboard__loading, .trust-admin__loading, .issuer-trust__loading, .certificate-inventory__loading, .trust-audit-log__loading, .loading-text')
|
||||
.evaluateAll((nodes) => nodes.map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, 6))
|
||||
.catch(() => []);
|
||||
const primaryButtons = await page
|
||||
.locator('button')
|
||||
.evaluateAll((nodes) =>
|
||||
nodes
|
||||
.map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 8),
|
||||
)
|
||||
.catch(() => []);
|
||||
|
||||
return {
|
||||
tab,
|
||||
tableRowCount,
|
||||
eventCardCount,
|
||||
emptyTexts,
|
||||
loadingTexts,
|
||||
primaryButtons,
|
||||
};
|
||||
}
|
||||
|
||||
async function createRoleCheck(page) {
|
||||
const roleName = `qa-role-${Date.now()}`;
|
||||
await page.getByRole('button', { name: '+ Create Role' }).click();
|
||||
await settle(page, 750);
|
||||
await page.locator('input[placeholder="security-analyst"]').fill(roleName);
|
||||
await page.locator('input[placeholder="Security analyst with triage access"]').fill('QA-created role');
|
||||
await page.locator('textarea[placeholder*="findings:read"]').fill('findings:read, vex:read');
|
||||
await page.getByRole('button', { name: 'Create Role', exact: true }).click();
|
||||
await settle(page, 1250);
|
||||
|
||||
const successText = await page.locator('.success-banner').first().textContent().then((text) => text?.trim() || '').catch(() => '');
|
||||
const tableContainsRole = await page.locator('tbody tr td:first-child').evaluateAll(
|
||||
(cells, expected) => cells.some((cell) => (cell.textContent || '').replace(/\s+/g, ' ').trim() === expected),
|
||||
roleName,
|
||||
).catch(() => false);
|
||||
|
||||
return {
|
||||
roleName,
|
||||
successText,
|
||||
tableContainsRole,
|
||||
};
|
||||
}
|
||||
|
||||
async function createTenantCheck(page) {
|
||||
const tenantId = `qa-tenant-${Date.now()}`;
|
||||
const displayName = `QA Tenant ${Date.now()}`;
|
||||
await page.getByRole('button', { name: '+ Add Tenant' }).click();
|
||||
await settle(page, 750);
|
||||
await page.locator('input[placeholder="customer-stage"]').fill(tenantId);
|
||||
await page.locator('input[placeholder="Customer Stage"]').fill(displayName);
|
||||
await page.locator('select').last().selectOption('shared').catch(() => {});
|
||||
await page.getByRole('button', { name: 'Create Tenant', exact: true }).click();
|
||||
await settle(page, 1250);
|
||||
|
||||
const successText = await page.locator('.success-banner').first().textContent().then((text) => text?.trim() || '').catch(() => '');
|
||||
const tableContainsTenant = await page.locator('tbody tr td:first-child').evaluateAll(
|
||||
(cells, expected) => cells.some((cell) => (cell.textContent || '').replace(/\s+/g, ' ').trim() === expected),
|
||||
displayName,
|
||||
).catch(() => false);
|
||||
|
||||
return {
|
||||
tenantId,
|
||||
displayName,
|
||||
successText,
|
||||
tableContainsTenant,
|
||||
};
|
||||
}
|
||||
|
||||
async function collectReportsTabState(page, tab) {
|
||||
await page.getByRole('tab', { name: tab }).click();
|
||||
await settle(page, 1000);
|
||||
|
||||
return {
|
||||
tab,
|
||||
url: page.url(),
|
||||
headings: await page.locator('h1, h2, h3').evaluateAll((nodes) =>
|
||||
nodes.map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, 16)
|
||||
).catch(() => []),
|
||||
primaryButtons: await page.locator('main button').evaluateAll((nodes) =>
|
||||
nodes.map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, 12)
|
||||
).catch(() => []),
|
||||
};
|
||||
}
|
||||
|
||||
async function runSearchQueryCheck(page, query) {
|
||||
const searchInput = page.locator('input[aria-label="Global search"]').first();
|
||||
const responses = [];
|
||||
const responseListener = async (response) => {
|
||||
if (!response.url().includes('/api/v1/search/query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
responses.push({
|
||||
status: response.status(),
|
||||
url: response.url(),
|
||||
body: await response.json(),
|
||||
});
|
||||
} catch {
|
||||
responses.push({
|
||||
status: response.status(),
|
||||
url: response.url(),
|
||||
body: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
page.on('response', responseListener);
|
||||
try {
|
||||
await searchInput.click();
|
||||
await searchInput.fill(query);
|
||||
await settle(page, 2500);
|
||||
|
||||
const cards = await page.locator('.entity-card').evaluateAll((nodes) =>
|
||||
nodes.slice(0, 5).map((node) => {
|
||||
const title = node.querySelector('.entity-card__title')?.textContent?.trim() || '';
|
||||
const domain = node.querySelector('.entity-card__badge')?.textContent?.trim() || '';
|
||||
const snippet = node.querySelector('.entity-card__snippet')?.textContent?.replace(/\s+/g, ' ').trim() || '';
|
||||
const actions = Array.from(node.querySelectorAll('.entity-card__action'))
|
||||
.map((button) => button.textContent?.replace(/\s+/g, ' ').trim() || '')
|
||||
.filter(Boolean);
|
||||
return { title, domain, snippet, actions };
|
||||
}),
|
||||
).catch(() => []);
|
||||
|
||||
const firstPrimaryAction = page.locator('.entity-card').first().locator('.entity-card__action--primary').first();
|
||||
const primaryActionLabel = await firstPrimaryAction.textContent().then((text) => text?.replace(/\s+/g, ' ').trim() || '').catch(() => '');
|
||||
await firstPrimaryAction.click({ timeout: 10000 }).catch(() => {});
|
||||
await settle(page, 1500);
|
||||
|
||||
const latestResponse = responses.at(-1)?.body;
|
||||
return {
|
||||
query,
|
||||
cards,
|
||||
primaryActionLabel,
|
||||
latestDiagnostics: latestResponse?.diagnostics ?? null,
|
||||
latestCardActions: (latestResponse?.cards ?? []).slice(0, 3).map((card) => ({
|
||||
title: card.title ?? '',
|
||||
domain: card.domain ?? '',
|
||||
actions: (card.actions ?? []).map((action) => ({
|
||||
label: action.label ?? '',
|
||||
actionType: action.actionType ?? '',
|
||||
route: action.route ?? '',
|
||||
})),
|
||||
})),
|
||||
finalUrl: page.url(),
|
||||
snapshot: await snapshot(page, `search:${query}`),
|
||||
};
|
||||
} finally {
|
||||
page.off('response', responseListener);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch({ headless: process.env.PLAYWRIGHT_HEADLESS !== 'false' });
|
||||
const auth = await authenticateFrontdoor({ statePath: authStatePath, reportPath: authReportPath });
|
||||
const context = await createAuthenticatedContext(browser, auth, { statePath: auth.statePath });
|
||||
const page = await context.newPage();
|
||||
|
||||
const results = [];
|
||||
|
||||
try {
|
||||
console.log('[live-user-reported-admin-trust-check] sidebar');
|
||||
await navigate(page, '/setup/identity-access');
|
||||
const setupGroupContainsDiagnostics = await page
|
||||
.locator('[role="group"][aria-label="Platform & Setup"]')
|
||||
.getByText('Diagnostics', { exact: true })
|
||||
.count()
|
||||
.then((count) => count > 0)
|
||||
.catch(() => false);
|
||||
results.push({
|
||||
action: 'sidebar:diagnostics-under-setup',
|
||||
setupGroupContainsDiagnostics,
|
||||
snapshot: await snapshot(page, 'sidebar:diagnostics-under-setup'),
|
||||
});
|
||||
|
||||
console.log('[live-user-reported-admin-trust-check] invalid-email');
|
||||
await page.getByRole('button', { name: 'Users' }).click();
|
||||
await settle(page, 500);
|
||||
await page.getByRole('button', { name: '+ Add User' }).click();
|
||||
await settle(page, 500);
|
||||
const emailInput = page.locator('input[type="email"]').first();
|
||||
await page.locator('input[placeholder="e.g. jane.doe"]').fill(`qa-user-${Date.now()}`);
|
||||
await emailInput.fill('not-an-email');
|
||||
await page.locator('input[placeholder="Jane Doe"]').fill('QA Invalid Email');
|
||||
const emailValidity = await emailInput.evaluate((input) => ({
|
||||
valid: input.checkValidity(),
|
||||
validationMessage: input.validationMessage,
|
||||
}));
|
||||
await page.getByRole('button', { name: 'Create User' }).click();
|
||||
await settle(page, 1000);
|
||||
results.push({
|
||||
action: 'identity-access:create-user-invalid-email',
|
||||
emailValidity,
|
||||
snapshot: await snapshot(page, 'identity-access:create-user-invalid-email'),
|
||||
});
|
||||
|
||||
console.log('[live-user-reported-admin-trust-check] roles');
|
||||
await page.getByRole('button', { name: 'Roles' }).click();
|
||||
await settle(page, 1000);
|
||||
const roleNames = await page.locator('tbody tr td:first-child').evaluateAll((cells) =>
|
||||
cells.map((cell) => (cell.textContent || '').replace(/\s+/g, ' ').trim()).filter((value) => value.length > 0),
|
||||
).catch(() => []);
|
||||
const roleCreate = await createRoleCheck(page);
|
||||
results.push({
|
||||
action: 'identity-access:roles-tab',
|
||||
roleNames,
|
||||
roleCreate,
|
||||
snapshot: await snapshot(page, 'identity-access:roles-tab'),
|
||||
});
|
||||
|
||||
console.log('[live-user-reported-admin-trust-check] tenants');
|
||||
await page.getByRole('button', { name: 'Tenants' }).click();
|
||||
await settle(page, 1000);
|
||||
const tenantCreate = await createTenantCheck(page);
|
||||
results.push({
|
||||
action: 'identity-access:tenants-tab',
|
||||
tenantCreate,
|
||||
snapshot: await snapshot(page, 'identity-access:tenants-tab'),
|
||||
});
|
||||
|
||||
console.log('[live-user-reported-admin-trust-check] trust-tabs');
|
||||
await navigate(page, '/setup/trust-signing');
|
||||
for (const tab of ['Signing Keys', 'Trusted Issuers', 'Certificates', 'Audit Log']) {
|
||||
await page.getByRole('tab', { name: tab }).click();
|
||||
await settle(page, 1500);
|
||||
results.push({
|
||||
action: `trust-signing:${tab}`,
|
||||
detail: await collectTrustTabState(page, tab),
|
||||
snapshot: await snapshot(page, `trust-signing:${tab}`),
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[live-user-reported-admin-trust-check] reports-tabs');
|
||||
await navigate(page, '/security/reports');
|
||||
results.push({
|
||||
action: 'security-reports:tabs-embedded',
|
||||
detail: [
|
||||
await collectReportsTabState(page, 'Risk Report'),
|
||||
await collectReportsTabState(page, 'VEX Ledger'),
|
||||
await collectReportsTabState(page, 'Evidence Export'),
|
||||
],
|
||||
snapshot: await snapshot(page, 'security-reports:tabs-embedded'),
|
||||
});
|
||||
|
||||
console.log('[live-user-reported-admin-trust-check] triage');
|
||||
await navigate(page, '/security/triage');
|
||||
const triageRawSvgTextVisible = await page.locator('main').innerText().then((text) => /<svg|stroke-width|viewBox=/.test(text)).catch(() => false);
|
||||
results.push({
|
||||
action: 'security-triage:raw-svg-visible',
|
||||
triageRawSvgTextVisible,
|
||||
snapshot: await snapshot(page, 'security-triage:raw-svg-visible'),
|
||||
});
|
||||
|
||||
console.log('[live-user-reported-admin-trust-check] decision-capsules');
|
||||
await navigate(page, '/evidence/capsules');
|
||||
const capsulesSearchInput = page.locator('.filter-bar__search-input').first();
|
||||
const capsulesSearchButton = page.locator('.filter-bar__search-btn').first();
|
||||
const capsulesSearchInputBox = await capsulesSearchInput.boundingBox().catch(() => null);
|
||||
const capsulesSearchButtonBox = await capsulesSearchButton.boundingBox().catch(() => null);
|
||||
results.push({
|
||||
action: 'decision-capsules:search-layout',
|
||||
overlaps: boxesOverlap(capsulesSearchInputBox, capsulesSearchButtonBox),
|
||||
inputBox: capsulesSearchInputBox,
|
||||
buttonBox: capsulesSearchButtonBox,
|
||||
snapshot: await snapshot(page, 'decision-capsules:search-layout'),
|
||||
});
|
||||
|
||||
console.log('[live-user-reported-admin-trust-check] global-search');
|
||||
await navigate(page, '/mission-control/board');
|
||||
results.push({
|
||||
action: 'global-search:cve',
|
||||
detail: await runSearchQueryCheck(page, 'cve'),
|
||||
});
|
||||
|
||||
console.log('[live-user-reported-admin-trust-check] docs');
|
||||
await navigate(page, '/docs/modules/platform/architecture-overview.md');
|
||||
results.push({
|
||||
action: 'docs:architecture-overview',
|
||||
snapshot: await snapshot(page, 'docs:architecture-overview'),
|
||||
bodyPreview: await page.locator('.docs-viewer__content').innerText().then((text) => text.slice(0, 240)).catch(() => ''),
|
||||
});
|
||||
|
||||
console.log('[live-user-reported-admin-trust-check] branding');
|
||||
await navigate(page, '/setup/tenant-branding');
|
||||
const titleInput = page.locator('#title');
|
||||
const applyButton = page.getByRole('button', { name: 'Apply Changes' });
|
||||
const tokenKeyInput = page.locator('input[placeholder="--theme-custom-color"]').first();
|
||||
const tokenValueInput = page.locator('input[placeholder="var(--color-text-heading)"]').first();
|
||||
const updatedTitle = `Stella Ops QA ${Date.now()}`;
|
||||
await titleInput.fill(updatedTitle).catch(() => {});
|
||||
await settle(page, 300);
|
||||
const applyDisabled = await applyButton.isDisabled().catch(() => null);
|
||||
await tokenKeyInput.fill(`--theme-qa-${Date.now()}`).catch(() => {});
|
||||
await tokenValueInput.fill('#123456').catch(() => {});
|
||||
await settle(page, 200);
|
||||
const addTokenButton = page.getByRole('button', { name: 'Add Token' });
|
||||
const addTokenDisabled = await addTokenButton.isDisabled().catch(() => null);
|
||||
await addTokenButton.click().catch(() => {});
|
||||
await settle(page, 500);
|
||||
const tokenFormCleared =
|
||||
(await tokenKeyInput.inputValue().catch(() => '')) === '' &&
|
||||
(await tokenValueInput.inputValue().catch(() => '')) === '';
|
||||
|
||||
let applyResult = {
|
||||
successText: '',
|
||||
errorText: '',
|
||||
url: page.url(),
|
||||
freshAuthPrompt: '',
|
||||
};
|
||||
let persistedBranding = {
|
||||
titleValue: '',
|
||||
matchesUpdatedTitle: false,
|
||||
};
|
||||
if (applyDisabled === false) {
|
||||
console.log('[live-user-reported-admin-trust-check] branding-apply');
|
||||
page.once('dialog', async (dialog) => {
|
||||
applyResult.freshAuthPrompt = dialog.message();
|
||||
await dialog.accept().catch(() => {});
|
||||
});
|
||||
await applyButton.click().catch(() => {});
|
||||
await settle(page, 2500);
|
||||
applyResult = {
|
||||
successText: await page.locator('.success, .success-banner, .alert-success').first().textContent().then((text) => text?.trim() || '').catch(() => ''),
|
||||
errorText: await page.locator('.error, .error-banner, .alert-error').first().textContent().then((text) => text?.trim() || '').catch(() => ''),
|
||||
url: page.url(),
|
||||
freshAuthPrompt: applyResult.freshAuthPrompt,
|
||||
};
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
await navigate(page, '/setup/tenant-branding');
|
||||
persistedBranding = {
|
||||
titleValue: await page.locator('#title').inputValue().catch(() => ''),
|
||||
matchesUpdatedTitle: (await page.locator('#title').inputValue().catch(() => '')) === updatedTitle,
|
||||
};
|
||||
}
|
||||
results.push({
|
||||
action: 'tenant-branding:action-state',
|
||||
applyDisabled,
|
||||
addTokenDisabled,
|
||||
tokenFormCleared,
|
||||
applyResult,
|
||||
persistedBranding,
|
||||
snapshot: await snapshot(page, 'tenant-branding:action-state'),
|
||||
});
|
||||
} finally {
|
||||
console.log('[live-user-reported-admin-trust-check] cleanup');
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
|
||||
const report = {
|
||||
generatedAtUtc: new Date().toISOString(),
|
||||
baseUrl,
|
||||
results,
|
||||
};
|
||||
|
||||
const failures = [];
|
||||
const byAction = new Map(results.map((entry) => [entry.action, entry]));
|
||||
|
||||
if (!byAction.get('sidebar:diagnostics-under-setup')?.setupGroupContainsDiagnostics) {
|
||||
failures.push('Diagnostics is not grouped under Platform & Setup in the sidebar.');
|
||||
}
|
||||
|
||||
if (byAction.get('identity-access:create-user-invalid-email')?.emailValidity?.valid !== false) {
|
||||
failures.push('Identity & Access user creation did not reject an invalid email address.');
|
||||
}
|
||||
|
||||
if ((byAction.get('identity-access:roles-tab')?.roleNames?.length ?? 0) === 0) {
|
||||
failures.push('Identity & Access roles table still shows empty role names.');
|
||||
}
|
||||
|
||||
if (!byAction.get('identity-access:roles-tab')?.roleCreate?.tableContainsRole) {
|
||||
failures.push('Identity & Access role creation did not persist a new role in the table.');
|
||||
}
|
||||
|
||||
if (!byAction.get('identity-access:tenants-tab')?.tenantCreate?.tableContainsTenant) {
|
||||
failures.push('Identity & Access tenant creation did not persist a new tenant in the table.');
|
||||
}
|
||||
|
||||
for (const tab of ['Signing Keys', 'Trusted Issuers', 'Certificates', 'Audit Log']) {
|
||||
const detail = byAction.get(`trust-signing:${tab}`)?.detail;
|
||||
const resolved =
|
||||
(detail?.tableRowCount ?? 0) > 0 ||
|
||||
(detail?.eventCardCount ?? 0) > 0 ||
|
||||
(detail?.emptyTexts?.length ?? 0) > 0;
|
||||
const stillLoading = (detail?.loadingTexts?.length ?? 0) > 0;
|
||||
if (!resolved || stillLoading) {
|
||||
failures.push(`Trust & Signing tab "${tab}" did not resolve cleanly.`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const tabState of byAction.get('security-reports:tabs-embedded')?.detail ?? []) {
|
||||
if (!tabState.url.includes('/security/reports')) {
|
||||
failures.push(`Security Reports tab "${tabState.tab}" still navigates away instead of embedding its workspace.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (byAction.get('security-triage:raw-svg-visible')?.triageRawSvgTextVisible) {
|
||||
failures.push('Triage still renders SVG markup as raw code.');
|
||||
}
|
||||
|
||||
if (byAction.get('decision-capsules:search-layout')?.overlaps !== false) {
|
||||
failures.push('Decision Capsules search input still overlaps the search button.');
|
||||
}
|
||||
|
||||
const searchDetail = byAction.get('global-search:cve')?.detail;
|
||||
const topCardDomain = (searchDetail?.cards?.[0]?.domain ?? '').toLowerCase();
|
||||
const topCardTitle = searchDetail?.cards?.[0]?.title ?? '';
|
||||
const topCardActionLabels = (searchDetail?.latestCardActions?.[0]?.actions ?? []).map((action) => action.label);
|
||||
const topCardRoute = (searchDetail?.latestCardActions?.[0]?.actions ?? [])[0]?.route ?? '';
|
||||
const topCardHasDeadNavigation = (searchDetail?.latestCardActions?.[0]?.actions ?? [])
|
||||
.some((action) => action.actionType === 'navigate' && !action.route);
|
||||
const searchLandedOnDashboardFallback =
|
||||
searchDetail?.snapshot?.title === 'Dashboard - StellaOps' ||
|
||||
searchDetail?.snapshot?.heading === 'Dashboard';
|
||||
const searchLandedOnBlankDocs =
|
||||
searchDetail?.finalUrl?.includes('/docs/') &&
|
||||
!(searchDetail?.snapshot?.heading ?? '').trim();
|
||||
if (
|
||||
topCardDomain.includes('api') ||
|
||||
topCardDomain.includes('knowledge') ||
|
||||
topCardActionLabels.includes('Copy Curl') ||
|
||||
topCardHasDeadNavigation ||
|
||||
searchLandedOnDashboardFallback ||
|
||||
searchLandedOnBlankDocs
|
||||
) {
|
||||
failures.push(`Global search still misranks generic CVE queries or routes them into dead docs/dashboard fallback (top card: ${topCardTitle || 'unknown'}).`);
|
||||
}
|
||||
|
||||
const docsRoute = byAction.get('docs:architecture-overview');
|
||||
if (!(docsRoute?.snapshot?.heading ?? '').trim() || !(docsRoute?.bodyPreview ?? '').trim()) {
|
||||
failures.push('Direct /docs navigation still renders an empty or blank documentation page.');
|
||||
}
|
||||
|
||||
if (byAction.get('tenant-branding:action-state')?.applyDisabled !== false) {
|
||||
failures.push('Tenant & Branding apply action did not become available after editing.');
|
||||
}
|
||||
|
||||
if (byAction.get('tenant-branding:action-state')?.tokenFormCleared !== true) {
|
||||
failures.push('Tenant & Branding add-token action did not complete cleanly.');
|
||||
}
|
||||
|
||||
const brandingApplyResult = byAction.get('tenant-branding:action-state')?.applyResult;
|
||||
if (
|
||||
byAction.get('tenant-branding:action-state')?.applyDisabled === false &&
|
||||
!brandingApplyResult?.successText &&
|
||||
!brandingApplyResult?.errorText
|
||||
) {
|
||||
failures.push('Tenant & Branding apply action did not produce a visible success or error outcome.');
|
||||
}
|
||||
|
||||
if (
|
||||
byAction.get('tenant-branding:action-state')?.applyDisabled === false &&
|
||||
byAction.get('tenant-branding:action-state')?.persistedBranding?.matchesUpdatedTitle !== true
|
||||
) {
|
||||
failures.push('Tenant & Branding apply action did not persist the updated title after reload.');
|
||||
}
|
||||
|
||||
report.failures = failures;
|
||||
report.failedCheckCount = failures.length;
|
||||
report.ok = failures.length === 0;
|
||||
|
||||
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
||||
|
||||
if (failures.length > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -166,6 +166,21 @@ export const routes: Routes = [
|
||||
data: { breadcrumb: 'Settings' },
|
||||
loadChildren: () => import('./features/settings/settings.routes').then((m) => m.SETTINGS_ROUTES),
|
||||
},
|
||||
{
|
||||
path: 'docs',
|
||||
title: 'Documentation',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
pathMatch: 'full',
|
||||
loadComponent: () => import('./features/docs/docs-viewer.component').then((m) => m.DocsViewerComponent),
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
loadComponent: () => import('./features/docs/docs-viewer.component').then((m) => m.DocsViewerComponent),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'welcome',
|
||||
title: 'Welcome',
|
||||
|
||||
@@ -40,6 +40,9 @@ export interface AdminClient {
|
||||
scopes: string[];
|
||||
status: 'active' | 'disabled';
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
defaultTenant?: string;
|
||||
tenants?: string[];
|
||||
}
|
||||
|
||||
export interface AdminToken {
|
||||
@@ -73,6 +76,18 @@ export interface CreateUserRequest {
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export interface CreateRoleRequest {
|
||||
name: string;
|
||||
description: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface CreateTenantRequest {
|
||||
id: string;
|
||||
displayName: string;
|
||||
isolationMode: string;
|
||||
}
|
||||
|
||||
export interface AuthorityAdminApi {
|
||||
listUsers(tenantId?: string): Observable<AdminUser[]>;
|
||||
listRoles(tenantId?: string): Observable<AdminRole[]>;
|
||||
@@ -80,11 +95,89 @@ export interface AuthorityAdminApi {
|
||||
listTokens(tenantId?: string): Observable<AdminToken[]>;
|
||||
listTenants(): Observable<AdminTenant[]>;
|
||||
createUser(request: CreateUserRequest): Observable<AdminUser>;
|
||||
createRole(request: CreateRoleRequest): Observable<AdminRole>;
|
||||
createTenant(request: CreateTenantRequest): Observable<AdminTenant>;
|
||||
}
|
||||
|
||||
export const AUTHORITY_ADMIN_API = new InjectionToken<AuthorityAdminApi>('AUTHORITY_ADMIN_API');
|
||||
export const AUTHORITY_ADMIN_API_BASE_URL = new InjectionToken<string>('AUTHORITY_ADMIN_API_BASE_URL');
|
||||
|
||||
interface AdminUsersResponseDto {
|
||||
users?: AdminUserDto[];
|
||||
}
|
||||
|
||||
interface AdminRolesResponseDto {
|
||||
roles?: AdminRoleDto[];
|
||||
}
|
||||
|
||||
interface AdminClientsResponseDto {
|
||||
clients?: AdminClientDto[];
|
||||
}
|
||||
|
||||
interface AdminTokensResponseDto {
|
||||
tokens?: AdminTokenDto[];
|
||||
}
|
||||
|
||||
interface AdminTenantsResponseDto {
|
||||
tenants?: AdminTenantDto[];
|
||||
}
|
||||
|
||||
interface AdminUserDto {
|
||||
id?: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
roles?: string[];
|
||||
status?: string;
|
||||
createdAt?: string;
|
||||
lastLoginAt?: string;
|
||||
}
|
||||
|
||||
interface AdminRoleDto {
|
||||
id?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
permissions?: string[];
|
||||
userCount?: number;
|
||||
isBuiltIn?: boolean;
|
||||
}
|
||||
|
||||
interface AdminClientDto {
|
||||
id?: string;
|
||||
clientId?: string;
|
||||
displayName?: string;
|
||||
grantTypes?: string[];
|
||||
scopes?: string[];
|
||||
status?: string;
|
||||
createdAt?: string;
|
||||
enabled?: boolean;
|
||||
allowedGrantTypes?: string[];
|
||||
allowedScopes?: string[];
|
||||
updatedAt?: string;
|
||||
defaultTenant?: string | null;
|
||||
tenants?: string[];
|
||||
}
|
||||
|
||||
interface AdminTokenDto {
|
||||
id?: string;
|
||||
name?: string;
|
||||
clientId?: string;
|
||||
scopes?: string[];
|
||||
expiresAt?: string;
|
||||
createdAt?: string;
|
||||
lastUsedAt?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface AdminTenantDto {
|
||||
id?: string;
|
||||
displayName?: string;
|
||||
status?: string;
|
||||
isolationMode?: string;
|
||||
userCount?: number;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HTTP Implementation
|
||||
// ============================================================================
|
||||
@@ -98,39 +191,59 @@ export class AuthorityAdminHttpClient implements AuthorityAdminApi {
|
||||
) {}
|
||||
|
||||
listUsers(tenantId?: string): Observable<AdminUser[]> {
|
||||
return this.http.get<{ users: AdminUser[] }>(`${this.baseUrl}/users`, {
|
||||
return this.http.get<AdminUsersResponseDto>(`${this.baseUrl}/users`, {
|
||||
headers: this.buildHeaders(tenantId),
|
||||
}).pipe(map(r => r.users ?? []));
|
||||
}).pipe(map((response) => (response.users ?? []).map((user) => this.mapUser(user))));
|
||||
}
|
||||
|
||||
listRoles(tenantId?: string): Observable<AdminRole[]> {
|
||||
return this.http.get<{ roles: AdminRole[] }>(`${this.baseUrl}/roles`, {
|
||||
return this.http.get<AdminRolesResponseDto>(`${this.baseUrl}/roles`, {
|
||||
headers: this.buildHeaders(tenantId),
|
||||
}).pipe(map(r => r.roles ?? []));
|
||||
}).pipe(map((response) => (response.roles ?? []).map((role) => this.mapRole(role))));
|
||||
}
|
||||
|
||||
listClients(tenantId?: string): Observable<AdminClient[]> {
|
||||
return this.http.get<{ clients: AdminClient[] }>(`${this.baseUrl}/clients`, {
|
||||
return this.http.get<AdminClientsResponseDto>(`${this.baseUrl}/clients`, {
|
||||
headers: this.buildHeaders(tenantId),
|
||||
}).pipe(map(r => r.clients ?? []));
|
||||
}).pipe(map((response) => (response.clients ?? []).map((client) => this.mapClient(client))));
|
||||
}
|
||||
|
||||
listTokens(tenantId?: string): Observable<AdminToken[]> {
|
||||
return this.http.get<{ tokens: AdminToken[] }>(`${this.baseUrl}/tokens`, {
|
||||
return this.http.get<AdminTokensResponseDto>(`${this.baseUrl}/tokens`, {
|
||||
headers: this.buildHeaders(tenantId),
|
||||
}).pipe(map(r => r.tokens ?? []));
|
||||
}).pipe(map((response) => (response.tokens ?? []).map((token) => this.mapToken(token))));
|
||||
}
|
||||
|
||||
listTenants(): Observable<AdminTenant[]> {
|
||||
return this.http.get<{ tenants: AdminTenant[] }>(`${this.baseUrl}/tenants`, {
|
||||
return this.http.get<AdminTenantsResponseDto>(`${this.baseUrl}/tenants`, {
|
||||
headers: this.buildHeaders(),
|
||||
}).pipe(map(r => r.tenants ?? []));
|
||||
}).pipe(map((response) => (response.tenants ?? []).map((tenant) => this.mapTenant(tenant))));
|
||||
}
|
||||
|
||||
createUser(request: CreateUserRequest): Observable<AdminUser> {
|
||||
return this.http.post<AdminUser>(`${this.baseUrl}/users`, request, {
|
||||
return this.http.post<AdminUserDto>(`${this.baseUrl}/users`, request, {
|
||||
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,
|
||||
displayName: request.description,
|
||||
scopes: request.permissions,
|
||||
}, {
|
||||
headers: this.buildHeaders(),
|
||||
}).pipe(map((role) => this.mapRole(role)));
|
||||
}
|
||||
|
||||
createTenant(request: CreateTenantRequest): Observable<AdminTenant> {
|
||||
return this.http.post<AdminTenantDto>(`${this.baseUrl}/tenants`, {
|
||||
id: request.id,
|
||||
displayName: request.displayName,
|
||||
isolationMode: request.isolationMode,
|
||||
}, {
|
||||
headers: this.buildHeaders(),
|
||||
}).pipe(map((tenant) => this.mapTenant(tenant)));
|
||||
}
|
||||
|
||||
private buildHeaders(tenantOverride?: string): HttpHeaders {
|
||||
@@ -142,6 +255,86 @@ export class AuthorityAdminHttpClient implements AuthorityAdminApi {
|
||||
[StellaOpsHeaders.Tenant]: tenantId,
|
||||
});
|
||||
}
|
||||
|
||||
private mapUser(dto: AdminUserDto): AdminUser {
|
||||
const username = dto.username?.trim() || dto.email?.trim() || 'unknown-user';
|
||||
return {
|
||||
id: dto.id?.trim() || username,
|
||||
username,
|
||||
email: dto.email?.trim() || '',
|
||||
displayName: dto.displayName?.trim() || username,
|
||||
roles: (dto.roles ?? []).map((role) => role.trim()).filter((role) => role.length > 0),
|
||||
status: this.normalizeStatus(dto.status, ['active', 'disabled', 'locked'], 'active'),
|
||||
createdAt: dto.createdAt?.trim() || new Date(0).toISOString(),
|
||||
lastLoginAt: dto.lastLoginAt?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private mapRole(dto: AdminRoleDto): AdminRole {
|
||||
const name = dto.name?.trim() || dto.id?.trim() || 'unnamed-role';
|
||||
return {
|
||||
id: dto.id?.trim() || name,
|
||||
name,
|
||||
description: dto.description?.trim() || '',
|
||||
permissions: (dto.permissions ?? []).map((permission) => permission.trim()).filter((permission) => permission.length > 0),
|
||||
userCount: dto.userCount ?? 0,
|
||||
isBuiltIn: dto.isBuiltIn ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
private mapClient(dto: AdminClientDto): AdminClient {
|
||||
const clientId = dto.clientId?.trim() || dto.id?.trim() || 'unknown-client';
|
||||
return {
|
||||
id: dto.id?.trim() || clientId,
|
||||
clientId,
|
||||
displayName: dto.displayName?.trim() || clientId,
|
||||
grantTypes: this.normalizeValues(dto.grantTypes ?? dto.allowedGrantTypes),
|
||||
scopes: this.normalizeValues(dto.scopes ?? dto.allowedScopes),
|
||||
status: this.normalizeStatus(
|
||||
dto.status ?? (dto.enabled === false ? 'disabled' : 'active'),
|
||||
['active', 'disabled'],
|
||||
'active',
|
||||
),
|
||||
createdAt: dto.createdAt?.trim() || dto.updatedAt?.trim() || new Date(0).toISOString(),
|
||||
updatedAt: dto.updatedAt?.trim() || undefined,
|
||||
defaultTenant: dto.defaultTenant?.trim() || undefined,
|
||||
tenants: this.normalizeValues(dto.tenants),
|
||||
};
|
||||
}
|
||||
|
||||
private mapToken(dto: AdminTokenDto): AdminToken {
|
||||
return {
|
||||
id: dto.id?.trim() || dto.name?.trim() || 'unknown-token',
|
||||
name: dto.name?.trim() || 'Unnamed token',
|
||||
clientId: dto.clientId?.trim() || 'unknown-client',
|
||||
scopes: this.normalizeValues(dto.scopes),
|
||||
expiresAt: dto.expiresAt?.trim() || '',
|
||||
createdAt: dto.createdAt?.trim() || new Date(0).toISOString(),
|
||||
lastUsedAt: dto.lastUsedAt?.trim() || undefined,
|
||||
status: this.normalizeStatus(dto.status, ['active', 'expired', 'revoked'], 'active'),
|
||||
};
|
||||
}
|
||||
|
||||
private mapTenant(dto: AdminTenantDto): AdminTenant {
|
||||
const tenantId = dto.id?.trim() || 'unknown-tenant';
|
||||
return {
|
||||
id: tenantId,
|
||||
displayName: dto.displayName?.trim() || tenantId,
|
||||
status: this.normalizeStatus(dto.status, ['active', 'disabled'], 'active'),
|
||||
isolationMode: dto.isolationMode?.trim() || 'shared',
|
||||
userCount: dto.userCount ?? 0,
|
||||
createdAt: dto.createdAt?.trim() || new Date(0).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeValues(values?: string[]): string[] {
|
||||
return (values ?? []).map((value) => value.trim()).filter((value) => value.length > 0);
|
||||
}
|
||||
|
||||
private normalizeStatus<T extends string>(status: string | undefined, allowed: readonly T[], fallback: T): T {
|
||||
const normalizedStatus = status?.trim().toLowerCase();
|
||||
return allowed.includes(normalizedStatus as T) ? normalizedStatus as T : fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -210,4 +403,28 @@ export class MockAuthorityAdminClient implements AuthorityAdminApi {
|
||||
};
|
||||
return of(user).pipe(delay(400));
|
||||
}
|
||||
|
||||
createRole(request: CreateRoleRequest): Observable<AdminRole> {
|
||||
const role: AdminRole = {
|
||||
id: `r-${Date.now()}`,
|
||||
name: request.name,
|
||||
description: request.description,
|
||||
permissions: request.permissions,
|
||||
userCount: 0,
|
||||
isBuiltIn: false,
|
||||
};
|
||||
return of(role).pipe(delay(400));
|
||||
}
|
||||
|
||||
createTenant(request: CreateTenantRequest): Observable<AdminTenant> {
|
||||
const tenant: AdminTenant = {
|
||||
id: request.id,
|
||||
displayName: request.displayName,
|
||||
isolationMode: request.isolationMode,
|
||||
status: 'active',
|
||||
userCount: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
return of(tenant).pipe(delay(400));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -188,6 +188,8 @@ describe('BrandingService', () => {
|
||||
}).subscribe((response) => {
|
||||
expect(response.branding.title).toBe('Demo Production');
|
||||
expect(response.branding.logoUrl).toBe('data:image/png;base64,AAAA');
|
||||
expect(response.message).toBe('Branding saved.');
|
||||
expect(response.metadata?.hash).toBe('hash-456');
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne('/console/admin/branding');
|
||||
@@ -202,6 +204,7 @@ describe('BrandingService', () => {
|
||||
},
|
||||
});
|
||||
req.flush({
|
||||
message: 'Branding saved.',
|
||||
branding: {
|
||||
tenantId: 'demo-prod',
|
||||
displayName: 'Demo Production',
|
||||
@@ -211,6 +214,10 @@ describe('BrandingService', () => {
|
||||
'--theme-brand-primary': '#112233',
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
tenantId: 'demo-prod',
|
||||
hash: 'hash-456',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface BrandingConfiguration {
|
||||
|
||||
export interface BrandingResponse {
|
||||
branding: BrandingConfiguration;
|
||||
message?: string;
|
||||
metadata?: BrandingMetadata;
|
||||
}
|
||||
|
||||
export interface BrandingMetadata {
|
||||
@@ -50,6 +52,7 @@ interface AuthorityBrandingDto {
|
||||
|
||||
interface AuthorityAdminBrandingEnvelopeDto {
|
||||
branding: AuthorityBrandingDto;
|
||||
message?: string;
|
||||
metadata?: BrandingMetadata;
|
||||
}
|
||||
|
||||
@@ -135,14 +138,18 @@ export class BrandingService {
|
||||
themeTokens: request.themeTokens ?? {},
|
||||
};
|
||||
|
||||
return this.http.put<{ branding: AuthorityBrandingDto }>(
|
||||
return this.http.put<AuthorityAdminBrandingEnvelopeDto>(
|
||||
'/console/admin/branding',
|
||||
payload,
|
||||
{
|
||||
headers: this.buildTenantHeaders(resolvedTenantId),
|
||||
}
|
||||
).pipe(
|
||||
map((response) => this.mapBrandingResponse(response.branding)),
|
||||
map((response) => ({
|
||||
...this.mapBrandingResponse(response.branding),
|
||||
message: response.message,
|
||||
metadata: response.metadata,
|
||||
})),
|
||||
tap((response) => {
|
||||
this.applyBranding(response.branding);
|
||||
})
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
buildDocsAssetCandidates,
|
||||
buildDocsRoute,
|
||||
normalizeDocsPath,
|
||||
parseDocsUrl,
|
||||
resolveDocsLink,
|
||||
} from './docs-route';
|
||||
|
||||
describe('docs-route helpers', () => {
|
||||
it('builds canonical docs routes without collapsing path segments', () => {
|
||||
expect(buildDocsRoute('modules/platform/architecture-overview.md', 'release-flow'))
|
||||
.toBe('/docs/modules/platform/architecture-overview.md#release-flow');
|
||||
});
|
||||
|
||||
it('normalizes already-encoded full doc paths', () => {
|
||||
expect(normalizeDocsPath('/docs/modules%2Fplatform%2Farchitecture-overview.md'))
|
||||
.toBe('modules/platform/architecture-overview.md');
|
||||
});
|
||||
|
||||
it('falls back to the docs index when the requested slug is empty', () => {
|
||||
expect(buildDocsAssetCandidates('/docs')).toEqual(['/docs-content/README.md']);
|
||||
});
|
||||
|
||||
it('tries markdown and README fallbacks for friendly slugs', () => {
|
||||
expect(buildDocsAssetCandidates('getting-started')).toEqual([
|
||||
'/docs-content/getting-started.md',
|
||||
'/docs-content/getting-started/README.md',
|
||||
'/docs-content/README.md',
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses full docs urls into path and anchor', () => {
|
||||
expect(parseDocsUrl('https://stella-ops.local/docs/modules/platform/architecture-overview.md#release-flow')).toEqual({
|
||||
path: 'modules/platform/architecture-overview.md',
|
||||
anchor: 'release-flow',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves relative markdown links against the current document path', () => {
|
||||
expect(resolveDocsLink('../signals/architecture.md#telemetry', 'modules/platform/architecture-overview.md'))
|
||||
.toBe('/docs/modules/signals/architecture.md#telemetry');
|
||||
});
|
||||
});
|
||||
149
src/Web/StellaOps.Web/src/app/core/navigation/docs-route.ts
Normal file
149
src/Web/StellaOps.Web/src/app/core/navigation/docs-route.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
const DOCS_ROUTE_PREFIX = '/docs';
|
||||
const DOCS_ASSET_PREFIX = '/docs-content';
|
||||
const DEFAULT_DOCS_PATH = 'README.md';
|
||||
|
||||
export interface ParsedDocsUrl {
|
||||
path: string;
|
||||
anchor: string | null;
|
||||
}
|
||||
|
||||
function safeDecode(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function hasExplicitExtension(path: string): boolean {
|
||||
const lastSegment = path.split('/').at(-1) ?? '';
|
||||
return /\.[a-z0-9]+$/i.test(lastSegment);
|
||||
}
|
||||
|
||||
export function normalizeDocsAnchor(anchor: string | null | undefined): string | null {
|
||||
const normalized = safeDecode((anchor ?? '').trim()).replace(/^#+/, '').trim();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
export function normalizeDocsPath(path: string | null | undefined): string {
|
||||
const normalized = safeDecode((path ?? '').trim())
|
||||
.replace(/^https?:\/\/[^/]+/i, '')
|
||||
.split('#', 1)[0]
|
||||
.split('?', 1)[0]
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\/?docs\/?/, '')
|
||||
.replace(/^\/+/, '')
|
||||
.replace(/\/{2,}/g, '/')
|
||||
.trim();
|
||||
|
||||
if (!normalized) {
|
||||
return DEFAULT_DOCS_PATH;
|
||||
}
|
||||
|
||||
if (normalized.endsWith('/')) {
|
||||
return `${normalized}README.md`;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function encodeDocsPath(path: string | null | undefined): string {
|
||||
return normalizeDocsPath(path)
|
||||
.split('/')
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join('/');
|
||||
}
|
||||
|
||||
export function buildDocsRoute(path: string | null | undefined, anchor?: string | null): string {
|
||||
const route = `${DOCS_ROUTE_PREFIX}/${encodeDocsPath(path)}`;
|
||||
const normalizedAnchor = normalizeDocsAnchor(anchor);
|
||||
return normalizedAnchor ? `${route}#${encodeURIComponent(normalizedAnchor)}` : route;
|
||||
}
|
||||
|
||||
export function buildDocsAssetCandidates(path: string | null | undefined): string[] {
|
||||
const normalizedPath = normalizeDocsPath(path);
|
||||
const candidates = new Set<string>();
|
||||
|
||||
const addCandidate = (candidate: string) => {
|
||||
candidates.add(`${DOCS_ASSET_PREFIX}/${candidate}`);
|
||||
};
|
||||
|
||||
if (hasExplicitExtension(normalizedPath)) {
|
||||
addCandidate(normalizedPath);
|
||||
} else {
|
||||
addCandidate(`${normalizedPath}.md`);
|
||||
addCandidate(`${normalizedPath}/README.md`);
|
||||
}
|
||||
|
||||
if (normalizedPath !== DEFAULT_DOCS_PATH) {
|
||||
addCandidate(DEFAULT_DOCS_PATH);
|
||||
}
|
||||
|
||||
return Array.from(candidates);
|
||||
}
|
||||
|
||||
function resolveRelativeDocsPath(targetPath: string, currentDocPath: string): string {
|
||||
const baseSegments = normalizeDocsPath(currentDocPath).split('/');
|
||||
if (baseSegments.length > 0) {
|
||||
baseSegments.pop();
|
||||
}
|
||||
|
||||
for (const segment of targetPath.split('/')) {
|
||||
const normalizedSegment = safeDecode(segment).trim();
|
||||
if (!normalizedSegment || normalizedSegment === '.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalizedSegment === '..') {
|
||||
if (baseSegments.length > 0) {
|
||||
baseSegments.pop();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
baseSegments.push(normalizedSegment);
|
||||
}
|
||||
|
||||
return baseSegments.join('/') || DEFAULT_DOCS_PATH;
|
||||
}
|
||||
|
||||
export function resolveDocsLink(target: string, currentDocPath: string): string | null {
|
||||
const normalizedTarget = target.trim();
|
||||
if (!normalizedTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(normalizedTarget)) {
|
||||
return normalizedTarget;
|
||||
}
|
||||
|
||||
if (normalizedTarget.startsWith('#')) {
|
||||
const anchor = normalizeDocsAnchor(normalizedTarget);
|
||||
return anchor ? `#${encodeURIComponent(anchor)}` : null;
|
||||
}
|
||||
|
||||
const [pathPart, anchorPart] = normalizedTarget.split('#', 2);
|
||||
|
||||
if (pathPart.startsWith('/docs') || pathPart.startsWith('docs/')) {
|
||||
return buildDocsRoute(pathPart, anchorPart);
|
||||
}
|
||||
|
||||
if (pathPart.startsWith('/')) {
|
||||
return buildDocsRoute(pathPart, anchorPart);
|
||||
}
|
||||
|
||||
return buildDocsRoute(resolveRelativeDocsPath(pathPart, currentDocPath), anchorPart);
|
||||
}
|
||||
|
||||
export function parseDocsUrl(url: string): ParsedDocsUrl {
|
||||
const decodedUrl = safeDecode(url);
|
||||
const hashIndex = decodedUrl.indexOf('#');
|
||||
const beforeHash = hashIndex >= 0 ? decodedUrl.slice(0, hashIndex) : decodedUrl;
|
||||
const rawAnchor = hashIndex >= 0 ? decodedUrl.slice(hashIndex + 1) : '';
|
||||
const pathWithoutOrigin = beforeHash.replace(/^https?:\/\/[^/]+/i, '');
|
||||
|
||||
return {
|
||||
path: normalizeDocsPath(pathWithoutOrigin),
|
||||
anchor: normalizeDocsAnchor(rawAnchor),
|
||||
};
|
||||
}
|
||||
@@ -318,11 +318,36 @@ describe('Topology scope-preserving links', () => {
|
||||
|
||||
expect(component.selectedRegionId()).toBe('us-east');
|
||||
expect(component.selectedEnvironmentId()).toBe('stage');
|
||||
expect(links.find((item) => item.text === 'Open Environment')?.link.queryParams).toEqual({
|
||||
environment: 'stage',
|
||||
environments: 'stage',
|
||||
});
|
||||
expect(links.find((item) => item.text === 'Open Targets')?.link.queryParams).toEqual({ environment: 'stage' });
|
||||
expect(links.find((item) => item.text === 'Open Agents')?.link.queryParams).toEqual({ environment: 'stage' });
|
||||
expect(links.find((item) => item.text === 'Open Runs')?.link.queryParams).toEqual({ environment: 'stage' });
|
||||
});
|
||||
|
||||
it('prefers the explicit route environment over broader hydrated context selections', () => {
|
||||
const originalSelectedEnvironments = mockContextStore.selectedEnvironments;
|
||||
mockContextStore.selectedEnvironments = () => ['dev', 'stage'];
|
||||
|
||||
try {
|
||||
configureTestingModule(TopologyRegionsEnvironmentsPageComponent);
|
||||
routeData$.next({ defaultView: 'region-first' });
|
||||
queryParamMap$.next(convertToParamMap({ tenant: 'demo-prod', regions: 'us-east', environments: 'stage' }));
|
||||
|
||||
const fixture = TestBed.createComponent(TopologyRegionsEnvironmentsPageComponent);
|
||||
fixture.detectChanges();
|
||||
fixture.detectChanges();
|
||||
|
||||
const component = fixture.componentInstance;
|
||||
expect(component.selectedRegionId()).toBe('us-east');
|
||||
expect(component.selectedEnvironmentId()).toBe('stage');
|
||||
} finally {
|
||||
mockContextStore.selectedEnvironments = originalSelectedEnvironments;
|
||||
}
|
||||
});
|
||||
|
||||
it('marks promotion inventory links to merge the active query scope', () => {
|
||||
configureTestingModule(TopologyPromotionPathsPageComponent);
|
||||
|
||||
|
||||
@@ -755,15 +755,9 @@ export class BrandingEditorComponent implements OnInit {
|
||||
};
|
||||
|
||||
this.brandingService.updateBranding(payload).subscribe({
|
||||
next: () => {
|
||||
this.success.set('Branding applied successfully! Refreshing page...');
|
||||
next: (response) => {
|
||||
this.success.set(response.message || 'Branding applied successfully.');
|
||||
this.hasChanges.set(false);
|
||||
|
||||
// Reload page after 2 seconds to ensure all components reflect the changes
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
|
||||
this.isSaving.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { filter, firstValueFrom } from 'rxjs';
|
||||
|
||||
import {
|
||||
buildDocsAssetCandidates,
|
||||
normalizeDocsAnchor,
|
||||
parseDocsUrl,
|
||||
resolveDocsLink,
|
||||
} from '../../core/navigation/docs-route';
|
||||
|
||||
interface DocsHeading {
|
||||
level: number;
|
||||
text: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface RenderedDocument {
|
||||
title: string;
|
||||
headings: DocsHeading[];
|
||||
html: string;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function slugifyHeading(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[`*_~]/g, '')
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-');
|
||||
}
|
||||
|
||||
function renderInlineMarkdown(value: string, currentDocPath: string): string {
|
||||
let rendered = escapeHtml(value);
|
||||
|
||||
rendered = rendered.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label: string, target: string) => {
|
||||
const resolvedHref = resolveDocsLink(target, currentDocPath) ?? '#';
|
||||
const isExternal = /^[a-z][a-z0-9+.-]*:\/\//i.test(resolvedHref);
|
||||
const attributes = isExternal ? ' target="_blank" rel="noopener"' : '';
|
||||
return `<a href="${escapeHtml(resolvedHref)}"${attributes}>${escapeHtml(label)}</a>`;
|
||||
});
|
||||
|
||||
rendered = rendered.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
rendered = rendered.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
rendered = rendered.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||||
|
||||
return rendered;
|
||||
}
|
||||
|
||||
function renderMarkdownDocument(markdown: string, currentDocPath: string): RenderedDocument {
|
||||
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
|
||||
const parts: string[] = [];
|
||||
const headings: DocsHeading[] = [];
|
||||
let title = 'Documentation';
|
||||
let index = 0;
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index];
|
||||
|
||||
if (!line.trim()) {
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const codeFenceMatch = line.match(/^```(\w+)?\s*$/);
|
||||
if (codeFenceMatch) {
|
||||
const language = codeFenceMatch[1]?.trim() ?? '';
|
||||
index++;
|
||||
const codeLines: string[] = [];
|
||||
while (index < lines.length && !/^```/.test(lines[index])) {
|
||||
codeLines.push(lines[index]);
|
||||
index++;
|
||||
}
|
||||
if (index < lines.length) {
|
||||
index++;
|
||||
}
|
||||
|
||||
parts.push(
|
||||
`<pre class="docs-viewer__code"><code class="language-${escapeHtml(language)}">${escapeHtml(codeLines.join('\n'))}</code></pre>`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
|
||||
if (headingMatch) {
|
||||
const level = headingMatch[1].length;
|
||||
const text = headingMatch[2].trim();
|
||||
const id = slugifyHeading(text);
|
||||
if (headings.length === 0 && text) {
|
||||
title = text;
|
||||
}
|
||||
headings.push({ level, text, id });
|
||||
parts.push(`<h${level} id="${id}">${renderInlineMarkdown(text, currentDocPath)}</h${level}>`);
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*>\s?/.test(line)) {
|
||||
const quoteLines: string[] = [];
|
||||
while (index < lines.length && /^\s*>\s?/.test(lines[index])) {
|
||||
quoteLines.push(lines[index].replace(/^\s*>\s?/, '').trim());
|
||||
index++;
|
||||
}
|
||||
|
||||
parts.push(
|
||||
`<blockquote>${quoteLines.map((entry) => `<p>${renderInlineMarkdown(entry, currentDocPath)}</p>`).join('')}</blockquote>`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*[-*]\s+/.test(line)) {
|
||||
const items: string[] = [];
|
||||
while (index < lines.length && /^\s*[-*]\s+/.test(lines[index])) {
|
||||
items.push(lines[index].replace(/^\s*[-*]\s+/, '').trim());
|
||||
index++;
|
||||
}
|
||||
|
||||
parts.push(`<ul>${items.map((item) => `<li>${renderInlineMarkdown(item, currentDocPath)}</li>`).join('')}</ul>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*\d+\.\s+/.test(line)) {
|
||||
const items: string[] = [];
|
||||
while (index < lines.length && /^\s*\d+\.\s+/.test(lines[index])) {
|
||||
items.push(lines[index].replace(/^\s*\d+\.\s+/, '').trim());
|
||||
index++;
|
||||
}
|
||||
|
||||
parts.push(`<ol>${items.map((item) => `<li>${renderInlineMarkdown(item, currentDocPath)}</li>`).join('')}</ol>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\|/.test(line)) {
|
||||
const tableLines: string[] = [];
|
||||
while (index < lines.length && /^\|/.test(lines[index])) {
|
||||
tableLines.push(lines[index]);
|
||||
index++;
|
||||
}
|
||||
|
||||
parts.push(`<pre class="docs-viewer__table-fallback">${escapeHtml(tableLines.join('\n'))}</pre>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const paragraphLines: string[] = [];
|
||||
while (
|
||||
index < lines.length &&
|
||||
lines[index].trim() &&
|
||||
!/^(#{1,6})\s+/.test(lines[index]) &&
|
||||
!/^```/.test(lines[index]) &&
|
||||
!/^\s*>\s?/.test(lines[index]) &&
|
||||
!/^\s*[-*]\s+/.test(lines[index]) &&
|
||||
!/^\s*\d+\.\s+/.test(lines[index]) &&
|
||||
!/^\|/.test(lines[index])
|
||||
) {
|
||||
paragraphLines.push(lines[index].trim());
|
||||
index++;
|
||||
}
|
||||
|
||||
parts.push(`<p>${renderInlineMarkdown(paragraphLines.join(' '), currentDocPath)}</p>`);
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
parts.push('<p>No rendered documentation content is available for this entry.</p>');
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
headings,
|
||||
html: parts.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-docs-viewer',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="docs-viewer">
|
||||
<header class="docs-viewer__header">
|
||||
<div>
|
||||
<p class="docs-viewer__eyebrow">Documentation</p>
|
||||
<h1>{{ title() }}</h1>
|
||||
<p class="docs-viewer__path">{{ requestedPath() }}</p>
|
||||
</div>
|
||||
<div class="docs-viewer__actions">
|
||||
<a href="/docs/README.md">Docs home</a>
|
||||
@if (resolvedAssetPath(); as assetPath) {
|
||||
<a [href]="assetPath" target="_blank" rel="noopener">Open raw</a>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="docs-viewer__banner">Loading documentation...</div>
|
||||
} @else if (error(); as errorMessage) {
|
||||
<div class="docs-viewer__banner docs-viewer__banner--error" role="alert">
|
||||
<strong>Documentation entry not found.</strong>
|
||||
<span>{{ errorMessage }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="docs-viewer__layout">
|
||||
@if (headings().length > 1) {
|
||||
<nav class="docs-viewer__toc" aria-label="Documentation outline">
|
||||
<strong>On this page</strong>
|
||||
<ul>
|
||||
@for (heading of headings(); track heading.id) {
|
||||
<li [class]="'docs-viewer__toc-level-' + heading.level">
|
||||
<a [href]="'#' + heading.id">{{ heading.text }}</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
}
|
||||
|
||||
<article class="docs-viewer__content" [innerHTML]="renderedHtml()"></article>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.docs-viewer {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.docs-viewer__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: linear-gradient(135deg, rgba(15, 23, 42, 0.03), rgba(191, 219, 254, 0.28));
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
|
||||
.docs-viewer__eyebrow {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.docs-viewer__header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.6rem;
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.docs-viewer__path {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-family: var(--font-mono, monospace);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.docs-viewer__actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.docs-viewer__actions a {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-brand-primary);
|
||||
text-decoration: none;
|
||||
padding: 0.3rem 0.65rem;
|
||||
font-size: 0.78rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.docs-viewer__banner {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0.8rem 0.9rem;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.docs-viewer__banner--error {
|
||||
border-color: var(--color-status-error-border);
|
||||
background: var(--color-status-error-bg);
|
||||
color: var(--color-status-error-text);
|
||||
}
|
||||
|
||||
.docs-viewer__layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 220px) minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.docs-viewer__toc {
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.docs-viewer__toc strong {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.docs-viewer__toc ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.docs-viewer__toc li {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.docs-viewer__toc a {
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.docs-viewer__toc-level-3,
|
||||
.docs-viewer__toc-level-4,
|
||||
.docs-viewer__toc-level-5,
|
||||
.docs-viewer__toc-level-6 {
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
.docs-viewer__content {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 1.2rem 1.3rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.docs-viewer__content :is(h1, h2, h3, h4, h5, h6) {
|
||||
color: var(--color-text-heading);
|
||||
scroll-margin-top: 1rem;
|
||||
}
|
||||
|
||||
.docs-viewer__content h1 {
|
||||
font-size: 1.7rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.docs-viewer__content h2 {
|
||||
font-size: 1.3rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.docs-viewer__content h3 {
|
||||
font-size: 1.05rem;
|
||||
margin-top: 1.2rem;
|
||||
}
|
||||
|
||||
.docs-viewer__content p,
|
||||
.docs-viewer__content li,
|
||||
.docs-viewer__content blockquote {
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.65;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.docs-viewer__content ul,
|
||||
.docs-viewer__content ol {
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.docs-viewer__content a {
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.docs-viewer__content code {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.88em;
|
||||
background: var(--color-surface-secondary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.08rem 0.28rem;
|
||||
}
|
||||
|
||||
.docs-viewer__content pre {
|
||||
overflow: auto;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-secondary);
|
||||
padding: 0.9rem;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.docs-viewer__content blockquote {
|
||||
margin: 0;
|
||||
border-left: 3px solid var(--color-brand-primary);
|
||||
background: var(--color-brand-primary-10);
|
||||
padding: 0.2rem 0.9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.docs-viewer__layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.docs-viewer__toc {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.docs-viewer__header {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class DocsViewerComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly router = inject(Router);
|
||||
private readonly sanitizer = inject(DomSanitizer);
|
||||
private requestVersion = 0;
|
||||
|
||||
readonly title = signal('Documentation');
|
||||
readonly requestedPath = signal('README.md');
|
||||
readonly resolvedAssetPath = signal<string | null>(null);
|
||||
readonly headings = signal<DocsHeading[]>([]);
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly renderedHtml = signal<SafeHtml | string>('');
|
||||
|
||||
constructor() {
|
||||
this.loadFromUrl(this.router.url);
|
||||
|
||||
this.router.events
|
||||
.pipe(
|
||||
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe((event) => {
|
||||
this.loadFromUrl(event.urlAfterRedirects);
|
||||
});
|
||||
}
|
||||
|
||||
private loadFromUrl(url: string): void {
|
||||
const { path, anchor } = parseDocsUrl(url);
|
||||
this.requestedPath.set(path);
|
||||
void this.loadDocument(path, anchor);
|
||||
}
|
||||
|
||||
private async loadDocument(path: string, anchor: string | null): Promise<void> {
|
||||
const requestVersion = ++this.requestVersion;
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
this.resolvedAssetPath.set(null);
|
||||
this.headings.set([]);
|
||||
|
||||
for (const candidate of buildDocsAssetCandidates(path)) {
|
||||
try {
|
||||
const markdown = await firstValueFrom(this.http.get(candidate, { responseType: 'text' }));
|
||||
if (requestVersion !== this.requestVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rendered = renderMarkdownDocument(markdown, path);
|
||||
this.title.set(rendered.title);
|
||||
this.headings.set(rendered.headings);
|
||||
this.resolvedAssetPath.set(candidate);
|
||||
this.renderedHtml.set(this.sanitizer.bypassSecurityTrustHtml(rendered.html));
|
||||
this.loading.set(false);
|
||||
|
||||
queueMicrotask(() => this.scrollToAnchor(anchor));
|
||||
return;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (requestVersion !== this.requestVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.title.set('Documentation');
|
||||
this.renderedHtml.set(this.sanitizer.bypassSecurityTrustHtml(''));
|
||||
this.error.set(`No documentation asset matched ${path}.`);
|
||||
this.loading.set(false);
|
||||
}
|
||||
|
||||
private scrollToAnchor(anchor: string | null): void {
|
||||
const normalizedAnchor = normalizeDocsAnchor(anchor);
|
||||
if (!normalizedAnchor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target =
|
||||
document.getElementById(normalizedAnchor) ??
|
||||
document.getElementById(slugifyHeading(normalizedAnchor));
|
||||
|
||||
target?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { AUTH_SERVICE } from '../../core/auth';
|
||||
import { ORCHESTRATOR_CONTROL_API } from '../../core/api/jobengine-control.client';
|
||||
import { JobEngineDashboardComponent } from './jobengine-dashboard.component';
|
||||
|
||||
describe('JobEngineDashboardComponent', () => {
|
||||
function configure(canManageJobEngineQuotas: boolean) {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [JobEngineDashboardComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: AUTH_SERVICE,
|
||||
useValue: {
|
||||
canViewOrchestrator: () => true,
|
||||
canOperateOrchestrator: () => true,
|
||||
canManageJobEngineQuotas: () => canManageJobEngineQuotas,
|
||||
canInitiateBackfill: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ORCHESTRATOR_CONTROL_API,
|
||||
useValue: {
|
||||
getJobSummary: () => of({
|
||||
totalJobs: 12,
|
||||
leasedJobs: 2,
|
||||
failedJobs: 1,
|
||||
pendingJobs: 3,
|
||||
scheduledJobs: 4,
|
||||
succeededJobs: 5,
|
||||
}),
|
||||
getQuotaSummary: () => of({
|
||||
totalQuotas: 3,
|
||||
pausedQuotas: 1,
|
||||
averageTokenUtilization: 0.42,
|
||||
averageConcurrencyUtilization: 0.33,
|
||||
}),
|
||||
getDeadLetterStats: () => of({
|
||||
totalEntries: 4,
|
||||
retryableEntries: 2,
|
||||
replayedEntries: 1,
|
||||
resolvedEntries: 1,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
it('renders the quotas card as read-only when the user lacks quota scope', () => {
|
||||
configure(false);
|
||||
const fixture = TestBed.createComponent(JobEngineDashboardComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const quotasCard = fixture.nativeElement.querySelector('[data-testid="jobengine-quotas-card"]') as HTMLElement;
|
||||
expect(quotasCard.tagName).toBe('ARTICLE');
|
||||
expect(quotasCard.textContent).toContain('Access required to manage quotas.');
|
||||
});
|
||||
|
||||
it('renders the quotas card as a link when the user can manage quotas', () => {
|
||||
configure(true);
|
||||
const fixture = TestBed.createComponent(JobEngineDashboardComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const quotasCard = fixture.nativeElement.querySelector('[data-testid="jobengine-quotas-card"]') as HTMLElement;
|
||||
expect(quotasCard.tagName).toBe('A');
|
||||
expect(quotasCard.getAttribute('href')).toContain('/ops/operations/jobengine/quotas');
|
||||
});
|
||||
});
|
||||
@@ -78,24 +78,46 @@ import { OPERATIONS_PATHS } from '../platform/ops/operations-paths';
|
||||
</dl>
|
||||
</a>
|
||||
|
||||
<a class="surface" [routerLink]="OPERATIONS_PATHS.jobEngineQuotas">
|
||||
<h2>Execution Quotas</h2>
|
||||
<p>Manage per-job-type concurrency, refill rate, and pause state.</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Average Token Usage</dt>
|
||||
<dd>{{ quotaPercent(quotaSummary()?.averageTokenUtilization) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Average Concurrency Usage</dt>
|
||||
<dd>{{ quotaPercent(quotaSummary()?.averageConcurrencyUtilization) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Paused Quotas</dt>
|
||||
<dd>{{ quotaSummary()?.pausedQuotas ?? 0 }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</a>
|
||||
@if (authService.canManageJobEngineQuotas()) {
|
||||
<a class="surface" data-testid="jobengine-quotas-card" [routerLink]="OPERATIONS_PATHS.jobEngineQuotas">
|
||||
<h2>Execution Quotas</h2>
|
||||
<p>Manage per-job-type concurrency, refill rate, and pause state.</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Average Token Usage</dt>
|
||||
<dd>{{ quotaPercent(quotaSummary()?.averageTokenUtilization) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Average Concurrency Usage</dt>
|
||||
<dd>{{ quotaPercent(quotaSummary()?.averageConcurrencyUtilization) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Paused Quotas</dt>
|
||||
<dd>{{ quotaSummary()?.pausedQuotas ?? 0 }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</a>
|
||||
} @else {
|
||||
<article class="surface surface--restricted" data-testid="jobengine-quotas-card" aria-disabled="true">
|
||||
<h2>Execution Quotas</h2>
|
||||
<p>Quota metrics are visible, but management stays locked until the session has quota-admin scope.</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Average Token Usage</dt>
|
||||
<dd>{{ quotaPercent(quotaSummary()?.averageTokenUtilization) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Average Concurrency Usage</dt>
|
||||
<dd>{{ quotaPercent(quotaSummary()?.averageConcurrencyUtilization) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Paused Quotas</dt>
|
||||
<dd>{{ quotaSummary()?.pausedQuotas ?? 0 }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<span class="surface__notice">Access required to manage quotas.</span>
|
||||
</article>
|
||||
}
|
||||
|
||||
<a class="surface" [routerLink]="OPERATIONS_PATHS.deadLetter">
|
||||
<h2>Dead-Letter Recovery</h2>
|
||||
@@ -234,6 +256,10 @@ import { OPERATIONS_PATHS } from '../platform/ops/operations-paths';
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.surface--restricted {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.surface p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
@@ -263,6 +289,15 @@ import { OPERATIONS_PATHS } from '../platform/ops/operations-paths';
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.surface__notice {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--color-status-warning-border);
|
||||
font-size: 0.8rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.jobengine-dashboard__access ul {
|
||||
margin: 0;
|
||||
padding-left: 1.2rem;
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
import { ExportCenterComponent } from '../evidence-export/export-center.component';
|
||||
import { TriageWorkspaceComponent } from '../triage/triage-workspace.component';
|
||||
import { SecurityDispositionPageComponent } from './security-disposition-page.component';
|
||||
|
||||
type ReportTab = 'risk' | 'vex' | 'evidence';
|
||||
|
||||
@Component({
|
||||
selector: 'app-security-reports-page',
|
||||
standalone: true,
|
||||
imports: [RouterLink],
|
||||
imports: [TriageWorkspaceComponent, SecurityDispositionPageComponent, ExportCenterComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="security-reports">
|
||||
<header>
|
||||
<h1>Security Reports</h1>
|
||||
<p>Export posture snapshots, waiver ledgers, and evidence-linked risk summaries.</p>
|
||||
<p>Review risk posture, VEX decisions, and evidence exports without leaving the reports workspace.</p>
|
||||
</header>
|
||||
|
||||
<nav class="tabs" role="tablist">
|
||||
@@ -42,31 +45,19 @@ type ReportTab = 'risk' | 'vex' | 'evidence';
|
||||
<div class="tab-content" role="tabpanel">
|
||||
@switch (activeTab()) {
|
||||
@case ('risk') {
|
||||
<article class="report-card">
|
||||
<h2>Risk Report</h2>
|
||||
<p>Aggregated risk posture across all scanned artifacts, environments, and triage dispositions.</p>
|
||||
<div class="report-actions">
|
||||
<a routerLink="/security/triage" class="btn btn--primary">View Full Triage</a>
|
||||
</div>
|
||||
</article>
|
||||
<section class="report-panel">
|
||||
<app-triage-workspace></app-triage-workspace>
|
||||
</section>
|
||||
}
|
||||
@case ('vex') {
|
||||
<article class="report-card">
|
||||
<h2>VEX and Waiver Ledger</h2>
|
||||
<p>Active VEX statements, exception waivers, and disposition history with expiration tracking.</p>
|
||||
<div class="report-actions">
|
||||
<a routerLink="/security/disposition" class="btn btn--primary">View Dispositions</a>
|
||||
</div>
|
||||
</article>
|
||||
<section class="report-panel">
|
||||
<app-security-disposition-page></app-security-disposition-page>
|
||||
</section>
|
||||
}
|
||||
@case ('evidence') {
|
||||
<article class="report-card">
|
||||
<h2>Evidence Export Bundle</h2>
|
||||
<p>Export signed evidence bundles for audit, compliance, and offline verification workflows.</p>
|
||||
<div class="report-actions">
|
||||
<a routerLink="/evidence/exports" class="btn btn--primary">Open Export Center</a>
|
||||
</div>
|
||||
</article>
|
||||
<section class="report-panel">
|
||||
<app-export-center></app-export-center>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -105,49 +96,11 @@ type ReportTab = 'risk' | 'vex' | 'evidence';
|
||||
border-bottom-color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.report-card {
|
||||
padding: 1rem;
|
||||
.report-panel {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
|
||||
.report-card h2 {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.report-card p {
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.report-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--color-brand-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn--primary:hover {
|
||||
opacity: 0.9;
|
||||
overflow: hidden;
|
||||
}
|
||||
`,
|
||||
],
|
||||
|
||||
@@ -43,6 +43,9 @@ import {
|
||||
@if (error()) {
|
||||
<div class="error-banner">{{ error() }}</div>
|
||||
}
|
||||
@if (successMessage()) {
|
||||
<div class="success-banner">{{ successMessage() }}</div>
|
||||
}
|
||||
|
||||
<div class="admin-content">
|
||||
@switch (activeTab()) {
|
||||
@@ -71,9 +74,11 @@ import {
|
||||
<div class="form-field">
|
||||
<label class="form-label">Role</label>
|
||||
<select class="form-input" #addUserRole>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="operator">Operator</option>
|
||||
<option value="admin">Admin</option>
|
||||
@for (role of availableUserRoles(); track role.id) {
|
||||
<option [value]="role.name">{{ role.name }}</option>
|
||||
} @empty {
|
||||
<option value="admin">admin</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,7 +101,7 @@ import {
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
<th>Directory</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -106,7 +111,7 @@ import {
|
||||
<td>{{ user.email }}</td>
|
||||
<td>{{ user.roles.join(', ') }}</td>
|
||||
<td><span class="badge" [class]="'badge--' + user.status">{{ user.status }}</span></td>
|
||||
<td><button class="btn btn--sm">Edit</button></td>
|
||||
<td class="muted-cell">Managed by Authority directory</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="5" class="empty-cell">No users found</td></tr>
|
||||
@@ -122,6 +127,34 @@ import {
|
||||
<h2>Roles</h2>
|
||||
<button type="button" class="btn btn--primary" (click)="showAddForm('roles')">+ Create Role</button>
|
||||
</div>
|
||||
@if (addFormVisible() === 'roles') {
|
||||
<div class="add-form">
|
||||
<h3 class="add-form__title">New Role</h3>
|
||||
<div class="add-form__fields">
|
||||
<div class="form-field">
|
||||
<label class="form-label">Role Name</label>
|
||||
<input type="text" class="form-input" placeholder="security-analyst" #addRoleName />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">Description</label>
|
||||
<input type="text" class="form-input" placeholder="Security analyst with triage access" #addRoleDescription />
|
||||
</div>
|
||||
<div class="form-field form-field--full">
|
||||
<label class="form-label">Permissions</label>
|
||||
<textarea class="form-input form-input--textarea" placeholder="findings:read, vex:read, vuln:investigate" #addRolePermissions></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="add-form__actions">
|
||||
<button type="button" class="btn btn--secondary" (click)="hideAddForm()">Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--primary"
|
||||
(click)="createRole(addRoleName.value, addRoleDescription.value, addRolePermissions.value)">
|
||||
Create Role
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (loading()) {
|
||||
<p class="loading-text">Loading roles...</p>
|
||||
} @else {
|
||||
@@ -132,7 +165,6 @@ import {
|
||||
<th>Description</th>
|
||||
<th>Users</th>
|
||||
<th>Built-in</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -142,10 +174,9 @@ import {
|
||||
<td>{{ role.description }}</td>
|
||||
<td>{{ role.userCount }}</td>
|
||||
<td>{{ role.isBuiltIn ? 'Yes' : 'No' }}</td>
|
||||
<td><button class="btn btn--sm" [disabled]="role.isBuiltIn">Edit</button></td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="5" class="empty-cell">No roles found</td></tr>
|
||||
<tr><td colspan="4" class="empty-cell">No roles found</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -156,8 +187,8 @@ import {
|
||||
<div class="content-section">
|
||||
<div class="section-header">
|
||||
<h2>OAuth Clients</h2>
|
||||
<button type="button" class="btn btn--primary" (click)="showAddForm('clients')">+ Register Client</button>
|
||||
</div>
|
||||
<p class="section-note">OAuth clients are visible here, but registration and secret rotation remain outside this setup tab until the full guided flow is shipped.</p>
|
||||
@if (loading()) {
|
||||
<p class="loading-text">Loading clients...</p>
|
||||
} @else {
|
||||
@@ -167,8 +198,8 @@ import {
|
||||
<th>Client ID</th>
|
||||
<th>Display Name</th>
|
||||
<th>Grant Types</th>
|
||||
<th>Tenant Scope</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -177,8 +208,8 @@ import {
|
||||
<td><code>{{ client.clientId }}</code></td>
|
||||
<td>{{ client.displayName }}</td>
|
||||
<td>{{ client.grantTypes.join(', ') }}</td>
|
||||
<td>{{ describeClientTenants(client) }}</td>
|
||||
<td><span class="badge" [class]="'badge--' + client.status">{{ client.status }}</span></td>
|
||||
<td><button class="btn btn--sm">Edit</button></td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="5" class="empty-cell">No OAuth clients found</td></tr>
|
||||
@@ -192,8 +223,8 @@ import {
|
||||
<div class="content-section">
|
||||
<div class="section-header">
|
||||
<h2>API Tokens</h2>
|
||||
<button type="button" class="btn btn--primary" (click)="showAddForm('tokens')">+ Generate Token</button>
|
||||
</div>
|
||||
<p class="section-note">Token issuance and revocation are not exposed on this setup route yet, so this view is intentionally read-only.</p>
|
||||
@if (loading()) {
|
||||
<p class="loading-text">Loading tokens...</p>
|
||||
} @else {
|
||||
@@ -205,7 +236,6 @@ import {
|
||||
<th>Scopes</th>
|
||||
<th>Expires</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -216,10 +246,9 @@ import {
|
||||
<td>{{ token.scopes.join(', ') }}</td>
|
||||
<td>{{ token.expiresAt }}</td>
|
||||
<td><span class="badge" [class]="'badge--' + token.status">{{ token.status }}</span></td>
|
||||
<td><button class="btn btn--sm">Revoke</button></td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="6" class="empty-cell">No API tokens found</td></tr>
|
||||
<tr><td colspan="5" class="empty-cell">No API tokens found</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -232,6 +261,37 @@ import {
|
||||
<h2>Tenants</h2>
|
||||
<button type="button" class="btn btn--primary" (click)="showAddForm('tenants')">+ Add Tenant</button>
|
||||
</div>
|
||||
@if (addFormVisible() === 'tenants') {
|
||||
<div class="add-form">
|
||||
<h3 class="add-form__title">New Tenant</h3>
|
||||
<div class="add-form__fields">
|
||||
<div class="form-field">
|
||||
<label class="form-label">Tenant ID</label>
|
||||
<input type="text" class="form-input" placeholder="customer-stage" #addTenantId />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">Display Name</label>
|
||||
<input type="text" class="form-input" placeholder="Customer Stage" #addTenantDisplayName />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">Isolation Mode</label>
|
||||
<select class="form-input" #addTenantIsolation>
|
||||
<option value="shared">Shared</option>
|
||||
<option value="dedicated">Dedicated</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="add-form__actions">
|
||||
<button type="button" class="btn btn--secondary" (click)="hideAddForm()">Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--primary"
|
||||
(click)="createTenant(addTenantId.value, addTenantDisplayName.value, addTenantIsolation.value)">
|
||||
Create Tenant
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (loading()) {
|
||||
<p class="loading-text">Loading tenants...</p>
|
||||
} @else {
|
||||
@@ -242,7 +302,7 @@ import {
|
||||
<th>Status</th>
|
||||
<th>Isolation</th>
|
||||
<th>Users</th>
|
||||
<th>Actions</th>
|
||||
<th>Lifecycle</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -252,7 +312,7 @@ import {
|
||||
<td><span class="badge" [class]="'badge--' + tenant.status">{{ tenant.status }}</span></td>
|
||||
<td>{{ tenant.isolationMode }}</td>
|
||||
<td>{{ tenant.userCount }}</td>
|
||||
<td><button class="btn btn--sm">Edit</button></td>
|
||||
<td class="muted-cell">Branding and policies are managed from the canonical setup surfaces.</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="5" class="empty-cell">No tenants found</td></tr>
|
||||
@@ -336,7 +396,13 @@ import {
|
||||
.btn--sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary); }
|
||||
.btn--sm:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.loading-text { color: var(--color-text-secondary); font-size: 0.875rem; }
|
||||
.section-note {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.empty-cell { text-align: center; color: var(--color-text-secondary); padding: 2rem !important; }
|
||||
.muted-cell { color: var(--color-text-secondary); font-size: 0.8125rem; }
|
||||
.error-banner {
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
@@ -367,6 +433,7 @@ import {
|
||||
.add-form__title { margin: 0 0 1rem; font-size: 1rem; font-weight: var(--font-weight-semibold); }
|
||||
.add-form__fields { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-bottom: 1rem; }
|
||||
.form-field { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.form-field--full { grid-column: 1 / -1; }
|
||||
.form-label { font-size: 0.75rem; font-weight: var(--font-weight-medium); color: var(--color-text-secondary); text-transform: uppercase; }
|
||||
.form-input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
@@ -376,6 +443,7 @@ import {
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.form-input--textarea { min-height: 6rem; resize: vertical; }
|
||||
.form-input:focus { outline: none; border-color: var(--color-brand-primary); box-shadow: 0 0 0 2px rgba(245,166,35,0.15); }
|
||||
.add-form__actions { display: flex; justify-content: flex-end; gap: 0.5rem; }
|
||||
.btn--secondary {
|
||||
@@ -401,6 +469,8 @@ import {
|
||||
})
|
||||
export class AdminSettingsPageComponent implements OnInit {
|
||||
private readonly api = inject(AUTHORITY_ADMIN_API);
|
||||
private static readonly emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
private static readonly tenantIdPattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
|
||||
tabs = [
|
||||
{ id: 'users', label: 'Users' },
|
||||
@@ -424,6 +494,7 @@ export class AdminSettingsPageComponent implements OnInit {
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadTab('users');
|
||||
this.ensureRolesLoaded();
|
||||
}
|
||||
|
||||
setTab(tabId: string): void {
|
||||
@@ -434,6 +505,12 @@ export class AdminSettingsPageComponent implements OnInit {
|
||||
|
||||
showAddForm(formId: string): void {
|
||||
this.addFormVisible.set(this.addFormVisible() === formId ? null : formId);
|
||||
if (formId === 'users') {
|
||||
this.ensureRolesLoaded();
|
||||
}
|
||||
if (this.addFormVisible()) {
|
||||
this.error.set(null);
|
||||
}
|
||||
this.successMessage.set(null);
|
||||
}
|
||||
|
||||
@@ -446,22 +523,138 @@ export class AdminSettingsPageComponent implements OnInit {
|
||||
this.error.set('Username and email are required.');
|
||||
return;
|
||||
}
|
||||
if (!AdminSettingsPageComponent.emailPattern.test(email.trim())) {
|
||||
this.error.set('Enter a valid email address before creating the user.');
|
||||
return;
|
||||
}
|
||||
this.error.set(null);
|
||||
this.loading.set(true);
|
||||
this.api.createUser({ username: username.trim(), email: email.trim(), displayName: displayName.trim(), roles: [role] }).pipe(
|
||||
catchError((err) => {
|
||||
this.error.set('Failed to create user. The backend may be unavailable.');
|
||||
this.error.set(err?.status === 403
|
||||
? 'Your current session is missing the write scopes required to create users.'
|
||||
: 'Failed to create user. The backend may be unavailable.');
|
||||
return of(null);
|
||||
})
|
||||
).subscribe((result) => {
|
||||
this.loading.set(false);
|
||||
if (result) {
|
||||
this.addFormVisible.set(null);
|
||||
this.successMessage.set(`Created user ${result.username}.`);
|
||||
this.loadTab('users');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createRole(name: string, description: string, permissionsText: string): void {
|
||||
const normalizedName = name.trim().toLowerCase();
|
||||
const normalizedDescription = description.trim();
|
||||
const permissions = permissionsText
|
||||
.split(/[,\n]/)
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0);
|
||||
|
||||
if (!normalizedName) {
|
||||
this.error.set('Role name is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!normalizedDescription) {
|
||||
this.error.set('Role description is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (permissions.length === 0) {
|
||||
this.error.set('At least one permission is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.error.set(null);
|
||||
this.loading.set(true);
|
||||
this.api.createRole({
|
||||
name: normalizedName,
|
||||
description: normalizedDescription,
|
||||
permissions,
|
||||
}).pipe(
|
||||
catchError((err) => {
|
||||
this.error.set(err?.status === 403
|
||||
? 'Your current session is missing the write scopes required to create roles.'
|
||||
: 'Failed to create role.');
|
||||
return of(null);
|
||||
})
|
||||
).subscribe((result) => {
|
||||
this.loading.set(false);
|
||||
if (result) {
|
||||
this.addFormVisible.set(null);
|
||||
this.successMessage.set(`Created role ${result.name}.`);
|
||||
this.roles.update((roles) => [...roles, result].sort((left, right) => left.name.localeCompare(right.name)));
|
||||
if (this.activeTab() === 'roles') {
|
||||
this.loadTab('roles');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createTenant(id: string, displayName: string, isolationMode: string): void {
|
||||
const normalizedId = id.trim().toLowerCase();
|
||||
const normalizedDisplayName = displayName.trim();
|
||||
|
||||
if (!normalizedId) {
|
||||
this.error.set('Tenant ID is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!AdminSettingsPageComponent.tenantIdPattern.test(normalizedId)) {
|
||||
this.error.set('Tenant ID must use lowercase letters, digits, and hyphens only.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!normalizedDisplayName) {
|
||||
this.error.set('Tenant display name is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.error.set(null);
|
||||
this.loading.set(true);
|
||||
this.api.createTenant({
|
||||
id: normalizedId,
|
||||
displayName: normalizedDisplayName,
|
||||
isolationMode: isolationMode === 'dedicated' ? 'dedicated' : 'shared',
|
||||
}).pipe(
|
||||
catchError((err) => {
|
||||
this.error.set(err?.status === 403
|
||||
? 'Your current session is missing the write scopes required to create tenants.'
|
||||
: 'Failed to create tenant.');
|
||||
return of(null);
|
||||
})
|
||||
).subscribe((result) => {
|
||||
this.loading.set(false);
|
||||
if (result) {
|
||||
this.addFormVisible.set(null);
|
||||
this.successMessage.set(`Created tenant ${result.displayName}.`);
|
||||
this.loadTab('tenants');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
availableUserRoles(): AdminRole[] {
|
||||
return this.roles().length > 0
|
||||
? this.roles()
|
||||
: [{ id: 'admin', name: 'admin', description: 'Administrator', permissions: [], userCount: 0, isBuiltIn: true }];
|
||||
}
|
||||
|
||||
describeClientTenants(client: AdminClient): string {
|
||||
if (client.defaultTenant) {
|
||||
return client.defaultTenant;
|
||||
}
|
||||
|
||||
if (client.tenants && client.tenants.length > 0) {
|
||||
return client.tenants.join(', ');
|
||||
}
|
||||
|
||||
return 'All tenants';
|
||||
}
|
||||
|
||||
private loadTab(tabId: string): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
@@ -494,4 +687,14 @@ export class AdminSettingsPageComponent implements OnInit {
|
||||
})
|
||||
).subscribe(() => this.loading.set(false));
|
||||
}
|
||||
|
||||
private ensureRolesLoaded(): void {
|
||||
if (this.roles().length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.api.listRoles().pipe(
|
||||
catchError(() => of([]))
|
||||
).subscribe((roles) => this.roles.set(roles));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,11 @@ type RegionsView = 'region-first' | 'flat' | 'graph';
|
||||
<td>{{ environmentHealthLabel(env.environmentId) }}</td>
|
||||
<td>{{ env.targetCount }}</td>
|
||||
<td>
|
||||
<a [routerLink]="['/setup/topology/environments', env.environmentId, 'posture']" queryParamsHandling="merge">Open</a>
|
||||
<a
|
||||
[routerLink]="['/setup/topology/environments', env.environmentId, 'posture']"
|
||||
[queryParams]="{ environment: env.environmentId, environments: env.environmentId }"
|
||||
queryParamsHandling="merge"
|
||||
>Open</a>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
@@ -126,7 +130,13 @@ type RegionsView = 'region-first' | 'flat' | 'graph';
|
||||
<td>{{ env.environmentType }}</td>
|
||||
<td>{{ environmentHealthLabel(env.environmentId) }}</td>
|
||||
<td>{{ env.targetCount }}</td>
|
||||
<td><a [routerLink]="['/setup/topology/environments', env.environmentId, 'posture']" queryParamsHandling="merge">Open</a></td>
|
||||
<td>
|
||||
<a
|
||||
[routerLink]="['/setup/topology/environments', env.environmentId, 'posture']"
|
||||
[queryParams]="{ environment: env.environmentId, environments: env.environmentId }"
|
||||
queryParamsHandling="merge"
|
||||
>Open</a>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
@@ -158,7 +168,11 @@ type RegionsView = 'region-first' | 'flat' | 'graph';
|
||||
· targets {{ selectedEnvironmentTargetCount() }}
|
||||
</p>
|
||||
<div class="actions">
|
||||
<a [routerLink]="['/setup/topology/environments', selectedEnvironmentId(), 'posture']" queryParamsHandling="merge">Open Environment</a>
|
||||
<a
|
||||
[routerLink]="['/setup/topology/environments', selectedEnvironmentId(), 'posture']"
|
||||
[queryParams]="{ environment: selectedEnvironmentId(), environments: selectedEnvironmentId() }"
|
||||
queryParamsHandling="merge"
|
||||
>Open Environment</a>
|
||||
<a [routerLink]="['/setup/topology/targets']" [queryParams]="{ environment: selectedEnvironmentId() }" queryParamsHandling="merge">Open Targets</a>
|
||||
<a [routerLink]="['/setup/topology/agents']" [queryParams]="{ environment: selectedEnvironmentId() }" queryParamsHandling="merge">Open Agents</a>
|
||||
<a [routerLink]="['/releases/runs']" [queryParams]="{ environment: selectedEnvironmentId() }" queryParamsHandling="merge">Open Runs</a>
|
||||
@@ -395,6 +409,7 @@ export class TopologyRegionsEnvironmentsPageComponent {
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly searchQuery = signal('');
|
||||
readonly viewMode = signal<RegionsView>('region-first');
|
||||
readonly requestedEnvironmentId = signal('');
|
||||
readonly selectedRegionId = signal('');
|
||||
readonly selectedEnvironmentId = signal('');
|
||||
|
||||
@@ -475,8 +490,13 @@ export class TopologyRegionsEnvironmentsPageComponent {
|
||||
this.viewMode.set(defaultView);
|
||||
});
|
||||
|
||||
this.route.queryParamMap.subscribe((queryParamMap) => {
|
||||
this.requestedEnvironmentId.set(this.resolveRequestedEnvironmentId(queryParamMap));
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
this.context.contextVersion();
|
||||
this.requestedEnvironmentId();
|
||||
this.load();
|
||||
});
|
||||
}
|
||||
@@ -558,9 +578,13 @@ export class TopologyRegionsEnvironmentsPageComponent {
|
||||
const current = this.selectedRegionId();
|
||||
const scopedEnvironments = this.context.selectedEnvironments();
|
||||
const scopedRegions = this.context.selectedRegions();
|
||||
const requestedEnvironmentId = this.requestedEnvironmentId();
|
||||
const regionFromRequestedEnvironment =
|
||||
environments.find((item) => item.environmentId === requestedEnvironmentId)?.regionId ?? '';
|
||||
const regionFromScopedEnvironment = environments.find((item) => scopedEnvironments.includes(item.environmentId))?.regionId ?? '';
|
||||
const preferredScopedRegion =
|
||||
regionFromScopedEnvironment
|
||||
regionFromRequestedEnvironment
|
||||
|| regionFromScopedEnvironment
|
||||
|| scopedRegions.find((regionId) => regions.some((item) => item.regionId === regionId))
|
||||
|| '';
|
||||
|
||||
@@ -581,9 +605,18 @@ export class TopologyRegionsEnvironmentsPageComponent {
|
||||
): string {
|
||||
const current = this.selectedEnvironmentId();
|
||||
const scopedEnvironments = this.context.selectedEnvironments();
|
||||
const requestedEnvironmentId = this.requestedEnvironmentId();
|
||||
const environmentsInRegion = selectedRegionId
|
||||
? environments.filter((item) => item.regionId === selectedRegionId)
|
||||
: environments;
|
||||
const preferredRequestedEnvironment =
|
||||
environmentsInRegion.find((item) => item.environmentId === requestedEnvironmentId)?.environmentId
|
||||
?? environments.find((item) => item.environmentId === requestedEnvironmentId)?.environmentId
|
||||
?? '';
|
||||
|
||||
if (preferredRequestedEnvironment) {
|
||||
return preferredRequestedEnvironment;
|
||||
}
|
||||
|
||||
const preferredScopedEnvironment =
|
||||
scopedEnvironments.find((environmentId) =>
|
||||
@@ -604,6 +637,23 @@ export class TopologyRegionsEnvironmentsPageComponent {
|
||||
|
||||
return environmentsInRegion[0]?.environmentId ?? environments[0]?.environmentId ?? '';
|
||||
}
|
||||
|
||||
private resolveRequestedEnvironmentId(queryParamMap: Pick<import('@angular/router').ParamMap, 'get'>): string {
|
||||
const explicitEnvironment = queryParamMap.get('environment')?.trim();
|
||||
if (explicitEnvironment) {
|
||||
return explicitEnvironment;
|
||||
}
|
||||
|
||||
const environmentsValue = queryParamMap.get('environments')?.trim();
|
||||
if (!environmentsValue) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return environmentsValue
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.find((value) => value.length > 0) ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -105,6 +105,13 @@ describe('GatedBucketsComponent', () => {
|
||||
expect(chip.textContent).toContain('+23');
|
||||
});
|
||||
|
||||
it('renders bucket icons as SVG markup instead of escaped text', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
const icon = compiled.querySelector('.bucket-chip.unreachable .icon');
|
||||
expect(icon.innerHTML).toContain('<svg');
|
||||
expect(icon.textContent?.trim()).not.toContain('<svg');
|
||||
});
|
||||
|
||||
it('should render policy-dismissed chip', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
const chip = compiled.querySelector('.bucket-chip.policy-dismissed');
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface BucketExpandEvent {
|
||||
(click)="toggleBucket('unreachable')"
|
||||
[attr.aria-expanded]="expandedBucket() === 'unreachable'"
|
||||
attr.aria-label="Show {{ unreachableCount() }} unreachable findings">
|
||||
<span class="icon">{{ getIcon('unreachable') }}</span>
|
||||
<span class="icon" [innerHTML]="getIcon('unreachable')"></span>
|
||||
<span class="count">+{{ unreachableCount() }}</span>
|
||||
<span class="label">unreachable</span>
|
||||
</button>
|
||||
@@ -53,7 +53,7 @@ export interface BucketExpandEvent {
|
||||
(click)="toggleBucket('policy_dismissed')"
|
||||
[attr.aria-expanded]="expandedBucket() === 'policy_dismissed'"
|
||||
attr.aria-label="Show {{ policyDismissedCount() }} policy-dismissed findings">
|
||||
<span class="icon">{{ getIcon('policy_dismissed') }}</span>
|
||||
<span class="icon" [innerHTML]="getIcon('policy_dismissed')"></span>
|
||||
<span class="count">+{{ policyDismissedCount() }}</span>
|
||||
<span class="label">policy</span>
|
||||
</button>
|
||||
@@ -65,7 +65,7 @@ export interface BucketExpandEvent {
|
||||
(click)="toggleBucket('backported')"
|
||||
[attr.aria-expanded]="expandedBucket() === 'backported'"
|
||||
attr.aria-label="Show {{ backportedCount() }} backported findings">
|
||||
<span class="icon">{{ getIcon('backported') }}</span>
|
||||
<span class="icon" [innerHTML]="getIcon('backported')"></span>
|
||||
<span class="count">+{{ backportedCount() }}</span>
|
||||
<span class="label">backported</span>
|
||||
</button>
|
||||
@@ -77,7 +77,7 @@ export interface BucketExpandEvent {
|
||||
(click)="toggleBucket('vex_not_affected')"
|
||||
[attr.aria-expanded]="expandedBucket() === 'vex_not_affected'"
|
||||
attr.aria-label="Show {{ vexNotAffectedCount() }} VEX not-affected findings">
|
||||
<span class="icon">{{ getIcon('vex_not_affected') }}</span>
|
||||
<span class="icon" [innerHTML]="getIcon('vex_not_affected')"></span>
|
||||
<span class="count">+{{ vexNotAffectedCount() }}</span>
|
||||
<span class="label">VEX</span>
|
||||
</button>
|
||||
@@ -89,7 +89,7 @@ export interface BucketExpandEvent {
|
||||
(click)="toggleBucket('superseded')"
|
||||
[attr.aria-expanded]="expandedBucket() === 'superseded'"
|
||||
attr.aria-label="Show {{ supersededCount() }} superseded findings">
|
||||
<span class="icon">{{ getIcon('superseded') }}</span>
|
||||
<span class="icon" [innerHTML]="getIcon('superseded')"></span>
|
||||
<span class="count">+{{ supersededCount() }}</span>
|
||||
<span class="label">superseded</span>
|
||||
</button>
|
||||
@@ -101,7 +101,7 @@ export interface BucketExpandEvent {
|
||||
(click)="toggleBucket('user_muted')"
|
||||
[attr.aria-expanded]="expandedBucket() === 'user_muted'"
|
||||
attr.aria-label="Show {{ userMutedCount() }} user-muted findings">
|
||||
<span class="icon">{{ getIcon('user_muted') }}</span>
|
||||
<span class="icon" [innerHTML]="getIcon('user_muted')"></span>
|
||||
<span class="count">+{{ userMutedCount() }}</span>
|
||||
<span class="label">muted</span>
|
||||
</button>
|
||||
@@ -190,6 +190,9 @@ export interface BucketExpandEvent {
|
||||
}
|
||||
|
||||
.bucket-chip .icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
|
||||
@@ -381,7 +381,7 @@ export function getGatingReasonClass(reason: GatingReason): string {
|
||||
* Format trust score for display.
|
||||
*/
|
||||
export function formatTrustScore(score?: number): string {
|
||||
if (score === undefined || score === null) return '—';
|
||||
if (score === undefined || score === null) return '--';
|
||||
return (score * 100).toFixed(0) + '%';
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,738 +1,230 @@
|
||||
/**
|
||||
* @file issuer-trust-list.component.ts
|
||||
* @sprint SPRINT_20251229_018c_FE
|
||||
* @description Trusted issuers list with trust scores and management
|
||||
* @description Live trusted issuer inventory aligned to the administration trust API
|
||||
*/
|
||||
|
||||
import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit } 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 {
|
||||
TrustedIssuer,
|
||||
IssuerType,
|
||||
IssuerTrustLevel,
|
||||
ListIssuersParams,
|
||||
} from '../../core/api/trust.models';
|
||||
import { TrustScoreConfigComponent } from './trust-score-config.component';
|
||||
import { TrustedIssuer, IssuerTrustLevel } from '../../core/api/trust.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-issuer-trust-list',
|
||||
imports: [CommonModule, FormsModule, TrustScoreConfigComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="issuer-list">
|
||||
<!-- Filters -->
|
||||
<div class="issuer-list__filters">
|
||||
<div class="filter-group">
|
||||
<label for="search">Search</label>
|
||||
<input
|
||||
id="search"
|
||||
type="text"
|
||||
placeholder="Search issuers..."
|
||||
[ngModel]="searchQuery()"
|
||||
(ngModelChange)="searchQuery.set($event); onSearch()"
|
||||
/>
|
||||
selector: 'app-issuer-trust-list',
|
||||
imports: [CommonModule, FormsModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="issuer-list">
|
||||
<header class="issuer-list__header">
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="btn-secondary" (click)="loadIssuers()" [disabled]="loading()">
|
||||
{{ loading() ? 'Refreshing...' : 'Refresh' }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="filter-group">
|
||||
<label for="trustLevel">Trust Level</label>
|
||||
<select
|
||||
id="trustLevel"
|
||||
[ngModel]="selectedTrustLevel()"
|
||||
(ngModelChange)="selectedTrustLevel.set($event); onFilterChange()"
|
||||
>
|
||||
<option value="all">All Levels</option>
|
||||
<option value="full">Full Trust</option>
|
||||
<option value="partial">Partial Trust</option>
|
||||
<option value="minimal">Minimal Trust</option>
|
||||
<div class="issuer-list__filters">
|
||||
<label class="filter-field">
|
||||
<span>Search</span>
|
||||
<input
|
||||
type="text"
|
||||
[ngModel]="searchQuery()"
|
||||
(ngModelChange)="searchQuery.set($event); applyFilters()"
|
||||
placeholder="Search issuer name or URI"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="filter-field">
|
||||
<span>Trust Level</span>
|
||||
<select [ngModel]="selectedTrustLevel()" (ngModelChange)="selectedTrustLevel.set($event); applyFilters()">
|
||||
<option value="all">All trust levels</option>
|
||||
<option value="full">Full</option>
|
||||
<option value="partial">Partial</option>
|
||||
<option value="minimal">Minimal</option>
|
||||
<option value="untrusted">Untrusted</option>
|
||||
<option value="blocked">Blocked</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label for="issuerType">Type</label>
|
||||
<select
|
||||
id="issuerType"
|
||||
[ngModel]="selectedType()"
|
||||
(ngModelChange)="selectedType.set($event); onFilterChange()"
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
<option value="csaf_publisher">CSAF Publisher</option>
|
||||
<option value="vex_issuer">VEX Issuer</option>
|
||||
<option value="sbom_producer">SBOM Producer</option>
|
||||
<option value="attestation_authority">Attestation Authority</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn-config"
|
||||
(click)="showConfig.set(!showConfig())"
|
||||
>
|
||||
Configure Scoring
|
||||
</button>
|
||||
</label>
|
||||
|
||||
@if (hasFilters()) {
|
||||
<button type="button" class="btn-link" (click)="clearFilters()">
|
||||
Clear filters
|
||||
</button>
|
||||
<button type="button" class="btn-link" (click)="clearFilters()">Clear filters</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Trust Score Config Panel -->
|
||||
@if (showConfig()) {
|
||||
<app-trust-score-config
|
||||
[selectedIssuer]="selectedIssuer()"
|
||||
(configSaved)="onConfigSaved()"
|
||||
(close)="showConfig.set(false)"
|
||||
></app-trust-score-config>
|
||||
}
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="issuer-list__stats">
|
||||
<div class="stat">
|
||||
<span class="stat__value">{{ issuers().length }}</span>
|
||||
<span class="stat__label">Total Issuers</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat__value stat__value--full">{{ countByLevel('full') }}</span>
|
||||
<span class="stat__label">Full Trust</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat__value stat__value--partial">{{ countByLevel('partial') }}</span>
|
||||
<span class="stat__label">Partial</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat__value stat__value--blocked">{{ countByLevel('blocked') }}</span>
|
||||
<span class="stat__label">Blocked</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat__value">{{ averageScore() | number:'1.1-1' }}</span>
|
||||
<span class="stat__label">Avg Score</span>
|
||||
</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>
|
||||
|
||||
<!-- Issuers Table -->
|
||||
<div class="issuer-list__table-container">
|
||||
@if (loading()) {
|
||||
<div class="issuer-list__loading">Loading issuers...</div>
|
||||
} @else if (error()) {
|
||||
<div class="issuer-list__error">{{ error() }}</div>
|
||||
} @else if (issuers().length === 0) {
|
||||
<div class="issuer-list__empty">
|
||||
No issuers found.
|
||||
@if (hasFilters()) {
|
||||
<button type="button" class="btn-link" (click)="clearFilters()">
|
||||
Clear filters
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<table class="issuer-table">
|
||||
<thead>
|
||||
@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>
|
||||
} @else {
|
||||
<table class="issuer-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Issuer URI</th>
|
||||
<th>Trust Level</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Updated</th>
|
||||
<th>Updated By</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (issuer of issuers(); track issuer.issuerId) {
|
||||
<tr>
|
||||
<th class="sortable" (click)="onSort('name')">
|
||||
Issuer
|
||||
@if (sortBy() === 'name') {
|
||||
<span class="sort-indicator">{{ sortDirection() === 'asc' ? '+' : '-' }}</span>
|
||||
}
|
||||
</th>
|
||||
<th>Type</th>
|
||||
<th class="sortable" (click)="onSort('trustScore')">
|
||||
Score
|
||||
@if (sortBy() === 'trustScore') {
|
||||
<span class="sort-indicator">{{ sortDirection() === 'asc' ? '+' : '-' }}</span>
|
||||
}
|
||||
</th>
|
||||
<th>Trust Level</th>
|
||||
<th>Documents</th>
|
||||
<th class="sortable" (click)="onSort('lastVerifiedAt')">
|
||||
Last Verified
|
||||
@if (sortBy() === 'lastVerifiedAt') {
|
||||
<span class="sort-indicator">{{ sortDirection() === 'asc' ? '+' : '-' }}</span>
|
||||
}
|
||||
</th>
|
||||
<th>Actions</th>
|
||||
<td>{{ issuer.displayName }}</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (issuer of issuers(); track issuer.issuerId) {
|
||||
<tr [class.row-blocked]="issuer.trustLevel === 'blocked'">
|
||||
<td>
|
||||
<div class="issuer-info">
|
||||
<strong>{{ issuer.displayName }}</strong>
|
||||
<span class="issuer-name">{{ issuer.name }}</span>
|
||||
@if (issuer.description) {
|
||||
<span class="issuer-desc">{{ issuer.description }}</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="type-badge" [class]="'type-' + issuer.issuerType">
|
||||
{{ formatType(issuer.issuerType) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="score-cell">
|
||||
<div class="score-bar">
|
||||
<div
|
||||
class="score-fill"
|
||||
[style.width.%]="issuer.trustScore"
|
||||
[class]="'score-fill--' + issuer.trustLevel"
|
||||
></div>
|
||||
</div>
|
||||
<span class="score-value">{{ issuer.trustScore }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="trust-badge" [class]="'trust-' + issuer.trustLevel">
|
||||
{{ formatTrustLevel(issuer.trustLevel) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="doc-count">{{ issuer.documentCount | number }}</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (issuer.lastVerifiedAt) {
|
||||
{{ issuer.lastVerifiedAt | date:'short' }}
|
||||
} @else {
|
||||
<span class="never-verified">Never</span>
|
||||
}
|
||||
</td>
|
||||
<td class="actions-cell">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action"
|
||||
title="Configure Weights"
|
||||
(click)="selectIssuer(issuer); showConfig.set(true)"
|
||||
>
|
||||
Configure
|
||||
</button>
|
||||
@if (issuer.trustLevel !== 'blocked') {
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action btn-action--danger"
|
||||
title="Block Issuer"
|
||||
(click)="onBlockIssuer(issuer)"
|
||||
>
|
||||
Block
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action btn-action--success"
|
||||
title="Unblock Issuer"
|
||||
(click)="onUnblockIssuer(issuer)"
|
||||
>
|
||||
Unblock
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Pagination -->
|
||||
@if (totalPages() > 1) {
|
||||
<div class="issuer-list__pagination">
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="pageNumber() <= 1"
|
||||
(click)="onPageChange(pageNumber() - 1)"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span class="page-info">
|
||||
Page {{ pageNumber() }} of {{ totalPages() }}
|
||||
({{ totalCount() }} issuers)
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="pageNumber() >= totalPages()"
|
||||
(click)="onPageChange(pageNumber() + 1)"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.issuer-list {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.issuer-list__filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.filter-group input,
|
||||
.filter-group select {
|
||||
background: var(--color-surface-primary);
|
||||
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__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 {
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-primary);
|
||||
padding: 0.5rem 0.75rem;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.filter-group input:focus,
|
||||
.filter-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-status-info);
|
||||
}
|
||||
|
||||
.btn-config {
|
||||
background: var(--color-status-excepted-border);
|
||||
border: none;
|
||||
color: var(--color-surface-inverse);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.5rem 1rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-config:hover {
|
||||
background: var(--color-status-excepted);
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-status-info);
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.issuer-list__stats {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--color-surface-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.stat__value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stat__value--full { color: var(--color-status-success-border); }
|
||||
.stat__value--partial { color: var(--color-status-warning-border); }
|
||||
.stat__value--blocked { color: var(--color-status-error); }
|
||||
|
||||
.stat__label {
|
||||
font-size: 0.75rem;
|
||||
.contract-note {
|
||||
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);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.issuer-list__table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.issuer-list__loading,
|
||||
.issuer-list__error,
|
||||
.issuer-list__empty {
|
||||
padding: 3rem;
|
||||
.state {
|
||||
padding: 2rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.issuer-list__error {
|
||||
color: var(--color-status-error);
|
||||
}
|
||||
|
||||
.issuer-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.issuer-table th,
|
||||
.issuer-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.issuer-table th {
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-muted);
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.issuer-table th.sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.issuer-table th.sortable:hover {
|
||||
color: var(--color-status-info);
|
||||
}
|
||||
|
||||
.sort-indicator {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.issuer-table tbody tr {
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.issuer-table tbody tr:hover {
|
||||
background: rgba(34, 211, 238, 0.05);
|
||||
}
|
||||
|
||||
.issuer-table tbody tr.row-blocked {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.issuer-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.issuer-info strong {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.issuer-name {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.issuer-desc {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.7rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.type-csaf_publisher { background: rgba(34, 211, 238, 0.15); color: var(--color-status-info); }
|
||||
.type-vex_issuer { background: rgba(74, 222, 128, 0.15); color: var(--color-status-success-border); }
|
||||
.type-sbom_producer { background: rgba(167, 139, 250, 0.15); color: var(--color-status-excepted-border); }
|
||||
.type-attestation_authority { background: rgba(251, 191, 36, 0.15); color: var(--color-status-warning-border); }
|
||||
|
||||
.score-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.score-bar {
|
||||
width: 60px;
|
||||
height: 6px;
|
||||
background: var(--color-surface-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.score-fill {
|
||||
height: 100%;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.score-fill--full { background: var(--color-status-success-border); }
|
||||
.score-fill--partial { background: var(--color-status-warning-border); }
|
||||
.score-fill--minimal { background: var(--color-severity-high-border); }
|
||||
.score-fill--untrusted { background: var(--color-text-muted); }
|
||||
.score-fill--blocked { background: var(--color-status-error); }
|
||||
|
||||
.score-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: var(--font-weight-medium);
|
||||
min-width: 2rem;
|
||||
}
|
||||
|
||||
.trust-badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.trust-full { background: rgba(74, 222, 128, 0.15); color: var(--color-status-success-border); }
|
||||
.trust-partial { background: rgba(251, 191, 36, 0.15); color: var(--color-status-warning-border); }
|
||||
.trust-minimal { background: rgba(251, 146, 60, 0.15); color: var(--color-severity-high-border); }
|
||||
.trust-untrusted { background: rgba(148, 163, 184, 0.15); color: var(--color-text-muted); }
|
||||
.trust-blocked { background: rgba(239, 68, 68, 0.15); color: var(--color-status-error); }
|
||||
|
||||
.doc-count {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.never-verified {
|
||||
color: var(--color-text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
color: var(--color-text-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
margin-right: 0.25rem;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-action:hover {
|
||||
border-color: var(--color-status-info);
|
||||
color: var(--color-status-info);
|
||||
}
|
||||
|
||||
.btn-action--danger:hover {
|
||||
border-color: var(--color-status-error);
|
||||
.state--error {
|
||||
color: var(--color-status-error);
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
|
||||
.btn-action--success:hover {
|
||||
border-color: var(--color-status-success-border);
|
||||
color: var(--color-status-success-border);
|
||||
.issuer-table { width: 100%; border-collapse: collapse; }
|
||||
.issuer-table th, .issuer-table td {
|
||||
padding: 0.75rem 0.9rem;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.issuer-list__pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
.issuer-table th {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
|
||||
.issuer-list__pagination button {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
color: var(--color-text-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.5rem 1rem;
|
||||
.issuer-table a { color: var(--color-status-info); word-break: break-word; }
|
||||
.btn-secondary, .btn-link {
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.issuer-list__pagination button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
.btn-secondary {
|
||||
padding: 0.4rem 0.7rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.issuer-list__pagination button:hover:not(:disabled) {
|
||||
border-color: var(--color-status-info);
|
||||
.btn-link {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-status-info);
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.74rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
`]
|
||||
.badge--full { background: rgba(74, 222, 128, 0.15); color: var(--color-status-success-border); }
|
||||
.badge--partial { background: rgba(251, 191, 36, 0.16); color: var(--color-status-warning-border); }
|
||||
.badge--minimal { background: rgba(251, 146, 60, 0.12); color: var(--color-severity-high-border); }
|
||||
.badge--untrusted, .badge--blocked { background: rgba(239, 68, 68, 0.12); color: var(--color-status-error); }
|
||||
`],
|
||||
})
|
||||
export class IssuerTrustListComponent implements OnInit {
|
||||
export class IssuerTrustListComponent {
|
||||
private readonly trustApi = inject(TRUST_API);
|
||||
|
||||
// State
|
||||
readonly issuers = signal<TrustedIssuer[]>([]);
|
||||
readonly loading = signal(false);
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly selectedIssuer = signal<TrustedIssuer | null>(null);
|
||||
readonly showConfig = signal(false);
|
||||
|
||||
// Pagination
|
||||
readonly pageNumber = signal(1);
|
||||
readonly pageSize = signal(20);
|
||||
readonly totalCount = signal(0);
|
||||
readonly totalPages = computed(() => Math.ceil(this.totalCount() / this.pageSize()));
|
||||
|
||||
// Filters
|
||||
readonly searchQuery = signal('');
|
||||
readonly selectedTrustLevel = signal<IssuerTrustLevel | 'all'>('all');
|
||||
readonly selectedType = signal<IssuerType | 'all'>('all');
|
||||
readonly sortBy = signal<'name' | 'trustScore' | 'createdAt' | 'lastVerifiedAt'>('trustScore');
|
||||
readonly sortDirection = signal<'asc' | 'desc'>('desc');
|
||||
|
||||
// Computed
|
||||
readonly hasFilters = computed(() =>
|
||||
this.searchQuery() !== '' ||
|
||||
this.selectedTrustLevel() !== 'all' ||
|
||||
this.selectedType() !== 'all'
|
||||
);
|
||||
readonly hasFilters = computed(() => this.searchQuery().trim().length > 0 || this.selectedTrustLevel() !== 'all');
|
||||
|
||||
readonly averageScore = computed(() => {
|
||||
const list = this.issuers();
|
||||
if (list.length === 0) return 0;
|
||||
return list.reduce((sum, i) => sum + i.trustScore, 0) / list.length;
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
constructor() {
|
||||
this.loadIssuers();
|
||||
}
|
||||
|
||||
private loadIssuers(): void {
|
||||
loadIssuers(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
const params: ListIssuersParams = {
|
||||
pageNumber: this.pageNumber(),
|
||||
pageSize: this.pageSize(),
|
||||
search: this.searchQuery() || undefined,
|
||||
trustLevel: this.selectedTrustLevel() !== 'all' ? this.selectedTrustLevel() as IssuerTrustLevel : undefined,
|
||||
issuerType: this.selectedType() !== 'all' ? this.selectedType() as IssuerType : undefined,
|
||||
sortBy: this.sortBy(),
|
||||
sortDirection: this.sortDirection(),
|
||||
};
|
||||
|
||||
this.trustApi.listIssuers(params).subscribe({
|
||||
const trustLevel = this.selectedTrustLevel();
|
||||
this.trustApi.listIssuers({
|
||||
pageNumber: 1,
|
||||
pageSize: 200,
|
||||
search: this.searchQuery().trim() || undefined,
|
||||
trustLevel: trustLevel === 'all' ? undefined : trustLevel,
|
||||
sortBy: 'name',
|
||||
sortDirection: 'asc',
|
||||
}).subscribe({
|
||||
next: (result) => {
|
||||
this.issuers.set([...result.items]);
|
||||
this.totalCount.set(result.totalCount);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err.message || 'Failed to load issuers');
|
||||
this.error.set(err?.error?.error || err?.message || 'Failed to load issuers.');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onSearch(): void {
|
||||
this.pageNumber.set(1);
|
||||
this.loadIssuers();
|
||||
}
|
||||
|
||||
onFilterChange(): void {
|
||||
this.pageNumber.set(1);
|
||||
this.loadIssuers();
|
||||
}
|
||||
|
||||
onSort(column: 'name' | 'trustScore' | 'createdAt' | 'lastVerifiedAt'): void {
|
||||
if (this.sortBy() === column) {
|
||||
this.sortDirection.set(this.sortDirection() === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
this.sortBy.set(column);
|
||||
this.sortDirection.set(column === 'trustScore' ? 'desc' : 'asc');
|
||||
}
|
||||
this.loadIssuers();
|
||||
}
|
||||
|
||||
onPageChange(page: number): void {
|
||||
this.pageNumber.set(page);
|
||||
applyFilters(): void {
|
||||
this.loadIssuers();
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.searchQuery.set('');
|
||||
this.selectedTrustLevel.set('all');
|
||||
this.selectedType.set('all');
|
||||
this.pageNumber.set(1);
|
||||
this.loadIssuers();
|
||||
}
|
||||
|
||||
selectIssuer(issuer: TrustedIssuer): void {
|
||||
this.selectedIssuer.set(issuer);
|
||||
}
|
||||
|
||||
countByLevel(level: IssuerTrustLevel): number {
|
||||
return this.issuers().filter(i => i.trustLevel === level).length;
|
||||
}
|
||||
|
||||
onBlockIssuer(issuer: TrustedIssuer): void {
|
||||
const reason = prompt(`Enter reason for blocking "${issuer.displayName}":`);
|
||||
if (!reason) return;
|
||||
|
||||
this.trustApi.blockIssuer(issuer.issuerId, reason).subscribe({
|
||||
next: () => {
|
||||
this.loadIssuers();
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(`Failed to block issuer: ${err.message}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onUnblockIssuer(issuer: TrustedIssuer): void {
|
||||
if (!confirm(`Unblock "${issuer.displayName}"?`)) return;
|
||||
|
||||
this.trustApi.unblockIssuer(issuer.issuerId).subscribe({
|
||||
next: () => {
|
||||
this.loadIssuers();
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(`Failed to unblock issuer: ${err.message}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onConfigSaved(): void {
|
||||
this.loadIssuers();
|
||||
this.selectedIssuer.set(null);
|
||||
}
|
||||
|
||||
formatType(type: IssuerType): string {
|
||||
const labels: Record<IssuerType, string> = {
|
||||
csaf_publisher: 'CSAF Publisher',
|
||||
vex_issuer: 'VEX Issuer',
|
||||
sbom_producer: 'SBOM Producer',
|
||||
attestation_authority: 'Attestation Authority',
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
||||
formatTrustLevel(level: IssuerTrustLevel): string {
|
||||
const labels: Record<IssuerTrustLevel, string> = {
|
||||
full: 'Full Trust',
|
||||
partial: 'Partial',
|
||||
minimal: 'Minimal',
|
||||
untrusted: 'Untrusted',
|
||||
blocked: 'Blocked',
|
||||
};
|
||||
return labels[level] || level;
|
||||
return level.charAt(0).toUpperCase() + level.slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -426,7 +426,7 @@ export class TrustAdminComponent implements OnInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
// State
|
||||
readonly loading = signal(false);
|
||||
readonly loading = signal(true);
|
||||
readonly refreshing = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly overview = signal<TrustAdministrationOverview | null>(null);
|
||||
|
||||
@@ -153,7 +153,7 @@ describe('AppSidebarComponent', () => {
|
||||
expect(text).toContain('Bundles');
|
||||
});
|
||||
|
||||
it('shows Operations children: Scheduled Jobs, Diagnostics, Signals, Offline Kit, Environments', () => {
|
||||
it('shows Operations children: Scheduled Jobs, Signals, Offline Kit, Environments', () => {
|
||||
setScopes([
|
||||
StellaOpsScopes.UI_ADMIN,
|
||||
StellaOpsScopes.ORCH_READ,
|
||||
@@ -167,12 +167,26 @@ describe('AppSidebarComponent', () => {
|
||||
const hrefs = links.map((link) => link.getAttribute('href'));
|
||||
|
||||
expect(hrefs).toContain('/ops/operations/jobengine');
|
||||
expect(hrefs).toContain('/ops/operations/doctor');
|
||||
expect(hrefs).toContain('/ops/operations/signals');
|
||||
expect(hrefs).toContain('/ops/operations/offline-kit');
|
||||
expect(hrefs).toContain('/ops/operations/environments');
|
||||
});
|
||||
|
||||
it('shows Diagnostics under Setup', () => {
|
||||
setScopes([
|
||||
StellaOpsScopes.UI_ADMIN,
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
StellaOpsScopes.ORCH_READ,
|
||||
StellaOpsScopes.ORCH_OPERATE,
|
||||
StellaOpsScopes.HEALTH_READ,
|
||||
]);
|
||||
const fixture = createComponent();
|
||||
const links = Array.from(fixture.nativeElement.querySelectorAll('a')) as HTMLAnchorElement[];
|
||||
const hrefs = links.map((link) => link.getAttribute('href'));
|
||||
|
||||
expect(hrefs).toContain('/ops/operations/doctor');
|
||||
});
|
||||
|
||||
it('does not show Notifications under Setup', () => {
|
||||
setScopes([
|
||||
StellaOpsScopes.UI_ADMIN,
|
||||
|
||||
@@ -713,7 +713,6 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
],
|
||||
children: [
|
||||
{ id: 'ops-jobs', label: 'Scheduled Jobs', route: '/ops/operations/jobengine', icon: 'clock' },
|
||||
{ id: 'ops-diagnostics', label: 'Diagnostics', route: '/ops/operations/doctor', icon: 'stethoscope' },
|
||||
{ id: 'ops-signals', label: 'Signals', route: '/ops/operations/signals', icon: 'radio' },
|
||||
{ id: 'ops-offline-kit', label: 'Offline Kit', route: '/ops/operations/offline-kit', icon: 'download-cloud' },
|
||||
{ id: 'ops-environments', label: 'Environments', route: '/ops/operations/environments', icon: 'globe' },
|
||||
@@ -788,6 +787,13 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
],
|
||||
children: [
|
||||
{ id: 'setup-topology', label: 'Topology', route: '/setup/topology/overview', icon: 'globe' },
|
||||
{
|
||||
id: 'setup-diagnostics',
|
||||
label: 'Diagnostics',
|
||||
route: '/ops/operations/doctor',
|
||||
icon: 'stethoscope',
|
||||
requireAnyScope: [StellaOpsScopes.HEALTH_READ, StellaOpsScopes.UI_ADMIN],
|
||||
},
|
||||
{ id: 'setup-integrations', label: 'Integrations', route: '/setup/integrations', icon: 'plug' },
|
||||
{ id: 'setup-iam', label: 'Identity & Access', route: '/setup/identity-access', icon: 'user' },
|
||||
{ id: 'setup-trust-signing', label: 'Trust & Signing', route: '/setup/trust-signing', icon: 'shield' },
|
||||
|
||||
@@ -140,12 +140,12 @@ describe('FilterBarComponent', () => {
|
||||
});
|
||||
|
||||
it('should show clear-all button only when active filters exist', () => {
|
||||
component.activeFilters = [];
|
||||
fixture.componentRef.setInput('activeFilters', []);
|
||||
fixture.detectChanges();
|
||||
let clearBtn = fixture.nativeElement.querySelector('.filter-bar__clear');
|
||||
expect(clearBtn).toBeNull();
|
||||
|
||||
component.activeFilters = [{ key: 'severity', value: 'high', label: 'Severity: High' }];
|
||||
fixture.componentRef.setInput('activeFilters', [{ key: 'severity', value: 'high', label: 'Severity: High' }]);
|
||||
fixture.detectChanges();
|
||||
clearBtn = fixture.nativeElement.querySelector('.filter-bar__clear');
|
||||
expect(clearBtn).toBeTruthy();
|
||||
|
||||
@@ -92,18 +92,21 @@ export interface ActiveFilter {
|
||||
}
|
||||
|
||||
.filter-bar__top-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-bar__search {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filter-bar__search-btn {
|
||||
align-self: stretch;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--color-brand-primary);
|
||||
border-radius: var(--radius-md);
|
||||
@@ -120,6 +123,29 @@ export interface ActiveFilter {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.filter-bar__top-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.filter-bar__search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filter-bar__search-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.filter-bar__active {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-bar__clear {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-bar__search-icon {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
@@ -129,7 +155,9 @@ export interface ActiveFilter {
|
||||
}
|
||||
|
||||
.filter-bar__search-input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0.5rem 0.75rem 0.5rem 2.25rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"src/app/features/evidence-export/provenance-visualization.component.spec.ts",
|
||||
"src/app/features/evidence-export/replay-controls.component.spec.ts",
|
||||
"src/app/features/evidence-export/stella-bundle-export-button/stella-bundle-export-button.component.spec.ts",
|
||||
"src/app/features/jobengine/jobengine-dashboard.component.spec.ts",
|
||||
"src/app/features/platform/ops/platform-jobs-queues-page.component.spec.ts",
|
||||
"src/app/features/policy-simulation/policy-simulation-direct-route-defaults.spec.ts",
|
||||
"src/app/features/policy-simulation/policy-simulation-defaults.spec.ts",
|
||||
@@ -29,6 +30,7 @@
|
||||
"src/app/features/registry-admin/registry-admin.component.spec.ts",
|
||||
"src/app/features/triage/services/ttfs-telemetry.service.spec.ts",
|
||||
"src/app/features/triage/triage-workspace.component.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"
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user