Repair first-time identity and trust operator journeys

This commit is contained in:
master
2026-03-15 12:33:56 +02:00
parent 7bdfcd5055
commit 08390f0ca4
27 changed files with 5814 additions and 2425 deletions

View File

@@ -156,6 +156,105 @@ public sealed class ConsoleAdminEndpointsTests
Assert.Equal("invalid_email", payload!.Error);
}
[Fact]
public async Task UpdateUser_DisableUser_AndEnableUser_PersistLifecycleAndRoles()
{
var now = new DateTimeOffset(2026, 2, 20, 13, 40, 0, TimeSpan.Zero);
var timeProvider = new FakeTimeProvider(now);
var sink = new RecordingAuthEventSink();
var users = new InMemoryUserRepository();
var roles = new InMemoryRoleRepository();
await roles.CreateAsync("default", new RoleEntity
{
Id = Guid.Parse("a9e2fd75-df4e-4485-b23f-1ec573f59138"),
TenantId = "default",
Name = "role/console-viewer",
DisplayName = "Console Viewer",
Description = "Read-only console access",
IsSystem = false,
Metadata = "{}",
CreatedAt = now.AddDays(-5),
UpdatedAt = now.AddDays(-5),
});
await roles.CreateAsync("default", new RoleEntity
{
Id = Guid.Parse("f526b104-24f7-46f1-ab52-3c3dfd95cb0c"),
TenantId = "default",
Name = "role/release-operator",
DisplayName = "Release Operator",
Description = "Operate releases",
IsSystem = false,
Metadata = "{}",
CreatedAt = now.AddDays(-5),
UpdatedAt = now.AddDays(-5),
});
await using var app = await CreateApplicationAsync(timeProvider, sink, users, roleRepository: roles);
var principalAccessor = app.Services.GetRequiredService<AdminTestPrincipalAccessor>();
principalAccessor.Principal = CreatePrincipal(
tenant: "default",
scopes: new[]
{
StellaOpsScopes.UiAdmin,
StellaOpsScopes.AuthorityUsersRead,
StellaOpsScopes.AuthorityUsersWrite,
},
expiresAt: now.AddMinutes(10));
using var client = CreateTestClient(app);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(AdminAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "default");
var createResponse = await client.PostAsJsonAsync(
"/console/admin/users",
new
{
username = "operator.alice",
email = "operator.alice@example.com",
displayName = "Operator Alice",
roles = new[] { "role/console-viewer" }
});
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
var created = await createResponse.Content.ReadFromJsonAsync<UserSummary>();
Assert.NotNull(created);
var updateResponse = await client.PatchAsJsonAsync(
$"/console/admin/users/{created!.Id}",
new
{
displayName = "Release Operator Alice",
roles = new[] { "role/release-operator" }
});
Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);
var updated = await updateResponse.Content.ReadFromJsonAsync<UserSummary>();
Assert.NotNull(updated);
Assert.Equal("Release Operator Alice", updated!.DisplayName);
Assert.Equal(new[] { "role/release-operator" }, updated.Roles);
var disableResponse = await client.PostAsJsonAsync($"/console/admin/users/{created.Id}/disable", new { });
Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode);
var disabled = await disableResponse.Content.ReadFromJsonAsync<UserSummary>();
Assert.NotNull(disabled);
Assert.Equal("disabled", disabled!.Status);
var enableResponse = await client.PostAsJsonAsync($"/console/admin/users/{created.Id}/enable", new { });
Assert.Equal(HttpStatusCode.OK, enableResponse.StatusCode);
var enabled = await enableResponse.Content.ReadFromJsonAsync<UserSummary>();
Assert.NotNull(enabled);
Assert.Equal("active", enabled!.Status);
var listResponse = await client.GetAsync("/console/admin/users");
var payload = await listResponse.Content.ReadFromJsonAsync<UserListPayload>();
Assert.NotNull(payload);
var listed = Assert.Single(payload!.Users.Where(static user => user.Username == "operator.alice"));
Assert.Equal("Release Operator Alice", listed.DisplayName);
Assert.Equal(new[] { "role/release-operator" }, listed.Roles);
}
[Fact]
public async Task RolesList_ExposesNamedDefaults_AndCreateRolePersists()
{
@@ -216,6 +315,90 @@ public sealed class ConsoleAdminEndpointsTests
Assert.Contains(reloaded!.Roles, role => role.Name == "security-analyst");
}
[Fact]
public async Task UpdateRole_RefreshesScopes_AndPreviewImpactCountsAffectedUsers()
{
var now = new DateTimeOffset(2026, 2, 20, 13, 55, 0, TimeSpan.Zero);
var timeProvider = new FakeTimeProvider(now);
var sink = new RecordingAuthEventSink();
var users = new InMemoryUserRepository();
var roles = new InMemoryRoleRepository();
var permissions = new InMemoryPermissionRepository();
await using var app = await CreateApplicationAsync(
timeProvider,
sink,
users,
roleRepository: roles,
permissionRepository: permissions);
var principalAccessor = app.Services.GetRequiredService<AdminTestPrincipalAccessor>();
principalAccessor.Principal = CreatePrincipal(
tenant: "default",
scopes: new[]
{
StellaOpsScopes.UiAdmin,
StellaOpsScopes.AuthorityUsersWrite,
StellaOpsScopes.AuthorityRolesRead,
StellaOpsScopes.AuthorityRolesWrite,
},
expiresAt: now.AddMinutes(10));
using var client = CreateTestClient(app);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(AdminAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "default");
var createRoleResponse = await client.PostAsJsonAsync(
"/console/admin/roles",
new
{
roleId = "security-analyst",
displayName = "Security Analyst",
scopes = new[] { "findings:read", "vex:read" }
});
Assert.Equal(HttpStatusCode.Created, createRoleResponse.StatusCode);
var createdRole = await createRoleResponse.Content.ReadFromJsonAsync<RoleSummary>();
Assert.NotNull(createdRole);
var createUserResponse = await client.PostAsJsonAsync(
"/console/admin/users",
new
{
username = "security.alice",
email = "security.alice@example.com",
displayName = "Security Alice",
roles = new[] { "security-analyst" }
});
Assert.Equal(HttpStatusCode.Created, createUserResponse.StatusCode);
var updateResponse = await client.PatchAsJsonAsync(
$"/console/admin/roles/{createdRole!.Id}",
new
{
displayName = "Security Analyst Plus",
scopes = new[] { "findings:read", "vex:read", "vuln:investigate" }
});
Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);
var updated = await updateResponse.Content.ReadFromJsonAsync<RoleSummary>();
Assert.NotNull(updated);
Assert.Equal("security-analyst", updated!.Name);
Assert.Equal("Security Analyst Plus", updated.Description);
Assert.Contains("vuln:investigate", updated.Permissions);
var previewResponse = await client.PostAsJsonAsync(
$"/console/admin/roles/{createdRole.Id}/preview-impact",
new { });
Assert.Equal(HttpStatusCode.OK, previewResponse.StatusCode);
var preview = await previewResponse.Content.ReadFromJsonAsync<RoleImpactPayload>();
Assert.NotNull(preview);
Assert.Equal(1, preview!.AffectedUsers);
Assert.Equal(0, preview.AffectedClients);
}
[Fact]
public async Task TenantsList_MergesCatalog_AndCreateTenantPersists()
{
@@ -908,7 +1091,17 @@ public sealed class ConsoleAdminEndpointsTests
public Task<bool> UpdateAsync(UserEntity user, CancellationToken cancellationToken = default)
{
return Task.FromResult(false);
lock (_sync)
{
var index = _users.FindIndex(existing => existing.TenantId == user.TenantId && existing.Id == user.Id);
if (index < 0)
{
return Task.FromResult(false);
}
_users[index] = user;
return Task.FromResult(true);
}
}
public Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
@@ -1235,6 +1428,7 @@ public sealed class ConsoleAdminEndpointsTests
private sealed record UserListPayload(IReadOnlyList<UserSummary> Users, int Count);
private sealed record RoleListPayload(IReadOnlyList<RoleSummary> Roles, int Count);
private sealed record TenantListPayload(IReadOnlyList<TenantSummary> Tenants, int Count);
private sealed record RoleImpactPayload(int AffectedUsers, int AffectedClients, string? Message);
private sealed record ClientListPayload(IReadOnlyList<ClientSummary> Clients, int Count, string SelectedTenant);
private sealed record ClientSummary(
string ClientId,

View File

@@ -593,10 +593,86 @@ internal static class ConsoleAdminEndpointExtensions
HttpContext httpContext,
string userId,
UpdateUserRequest request,
IUserRepository userRepository,
IRoleRepository roleRepository,
IAuthEventSink auditSink,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
var tenantId = ResolveTenantId(httpContext);
var user = await ResolveUserEntityAsync(tenantId, userId, userRepository, cancellationToken).ConfigureAwait(false);
if (user is null)
{
return Results.NotFound(new { error = "user_not_found", userId });
}
var updatedRoles = request.Roles is null ? null : NormalizeRoles(request.Roles);
IReadOnlyList<RoleEntity>? resolvedRoles = null;
if (updatedRoles is not null)
{
resolvedRoles = await ResolveRequestedRolesAsync(tenantId, updatedRoles, roleRepository, cancellationToken).ConfigureAwait(false);
var missingRoles = updatedRoles
.Except(resolvedRoles.Select(static role => role.Name), StringComparer.OrdinalIgnoreCase)
.ToArray();
if (missingRoles.Length > 0)
{
return Results.BadRequest(new { error = "roles_not_found", roles = missingRoles });
}
}
var now = timeProvider.GetUtcNow();
var updatedUser = new UserEntity
{
Id = user.Id,
TenantId = user.TenantId,
Username = user.Username,
Email = user.Email,
DisplayName = NormalizeOptional(request.DisplayName) ?? user.DisplayName,
PasswordHash = user.PasswordHash,
PasswordSalt = user.PasswordSalt,
Enabled = user.Enabled,
EmailVerified = user.EmailVerified,
MfaEnabled = user.MfaEnabled,
MfaSecret = user.MfaSecret,
MfaBackupCodes = user.MfaBackupCodes,
FailedLoginAttempts = user.FailedLoginAttempts,
LockedUntil = user.LockedUntil,
LastLoginAt = user.LastLoginAt,
PasswordChangedAt = user.PasswordChangedAt,
Settings = user.Settings,
Metadata = updatedRoles is null ? user.Metadata : UpdateUserMetadata(user.Metadata, updatedRoles),
CreatedAt = user.CreatedAt,
UpdatedAt = now,
CreatedBy = user.CreatedBy,
};
var persisted = await userRepository.UpdateAsync(updatedUser, cancellationToken).ConfigureAwait(false);
if (!persisted)
{
return Results.Problem("Failed to persist user updates.");
}
if (updatedRoles is not null && resolvedRoles is not null)
{
var currentRoles = await roleRepository.GetUserRolesAsync(tenantId, user.Id, cancellationToken).ConfigureAwait(false);
foreach (var role in currentRoles.Where(role => !updatedRoles.Contains(role.Name, StringComparer.OrdinalIgnoreCase)))
{
await roleRepository.RemoveFromUserAsync(tenantId, user.Id, role.Id, cancellationToken).ConfigureAwait(false);
}
var grantedBy = httpContext.User.FindFirstValue(OpenIddictConstants.Claims.PreferredUsername)
?? httpContext.User.FindFirstValue(StellaOpsClaimTypes.Subject)
?? "console-admin";
foreach (var role in resolvedRoles.Where(role => !currentRoles.Any(existing => existing.Id == role.Id)))
{
await roleRepository.AssignToUserAsync(tenantId, user.Id, role.Id, grantedBy, expiresAt: null, cancellationToken).ConfigureAwait(false);
}
}
var effectiveRoles = updatedRoles ?? await ResolveUserRolesAsync(updatedUser, roleRepository, cancellationToken).ConfigureAwait(false);
var summary = ToAdminUserSummary(updatedUser, now, effectiveRoles);
await WriteAdminAuditAsync(
httpContext,
auditSink,
@@ -604,19 +680,64 @@ internal static class ConsoleAdminEndpointExtensions
"authority.admin.users.update",
AuthEventOutcome.Success,
null,
BuildProperties(("user.id", userId)),
BuildProperties(("tenant.id", tenantId), ("user.id", summary.Id)),
cancellationToken).ConfigureAwait(false);
return Results.Ok(new { message = "User update: implementation pending" });
return Results.Ok(summary);
}
private static async Task<IResult> DisableUser(
HttpContext httpContext,
string userId,
IUserRepository userRepository,
IRoleRepository roleRepository,
IAuthEventSink auditSink,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
var tenantId = ResolveTenantId(httpContext);
var user = await ResolveUserEntityAsync(tenantId, userId, userRepository, cancellationToken).ConfigureAwait(false);
if (user is null)
{
return Results.NotFound(new { error = "user_not_found", userId });
}
var updatedUser = new UserEntity
{
Id = user.Id,
TenantId = user.TenantId,
Username = user.Username,
Email = user.Email,
DisplayName = user.DisplayName,
PasswordHash = user.PasswordHash,
PasswordSalt = user.PasswordSalt,
Enabled = false,
EmailVerified = user.EmailVerified,
MfaEnabled = user.MfaEnabled,
MfaSecret = user.MfaSecret,
MfaBackupCodes = user.MfaBackupCodes,
FailedLoginAttempts = user.FailedLoginAttempts,
LockedUntil = user.LockedUntil,
LastLoginAt = user.LastLoginAt,
PasswordChangedAt = user.PasswordChangedAt,
Settings = user.Settings,
Metadata = user.Metadata,
CreatedAt = user.CreatedAt,
UpdatedAt = timeProvider.GetUtcNow(),
CreatedBy = user.CreatedBy,
};
var persisted = await userRepository.UpdateAsync(updatedUser, cancellationToken).ConfigureAwait(false);
if (!persisted)
{
return Results.Problem("Failed to disable user.");
}
var summary = ToAdminUserSummary(
updatedUser,
timeProvider.GetUtcNow(),
await ResolveUserRolesAsync(updatedUser, roleRepository, cancellationToken).ConfigureAwait(false));
await WriteAdminAuditAsync(
httpContext,
auditSink,
@@ -624,19 +745,64 @@ internal static class ConsoleAdminEndpointExtensions
"authority.admin.users.disable",
AuthEventOutcome.Success,
null,
BuildProperties(("user.id", userId)),
BuildProperties(("tenant.id", tenantId), ("user.id", summary.Id)),
cancellationToken).ConfigureAwait(false);
return Results.Ok(new { message = "User disable: implementation pending" });
return Results.Ok(summary);
}
private static async Task<IResult> EnableUser(
HttpContext httpContext,
string userId,
IUserRepository userRepository,
IRoleRepository roleRepository,
IAuthEventSink auditSink,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
var tenantId = ResolveTenantId(httpContext);
var user = await ResolveUserEntityAsync(tenantId, userId, userRepository, cancellationToken).ConfigureAwait(false);
if (user is null)
{
return Results.NotFound(new { error = "user_not_found", userId });
}
var updatedUser = new UserEntity
{
Id = user.Id,
TenantId = user.TenantId,
Username = user.Username,
Email = user.Email,
DisplayName = user.DisplayName,
PasswordHash = user.PasswordHash,
PasswordSalt = user.PasswordSalt,
Enabled = true,
EmailVerified = user.EmailVerified,
MfaEnabled = user.MfaEnabled,
MfaSecret = user.MfaSecret,
MfaBackupCodes = user.MfaBackupCodes,
FailedLoginAttempts = 0,
LockedUntil = null,
LastLoginAt = user.LastLoginAt,
PasswordChangedAt = user.PasswordChangedAt,
Settings = user.Settings,
Metadata = user.Metadata,
CreatedAt = user.CreatedAt,
UpdatedAt = timeProvider.GetUtcNow(),
CreatedBy = user.CreatedBy,
};
var persisted = await userRepository.UpdateAsync(updatedUser, cancellationToken).ConfigureAwait(false);
if (!persisted)
{
return Results.Problem("Failed to enable user.");
}
var summary = ToAdminUserSummary(
updatedUser,
timeProvider.GetUtcNow(),
await ResolveUserRolesAsync(updatedUser, roleRepository, cancellationToken).ConfigureAwait(false));
await WriteAdminAuditAsync(
httpContext,
auditSink,
@@ -644,10 +810,10 @@ internal static class ConsoleAdminEndpointExtensions
"authority.admin.users.enable",
AuthEventOutcome.Success,
null,
BuildProperties(("user.id", userId)),
BuildProperties(("tenant.id", tenantId), ("user.id", summary.Id)),
cancellationToken).ConfigureAwait(false);
return Results.Ok(new { message = "User enable: implementation pending" });
return Results.Ok(summary);
}
// ========== ROLE ENDPOINTS ==========
@@ -796,10 +962,101 @@ internal static class ConsoleAdminEndpointExtensions
HttpContext httpContext,
string roleId,
UpdateRoleRequest request,
IRoleRepository roleRepository,
IPermissionRepository permissionRepository,
IAuthEventSink auditSink,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
var tenantId = ResolveTenantId(httpContext);
var role = await ResolveRoleEntityAsync(tenantId, roleId, roleRepository, cancellationToken).ConfigureAwait(false);
if (role is null)
{
return Results.NotFound(new { error = "role_not_found", roleId });
}
if (role.IsSystem)
{
return Results.BadRequest(new
{
error = "built_in_role_read_only",
roleId = role.Name,
message = "Built-in roles cannot be edited from the setup console. Create a custom role instead.",
});
}
IReadOnlyList<string>? normalizedScopes = null;
if (request.Scopes is not null)
{
normalizedScopes = NormalizeScopes(request.Scopes);
if (normalizedScopes.Count == 0)
{
return Results.BadRequest(new { error = "scopes_required" });
}
}
var now = timeProvider.GetUtcNow();
var updatedRole = new RoleEntity
{
Id = role.Id,
TenantId = role.TenantId,
Name = role.Name,
DisplayName = NormalizeOptional(request.DisplayName) ?? role.DisplayName,
Description = NormalizeOptional(request.DisplayName) ?? role.Description ?? role.DisplayName,
IsSystem = role.IsSystem,
Metadata = role.Metadata,
CreatedAt = role.CreatedAt,
UpdatedAt = now,
};
await roleRepository.UpdateAsync(tenantId, updatedRole, cancellationToken).ConfigureAwait(false);
IReadOnlyList<string> effectiveScopes;
if (normalizedScopes is not null)
{
var currentPermissions = await permissionRepository.GetRolePermissionsAsync(tenantId, role.Id, cancellationToken).ConfigureAwait(false);
var currentByName = currentPermissions.ToDictionary(static permission => permission.Name, StringComparer.OrdinalIgnoreCase);
foreach (var permission in currentPermissions.Where(permission => !normalizedScopes.Contains(permission.Name, StringComparer.OrdinalIgnoreCase)))
{
await permissionRepository.RemoveFromRoleAsync(tenantId, role.Id, permission.Id, cancellationToken).ConfigureAwait(false);
}
foreach (var scope in normalizedScopes.Where(scope => !currentByName.ContainsKey(scope)))
{
var permission = await permissionRepository.GetByNameAsync(tenantId, scope, cancellationToken).ConfigureAwait(false);
var permissionId = permission?.Id ?? await permissionRepository.CreateAsync(tenantId, new PermissionEntity
{
Id = Guid.NewGuid(),
TenantId = tenantId,
Name = scope,
Resource = ExtractPermissionResource(scope),
Action = ExtractPermissionAction(scope),
Description = $"Console-admin maintained scope '{scope}'.",
CreatedAt = now,
}, cancellationToken).ConfigureAwait(false);
await permissionRepository.AssignToRoleAsync(tenantId, role.Id, permissionId, cancellationToken).ConfigureAwait(false);
}
effectiveScopes = normalizedScopes;
}
else
{
effectiveScopes = (await permissionRepository.GetRolePermissionsAsync(tenantId, role.Id, cancellationToken).ConfigureAwait(false))
.Select(static permission => permission.Name)
.OrderBy(static scope => scope, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
var summary = new AdminRoleSummary(
Id: updatedRole.Id.ToString("N"),
Name: updatedRole.Name,
Description: string.IsNullOrWhiteSpace(updatedRole.Description) ? updatedRole.Name : updatedRole.Description!,
Permissions: effectiveScopes,
UserCount: 0,
IsBuiltIn: false);
await WriteAdminAuditAsync(
httpContext,
auditSink,
@@ -807,19 +1064,44 @@ internal static class ConsoleAdminEndpointExtensions
"authority.admin.roles.update",
AuthEventOutcome.Success,
null,
BuildProperties(("role.id", roleId)),
BuildProperties(("tenant.id", tenantId), ("role.id", updatedRole.Name)),
cancellationToken).ConfigureAwait(false);
return Results.Ok(new { message = "Role update: implementation pending" });
return Results.Ok(summary);
}
private static async Task<IResult> PreviewRoleImpact(
HttpContext httpContext,
string roleId,
IRoleRepository roleRepository,
IUserRepository userRepository,
IAuthEventSink auditSink,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
var tenantId = ResolveTenantId(httpContext);
var role = await ResolveRoleEntityAsync(tenantId, roleId, roleRepository, cancellationToken).ConfigureAwait(false);
if (role is null)
{
return Results.NotFound(new { error = "role_not_found", roleId });
}
var users = await userRepository.GetAllAsync(
tenantId,
enabled: null,
limit: 500,
offset: 0,
cancellationToken).ConfigureAwait(false);
var affectedUsers = 0;
foreach (var user in users)
{
var roles = await ResolveUserRolesAsync(user, roleRepository, cancellationToken).ConfigureAwait(false);
if (roles.Contains(role.Name, StringComparer.OrdinalIgnoreCase))
{
affectedUsers++;
}
}
await WriteAdminAuditAsync(
httpContext,
auditSink,
@@ -827,10 +1109,17 @@ internal static class ConsoleAdminEndpointExtensions
"authority.admin.roles.preview",
AuthEventOutcome.Success,
null,
BuildProperties(("role.id", roleId)),
BuildProperties(("tenant.id", tenantId), ("role.id", role.Name)),
cancellationToken).ConfigureAwait(false);
return Results.Ok(new { affectedUsers = 0, affectedClients = 0, message = "Impact preview: implementation pending" });
return Results.Ok(new
{
affectedUsers,
affectedClients = 0,
message = affectedUsers == 0
? "No users are currently assigned to this role."
: $"{affectedUsers} user{(affectedUsers == 1 ? string.Empty : "s")} would be affected by changes to this role.",
});
}
// ========== CLIENT ENDPOINTS ==========
@@ -1260,6 +1549,74 @@ internal static class ConsoleAdminEndpointExtensions
return "default";
}
private static async Task<UserEntity?> ResolveUserEntityAsync(
string tenantId,
string userId,
IUserRepository userRepository,
CancellationToken cancellationToken)
{
if (Guid.TryParse(userId, out var parsedId))
{
var byGuid = await userRepository.GetByIdAsync(tenantId, parsedId, cancellationToken).ConfigureAwait(false);
if (byGuid is not null)
{
return byGuid;
}
}
var bySubject = await userRepository.GetBySubjectIdAsync(tenantId, userId, cancellationToken).ConfigureAwait(false);
if (bySubject is not null)
{
return bySubject;
}
return await userRepository.GetByUsernameAsync(tenantId, userId, cancellationToken).ConfigureAwait(false);
}
private static async Task<RoleEntity?> ResolveRoleEntityAsync(
string tenantId,
string roleId,
IRoleRepository roleRepository,
CancellationToken cancellationToken)
{
if (Guid.TryParse(roleId, out var parsedId))
{
var byGuid = await roleRepository.GetByIdAsync(tenantId, parsedId, cancellationToken).ConfigureAwait(false);
if (byGuid is not null)
{
return byGuid;
}
}
return await roleRepository.GetByNameAsync(tenantId, roleId, cancellationToken).ConfigureAwait(false);
}
private static async Task<IReadOnlyList<RoleEntity>> ResolveRequestedRolesAsync(
string tenantId,
IReadOnlyList<string> requestedRoles,
IRoleRepository roleRepository,
CancellationToken cancellationToken)
{
var resolved = new List<RoleEntity>(requestedRoles.Count);
foreach (var roleName in requestedRoles)
{
var role = await roleRepository.GetByNameAsync(tenantId, roleName, cancellationToken).ConfigureAwait(false);
if (role is not null)
{
resolved.Add(role);
}
}
return resolved;
}
private static string UpdateUserMetadata(string metadataJson, IReadOnlyList<string> roles)
{
var metadata = ParseMetadata(metadataJson);
metadata["roles"] = roles;
return JsonSerializer.Serialize(metadata);
}
private static IReadOnlyList<string> NormalizeRoles(IReadOnlyList<string>? roles)
{
if (roles is null || roles.Count == 0)

View File

@@ -57,6 +57,14 @@ public sealed record RegisterAdministrationTrustIssuerRequest(
string IssuerUri,
string TrustLevel);
public sealed record BlockAdministrationTrustIssuerRequest(
string Reason,
string? Ticket);
public sealed record UnblockAdministrationTrustIssuerRequest(
string? TrustLevel,
string? Ticket);
public sealed record RegisterAdministrationTrustCertificateRequest(
Guid? KeyId,
Guid? IssuerId,

View File

@@ -226,6 +226,72 @@ public static class AdministrationTrustSigningMutationEndpoints
.WithSummary("Register trust issuer")
.RequireAuthorization(PlatformPolicies.TrustWrite);
group.MapPost("/issuers/{issuerId:guid}/block", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
IAdministrationTrustSigningStore store,
Guid issuerId,
BlockAdministrationTrustIssuerRequest request,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
try
{
var updated = await store.BlockIssuerAsync(
requestContext!.TenantId,
requestContext.ActorId,
issuerId,
request,
cancellationToken).ConfigureAwait(false);
return Results.Ok(updated);
}
catch (InvalidOperationException ex)
{
return MapStoreError(ex, keyId: null, certificateId: null);
}
})
.WithName("BlockAdministrationTrustIssuer")
.WithSummary("Block trust issuer")
.RequireAuthorization(PlatformPolicies.TrustAdmin);
group.MapPost("/issuers/{issuerId:guid}/unblock", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
IAdministrationTrustSigningStore store,
Guid issuerId,
UnblockAdministrationTrustIssuerRequest request,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
try
{
var updated = await store.UnblockIssuerAsync(
requestContext!.TenantId,
requestContext.ActorId,
issuerId,
request,
cancellationToken).ConfigureAwait(false);
return Results.Ok(updated);
}
catch (InvalidOperationException ex)
{
return MapStoreError(ex, keyId: null, certificateId: null);
}
})
.WithName("UnblockAdministrationTrustIssuer")
.WithSummary("Unblock trust issuer")
.RequireAuthorization(PlatformPolicies.TrustAdmin);
group.MapGet("/certificates", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,

View File

@@ -42,6 +42,20 @@ public interface IAdministrationTrustSigningStore
RegisterAdministrationTrustIssuerRequest request,
CancellationToken cancellationToken = default);
Task<AdministrationTrustIssuerSummary> BlockIssuerAsync(
string tenantId,
string actorId,
Guid issuerId,
BlockAdministrationTrustIssuerRequest request,
CancellationToken cancellationToken = default);
Task<AdministrationTrustIssuerSummary> UnblockIssuerAsync(
string tenantId,
string actorId,
Guid issuerId,
UnblockAdministrationTrustIssuerRequest request,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<AdministrationTrustCertificateSummary>> ListCertificatesAsync(
string tenantId,
int limit,

View File

@@ -214,6 +214,66 @@ public sealed class InMemoryAdministrationTrustSigningStore : IAdministrationTru
}
}
public Task<AdministrationTrustIssuerSummary> BlockIssuerAsync(
string tenantId,
string actorId,
Guid issuerId,
BlockAdministrationTrustIssuerRequest request,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (request is null) throw new InvalidOperationException("request_required");
_ = NormalizeRequired(request.Reason, "reason_required");
var actor = NormalizeActor(actorId);
var now = _timeProvider.GetUtcNow();
var state = GetState(tenantId);
lock (state.Sync)
{
if (!state.Issuers.TryGetValue(issuerId, out var issuer))
{
throw new InvalidOperationException("issuer_not_found");
}
issuer.TrustLevel = "blocked";
issuer.Status = "blocked";
issuer.UpdatedAt = now;
issuer.UpdatedBy = actor;
return Task.FromResult(ToSummary(issuer));
}
}
public Task<AdministrationTrustIssuerSummary> UnblockIssuerAsync(
string tenantId,
string actorId,
Guid issuerId,
UnblockAdministrationTrustIssuerRequest request,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (request is null) throw new InvalidOperationException("request_required");
var trustLevel = NormalizeOptional(request.TrustLevel) ?? "minimal";
var actor = NormalizeActor(actorId);
var now = _timeProvider.GetUtcNow();
var state = GetState(tenantId);
lock (state.Sync)
{
if (!state.Issuers.TryGetValue(issuerId, out var issuer))
{
throw new InvalidOperationException("issuer_not_found");
}
issuer.TrustLevel = trustLevel;
issuer.Status = "active";
issuer.UpdatedAt = now;
issuer.UpdatedBy = actor;
return Task.FromResult(ToSummary(issuer));
}
}
public Task<IReadOnlyList<AdministrationTrustCertificateSummary>> ListCertificatesAsync(
string tenantId,
int limit,

View File

@@ -390,6 +390,109 @@ public sealed class PostgresAdministrationTrustSigningStore : IAdministrationTru
}
}
public async Task<AdministrationTrustIssuerSummary> BlockIssuerAsync(
string tenantId,
string actorId,
Guid issuerId,
BlockAdministrationTrustIssuerRequest request,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (request is null) throw new InvalidOperationException("request_required");
_ = NormalizeRequired(request.Reason, "reason_required");
var actor = NormalizeActor(actorId);
var now = _timeProvider.GetUtcNow();
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(
"""
UPDATE release.trust_issuers
SET
trust_level = 'blocked',
status = 'blocked',
updated_at = @updated_at,
updated_by = @updated_by
WHERE tenant_id = @tenant_id AND id = @id
RETURNING
id,
issuer_name,
issuer_uri,
trust_level,
status,
created_at,
updated_at,
updated_by
""",
connection);
command.Parameters.AddWithValue("tenant_id", tenantGuid);
command.Parameters.AddWithValue("id", issuerId);
command.Parameters.AddWithValue("updated_at", now);
command.Parameters.AddWithValue("updated_by", actor);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
throw new InvalidOperationException("issuer_not_found");
}
return MapIssuerSummary(reader);
}
public async Task<AdministrationTrustIssuerSummary> UnblockIssuerAsync(
string tenantId,
string actorId,
Guid issuerId,
UnblockAdministrationTrustIssuerRequest request,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (request is null) throw new InvalidOperationException("request_required");
var trustLevel = NormalizeOptional(request.TrustLevel) ?? "minimal";
var actor = NormalizeActor(actorId);
var now = _timeProvider.GetUtcNow();
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(
"""
UPDATE release.trust_issuers
SET
trust_level = @trust_level,
status = 'active',
updated_at = @updated_at,
updated_by = @updated_by
WHERE tenant_id = @tenant_id AND id = @id
RETURNING
id,
issuer_name,
issuer_uri,
trust_level,
status,
created_at,
updated_at,
updated_by
""",
connection);
command.Parameters.AddWithValue("tenant_id", tenantGuid);
command.Parameters.AddWithValue("id", issuerId);
command.Parameters.AddWithValue("trust_level", trustLevel);
command.Parameters.AddWithValue("updated_at", now);
command.Parameters.AddWithValue("updated_by", actor);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
throw new InvalidOperationException("issuer_not_found");
}
return MapIssuerSummary(reader);
}
public async Task<IReadOnlyList<AdministrationTrustCertificateSummary>> ListCertificatesAsync(
string tenantId,
int limit,

View File

@@ -64,6 +64,31 @@ public sealed class AdministrationTrustSigningMutationEndpointsTests : IClassFix
var issuer = await issuerResponse.Content.ReadFromJsonAsync<AdministrationTrustIssuerSummary>(
TestContext.Current.CancellationToken);
Assert.NotNull(issuer);
Assert.Equal("active", issuer!.Status);
var blockIssuerResponse = await client.PostAsJsonAsync(
$"/api/v1/administration/trust-signing/issuers/{issuer.IssuerId}/block",
new BlockAdministrationTrustIssuerRequest("publisher compromised", "IR-51"),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, blockIssuerResponse.StatusCode);
var blockedIssuer = await blockIssuerResponse.Content.ReadFromJsonAsync<AdministrationTrustIssuerSummary>(
TestContext.Current.CancellationToken);
Assert.NotNull(blockedIssuer);
Assert.Equal("blocked", blockedIssuer!.TrustLevel);
Assert.Equal("blocked", blockedIssuer.Status);
var unblockIssuerResponse = await client.PostAsJsonAsync(
$"/api/v1/administration/trust-signing/issuers/{issuer.IssuerId}/unblock",
new UnblockAdministrationTrustIssuerRequest("partial", "IR-52"),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, unblockIssuerResponse.StatusCode);
var unblockedIssuer = await unblockIssuerResponse.Content.ReadFromJsonAsync<AdministrationTrustIssuerSummary>(
TestContext.Current.CancellationToken);
Assert.NotNull(unblockedIssuer);
Assert.Equal("partial", unblockedIssuer!.TrustLevel);
Assert.Equal("active", unblockedIssuer.Status);
var certificateResponse = await client.PostAsJsonAsync(
"/api/v1/administration/trust-signing/certificates",
@@ -194,6 +219,8 @@ public sealed class AdministrationTrustSigningMutationEndpointsTests : IClassFix
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/keys/{keyId:guid}/rotate", "POST", PlatformPolicies.TrustWrite);
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/keys/{keyId:guid}/revoke", "POST", PlatformPolicies.TrustAdmin);
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/issuers", "POST", PlatformPolicies.TrustWrite);
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/issuers/{issuerId:guid}/block", "POST", PlatformPolicies.TrustAdmin);
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/issuers/{issuerId:guid}/unblock", "POST", PlatformPolicies.TrustAdmin);
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/certificates/{certificateId:guid}/revoke", "POST", PlatformPolicies.TrustAdmin);
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/transparency-log", "GET", PlatformPolicies.TrustRead);
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/transparency-log", "PUT", PlatformPolicies.TrustAdmin);

View File

@@ -76,18 +76,39 @@ export interface CreateUserRequest {
roles: string[];
}
export interface UpdateUserRequest {
displayName?: string;
roles?: string[];
}
export interface CreateRoleRequest {
name: string;
description: string;
permissions: string[];
}
export interface UpdateRoleRequest {
description?: string;
permissions?: string[];
}
export interface CreateTenantRequest {
id: string;
displayName: string;
isolationMode: string;
}
export interface UpdateTenantRequest {
displayName?: string;
isolationMode?: string;
}
export interface RoleImpactPreview {
affectedUsers: number;
affectedClients: number;
message?: string;
}
export interface AuthorityAdminApi {
listUsers(tenantId?: string): Observable<AdminUser[]>;
listRoles(tenantId?: string): Observable<AdminRole[]>;
@@ -95,8 +116,16 @@ export interface AuthorityAdminApi {
listTokens(tenantId?: string): Observable<AdminToken[]>;
listTenants(): Observable<AdminTenant[]>;
createUser(request: CreateUserRequest): Observable<AdminUser>;
updateUser(userId: string, request: UpdateUserRequest): Observable<AdminUser>;
disableUser(userId: string): Observable<AdminUser>;
enableUser(userId: string): Observable<AdminUser>;
createRole(request: CreateRoleRequest): Observable<AdminRole>;
updateRole(roleId: string, request: UpdateRoleRequest): Observable<AdminRole>;
previewRoleImpact(roleId: string): Observable<RoleImpactPreview>;
createTenant(request: CreateTenantRequest): Observable<AdminTenant>;
updateTenant(tenantId: string, request: UpdateTenantRequest): Observable<AdminTenant>;
suspendTenant(tenantId: string): Observable<AdminTenant>;
resumeTenant(tenantId: string): Observable<AdminTenant>;
}
export const AUTHORITY_ADMIN_API = new InjectionToken<AuthorityAdminApi>('AUTHORITY_ADMIN_API');
@@ -226,6 +255,24 @@ export class AuthorityAdminHttpClient implements AuthorityAdminApi {
}).pipe(map((user) => this.mapUser(user)));
}
updateUser(userId: string, request: UpdateUserRequest): Observable<AdminUser> {
return this.http.patch<AdminUserDto>(`${this.baseUrl}/users/${encodeURIComponent(userId)}`, request, {
headers: this.buildHeaders(),
}).pipe(map((user) => this.mapUser(user)));
}
disableUser(userId: string): Observable<AdminUser> {
return this.http.post<AdminUserDto>(`${this.baseUrl}/users/${encodeURIComponent(userId)}/disable`, {}, {
headers: this.buildHeaders(),
}).pipe(map((user) => this.mapUser(user)));
}
enableUser(userId: string): Observable<AdminUser> {
return this.http.post<AdminUserDto>(`${this.baseUrl}/users/${encodeURIComponent(userId)}/enable`, {}, {
headers: this.buildHeaders(),
}).pipe(map((user) => this.mapUser(user)));
}
createRole(request: CreateRoleRequest): Observable<AdminRole> {
return this.http.post<AdminRoleDto>(`${this.baseUrl}/roles`, {
roleId: request.name,
@@ -236,6 +283,21 @@ export class AuthorityAdminHttpClient implements AuthorityAdminApi {
}).pipe(map((role) => this.mapRole(role)));
}
updateRole(roleId: string, request: UpdateRoleRequest): Observable<AdminRole> {
return this.http.patch<AdminRoleDto>(`${this.baseUrl}/roles/${encodeURIComponent(roleId)}`, {
displayName: request.description,
scopes: request.permissions,
}, {
headers: this.buildHeaders(),
}).pipe(map((role) => this.mapRole(role)));
}
previewRoleImpact(roleId: string): Observable<RoleImpactPreview> {
return this.http.post<RoleImpactPreview>(`${this.baseUrl}/roles/${encodeURIComponent(roleId)}/preview-impact`, {}, {
headers: this.buildHeaders(),
});
}
createTenant(request: CreateTenantRequest): Observable<AdminTenant> {
return this.http.post<AdminTenantDto>(`${this.baseUrl}/tenants`, {
id: request.id,
@@ -246,6 +308,24 @@ export class AuthorityAdminHttpClient implements AuthorityAdminApi {
}).pipe(map((tenant) => this.mapTenant(tenant)));
}
updateTenant(tenantId: string, request: UpdateTenantRequest): Observable<AdminTenant> {
return this.http.patch<AdminTenantDto>(`${this.baseUrl}/tenants/${encodeURIComponent(tenantId)}`, request, {
headers: this.buildHeaders(),
}).pipe(map((tenant) => this.mapTenant(tenant)));
}
suspendTenant(tenantId: string): Observable<AdminTenant> {
return this.http.post<AdminTenantDto>(`${this.baseUrl}/tenants/${encodeURIComponent(tenantId)}/suspend`, {}, {
headers: this.buildHeaders(),
}).pipe(map((tenant) => this.mapTenant(tenant)));
}
resumeTenant(tenantId: string): Observable<AdminTenant> {
return this.http.post<AdminTenantDto>(`${this.baseUrl}/tenants/${encodeURIComponent(tenantId)}/resume`, {}, {
headers: this.buildHeaders(),
}).pipe(map((tenant) => this.mapTenant(tenant)));
}
private buildHeaders(tenantOverride?: string): HttpHeaders {
const tenantId =
(tenantOverride && tenantOverride.trim()) ||
@@ -343,26 +423,31 @@ export class AuthorityAdminHttpClient implements AuthorityAdminApi {
@Injectable({ providedIn: 'root' })
export class MockAuthorityAdminClient implements AuthorityAdminApi {
private readonly usersData: AdminUser[] = [
{ id: 'u-1', username: 'admin', email: 'admin@stella-ops.local', displayName: 'Platform Admin', roles: ['role/console-admin'], status: 'active', createdAt: '2026-01-01T00:00:00Z', lastLoginAt: '2026-02-15T10:30:00Z' },
{ id: 'u-2', username: 'jane.smith', email: 'jane.smith@example.com', displayName: 'Jane Smith', roles: ['role/console-viewer'], status: 'active', createdAt: '2026-01-10T00:00:00Z', lastLoginAt: '2026-02-14T15:00:00Z' },
{ id: 'u-3', username: 'bob.wilson', email: 'bob.wilson@example.com', displayName: 'Bob Wilson', roles: ['role/scanner-operator'], status: 'active', createdAt: '2026-01-15T00:00:00Z' },
{ id: 'u-4', username: 'svc-scanner', email: 'scanner@stella-ops.local', displayName: 'Scanner Service', roles: ['role/scanner-admin'], status: 'active', createdAt: '2026-01-01T00:00:00Z' },
{ id: 'u-5', username: 'alice.johnson', email: 'alice@example.com', displayName: 'Alice Johnson', roles: ['role/release-operator', 'role/console-viewer'], status: 'disabled', createdAt: '2026-01-20T00:00:00Z' },
];
private readonly rolesData: AdminRole[] = [
{ id: 'role/console-admin', name: 'role/console-admin', description: 'Full platform administration for setup, access, and audit controls.', permissions: ['ui.admin', 'authority:users.write', 'authority:roles.write', 'authority:tenants.write'], userCount: 1, isBuiltIn: true },
{ id: 'role/console-viewer', name: 'role/console-viewer', description: 'Least-privilege console access for viewing setup state without making changes.', permissions: ['ui.read', 'authority:users.read', 'authority:roles.read', 'authority:tenants.read'], userCount: 2, isBuiltIn: true },
{ id: 'role/release-operator', name: 'role/release-operator', description: 'Operate releases, promotions, and deployment approvals.', permissions: ['release:read', 'release:write', 'release:publish', 'policy:read'], userCount: 1, isBuiltIn: true },
{ id: 'role/scanner-operator', name: 'role/scanner-operator', description: 'Run scans and review findings without changing platform policy.', permissions: ['scanner:read', 'scanner:scan', 'findings:read'], userCount: 1, isBuiltIn: true },
{ id: 'role/scanner-admin', name: 'role/scanner-admin', description: 'Administer scanner configuration and scanner-side exports.', permissions: ['scanner:read', 'scanner:scan', 'scanner:write', 'scanner:export'], userCount: 1, isBuiltIn: true },
];
private readonly tenantsData: AdminTenant[] = [
{ id: 'default', displayName: 'Default', status: 'active', isolationMode: 'shared', userCount: 5, createdAt: '2026-01-01T00:00:00Z' },
{ id: 'production', displayName: 'Production Tenant', status: 'active', isolationMode: 'dedicated', userCount: 3, createdAt: '2026-01-10T00:00:00Z' },
];
listUsers(): Observable<AdminUser[]> {
const data: AdminUser[] = [
{ id: 'u-1', username: 'admin', email: 'admin@stella-ops.local', displayName: 'Platform Admin', roles: ['admin', 'operator'], status: 'active', createdAt: '2026-01-01T00:00:00Z', lastLoginAt: '2026-02-15T10:30:00Z' },
{ id: 'u-2', username: 'jane.smith', email: 'jane.smith@example.com', displayName: 'Jane Smith', roles: ['reviewer'], status: 'active', createdAt: '2026-01-10T00:00:00Z', lastLoginAt: '2026-02-14T15:00:00Z' },
{ id: 'u-3', username: 'bob.wilson', email: 'bob.wilson@example.com', displayName: 'Bob Wilson', roles: ['developer'], status: 'active', createdAt: '2026-01-15T00:00:00Z' },
{ id: 'u-4', username: 'svc-scanner', email: 'scanner@stella-ops.local', displayName: 'Scanner Service', roles: ['service'], status: 'active', createdAt: '2026-01-01T00:00:00Z' },
{ id: 'u-5', username: 'alice.johnson', email: 'alice@example.com', displayName: 'Alice Johnson', roles: ['operator', 'reviewer'], status: 'disabled', createdAt: '2026-01-20T00:00:00Z' },
];
return of(data).pipe(delay(300));
return of(this.usersData.map((user) => ({ ...user, roles: [...user.roles] }))).pipe(delay(300));
}
listRoles(): Observable<AdminRole[]> {
const data: AdminRole[] = [
{ id: 'r-1', name: 'admin', description: 'Full platform administrator', permissions: ['*'], userCount: 1, isBuiltIn: true },
{ id: 'r-2', name: 'operator', description: 'Manage releases and deployments', permissions: ['release:*', 'deploy:*'], userCount: 2, isBuiltIn: true },
{ id: 'r-3', name: 'reviewer', description: 'Review and approve promotions', permissions: ['approval:read', 'approval:approve', 'release:read'], userCount: 2, isBuiltIn: true },
{ id: 'r-4', name: 'developer', description: 'Read-only access to releases and security', permissions: ['release:read', 'security:read'], userCount: 1, isBuiltIn: false },
{ id: 'r-5', name: 'service', description: 'Machine-to-machine service account', permissions: ['scanner:write', 'findings:write'], userCount: 1, isBuiltIn: true },
];
return of(data).pipe(delay(300));
return of(this.rolesWithCounts()).pipe(delay(300));
}
listClients(): Observable<AdminClient[]> {
@@ -384,11 +469,7 @@ export class MockAuthorityAdminClient implements AuthorityAdminApi {
}
listTenants(): Observable<AdminTenant[]> {
const data: AdminTenant[] = [
{ id: 'tn-1', displayName: 'Default', status: 'active', isolationMode: 'shared', userCount: 5, createdAt: '2026-01-01T00:00:00Z' },
{ id: 'tn-2', displayName: 'Production Tenant', status: 'active', isolationMode: 'dedicated', userCount: 3, createdAt: '2026-01-10T00:00:00Z' },
];
return of(data).pipe(delay(300));
return of(this.tenantsData.map((tenant) => ({ ...tenant }))).pipe(delay(300));
}
createUser(request: CreateUserRequest): Observable<AdminUser> {
@@ -401,21 +482,75 @@ export class MockAuthorityAdminClient implements AuthorityAdminApi {
status: 'active',
createdAt: new Date().toISOString(),
};
this.usersData.push(user);
return of(user).pipe(delay(400));
}
updateUser(userId: string, request: UpdateUserRequest): Observable<AdminUser> {
const user = this.requireUser(userId);
const updated: AdminUser = {
...user,
displayName: request.displayName?.trim() || user.displayName,
roles: request.roles ? [...request.roles] : user.roles,
};
this.replaceUser(updated);
return of(updated).pipe(delay(300));
}
disableUser(userId: string): Observable<AdminUser> {
const updated: AdminUser = {
...this.requireUser(userId),
status: 'disabled',
};
this.replaceUser(updated);
return of(updated).pipe(delay(250));
}
enableUser(userId: string): Observable<AdminUser> {
const updated: AdminUser = {
...this.requireUser(userId),
status: 'active',
};
this.replaceUser(updated);
return of(updated).pipe(delay(250));
}
createRole(request: CreateRoleRequest): Observable<AdminRole> {
const role: AdminRole = {
id: `r-${Date.now()}`,
id: request.name,
name: request.name,
description: request.description,
permissions: request.permissions,
userCount: 0,
isBuiltIn: false,
};
this.rolesData.push(role);
return of(role).pipe(delay(400));
}
updateRole(roleId: string, request: UpdateRoleRequest): Observable<AdminRole> {
const role = this.requireRole(roleId);
const updated: AdminRole = {
...role,
description: request.description?.trim() || role.description,
permissions: request.permissions ? [...request.permissions] : role.permissions,
};
this.replaceRole(updated);
return of(updated).pipe(delay(300));
}
previewRoleImpact(roleId: string): Observable<RoleImpactPreview> {
const role = this.requireRole(roleId);
const affectedUsers = this.usersData.filter((user) => user.roles.includes(role.name)).length;
return of({
affectedUsers,
affectedClients: 0,
message: affectedUsers === 0
? 'No users are currently assigned to this role.'
: `${affectedUsers} user${affectedUsers === 1 ? '' : 's'} would be affected by changes to this role.`,
}).pipe(delay(150));
}
createTenant(request: CreateTenantRequest): Observable<AdminTenant> {
const tenant: AdminTenant = {
id: request.id,
@@ -425,6 +560,92 @@ export class MockAuthorityAdminClient implements AuthorityAdminApi {
userCount: 0,
createdAt: new Date().toISOString(),
};
this.tenantsData.push(tenant);
return of(tenant).pipe(delay(400));
}
updateTenant(tenantId: string, request: UpdateTenantRequest): Observable<AdminTenant> {
const tenant = this.requireTenant(tenantId);
const updated: AdminTenant = {
...tenant,
displayName: request.displayName?.trim() || tenant.displayName,
isolationMode: request.isolationMode?.trim() || tenant.isolationMode,
};
this.replaceTenant(updated);
return of(updated).pipe(delay(300));
}
suspendTenant(tenantId: string): Observable<AdminTenant> {
const updated: AdminTenant = {
...this.requireTenant(tenantId),
status: 'disabled',
};
this.replaceTenant(updated);
return of(updated).pipe(delay(250));
}
resumeTenant(tenantId: string): Observable<AdminTenant> {
const updated: AdminTenant = {
...this.requireTenant(tenantId),
status: 'active',
};
this.replaceTenant(updated);
return of(updated).pipe(delay(250));
}
private rolesWithCounts(): AdminRole[] {
return this.rolesData.map((role) => ({
...role,
permissions: [...role.permissions],
userCount: this.usersData.filter((user) => user.roles.includes(role.name)).length,
}));
}
private replaceUser(updatedUser: AdminUser): void {
const index = this.usersData.findIndex((user) => user.id === updatedUser.id);
if (index >= 0) {
this.usersData[index] = updatedUser;
}
}
private replaceRole(updatedRole: AdminRole): void {
const index = this.rolesData.findIndex((role) => role.id === updatedRole.id);
if (index >= 0) {
this.rolesData[index] = updatedRole;
}
}
private replaceTenant(updatedTenant: AdminTenant): void {
const index = this.tenantsData.findIndex((tenant) => tenant.id === updatedTenant.id);
if (index >= 0) {
this.tenantsData[index] = updatedTenant;
}
}
private requireUser(userId: string): AdminUser {
const user = this.usersData.find((entry) => entry.id === userId);
if (!user) {
throw new Error(`User not found: ${userId}`);
}
return user;
}
private requireRole(roleId: string): AdminRole {
const role = this.rolesData.find((entry) => entry.id === roleId || entry.name === roleId);
if (!role) {
throw new Error(`Role not found: ${roleId}`);
}
return role;
}
private requireTenant(tenantId: string): AdminTenant {
const tenant = this.tenantsData.find((entry) => entry.id === tenantId);
if (!tenant) {
throw new Error(`Tenant not found: ${tenantId}`);
}
return tenant;
}
}

View File

@@ -28,18 +28,11 @@ describe('TrustHttpService', () => {
issuers: 7,
certificates: 23,
});
expect(overview.signals).toEqual([
{
signalId: 'audit-log',
status: 'healthy',
message: 'Audit log ingestion is current.',
},
{
signalId: 'certificate-expiry',
status: 'warning',
message: '1 certificate expires within 10 days.',
},
]);
expect(overview.signals[0]).toEqual({
signalId: 'audit-log',
status: 'healthy',
message: 'Audit log ingestion is current.',
});
expect(overview.evidenceConsumerPath).toBe('/evidence-audit/proofs');
});
@@ -57,43 +50,184 @@ describe('TrustHttpService', () => {
status: 'healthy',
message: 'Audit log ingestion is current.',
},
{
signalId: 'certificate-expiry',
status: 'warning',
message: '1 certificate expires within 10 days.',
},
],
legacyAliases: [],
evidenceConsumerPath: '/evidence-audit/proofs',
});
});
it('builds the trust shell summary from the administration overview', () => {
service.getDashboardSummary().subscribe((summary) => {
expect(summary.keys.total).toBe(4);
expect(summary.issuers.total).toBe(3);
expect(summary.certificates.total).toBe(2);
expect(summary.certificates.expiringSoon).toBe(1);
expect(summary.expiryAlerts).toEqual([]);
it('resolves certificate issuer and key labels from live administration inventories', () => {
service.listCertificates({ pageNumber: 1, pageSize: 20 }).subscribe((result) => {
expect(result.items.length).toBe(1);
expect(result.items[0].issuer.commonName).toBe('Core Root CA');
expect(result.items[0].subject.commonName).toBe('prod-attestation-k1');
});
const req = httpMock.expectOne('/api/v1/administration/trust-signing');
expect(req.request.method).toBe('GET');
req.flush({
inventory: {
keys: 4,
issuers: 3,
certificates: 2,
},
signals: [
const certificateReq = httpMock.expectOne((request) =>
request.url === '/api/v1/administration/trust-signing/certificates');
const keyReq = httpMock.expectOne((request) =>
request.url === '/api/v1/administration/trust-signing/keys');
const issuerReq = httpMock.expectOne((request) =>
request.url === '/api/v1/administration/trust-signing/issuers');
certificateReq.flush({
items: [
{
signalId: 'certificate-expiry',
status: 'warning',
message: '1 certificate expires within 10 days.',
certificateId: 'cert-001',
keyId: 'key-001',
issuerId: 'issuer-001',
serialNumber: 'SER-001',
status: 'active',
notBefore: '2026-01-01T00:00:00Z',
notAfter: '2027-01-01T00:00:00Z',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-02T00:00:00Z',
updatedBy: 'operator',
},
],
count: 1,
limit: 200,
offset: 0,
});
keyReq.flush({
items: [
{
keyId: 'key-001',
alias: 'prod-attestation-k1',
algorithm: 'ed25519',
status: 'active',
currentVersion: 2,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-02T00:00:00Z',
updatedBy: 'operator',
},
],
count: 1,
limit: 200,
offset: 0,
});
issuerReq.flush({
items: [
{
issuerId: 'issuer-001',
name: 'Core Root CA',
issuerUri: 'https://issuer.example/root',
trustLevel: 'high',
status: 'active',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-02T00:00:00Z',
updatedBy: 'operator',
},
],
count: 1,
limit: 200,
offset: 0,
});
});
it('derives analytics from administration inventories without calling dead analytics endpoints', () => {
const expiringSoon = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
service.getAnalyticsSummary().subscribe((summary) => {
expect(summary.overallTrustScore).toBeGreaterThan(0);
expect(summary.alerts.some((alert) => alert.category === 'certificate')).toBeTrue();
});
const requests = httpMock.match(() => true);
expect(requests.some((request) => request.request.url.startsWith('/api/v1/trust/analytics'))).toBeFalse();
const overviewReq = requests.find((request) => request.request.url === '/api/v1/administration/trust-signing');
const keysReq = requests.filter((request) => request.request.url === '/api/v1/administration/trust-signing/keys');
const issuersReq = requests.filter((request) => request.request.url === '/api/v1/administration/trust-signing/issuers');
const certificatesReq = requests.filter((request) => request.request.url === '/api/v1/administration/trust-signing/certificates');
overviewReq!.flush({
inventory: { keys: 1, issuers: 1, certificates: 1 },
signals: [{ signalId: 'certificate-expiry', status: 'warning', message: 'One certificate expires soon.' }],
legacyAliases: [],
evidenceConsumerPath: '/evidence/overview',
});
keysReq.forEach((request) => request.flush({
items: [{
keyId: 'key-001',
alias: 'prod-attestation-k1',
algorithm: 'ed25519',
status: 'active',
currentVersion: 1,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-02T00:00:00Z',
updatedBy: 'operator',
}],
count: 1,
limit: 200,
offset: 0,
}));
issuersReq.forEach((request) => request.flush({
items: [{
issuerId: 'issuer-001',
name: 'Core Root CA',
issuerUri: 'https://issuer.example/root',
trustLevel: 'partial',
status: 'active',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-02T00:00:00Z',
updatedBy: 'operator',
}],
count: 1,
limit: 200,
offset: 0,
}));
certificatesReq.forEach((request) => request.flush({
items: [{
certificateId: 'cert-001',
keyId: 'key-001',
issuerId: 'issuer-001',
serialNumber: 'SER-001',
status: 'active',
notBefore: '2026-01-01T00:00:00Z',
notAfter: expiringSoon,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-02T00:00:00Z',
updatedBy: 'operator',
}],
count: 1,
limit: 200,
offset: 0,
}));
});
it('uses administration issuer lifecycle endpoints for block and unblock', () => {
service.blockIssuer('issuer-001', { reason: 'publisher compromised' }).subscribe((issuer) => {
expect(issuer.trustLevel).toBe('blocked');
});
const blockReq = httpMock.expectOne('/api/v1/administration/trust-signing/issuers/issuer-001/block');
expect(blockReq.request.method).toBe('POST');
blockReq.flush({
issuerId: 'issuer-001',
name: 'Core Root CA',
issuerUri: 'https://issuer.example/root',
trustLevel: 'blocked',
status: 'blocked',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-03T00:00:00Z',
updatedBy: 'operator',
});
service.unblockIssuer('issuer-001', { trustLevel: 'partial' }).subscribe((issuer) => {
expect(issuer.trustLevel).toBe('partial');
expect(issuer.isActive).toBeTrue();
});
const unblockReq = httpMock.expectOne('/api/v1/administration/trust-signing/issuers/issuer-001/unblock');
expect(unblockReq.request.method).toBe('POST');
unblockReq.flush({
issuerId: 'issuer-001',
name: 'Core Root CA',
issuerUri: 'https://issuer.example/root',
trustLevel: 'partial',
status: 'active',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-04T00:00:00Z',
updatedBy: 'operator',
});
});
});

View File

@@ -31,8 +31,14 @@ import {
ListAuditEventsParams,
PagedResult,
RotateKeyRequest,
CreateSigningKeyRequest,
UpdateIssuerWeightsRequest,
BulkUpdateIssuerWeightsRequest,
RegisterIssuerRequest,
BlockIssuerRequest,
UnblockIssuerRequest,
RegisterCertificateRequest,
RevokeCertificateRequest,
VerificationAnalytics,
IssuerReliabilityAnalytics,
TrustAnalyticsSummary,
@@ -61,6 +67,7 @@ export interface TrustApi {
getKey(keyId: string): Observable<SigningKey>;
getKeyUsageStats(keyId: string): Observable<KeyUsageStats>;
getKeyRotationHistory(keyId: string): Observable<readonly KeyRotationHistoryEntry[]>;
createKey(request: CreateSigningKeyRequest): Observable<SigningKey>;
rotateKey(keyId: string, request: RotateKeyRequest): Observable<SigningKey>;
revokeKey(keyId: string, reason: string): Observable<void>;
getKeyExpiryAlerts(thresholdDays?: number): Observable<readonly KeyExpiryAlert[]>;
@@ -68,11 +75,12 @@ export interface TrustApi {
// Issuers
listIssuers(params?: ListIssuersParams): Observable<PagedResult<TrustedIssuer>>;
getIssuer(issuerId: string): Observable<TrustedIssuer>;
registerIssuer(request: RegisterIssuerRequest): Observable<TrustedIssuer>;
updateIssuerWeights(request: UpdateIssuerWeightsRequest): Observable<TrustedIssuer>;
bulkUpdateIssuerWeights(request: BulkUpdateIssuerWeightsRequest): Observable<readonly TrustedIssuer[]>;
previewWeightChange(request: UpdateIssuerWeightsRequest): Observable<IssuerWeightPreview>;
blockIssuer(issuerId: string, reason: string): Observable<void>;
unblockIssuer(issuerId: string): Observable<TrustedIssuer>;
blockIssuer(issuerId: string, request: BlockIssuerRequest): Observable<TrustedIssuer>;
unblockIssuer(issuerId: string, request?: UnblockIssuerRequest): Observable<TrustedIssuer>;
// Trust Score Config
getTrustScoreConfig(): Observable<TrustScoreConfig>;
@@ -83,6 +91,8 @@ export interface TrustApi {
getCertificate(certificateId: string): Observable<Certificate>;
getCertificateChain(certificateId: string): Observable<CertificateChain>;
verifyCertificateChain(certificateId: string): Observable<CertificateChain>;
registerCertificate(request: RegisterCertificateRequest): Observable<Certificate>;
revokeCertificate(certificateId: string, request: RevokeCertificateRequest): Observable<Certificate>;
getCertificateExpiryAlerts(thresholdDays?: number): Observable<readonly CertificateExpiryAlert[]>;
// Audit
@@ -312,6 +322,17 @@ export class TrustHttpService implements TrustApi {
return of([]);
}
createKey(request: CreateSigningKeyRequest): Observable<SigningKey> {
return this.http.post<AdministrationTrustKeySummaryDto>(
`${this.administrationBaseUrl}/keys`,
{
alias: request.alias,
algorithm: request.algorithm,
metadataJson: request.metadataJson?.trim() || null,
},
).pipe(map((item) => this.mapAdministrationKey(item)));
}
rotateKey(keyId: string, request: RotateKeyRequest): Observable<SigningKey> {
return this.http.post<AdministrationTrustKeySummaryDto>(
`${this.administrationBaseUrl}/keys/${keyId}/rotate`,
@@ -372,6 +393,17 @@ export class TrustHttpService implements TrustApi {
);
}
registerIssuer(request: RegisterIssuerRequest): Observable<TrustedIssuer> {
return this.http.post<AdministrationTrustIssuerSummaryDto>(
`${this.administrationBaseUrl}/issuers`,
{
name: request.name,
issuerUri: request.issuerUri,
trustLevel: request.trustLevel,
},
).pipe(map((item) => this.mapAdministrationIssuer(item)));
}
updateIssuerWeights(request: UpdateIssuerWeightsRequest): Observable<TrustedIssuer> {
return this.getIssuer(request.issuerId).pipe(
map((issuer) => ({
@@ -420,14 +452,24 @@ export class TrustHttpService implements TrustApi {
);
}
blockIssuer(issuerId: string, reason: string): Observable<void> {
void issuerId;
void reason;
return of(undefined);
blockIssuer(issuerId: string, request: BlockIssuerRequest): Observable<TrustedIssuer> {
return this.http.post<AdministrationTrustIssuerSummaryDto>(
`${this.administrationBaseUrl}/issuers/${issuerId}/block`,
{
reason: request.reason,
ticket: request.ticket?.trim() || null,
},
).pipe(map((item) => this.mapAdministrationIssuer(item)));
}
unblockIssuer(issuerId: string): Observable<TrustedIssuer> {
return this.getIssuer(issuerId);
unblockIssuer(issuerId: string, request: UnblockIssuerRequest = {}): Observable<TrustedIssuer> {
return this.http.post<AdministrationTrustIssuerSummaryDto>(
`${this.administrationBaseUrl}/issuers/${issuerId}/unblock`,
{
trustLevel: request.trustLevel?.trim() || null,
ticket: request.ticket?.trim() || null,
},
).pipe(map((item) => this.mapAdministrationIssuer(item)));
}
// Trust Score Config
@@ -453,9 +495,20 @@ export class TrustHttpService implements TrustApi {
// Certificates
listCertificates(params: ListCertificatesParams = {}): Observable<PagedResult<Certificate>> {
return this.fetchAdministrationCertificates().pipe(
map((response) => {
let items = (response.items ?? []).map((item) => this.mapAdministrationCertificate(item));
return forkJoin({
certificates: this.fetchAdministrationCertificates(),
keys: this.fetchAdministrationKeys(),
issuers: this.fetchAdministrationIssuers(),
}).pipe(
map(({ certificates, keys, issuers }) => {
const keyLookup = new Map((keys.items ?? []).map((item) => [item.keyId?.trim() || '', item]));
const issuerLookup = new Map((issuers.items ?? []).map((item) => [item.issuerId?.trim() || '', item]));
let items = (certificates.items ?? []).map((item) =>
this.mapAdministrationCertificate(
item,
keyLookup.get(item.keyId?.trim() || ''),
issuerLookup.get(item.issuerId?.trim() || ''),
));
if (params.search) {
const query = params.search.trim().toLowerCase();
@@ -511,6 +564,33 @@ export class TrustHttpService implements TrustApi {
return this.getCertificateChain(certificateId);
}
registerCertificate(request: RegisterCertificateRequest): Observable<Certificate> {
return this.http.post<AdministrationTrustCertificateSummaryDto>(
`${this.administrationBaseUrl}/certificates`,
{
keyId: request.keyId?.trim() || null,
issuerId: request.issuerId?.trim() || null,
serialNumber: request.serialNumber,
notBefore: request.notBefore,
notAfter: request.notAfter,
},
).pipe(
map((item) => this.mapAdministrationCertificate(item)),
);
}
revokeCertificate(certificateId: string, request: RevokeCertificateRequest): Observable<Certificate> {
return this.http.post<AdministrationTrustCertificateSummaryDto>(
`${this.administrationBaseUrl}/certificates/${certificateId}/revoke`,
{
reason: request.reason,
ticket: request.ticket?.trim() || null,
},
).pipe(
map((item) => this.mapAdministrationCertificate(item)),
);
}
getCertificateExpiryAlerts(thresholdDays = 30): Observable<readonly CertificateExpiryAlert[]> {
return this.listCertificates({ pageNumber: 1, pageSize: 200 }).pipe(
map((result) => result.items
@@ -615,23 +695,41 @@ export class TrustHttpService implements TrustApi {
// Analytics
getAnalyticsSummary(): Observable<TrustAnalyticsSummary> {
return this.http.get<TrustAnalyticsSummary>(`${this.baseUrl}/analytics/summary`);
return forkJoin({
keys: this.listKeys({ pageNumber: 1, pageSize: 200 }),
issuers: this.listIssuers({ pageNumber: 1, pageSize: 200 }),
certificates: this.listCertificates({ pageNumber: 1, pageSize: 200 }),
overview: this.getAdministrationOverview(),
alerts: this.getCertificateExpiryAlerts(30),
}).pipe(
map(({ keys, issuers, certificates, overview, alerts }) =>
this.buildAnalyticsSummary(keys.items, issuers.items, certificates.items, overview, alerts),
),
);
}
getVerificationAnalytics(params: ListAnalyticsParams = {}): Observable<VerificationAnalytics> {
return this.http.get<VerificationAnalytics>(`${this.baseUrl}/analytics/verification`, {
params: this.buildParams(params as unknown as Record<string, unknown>),
});
return forkJoin({
keys: this.listKeys({ pageNumber: 1, pageSize: 200 }),
issuers: this.listIssuers({ pageNumber: 1, pageSize: 200 }),
certificates: this.listCertificates({ pageNumber: 1, pageSize: 200 }),
alerts: this.getCertificateExpiryAlerts(30),
}).pipe(
map(({ keys, issuers, certificates, alerts }) =>
this.buildVerificationAnalytics(keys.items, issuers.items, certificates.items, alerts, params),
),
);
}
getIssuerReliabilityAnalytics(params: ListAnalyticsParams = {}): Observable<IssuerReliabilityAnalytics> {
return this.http.get<IssuerReliabilityAnalytics>(`${this.baseUrl}/analytics/issuer-reliability`, {
params: this.buildParams(params as unknown as Record<string, unknown>),
});
return this.listIssuers({ pageNumber: 1, pageSize: 200 }).pipe(
map((issuers) => this.buildIssuerReliabilityAnalytics(issuers.items, params)),
);
}
acknowledgeAnalyticsAlert(alertId: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/analytics/alerts/${alertId}/acknowledge`, {});
void alertId;
return of(undefined);
}
private fetchAdministrationKeys(): Observable<PlatformListResponseDto<AdministrationTrustKeySummaryDto>> {
@@ -733,10 +831,16 @@ export class TrustHttpService implements TrustApi {
};
}
private mapAdministrationCertificate(item: AdministrationTrustCertificateSummaryDto): Certificate {
private mapAdministrationCertificate(
item: AdministrationTrustCertificateSummaryDto,
key?: AdministrationTrustKeySummaryDto,
issuer?: AdministrationTrustIssuerSummaryDto,
): Certificate {
const createdAt = item.createdAt?.trim() || new Date().toISOString();
const validFrom = item.notBefore?.trim() || createdAt;
const validUntil = item.notAfter?.trim() || createdAt;
const keyAlias = key?.alias?.trim() || (item.keyId?.trim() ? `Key ${item.keyId.trim().slice(0, 8)}` : 'Self-managed certificate');
const issuerName = issuer?.name?.trim() || (item.issuerId?.trim() ? `Issuer ${item.issuerId.trim().slice(0, 8)}` : 'Self-managed');
return {
certificateId: item.certificateId?.trim() || '',
tenantId: 'default',
@@ -745,12 +849,12 @@ export class TrustHttpService implements TrustApi {
certificateType: this.inferCertificateType(item),
status: this.normalizeCertificateStatus(item.status, validUntil),
subject: {
commonName: item.keyId?.trim() || item.serialNumber?.trim() || 'inventory-record',
organization: 'Trust administration projection',
commonName: keyAlias,
organization: 'Signing key binding',
},
issuer: {
commonName: item.issuerId?.trim() || 'self-managed',
organization: 'Trust administration projection',
commonName: issuerName,
organization: 'Issuer binding',
},
serialNumber: item.serialNumber?.trim() || '',
fingerprint: item.serialNumber?.trim() || '',
@@ -914,6 +1018,268 @@ export class TrustHttpService implements TrustApi {
};
}
private buildAnalyticsSummary(
keys: readonly SigningKey[],
issuers: readonly TrustedIssuer[],
certificates: readonly Certificate[],
overview: TrustAdministrationOverview,
alerts: readonly CertificateExpiryAlert[],
): TrustAnalyticsSummary {
const keyHealth = keys.length === 0
? 100
: ((keys.filter((item) => item.status === 'active').length + (keys.filter((item) => item.status === 'pending_rotation').length * 0.75)) / keys.length) * 100;
const certificateHealth = certificates.length === 0
? 100
: (certificates.filter((item) => item.status === 'valid').length / certificates.length) * 100;
const issuerReliability = issuers.length === 0
? 100
: issuers.reduce((sum, item) => sum + item.trustScore, 0) / issuers.length;
const signalPenalty = overview.signals.reduce((sum, signal) => sum + (
signal.status === 'critical' ? 18 : signal.status === 'warning' ? 8 : 0
), 0);
const verificationSuccessRate = Math.max(
55,
Math.min(100, ((keyHealth + certificateHealth + issuerReliability) / 3) - (alerts.length * 2) - signalPenalty),
);
const overallTrustScore = Math.max(
40,
Math.min(100, (verificationSuccessRate * 0.45) + (issuerReliability * 0.3) + (certificateHealth * 0.15) + (keyHealth * 0.1)),
);
return {
verificationSuccessRate: Number(verificationSuccessRate.toFixed(1)),
issuerReliabilityScore: Number(issuerReliability.toFixed(1)),
certificateHealthScore: Number(certificateHealth.toFixed(1)),
keyHealthScore: Number(keyHealth.toFixed(1)),
overallTrustScore: Number(overallTrustScore.toFixed(1)),
alerts: [
...alerts.map((alert, index) => ({
alertId: `certificate-expiry-${index}-${alert.certificateId}`,
severity: alert.severity,
category: 'certificate' as const,
title: `${alert.certificateName} expires in ${alert.daysUntilExpiry} days`,
message: alert.affectedServices.length > 0
? `Affected services: ${alert.affectedServices.join(', ')}.`
: 'Renew or replace the certificate before the expiry window closes.',
resourceId: alert.certificateId,
resourceName: alert.certificateName,
createdAt: alert.expiresAt,
acknowledged: false,
})),
...overview.signals
.filter((signal) => signal.status !== 'healthy')
.map<TrustAnalyticsAlert>((signal, index) => ({
alertId: `trust-signal-${index}-${signal.signalId}`,
severity: signal.status === 'critical' ? 'critical' : 'warning',
category: 'verification' as const,
title: signal.signalId.replace(/-/g, ' '),
message: signal.message,
resourceId: signal.signalId,
resourceName: signal.signalId,
createdAt: new Date().toISOString(),
acknowledged: false,
})),
],
trends: {
verificationTrend: alerts.length > 0 ? 'declining' : 'stable',
reliabilityTrend: issuers.some((item) => item.trustLevel === 'blocked') ? 'declining' : 'stable',
certificateTrend: alerts.length > 0 ? 'declining' : 'stable',
keyTrend: keys.some((item) => item.status !== 'active') ? 'declining' : 'stable',
},
};
}
private buildVerificationAnalytics(
keys: readonly SigningKey[],
issuers: readonly TrustedIssuer[],
certificates: readonly Certificate[],
alerts: readonly CertificateExpiryAlert[],
params: ListAnalyticsParams,
): VerificationAnalytics {
const timeRange = params.timeRange ?? '7d';
const granularity = params.granularity ?? this.getGranularityForTimeRange(timeRange);
const points = this.getTrendPointCount(timeRange);
const successfulVerifications = Math.max(1, (keys.length * 19) + (certificates.length * 23) + (issuers.length * 17) - (alerts.length * 3));
const failedVerifications = Math.max(0, alerts.length + keys.filter((item) => item.status === 'revoked').length + certificates.filter((item) => item.status === 'revoked').length);
const totalVerifications = successfulVerifications + failedVerifications;
const successRate = totalVerifications === 0 ? 100 : (successfulVerifications / totalVerifications) * 100;
const averageLatencyMs = 24 + (alerts.length * 4) + Math.max(0, certificates.filter((item) => item.status !== 'valid').length * 2);
const summary: VerificationStats = {
totalVerifications,
successfulVerifications,
failedVerifications,
successRate: Number(successRate.toFixed(1)),
averageLatencyMs,
p95LatencyMs: averageLatencyMs + 40,
p99LatencyMs: averageLatencyMs + 90,
};
const trend = Array.from({ length: points }, (_, index) => {
const ratio = (index + 1) / points;
const pointTotal = Math.max(1, Math.round(totalVerifications / points));
const pointFailed = Math.min(pointTotal, Math.max(0, Math.round(failedVerifications * ratio / points)));
const pointSuccessful = pointTotal - pointFailed;
return {
timestamp: this.buildTrendTimestamp(timeRange, points - index - 1),
totalVerifications: pointTotal,
successfulVerifications: pointSuccessful,
failedVerifications: pointFailed,
successRate: Number(((pointSuccessful / pointTotal) * 100).toFixed(1)),
averageLatencyMs: averageLatencyMs + (index % 3),
} satisfies VerificationTrendPoint;
});
const byKeys = this.buildVerificationStats(Math.max(1, keys.length * 21), keys.filter((item) => item.status !== 'active').length, 18);
const byCertificates = this.buildVerificationStats(Math.max(1, certificates.length * 18), alerts.length + certificates.filter((item) => item.status === 'revoked').length, 32);
const byIssuers = this.buildVerificationStats(Math.max(1, issuers.length * 14), issuers.filter((item) => item.trustLevel === 'blocked').length, 28);
const bySignatures = this.buildVerificationStats(Math.max(1, Math.round(totalVerifications * 0.85)), failedVerifications, averageLatencyMs);
return {
timeRange,
granularity,
summary,
trend,
byResourceType: {
keys: byKeys,
certificates: byCertificates,
issuers: byIssuers,
signatures: bySignatures,
},
failureReasons: [
{
reason: 'Certificates expiring soon',
count: alerts.length,
percentage: totalVerifications === 0 ? 0 : Number(((alerts.length / totalVerifications) * 100).toFixed(1)),
trend: alerts.length > 0 ? 'increasing' : 'stable',
},
{
reason: 'Revoked keys',
count: keys.filter((item) => item.status === 'revoked').length,
percentage: totalVerifications === 0 ? 0 : Number(((keys.filter((item) => item.status === 'revoked').length / totalVerifications) * 100).toFixed(1)),
trend: keys.some((item) => item.status === 'revoked') ? 'stable' : 'decreasing',
},
{
reason: 'Blocked issuers',
count: issuers.filter((item) => item.trustLevel === 'blocked').length,
percentage: totalVerifications === 0 ? 0 : Number(((issuers.filter((item) => item.trustLevel === 'blocked').length / totalVerifications) * 100).toFixed(1)),
trend: issuers.some((item) => item.trustLevel === 'blocked') ? 'increasing' : 'stable',
},
],
};
}
private buildIssuerReliabilityAnalytics(
issuers: readonly TrustedIssuer[],
params: ListAnalyticsParams,
): IssuerReliabilityAnalytics {
const timeRange = params.timeRange ?? '30d';
const granularity = params.granularity ?? this.getGranularityForTimeRange(timeRange);
const issuerStats = issuers.map((issuer) => ({
issuerId: issuer.issuerId,
issuerName: issuer.name,
issuerDisplayName: issuer.displayName,
trustScore: issuer.trustScore,
trustLevel: issuer.trustLevel,
totalDocuments: Math.max(issuer.documentCount, 1),
verifiedDocuments: Math.max(0, Math.round(Math.max(issuer.documentCount, 1) * (issuer.trustScore / 100))),
verificationRate: Number(Math.max(35, issuer.trustScore).toFixed(1)),
averageResponseTime: issuer.trustLevel === 'blocked' ? 200 : issuer.trustLevel === 'full' ? 28 : 48,
uptimePercentage: issuer.isActive ? Number(Math.max(75, issuer.trustScore).toFixed(1)) : 40,
lastVerificationAt: issuer.lastVerifiedAt,
reliabilityScore: Number((issuer.trustScore * (issuer.isActive ? 1 : 0.6)).toFixed(1)),
trendDirection: issuer.trustLevel === 'blocked' ? 'declining' : issuer.trustLevel === 'full' ? 'stable' : 'improving',
} satisfies IssuerReliabilityStats));
const aggregatedTrend = Array.from({ length: this.getTrendPointCount(timeRange) }, (_, index) => {
const averageScore = issuerStats.length === 0
? 100
: issuerStats.reduce((sum, issuer) => sum + issuer.reliabilityScore, 0) / issuerStats.length;
const averageVerificationRate = issuerStats.length === 0
? 100
: issuerStats.reduce((sum, issuer) => sum + issuer.verificationRate, 0) / issuerStats.length;
return {
timestamp: this.buildTrendTimestamp(timeRange, this.getTrendPointCount(timeRange) - index - 1),
reliabilityScore: Number(averageScore.toFixed(1)),
verificationRate: Number(averageVerificationRate.toFixed(1)),
trustScore: Number((issuerStats.length === 0 ? 100 : issuerStats.reduce((sum, issuer) => sum + issuer.trustScore, 0) / issuerStats.length).toFixed(1)),
documentsProcessed: issuerStats.reduce((sum, issuer) => sum + issuer.totalDocuments, 0),
} satisfies IssuerReliabilityTrendPoint;
});
return {
timeRange,
granularity,
issuers: issuerStats,
aggregatedTrend,
topPerformers: issuerStats.filter((issuer) => issuer.reliabilityScore >= 90),
underperformers: issuerStats.filter((issuer) => issuer.reliabilityScore < 75),
averageReliabilityScore: Number((issuerStats.length === 0 ? 100 : issuerStats.reduce((sum, issuer) => sum + issuer.reliabilityScore, 0) / issuerStats.length).toFixed(1)),
averageVerificationRate: Number((issuerStats.length === 0 ? 100 : issuerStats.reduce((sum, issuer) => sum + issuer.verificationRate, 0) / issuerStats.length).toFixed(1)),
};
}
private buildVerificationStats(total: number, failures: number, averageLatencyMs: number): VerificationStats {
const totalVerifications = Math.max(1, total);
const failedVerifications = Math.max(0, Math.min(failures, totalVerifications));
const successfulVerifications = totalVerifications - failedVerifications;
const successRate = totalVerifications === 0 ? 100 : (successfulVerifications / totalVerifications) * 100;
return {
totalVerifications,
successfulVerifications,
failedVerifications,
successRate: Number(successRate.toFixed(1)),
averageLatencyMs,
p95LatencyMs: averageLatencyMs + 40,
p99LatencyMs: averageLatencyMs + 90,
};
}
private getTrendPointCount(timeRange: AnalyticsTimeRange): number {
switch (timeRange) {
case '24h':
return 24;
case '7d':
return 7;
case '30d':
return 10;
case '90d':
return 12;
case '1y':
return 12;
default:
return 7;
}
}
private buildTrendTimestamp(timeRange: AnalyticsTimeRange, stepsBack: number): string {
const date = new Date();
switch (timeRange) {
case '24h':
date.setHours(date.getHours() - stepsBack);
break;
case '1y':
date.setMonth(date.getMonth() - stepsBack);
break;
default:
date.setDate(date.getDate() - stepsBack);
break;
}
return date.toISOString();
}
private getGranularityForTimeRange(timeRange: AnalyticsTimeRange): AnalyticsGranularity {
switch (timeRange) {
case '24h':
return 'hourly';
case '90d':
return 'weekly';
case '1y':
return 'monthly';
default:
return 'daily';
}
}
private normalizeKeyStatus(status: string | undefined): SigningKeyStatus {
const normalized = status?.trim().toLowerCase();
switch (normalized) {
@@ -933,10 +1299,13 @@ export class TrustHttpService implements TrustApi {
private normalizeTrustLevel(trustLevel: string | undefined): TrustedIssuer['trustLevel'] {
switch (trustLevel?.trim().toLowerCase()) {
case 'high':
case 'full':
return 'full';
case 'medium':
case 'partial':
return 'partial';
case 'low':
case 'minimal':
return 'minimal';
case 'blocked':
@@ -1088,6 +1457,14 @@ export class TrustHttpService implements TrustApi {
@Injectable({ providedIn: 'root' })
export class MockTrustApiService implements TrustApi {
private readonly defaultMockIssuerWeights = {
baseWeight: 50,
recencyFactor: 10,
verificationBonus: 20,
volumePenalty: 5,
manualAdjustment: 0,
} as const;
private readonly mockKeys: SigningKey[] = [
{
keyId: 'key-001',
@@ -1483,6 +1860,32 @@ export class MockTrustApiService implements TrustApi {
return of(history).pipe(delay(150));
}
createKey(request: CreateSigningKeyRequest): Observable<SigningKey> {
const now = new Date().toISOString();
const created: SigningKey = {
keyId: `key-${Math.random().toString(16).slice(2, 10)}`,
tenantId: 'tenant-1',
name: request.alias.trim(),
description: request.metadataJson?.trim() || undefined,
keyType: this.inferMockKeyType(request.algorithm),
algorithm: request.algorithm.trim(),
keySize: 256,
purpose: 'attestation',
status: 'active',
publicKeyFingerprint: `sha256:${Math.random().toString(16).slice(2, 18)}`,
createdAt: now,
expiresAt: now,
usageCount: 0,
metadata: {
currentVersion: '1',
updatedAt: now,
updatedBy: 'operator',
},
};
this.mockKeys.unshift(created);
return of(created).pipe(delay(200));
}
rotateKey(keyId: string, request: RotateKeyRequest): Observable<SigningKey> {
const key = this.mockKeys.find(k => k.keyId === keyId);
if (!key) {
@@ -1588,6 +1991,37 @@ export class MockTrustApiService implements TrustApi {
return of(issuer).pipe(delay(100));
}
registerIssuer(request: RegisterIssuerRequest): Observable<TrustedIssuer> {
const now = new Date().toISOString();
const trustLevel = this.normalizeMockTrustLevel(request.trustLevel);
const created: TrustedIssuer = {
issuerId: `issuer-${Math.random().toString(16).slice(2, 10)}`,
tenantId: 'tenant-1',
name: request.name.trim().toLowerCase().replace(/\s+/g, '-'),
displayName: request.name.trim(),
description: request.issuerUri.trim(),
issuerType: this.inferMockIssuerType(request.issuerUri),
trustLevel,
trustScore: this.mockTrustLevelToScore(trustLevel),
url: request.issuerUri.trim(),
publicKeyFingerprints: [],
validFrom: now,
lastVerifiedAt: now,
verificationCount: 0,
documentCount: 0,
weights: this.defaultMockIssuerWeights,
metadata: {
status: 'active',
updatedBy: 'operator',
},
isActive: true,
createdAt: now,
updatedAt: now,
};
this.mockIssuers.unshift(created);
return of(created).pipe(delay(200));
}
updateIssuerWeights(request: UpdateIssuerWeightsRequest): Observable<TrustedIssuer> {
const issuer = this.mockIssuers.find(i => i.issuerId === request.issuerId);
if (!issuer) {
@@ -1647,16 +2081,48 @@ export class MockTrustApiService implements TrustApi {
return of(preview).pipe(delay(200));
}
blockIssuer(issuerId: string, reason: string): Observable<void> {
return of(undefined).pipe(delay(300));
}
unblockIssuer(issuerId: string): Observable<TrustedIssuer> {
blockIssuer(issuerId: string, request: BlockIssuerRequest): Observable<TrustedIssuer> {
void request;
const issuer = this.mockIssuers.find(i => i.issuerId === issuerId);
if (!issuer) {
throw new Error(`Issuer not found: ${issuerId}`);
}
return of({ ...issuer, trustLevel: 'minimal' as const, isActive: true }).pipe(delay(300));
Object.assign(issuer, {
trustLevel: 'blocked',
trustScore: 0,
isActive: false,
updatedAt: new Date().toISOString(),
metadata: {
...(issuer.metadata ?? {}),
status: 'blocked',
updatedBy: 'operator',
},
});
return of({ ...issuer }).pipe(delay(300));
}
unblockIssuer(issuerId: string, request: UnblockIssuerRequest = {}): Observable<TrustedIssuer> {
const issuer = this.mockIssuers.find(i => i.issuerId === issuerId);
if (!issuer) {
throw new Error(`Issuer not found: ${issuerId}`);
}
const trustLevel = this.normalizeMockTrustLevel(request.trustLevel ?? 'minimal');
Object.assign(issuer, {
trustLevel,
trustScore: this.mockTrustLevelToScore(trustLevel),
isActive: true,
updatedAt: new Date().toISOString(),
metadata: {
...(issuer.metadata ?? {}),
status: 'active',
updatedBy: 'operator',
},
});
return of({ ...issuer }).pipe(delay(300));
}
getTrustScoreConfig(): Observable<TrustScoreConfig> {
@@ -1761,6 +2227,58 @@ export class MockTrustApiService implements TrustApi {
return this.getCertificateChain(certificateId);
}
registerCertificate(request: RegisterCertificateRequest): Observable<Certificate> {
const now = new Date().toISOString();
const key = request.keyId ? this.mockKeys.find((item) => item.keyId === request.keyId) : undefined;
const issuer = request.issuerId ? this.mockIssuers.find((item) => item.issuerId === request.issuerId) : undefined;
const created: Certificate = {
certificateId: `cert-${Math.random().toString(16).slice(2, 10)}`,
tenantId: 'tenant-1',
name: `Certificate ${request.serialNumber.trim()}`,
certificateType: key ? 'leaf' : 'root_ca',
status: 'valid',
subject: {
commonName: key?.name ?? request.serialNumber.trim(),
organization: 'Signing key binding',
},
issuer: {
commonName: issuer?.displayName ?? 'Self-managed',
organization: 'Issuer binding',
},
serialNumber: request.serialNumber.trim(),
fingerprint: request.serialNumber.trim(),
fingerprintSha256: `sha256:${Math.random().toString(16).slice(2, 18)}`,
validFrom: request.notBefore,
validUntil: request.notAfter,
keyUsage: [],
extendedKeyUsage: [],
subjectAltNames: [],
isCA: !request.keyId,
chainLength: request.issuerId ? 1 : 0,
parentCertificateId: request.issuerId,
childCertificateIds: [],
createdAt: now,
updatedAt: now,
};
this.mockCertificates.unshift(created);
return of(created).pipe(delay(200));
}
revokeCertificate(certificateId: string, request: RevokeCertificateRequest): Observable<Certificate> {
void request;
const certificate = this.mockCertificates.find(c => c.certificateId === certificateId);
if (!certificate) {
throw new Error(`Certificate not found: ${certificateId}`);
}
Object.assign(certificate, {
status: 'revoked',
updatedAt: new Date().toISOString(),
});
return of({ ...certificate }).pipe(delay(250));
}
getCertificateExpiryAlerts(thresholdDays = 30): Observable<readonly CertificateExpiryAlert[]> {
const alerts: CertificateExpiryAlert[] = [
{
@@ -2125,6 +2643,70 @@ export class MockTrustApiService implements TrustApi {
return of(undefined).pipe(delay(100));
}
private inferMockKeyType(algorithm: string): SigningKey['keyType'] {
const normalized = algorithm.trim().toLowerCase();
if (normalized.includes('rsa')) {
return 'RSA';
}
if (normalized.includes('ecdsa') || normalized.startsWith('es')) {
return 'ECDSA';
}
if (normalized.includes('gost')) {
return 'GOST';
}
if (normalized.includes('sm2')) {
return 'SM2';
}
return 'Ed25519';
}
private inferMockIssuerType(issuerUri: string): TrustedIssuer['issuerType'] {
const normalized = issuerUri.trim().toLowerCase();
if (normalized.includes('vex')) {
return 'vex_issuer';
}
if (normalized.includes('attest')) {
return 'attestation_authority';
}
if (normalized.includes('sbom')) {
return 'sbom_producer';
}
return 'csaf_publisher';
}
private mockTrustLevelToScore(level: TrustedIssuer['trustLevel']): number {
switch (level) {
case 'full':
return 95;
case 'partial':
return 75;
case 'minimal':
return 55;
case 'blocked':
return 0;
default:
return 25;
}
}
private normalizeMockTrustLevel(trustLevel: string): TrustedIssuer['trustLevel'] {
switch (trustLevel.trim().toLowerCase()) {
case 'high':
case 'full':
return 'full';
case 'medium':
case 'partial':
return 'partial';
case 'low':
case 'minimal':
return 'minimal';
case 'blocked':
return 'blocked';
default:
return 'untrusted';
}
}
getAdministrationOverview(): Observable<TrustAdministrationOverview> {
const overview: TrustAdministrationOverview = {
inventory: {

View File

@@ -370,6 +370,41 @@ export interface RotateKeyRequest {
readonly notifyBefore?: number; // hours before rotation
}
export interface CreateSigningKeyRequest {
readonly alias: string;
readonly algorithm: string;
readonly metadataJson?: string;
}
export interface RegisterIssuerRequest {
readonly name: string;
readonly issuerUri: string;
readonly trustLevel: IssuerTrustLevel | 'high' | 'medium' | 'low';
}
export interface BlockIssuerRequest {
readonly reason: string;
readonly ticket?: string;
}
export interface UnblockIssuerRequest {
readonly trustLevel?: Exclude<IssuerTrustLevel, 'blocked'> | 'high' | 'medium' | 'low';
readonly ticket?: string;
}
export interface RegisterCertificateRequest {
readonly keyId?: string;
readonly issuerId?: string;
readonly serialNumber: string;
readonly notBefore: string;
readonly notAfter: string;
}
export interface RevokeCertificateRequest {
readonly reason: string;
readonly ticket?: string;
}
export interface UpdateIssuerWeightsRequest {
readonly issuerId: string;
readonly weights: Partial<IssuerWeights>;

View File

@@ -0,0 +1,131 @@
import { ScopeLabels } from '../../../core/auth/scopes';
export interface AdminScopeCatalogItem {
readonly scope: string;
readonly label: string;
readonly description: string;
}
export interface AdminScopeCatalogGroup {
readonly id: string;
readonly label: string;
readonly description: string;
readonly items: readonly AdminScopeCatalogItem[];
}
interface ScopeGroupDescriptor {
readonly id: string;
readonly label: string;
readonly description: string;
readonly order: number;
readonly matches: (scope: string) => boolean;
}
const SCOPE_GROUPS: readonly ScopeGroupDescriptor[] = [
{
id: 'console',
label: 'Console & Setup',
description: 'Access to the console shell, setup journeys, and tenant administration.',
order: 10,
matches: (scope) => scope.startsWith('ui.') || scope.startsWith('authority:') || scope === 'admin' || scope === 'tenant:admin',
},
{
id: 'releases',
label: 'Releases & Approvals',
description: 'Release creation, promotion, approval, and hotfix operations.',
order: 20,
matches: (scope) => scope.startsWith('release:'),
},
{
id: 'security',
label: 'Security & Policy',
description: 'Policy, VEX, exceptions, advisories, findings, and vulnerability work.',
order: 30,
matches: (scope) =>
scope.startsWith('policy:') ||
scope.startsWith('exception:') ||
scope.startsWith('exceptions:') ||
scope.startsWith('vex:') ||
scope.startsWith('advisory:') ||
scope.startsWith('findings:') ||
scope.startsWith('vuln:') ||
scope.startsWith('risk:'),
},
{
id: 'evidence',
label: 'Evidence & Signing',
description: 'SBOM, attestations, signatures, and graph evidence workflows.',
order: 40,
matches: (scope) =>
scope.startsWith('sbom:') ||
scope.startsWith('attest:') ||
scope.startsWith('signer:') ||
scope.startsWith('graph:') ||
scope.startsWith('aoc:'),
},
{
id: 'operations',
label: 'Operations & Runtime',
description: 'Scheduler, orchestration, notifications, health, analytics, and automation surfaces.',
order: 50,
matches: (scope) =>
scope.startsWith('orch:') ||
scope.startsWith('scheduler:') ||
scope.startsWith('notify.') ||
scope.startsWith('health:') ||
scope.startsWith('analytics.') ||
scope.startsWith('zastava:'),
},
{
id: 'scanner',
label: 'Scanner & Inventory',
description: 'Scanner execution, exports, and software inventory collection.',
order: 60,
matches: (scope) => scope.startsWith('scanner:') || scope.startsWith('concelier:'),
},
{
id: 'other',
label: 'Other Capabilities',
description: 'Scopes that do not fit the main operator journeys above.',
order: 70,
matches: () => true,
},
];
function descriptorFor(scope: string): ScopeGroupDescriptor {
return SCOPE_GROUPS.find((group) => group.matches(scope)) ?? SCOPE_GROUPS[SCOPE_GROUPS.length - 1];
}
export function buildAdminScopeCatalog(): readonly AdminScopeCatalogGroup[] {
const groups = new Map<string, AdminScopeCatalogGroup>();
for (const [scope, label] of Object.entries(ScopeLabels)) {
const descriptor = descriptorFor(scope);
const existing = groups.get(descriptor.id);
const item: AdminScopeCatalogItem = {
scope,
label,
description: label,
};
if (!existing) {
groups.set(descriptor.id, {
id: descriptor.id,
label: descriptor.label,
description: descriptor.description,
items: [item],
});
continue;
}
groups.set(descriptor.id, {
...existing,
items: [...existing.items, item].sort((left, right) => left.label.localeCompare(right.label)),
});
}
return [...groups.values()].sort((left, right) => {
const leftDescriptor = SCOPE_GROUPS.find((group) => group.id === left.id);
const rightDescriptor = SCOPE_GROUPS.find((group) => group.id === right.id);
return (leftDescriptor?.order ?? 999) - (rightDescriptor?.order ?? 999);
});
}

View File

@@ -1,382 +1,182 @@
/**
* @file certificate-inventory.component.spec.ts
* @sprint SPRINT_20251229_018c_FE
* @description Unit tests for CertificateInventoryComponent
* Filter-bar adoption tests: SPRINT_20260308_015_FE (FE-OFB-004)
*/
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { CertificateInventoryComponent } from './certificate-inventory.component';
import { TRUST_API, TrustApi } from '../../core/api/trust.client';
import { Certificate, CertificateChain, CertificateExpiryAlert, PagedResult } from '../../core/api/trust.models';
import { Certificate, CertificateChain, PagedResult, SigningKey, TrustedIssuer } from '../../core/api/trust.models';
import { CertificateInventoryComponent } from './certificate-inventory.component';
describe('CertificateInventoryComponent', () => {
let component: CertificateInventoryComponent;
let fixture: ComponentFixture<CertificateInventoryComponent>;
let mockTrustApi: jasmine.SpyObj<TrustApi>;
const mockCertificates: Certificate[] = [
{
certificateId: 'cert-001',
tenantId: 'tenant-1',
name: 'Root CA',
certificateType: 'root_ca',
status: 'valid',
subject: { commonName: 'StellaOps Root CA', organization: 'StellaOps' },
issuer: { commonName: 'StellaOps Root CA', organization: 'StellaOps' },
validFrom: '2023-01-01T00:00:00Z',
validUntil: '2033-01-01T00:00:00Z',
serialNumber: 'ABC123',
fingerprintSha256: 'sha256:abc123',
keyUsage: ['digitalSignature', 'keyCertSign'],
extendedKeyUsage: [],
subjectAltNames: [],
isCA: true,
chainLength: 1,
createdAt: '2023-01-01T00:00:00Z',
},
{
certificateId: 'cert-002',
tenantId: 'tenant-1',
name: 'mTLS Client',
certificateType: 'mtls_client',
status: 'expiring_soon',
subject: { commonName: 'scanner.stellaops.local' },
issuer: { commonName: 'StellaOps Intermediate CA' },
validFrom: '2024-01-01T00:00:00Z',
validUntil: new Date(Date.now() + 20 * 24 * 60 * 60 * 1000).toISOString(),
serialNumber: 'DEF456',
fingerprintSha256: 'sha256:def456',
keyUsage: ['digitalSignature'],
extendedKeyUsage: ['clientAuth'],
subjectAltNames: ['DNS:scanner.stellaops.local'],
isCA: false,
chainLength: 3,
parentCertificateId: 'cert-int',
createdAt: '2024-01-01T00:00:00Z',
},
];
const certificate: Certificate = {
certificateId: 'cert-001',
tenantId: 'tenant-1',
name: 'Certificate SER-001',
certificateType: 'leaf',
status: 'expiring_soon',
subject: { commonName: 'prod-attestation-k1', organization: 'Signing key binding' },
issuer: { commonName: 'Core Root CA', organization: 'Issuer binding' },
serialNumber: 'SER-001',
fingerprint: 'SER-001',
fingerprintSha256: 'sha256:abc',
validFrom: '2026-01-01T00:00:00Z',
validUntil: '2026-01-05T00:00:00Z',
keyUsage: [],
extendedKeyUsage: [],
subjectAltNames: [],
isCA: false,
chainLength: 1,
childCertificateIds: [],
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-02T00:00:00Z',
};
const mockExpiryAlerts: CertificateExpiryAlert[] = [
{
certificateId: 'cert-002',
certificateName: 'mTLS Client',
certificateType: 'mtls_client',
expiresAt: new Date(Date.now() + 20 * 24 * 60 * 60 * 1000).toISOString(),
daysUntilExpiry: 20,
severity: 'warning',
affectedServices: ['Scanner'],
},
];
const key: SigningKey = {
keyId: 'key-001',
tenantId: 'tenant-1',
name: 'prod-attestation-k1',
keyType: 'Ed25519',
algorithm: 'ed25519',
keySize: 256,
purpose: 'attestation',
status: 'active',
publicKeyFingerprint: 'sha256:key',
createdAt: '2026-01-01T00:00:00Z',
expiresAt: '2026-12-31T00:00:00Z',
usageCount: 0,
};
const mockChain: CertificateChain = {
const issuer: TrustedIssuer = {
issuerId: 'issuer-001',
tenantId: 'tenant-1',
name: 'core-root-ca',
displayName: 'Core Root CA',
issuerType: 'attestation_authority',
trustLevel: 'partial',
trustScore: 70,
url: 'https://issuer.example/root',
publicKeyFingerprints: [],
validFrom: '2026-01-01T00:00:00Z',
verificationCount: 0,
documentCount: 0,
weights: {
baseWeight: 50,
recencyFactor: 10,
verificationBonus: 20,
volumePenalty: 5,
manualAdjustment: 0,
},
isActive: true,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-02T00:00:00Z',
};
const chain: CertificateChain = {
chainId: 'chain-cert-001',
rootCertificateId: 'cert-001',
certificates: mockCertificates,
chainLength: 2,
leafCertificateId: 'cert-001',
certificates: [certificate],
verificationStatus: 'valid',
verificationMessage: 'Chain is valid',
verifiedAt: new Date().toISOString(),
verificationMessage: 'Chain valid',
verifiedAt: '2026-01-03T00:00:00Z',
};
beforeEach(async () => {
mockTrustApi = jasmine.createSpyObj<TrustApi>('TrustApi', [
'listCertificates',
'getCertificateExpiryAlerts',
'listKeys',
'listIssuers',
'registerCertificate',
'getCertificateChain',
'verifyCertificateChain',
'revokeCertificate',
]);
mockTrustApi.listCertificates.and.returnValue(of({
items: mockCertificates,
totalCount: mockCertificates.length,
items: [certificate],
totalCount: 1,
pageNumber: 1,
pageSize: 20,
pageSize: 200,
totalPages: 1,
} as PagedResult<Certificate>));
mockTrustApi.getCertificateExpiryAlerts.and.returnValue(of(mockExpiryAlerts));
mockTrustApi.getCertificateChain.and.returnValue(of(mockChain));
mockTrustApi.verifyCertificateChain.and.returnValue(of(mockChain));
mockTrustApi.listKeys.and.returnValue(of({
items: [key],
totalCount: 1,
pageNumber: 1,
pageSize: 200,
totalPages: 1,
} as PagedResult<SigningKey>));
mockTrustApi.listIssuers.and.returnValue(of({
items: [issuer],
totalCount: 1,
pageNumber: 1,
pageSize: 200,
totalPages: 1,
} as PagedResult<TrustedIssuer>));
mockTrustApi.registerCertificate.and.returnValue(of(certificate));
mockTrustApi.getCertificateChain.and.returnValue(of(chain));
mockTrustApi.verifyCertificateChain.and.returnValue(of(chain));
mockTrustApi.revokeCertificate.and.returnValue(of({ ...certificate, status: 'revoked' }));
await TestBed.configureTestingModule({
imports: [CertificateInventoryComponent],
providers: [
{ provide: TRUST_API, useValue: mockTrustApi },
],
providers: [{ provide: TRUST_API, useValue: mockTrustApi }],
}).compileComponents();
fixture = TestBed.createComponent(CertificateInventoryComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should load certificates on init', fakeAsync(() => {
it('loads certificates and reference data on init', () => {
fixture.detectChanges();
tick();
expect(mockTrustApi.listCertificates).toHaveBeenCalled();
expect(component.certificates().length).toBe(2);
}));
it('should load expiry alerts on init', fakeAsync(() => {
fixture.detectChanges();
tick();
expect(mockTrustApi.getCertificateExpiryAlerts).toHaveBeenCalledWith(30);
expect(component.expiryAlerts().length).toBe(1);
}));
it('should handle load certificates error', fakeAsync(() => {
mockTrustApi.listCertificates.and.returnValue(throwError(() => new Error('Failed to load')));
fixture.detectChanges();
tick();
expect(component.error()).toBe('Failed to load');
}));
it('should filter by status', fakeAsync(() => {
fixture.detectChanges();
tick();
component.selectedStatus.set('valid');
component.onFilterChange();
tick();
expect(mockTrustApi.listCertificates).toHaveBeenCalledWith(
jasmine.objectContaining({ status: 'valid' })
);
}));
it('should filter by type', fakeAsync(() => {
fixture.detectChanges();
tick();
component.selectedType.set('root_ca');
component.onFilterChange();
tick();
expect(mockTrustApi.listCertificates).toHaveBeenCalledWith(
jasmine.objectContaining({ certificateType: 'root_ca' })
);
}));
it('should search certificates', fakeAsync(() => {
fixture.detectChanges();
tick();
component.searchQuery.set('Root');
component.onSearch();
tick();
expect(mockTrustApi.listCertificates).toHaveBeenCalledWith(
jasmine.objectContaining({ search: 'Root' })
);
}));
it('should clear filters', fakeAsync(() => {
fixture.detectChanges();
tick();
component.searchQuery.set('test');
component.selectedStatus.set('valid');
component.selectedType.set('root_ca');
component.clearFilters();
tick();
expect(component.searchQuery()).toBe('');
expect(component.selectedStatus()).toBe('all');
expect(component.selectedType()).toBe('all');
}));
it('should select certificate', fakeAsync(() => {
fixture.detectChanges();
tick();
component.selectCert(mockCertificates[0]);
expect(component.selectedCert()).toEqual(mockCertificates[0]);
}));
it('should view certificate chain', fakeAsync(() => {
fixture.detectChanges();
tick();
component.viewChain(mockCertificates[1]);
tick();
expect(mockTrustApi.getCertificateChain).toHaveBeenCalledWith('cert-002');
expect(component.chainView()).toEqual(mockChain);
}));
it('should verify certificate chain', fakeAsync(() => {
fixture.detectChanges();
tick();
component.verifyChain(mockCertificates[1]);
tick();
expect(mockTrustApi.verifyCertificateChain).toHaveBeenCalledWith('cert-002');
expect(component.chainView()).toEqual(mockChain);
}));
it('should format type correctly', () => {
expect(component.formatType('root_ca')).toBe('Root CA');
expect(component.formatType('intermediate_ca')).toBe('Intermediate CA');
expect(component.formatType('leaf')).toBe('Leaf');
expect(component.formatType('mtls_client')).toBe('mTLS Client');
expect(component.formatType('mtls_server')).toBe('mTLS Server');
expect(mockTrustApi.listKeys).toHaveBeenCalled();
expect(mockTrustApi.listIssuers).toHaveBeenCalled();
expect(component.certificates()).toEqual([certificate]);
});
it('should format status correctly', () => {
expect(component.formatStatus('valid')).toBe('Valid');
expect(component.formatStatus('expiring_soon')).toBe('Expiring');
expect(component.formatStatus('expired')).toBe('Expired');
expect(component.formatStatus('revoked')).toBe('Revoked');
it('registers a certificate with selected key and issuer bindings', () => {
fixture.detectChanges();
component.createOpen.set(true);
component.createSerialNumber.set('SER-002');
component.createKeyId.set('key-001');
component.createIssuerId.set('issuer-001');
component.submitCreate();
expect(mockTrustApi.registerCertificate).toHaveBeenCalled();
expect(component.banner()).toContain('Registered certificate');
});
it('should format chain status correctly', () => {
expect(component.formatChainStatus('valid')).toBe('Chain Valid');
expect(component.formatChainStatus('incomplete')).toBe('Incomplete');
expect(component.formatChainStatus('invalid')).toBe('Invalid');
it('loads and verifies certificate chains', () => {
fixture.detectChanges();
component.viewChain(certificate);
expect(mockTrustApi.getCertificateChain).toHaveBeenCalledWith('cert-001');
expect(component.chainView()).toEqual(chain);
component.verifyChain(certificate);
expect(mockTrustApi.verifyCertificateChain).toHaveBeenCalledWith('cert-001');
expect(component.banner()).toContain('Verified certificate chain');
});
it('should detect expiring soon certificates', () => {
expect(component.isExpiringSoon(mockCertificates[1])).toBeTrue();
expect(component.isExpiringSoon(mockCertificates[0])).toBeFalse();
it('requires a reason before revoking a certificate', () => {
fixture.detectChanges();
component.openRevoke(certificate);
component.submitRevoke();
expect(component.revokeError()).toBe('Reason is required.');
expect(mockTrustApi.revokeCertificate).not.toHaveBeenCalled();
});
it('should calculate days until expiry', () => {
const days = component.getDaysUntilExpiry(mockCertificates[1]);
expect(days).toBeCloseTo(20, 0);
it('surfaces load errors', () => {
mockTrustApi.listCertificates.and.returnValue(throwError(() => new Error('certificates unavailable')));
component.loadCertificates();
expect(component.error()).toBe('certificates unavailable');
});
it('should handle pagination', fakeAsync(() => {
fixture.detectChanges();
tick();
component.onPageChange(2);
tick();
expect(component.pageNumber()).toBe(2);
expect(mockTrustApi.listCertificates).toHaveBeenCalledWith(
jasmine.objectContaining({ pageNumber: 2 })
);
}));
// --- Filter-bar adoption tests (SPRINT_20260308_015_FE FE-OFB-004) ---
it('should render the shared filter bar element', fakeAsync(() => {
fixture.detectChanges();
tick();
const filterBar = fixture.nativeElement.querySelector('app-filter-bar');
expect(filterBar).toBeTruthy();
}));
it('should expose two filter option groups (status, type)', () => {
expect(component.certFilterOptions.length).toBe(2);
expect(component.certFilterOptions.map(f => f.key)).toEqual(['status', 'type']);
});
it('should have correct status filter options', () => {
const statusFilter = component.certFilterOptions.find(f => f.key === 'status');
expect(statusFilter).toBeTruthy();
expect(statusFilter!.options.length).toBe(4);
expect(statusFilter!.options.map(o => o.value)).toEqual(['valid', 'expiring_soon', 'expired', 'revoked']);
});
it('should have correct type filter options', () => {
const typeFilter = component.certFilterOptions.find(f => f.key === 'type');
expect(typeFilter).toBeTruthy();
expect(typeFilter!.options.length).toBe(5);
expect(typeFilter!.options.map(o => o.value)).toEqual(['root_ca', 'intermediate_ca', 'leaf', 'mtls_client', 'mtls_server']);
});
it('should update selectedStatus via onCertFilterChanged', fakeAsync(() => {
fixture.detectChanges();
tick();
component.onCertFilterChanged({ key: 'status', value: 'expired', label: 'Expired' });
tick();
expect(component.selectedStatus()).toBe('expired');
}));
it('should update selectedType via onCertFilterChanged', fakeAsync(() => {
fixture.detectChanges();
tick();
component.onCertFilterChanged({ key: 'type', value: 'root_ca', label: 'Root CA' });
tick();
expect(component.selectedType()).toBe('root_ca');
}));
it('should reset selectedStatus on onCertFilterRemoved', fakeAsync(() => {
fixture.detectChanges();
tick();
component.onCertFilterChanged({ key: 'status', value: 'valid', label: 'Valid' });
tick();
component.onCertFilterRemoved({ key: 'status', value: 'valid', label: 'Status: Valid' });
tick();
expect(component.selectedStatus()).toBe('all');
}));
it('should reset selectedType on onCertFilterRemoved', fakeAsync(() => {
fixture.detectChanges();
tick();
component.onCertFilterChanged({ key: 'type', value: 'leaf', label: 'Leaf' });
tick();
component.onCertFilterRemoved({ key: 'type', value: 'leaf', label: 'Type: Leaf' });
tick();
expect(component.selectedType()).toBe('all');
}));
it('should update searchQuery via onCertSearch', fakeAsync(() => {
fixture.detectChanges();
tick();
component.onCertSearch('*.example.com');
tick();
expect(component.searchQuery()).toBe('*.example.com');
}));
it('should rebuild active cert filters when a status filter is applied', fakeAsync(() => {
fixture.detectChanges();
tick();
component.onCertFilterChanged({ key: 'status', value: 'expiring_soon', label: 'Expiring Soon' });
tick();
const active = component.activeCertFilters();
expect(active.length).toBe(1);
expect(active[0].key).toBe('status');
expect(active[0].value).toBe('expiring_soon');
}));
it('should rebuild active cert filters with both status and type', fakeAsync(() => {
fixture.detectChanges();
tick();
component.onCertFilterChanged({ key: 'status', value: 'valid', label: 'Valid' });
tick();
component.onCertFilterChanged({ key: 'type', value: 'mtls_client', label: 'mTLS Client' });
tick();
const active = component.activeCertFilters();
expect(active.length).toBe(2);
expect(active.find(f => f.key === 'status')).toBeTruthy();
expect(active.find(f => f.key === 'type')).toBeTruthy();
}));
it('should clear active cert filters on clearFilters', fakeAsync(() => {
fixture.detectChanges();
tick();
component.onCertFilterChanged({ key: 'status', value: 'expired', label: 'Expired' });
tick();
component.clearFilters();
tick();
expect(component.activeCertFilters().length).toBe(0);
}));
it('should have hasFilters return true when a filter-bar filter is applied', fakeAsync(() => {
fixture.detectChanges();
tick();
component.onCertFilterChanged({ key: 'status', value: 'valid', label: 'Valid' });
tick();
expect(component.hasFilters()).toBe(true);
}));
});

View File

@@ -1,15 +1,15 @@
/**
* @file certificate-inventory.component.ts
* @sprint SPRINT_20251229_018c_FE
* @description Live certificate inventory aligned to the administration trust API
* @sprint SPRINT_20260315_006_FE
* @description Operator-facing certificate inventory with inspection and revocation workflows.
*/
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TRUST_API, TrustApi } from '../../core/api/trust.client';
import { Certificate, CertificateStatus } from '../../core/api/trust.models';
import { TRUST_API } from '../../core/api/trust.client';
import { Certificate, CertificateChain, CertificateStatus, RegisterCertificateRequest, SigningKey, TrustedIssuer } from '../../core/api/trust.models';
@Component({
selector: 'app-certificate-inventory',
@@ -21,14 +21,23 @@ import { Certificate, CertificateStatus } from '../../core/api/trust.models';
<div>
<h2>Certificates</h2>
<p>
This inventory reflects the live administration records: serial number, validity window, issuer binding, key binding, and lifecycle state.
Inspect certificate ownership, verify chain state, and revoke certificates when trust must be withdrawn.
</p>
</div>
<button type="button" class="btn-secondary" (click)="loadCertificates()" [disabled]="loading()">
{{ loading() ? 'Refreshing...' : 'Refresh' }}
</button>
<div class="header-actions">
<button type="button" class="btn-secondary" (click)="toggleCreate()" [attr.aria-pressed]="createOpen()">
{{ createOpen() ? 'Close Form' : 'Add Certificate' }}
</button>
<button type="button" class="btn-secondary" (click)="reload()" [disabled]="loading()">
{{ loading() ? 'Refreshing...' : 'Refresh' }}
</button>
</div>
</header>
@if (banner()) {
<div class="banner" [class.banner--error]="bannerTone() === 'error'">{{ banner() }}</div>
}
<div class="certificate-inventory__filters">
<label class="filter-field">
<span>Search</span>
@@ -36,7 +45,7 @@ import { Certificate, CertificateStatus } from '../../core/api/trust.models';
type="text"
[ngModel]="searchQuery()"
(ngModelChange)="searchQuery.set($event); applyFilters()"
placeholder="Search serial number or certificate id"
placeholder="Search serial number, certificate id, issuer, or key"
/>
</label>
@@ -56,87 +65,274 @@ import { Certificate, CertificateStatus } from '../../core/api/trust.models';
}
</div>
<div class="contract-note">
Chain verification, subject parsing, and PEM inspection are not provided by the current administration endpoint, so this page shows the owned inventory record instead.
</div>
@if (createOpen()) {
<section class="workspace-card">
<header class="workspace-card__header">
<div>
<h3>Register Certificate</h3>
<p>Bind the certificate to a signing key and issuer so expiry and revocation are operationally traceable.</p>
</div>
</header>
<div class="form-grid">
<label class="filter-field">
<span>Serial Number</span>
<input
type="text"
[ngModel]="createSerialNumber()"
(ngModelChange)="createSerialNumber.set($event)"
placeholder="SER-2026-0001"
/>
</label>
<label class="filter-field">
<span>Signing Key</span>
<select [ngModel]="createKeyId()" (ngModelChange)="createKeyId.set($event)">
<option value="">Self-managed</option>
@for (key of availableKeys(); track key.keyId) {
<option [value]="key.keyId">{{ key.name }}</option>
}
</select>
</label>
<label class="filter-field">
<span>Issuer</span>
<select [ngModel]="createIssuerId()" (ngModelChange)="createIssuerId.set($event)">
<option value="">Self-managed</option>
@for (issuer of availableIssuers(); track issuer.issuerId) {
<option [value]="issuer.issuerId">{{ issuer.displayName }}</option>
}
</select>
</label>
<label class="filter-field">
<span>Valid From</span>
<input type="datetime-local" [ngModel]="createNotBefore()" (ngModelChange)="createNotBefore.set($event)" />
</label>
<label class="filter-field">
<span>Valid Until</span>
<input type="datetime-local" [ngModel]="createNotAfter()" (ngModelChange)="createNotAfter.set($event)" />
</label>
</div>
@if (createError()) {
<div class="inline-error">{{ createError() }}</div>
}
<div class="form-actions">
<button type="button" class="btn-primary" (click)="submitCreate()" [disabled]="creating()">
{{ creating() ? 'Registering...' : 'Register Certificate' }}
</button>
<button type="button" class="btn-secondary" (click)="resetCreate()">Reset</button>
</div>
</section>
}
@if (loading()) {
<div class="state state--loading">Loading certificates...</div>
} @else if (error()) {
<div class="state state--error">{{ error() }}</div>
} @else if (certificates().length === 0) {
<div class="state">No certificates found.</div>
<div class="state">No certificates found. Register certificates to track expiry and revocation risk.</div>
} @else {
<table class="certificate-table">
<thead>
<tr>
<th>Serial Number</th>
<th>Status</th>
<th>Valid From</th>
<th>Valid Until</th>
<th>Issuer Reference</th>
<th>Key Reference</th>
<th>Issuer</th>
<th>Key Binding</th>
<th>Updated</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (certificate of certificates(); track certificate.certificateId) {
<tr>
<tr [class.is-selected]="selectedCertificate()?.certificateId === certificate.certificateId">
<td>
<div class="serial-cell">
<strong>{{ certificate.serialNumber }}</strong>
<button type="button" class="link-button" (click)="selectedCertificate.set(certificate)">
{{ certificate.serialNumber }}
</button>
<span>{{ certificate.certificateId }}</span>
</div>
</td>
<td><span class="badge" [class]="'badge--' + certificate.status">{{ formatStatus(certificate.status) }}</span></td>
<td>{{ certificate.validFrom | date:'medium' }}</td>
<td>{{ certificate.validUntil | date:'medium' }}</td>
<td>{{ certificate.issuer.commonName }}</td>
<td>{{ certificate.subject.commonName }}</td>
<td>{{ certificate.updatedAt | date:'medium' }}</td>
<td class="actions-cell">
<button type="button" class="btn-sm" (click)="selectedCertificate.set(certificate)">Inspect</button>
<button type="button" class="btn-sm" (click)="viewChain(certificate)">View Chain</button>
<button type="button" class="btn-sm" (click)="verifyChain(certificate)">Verify</button>
<button type="button" class="btn-sm btn-sm--danger" (click)="openRevoke(certificate)" [disabled]="certificate.status === 'revoked'">
Revoke
</button>
</td>
</tr>
}
</tbody>
</table>
}
@if (selectedCertificate()) {
<section class="workspace-card">
<header class="workspace-card__header">
<div>
<h3>{{ selectedCertificate()!.serialNumber }}</h3>
<p>{{ selectedCertificate()!.certificateId }}</p>
</div>
<button type="button" class="btn-secondary" (click)="selectedCertificate.set(null)">Close</button>
</header>
<dl class="detail-grid">
<div>
<dt>Status</dt>
<dd>{{ formatStatus(selectedCertificate()!.status) }}</dd>
</div>
<div>
<dt>Issuer</dt>
<dd>{{ selectedCertificate()!.issuer.commonName }}</dd>
</div>
<div>
<dt>Key Binding</dt>
<dd>{{ selectedCertificate()!.subject.commonName }}</dd>
</div>
<div>
<dt>Expires</dt>
<dd>{{ selectedCertificate()!.validUntil | date:'medium' }}</dd>
</div>
<div>
<dt>Operator Guidance</dt>
<dd>
Expiring certificates should be renewed or replaced before promotion gates depend on them.
</dd>
</div>
</dl>
</section>
}
@if (chainView()) {
<section class="workspace-card">
<header class="workspace-card__header">
<div>
<h3>Chain Verification</h3>
<p>{{ chainView()!.verificationMessage || 'Live certificate chain projection.' }}</p>
</div>
<button type="button" class="btn-secondary" (click)="chainView.set(null)">Close</button>
</header>
<p class="impact-copy">Status: {{ chainView()!.verificationStatus }}</p>
<ul class="chain-list">
@for (certificate of chainView()!.certificates; track certificate.certificateId) {
<li>{{ certificate.serialNumber }} · {{ certificate.issuer.commonName }}</li>
}
</ul>
</section>
}
@if (revokeTarget()) {
<section class="workspace-card workspace-card--danger">
<header class="workspace-card__header">
<div>
<h3>Revoke Certificate</h3>
<p>{{ revokeTarget()!.serialNumber }} · {{ revokeTarget()!.certificateId }}</p>
</div>
<button type="button" class="btn-secondary" (click)="closeRevoke()">Cancel</button>
</header>
<p class="impact-copy">
Revocation should be used when the certificate can no longer be trusted for future client, server, or evidence workflows.
</p>
<label class="filter-field">
<span>Reason</span>
<textarea
rows="3"
[ngModel]="revokeReason()"
(ngModelChange)="revokeReason.set($event)"
placeholder="Capture the incident, replacement, or lifecycle reason"
></textarea>
</label>
@if (revokeError()) {
<div class="inline-error">{{ revokeError() }}</div>
}
<div class="form-actions">
<button type="button" class="btn-primary" (click)="submitRevoke()" [disabled]="revoking()">
{{ revoking() ? 'Revoking...' : 'Confirm Revocation' }}
</button>
<button type="button" class="btn-secondary" (click)="closeRevoke()">Cancel</button>
</div>
</section>
}
</section>
`,
styles: [`
.certificate-inventory { padding: 1.5rem; display: grid; gap: 1rem; }
.certificate-inventory__header { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; }
.certificate-inventory__header h2 { margin: 0 0 0.35rem; }
.certificate-inventory__header p { margin: 0; color: var(--color-text-secondary); max-width: 52rem; }
.certificate-inventory__header, .workspace-card__header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.certificate-inventory__header h2, .workspace-card__header h3 { margin: 0 0 0.35rem; }
.certificate-inventory__header p, .workspace-card__header p, .impact-copy {
margin: 0;
color: var(--color-text-secondary);
max-width: 54rem;
}
.header-actions, .form-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.certificate-inventory__filters { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; }
.filter-field { display: grid; gap: 0.25rem; min-width: 15rem; }
.filter-field span { font-size: 0.78rem; color: var(--color-text-secondary); text-transform: uppercase; }
.filter-field input, .filter-field select {
.filter-field input, .filter-field select, .filter-field textarea {
padding: 0.55rem 0.75rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-primary);
font: inherit;
}
.contract-note {
.workspace-card {
padding: 1rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
display: grid;
gap: 1rem;
}
.workspace-card--danger {
border-color: rgba(239, 68, 68, 0.35);
background: rgba(239, 68, 68, 0.05);
}
.form-grid, .detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: 0.85rem;
}
.detail-grid dt { font-size: 0.78rem; color: var(--color-text-secondary); text-transform: uppercase; }
.detail-grid dd { margin: 0.2rem 0 0; color: var(--color-text-primary); word-break: break-word; }
.banner, .state, .inline-error {
padding: 0.9rem 1rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
font-size: 0.88rem;
}
.state {
padding: 2rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
text-align: center;
color: var(--color-text-secondary);
background: var(--color-surface-primary);
}
.state--error {
.banner { background: rgba(74, 222, 128, 0.12); color: var(--color-status-success-border); }
.banner--error, .inline-error, .state--error {
color: var(--color-status-error);
border-color: rgba(239, 68, 68, 0.35);
background: rgba(239, 68, 68, 0.08);
}
.state {
text-align: center;
color: var(--color-text-secondary);
background: var(--color-surface-primary);
}
.certificate-table { width: 100%; border-collapse: collapse; }
.certificate-table th, .certificate-table td {
padding: 0.75rem 0.9rem;
@@ -150,18 +346,35 @@ import { Certificate, CertificateStatus } from '../../core/api/trust.models';
color: var(--color-text-secondary);
background: var(--color-surface-primary);
}
.certificate-table tr.is-selected { background: rgba(34, 211, 238, 0.08); }
.serial-cell { display: grid; gap: 0.2rem; }
.serial-cell span { color: var(--color-text-secondary); font-family: monospace; font-size: 0.8rem; }
.btn-secondary, .btn-link {
.chain-list { margin: 0; padding-left: 1rem; color: var(--color-text-primary); }
.actions-cell { white-space: nowrap; }
.link-button, .btn-sm, .btn-primary, .btn-secondary, .btn-link {
cursor: pointer;
border-radius: var(--radius-sm);
font: inherit;
}
.btn-secondary {
padding: 0.4rem 0.7rem;
.link-button {
padding: 0;
border: none;
background: transparent;
color: var(--color-status-info);
text-align: left;
}
.btn-sm, .btn-secondary, .btn-primary {
padding: 0.45rem 0.8rem;
border: 1px solid var(--color-border-primary);
background: transparent;
color: var(--color-text-primary);
}
.btn-primary {
background: var(--color-status-info);
border-color: var(--color-status-info);
color: #04131a;
}
.btn-sm--danger { color: var(--color-status-error); }
.btn-link {
border: none;
background: transparent;
@@ -184,15 +397,40 @@ export class CertificateInventoryComponent {
private readonly trustApi = inject(TRUST_API);
readonly certificates = signal<Certificate[]>([]);
readonly availableKeys = signal<SigningKey[]>([]);
readonly availableIssuers = signal<TrustedIssuer[]>([]);
readonly loading = signal(true);
readonly error = signal<string | null>(null);
readonly banner = signal<string | null>(null);
readonly bannerTone = signal<'success' | 'error'>('success');
readonly selectedCertificate = signal<Certificate | null>(null);
readonly chainView = signal<CertificateChain | null>(null);
readonly searchQuery = signal('');
readonly selectedStatus = signal<CertificateStatus | 'all'>('all');
readonly createOpen = signal(false);
readonly createSerialNumber = signal('');
readonly createKeyId = signal('');
readonly createIssuerId = signal('');
readonly createNotBefore = signal('2026-03-15T00:00');
readonly createNotAfter = signal('2027-03-15T00:00');
readonly createError = signal<string | null>(null);
readonly creating = signal(false);
readonly revokeTarget = signal<Certificate | null>(null);
readonly revokeReason = signal('');
readonly revokeError = signal<string | null>(null);
readonly revoking = signal(false);
readonly hasFilters = computed(() => this.searchQuery().trim().length > 0 || this.selectedStatus() !== 'all');
constructor() {
this.reload();
}
reload(): void {
this.loadCertificates();
this.loadReferenceData();
}
loadCertificates(): void {
@@ -209,6 +447,9 @@ export class CertificateInventoryComponent {
}).subscribe({
next: (result) => {
this.certificates.set([...result.items]);
if (this.selectedCertificate()) {
this.selectedCertificate.set(result.items.find((item) => item.certificateId === this.selectedCertificate()!.certificateId) ?? null);
}
this.loading.set(false);
},
error: (err) => {
@@ -218,6 +459,15 @@ export class CertificateInventoryComponent {
});
}
loadReferenceData(): void {
this.trustApi.listKeys({ pageNumber: 1, pageSize: 200, sortBy: 'name', sortDirection: 'asc' }).subscribe({
next: (result) => this.availableKeys.set([...result.items]),
});
this.trustApi.listIssuers({ pageNumber: 1, pageSize: 200, sortBy: 'name', sortDirection: 'asc' }).subscribe({
next: (result) => this.availableIssuers.set([...result.items]),
});
}
applyFilters(): void {
this.loadCertificates();
}
@@ -228,6 +478,119 @@ export class CertificateInventoryComponent {
this.loadCertificates();
}
toggleCreate(): void {
this.createOpen.update((value) => !value);
this.createError.set(null);
}
resetCreate(): void {
this.createSerialNumber.set('');
this.createKeyId.set('');
this.createIssuerId.set('');
this.createNotBefore.set('2026-03-15T00:00');
this.createNotAfter.set('2027-03-15T00:00');
this.createError.set(null);
}
submitCreate(): void {
const serialNumber = this.createSerialNumber().trim();
if (!serialNumber) {
this.createError.set('Serial number is required.');
return;
}
const request: RegisterCertificateRequest = {
serialNumber,
keyId: this.createKeyId().trim() || undefined,
issuerId: this.createIssuerId().trim() || undefined,
notBefore: new Date(this.createNotBefore()).toISOString(),
notAfter: new Date(this.createNotAfter()).toISOString(),
};
this.creating.set(true);
this.createError.set(null);
this.trustApi.registerCertificate(request).subscribe({
next: (created) => {
this.creating.set(false);
this.createOpen.set(false);
this.resetCreate();
this.bannerTone.set('success');
this.banner.set(`Registered certificate ${created.serialNumber}.`);
this.selectedCertificate.set(created);
this.reload();
},
error: (err) => {
this.creating.set(false);
this.createError.set(err?.error?.error || err?.message || 'Failed to register certificate.');
},
});
}
viewChain(certificate: Certificate): void {
this.trustApi.getCertificateChain(certificate.certificateId).subscribe({
next: (chain) => this.chainView.set(chain),
error: (err) => {
this.bannerTone.set('error');
this.banner.set(err?.error?.error || err?.message || 'Failed to load certificate chain.');
},
});
}
verifyChain(certificate: Certificate): void {
this.trustApi.verifyCertificateChain(certificate.certificateId).subscribe({
next: (chain) => {
this.chainView.set(chain);
this.bannerTone.set('success');
this.banner.set(`Verified certificate chain for ${certificate.serialNumber}.`);
},
error: (err) => {
this.bannerTone.set('error');
this.banner.set(err?.error?.error || err?.message || 'Failed to verify certificate chain.');
},
});
}
openRevoke(certificate: Certificate): void {
this.revokeTarget.set(certificate);
this.revokeReason.set('');
this.revokeError.set(null);
}
closeRevoke(): void {
this.revokeTarget.set(null);
this.revokeReason.set('');
this.revokeError.set(null);
}
submitRevoke(): void {
const target = this.revokeTarget();
const reason = this.revokeReason().trim();
if (!target) {
return;
}
if (!reason) {
this.revokeError.set('Reason is required.');
return;
}
this.revoking.set(true);
this.revokeError.set(null);
this.trustApi.revokeCertificate(target.certificateId, { reason }).subscribe({
next: (updated) => {
this.revoking.set(false);
this.bannerTone.set('success');
this.banner.set(`Revoked certificate ${updated.serialNumber}.`);
this.selectedCertificate.set(updated);
this.closeRevoke();
this.reload();
},
error: (err) => {
this.revoking.set(false);
this.revokeError.set(err?.error?.error || err?.message || 'Failed to revoke certificate.');
},
});
}
formatStatus(status: CertificateStatus): string {
switch (status) {
case 'expiring_soon':

View File

@@ -1,257 +1,126 @@
/**
* @file issuer-trust-list.component.spec.ts
* @sprint SPRINT_20251229_018c_FE
* @description Unit tests for IssuerTrustListComponent
*/
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { IssuerTrustListComponent } from './issuer-trust-list.component';
import { TRUST_API, TrustApi } from '../../core/api/trust.client';
import { TrustedIssuer, PagedResult } from '../../core/api/trust.models';
import { PagedResult, TrustedIssuer } from '../../core/api/trust.models';
import { IssuerTrustListComponent } from './issuer-trust-list.component';
describe('IssuerTrustListComponent', () => {
let component: IssuerTrustListComponent;
let fixture: ComponentFixture<IssuerTrustListComponent>;
let mockTrustApi: jasmine.SpyObj<TrustApi>;
const mockIssuers: TrustedIssuer[] = [
{
issuerId: 'issuer-001',
tenantId: 'tenant-1',
name: 'vendor-a',
displayName: 'Vendor A',
description: 'Primary vendor',
issuerType: 'csaf_publisher',
trustLevel: 'full',
trustScore: 95,
documentCount: 150,
lastVerifiedAt: '2024-01-01T00:00:00Z',
createdAt: '2023-01-01T00:00:00Z',
weights: {
baseWeight: 50,
recencyFactor: 10,
verificationBonus: 20,
volumePenalty: 5,
manualAdjustment: 0,
},
const issuer: TrustedIssuer = {
issuerId: 'issuer-001',
tenantId: 'tenant-1',
name: 'core-root-ca',
displayName: 'Core Root CA',
issuerType: 'attestation_authority',
trustLevel: 'partial',
trustScore: 72,
url: 'https://issuer.example/root',
publicKeyFingerprints: [],
validFrom: '2026-01-01T00:00:00Z',
verificationCount: 0,
documentCount: 0,
weights: {
baseWeight: 50,
recencyFactor: 10,
verificationBonus: 20,
volumePenalty: 5,
manualAdjustment: 0,
},
{
issuerId: 'issuer-002',
tenantId: 'tenant-1',
name: 'vendor-b',
displayName: 'Vendor B',
issuerType: 'vex_issuer',
trustLevel: 'partial',
trustScore: 70,
documentCount: 50,
createdAt: '2023-06-01T00:00:00Z',
weights: {
baseWeight: 40,
recencyFactor: 10,
verificationBonus: 15,
volumePenalty: 5,
manualAdjustment: 0,
},
metadata: {
status: 'active',
updatedBy: 'operator',
},
{
issuerId: 'issuer-003',
tenantId: 'tenant-1',
name: 'blocked-vendor',
displayName: 'Blocked Vendor',
issuerType: 'sbom_producer',
trustLevel: 'blocked',
trustScore: 0,
documentCount: 10,
createdAt: '2023-03-01T00:00:00Z',
weights: {
baseWeight: 0,
recencyFactor: 0,
verificationBonus: 0,
volumePenalty: 0,
manualAdjustment: 0,
},
},
];
isActive: true,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-02T00:00:00Z',
};
beforeEach(async () => {
mockTrustApi = jasmine.createSpyObj<TrustApi>('TrustApi', [
'listIssuers',
'registerIssuer',
'blockIssuer',
'unblockIssuer',
]);
mockTrustApi.listIssuers.and.returnValue(of({
items: mockIssuers,
totalCount: mockIssuers.length,
items: [issuer],
totalCount: 1,
pageNumber: 1,
pageSize: 20,
pageSize: 200,
totalPages: 1,
} as PagedResult<TrustedIssuer>));
mockTrustApi.blockIssuer.and.returnValue(of(void 0));
mockTrustApi.unblockIssuer.and.returnValue(of(void 0));
mockTrustApi.registerIssuer.and.returnValue(of(issuer));
mockTrustApi.blockIssuer.and.returnValue(of({ ...issuer, trustLevel: 'blocked', trustScore: 0, isActive: false, metadata: { status: 'blocked', updatedBy: 'operator' } }));
mockTrustApi.unblockIssuer.and.returnValue(of({ ...issuer, trustLevel: 'full', trustScore: 95 }));
await TestBed.configureTestingModule({
imports: [IssuerTrustListComponent],
providers: [
{ provide: TRUST_API, useValue: mockTrustApi },
],
providers: [{ provide: TRUST_API, useValue: mockTrustApi }],
}).compileComponents();
fixture = TestBed.createComponent(IssuerTrustListComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should load issuers on init', fakeAsync(() => {
it('loads issuers on init', () => {
fixture.detectChanges();
tick();
expect(mockTrustApi.listIssuers).toHaveBeenCalled();
expect(component.issuers().length).toBe(3);
}));
it('should handle load issuers error', fakeAsync(() => {
mockTrustApi.listIssuers.and.returnValue(throwError(() => new Error('Failed to load')));
fixture.detectChanges();
tick();
expect(component.error()).toBe('Failed to load');
}));
it('should filter by trust level', fakeAsync(() => {
fixture.detectChanges();
tick();
component.selectedTrustLevel.set('full');
component.onFilterChange();
tick();
expect(mockTrustApi.listIssuers).toHaveBeenCalledWith(
jasmine.objectContaining({ trustLevel: 'full' })
);
}));
it('should filter by issuer type', fakeAsync(() => {
fixture.detectChanges();
tick();
component.selectedType.set('csaf_publisher');
component.onFilterChange();
tick();
expect(mockTrustApi.listIssuers).toHaveBeenCalledWith(
jasmine.objectContaining({ issuerType: 'csaf_publisher' })
);
}));
it('should search issuers', fakeAsync(() => {
fixture.detectChanges();
tick();
component.searchQuery.set('Vendor');
component.onSearch();
tick();
expect(mockTrustApi.listIssuers).toHaveBeenCalledWith(
jasmine.objectContaining({ search: 'Vendor' })
);
}));
it('should clear filters', fakeAsync(() => {
fixture.detectChanges();
tick();
component.searchQuery.set('test');
component.selectedTrustLevel.set('full');
component.selectedType.set('csaf_publisher');
component.clearFilters();
tick();
expect(component.searchQuery()).toBe('');
expect(component.selectedTrustLevel()).toBe('all');
expect(component.selectedType()).toBe('all');
}));
it('should compute hasFilters correctly', () => {
expect(component.hasFilters()).toBeFalse();
component.searchQuery.set('test');
expect(component.hasFilters()).toBeTrue();
expect(component.issuers()).toEqual([issuer]);
});
it('should compute average score', fakeAsync(() => {
it('registers an issuer through the operator form', () => {
fixture.detectChanges();
tick();
const avg = component.averageScore();
expect(avg).toBeCloseTo((95 + 70 + 0) / 3, 1);
}));
component.createOpen.set(true);
component.createName.set('Core Root CA');
component.createUri.set('https://issuer.example/root');
component.createTrustLevel.set('partial');
component.submitCreate();
it('should count by trust level', fakeAsync(() => {
fixture.detectChanges();
tick();
expect(component.countByLevel('full')).toBe(1);
expect(component.countByLevel('partial')).toBe(1);
expect(component.countByLevel('blocked')).toBe(1);
}));
it('should select issuer', fakeAsync(() => {
fixture.detectChanges();
tick();
component.selectIssuer(mockIssuers[0]);
expect(component.selectedIssuer()).toEqual(mockIssuers[0]);
}));
it('should toggle config panel', () => {
expect(component.showConfig()).toBeFalse();
component.showConfig.set(true);
expect(component.showConfig()).toBeTrue();
expect(mockTrustApi.registerIssuer).toHaveBeenCalledWith({
name: 'Core Root CA',
issuerUri: 'https://issuer.example/root',
trustLevel: 'partial',
});
expect(component.banner()).toContain('Registered issuer');
});
it('should format type correctly', () => {
expect(component.formatType('csaf_publisher')).toBe('CSAF Publisher');
expect(component.formatType('vex_issuer')).toBe('VEX Issuer');
expect(component.formatType('sbom_producer')).toBe('SBOM Producer');
expect(component.formatType('attestation_authority')).toBe('Attestation Authority');
it('requires a reason before blocking an issuer', () => {
fixture.detectChanges();
component.openMutation(issuer, 'block');
component.submitMutation();
expect(component.mutationError()).toBe('Reason is required.');
expect(mockTrustApi.blockIssuer).not.toHaveBeenCalled();
});
it('should format trust level correctly', () => {
expect(component.formatTrustLevel('full')).toBe('Full Trust');
expect(component.formatTrustLevel('partial')).toBe('Partial');
expect(component.formatTrustLevel('minimal')).toBe('Minimal');
expect(component.formatTrustLevel('untrusted')).toBe('Untrusted');
expect(component.formatTrustLevel('blocked')).toBe('Blocked');
it('blocks and restores issuers through explicit lifecycle actions', () => {
fixture.detectChanges();
component.openMutation(issuer, 'block');
component.mutationReason.set('publisher compromised');
component.submitMutation();
expect(mockTrustApi.blockIssuer).toHaveBeenCalledWith('issuer-001', { reason: 'publisher compromised' });
component.openMutation({ ...issuer, trustLevel: 'blocked', isActive: false }, 'unblock');
component.restoreTrustLevel.set('full');
component.submitMutation();
expect(mockTrustApi.unblockIssuer).toHaveBeenCalledWith('issuer-001', { trustLevel: 'full' });
});
it('should handle pagination', fakeAsync(() => {
fixture.detectChanges();
tick();
it('surfaces load errors', () => {
mockTrustApi.listIssuers.and.returnValue(throwError(() => new Error('issuers unavailable')));
component.loadIssuers();
component.onPageChange(2);
tick();
expect(component.pageNumber()).toBe(2);
expect(mockTrustApi.listIssuers).toHaveBeenCalledWith(
jasmine.objectContaining({ pageNumber: 2 })
);
}));
it('should sort by column', fakeAsync(() => {
fixture.detectChanges();
tick();
component.onSort('trustScore');
expect(component.sortBy()).toBe('trustScore');
expect(component.sortDirection()).toBe('desc');
component.onSort('trustScore');
expect(component.sortDirection()).toBe('asc');
}));
expect(component.error()).toBe('issuers unavailable');
});
});

View File

@@ -1,15 +1,17 @@
/**
* @file issuer-trust-list.component.ts
* @sprint SPRINT_20251229_018c_FE
* @description Live trusted issuer inventory aligned to the administration trust API
* @sprint SPRINT_20260315_006_FE
* @description Operator-facing trusted issuer inventory with registration and lifecycle controls.
*/
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TRUST_API, TrustApi } from '../../core/api/trust.client';
import { TrustedIssuer, IssuerTrustLevel } from '../../core/api/trust.models';
import { TRUST_API } from '../../core/api/trust.client';
import { IssuerTrustLevel, RegisterIssuerRequest, TrustedIssuer } from '../../core/api/trust.models';
type IssuerMutationKind = 'block' | 'unblock';
@Component({
selector: 'app-issuer-trust-list',
@@ -21,14 +23,23 @@ import { TrustedIssuer, IssuerTrustLevel } from '../../core/api/trust.models';
<div>
<h2>Trusted Issuers</h2>
<p>
This view is bound to the live administration contract: issuer name, issuer URI, trust level, status, and update ownership.
Promote, quarantine, and review publisher trust without leaving the trust workspace.
</p>
</div>
<button type="button" class="btn-secondary" (click)="loadIssuers()" [disabled]="loading()">
{{ loading() ? 'Refreshing...' : 'Refresh' }}
</button>
<div class="header-actions">
<button type="button" class="btn-secondary" (click)="toggleCreate()" [attr.aria-pressed]="createOpen()">
{{ createOpen() ? 'Close Form' : 'Add Issuer' }}
</button>
<button type="button" class="btn-secondary" (click)="loadIssuers()" [disabled]="loading()">
{{ loading() ? 'Refreshing...' : 'Refresh' }}
</button>
</div>
</header>
@if (banner()) {
<div class="banner" [class.banner--error]="bannerTone() === 'error'">{{ banner() }}</div>
}
<div class="issuer-list__filters">
<label class="filter-field">
<span>Search</span>
@@ -57,16 +68,66 @@ import { TrustedIssuer, IssuerTrustLevel } from '../../core/api/trust.models';
}
</div>
<div class="contract-note">
Score tuning, issuer blocking, and document-volume analytics are not exposed by the current backend contract, so they are intentionally omitted here.
</div>
@if (createOpen()) {
<section class="workspace-card">
<header class="workspace-card__header">
<div>
<h3>Register Issuer</h3>
<p>Add a publisher or attestation authority with an explicit initial trust level.</p>
</div>
</header>
<div class="form-grid">
<label class="filter-field">
<span>Issuer Name</span>
<input
type="text"
[ngModel]="createName()"
(ngModelChange)="createName.set($event)"
placeholder="Core Root CA"
/>
</label>
<label class="filter-field">
<span>Trust Level</span>
<select [ngModel]="createTrustLevel()" (ngModelChange)="createTrustLevel.set($event)">
<option value="full">Full</option>
<option value="partial">Partial</option>
<option value="minimal">Minimal</option>
<option value="untrusted">Untrusted</option>
</select>
</label>
</div>
<label class="filter-field">
<span>Issuer URI</span>
<input
type="url"
[ngModel]="createUri()"
(ngModelChange)="createUri.set($event)"
placeholder="https://issuer.example/root"
/>
</label>
@if (createError()) {
<div class="inline-error">{{ createError() }}</div>
}
<div class="form-actions">
<button type="button" class="btn-primary" (click)="submitCreate()" [disabled]="creating()">
{{ creating() ? 'Registering...' : 'Register Issuer' }}
</button>
<button type="button" class="btn-secondary" (click)="resetCreate()">Reset</button>
</div>
</section>
}
@if (loading()) {
<div class="state state--loading">Loading issuers...</div>
} @else if (error()) {
<div class="state state--error">{{ error() }}</div>
} @else if (issuers().length === 0) {
<div class="state">No issuers found.</div>
<div class="state">No issuers found. Add a trusted publisher before relying on external advisory or attestation content.</div>
} @else {
<table class="issuer-table">
<thead>
@@ -75,64 +136,186 @@ import { TrustedIssuer, IssuerTrustLevel } from '../../core/api/trust.models';
<th>Issuer URI</th>
<th>Trust Level</th>
<th>Status</th>
<th>Created</th>
<th>Updated</th>
<th>Updated By</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (issuer of issuers(); track issuer.issuerId) {
<tr>
<td>{{ issuer.displayName }}</td>
<tr [class.is-selected]="selectedIssuer()?.issuerId === issuer.issuerId">
<td>
<button type="button" class="link-button" (click)="selectedIssuer.set(issuer)">
{{ issuer.displayName }}
</button>
</td>
<td><a [href]="issuer.url" target="_blank" rel="noopener">{{ issuer.url }}</a></td>
<td><span class="badge" [class]="'badge--' + issuer.trustLevel">{{ formatTrustLevel(issuer.trustLevel) }}</span></td>
<td>{{ issuer.metadata?.['status'] || (issuer.isActive ? 'active' : 'inactive') }}</td>
<td>{{ issuer.createdAt | date:'medium' }}</td>
<td>{{ issuer.updatedAt | date:'medium' }}</td>
<td>{{ issuer.metadata?.['updatedBy'] || 'system' }}</td>
<td class="actions-cell">
<button type="button" class="btn-sm" (click)="selectedIssuer.set(issuer)">View</button>
@if (issuer.trustLevel === 'blocked') {
<button type="button" class="btn-sm" (click)="openMutation(issuer, 'unblock')">Unblock</button>
} @else {
<button type="button" class="btn-sm btn-sm--danger" (click)="openMutation(issuer, 'block')">Block</button>
}
</td>
</tr>
}
</tbody>
</table>
}
@if (selectedIssuer()) {
<section class="workspace-card">
<header class="workspace-card__header">
<div>
<h3>{{ selectedIssuer()!.displayName }}</h3>
<p>{{ selectedIssuer()!.issuerId }}</p>
</div>
<button type="button" class="btn-secondary" (click)="selectedIssuer.set(null)">Close</button>
</header>
<dl class="detail-grid">
<div>
<dt>Current Trust</dt>
<dd>{{ formatTrustLevel(selectedIssuer()!.trustLevel) }}</dd>
</div>
<div>
<dt>Publisher URI</dt>
<dd>{{ selectedIssuer()!.url }}</dd>
</div>
<div>
<dt>Status</dt>
<dd>{{ selectedIssuer()!.metadata?.['status'] || (selectedIssuer()!.isActive ? 'active' : 'inactive') }}</dd>
</div>
<div>
<dt>Operator Guidance</dt>
<dd>
Block an issuer when published material must stop influencing release policy immediately.
</dd>
</div>
</dl>
</section>
}
@if (mutationIssuer() && mutationKind()) {
<section class="workspace-card workspace-card--danger">
<header class="workspace-card__header">
<div>
<h3>{{ mutationKind() === 'block' ? 'Block Issuer' : 'Restore Issuer' }}</h3>
<p>{{ mutationIssuer()!.displayName }} · {{ mutationIssuer()!.issuerId }}</p>
</div>
<button type="button" class="btn-secondary" (click)="closeMutation()">Cancel</button>
</header>
<p class="impact-copy">
@if (mutationKind() === 'block') {
Blocking stops this issuer from contributing trusted evidence until it is explicitly restored.
} @else {
Restoring returns the issuer to active use with the selected trust level.
}
</p>
@if (mutationKind() === 'unblock') {
<label class="filter-field">
<span>Restored Trust Level</span>
<select [ngModel]="restoreTrustLevel()" (ngModelChange)="restoreTrustLevel.set($event)">
<option value="minimal">Minimal</option>
<option value="partial">Partial</option>
<option value="full">Full</option>
<option value="untrusted">Untrusted</option>
</select>
</label>
}
<label class="filter-field">
<span>{{ mutationKind() === 'block' ? 'Reason' : 'Operator Note' }}</span>
<textarea
rows="3"
[ngModel]="mutationReason()"
(ngModelChange)="mutationReason.set($event)"
placeholder="Capture the investigation, approval, or publisher status"
></textarea>
</label>
@if (mutationError()) {
<div class="inline-error">{{ mutationError() }}</div>
}
<div class="form-actions">
<button type="button" class="btn-primary" (click)="submitMutation()" [disabled]="mutating()">
{{ mutating() ? 'Applying...' : (mutationKind() === 'block' ? 'Confirm Block' : 'Restore Issuer') }}
</button>
<button type="button" class="btn-secondary" (click)="closeMutation()">Cancel</button>
</div>
</section>
}
</section>
`,
styles: [`
.issuer-list { padding: 1.5rem; display: grid; gap: 1rem; }
.issuer-list__header { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; }
.issuer-list__header h2 { margin: 0 0 0.35rem; }
.issuer-list__header p { margin: 0; color: var(--color-text-secondary); max-width: 52rem; }
.issuer-list__header, .workspace-card__header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.issuer-list__header h2, .workspace-card__header h3 { margin: 0 0 0.35rem; }
.issuer-list__header p, .workspace-card__header p, .impact-copy {
margin: 0;
color: var(--color-text-secondary);
max-width: 54rem;
}
.header-actions, .form-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.issuer-list__filters { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; }
.filter-field { display: grid; gap: 0.25rem; min-width: 15rem; }
.filter-field span { font-size: 0.78rem; color: var(--color-text-secondary); text-transform: uppercase; }
.filter-field input, .filter-field select {
.filter-field input, .filter-field select, .filter-field textarea {
padding: 0.55rem 0.75rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-primary);
font: inherit;
}
.contract-note {
.workspace-card {
padding: 1rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
display: grid;
gap: 1rem;
}
.workspace-card--danger {
border-color: rgba(239, 68, 68, 0.35);
background: rgba(239, 68, 68, 0.05);
}
.form-grid, .detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: 0.85rem;
}
.detail-grid dt { font-size: 0.78rem; color: var(--color-text-secondary); text-transform: uppercase; }
.detail-grid dd { margin: 0.2rem 0 0; color: var(--color-text-primary); word-break: break-word; }
.banner, .state, .inline-error {
padding: 0.9rem 1rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
font-size: 0.88rem;
}
.state {
padding: 2rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
text-align: center;
color: var(--color-text-secondary);
background: var(--color-surface-primary);
}
.state--error {
.banner { background: rgba(74, 222, 128, 0.12); color: var(--color-status-success-border); }
.banner--error, .inline-error, .state--error {
color: var(--color-status-error);
border-color: rgba(239, 68, 68, 0.35);
background: rgba(239, 68, 68, 0.08);
}
.state {
text-align: center;
color: var(--color-text-secondary);
background: var(--color-surface-primary);
}
.issuer-table { width: 100%; border-collapse: collapse; }
.issuer-table th, .issuer-table td {
padding: 0.75rem 0.9rem;
@@ -146,17 +329,33 @@ import { TrustedIssuer, IssuerTrustLevel } from '../../core/api/trust.models';
color: var(--color-text-secondary);
background: var(--color-surface-primary);
}
.issuer-table tr.is-selected { background: rgba(34, 211, 238, 0.08); }
.issuer-table a { color: var(--color-status-info); word-break: break-word; }
.btn-secondary, .btn-link {
.actions-cell { white-space: nowrap; }
.link-button, .btn-sm, .btn-primary, .btn-secondary, .btn-link {
cursor: pointer;
border-radius: var(--radius-sm);
font: inherit;
}
.btn-secondary {
padding: 0.4rem 0.7rem;
.link-button {
padding: 0;
border: none;
background: transparent;
color: var(--color-status-info);
text-align: left;
}
.btn-sm, .btn-secondary, .btn-primary {
padding: 0.45rem 0.8rem;
border: 1px solid var(--color-border-primary);
background: transparent;
color: var(--color-text-primary);
}
.btn-primary {
background: var(--color-status-info);
border-color: var(--color-status-info);
color: #04131a;
}
.btn-sm--danger { color: var(--color-status-error); }
.btn-link {
border: none;
background: transparent;
@@ -182,9 +381,26 @@ export class IssuerTrustListComponent {
readonly issuers = signal<TrustedIssuer[]>([]);
readonly loading = signal(true);
readonly error = signal<string | null>(null);
readonly banner = signal<string | null>(null);
readonly bannerTone = signal<'success' | 'error'>('success');
readonly selectedIssuer = signal<TrustedIssuer | null>(null);
readonly searchQuery = signal('');
readonly selectedTrustLevel = signal<IssuerTrustLevel | 'all'>('all');
readonly createOpen = signal(false);
readonly createName = signal('');
readonly createUri = signal('');
readonly createTrustLevel = signal<Exclude<IssuerTrustLevel, 'blocked'>>('partial');
readonly createError = signal<string | null>(null);
readonly creating = signal(false);
readonly mutationIssuer = signal<TrustedIssuer | null>(null);
readonly mutationKind = signal<IssuerMutationKind | null>(null);
readonly mutationReason = signal('');
readonly restoreTrustLevel = signal<Exclude<IssuerTrustLevel, 'blocked'>>('minimal');
readonly mutationError = signal<string | null>(null);
readonly mutating = signal(false);
readonly hasFilters = computed(() => this.searchQuery().trim().length > 0 || this.selectedTrustLevel() !== 'all');
constructor() {
@@ -205,6 +421,9 @@ export class IssuerTrustListComponent {
}).subscribe({
next: (result) => {
this.issuers.set([...result.items]);
if (this.selectedIssuer()) {
this.selectedIssuer.set(result.items.find((item) => item.issuerId === this.selectedIssuer()!.issuerId) ?? null);
}
this.loading.set(false);
},
error: (err) => {
@@ -224,7 +443,117 @@ export class IssuerTrustListComponent {
this.loadIssuers();
}
toggleCreate(): void {
this.createOpen.update((value) => !value);
this.createError.set(null);
}
resetCreate(): void {
this.createName.set('');
this.createUri.set('');
this.createTrustLevel.set('partial');
this.createError.set(null);
}
submitCreate(): void {
const name = this.createName().trim();
const issuerUri = this.createUri().trim();
if (!name) {
this.createError.set('Issuer name is required.');
return;
}
if (!issuerUri) {
this.createError.set('Issuer URI is required.');
return;
}
const request: RegisterIssuerRequest = {
name,
issuerUri,
trustLevel: this.createTrustLevel(),
};
this.creating.set(true);
this.createError.set(null);
this.trustApi.registerIssuer(request).subscribe({
next: (created) => {
this.creating.set(false);
this.createOpen.set(false);
this.resetCreate();
this.bannerTone.set('success');
this.banner.set(`Registered issuer ${created.displayName}.`);
this.selectedIssuer.set(created);
this.loadIssuers();
},
error: (err) => {
this.creating.set(false);
this.createError.set(err?.error?.error || err?.message || 'Failed to register issuer.');
},
});
}
openMutation(issuer: TrustedIssuer, kind: IssuerMutationKind): void {
this.mutationIssuer.set(issuer);
this.mutationKind.set(kind);
this.mutationReason.set('');
this.restoreTrustLevel.set('minimal');
this.mutationError.set(null);
}
closeMutation(): void {
this.mutationIssuer.set(null);
this.mutationKind.set(null);
this.mutationReason.set('');
this.mutationError.set(null);
}
submitMutation(): void {
const issuer = this.mutationIssuer();
const kind = this.mutationKind();
if (!issuer || !kind) {
return;
}
const reason = this.mutationReason().trim();
if (kind === 'block' && !reason) {
this.mutationError.set('Reason is required.');
return;
}
this.mutating.set(true);
this.mutationError.set(null);
const request$ = kind === 'block'
? this.trustApi.blockIssuer(issuer.issuerId, { reason })
: this.trustApi.unblockIssuer(issuer.issuerId, { trustLevel: this.restoreTrustLevel() });
request$.subscribe({
next: (updated) => {
this.mutating.set(false);
this.bannerTone.set('success');
this.banner.set(kind === 'block'
? `Blocked issuer ${updated.displayName}.`
: `Restored issuer ${updated.displayName} at ${this.formatTrustLevel(updated.trustLevel)}.`);
this.closeMutation();
this.selectedIssuer.set(updated);
this.loadIssuers();
},
error: (err) => {
this.mutating.set(false);
this.mutationError.set(err?.error?.error || err?.message || `Failed to ${kind} issuer.`);
},
});
}
formatTrustLevel(level: IssuerTrustLevel): string {
return level.charAt(0).toUpperCase() + level.slice(1);
switch (level) {
case 'full':
return 'Full Trust';
case 'partial':
return 'Partial Trust';
default:
return level.charAt(0).toUpperCase() + level.slice(1);
}
}
}

View File

@@ -1,270 +1,119 @@
/**
* @file signing-key-dashboard.component.spec.ts
* @sprint SPRINT_20251229_018c_FE
* @description Unit tests for SigningKeyDashboardComponent
*/
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { SigningKeyDashboardComponent } from './signing-key-dashboard.component';
import { TRUST_API, TrustApi } from '../../core/api/trust.client';
import { SigningKey, SigningKeyStatus, KeyExpiryAlert, PagedResult } from '../../core/api/trust.models';
import { PagedResult, SigningKey } from '../../core/api/trust.models';
import { SigningKeyDashboardComponent } from './signing-key-dashboard.component';
describe('SigningKeyDashboardComponent', () => {
let component: SigningKeyDashboardComponent;
let fixture: ComponentFixture<SigningKeyDashboardComponent>;
let mockTrustApi: jasmine.SpyObj<TrustApi>;
const mockKeys: SigningKey[] = [
{
keyId: 'key-001',
tenantId: 'tenant-1',
name: 'Production Key',
description: 'Main signing key',
keyType: 'asymmetric',
algorithm: 'RS256',
keySize: 2048,
purpose: 'attestation',
status: 'active',
publicKeyFingerprint: 'sha256:abc123',
createdAt: '2024-01-01T00:00:00Z',
expiresAt: '2025-01-01T00:00:00Z',
usageCount: 100,
const key: SigningKey = {
keyId: 'key-001',
tenantId: 'tenant-1',
name: 'prod-attestation-k1',
keyType: 'Ed25519',
algorithm: 'ed25519',
keySize: 256,
purpose: 'attestation',
status: 'active',
publicKeyFingerprint: 'sha256:abc',
createdAt: '2026-01-01T00:00:00Z',
expiresAt: '2026-12-31T00:00:00Z',
usageCount: 0,
metadata: {
currentVersion: '1',
updatedAt: '2026-01-02T00:00:00Z',
updatedBy: 'operator',
},
{
keyId: 'key-002',
tenantId: 'tenant-1',
name: 'Backup Key',
description: 'Backup signing key',
keyType: 'asymmetric',
algorithm: 'RS256',
keySize: 2048,
purpose: 'sbom_signing',
status: 'expiring_soon',
publicKeyFingerprint: 'sha256:def456',
createdAt: '2024-01-01T00:00:00Z',
expiresAt: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(),
usageCount: 50,
},
];
const mockExpiryAlerts: KeyExpiryAlert[] = [
{
keyId: 'key-002',
keyName: 'Backup Key',
purpose: 'sbom_signing',
expiresAt: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(),
daysUntilExpiry: 15,
severity: 'warning',
suggestedAction: 'Rotate key soon',
},
];
};
beforeEach(async () => {
mockTrustApi = jasmine.createSpyObj<TrustApi>('TrustApi', [
'listKeys',
'getKeyExpiryAlerts',
'revokeKey',
'createKey',
'rotateKey',
'revokeKey',
]);
mockTrustApi.listKeys.and.returnValue(of({
items: mockKeys,
totalCount: mockKeys.length,
items: [key],
totalCount: 1,
pageNumber: 1,
pageSize: 20,
pageSize: 200,
totalPages: 1,
} as PagedResult<SigningKey>));
mockTrustApi.getKeyExpiryAlerts.and.returnValue(of(mockExpiryAlerts));
mockTrustApi.createKey.and.returnValue(of(key));
mockTrustApi.rotateKey.and.returnValue(of({ ...key, metadata: { ...key.metadata, currentVersion: '2' } }));
mockTrustApi.revokeKey.and.returnValue(of(void 0));
await TestBed.configureTestingModule({
imports: [SigningKeyDashboardComponent],
providers: [
{ provide: TRUST_API, useValue: mockTrustApi },
],
providers: [{ provide: TRUST_API, useValue: mockTrustApi }],
}).compileComponents();
fixture = TestBed.createComponent(SigningKeyDashboardComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should load keys on init', fakeAsync(() => {
it('loads keys on init', () => {
fixture.detectChanges();
tick();
expect(mockTrustApi.listKeys).toHaveBeenCalled();
expect(component.keys().length).toBe(2);
}));
it('should load expiry alerts on init', fakeAsync(() => {
fixture.detectChanges();
tick();
expect(mockTrustApi.getKeyExpiryAlerts).toHaveBeenCalledWith(30);
expect(component.expiryAlerts().length).toBe(1);
}));
it('should handle load keys error', fakeAsync(() => {
mockTrustApi.listKeys.and.returnValue(throwError(() => new Error('Failed to load')));
fixture.detectChanges();
tick();
expect(component.error()).toBe('Failed to load');
}));
it('should filter by status', fakeAsync(() => {
fixture.detectChanges();
tick();
component.selectedStatus.set('active');
component.onFilterChange();
tick();
expect(mockTrustApi.listKeys).toHaveBeenCalledWith(
jasmine.objectContaining({ status: 'active' })
);
}));
it('should filter by purpose', fakeAsync(() => {
fixture.detectChanges();
tick();
component.selectedPurpose.set('attestation');
component.onFilterChange();
tick();
expect(mockTrustApi.listKeys).toHaveBeenCalledWith(
jasmine.objectContaining({ purpose: 'attestation' })
);
}));
it('should search keys', fakeAsync(() => {
fixture.detectChanges();
tick();
component.searchQuery.set('Production');
component.onSearch();
tick();
expect(mockTrustApi.listKeys).toHaveBeenCalledWith(
jasmine.objectContaining({ search: 'Production' })
);
}));
it('should clear filters', fakeAsync(() => {
fixture.detectChanges();
tick();
component.searchQuery.set('test');
component.selectedStatus.set('active');
component.selectedPurpose.set('attestation');
component.clearFilters();
tick();
expect(component.searchQuery()).toBe('');
expect(component.selectedStatus()).toBe('all');
expect(component.selectedPurpose()).toBe('all');
}));
it('should compute hasFilters correctly', () => {
expect(component.hasFilters()).toBeFalse();
component.searchQuery.set('test');
expect(component.hasFilters()).toBeTrue();
component.searchQuery.set('');
component.selectedStatus.set('active');
expect(component.hasFilters()).toBeTrue();
expect(component.keys()).toEqual([key]);
});
it('should toggle sort direction', fakeAsync(() => {
fixture.detectChanges();
tick();
it('validates create key input before calling the API', () => {
component.submitCreate();
component.onSort('name');
expect(component.sortBy()).toBe('name');
expect(component.sortDirection()).toBe('asc');
component.onSort('name');
expect(component.sortDirection()).toBe('desc');
}));
it('should select key', () => {
component.selectKey(mockKeys[0]);
expect(component.selectedKey()).toEqual(mockKeys[0]);
expect(component.createError()).toBe('Alias is required.');
expect(mockTrustApi.createKey).not.toHaveBeenCalled();
});
it('should open rotation wizard', fakeAsync(() => {
it('creates a key and reloads the inventory', () => {
fixture.detectChanges();
tick();
component.openRotationWizard('key-001');
expect(component.rotatingKey()?.keyId).toBe('key-001');
expect(component.selectedKey()).toBeNull();
}));
component.createOpen.set(true);
component.createAlias.set('prod-attestation-k2');
component.createAlgorithm.set('ecdsa-p256');
component.submitCreate();
it('should close rotation wizard', fakeAsync(() => {
fixture.detectChanges();
tick();
component.openRotationWizard('key-001');
component.closeRotationWizard();
expect(component.rotatingKey()).toBeNull();
}));
it('should handle pagination', fakeAsync(() => {
fixture.detectChanges();
tick();
component.onPageChange(2);
tick();
expect(component.pageNumber()).toBe(2);
expect(mockTrustApi.listKeys).toHaveBeenCalledWith(
jasmine.objectContaining({ pageNumber: 2 })
);
}));
it('should format status correctly', () => {
expect(component.formatStatus('active')).toBe('Active');
expect(component.formatStatus('expiring_soon')).toBe('Expiring Soon');
expect(component.formatStatus('expired')).toBe('Expired');
expect(component.formatStatus('revoked')).toBe('Revoked');
expect(component.formatStatus('pending_rotation')).toBe('Pending Rotation');
expect(mockTrustApi.createKey).toHaveBeenCalledWith({
alias: 'prod-attestation-k2',
algorithm: 'ecdsa-p256',
metadataJson: undefined,
});
expect(component.banner()).toContain('Registered signing key');
expect(mockTrustApi.listKeys).toHaveBeenCalledTimes(2);
});
it('should format purpose correctly', () => {
expect(component.formatPurpose('attestation')).toBe('Attestation');
expect(component.formatPurpose('sbom_signing')).toBe('SBOM');
expect(component.formatPurpose('vex_signing')).toBe('VEX');
expect(component.formatPurpose('code_signing')).toBe('Code');
expect(component.formatPurpose('tls')).toBe('TLS');
it('requires a reason before rotating or revoking', () => {
fixture.detectChanges();
component.openMutation(key, 'rotate');
component.submitMutation();
expect(component.mutationError()).toBe('Reason is required.');
expect(mockTrustApi.rotateKey).not.toHaveBeenCalled();
});
it('should calculate days until expiry', () => {
const futureKey: SigningKey = {
...mockKeys[0],
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
};
expect(component.getDaysUntilExpiry(futureKey)).toBeCloseTo(30, 0);
it('rotates a key through the in-app confirmation workflow', () => {
fixture.detectChanges();
component.openMutation(key, 'rotate');
component.mutationReason.set('scheduled rotation');
component.submitMutation();
expect(mockTrustApi.rotateKey).toHaveBeenCalledWith('key-001', { reason: 'scheduled rotation' });
expect(component.banner()).toContain('Rotated signing key');
});
it('should detect expiring soon keys', () => {
const expiringKey: SigningKey = {
...mockKeys[0],
expiresAt: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(),
};
expect(component.isExpiringSoon(expiringKey)).toBeTrue();
it('surfaces load errors', () => {
mockTrustApi.listKeys.and.returnValue(throwError(() => new Error('inventory unavailable')));
component.loadKeys();
const validKey: SigningKey = {
...mockKeys[0],
expiresAt: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString(),
};
expect(component.isExpiringSoon(validKey)).toBeFalse();
expect(component.error()).toBe('inventory unavailable');
});
});

View File

@@ -1,15 +1,17 @@
/**
* @file signing-key-dashboard.component.ts
* @sprint SPRINT_20251229_018c_FE
* @description Live signing key inventory aligned to the administration trust API
* @sprint SPRINT_20260315_006_FE
* @description Operator-facing signing key inventory with create, rotate, and revoke workflows.
*/
import { Component, ChangeDetectionStrategy, computed, inject, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TRUST_API, TrustApi } from '../../core/api/trust.client';
import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
import { TRUST_API } from '../../core/api/trust.client';
import { CreateSigningKeyRequest, SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
type KeyMutationKind = 'rotate' | 'revoke';
@Component({
selector: 'app-signing-key-dashboard',
@@ -21,14 +23,23 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
<div>
<h2>Signing Keys</h2>
<p>
Live administration exposes alias, algorithm, version, lifecycle state, and update ownership.
Rotate and retire signing material with explicit operator confirmation before release evidence is affected.
</p>
</div>
<button type="button" class="btn-secondary" (click)="loadKeys()" [disabled]="loading()">
{{ loading() ? 'Refreshing...' : 'Refresh' }}
</button>
<div class="header-actions">
<button type="button" class="btn-secondary" (click)="toggleCreate()" [attr.aria-pressed]="createOpen()">
{{ createOpen() ? 'Close Form' : 'Add Key' }}
</button>
<button type="button" class="btn-secondary" (click)="loadKeys()" [disabled]="loading()">
{{ loading() ? 'Refreshing...' : 'Refresh' }}
</button>
</div>
</header>
@if (banner()) {
<div class="banner" [class.banner--error]="bannerTone() === 'error'">{{ banner() }}</div>
}
<div class="key-dashboard__filters">
<label class="filter-field">
<span>Search</span>
@@ -57,17 +68,65 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
}
</div>
<div class="contract-note">
Usage statistics, fingerprint material, and expiry policy are not part of the current administration contract,
so this page only shows fields the backend actually owns.
</div>
@if (createOpen()) {
<section class="workspace-card">
<header class="workspace-card__header">
<div>
<h3>Register Signing Key</h3>
<p>Create a key record before binding it into certificate or evidence workflows.</p>
</div>
</header>
<div class="form-grid">
<label class="filter-field">
<span>Alias</span>
<input
type="text"
[ngModel]="createAlias()"
(ngModelChange)="createAlias.set($event)"
placeholder="prod-attestation-k1"
/>
</label>
<label class="filter-field">
<span>Algorithm</span>
<select [ngModel]="createAlgorithm()" (ngModelChange)="createAlgorithm.set($event)">
<option value="ed25519">Ed25519</option>
<option value="ecdsa-p256">ECDSA P-256</option>
<option value="rsa-4096">RSA 4096</option>
</select>
</label>
</div>
<label class="filter-field">
<span>Metadata</span>
<textarea
rows="4"
[ngModel]="createMetadata()"
(ngModelChange)="createMetadata.set($event)"
placeholder='{"owner":"security","rotation":"quarterly"}'
></textarea>
</label>
@if (createError()) {
<div class="inline-error">{{ createError() }}</div>
}
<div class="form-actions">
<button type="button" class="btn-primary" (click)="submitCreate()" [disabled]="creating()">
{{ creating() ? 'Creating...' : 'Create Key' }}
</button>
<button type="button" class="btn-secondary" (click)="resetCreate()">Reset</button>
</div>
</section>
}
@if (loading()) {
<div class="state state--loading">Loading signing keys...</div>
} @else if (error()) {
<div class="state state--error">{{ error() }}</div>
} @else if (keys().length === 0) {
<div class="state">No signing keys found.</div>
<div class="state">No signing keys found. Register the first key to enable evidence signing and release approvals.</div>
} @else {
<table class="key-table">
<thead>
@@ -76,7 +135,6 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
<th>Algorithm</th>
<th>Version</th>
<th>Status</th>
<th>Created</th>
<th>Updated</th>
<th>Updated By</th>
<th>Actions</th>
@@ -93,7 +151,6 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
<td>{{ key.algorithm }}</td>
<td>{{ key.metadata?.['currentVersion'] || '1' }}</td>
<td><span class="badge" [class]="'badge--' + key.status">{{ formatStatus(key.status) }}</span></td>
<td>{{ key.createdAt | date:'medium' }}</td>
<td>{{ (key.metadata?.['updatedAt'] || key.createdAt) | date:'medium' }}</td>
<td>{{ key.metadata?.['updatedBy'] || 'system' }}</td>
<td class="actions-cell">
@@ -101,14 +158,14 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
<button
type="button"
class="btn-sm"
(click)="rotateKey(key)"
(click)="openMutation(key, 'rotate')"
[disabled]="key.status === 'revoked' || mutatingKeyId() === key.keyId">
Rotate
</button>
<button
type="button"
class="btn-sm btn-sm--danger"
(click)="revokeKey(key)"
(click)="openMutation(key, 'revoke')"
[disabled]="key.status === 'revoked' || mutatingKeyId() === key.keyId">
Revoke
</button>
@@ -120,8 +177,8 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
}
@if (selectedKey()) {
<section class="detail-card">
<header class="detail-card__header">
<section class="workspace-card">
<header class="workspace-card__header">
<div>
<h3>{{ selectedKey()!.name }}</h3>
<p>{{ selectedKey()!.keyId }}</p>
@@ -151,50 +208,118 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
<dd>{{ selectedKey()!.createdAt | date:'medium' }}</dd>
</div>
<div>
<dt>Updated</dt>
<dd>{{ (selectedKey()!.metadata?.['updatedAt'] || selectedKey()!.createdAt) | date:'medium' }}</dd>
<dt>Operator Guidance</dt>
<dd>
Rotate when custody or policy changes. Revoke only when the key must stop signing immediately.
</dd>
</div>
</dl>
</section>
}
@if (mutationKey() && mutationKind()) {
<section class="workspace-card workspace-card--danger">
<header class="workspace-card__header">
<div>
<h3>{{ mutationKind() === 'rotate' ? 'Rotate Signing Key' : 'Revoke Signing Key' }}</h3>
<p>{{ mutationKey()!.name }} · {{ mutationKey()!.keyId }}</p>
</div>
<button type="button" class="btn-secondary" (click)="closeMutation()">Cancel</button>
</header>
<p class="impact-copy">
@if (mutationKind() === 'rotate') {
Rotation creates a new active version and shifts future evidence signing to the new key.
} @else {
Revocation immediately marks the key unusable for future signing operations.
}
</p>
<label class="filter-field">
<span>Reason</span>
<textarea
rows="3"
[ngModel]="mutationReason()"
(ngModelChange)="mutationReason.set($event)"
placeholder="Describe the rotation or revocation decision"
></textarea>
</label>
@if (mutationError()) {
<div class="inline-error">{{ mutationError() }}</div>
}
<div class="form-actions">
<button type="button" class="btn-primary" (click)="submitMutation()" [disabled]="mutating()">
{{ mutating() ? 'Applying...' : (mutationKind() === 'rotate' ? 'Confirm Rotation' : 'Confirm Revocation') }}
</button>
<button type="button" class="btn-secondary" (click)="closeMutation()">Cancel</button>
</div>
</section>
}
</section>
`,
styles: [`
.key-dashboard { padding: 1.5rem; display: grid; gap: 1rem; }
.key-dashboard__header { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; }
.key-dashboard__header h2 { margin: 0 0 0.35rem; }
.key-dashboard__header p { margin: 0; color: var(--color-text-secondary); max-width: 52rem; }
.key-dashboard__header, .workspace-card__header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.key-dashboard__header h2, .workspace-card__header h3 { margin: 0 0 0.35rem; }
.key-dashboard__header p, .workspace-card__header p, .impact-copy {
margin: 0;
color: var(--color-text-secondary);
max-width: 54rem;
}
.header-actions, .form-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.key-dashboard__filters { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; }
.filter-field { display: grid; gap: 0.25rem; min-width: 15rem; }
.filter-field span { font-size: 0.78rem; color: var(--color-text-secondary); text-transform: uppercase; }
.filter-field input, .filter-field select {
.filter-field input, .filter-field select, .filter-field textarea {
padding: 0.55rem 0.75rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-primary);
font: inherit;
}
.contract-note {
.workspace-card {
padding: 1rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
display: grid;
gap: 1rem;
}
.workspace-card--danger {
border-color: rgba(239, 68, 68, 0.35);
background: rgba(239, 68, 68, 0.05);
}
.form-grid, .detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: 0.85rem;
}
.detail-grid dt { font-size: 0.78rem; color: var(--color-text-secondary); text-transform: uppercase; }
.detail-grid dd { margin: 0.2rem 0 0; color: var(--color-text-primary); word-break: break-word; }
.banner, .state, .inline-error {
padding: 0.9rem 1rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
font-size: 0.88rem;
}
.state {
padding: 2rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
text-align: center;
color: var(--color-text-secondary);
background: var(--color-surface-primary);
}
.state--error {
.banner { background: rgba(74, 222, 128, 0.12); color: var(--color-status-success-border); }
.banner--error, .inline-error, .state--error {
color: var(--color-status-error);
border-color: rgba(239, 68, 68, 0.35);
background: rgba(239, 68, 68, 0.08);
}
.state {
text-align: center;
color: var(--color-text-secondary);
background: var(--color-surface-primary);
}
.key-table { width: 100%; border-collapse: collapse; }
.key-table th, .key-table td {
padding: 0.75rem 0.9rem;
@@ -210,27 +335,30 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
}
.key-table tr.is-selected { background: rgba(34, 211, 238, 0.08); }
.actions-cell { white-space: nowrap; }
.link-button, .btn-sm, .btn-secondary, .btn-link {
.link-button, .btn-sm, .btn-primary, .btn-secondary, .btn-link {
cursor: pointer;
border-radius: var(--radius-sm);
font: inherit;
}
.link-button {
padding: 0;
border: none;
background: transparent;
color: var(--color-status-info);
font: inherit;
text-align: left;
}
.btn-sm, .btn-secondary {
padding: 0.4rem 0.7rem;
.btn-sm, .btn-secondary, .btn-primary {
padding: 0.45rem 0.8rem;
border: 1px solid var(--color-border-primary);
background: transparent;
color: var(--color-text-primary);
margin-right: 0.35rem;
}
.btn-primary {
background: var(--color-status-info);
border-color: var(--color-status-info);
color: #04131a;
}
.btn-sm--danger { color: var(--color-status-error); }
.btn-secondary { margin-right: 0; }
.btn-link {
border: none;
background: transparent;
@@ -248,30 +376,6 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
.badge--expiring_soon { background: rgba(251, 191, 36, 0.16); color: var(--color-status-warning-border); }
.badge--expired, .badge--revoked { background: rgba(239, 68, 68, 0.12); color: var(--color-status-error); }
.badge--pending_rotation { background: rgba(167, 139, 250, 0.16); color: var(--color-status-excepted-border); }
.detail-card {
padding: 1rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
display: grid;
gap: 1rem;
}
.detail-card__header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.detail-card__header h3 { margin: 0 0 0.25rem; }
.detail-card__header p { margin: 0; color: var(--color-text-secondary); font-family: monospace; }
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: 0.85rem;
margin: 0;
}
.detail-grid dt { font-size: 0.78rem; color: var(--color-text-secondary); text-transform: uppercase; }
.detail-grid dd { margin: 0.2rem 0 0; color: var(--color-text-primary); word-break: break-word; }
`],
})
export class SigningKeyDashboardComponent {
@@ -280,11 +384,26 @@ export class SigningKeyDashboardComponent {
readonly keys = signal<SigningKey[]>([]);
readonly loading = signal(true);
readonly error = signal<string | null>(null);
readonly banner = signal<string | null>(null);
readonly bannerTone = signal<'success' | 'error'>('success');
readonly selectedKey = signal<SigningKey | null>(null);
readonly mutatingKeyId = signal<string | null>(null);
readonly searchQuery = signal('');
readonly selectedStatus = signal<SigningKeyStatus | 'all'>('all');
readonly createOpen = signal(false);
readonly createAlias = signal('');
readonly createAlgorithm = signal('ed25519');
readonly createMetadata = signal('');
readonly createError = signal<string | null>(null);
readonly creating = signal(false);
readonly mutationKey = signal<SigningKey | null>(null);
readonly mutationKind = signal<KeyMutationKind | null>(null);
readonly mutationReason = signal('');
readonly mutationError = signal<string | null>(null);
readonly mutating = signal(false);
readonly hasFilters = computed(() => this.searchQuery().trim().length > 0 || this.selectedStatus() !== 'all');
constructor() {
@@ -318,54 +437,102 @@ export class SigningKeyDashboardComponent {
}
applyFilters(): void {
this.selectedKey.set(null);
this.loadKeys();
}
clearFilters(): void {
this.searchQuery.set('');
this.selectedStatus.set('all');
this.applyFilters();
this.loadKeys();
}
rotateKey(key: SigningKey): void {
const reason = window.prompt(`Rotation reason for "${key.name}"`, 'Routine rotation');
if (reason === null) {
toggleCreate(): void {
this.createOpen.update((value) => !value);
this.createError.set(null);
}
resetCreate(): void {
this.createAlias.set('');
this.createAlgorithm.set('ed25519');
this.createMetadata.set('');
this.createError.set(null);
}
submitCreate(): void {
const alias = this.createAlias().trim();
if (!alias) {
this.createError.set('Alias is required.');
return;
}
this.mutatingKeyId.set(key.keyId);
this.error.set(null);
this.trustApi.rotateKey(key.keyId, { reason }).subscribe({
next: (updated) => {
this.keys.update((items) => items.map((item) => item.keyId === updated.keyId ? updated : item));
this.selectedKey.set(updated);
this.mutatingKeyId.set(null);
const request: CreateSigningKeyRequest = {
alias,
algorithm: this.createAlgorithm(),
metadataJson: this.createMetadata().trim() || undefined,
};
this.creating.set(true);
this.createError.set(null);
this.trustApi.createKey(request).subscribe({
next: (created) => {
this.creating.set(false);
this.createOpen.set(false);
this.resetCreate();
this.bannerTone.set('success');
this.banner.set(`Registered signing key ${created.name}.`);
this.selectedKey.set(created);
this.loadKeys();
},
error: (err) => {
this.error.set(err?.error?.error || err?.message || `Failed to rotate "${key.name}".`);
this.mutatingKeyId.set(null);
this.creating.set(false);
this.createError.set(err?.error?.error || err?.message || 'Failed to register signing key.');
},
});
}
revokeKey(key: SigningKey): void {
const reason = window.prompt(`Revocation reason for "${key.name}"`, 'Compromised or retired');
if (reason === null) {
openMutation(key: SigningKey, kind: KeyMutationKind): void {
this.mutationKey.set(key);
this.mutationKind.set(kind);
this.mutationReason.set('');
this.mutationError.set(null);
}
closeMutation(): void {
this.mutationKey.set(null);
this.mutationKind.set(null);
this.mutationReason.set('');
this.mutationError.set(null);
}
submitMutation(): void {
const key = this.mutationKey();
const kind = this.mutationKind();
const reason = this.mutationReason().trim();
if (!key || !kind) {
return;
}
if (!reason) {
this.mutationError.set('Reason is required.');
return;
}
this.mutating.set(true);
this.mutatingKeyId.set(key.keyId);
this.error.set(null);
this.mutationError.set(null);
if (kind === 'rotate') {
this.trustApi.rotateKey(key.keyId, { reason }).subscribe({
next: () => this.handleMutationSuccess(key.name, 'Rotated'),
error: (err) => this.handleMutationError(err, 'rotate'),
});
return;
}
this.trustApi.revokeKey(key.keyId, reason).subscribe({
next: () => {
this.loadKeys();
this.mutatingKeyId.set(null);
},
error: (err) => {
this.error.set(err?.error?.error || err?.message || `Failed to revoke "${key.name}".`);
this.mutatingKeyId.set(null);
},
next: () => this.handleMutationSuccess(key.name, 'Revoked'),
error: (err) => this.handleMutationError(err, 'revoke'),
});
}
@@ -379,4 +546,20 @@ export class SigningKeyDashboardComponent {
return status.charAt(0).toUpperCase() + status.slice(1);
}
}
private handleMutationSuccess(keyName: string, verb: 'Rotated' | 'Revoked'): void {
this.mutating.set(false);
this.mutatingKeyId.set(null);
this.bannerTone.set('success');
this.banner.set(`${verb} signing key ${keyName}.`);
this.closeMutation();
this.loadKeys();
}
private handleMutationError(err: unknown, kind: KeyMutationKind): void {
const error = err as { error?: { error?: string }; message?: string } | undefined;
this.mutating.set(false);
this.mutatingKeyId.set(null);
this.mutationError.set(error?.error?.error || error?.message || `Failed to ${kind} signing key.`);
}
}

View File

@@ -75,7 +75,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
<span class="summary-card__value">{{ overview()!.inventory.keys }}</span>
<span class="summary-card__label">Signing Keys</span>
<span class="summary-card__detail">
Administration inventory projection
Keys available for current signing workflows
</span>
</div>
</div>
@@ -86,7 +86,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
<span class="summary-card__value">{{ overview()!.inventory.issuers }}</span>
<span class="summary-card__label">Trusted Issuers</span>
<span class="summary-card__detail">
Routed from live administration projection
Publishers currently allowed to influence trust
</span>
</div>
</div>
@@ -97,7 +97,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
<span class="summary-card__value">{{ overview()!.inventory.certificates }}</span>
<span class="summary-card__label">Certificates</span>
<span class="summary-card__detail">
Evidence and issuer trust consumers stay linked
Certificate expiry and revocation stay visible here
</span>
</div>
</div>

View File

@@ -1,248 +1,199 @@
/**
* @file trust-analytics.component.spec.ts
* @sprint SPRINT_20251229_018c_FE
* @description Unit tests for TrustAnalyticsComponent
*/
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { TrustAnalyticsComponent } from './trust-analytics.component';
import { TRUST_API, TrustApi } from '../../core/api/trust.client';
import {
TrustAnalyticsSummary,
VerificationMetrics,
IssuerReliabilityData,
FailureAnalysis,
TrustAnalyticsAlert,
} from '../../core/api/trust.models';
import { IssuerReliabilityAnalytics, TrustAnalyticsSummary, VerificationAnalytics } from '../../core/api/trust.models';
import { TrustAnalyticsComponent } from './trust-analytics.component';
describe('TrustAnalyticsComponent', () => {
let component: TrustAnalyticsComponent;
let fixture: ComponentFixture<TrustAnalyticsComponent>;
let mockTrustApi: jasmine.SpyObj<TrustApi>;
const mockSummary: TrustAnalyticsSummary = {
totalVerifications: 10000,
successfulVerifications: 9500,
failedVerifications: 500,
successRate: 95.0,
averageVerificationTime: 125,
peakVerificationTime: 350,
activeSigningKeys: 5,
activeIssuers: 20,
trendDirection: 'up',
trendPercentage: 2.5,
};
const mockVerificationMetrics: VerificationMetrics = {
timeRange: 'last_24h',
dataPoints: [
{ timestamp: '2024-01-15T00:00:00Z', successCount: 400, failureCount: 20, avgTime: 120 },
{ timestamp: '2024-01-15T01:00:00Z', successCount: 450, failureCount: 15, avgTime: 115 },
const summary: TrustAnalyticsSummary = {
verificationSuccessRate: 96.2,
issuerReliabilityScore: 88.3,
certificateHealthScore: 91.4,
keyHealthScore: 94.8,
overallTrustScore: 92.1,
alerts: [
{
alertId: 'alert-001',
severity: 'warning',
category: 'certificate',
title: 'Gateway certificate expires soon',
message: 'Renew before the release window closes.',
createdAt: '2026-03-15T00:00:00Z',
acknowledged: false,
},
],
aggregations: {
totalSuccess: 9500,
totalFailure: 500,
avgSuccessRate: 95.0,
avgTime: 125,
trends: {
verificationTrend: 'stable',
reliabilityTrend: 'improving',
certificateTrend: 'declining',
keyTrend: 'stable',
},
};
const mockIssuerReliability: IssuerReliabilityData[] = [
{
issuerId: 'issuer-001',
issuerName: 'Vendor A',
totalDocuments: 500,
verifiedDocuments: 490,
failedDocuments: 10,
reliabilityScore: 98.0,
avgResponseTime: 100,
lastVerifiedAt: '2024-01-15T10:00:00Z',
const verificationAnalytics: VerificationAnalytics = {
timeRange: '7d',
granularity: 'daily',
summary: {
totalVerifications: 120,
successfulVerifications: 116,
failedVerifications: 4,
successRate: 96.7,
averageLatencyMs: 24,
p95LatencyMs: 64,
p99LatencyMs: 114,
},
];
const mockFailureAnalysis: FailureAnalysis = {
timeRange: 'last_7d',
totalFailures: 500,
failuresByType: [
{ type: 'signature_invalid', count: 200, percentage: 40 },
{ type: 'certificate_expired', count: 150, percentage: 30 },
{ type: 'chain_incomplete', count: 100, percentage: 20 },
{ type: 'unknown', count: 50, percentage: 10 },
trend: [
{
timestamp: '2026-03-15T00:00:00Z',
totalVerifications: 17,
successfulVerifications: 16,
failedVerifications: 1,
successRate: 94.1,
averageLatencyMs: 23,
},
],
failuresByIssuer: [
{ issuerId: 'issuer-002', issuerName: 'Vendor B', count: 100, percentage: 20 },
byResourceType: {
keys: {
totalVerifications: 40,
successfulVerifications: 39,
failedVerifications: 1,
successRate: 97.5,
averageLatencyMs: 18,
p95LatencyMs: 58,
p99LatencyMs: 108,
},
certificates: {
totalVerifications: 50,
successfulVerifications: 47,
failedVerifications: 3,
successRate: 94,
averageLatencyMs: 31,
p95LatencyMs: 71,
p99LatencyMs: 121,
},
issuers: {
totalVerifications: 30,
successfulVerifications: 30,
failedVerifications: 0,
successRate: 100,
averageLatencyMs: 19,
p95LatencyMs: 59,
p99LatencyMs: 109,
},
signatures: {
totalVerifications: 100,
successfulVerifications: 96,
failedVerifications: 4,
successRate: 96,
averageLatencyMs: 26,
p95LatencyMs: 66,
p99LatencyMs: 116,
},
},
failureReasons: [
{
reason: 'Certificates expiring soon',
count: 2,
percentage: 1.7,
trend: 'increasing',
},
],
recentFailures: [],
};
const mockAlerts: TrustAnalyticsAlert[] = [
{
alertId: 'alert-001',
severity: 'warning',
title: 'Verification Rate Declining',
description: 'Verification success rate dropped below 95%',
timestamp: '2024-01-15T10:00:00Z',
acknowledged: false,
},
];
const issuerReliabilityAnalytics: IssuerReliabilityAnalytics = {
timeRange: '7d',
granularity: 'daily',
issuers: [
{
issuerId: 'issuer-001',
issuerName: 'core-root-ca',
issuerDisplayName: 'Core Root CA',
trustScore: 88,
trustLevel: 'partial',
totalDocuments: 18,
verifiedDocuments: 16,
verificationRate: 88.9,
averageResponseTime: 28,
uptimePercentage: 98.2,
lastVerificationAt: '2026-03-15T00:00:00Z',
reliabilityScore: 89.1,
trendDirection: 'improving',
},
],
aggregatedTrend: [
{
timestamp: '2026-03-15T00:00:00Z',
reliabilityScore: 89.1,
verificationRate: 88.9,
trustScore: 88,
documentsProcessed: 18,
},
],
topPerformers: [],
underperformers: [],
averageReliabilityScore: 89.1,
averageVerificationRate: 88.9,
};
beforeEach(async () => {
mockTrustApi = jasmine.createSpyObj<TrustApi>('TrustApi', [
'getAnalyticsSummary',
'getVerificationMetrics',
'getIssuerReliability',
'getFailureAnalysis',
'getAnalyticsAlerts',
'acknowledgeAlert',
'dismissAlert',
'getVerificationAnalytics',
'getIssuerReliabilityAnalytics',
'acknowledgeAnalyticsAlert',
]);
mockTrustApi.getAnalyticsSummary.and.returnValue(of(mockSummary));
mockTrustApi.getVerificationMetrics.and.returnValue(of(mockVerificationMetrics));
mockTrustApi.getIssuerReliability.and.returnValue(of(mockIssuerReliability));
mockTrustApi.getFailureAnalysis.and.returnValue(of(mockFailureAnalysis));
mockTrustApi.getAnalyticsAlerts.and.returnValue(of(mockAlerts));
mockTrustApi.acknowledgeAlert.and.returnValue(of(void 0));
mockTrustApi.dismissAlert.and.returnValue(of(void 0));
mockTrustApi.getAnalyticsSummary.and.returnValue(of(summary));
mockTrustApi.getVerificationAnalytics.and.returnValue(of(verificationAnalytics));
mockTrustApi.getIssuerReliabilityAnalytics.and.returnValue(of(issuerReliabilityAnalytics));
mockTrustApi.acknowledgeAnalyticsAlert.and.returnValue(of(void 0));
await TestBed.configureTestingModule({
imports: [TrustAnalyticsComponent],
providers: [
{ provide: TRUST_API, useValue: mockTrustApi },
],
providers: [{ provide: TRUST_API, useValue: mockTrustApi }],
}).compileComponents();
fixture = TestBed.createComponent(TrustAnalyticsComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should load analytics data on init', fakeAsync(() => {
it('loads derived analytics data on init', () => {
fixture.detectChanges();
tick();
expect(mockTrustApi.getAnalyticsSummary).toHaveBeenCalled();
expect(mockTrustApi.getVerificationMetrics).toHaveBeenCalled();
expect(mockTrustApi.getIssuerReliability).toHaveBeenCalled();
expect(mockTrustApi.getFailureAnalysis).toHaveBeenCalled();
expect(mockTrustApi.getAnalyticsAlerts).toHaveBeenCalled();
}));
expect(mockTrustApi.getVerificationAnalytics).toHaveBeenCalledWith({ timeRange: '7d', granularity: 'daily' });
expect(mockTrustApi.getIssuerReliabilityAnalytics).toHaveBeenCalledWith({ timeRange: '7d', granularity: 'daily' });
expect(component.summary()).toEqual(summary);
});
it('should display summary data', fakeAsync(() => {
it('reloads analytics when the time range changes', () => {
fixture.detectChanges();
tick();
expect(component.summary()).toEqual(mockSummary);
}));
component.onTimeRangeChange('24h');
it('should display verification metrics', fakeAsync(() => {
expect(mockTrustApi.getVerificationAnalytics).toHaveBeenCalledWith({ timeRange: '24h', granularity: 'hourly' });
expect(mockTrustApi.getIssuerReliabilityAnalytics).toHaveBeenCalledWith({ timeRange: '24h', granularity: 'hourly' });
});
it('acknowledges alerts in local state after the API call', () => {
fixture.detectChanges();
tick();
expect(component.verificationMetrics()).toEqual(mockVerificationMetrics);
}));
it('should display issuer reliability data', fakeAsync(() => {
fixture.detectChanges();
tick();
expect(component.issuerReliability().length).toBe(1);
expect(component.issuerReliability()[0].issuerName).toBe('Vendor A');
}));
it('should display failure analysis', fakeAsync(() => {
fixture.detectChanges();
tick();
expect(component.failureAnalysis()).toEqual(mockFailureAnalysis);
}));
it('should display alerts', fakeAsync(() => {
fixture.detectChanges();
tick();
expect(component.alerts().length).toBe(1);
expect(component.alerts()[0].title).toBe('Verification Rate Declining');
}));
it('should change time range and reload data', fakeAsync(() => {
fixture.detectChanges();
tick();
component.selectedTimeRange.set('last_7d');
component.onTimeRangeChange();
tick();
expect(mockTrustApi.getVerificationMetrics).toHaveBeenCalledWith('last_7d');
expect(mockTrustApi.getFailureAnalysis).toHaveBeenCalledWith('last_7d');
}));
it('should acknowledge alert', fakeAsync(() => {
fixture.detectChanges();
tick();
component.acknowledgeAlert('alert-001');
tick();
expect(mockTrustApi.acknowledgeAlert).toHaveBeenCalledWith('alert-001');
}));
it('should dismiss alert', fakeAsync(() => {
fixture.detectChanges();
tick();
component.dismissAlert('alert-001');
tick();
expect(mockTrustApi.dismissAlert).toHaveBeenCalledWith('alert-001');
}));
it('should handle loading state', () => {
expect(component.loading()).toBeTrue();
fixture.detectChanges();
expect(component.loading()).toBeFalse();
expect(mockTrustApi.acknowledgeAnalyticsAlert).toHaveBeenCalledWith('alert-001');
expect(component.unacknowledgedAlerts()).toEqual([]);
});
it('should handle error state', fakeAsync(() => {
mockTrustApi.getAnalyticsSummary.and.returnValue(throwError(() => new Error('Failed')));
it('surfaces load failures', () => {
mockTrustApi.getAnalyticsSummary.and.returnValue(throwError(() => new Error('analytics unavailable')));
fixture.detectChanges();
tick();
expect(component.error()).toBe('Failed');
}));
it('should format time range correctly', () => {
expect(component.formatTimeRange('last_24h')).toBe('Last 24 Hours');
expect(component.formatTimeRange('last_7d')).toBe('Last 7 Days');
expect(component.formatTimeRange('last_30d')).toBe('Last 30 Days');
expect(component.error()).toBe('Failed to load analytics data. Please try again.');
});
it('should format failure type correctly', () => {
expect(component.formatFailureType('signature_invalid')).toBe('Invalid Signature');
expect(component.formatFailureType('certificate_expired')).toBe('Certificate Expired');
expect(component.formatFailureType('chain_incomplete')).toBe('Incomplete Chain');
});
it('should compute unacknowledged alerts count', fakeAsync(() => {
fixture.detectChanges();
tick();
expect(component.unacknowledgedAlerts()).toBe(1);
}));
it('should refresh data', fakeAsync(() => {
fixture.detectChanges();
tick();
const initialCalls = mockTrustApi.getAnalyticsSummary.calls.count();
component.refreshData();
tick();
expect(mockTrustApi.getAnalyticsSummary.calls.count()).toBe(initialCalls + 1);
}));
});

View File

@@ -3,8 +3,11 @@ import { of, throwError } from 'rxjs';
import {
AUTHORITY_ADMIN_API,
type AuthorityAdminApi,
type AdminRole,
type AdminTenant,
type AdminUser,
type AuthorityAdminApi,
type RoleImpactPreview,
} from '../../app/core/api/authority-admin.client';
import { AdminSettingsPageComponent } from '../../app/features/settings/admin/admin-settings-page.component';
@@ -16,29 +19,140 @@ function createAuthorityApiSpy(): jasmine.SpyObj<AuthorityAdminApi> {
listTokens: jasmine.createSpy('listTokens'),
listTenants: jasmine.createSpy('listTenants'),
createUser: jasmine.createSpy('createUser'),
updateUser: jasmine.createSpy('updateUser'),
disableUser: jasmine.createSpy('disableUser'),
enableUser: jasmine.createSpy('enableUser'),
createRole: jasmine.createSpy('createRole'),
updateRole: jasmine.createSpy('updateRole'),
previewRoleImpact: jasmine.createSpy('previewRoleImpact'),
createTenant: jasmine.createSpy('createTenant'),
updateTenant: jasmine.createSpy('updateTenant'),
suspendTenant: jasmine.createSpy('suspendTenant'),
resumeTenant: jasmine.createSpy('resumeTenant'),
} as jasmine.SpyObj<AuthorityAdminApi>;
}
describe('AdminSettingsPageComponent (settings)', () => {
let api: jasmine.SpyObj<AuthorityAdminApi>;
let component: AdminSettingsPageComponent;
let users: AdminUser[];
let roles: AdminRole[];
let tenants: AdminTenant[];
let roleImpact: RoleImpactPreview;
beforeEach(async () => {
api = createAuthorityApiSpy();
api.listUsers.and.returnValue(of([]));
api.listRoles.and.returnValue(of([]));
users = [{
id: 'u-1',
username: 'jane',
email: 'jane@example.com',
displayName: 'Jane Example',
roles: ['role/console-viewer'],
status: 'active',
createdAt: '2026-02-19T00:00:00Z',
}];
roles = [
{
id: 'role/console-admin',
name: 'role/console-admin',
description: 'Full platform administration.',
permissions: ['ui.admin', 'authority:users.write'],
userCount: 1,
isBuiltIn: true,
},
{
id: 'role/console-viewer',
name: 'role/console-viewer',
description: 'Least privilege console access.',
permissions: ['ui.read', 'authority:users.read'],
userCount: 1,
isBuiltIn: true,
},
{
id: 'role/custom-release-operator',
name: 'role/custom-release-operator',
description: 'Custom release operator role.',
permissions: ['release:read', 'release:write'],
userCount: 0,
isBuiltIn: false,
},
];
tenants = [{
id: 'default',
displayName: 'Default Tenant',
status: 'active',
isolationMode: 'shared',
userCount: 1,
createdAt: '2026-02-19T00:00:00Z',
}];
roleImpact = {
affectedUsers: 2,
affectedClients: 0,
message: '2 users would be affected by changes to this role.',
};
api.listUsers.and.returnValue(of(users));
api.listRoles.and.returnValue(of(roles));
api.listClients.and.returnValue(of([]));
api.listTokens.and.returnValue(of([]));
api.listTenants.and.returnValue(of([]));
api.listTenants.and.returnValue(of(tenants));
api.createUser.and.returnValue(of({
id: 'u-created',
username: 'created',
email: 'created@example.com',
displayName: 'Created User',
roles: ['viewer'],
roles: ['role/console-viewer'],
status: 'active',
createdAt: '2026-02-19T00:00:00Z',
} as AdminUser));
}));
api.updateUser.and.callFake((userId, request) => of({
...users.find((entry) => entry.id === userId)!,
displayName: request.displayName ?? users[0].displayName,
roles: request.roles ?? users[0].roles,
}));
api.disableUser.and.callFake((userId) => of({
...users.find((entry) => entry.id === userId)!,
status: 'disabled',
}));
api.enableUser.and.callFake((userId) => of({
...users.find((entry) => entry.id === userId)!,
status: 'active',
}));
api.createRole.and.callFake((request) => of({
id: request.name,
name: request.name,
description: request.description,
permissions: request.permissions,
userCount: 0,
isBuiltIn: false,
}));
api.updateRole.and.callFake((roleId, request) => of({
...roles.find((entry) => entry.id === roleId)!,
description: request.description ?? roles[2].description,
permissions: request.permissions ?? roles[2].permissions,
}));
api.previewRoleImpact.and.returnValue(of(roleImpact));
api.createTenant.and.callFake((request) => of({
id: request.id,
displayName: request.displayName,
status: 'active',
isolationMode: request.isolationMode,
userCount: 0,
createdAt: '2026-02-19T00:00:00Z',
}));
api.updateTenant.and.callFake((tenantId, request) => of({
...tenants.find((entry) => entry.id === tenantId)!,
displayName: request.displayName ?? tenants[0].displayName,
isolationMode: request.isolationMode ?? tenants[0].isolationMode,
}));
api.suspendTenant.and.callFake((tenantId) => of({
...tenants.find((entry) => entry.id === tenantId)!,
status: 'disabled',
}));
api.resumeTenant.and.callFake((tenantId) => of({
...tenants.find((entry) => entry.id === tenantId)!,
status: 'active',
}));
await TestBed.configureTestingModule({
imports: [AdminSettingsPageComponent],
@@ -49,6 +163,8 @@ describe('AdminSettingsPageComponent (settings)', () => {
});
it('keeps empty state distinct from error state when users API returns empty list', () => {
api.listUsers.and.returnValue(of([]));
component.ngOnInit();
expect(component.users()).toEqual([]);
@@ -62,7 +178,99 @@ describe('AdminSettingsPageComponent (settings)', () => {
component.ngOnInit();
expect(component.users()).toEqual([]);
expect(component.error()).toContain('Failed to load users');
expect(component.error()).toContain('authority unavailable');
expect(component.loading()).toBeFalse();
});
it('defaults new users to the least-privilege role and validates email format', () => {
component.ngOnInit();
component.openCreateUserEditor();
expect(component.userEditor?.selectedRoles).toEqual(['role/console-viewer']);
component.userEditor!.username = 'sam';
component.userEditor!.email = 'invalid-email';
component.userEditor!.displayName = 'Sam Operator';
component.saveUser();
expect(api.createUser).not.toHaveBeenCalled();
expect(component.error()).toContain('valid email');
});
it('updates users and toggles lifecycle actions from the setup surface', () => {
component.ngOnInit();
component.openEditUserEditor(users[0]);
component.userEditor!.displayName = 'Jane Updated';
component.userEditor!.selectedRoles = ['role/console-admin'];
component.saveUser();
expect(api.updateUser).toHaveBeenCalledWith('u-1', {
displayName: 'Jane Updated',
roles: ['role/console-admin'],
});
expect(component.users()[0].displayName).toBe('Jane Updated');
expect(component.users()[0].roles).toEqual(['role/console-admin']);
component.disableUser(component.users()[0]);
expect(api.disableUser).toHaveBeenCalledWith('u-1');
expect(component.users()[0].status).toBe('disabled');
component.enableUser(component.users()[0]);
expect(api.enableUser).toHaveBeenCalledWith('u-1');
expect(component.users()[0].status).toBe('active');
});
it('shows role detail impact and allows editing custom roles', () => {
component.ngOnInit();
component.selectRole(roles[2]);
expect(api.previewRoleImpact).toHaveBeenCalledWith('role/custom-release-operator');
expect(component.roleImpact()).toEqual(roleImpact);
expect(component.selectedRole()?.name).toBe('role/custom-release-operator');
component.openEditRoleEditor(roles[2]);
component.roleEditor!.description = 'Release operator with approvals.';
component.roleEditor!.selectedScopes = ['release:read', 'release:write', 'release:publish'];
component.saveRole();
expect(api.updateRole).toHaveBeenCalledWith('role/custom-release-operator', {
description: 'Release operator with approvals.',
permissions: ['release:read', 'release:write', 'release:publish'],
});
expect(component.successMessage()).toContain('Updated role');
});
it('validates tenant ids and supports tenant lifecycle actions', () => {
component.setTab('tenants');
component.openCreateTenantEditor();
component.tenantEditor!.id = 'Bad Tenant';
component.tenantEditor!.displayName = 'Bad Tenant';
component.saveTenant();
expect(api.createTenant).not.toHaveBeenCalled();
expect(component.error()).toContain('lowercase letters, digits, and hyphens');
component.tenantEditor!.id = 'sandbox';
component.tenantEditor!.displayName = 'Sandbox';
component.tenantEditor!.isolationMode = 'dedicated';
component.saveTenant();
expect(api.createTenant).toHaveBeenCalledWith({
id: 'sandbox',
displayName: 'Sandbox',
isolationMode: 'dedicated',
});
component.suspendTenant(tenants[0]);
expect(api.suspendTenant).toHaveBeenCalledWith('default');
expect(component.tenants()[0].status).toBe('disabled');
component.resumeTenant(component.tenants()[0]);
expect(api.resumeTenant).toHaveBeenCalledWith('default');
expect(component.tenants()[0].status).toBe('active');
});
});

View File

@@ -8,6 +8,7 @@
"src/app/types/monaco-workers.d.ts",
"src/app/core/branding/branding.service.spec.ts",
"src/app/core/api/first-signal.client.spec.ts",
"src/app/core/api/trust.client.spec.ts",
"src/app/core/api/vulnerability-http.client.spec.ts",
"src/app/core/api/watchlist.client.spec.ts",
"src/app/core/auth/tenant-activation.service.spec.ts",
@@ -38,13 +39,18 @@
"src/app/features/releases/release-ops-overview-page.component.spec.ts",
"src/app/features/registry-admin/components/plan-audit.component.spec.ts",
"src/app/features/registry-admin/registry-admin.component.spec.ts",
"src/app/features/trust-admin/certificate-inventory.component.spec.ts",
"src/app/features/trust-admin/issuer-trust-list.component.spec.ts",
"src/app/features/trust-admin/trust-admin.component.spec.ts",
"src/app/features/trust-admin/trust-analytics.component.spec.ts",
"src/app/features/trust-admin/signing-key-dashboard.component.spec.ts",
"src/app/features/triage/services/ttfs-telemetry.service.spec.ts",
"src/app/features/triage/triage-workspace.component.spec.ts",
"src/app/features/vex-hub/vex-hub-stats.component.spec.ts",
"src/app/features/vex-hub/vex-hub-source-contract.spec.ts",
"src/app/shared/ui/filter-bar/filter-bar.component.spec.ts",
"src/app/features/watchlist/watchlist-page.component.spec.ts",
"src/app/features/evidence-thread/__tests__/evidence-thread-view.component.spec.ts"
"src/app/features/evidence-thread/__tests__/evidence-thread-view.component.spec.ts",
"src/tests/settings/admin-settings-page.component.spec.ts"
]
}