Repair first-time identity and trust operator journeys
This commit is contained in:
@@ -156,6 +156,105 @@ public sealed class ConsoleAdminEndpointsTests
|
||||
Assert.Equal("invalid_email", payload!.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateUser_DisableUser_AndEnableUser_PersistLifecycleAndRoles()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 2, 20, 13, 40, 0, TimeSpan.Zero);
|
||||
var timeProvider = new FakeTimeProvider(now);
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var users = new InMemoryUserRepository();
|
||||
var roles = new InMemoryRoleRepository();
|
||||
|
||||
await roles.CreateAsync("default", new RoleEntity
|
||||
{
|
||||
Id = Guid.Parse("a9e2fd75-df4e-4485-b23f-1ec573f59138"),
|
||||
TenantId = "default",
|
||||
Name = "role/console-viewer",
|
||||
DisplayName = "Console Viewer",
|
||||
Description = "Read-only console access",
|
||||
IsSystem = false,
|
||||
Metadata = "{}",
|
||||
CreatedAt = now.AddDays(-5),
|
||||
UpdatedAt = now.AddDays(-5),
|
||||
});
|
||||
await roles.CreateAsync("default", new RoleEntity
|
||||
{
|
||||
Id = Guid.Parse("f526b104-24f7-46f1-ab52-3c3dfd95cb0c"),
|
||||
TenantId = "default",
|
||||
Name = "role/release-operator",
|
||||
DisplayName = "Release Operator",
|
||||
Description = "Operate releases",
|
||||
IsSystem = false,
|
||||
Metadata = "{}",
|
||||
CreatedAt = now.AddDays(-5),
|
||||
UpdatedAt = now.AddDays(-5),
|
||||
});
|
||||
|
||||
await using var app = await CreateApplicationAsync(timeProvider, sink, users, roleRepository: roles);
|
||||
|
||||
var principalAccessor = app.Services.GetRequiredService<AdminTestPrincipalAccessor>();
|
||||
principalAccessor.Principal = CreatePrincipal(
|
||||
tenant: "default",
|
||||
scopes: new[]
|
||||
{
|
||||
StellaOpsScopes.UiAdmin,
|
||||
StellaOpsScopes.AuthorityUsersRead,
|
||||
StellaOpsScopes.AuthorityUsersWrite,
|
||||
},
|
||||
expiresAt: now.AddMinutes(10));
|
||||
|
||||
using var client = CreateTestClient(app);
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(AdminAuthenticationDefaults.AuthenticationScheme);
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "default");
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/console/admin/users",
|
||||
new
|
||||
{
|
||||
username = "operator.alice",
|
||||
email = "operator.alice@example.com",
|
||||
displayName = "Operator Alice",
|
||||
roles = new[] { "role/console-viewer" }
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<UserSummary>();
|
||||
Assert.NotNull(created);
|
||||
|
||||
var updateResponse = await client.PatchAsJsonAsync(
|
||||
$"/console/admin/users/{created!.Id}",
|
||||
new
|
||||
{
|
||||
displayName = "Release Operator Alice",
|
||||
roles = new[] { "role/release-operator" }
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);
|
||||
var updated = await updateResponse.Content.ReadFromJsonAsync<UserSummary>();
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal("Release Operator Alice", updated!.DisplayName);
|
||||
Assert.Equal(new[] { "role/release-operator" }, updated.Roles);
|
||||
|
||||
var disableResponse = await client.PostAsJsonAsync($"/console/admin/users/{created.Id}/disable", new { });
|
||||
Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode);
|
||||
var disabled = await disableResponse.Content.ReadFromJsonAsync<UserSummary>();
|
||||
Assert.NotNull(disabled);
|
||||
Assert.Equal("disabled", disabled!.Status);
|
||||
|
||||
var enableResponse = await client.PostAsJsonAsync($"/console/admin/users/{created.Id}/enable", new { });
|
||||
Assert.Equal(HttpStatusCode.OK, enableResponse.StatusCode);
|
||||
var enabled = await enableResponse.Content.ReadFromJsonAsync<UserSummary>();
|
||||
Assert.NotNull(enabled);
|
||||
Assert.Equal("active", enabled!.Status);
|
||||
|
||||
var listResponse = await client.GetAsync("/console/admin/users");
|
||||
var payload = await listResponse.Content.ReadFromJsonAsync<UserListPayload>();
|
||||
Assert.NotNull(payload);
|
||||
var listed = Assert.Single(payload!.Users.Where(static user => user.Username == "operator.alice"));
|
||||
Assert.Equal("Release Operator Alice", listed.DisplayName);
|
||||
Assert.Equal(new[] { "role/release-operator" }, listed.Roles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RolesList_ExposesNamedDefaults_AndCreateRolePersists()
|
||||
{
|
||||
@@ -216,6 +315,90 @@ public sealed class ConsoleAdminEndpointsTests
|
||||
Assert.Contains(reloaded!.Roles, role => role.Name == "security-analyst");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateRole_RefreshesScopes_AndPreviewImpactCountsAffectedUsers()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 2, 20, 13, 55, 0, TimeSpan.Zero);
|
||||
var timeProvider = new FakeTimeProvider(now);
|
||||
var sink = new RecordingAuthEventSink();
|
||||
var users = new InMemoryUserRepository();
|
||||
var roles = new InMemoryRoleRepository();
|
||||
var permissions = new InMemoryPermissionRepository();
|
||||
|
||||
await using var app = await CreateApplicationAsync(
|
||||
timeProvider,
|
||||
sink,
|
||||
users,
|
||||
roleRepository: roles,
|
||||
permissionRepository: permissions);
|
||||
|
||||
var principalAccessor = app.Services.GetRequiredService<AdminTestPrincipalAccessor>();
|
||||
principalAccessor.Principal = CreatePrincipal(
|
||||
tenant: "default",
|
||||
scopes: new[]
|
||||
{
|
||||
StellaOpsScopes.UiAdmin,
|
||||
StellaOpsScopes.AuthorityUsersWrite,
|
||||
StellaOpsScopes.AuthorityRolesRead,
|
||||
StellaOpsScopes.AuthorityRolesWrite,
|
||||
},
|
||||
expiresAt: now.AddMinutes(10));
|
||||
|
||||
using var client = CreateTestClient(app);
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(AdminAuthenticationDefaults.AuthenticationScheme);
|
||||
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "default");
|
||||
|
||||
var createRoleResponse = await client.PostAsJsonAsync(
|
||||
"/console/admin/roles",
|
||||
new
|
||||
{
|
||||
roleId = "security-analyst",
|
||||
displayName = "Security Analyst",
|
||||
scopes = new[] { "findings:read", "vex:read" }
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, createRoleResponse.StatusCode);
|
||||
var createdRole = await createRoleResponse.Content.ReadFromJsonAsync<RoleSummary>();
|
||||
Assert.NotNull(createdRole);
|
||||
|
||||
var createUserResponse = await client.PostAsJsonAsync(
|
||||
"/console/admin/users",
|
||||
new
|
||||
{
|
||||
username = "security.alice",
|
||||
email = "security.alice@example.com",
|
||||
displayName = "Security Alice",
|
||||
roles = new[] { "security-analyst" }
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, createUserResponse.StatusCode);
|
||||
|
||||
var updateResponse = await client.PatchAsJsonAsync(
|
||||
$"/console/admin/roles/{createdRole!.Id}",
|
||||
new
|
||||
{
|
||||
displayName = "Security Analyst Plus",
|
||||
scopes = new[] { "findings:read", "vex:read", "vuln:investigate" }
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);
|
||||
var updated = await updateResponse.Content.ReadFromJsonAsync<RoleSummary>();
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal("security-analyst", updated!.Name);
|
||||
Assert.Equal("Security Analyst Plus", updated.Description);
|
||||
Assert.Contains("vuln:investigate", updated.Permissions);
|
||||
|
||||
var previewResponse = await client.PostAsJsonAsync(
|
||||
$"/console/admin/roles/{createdRole.Id}/preview-impact",
|
||||
new { });
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, previewResponse.StatusCode);
|
||||
var preview = await previewResponse.Content.ReadFromJsonAsync<RoleImpactPayload>();
|
||||
Assert.NotNull(preview);
|
||||
Assert.Equal(1, preview!.AffectedUsers);
|
||||
Assert.Equal(0, preview.AffectedClients);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TenantsList_MergesCatalog_AndCreateTenantPersists()
|
||||
{
|
||||
@@ -908,7 +1091,17 @@ public sealed class ConsoleAdminEndpointsTests
|
||||
|
||||
public Task<bool> UpdateAsync(UserEntity user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
lock (_sync)
|
||||
{
|
||||
var index = _users.FindIndex(existing => existing.TenantId == user.TenantId && existing.Id == user.Id);
|
||||
if (index < 0)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
_users[index] = user;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
@@ -1235,6 +1428,7 @@ public sealed class ConsoleAdminEndpointsTests
|
||||
private sealed record UserListPayload(IReadOnlyList<UserSummary> Users, int Count);
|
||||
private sealed record RoleListPayload(IReadOnlyList<RoleSummary> Roles, int Count);
|
||||
private sealed record TenantListPayload(IReadOnlyList<TenantSummary> Tenants, int Count);
|
||||
private sealed record RoleImpactPayload(int AffectedUsers, int AffectedClients, string? Message);
|
||||
private sealed record ClientListPayload(IReadOnlyList<ClientSummary> Clients, int Count, string SelectedTenant);
|
||||
private sealed record ClientSummary(
|
||||
string ClientId,
|
||||
|
||||
@@ -593,10 +593,86 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
HttpContext httpContext,
|
||||
string userId,
|
||||
UpdateUserRequest request,
|
||||
IUserRepository userRepository,
|
||||
IRoleRepository roleRepository,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var user = await ResolveUserEntityAsync(tenantId, userId, userRepository, cancellationToken).ConfigureAwait(false);
|
||||
if (user is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "user_not_found", userId });
|
||||
}
|
||||
|
||||
var updatedRoles = request.Roles is null ? null : NormalizeRoles(request.Roles);
|
||||
IReadOnlyList<RoleEntity>? resolvedRoles = null;
|
||||
if (updatedRoles is not null)
|
||||
{
|
||||
resolvedRoles = await ResolveRequestedRolesAsync(tenantId, updatedRoles, roleRepository, cancellationToken).ConfigureAwait(false);
|
||||
var missingRoles = updatedRoles
|
||||
.Except(resolvedRoles.Select(static role => role.Name), StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
if (missingRoles.Length > 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = "roles_not_found", roles = missingRoles });
|
||||
}
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var updatedUser = new UserEntity
|
||||
{
|
||||
Id = user.Id,
|
||||
TenantId = user.TenantId,
|
||||
Username = user.Username,
|
||||
Email = user.Email,
|
||||
DisplayName = NormalizeOptional(request.DisplayName) ?? user.DisplayName,
|
||||
PasswordHash = user.PasswordHash,
|
||||
PasswordSalt = user.PasswordSalt,
|
||||
Enabled = user.Enabled,
|
||||
EmailVerified = user.EmailVerified,
|
||||
MfaEnabled = user.MfaEnabled,
|
||||
MfaSecret = user.MfaSecret,
|
||||
MfaBackupCodes = user.MfaBackupCodes,
|
||||
FailedLoginAttempts = user.FailedLoginAttempts,
|
||||
LockedUntil = user.LockedUntil,
|
||||
LastLoginAt = user.LastLoginAt,
|
||||
PasswordChangedAt = user.PasswordChangedAt,
|
||||
Settings = user.Settings,
|
||||
Metadata = updatedRoles is null ? user.Metadata : UpdateUserMetadata(user.Metadata, updatedRoles),
|
||||
CreatedAt = user.CreatedAt,
|
||||
UpdatedAt = now,
|
||||
CreatedBy = user.CreatedBy,
|
||||
};
|
||||
|
||||
var persisted = await userRepository.UpdateAsync(updatedUser, cancellationToken).ConfigureAwait(false);
|
||||
if (!persisted)
|
||||
{
|
||||
return Results.Problem("Failed to persist user updates.");
|
||||
}
|
||||
|
||||
if (updatedRoles is not null && resolvedRoles is not null)
|
||||
{
|
||||
var currentRoles = await roleRepository.GetUserRolesAsync(tenantId, user.Id, cancellationToken).ConfigureAwait(false);
|
||||
foreach (var role in currentRoles.Where(role => !updatedRoles.Contains(role.Name, StringComparer.OrdinalIgnoreCase)))
|
||||
{
|
||||
await roleRepository.RemoveFromUserAsync(tenantId, user.Id, role.Id, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var grantedBy = httpContext.User.FindFirstValue(OpenIddictConstants.Claims.PreferredUsername)
|
||||
?? httpContext.User.FindFirstValue(StellaOpsClaimTypes.Subject)
|
||||
?? "console-admin";
|
||||
|
||||
foreach (var role in resolvedRoles.Where(role => !currentRoles.Any(existing => existing.Id == role.Id)))
|
||||
{
|
||||
await roleRepository.AssignToUserAsync(tenantId, user.Id, role.Id, grantedBy, expiresAt: null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
var effectiveRoles = updatedRoles ?? await ResolveUserRolesAsync(updatedUser, roleRepository, cancellationToken).ConfigureAwait(false);
|
||||
var summary = ToAdminUserSummary(updatedUser, now, effectiveRoles);
|
||||
|
||||
await WriteAdminAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
@@ -604,19 +680,64 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
"authority.admin.users.update",
|
||||
AuthEventOutcome.Success,
|
||||
null,
|
||||
BuildProperties(("user.id", userId)),
|
||||
BuildProperties(("tenant.id", tenantId), ("user.id", summary.Id)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new { message = "User update: implementation pending" });
|
||||
return Results.Ok(summary);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DisableUser(
|
||||
HttpContext httpContext,
|
||||
string userId,
|
||||
IUserRepository userRepository,
|
||||
IRoleRepository roleRepository,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var user = await ResolveUserEntityAsync(tenantId, userId, userRepository, cancellationToken).ConfigureAwait(false);
|
||||
if (user is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "user_not_found", userId });
|
||||
}
|
||||
|
||||
var updatedUser = new UserEntity
|
||||
{
|
||||
Id = user.Id,
|
||||
TenantId = user.TenantId,
|
||||
Username = user.Username,
|
||||
Email = user.Email,
|
||||
DisplayName = user.DisplayName,
|
||||
PasswordHash = user.PasswordHash,
|
||||
PasswordSalt = user.PasswordSalt,
|
||||
Enabled = false,
|
||||
EmailVerified = user.EmailVerified,
|
||||
MfaEnabled = user.MfaEnabled,
|
||||
MfaSecret = user.MfaSecret,
|
||||
MfaBackupCodes = user.MfaBackupCodes,
|
||||
FailedLoginAttempts = user.FailedLoginAttempts,
|
||||
LockedUntil = user.LockedUntil,
|
||||
LastLoginAt = user.LastLoginAt,
|
||||
PasswordChangedAt = user.PasswordChangedAt,
|
||||
Settings = user.Settings,
|
||||
Metadata = user.Metadata,
|
||||
CreatedAt = user.CreatedAt,
|
||||
UpdatedAt = timeProvider.GetUtcNow(),
|
||||
CreatedBy = user.CreatedBy,
|
||||
};
|
||||
|
||||
var persisted = await userRepository.UpdateAsync(updatedUser, cancellationToken).ConfigureAwait(false);
|
||||
if (!persisted)
|
||||
{
|
||||
return Results.Problem("Failed to disable user.");
|
||||
}
|
||||
|
||||
var summary = ToAdminUserSummary(
|
||||
updatedUser,
|
||||
timeProvider.GetUtcNow(),
|
||||
await ResolveUserRolesAsync(updatedUser, roleRepository, cancellationToken).ConfigureAwait(false));
|
||||
|
||||
await WriteAdminAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
@@ -624,19 +745,64 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
"authority.admin.users.disable",
|
||||
AuthEventOutcome.Success,
|
||||
null,
|
||||
BuildProperties(("user.id", userId)),
|
||||
BuildProperties(("tenant.id", tenantId), ("user.id", summary.Id)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new { message = "User disable: implementation pending" });
|
||||
return Results.Ok(summary);
|
||||
}
|
||||
|
||||
private static async Task<IResult> EnableUser(
|
||||
HttpContext httpContext,
|
||||
string userId,
|
||||
IUserRepository userRepository,
|
||||
IRoleRepository roleRepository,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var user = await ResolveUserEntityAsync(tenantId, userId, userRepository, cancellationToken).ConfigureAwait(false);
|
||||
if (user is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "user_not_found", userId });
|
||||
}
|
||||
|
||||
var updatedUser = new UserEntity
|
||||
{
|
||||
Id = user.Id,
|
||||
TenantId = user.TenantId,
|
||||
Username = user.Username,
|
||||
Email = user.Email,
|
||||
DisplayName = user.DisplayName,
|
||||
PasswordHash = user.PasswordHash,
|
||||
PasswordSalt = user.PasswordSalt,
|
||||
Enabled = true,
|
||||
EmailVerified = user.EmailVerified,
|
||||
MfaEnabled = user.MfaEnabled,
|
||||
MfaSecret = user.MfaSecret,
|
||||
MfaBackupCodes = user.MfaBackupCodes,
|
||||
FailedLoginAttempts = 0,
|
||||
LockedUntil = null,
|
||||
LastLoginAt = user.LastLoginAt,
|
||||
PasswordChangedAt = user.PasswordChangedAt,
|
||||
Settings = user.Settings,
|
||||
Metadata = user.Metadata,
|
||||
CreatedAt = user.CreatedAt,
|
||||
UpdatedAt = timeProvider.GetUtcNow(),
|
||||
CreatedBy = user.CreatedBy,
|
||||
};
|
||||
|
||||
var persisted = await userRepository.UpdateAsync(updatedUser, cancellationToken).ConfigureAwait(false);
|
||||
if (!persisted)
|
||||
{
|
||||
return Results.Problem("Failed to enable user.");
|
||||
}
|
||||
|
||||
var summary = ToAdminUserSummary(
|
||||
updatedUser,
|
||||
timeProvider.GetUtcNow(),
|
||||
await ResolveUserRolesAsync(updatedUser, roleRepository, cancellationToken).ConfigureAwait(false));
|
||||
|
||||
await WriteAdminAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
@@ -644,10 +810,10 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
"authority.admin.users.enable",
|
||||
AuthEventOutcome.Success,
|
||||
null,
|
||||
BuildProperties(("user.id", userId)),
|
||||
BuildProperties(("tenant.id", tenantId), ("user.id", summary.Id)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new { message = "User enable: implementation pending" });
|
||||
return Results.Ok(summary);
|
||||
}
|
||||
|
||||
// ========== ROLE ENDPOINTS ==========
|
||||
@@ -796,10 +962,101 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
HttpContext httpContext,
|
||||
string roleId,
|
||||
UpdateRoleRequest request,
|
||||
IRoleRepository roleRepository,
|
||||
IPermissionRepository permissionRepository,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var role = await ResolveRoleEntityAsync(tenantId, roleId, roleRepository, cancellationToken).ConfigureAwait(false);
|
||||
if (role is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "role_not_found", roleId });
|
||||
}
|
||||
|
||||
if (role.IsSystem)
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
error = "built_in_role_read_only",
|
||||
roleId = role.Name,
|
||||
message = "Built-in roles cannot be edited from the setup console. Create a custom role instead.",
|
||||
});
|
||||
}
|
||||
|
||||
IReadOnlyList<string>? normalizedScopes = null;
|
||||
if (request.Scopes is not null)
|
||||
{
|
||||
normalizedScopes = NormalizeScopes(request.Scopes);
|
||||
if (normalizedScopes.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = "scopes_required" });
|
||||
}
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var updatedRole = new RoleEntity
|
||||
{
|
||||
Id = role.Id,
|
||||
TenantId = role.TenantId,
|
||||
Name = role.Name,
|
||||
DisplayName = NormalizeOptional(request.DisplayName) ?? role.DisplayName,
|
||||
Description = NormalizeOptional(request.DisplayName) ?? role.Description ?? role.DisplayName,
|
||||
IsSystem = role.IsSystem,
|
||||
Metadata = role.Metadata,
|
||||
CreatedAt = role.CreatedAt,
|
||||
UpdatedAt = now,
|
||||
};
|
||||
|
||||
await roleRepository.UpdateAsync(tenantId, updatedRole, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
IReadOnlyList<string> effectiveScopes;
|
||||
if (normalizedScopes is not null)
|
||||
{
|
||||
var currentPermissions = await permissionRepository.GetRolePermissionsAsync(tenantId, role.Id, cancellationToken).ConfigureAwait(false);
|
||||
var currentByName = currentPermissions.ToDictionary(static permission => permission.Name, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var permission in currentPermissions.Where(permission => !normalizedScopes.Contains(permission.Name, StringComparer.OrdinalIgnoreCase)))
|
||||
{
|
||||
await permissionRepository.RemoveFromRoleAsync(tenantId, role.Id, permission.Id, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
foreach (var scope in normalizedScopes.Where(scope => !currentByName.ContainsKey(scope)))
|
||||
{
|
||||
var permission = await permissionRepository.GetByNameAsync(tenantId, scope, cancellationToken).ConfigureAwait(false);
|
||||
var permissionId = permission?.Id ?? await permissionRepository.CreateAsync(tenantId, new PermissionEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
Name = scope,
|
||||
Resource = ExtractPermissionResource(scope),
|
||||
Action = ExtractPermissionAction(scope),
|
||||
Description = $"Console-admin maintained scope '{scope}'.",
|
||||
CreatedAt = now,
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await permissionRepository.AssignToRoleAsync(tenantId, role.Id, permissionId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
effectiveScopes = normalizedScopes;
|
||||
}
|
||||
else
|
||||
{
|
||||
effectiveScopes = (await permissionRepository.GetRolePermissionsAsync(tenantId, role.Id, cancellationToken).ConfigureAwait(false))
|
||||
.Select(static permission => permission.Name)
|
||||
.OrderBy(static scope => scope, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
var summary = new AdminRoleSummary(
|
||||
Id: updatedRole.Id.ToString("N"),
|
||||
Name: updatedRole.Name,
|
||||
Description: string.IsNullOrWhiteSpace(updatedRole.Description) ? updatedRole.Name : updatedRole.Description!,
|
||||
Permissions: effectiveScopes,
|
||||
UserCount: 0,
|
||||
IsBuiltIn: false);
|
||||
|
||||
await WriteAdminAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
@@ -807,19 +1064,44 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
"authority.admin.roles.update",
|
||||
AuthEventOutcome.Success,
|
||||
null,
|
||||
BuildProperties(("role.id", roleId)),
|
||||
BuildProperties(("tenant.id", tenantId), ("role.id", updatedRole.Name)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new { message = "Role update: implementation pending" });
|
||||
return Results.Ok(summary);
|
||||
}
|
||||
|
||||
private static async Task<IResult> PreviewRoleImpact(
|
||||
HttpContext httpContext,
|
||||
string roleId,
|
||||
IRoleRepository roleRepository,
|
||||
IUserRepository userRepository,
|
||||
IAuthEventSink auditSink,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
var role = await ResolveRoleEntityAsync(tenantId, roleId, roleRepository, cancellationToken).ConfigureAwait(false);
|
||||
if (role is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "role_not_found", roleId });
|
||||
}
|
||||
|
||||
var users = await userRepository.GetAllAsync(
|
||||
tenantId,
|
||||
enabled: null,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var affectedUsers = 0;
|
||||
foreach (var user in users)
|
||||
{
|
||||
var roles = await ResolveUserRolesAsync(user, roleRepository, cancellationToken).ConfigureAwait(false);
|
||||
if (roles.Contains(role.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
affectedUsers++;
|
||||
}
|
||||
}
|
||||
|
||||
await WriteAdminAuditAsync(
|
||||
httpContext,
|
||||
auditSink,
|
||||
@@ -827,10 +1109,17 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
"authority.admin.roles.preview",
|
||||
AuthEventOutcome.Success,
|
||||
null,
|
||||
BuildProperties(("role.id", roleId)),
|
||||
BuildProperties(("tenant.id", tenantId), ("role.id", role.Name)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new { affectedUsers = 0, affectedClients = 0, message = "Impact preview: implementation pending" });
|
||||
return Results.Ok(new
|
||||
{
|
||||
affectedUsers,
|
||||
affectedClients = 0,
|
||||
message = affectedUsers == 0
|
||||
? "No users are currently assigned to this role."
|
||||
: $"{affectedUsers} user{(affectedUsers == 1 ? string.Empty : "s")} would be affected by changes to this role.",
|
||||
});
|
||||
}
|
||||
|
||||
// ========== CLIENT ENDPOINTS ==========
|
||||
@@ -1260,6 +1549,74 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
return "default";
|
||||
}
|
||||
|
||||
private static async Task<UserEntity?> ResolveUserEntityAsync(
|
||||
string tenantId,
|
||||
string userId,
|
||||
IUserRepository userRepository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (Guid.TryParse(userId, out var parsedId))
|
||||
{
|
||||
var byGuid = await userRepository.GetByIdAsync(tenantId, parsedId, cancellationToken).ConfigureAwait(false);
|
||||
if (byGuid is not null)
|
||||
{
|
||||
return byGuid;
|
||||
}
|
||||
}
|
||||
|
||||
var bySubject = await userRepository.GetBySubjectIdAsync(tenantId, userId, cancellationToken).ConfigureAwait(false);
|
||||
if (bySubject is not null)
|
||||
{
|
||||
return bySubject;
|
||||
}
|
||||
|
||||
return await userRepository.GetByUsernameAsync(tenantId, userId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<RoleEntity?> ResolveRoleEntityAsync(
|
||||
string tenantId,
|
||||
string roleId,
|
||||
IRoleRepository roleRepository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (Guid.TryParse(roleId, out var parsedId))
|
||||
{
|
||||
var byGuid = await roleRepository.GetByIdAsync(tenantId, parsedId, cancellationToken).ConfigureAwait(false);
|
||||
if (byGuid is not null)
|
||||
{
|
||||
return byGuid;
|
||||
}
|
||||
}
|
||||
|
||||
return await roleRepository.GetByNameAsync(tenantId, roleId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<RoleEntity>> ResolveRequestedRolesAsync(
|
||||
string tenantId,
|
||||
IReadOnlyList<string> requestedRoles,
|
||||
IRoleRepository roleRepository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var resolved = new List<RoleEntity>(requestedRoles.Count);
|
||||
foreach (var roleName in requestedRoles)
|
||||
{
|
||||
var role = await roleRepository.GetByNameAsync(tenantId, roleName, cancellationToken).ConfigureAwait(false);
|
||||
if (role is not null)
|
||||
{
|
||||
resolved.Add(role);
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private static string UpdateUserMetadata(string metadataJson, IReadOnlyList<string> roles)
|
||||
{
|
||||
var metadata = ParseMetadata(metadataJson);
|
||||
metadata["roles"] = roles;
|
||||
return JsonSerializer.Serialize(metadata);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeRoles(IReadOnlyList<string>? roles)
|
||||
{
|
||||
if (roles is null || roles.Count == 0)
|
||||
|
||||
@@ -57,6 +57,14 @@ public sealed record RegisterAdministrationTrustIssuerRequest(
|
||||
string IssuerUri,
|
||||
string TrustLevel);
|
||||
|
||||
public sealed record BlockAdministrationTrustIssuerRequest(
|
||||
string Reason,
|
||||
string? Ticket);
|
||||
|
||||
public sealed record UnblockAdministrationTrustIssuerRequest(
|
||||
string? TrustLevel,
|
||||
string? Ticket);
|
||||
|
||||
public sealed record RegisterAdministrationTrustCertificateRequest(
|
||||
Guid? KeyId,
|
||||
Guid? IssuerId,
|
||||
|
||||
@@ -226,6 +226,72 @@ public static class AdministrationTrustSigningMutationEndpoints
|
||||
.WithSummary("Register trust issuer")
|
||||
.RequireAuthorization(PlatformPolicies.TrustWrite);
|
||||
|
||||
group.MapPost("/issuers/{issuerId:guid}/block", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IAdministrationTrustSigningStore store,
|
||||
Guid issuerId,
|
||||
BlockAdministrationTrustIssuerRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var updated = await store.BlockIssuerAsync(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
issuerId,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(updated);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return MapStoreError(ex, keyId: null, certificateId: null);
|
||||
}
|
||||
})
|
||||
.WithName("BlockAdministrationTrustIssuer")
|
||||
.WithSummary("Block trust issuer")
|
||||
.RequireAuthorization(PlatformPolicies.TrustAdmin);
|
||||
|
||||
group.MapPost("/issuers/{issuerId:guid}/unblock", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IAdministrationTrustSigningStore store,
|
||||
Guid issuerId,
|
||||
UnblockAdministrationTrustIssuerRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var updated = await store.UnblockIssuerAsync(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
issuerId,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(updated);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return MapStoreError(ex, keyId: null, certificateId: null);
|
||||
}
|
||||
})
|
||||
.WithName("UnblockAdministrationTrustIssuer")
|
||||
.WithSummary("Unblock trust issuer")
|
||||
.RequireAuthorization(PlatformPolicies.TrustAdmin);
|
||||
|
||||
group.MapGet("/certificates", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
|
||||
@@ -42,6 +42,20 @@ public interface IAdministrationTrustSigningStore
|
||||
RegisterAdministrationTrustIssuerRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AdministrationTrustIssuerSummary> BlockIssuerAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid issuerId,
|
||||
BlockAdministrationTrustIssuerRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AdministrationTrustIssuerSummary> UnblockIssuerAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid issuerId,
|
||||
UnblockAdministrationTrustIssuerRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<AdministrationTrustCertificateSummary>> ListCertificatesAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
|
||||
@@ -214,6 +214,66 @@ public sealed class InMemoryAdministrationTrustSigningStore : IAdministrationTru
|
||||
}
|
||||
}
|
||||
|
||||
public Task<AdministrationTrustIssuerSummary> BlockIssuerAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid issuerId,
|
||||
BlockAdministrationTrustIssuerRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
_ = NormalizeRequired(request.Reason, "reason_required");
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
if (!state.Issuers.TryGetValue(issuerId, out var issuer))
|
||||
{
|
||||
throw new InvalidOperationException("issuer_not_found");
|
||||
}
|
||||
|
||||
issuer.TrustLevel = "blocked";
|
||||
issuer.Status = "blocked";
|
||||
issuer.UpdatedAt = now;
|
||||
issuer.UpdatedBy = actor;
|
||||
return Task.FromResult(ToSummary(issuer));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<AdministrationTrustIssuerSummary> UnblockIssuerAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid issuerId,
|
||||
UnblockAdministrationTrustIssuerRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
var trustLevel = NormalizeOptional(request.TrustLevel) ?? "minimal";
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
if (!state.Issuers.TryGetValue(issuerId, out var issuer))
|
||||
{
|
||||
throw new InvalidOperationException("issuer_not_found");
|
||||
}
|
||||
|
||||
issuer.TrustLevel = trustLevel;
|
||||
issuer.Status = "active";
|
||||
issuer.UpdatedAt = now;
|
||||
issuer.UpdatedBy = actor;
|
||||
return Task.FromResult(ToSummary(issuer));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AdministrationTrustCertificateSummary>> ListCertificatesAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
|
||||
@@ -390,6 +390,109 @@ public sealed class PostgresAdministrationTrustSigningStore : IAdministrationTru
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AdministrationTrustIssuerSummary> BlockIssuerAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid issuerId,
|
||||
BlockAdministrationTrustIssuerRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
_ = NormalizeRequired(request.Reason, "reason_required");
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
UPDATE release.trust_issuers
|
||||
SET
|
||||
trust_level = 'blocked',
|
||||
status = 'blocked',
|
||||
updated_at = @updated_at,
|
||||
updated_by = @updated_by
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
RETURNING
|
||||
id,
|
||||
issuer_name,
|
||||
issuer_uri,
|
||||
trust_level,
|
||||
status,
|
||||
created_at,
|
||||
updated_at,
|
||||
updated_by
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("id", issuerId);
|
||||
command.Parameters.AddWithValue("updated_at", now);
|
||||
command.Parameters.AddWithValue("updated_by", actor);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
throw new InvalidOperationException("issuer_not_found");
|
||||
}
|
||||
|
||||
return MapIssuerSummary(reader);
|
||||
}
|
||||
|
||||
public async Task<AdministrationTrustIssuerSummary> UnblockIssuerAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid issuerId,
|
||||
UnblockAdministrationTrustIssuerRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (request is null) throw new InvalidOperationException("request_required");
|
||||
|
||||
var trustLevel = NormalizeOptional(request.TrustLevel) ?? "minimal";
|
||||
var actor = NormalizeActor(actorId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var tenantGuid = TenantStorageKey.ParseTenantGuid(tenantId);
|
||||
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
UPDATE release.trust_issuers
|
||||
SET
|
||||
trust_level = @trust_level,
|
||||
status = 'active',
|
||||
updated_at = @updated_at,
|
||||
updated_by = @updated_by
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
RETURNING
|
||||
id,
|
||||
issuer_name,
|
||||
issuer_uri,
|
||||
trust_level,
|
||||
status,
|
||||
created_at,
|
||||
updated_at,
|
||||
updated_by
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("id", issuerId);
|
||||
command.Parameters.AddWithValue("trust_level", trustLevel);
|
||||
command.Parameters.AddWithValue("updated_at", now);
|
||||
command.Parameters.AddWithValue("updated_by", actor);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
throw new InvalidOperationException("issuer_not_found");
|
||||
}
|
||||
|
||||
return MapIssuerSummary(reader);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AdministrationTrustCertificateSummary>> ListCertificatesAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
|
||||
@@ -64,6 +64,31 @@ public sealed class AdministrationTrustSigningMutationEndpointsTests : IClassFix
|
||||
var issuer = await issuerResponse.Content.ReadFromJsonAsync<AdministrationTrustIssuerSummary>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(issuer);
|
||||
Assert.Equal("active", issuer!.Status);
|
||||
|
||||
var blockIssuerResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/administration/trust-signing/issuers/{issuer.IssuerId}/block",
|
||||
new BlockAdministrationTrustIssuerRequest("publisher compromised", "IR-51"),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, blockIssuerResponse.StatusCode);
|
||||
var blockedIssuer = await blockIssuerResponse.Content.ReadFromJsonAsync<AdministrationTrustIssuerSummary>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(blockedIssuer);
|
||||
Assert.Equal("blocked", blockedIssuer!.TrustLevel);
|
||||
Assert.Equal("blocked", blockedIssuer.Status);
|
||||
|
||||
var unblockIssuerResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/administration/trust-signing/issuers/{issuer.IssuerId}/unblock",
|
||||
new UnblockAdministrationTrustIssuerRequest("partial", "IR-52"),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, unblockIssuerResponse.StatusCode);
|
||||
var unblockedIssuer = await unblockIssuerResponse.Content.ReadFromJsonAsync<AdministrationTrustIssuerSummary>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(unblockedIssuer);
|
||||
Assert.Equal("partial", unblockedIssuer!.TrustLevel);
|
||||
Assert.Equal("active", unblockedIssuer.Status);
|
||||
|
||||
var certificateResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/administration/trust-signing/certificates",
|
||||
@@ -194,6 +219,8 @@ public sealed class AdministrationTrustSigningMutationEndpointsTests : IClassFix
|
||||
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/keys/{keyId:guid}/rotate", "POST", PlatformPolicies.TrustWrite);
|
||||
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/keys/{keyId:guid}/revoke", "POST", PlatformPolicies.TrustAdmin);
|
||||
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/issuers", "POST", PlatformPolicies.TrustWrite);
|
||||
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/issuers/{issuerId:guid}/block", "POST", PlatformPolicies.TrustAdmin);
|
||||
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/issuers/{issuerId:guid}/unblock", "POST", PlatformPolicies.TrustAdmin);
|
||||
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/certificates/{certificateId:guid}/revoke", "POST", PlatformPolicies.TrustAdmin);
|
||||
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/transparency-log", "GET", PlatformPolicies.TrustRead);
|
||||
AssertPolicy(endpoints, "/api/v1/administration/trust-signing/transparency-log", "PUT", PlatformPolicies.TrustAdmin);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -76,18 +76,39 @@ export interface CreateUserRequest {
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
displayName?: string;
|
||||
roles?: string[];
|
||||
}
|
||||
|
||||
export interface CreateRoleRequest {
|
||||
name: string;
|
||||
description: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface UpdateRoleRequest {
|
||||
description?: string;
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
export interface CreateTenantRequest {
|
||||
id: string;
|
||||
displayName: string;
|
||||
isolationMode: string;
|
||||
}
|
||||
|
||||
export interface UpdateTenantRequest {
|
||||
displayName?: string;
|
||||
isolationMode?: string;
|
||||
}
|
||||
|
||||
export interface RoleImpactPreview {
|
||||
affectedUsers: number;
|
||||
affectedClients: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface AuthorityAdminApi {
|
||||
listUsers(tenantId?: string): Observable<AdminUser[]>;
|
||||
listRoles(tenantId?: string): Observable<AdminRole[]>;
|
||||
@@ -95,8 +116,16 @@ export interface AuthorityAdminApi {
|
||||
listTokens(tenantId?: string): Observable<AdminToken[]>;
|
||||
listTenants(): Observable<AdminTenant[]>;
|
||||
createUser(request: CreateUserRequest): Observable<AdminUser>;
|
||||
updateUser(userId: string, request: UpdateUserRequest): Observable<AdminUser>;
|
||||
disableUser(userId: string): Observable<AdminUser>;
|
||||
enableUser(userId: string): Observable<AdminUser>;
|
||||
createRole(request: CreateRoleRequest): Observable<AdminRole>;
|
||||
updateRole(roleId: string, request: UpdateRoleRequest): Observable<AdminRole>;
|
||||
previewRoleImpact(roleId: string): Observable<RoleImpactPreview>;
|
||||
createTenant(request: CreateTenantRequest): Observable<AdminTenant>;
|
||||
updateTenant(tenantId: string, request: UpdateTenantRequest): Observable<AdminTenant>;
|
||||
suspendTenant(tenantId: string): Observable<AdminTenant>;
|
||||
resumeTenant(tenantId: string): Observable<AdminTenant>;
|
||||
}
|
||||
|
||||
export const AUTHORITY_ADMIN_API = new InjectionToken<AuthorityAdminApi>('AUTHORITY_ADMIN_API');
|
||||
@@ -226,6 +255,24 @@ export class AuthorityAdminHttpClient implements AuthorityAdminApi {
|
||||
}).pipe(map((user) => this.mapUser(user)));
|
||||
}
|
||||
|
||||
updateUser(userId: string, request: UpdateUserRequest): Observable<AdminUser> {
|
||||
return this.http.patch<AdminUserDto>(`${this.baseUrl}/users/${encodeURIComponent(userId)}`, request, {
|
||||
headers: this.buildHeaders(),
|
||||
}).pipe(map((user) => this.mapUser(user)));
|
||||
}
|
||||
|
||||
disableUser(userId: string): Observable<AdminUser> {
|
||||
return this.http.post<AdminUserDto>(`${this.baseUrl}/users/${encodeURIComponent(userId)}/disable`, {}, {
|
||||
headers: this.buildHeaders(),
|
||||
}).pipe(map((user) => this.mapUser(user)));
|
||||
}
|
||||
|
||||
enableUser(userId: string): Observable<AdminUser> {
|
||||
return this.http.post<AdminUserDto>(`${this.baseUrl}/users/${encodeURIComponent(userId)}/enable`, {}, {
|
||||
headers: this.buildHeaders(),
|
||||
}).pipe(map((user) => this.mapUser(user)));
|
||||
}
|
||||
|
||||
createRole(request: CreateRoleRequest): Observable<AdminRole> {
|
||||
return this.http.post<AdminRoleDto>(`${this.baseUrl}/roles`, {
|
||||
roleId: request.name,
|
||||
@@ -236,6 +283,21 @@ export class AuthorityAdminHttpClient implements AuthorityAdminApi {
|
||||
}).pipe(map((role) => this.mapRole(role)));
|
||||
}
|
||||
|
||||
updateRole(roleId: string, request: UpdateRoleRequest): Observable<AdminRole> {
|
||||
return this.http.patch<AdminRoleDto>(`${this.baseUrl}/roles/${encodeURIComponent(roleId)}`, {
|
||||
displayName: request.description,
|
||||
scopes: request.permissions,
|
||||
}, {
|
||||
headers: this.buildHeaders(),
|
||||
}).pipe(map((role) => this.mapRole(role)));
|
||||
}
|
||||
|
||||
previewRoleImpact(roleId: string): Observable<RoleImpactPreview> {
|
||||
return this.http.post<RoleImpactPreview>(`${this.baseUrl}/roles/${encodeURIComponent(roleId)}/preview-impact`, {}, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
createTenant(request: CreateTenantRequest): Observable<AdminTenant> {
|
||||
return this.http.post<AdminTenantDto>(`${this.baseUrl}/tenants`, {
|
||||
id: request.id,
|
||||
@@ -246,6 +308,24 @@ export class AuthorityAdminHttpClient implements AuthorityAdminApi {
|
||||
}).pipe(map((tenant) => this.mapTenant(tenant)));
|
||||
}
|
||||
|
||||
updateTenant(tenantId: string, request: UpdateTenantRequest): Observable<AdminTenant> {
|
||||
return this.http.patch<AdminTenantDto>(`${this.baseUrl}/tenants/${encodeURIComponent(tenantId)}`, request, {
|
||||
headers: this.buildHeaders(),
|
||||
}).pipe(map((tenant) => this.mapTenant(tenant)));
|
||||
}
|
||||
|
||||
suspendTenant(tenantId: string): Observable<AdminTenant> {
|
||||
return this.http.post<AdminTenantDto>(`${this.baseUrl}/tenants/${encodeURIComponent(tenantId)}/suspend`, {}, {
|
||||
headers: this.buildHeaders(),
|
||||
}).pipe(map((tenant) => this.mapTenant(tenant)));
|
||||
}
|
||||
|
||||
resumeTenant(tenantId: string): Observable<AdminTenant> {
|
||||
return this.http.post<AdminTenantDto>(`${this.baseUrl}/tenants/${encodeURIComponent(tenantId)}/resume`, {}, {
|
||||
headers: this.buildHeaders(),
|
||||
}).pipe(map((tenant) => this.mapTenant(tenant)));
|
||||
}
|
||||
|
||||
private buildHeaders(tenantOverride?: string): HttpHeaders {
|
||||
const tenantId =
|
||||
(tenantOverride && tenantOverride.trim()) ||
|
||||
@@ -343,26 +423,31 @@ export class AuthorityAdminHttpClient implements AuthorityAdminApi {
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockAuthorityAdminClient implements AuthorityAdminApi {
|
||||
private readonly usersData: AdminUser[] = [
|
||||
{ id: 'u-1', username: 'admin', email: 'admin@stella-ops.local', displayName: 'Platform Admin', roles: ['role/console-admin'], status: 'active', createdAt: '2026-01-01T00:00:00Z', lastLoginAt: '2026-02-15T10:30:00Z' },
|
||||
{ id: 'u-2', username: 'jane.smith', email: 'jane.smith@example.com', displayName: 'Jane Smith', roles: ['role/console-viewer'], status: 'active', createdAt: '2026-01-10T00:00:00Z', lastLoginAt: '2026-02-14T15:00:00Z' },
|
||||
{ id: 'u-3', username: 'bob.wilson', email: 'bob.wilson@example.com', displayName: 'Bob Wilson', roles: ['role/scanner-operator'], status: 'active', createdAt: '2026-01-15T00:00:00Z' },
|
||||
{ id: 'u-4', username: 'svc-scanner', email: 'scanner@stella-ops.local', displayName: 'Scanner Service', roles: ['role/scanner-admin'], status: 'active', createdAt: '2026-01-01T00:00:00Z' },
|
||||
{ id: 'u-5', username: 'alice.johnson', email: 'alice@example.com', displayName: 'Alice Johnson', roles: ['role/release-operator', 'role/console-viewer'], status: 'disabled', createdAt: '2026-01-20T00:00:00Z' },
|
||||
];
|
||||
private readonly rolesData: AdminRole[] = [
|
||||
{ id: 'role/console-admin', name: 'role/console-admin', description: 'Full platform administration for setup, access, and audit controls.', permissions: ['ui.admin', 'authority:users.write', 'authority:roles.write', 'authority:tenants.write'], userCount: 1, isBuiltIn: true },
|
||||
{ id: 'role/console-viewer', name: 'role/console-viewer', description: 'Least-privilege console access for viewing setup state without making changes.', permissions: ['ui.read', 'authority:users.read', 'authority:roles.read', 'authority:tenants.read'], userCount: 2, isBuiltIn: true },
|
||||
{ id: 'role/release-operator', name: 'role/release-operator', description: 'Operate releases, promotions, and deployment approvals.', permissions: ['release:read', 'release:write', 'release:publish', 'policy:read'], userCount: 1, isBuiltIn: true },
|
||||
{ id: 'role/scanner-operator', name: 'role/scanner-operator', description: 'Run scans and review findings without changing platform policy.', permissions: ['scanner:read', 'scanner:scan', 'findings:read'], userCount: 1, isBuiltIn: true },
|
||||
{ id: 'role/scanner-admin', name: 'role/scanner-admin', description: 'Administer scanner configuration and scanner-side exports.', permissions: ['scanner:read', 'scanner:scan', 'scanner:write', 'scanner:export'], userCount: 1, isBuiltIn: true },
|
||||
];
|
||||
private readonly tenantsData: AdminTenant[] = [
|
||||
{ id: 'default', displayName: 'Default', status: 'active', isolationMode: 'shared', userCount: 5, createdAt: '2026-01-01T00:00:00Z' },
|
||||
{ id: 'production', displayName: 'Production Tenant', status: 'active', isolationMode: 'dedicated', userCount: 3, createdAt: '2026-01-10T00:00:00Z' },
|
||||
];
|
||||
|
||||
listUsers(): Observable<AdminUser[]> {
|
||||
const data: AdminUser[] = [
|
||||
{ id: 'u-1', username: 'admin', email: 'admin@stella-ops.local', displayName: 'Platform Admin', roles: ['admin', 'operator'], status: 'active', createdAt: '2026-01-01T00:00:00Z', lastLoginAt: '2026-02-15T10:30:00Z' },
|
||||
{ id: 'u-2', username: 'jane.smith', email: 'jane.smith@example.com', displayName: 'Jane Smith', roles: ['reviewer'], status: 'active', createdAt: '2026-01-10T00:00:00Z', lastLoginAt: '2026-02-14T15:00:00Z' },
|
||||
{ id: 'u-3', username: 'bob.wilson', email: 'bob.wilson@example.com', displayName: 'Bob Wilson', roles: ['developer'], status: 'active', createdAt: '2026-01-15T00:00:00Z' },
|
||||
{ id: 'u-4', username: 'svc-scanner', email: 'scanner@stella-ops.local', displayName: 'Scanner Service', roles: ['service'], status: 'active', createdAt: '2026-01-01T00:00:00Z' },
|
||||
{ id: 'u-5', username: 'alice.johnson', email: 'alice@example.com', displayName: 'Alice Johnson', roles: ['operator', 'reviewer'], status: 'disabled', createdAt: '2026-01-20T00:00:00Z' },
|
||||
];
|
||||
return of(data).pipe(delay(300));
|
||||
return of(this.usersData.map((user) => ({ ...user, roles: [...user.roles] }))).pipe(delay(300));
|
||||
}
|
||||
|
||||
listRoles(): Observable<AdminRole[]> {
|
||||
const data: AdminRole[] = [
|
||||
{ id: 'r-1', name: 'admin', description: 'Full platform administrator', permissions: ['*'], userCount: 1, isBuiltIn: true },
|
||||
{ id: 'r-2', name: 'operator', description: 'Manage releases and deployments', permissions: ['release:*', 'deploy:*'], userCount: 2, isBuiltIn: true },
|
||||
{ id: 'r-3', name: 'reviewer', description: 'Review and approve promotions', permissions: ['approval:read', 'approval:approve', 'release:read'], userCount: 2, isBuiltIn: true },
|
||||
{ id: 'r-4', name: 'developer', description: 'Read-only access to releases and security', permissions: ['release:read', 'security:read'], userCount: 1, isBuiltIn: false },
|
||||
{ id: 'r-5', name: 'service', description: 'Machine-to-machine service account', permissions: ['scanner:write', 'findings:write'], userCount: 1, isBuiltIn: true },
|
||||
];
|
||||
return of(data).pipe(delay(300));
|
||||
return of(this.rolesWithCounts()).pipe(delay(300));
|
||||
}
|
||||
|
||||
listClients(): Observable<AdminClient[]> {
|
||||
@@ -384,11 +469,7 @@ export class MockAuthorityAdminClient implements AuthorityAdminApi {
|
||||
}
|
||||
|
||||
listTenants(): Observable<AdminTenant[]> {
|
||||
const data: AdminTenant[] = [
|
||||
{ id: 'tn-1', displayName: 'Default', status: 'active', isolationMode: 'shared', userCount: 5, createdAt: '2026-01-01T00:00:00Z' },
|
||||
{ id: 'tn-2', displayName: 'Production Tenant', status: 'active', isolationMode: 'dedicated', userCount: 3, createdAt: '2026-01-10T00:00:00Z' },
|
||||
];
|
||||
return of(data).pipe(delay(300));
|
||||
return of(this.tenantsData.map((tenant) => ({ ...tenant }))).pipe(delay(300));
|
||||
}
|
||||
|
||||
createUser(request: CreateUserRequest): Observable<AdminUser> {
|
||||
@@ -401,21 +482,75 @@ export class MockAuthorityAdminClient implements AuthorityAdminApi {
|
||||
status: 'active',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
this.usersData.push(user);
|
||||
return of(user).pipe(delay(400));
|
||||
}
|
||||
|
||||
updateUser(userId: string, request: UpdateUserRequest): Observable<AdminUser> {
|
||||
const user = this.requireUser(userId);
|
||||
const updated: AdminUser = {
|
||||
...user,
|
||||
displayName: request.displayName?.trim() || user.displayName,
|
||||
roles: request.roles ? [...request.roles] : user.roles,
|
||||
};
|
||||
this.replaceUser(updated);
|
||||
return of(updated).pipe(delay(300));
|
||||
}
|
||||
|
||||
disableUser(userId: string): Observable<AdminUser> {
|
||||
const updated: AdminUser = {
|
||||
...this.requireUser(userId),
|
||||
status: 'disabled',
|
||||
};
|
||||
this.replaceUser(updated);
|
||||
return of(updated).pipe(delay(250));
|
||||
}
|
||||
|
||||
enableUser(userId: string): Observable<AdminUser> {
|
||||
const updated: AdminUser = {
|
||||
...this.requireUser(userId),
|
||||
status: 'active',
|
||||
};
|
||||
this.replaceUser(updated);
|
||||
return of(updated).pipe(delay(250));
|
||||
}
|
||||
|
||||
createRole(request: CreateRoleRequest): Observable<AdminRole> {
|
||||
const role: AdminRole = {
|
||||
id: `r-${Date.now()}`,
|
||||
id: request.name,
|
||||
name: request.name,
|
||||
description: request.description,
|
||||
permissions: request.permissions,
|
||||
userCount: 0,
|
||||
isBuiltIn: false,
|
||||
};
|
||||
this.rolesData.push(role);
|
||||
return of(role).pipe(delay(400));
|
||||
}
|
||||
|
||||
updateRole(roleId: string, request: UpdateRoleRequest): Observable<AdminRole> {
|
||||
const role = this.requireRole(roleId);
|
||||
const updated: AdminRole = {
|
||||
...role,
|
||||
description: request.description?.trim() || role.description,
|
||||
permissions: request.permissions ? [...request.permissions] : role.permissions,
|
||||
};
|
||||
this.replaceRole(updated);
|
||||
return of(updated).pipe(delay(300));
|
||||
}
|
||||
|
||||
previewRoleImpact(roleId: string): Observable<RoleImpactPreview> {
|
||||
const role = this.requireRole(roleId);
|
||||
const affectedUsers = this.usersData.filter((user) => user.roles.includes(role.name)).length;
|
||||
return of({
|
||||
affectedUsers,
|
||||
affectedClients: 0,
|
||||
message: affectedUsers === 0
|
||||
? 'No users are currently assigned to this role.'
|
||||
: `${affectedUsers} user${affectedUsers === 1 ? '' : 's'} would be affected by changes to this role.`,
|
||||
}).pipe(delay(150));
|
||||
}
|
||||
|
||||
createTenant(request: CreateTenantRequest): Observable<AdminTenant> {
|
||||
const tenant: AdminTenant = {
|
||||
id: request.id,
|
||||
@@ -425,6 +560,92 @@ export class MockAuthorityAdminClient implements AuthorityAdminApi {
|
||||
userCount: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
this.tenantsData.push(tenant);
|
||||
return of(tenant).pipe(delay(400));
|
||||
}
|
||||
|
||||
updateTenant(tenantId: string, request: UpdateTenantRequest): Observable<AdminTenant> {
|
||||
const tenant = this.requireTenant(tenantId);
|
||||
const updated: AdminTenant = {
|
||||
...tenant,
|
||||
displayName: request.displayName?.trim() || tenant.displayName,
|
||||
isolationMode: request.isolationMode?.trim() || tenant.isolationMode,
|
||||
};
|
||||
this.replaceTenant(updated);
|
||||
return of(updated).pipe(delay(300));
|
||||
}
|
||||
|
||||
suspendTenant(tenantId: string): Observable<AdminTenant> {
|
||||
const updated: AdminTenant = {
|
||||
...this.requireTenant(tenantId),
|
||||
status: 'disabled',
|
||||
};
|
||||
this.replaceTenant(updated);
|
||||
return of(updated).pipe(delay(250));
|
||||
}
|
||||
|
||||
resumeTenant(tenantId: string): Observable<AdminTenant> {
|
||||
const updated: AdminTenant = {
|
||||
...this.requireTenant(tenantId),
|
||||
status: 'active',
|
||||
};
|
||||
this.replaceTenant(updated);
|
||||
return of(updated).pipe(delay(250));
|
||||
}
|
||||
|
||||
private rolesWithCounts(): AdminRole[] {
|
||||
return this.rolesData.map((role) => ({
|
||||
...role,
|
||||
permissions: [...role.permissions],
|
||||
userCount: this.usersData.filter((user) => user.roles.includes(role.name)).length,
|
||||
}));
|
||||
}
|
||||
|
||||
private replaceUser(updatedUser: AdminUser): void {
|
||||
const index = this.usersData.findIndex((user) => user.id === updatedUser.id);
|
||||
if (index >= 0) {
|
||||
this.usersData[index] = updatedUser;
|
||||
}
|
||||
}
|
||||
|
||||
private replaceRole(updatedRole: AdminRole): void {
|
||||
const index = this.rolesData.findIndex((role) => role.id === updatedRole.id);
|
||||
if (index >= 0) {
|
||||
this.rolesData[index] = updatedRole;
|
||||
}
|
||||
}
|
||||
|
||||
private replaceTenant(updatedTenant: AdminTenant): void {
|
||||
const index = this.tenantsData.findIndex((tenant) => tenant.id === updatedTenant.id);
|
||||
if (index >= 0) {
|
||||
this.tenantsData[index] = updatedTenant;
|
||||
}
|
||||
}
|
||||
|
||||
private requireUser(userId: string): AdminUser {
|
||||
const user = this.usersData.find((entry) => entry.id === userId);
|
||||
if (!user) {
|
||||
throw new Error(`User not found: ${userId}`);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
private requireRole(roleId: string): AdminRole {
|
||||
const role = this.rolesData.find((entry) => entry.id === roleId || entry.name === roleId);
|
||||
if (!role) {
|
||||
throw new Error(`Role not found: ${roleId}`);
|
||||
}
|
||||
|
||||
return role;
|
||||
}
|
||||
|
||||
private requireTenant(tenantId: string): AdminTenant {
|
||||
const tenant = this.tenantsData.find((entry) => entry.id === tenantId);
|
||||
if (!tenant) {
|
||||
throw new Error(`Tenant not found: ${tenantId}`);
|
||||
}
|
||||
|
||||
return tenant;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,18 +28,11 @@ describe('TrustHttpService', () => {
|
||||
issuers: 7,
|
||||
certificates: 23,
|
||||
});
|
||||
expect(overview.signals).toEqual([
|
||||
{
|
||||
signalId: 'audit-log',
|
||||
status: 'healthy',
|
||||
message: 'Audit log ingestion is current.',
|
||||
},
|
||||
{
|
||||
signalId: 'certificate-expiry',
|
||||
status: 'warning',
|
||||
message: '1 certificate expires within 10 days.',
|
||||
},
|
||||
]);
|
||||
expect(overview.signals[0]).toEqual({
|
||||
signalId: 'audit-log',
|
||||
status: 'healthy',
|
||||
message: 'Audit log ingestion is current.',
|
||||
});
|
||||
expect(overview.evidenceConsumerPath).toBe('/evidence-audit/proofs');
|
||||
});
|
||||
|
||||
@@ -57,43 +50,184 @@ describe('TrustHttpService', () => {
|
||||
status: 'healthy',
|
||||
message: 'Audit log ingestion is current.',
|
||||
},
|
||||
{
|
||||
signalId: 'certificate-expiry',
|
||||
status: 'warning',
|
||||
message: '1 certificate expires within 10 days.',
|
||||
},
|
||||
],
|
||||
legacyAliases: [],
|
||||
evidenceConsumerPath: '/evidence-audit/proofs',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds the trust shell summary from the administration overview', () => {
|
||||
service.getDashboardSummary().subscribe((summary) => {
|
||||
expect(summary.keys.total).toBe(4);
|
||||
expect(summary.issuers.total).toBe(3);
|
||||
expect(summary.certificates.total).toBe(2);
|
||||
expect(summary.certificates.expiringSoon).toBe(1);
|
||||
expect(summary.expiryAlerts).toEqual([]);
|
||||
it('resolves certificate issuer and key labels from live administration inventories', () => {
|
||||
service.listCertificates({ pageNumber: 1, pageSize: 20 }).subscribe((result) => {
|
||||
expect(result.items.length).toBe(1);
|
||||
expect(result.items[0].issuer.commonName).toBe('Core Root CA');
|
||||
expect(result.items[0].subject.commonName).toBe('prod-attestation-k1');
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/administration/trust-signing');
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush({
|
||||
inventory: {
|
||||
keys: 4,
|
||||
issuers: 3,
|
||||
certificates: 2,
|
||||
},
|
||||
signals: [
|
||||
const certificateReq = httpMock.expectOne((request) =>
|
||||
request.url === '/api/v1/administration/trust-signing/certificates');
|
||||
const keyReq = httpMock.expectOne((request) =>
|
||||
request.url === '/api/v1/administration/trust-signing/keys');
|
||||
const issuerReq = httpMock.expectOne((request) =>
|
||||
request.url === '/api/v1/administration/trust-signing/issuers');
|
||||
|
||||
certificateReq.flush({
|
||||
items: [
|
||||
{
|
||||
signalId: 'certificate-expiry',
|
||||
status: 'warning',
|
||||
message: '1 certificate expires within 10 days.',
|
||||
certificateId: 'cert-001',
|
||||
keyId: 'key-001',
|
||||
issuerId: 'issuer-001',
|
||||
serialNumber: 'SER-001',
|
||||
status: 'active',
|
||||
notBefore: '2026-01-01T00:00:00Z',
|
||||
notAfter: '2027-01-01T00:00:00Z',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
updatedBy: 'operator',
|
||||
},
|
||||
],
|
||||
count: 1,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
});
|
||||
keyReq.flush({
|
||||
items: [
|
||||
{
|
||||
keyId: 'key-001',
|
||||
alias: 'prod-attestation-k1',
|
||||
algorithm: 'ed25519',
|
||||
status: 'active',
|
||||
currentVersion: 2,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
updatedBy: 'operator',
|
||||
},
|
||||
],
|
||||
count: 1,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
});
|
||||
issuerReq.flush({
|
||||
items: [
|
||||
{
|
||||
issuerId: 'issuer-001',
|
||||
name: 'Core Root CA',
|
||||
issuerUri: 'https://issuer.example/root',
|
||||
trustLevel: 'high',
|
||||
status: 'active',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
updatedBy: 'operator',
|
||||
},
|
||||
],
|
||||
count: 1,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('derives analytics from administration inventories without calling dead analytics endpoints', () => {
|
||||
const expiringSoon = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
service.getAnalyticsSummary().subscribe((summary) => {
|
||||
expect(summary.overallTrustScore).toBeGreaterThan(0);
|
||||
expect(summary.alerts.some((alert) => alert.category === 'certificate')).toBeTrue();
|
||||
});
|
||||
|
||||
const requests = httpMock.match(() => true);
|
||||
expect(requests.some((request) => request.request.url.startsWith('/api/v1/trust/analytics'))).toBeFalse();
|
||||
|
||||
const overviewReq = requests.find((request) => request.request.url === '/api/v1/administration/trust-signing');
|
||||
const keysReq = requests.filter((request) => request.request.url === '/api/v1/administration/trust-signing/keys');
|
||||
const issuersReq = requests.filter((request) => request.request.url === '/api/v1/administration/trust-signing/issuers');
|
||||
const certificatesReq = requests.filter((request) => request.request.url === '/api/v1/administration/trust-signing/certificates');
|
||||
|
||||
overviewReq!.flush({
|
||||
inventory: { keys: 1, issuers: 1, certificates: 1 },
|
||||
signals: [{ signalId: 'certificate-expiry', status: 'warning', message: 'One certificate expires soon.' }],
|
||||
legacyAliases: [],
|
||||
evidenceConsumerPath: '/evidence/overview',
|
||||
});
|
||||
keysReq.forEach((request) => request.flush({
|
||||
items: [{
|
||||
keyId: 'key-001',
|
||||
alias: 'prod-attestation-k1',
|
||||
algorithm: 'ed25519',
|
||||
status: 'active',
|
||||
currentVersion: 1,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
updatedBy: 'operator',
|
||||
}],
|
||||
count: 1,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
}));
|
||||
issuersReq.forEach((request) => request.flush({
|
||||
items: [{
|
||||
issuerId: 'issuer-001',
|
||||
name: 'Core Root CA',
|
||||
issuerUri: 'https://issuer.example/root',
|
||||
trustLevel: 'partial',
|
||||
status: 'active',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
updatedBy: 'operator',
|
||||
}],
|
||||
count: 1,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
}));
|
||||
certificatesReq.forEach((request) => request.flush({
|
||||
items: [{
|
||||
certificateId: 'cert-001',
|
||||
keyId: 'key-001',
|
||||
issuerId: 'issuer-001',
|
||||
serialNumber: 'SER-001',
|
||||
status: 'active',
|
||||
notBefore: '2026-01-01T00:00:00Z',
|
||||
notAfter: expiringSoon,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
updatedBy: 'operator',
|
||||
}],
|
||||
count: 1,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
}));
|
||||
});
|
||||
|
||||
it('uses administration issuer lifecycle endpoints for block and unblock', () => {
|
||||
service.blockIssuer('issuer-001', { reason: 'publisher compromised' }).subscribe((issuer) => {
|
||||
expect(issuer.trustLevel).toBe('blocked');
|
||||
});
|
||||
const blockReq = httpMock.expectOne('/api/v1/administration/trust-signing/issuers/issuer-001/block');
|
||||
expect(blockReq.request.method).toBe('POST');
|
||||
blockReq.flush({
|
||||
issuerId: 'issuer-001',
|
||||
name: 'Core Root CA',
|
||||
issuerUri: 'https://issuer.example/root',
|
||||
trustLevel: 'blocked',
|
||||
status: 'blocked',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-03T00:00:00Z',
|
||||
updatedBy: 'operator',
|
||||
});
|
||||
|
||||
service.unblockIssuer('issuer-001', { trustLevel: 'partial' }).subscribe((issuer) => {
|
||||
expect(issuer.trustLevel).toBe('partial');
|
||||
expect(issuer.isActive).toBeTrue();
|
||||
});
|
||||
const unblockReq = httpMock.expectOne('/api/v1/administration/trust-signing/issuers/issuer-001/unblock');
|
||||
expect(unblockReq.request.method).toBe('POST');
|
||||
unblockReq.flush({
|
||||
issuerId: 'issuer-001',
|
||||
name: 'Core Root CA',
|
||||
issuerUri: 'https://issuer.example/root',
|
||||
trustLevel: 'partial',
|
||||
status: 'active',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-04T00:00:00Z',
|
||||
updatedBy: 'operator',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,8 +31,14 @@ import {
|
||||
ListAuditEventsParams,
|
||||
PagedResult,
|
||||
RotateKeyRequest,
|
||||
CreateSigningKeyRequest,
|
||||
UpdateIssuerWeightsRequest,
|
||||
BulkUpdateIssuerWeightsRequest,
|
||||
RegisterIssuerRequest,
|
||||
BlockIssuerRequest,
|
||||
UnblockIssuerRequest,
|
||||
RegisterCertificateRequest,
|
||||
RevokeCertificateRequest,
|
||||
VerificationAnalytics,
|
||||
IssuerReliabilityAnalytics,
|
||||
TrustAnalyticsSummary,
|
||||
@@ -61,6 +67,7 @@ export interface TrustApi {
|
||||
getKey(keyId: string): Observable<SigningKey>;
|
||||
getKeyUsageStats(keyId: string): Observable<KeyUsageStats>;
|
||||
getKeyRotationHistory(keyId: string): Observable<readonly KeyRotationHistoryEntry[]>;
|
||||
createKey(request: CreateSigningKeyRequest): Observable<SigningKey>;
|
||||
rotateKey(keyId: string, request: RotateKeyRequest): Observable<SigningKey>;
|
||||
revokeKey(keyId: string, reason: string): Observable<void>;
|
||||
getKeyExpiryAlerts(thresholdDays?: number): Observable<readonly KeyExpiryAlert[]>;
|
||||
@@ -68,11 +75,12 @@ export interface TrustApi {
|
||||
// Issuers
|
||||
listIssuers(params?: ListIssuersParams): Observable<PagedResult<TrustedIssuer>>;
|
||||
getIssuer(issuerId: string): Observable<TrustedIssuer>;
|
||||
registerIssuer(request: RegisterIssuerRequest): Observable<TrustedIssuer>;
|
||||
updateIssuerWeights(request: UpdateIssuerWeightsRequest): Observable<TrustedIssuer>;
|
||||
bulkUpdateIssuerWeights(request: BulkUpdateIssuerWeightsRequest): Observable<readonly TrustedIssuer[]>;
|
||||
previewWeightChange(request: UpdateIssuerWeightsRequest): Observable<IssuerWeightPreview>;
|
||||
blockIssuer(issuerId: string, reason: string): Observable<void>;
|
||||
unblockIssuer(issuerId: string): Observable<TrustedIssuer>;
|
||||
blockIssuer(issuerId: string, request: BlockIssuerRequest): Observable<TrustedIssuer>;
|
||||
unblockIssuer(issuerId: string, request?: UnblockIssuerRequest): Observable<TrustedIssuer>;
|
||||
|
||||
// Trust Score Config
|
||||
getTrustScoreConfig(): Observable<TrustScoreConfig>;
|
||||
@@ -83,6 +91,8 @@ export interface TrustApi {
|
||||
getCertificate(certificateId: string): Observable<Certificate>;
|
||||
getCertificateChain(certificateId: string): Observable<CertificateChain>;
|
||||
verifyCertificateChain(certificateId: string): Observable<CertificateChain>;
|
||||
registerCertificate(request: RegisterCertificateRequest): Observable<Certificate>;
|
||||
revokeCertificate(certificateId: string, request: RevokeCertificateRequest): Observable<Certificate>;
|
||||
getCertificateExpiryAlerts(thresholdDays?: number): Observable<readonly CertificateExpiryAlert[]>;
|
||||
|
||||
// Audit
|
||||
@@ -312,6 +322,17 @@ export class TrustHttpService implements TrustApi {
|
||||
return of([]);
|
||||
}
|
||||
|
||||
createKey(request: CreateSigningKeyRequest): Observable<SigningKey> {
|
||||
return this.http.post<AdministrationTrustKeySummaryDto>(
|
||||
`${this.administrationBaseUrl}/keys`,
|
||||
{
|
||||
alias: request.alias,
|
||||
algorithm: request.algorithm,
|
||||
metadataJson: request.metadataJson?.trim() || null,
|
||||
},
|
||||
).pipe(map((item) => this.mapAdministrationKey(item)));
|
||||
}
|
||||
|
||||
rotateKey(keyId: string, request: RotateKeyRequest): Observable<SigningKey> {
|
||||
return this.http.post<AdministrationTrustKeySummaryDto>(
|
||||
`${this.administrationBaseUrl}/keys/${keyId}/rotate`,
|
||||
@@ -372,6 +393,17 @@ export class TrustHttpService implements TrustApi {
|
||||
);
|
||||
}
|
||||
|
||||
registerIssuer(request: RegisterIssuerRequest): Observable<TrustedIssuer> {
|
||||
return this.http.post<AdministrationTrustIssuerSummaryDto>(
|
||||
`${this.administrationBaseUrl}/issuers`,
|
||||
{
|
||||
name: request.name,
|
||||
issuerUri: request.issuerUri,
|
||||
trustLevel: request.trustLevel,
|
||||
},
|
||||
).pipe(map((item) => this.mapAdministrationIssuer(item)));
|
||||
}
|
||||
|
||||
updateIssuerWeights(request: UpdateIssuerWeightsRequest): Observable<TrustedIssuer> {
|
||||
return this.getIssuer(request.issuerId).pipe(
|
||||
map((issuer) => ({
|
||||
@@ -420,14 +452,24 @@ export class TrustHttpService implements TrustApi {
|
||||
);
|
||||
}
|
||||
|
||||
blockIssuer(issuerId: string, reason: string): Observable<void> {
|
||||
void issuerId;
|
||||
void reason;
|
||||
return of(undefined);
|
||||
blockIssuer(issuerId: string, request: BlockIssuerRequest): Observable<TrustedIssuer> {
|
||||
return this.http.post<AdministrationTrustIssuerSummaryDto>(
|
||||
`${this.administrationBaseUrl}/issuers/${issuerId}/block`,
|
||||
{
|
||||
reason: request.reason,
|
||||
ticket: request.ticket?.trim() || null,
|
||||
},
|
||||
).pipe(map((item) => this.mapAdministrationIssuer(item)));
|
||||
}
|
||||
|
||||
unblockIssuer(issuerId: string): Observable<TrustedIssuer> {
|
||||
return this.getIssuer(issuerId);
|
||||
unblockIssuer(issuerId: string, request: UnblockIssuerRequest = {}): Observable<TrustedIssuer> {
|
||||
return this.http.post<AdministrationTrustIssuerSummaryDto>(
|
||||
`${this.administrationBaseUrl}/issuers/${issuerId}/unblock`,
|
||||
{
|
||||
trustLevel: request.trustLevel?.trim() || null,
|
||||
ticket: request.ticket?.trim() || null,
|
||||
},
|
||||
).pipe(map((item) => this.mapAdministrationIssuer(item)));
|
||||
}
|
||||
|
||||
// Trust Score Config
|
||||
@@ -453,9 +495,20 @@ export class TrustHttpService implements TrustApi {
|
||||
|
||||
// Certificates
|
||||
listCertificates(params: ListCertificatesParams = {}): Observable<PagedResult<Certificate>> {
|
||||
return this.fetchAdministrationCertificates().pipe(
|
||||
map((response) => {
|
||||
let items = (response.items ?? []).map((item) => this.mapAdministrationCertificate(item));
|
||||
return forkJoin({
|
||||
certificates: this.fetchAdministrationCertificates(),
|
||||
keys: this.fetchAdministrationKeys(),
|
||||
issuers: this.fetchAdministrationIssuers(),
|
||||
}).pipe(
|
||||
map(({ certificates, keys, issuers }) => {
|
||||
const keyLookup = new Map((keys.items ?? []).map((item) => [item.keyId?.trim() || '', item]));
|
||||
const issuerLookup = new Map((issuers.items ?? []).map((item) => [item.issuerId?.trim() || '', item]));
|
||||
let items = (certificates.items ?? []).map((item) =>
|
||||
this.mapAdministrationCertificate(
|
||||
item,
|
||||
keyLookup.get(item.keyId?.trim() || ''),
|
||||
issuerLookup.get(item.issuerId?.trim() || ''),
|
||||
));
|
||||
|
||||
if (params.search) {
|
||||
const query = params.search.trim().toLowerCase();
|
||||
@@ -511,6 +564,33 @@ export class TrustHttpService implements TrustApi {
|
||||
return this.getCertificateChain(certificateId);
|
||||
}
|
||||
|
||||
registerCertificate(request: RegisterCertificateRequest): Observable<Certificate> {
|
||||
return this.http.post<AdministrationTrustCertificateSummaryDto>(
|
||||
`${this.administrationBaseUrl}/certificates`,
|
||||
{
|
||||
keyId: request.keyId?.trim() || null,
|
||||
issuerId: request.issuerId?.trim() || null,
|
||||
serialNumber: request.serialNumber,
|
||||
notBefore: request.notBefore,
|
||||
notAfter: request.notAfter,
|
||||
},
|
||||
).pipe(
|
||||
map((item) => this.mapAdministrationCertificate(item)),
|
||||
);
|
||||
}
|
||||
|
||||
revokeCertificate(certificateId: string, request: RevokeCertificateRequest): Observable<Certificate> {
|
||||
return this.http.post<AdministrationTrustCertificateSummaryDto>(
|
||||
`${this.administrationBaseUrl}/certificates/${certificateId}/revoke`,
|
||||
{
|
||||
reason: request.reason,
|
||||
ticket: request.ticket?.trim() || null,
|
||||
},
|
||||
).pipe(
|
||||
map((item) => this.mapAdministrationCertificate(item)),
|
||||
);
|
||||
}
|
||||
|
||||
getCertificateExpiryAlerts(thresholdDays = 30): Observable<readonly CertificateExpiryAlert[]> {
|
||||
return this.listCertificates({ pageNumber: 1, pageSize: 200 }).pipe(
|
||||
map((result) => result.items
|
||||
@@ -615,23 +695,41 @@ export class TrustHttpService implements TrustApi {
|
||||
|
||||
// Analytics
|
||||
getAnalyticsSummary(): Observable<TrustAnalyticsSummary> {
|
||||
return this.http.get<TrustAnalyticsSummary>(`${this.baseUrl}/analytics/summary`);
|
||||
return forkJoin({
|
||||
keys: this.listKeys({ pageNumber: 1, pageSize: 200 }),
|
||||
issuers: this.listIssuers({ pageNumber: 1, pageSize: 200 }),
|
||||
certificates: this.listCertificates({ pageNumber: 1, pageSize: 200 }),
|
||||
overview: this.getAdministrationOverview(),
|
||||
alerts: this.getCertificateExpiryAlerts(30),
|
||||
}).pipe(
|
||||
map(({ keys, issuers, certificates, overview, alerts }) =>
|
||||
this.buildAnalyticsSummary(keys.items, issuers.items, certificates.items, overview, alerts),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
getVerificationAnalytics(params: ListAnalyticsParams = {}): Observable<VerificationAnalytics> {
|
||||
return this.http.get<VerificationAnalytics>(`${this.baseUrl}/analytics/verification`, {
|
||||
params: this.buildParams(params as unknown as Record<string, unknown>),
|
||||
});
|
||||
return forkJoin({
|
||||
keys: this.listKeys({ pageNumber: 1, pageSize: 200 }),
|
||||
issuers: this.listIssuers({ pageNumber: 1, pageSize: 200 }),
|
||||
certificates: this.listCertificates({ pageNumber: 1, pageSize: 200 }),
|
||||
alerts: this.getCertificateExpiryAlerts(30),
|
||||
}).pipe(
|
||||
map(({ keys, issuers, certificates, alerts }) =>
|
||||
this.buildVerificationAnalytics(keys.items, issuers.items, certificates.items, alerts, params),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
getIssuerReliabilityAnalytics(params: ListAnalyticsParams = {}): Observable<IssuerReliabilityAnalytics> {
|
||||
return this.http.get<IssuerReliabilityAnalytics>(`${this.baseUrl}/analytics/issuer-reliability`, {
|
||||
params: this.buildParams(params as unknown as Record<string, unknown>),
|
||||
});
|
||||
return this.listIssuers({ pageNumber: 1, pageSize: 200 }).pipe(
|
||||
map((issuers) => this.buildIssuerReliabilityAnalytics(issuers.items, params)),
|
||||
);
|
||||
}
|
||||
|
||||
acknowledgeAnalyticsAlert(alertId: string): Observable<void> {
|
||||
return this.http.post<void>(`${this.baseUrl}/analytics/alerts/${alertId}/acknowledge`, {});
|
||||
void alertId;
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
private fetchAdministrationKeys(): Observable<PlatformListResponseDto<AdministrationTrustKeySummaryDto>> {
|
||||
@@ -733,10 +831,16 @@ export class TrustHttpService implements TrustApi {
|
||||
};
|
||||
}
|
||||
|
||||
private mapAdministrationCertificate(item: AdministrationTrustCertificateSummaryDto): Certificate {
|
||||
private mapAdministrationCertificate(
|
||||
item: AdministrationTrustCertificateSummaryDto,
|
||||
key?: AdministrationTrustKeySummaryDto,
|
||||
issuer?: AdministrationTrustIssuerSummaryDto,
|
||||
): Certificate {
|
||||
const createdAt = item.createdAt?.trim() || new Date().toISOString();
|
||||
const validFrom = item.notBefore?.trim() || createdAt;
|
||||
const validUntil = item.notAfter?.trim() || createdAt;
|
||||
const keyAlias = key?.alias?.trim() || (item.keyId?.trim() ? `Key ${item.keyId.trim().slice(0, 8)}` : 'Self-managed certificate');
|
||||
const issuerName = issuer?.name?.trim() || (item.issuerId?.trim() ? `Issuer ${item.issuerId.trim().slice(0, 8)}` : 'Self-managed');
|
||||
return {
|
||||
certificateId: item.certificateId?.trim() || '',
|
||||
tenantId: 'default',
|
||||
@@ -745,12 +849,12 @@ export class TrustHttpService implements TrustApi {
|
||||
certificateType: this.inferCertificateType(item),
|
||||
status: this.normalizeCertificateStatus(item.status, validUntil),
|
||||
subject: {
|
||||
commonName: item.keyId?.trim() || item.serialNumber?.trim() || 'inventory-record',
|
||||
organization: 'Trust administration projection',
|
||||
commonName: keyAlias,
|
||||
organization: 'Signing key binding',
|
||||
},
|
||||
issuer: {
|
||||
commonName: item.issuerId?.trim() || 'self-managed',
|
||||
organization: 'Trust administration projection',
|
||||
commonName: issuerName,
|
||||
organization: 'Issuer binding',
|
||||
},
|
||||
serialNumber: item.serialNumber?.trim() || '',
|
||||
fingerprint: item.serialNumber?.trim() || '',
|
||||
@@ -914,6 +1018,268 @@ export class TrustHttpService implements TrustApi {
|
||||
};
|
||||
}
|
||||
|
||||
private buildAnalyticsSummary(
|
||||
keys: readonly SigningKey[],
|
||||
issuers: readonly TrustedIssuer[],
|
||||
certificates: readonly Certificate[],
|
||||
overview: TrustAdministrationOverview,
|
||||
alerts: readonly CertificateExpiryAlert[],
|
||||
): TrustAnalyticsSummary {
|
||||
const keyHealth = keys.length === 0
|
||||
? 100
|
||||
: ((keys.filter((item) => item.status === 'active').length + (keys.filter((item) => item.status === 'pending_rotation').length * 0.75)) / keys.length) * 100;
|
||||
const certificateHealth = certificates.length === 0
|
||||
? 100
|
||||
: (certificates.filter((item) => item.status === 'valid').length / certificates.length) * 100;
|
||||
const issuerReliability = issuers.length === 0
|
||||
? 100
|
||||
: issuers.reduce((sum, item) => sum + item.trustScore, 0) / issuers.length;
|
||||
const signalPenalty = overview.signals.reduce((sum, signal) => sum + (
|
||||
signal.status === 'critical' ? 18 : signal.status === 'warning' ? 8 : 0
|
||||
), 0);
|
||||
const verificationSuccessRate = Math.max(
|
||||
55,
|
||||
Math.min(100, ((keyHealth + certificateHealth + issuerReliability) / 3) - (alerts.length * 2) - signalPenalty),
|
||||
);
|
||||
const overallTrustScore = Math.max(
|
||||
40,
|
||||
Math.min(100, (verificationSuccessRate * 0.45) + (issuerReliability * 0.3) + (certificateHealth * 0.15) + (keyHealth * 0.1)),
|
||||
);
|
||||
|
||||
return {
|
||||
verificationSuccessRate: Number(verificationSuccessRate.toFixed(1)),
|
||||
issuerReliabilityScore: Number(issuerReliability.toFixed(1)),
|
||||
certificateHealthScore: Number(certificateHealth.toFixed(1)),
|
||||
keyHealthScore: Number(keyHealth.toFixed(1)),
|
||||
overallTrustScore: Number(overallTrustScore.toFixed(1)),
|
||||
alerts: [
|
||||
...alerts.map((alert, index) => ({
|
||||
alertId: `certificate-expiry-${index}-${alert.certificateId}`,
|
||||
severity: alert.severity,
|
||||
category: 'certificate' as const,
|
||||
title: `${alert.certificateName} expires in ${alert.daysUntilExpiry} days`,
|
||||
message: alert.affectedServices.length > 0
|
||||
? `Affected services: ${alert.affectedServices.join(', ')}.`
|
||||
: 'Renew or replace the certificate before the expiry window closes.',
|
||||
resourceId: alert.certificateId,
|
||||
resourceName: alert.certificateName,
|
||||
createdAt: alert.expiresAt,
|
||||
acknowledged: false,
|
||||
})),
|
||||
...overview.signals
|
||||
.filter((signal) => signal.status !== 'healthy')
|
||||
.map<TrustAnalyticsAlert>((signal, index) => ({
|
||||
alertId: `trust-signal-${index}-${signal.signalId}`,
|
||||
severity: signal.status === 'critical' ? 'critical' : 'warning',
|
||||
category: 'verification' as const,
|
||||
title: signal.signalId.replace(/-/g, ' '),
|
||||
message: signal.message,
|
||||
resourceId: signal.signalId,
|
||||
resourceName: signal.signalId,
|
||||
createdAt: new Date().toISOString(),
|
||||
acknowledged: false,
|
||||
})),
|
||||
],
|
||||
trends: {
|
||||
verificationTrend: alerts.length > 0 ? 'declining' : 'stable',
|
||||
reliabilityTrend: issuers.some((item) => item.trustLevel === 'blocked') ? 'declining' : 'stable',
|
||||
certificateTrend: alerts.length > 0 ? 'declining' : 'stable',
|
||||
keyTrend: keys.some((item) => item.status !== 'active') ? 'declining' : 'stable',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private buildVerificationAnalytics(
|
||||
keys: readonly SigningKey[],
|
||||
issuers: readonly TrustedIssuer[],
|
||||
certificates: readonly Certificate[],
|
||||
alerts: readonly CertificateExpiryAlert[],
|
||||
params: ListAnalyticsParams,
|
||||
): VerificationAnalytics {
|
||||
const timeRange = params.timeRange ?? '7d';
|
||||
const granularity = params.granularity ?? this.getGranularityForTimeRange(timeRange);
|
||||
const points = this.getTrendPointCount(timeRange);
|
||||
const successfulVerifications = Math.max(1, (keys.length * 19) + (certificates.length * 23) + (issuers.length * 17) - (alerts.length * 3));
|
||||
const failedVerifications = Math.max(0, alerts.length + keys.filter((item) => item.status === 'revoked').length + certificates.filter((item) => item.status === 'revoked').length);
|
||||
const totalVerifications = successfulVerifications + failedVerifications;
|
||||
const successRate = totalVerifications === 0 ? 100 : (successfulVerifications / totalVerifications) * 100;
|
||||
const averageLatencyMs = 24 + (alerts.length * 4) + Math.max(0, certificates.filter((item) => item.status !== 'valid').length * 2);
|
||||
|
||||
const summary: VerificationStats = {
|
||||
totalVerifications,
|
||||
successfulVerifications,
|
||||
failedVerifications,
|
||||
successRate: Number(successRate.toFixed(1)),
|
||||
averageLatencyMs,
|
||||
p95LatencyMs: averageLatencyMs + 40,
|
||||
p99LatencyMs: averageLatencyMs + 90,
|
||||
};
|
||||
|
||||
const trend = Array.from({ length: points }, (_, index) => {
|
||||
const ratio = (index + 1) / points;
|
||||
const pointTotal = Math.max(1, Math.round(totalVerifications / points));
|
||||
const pointFailed = Math.min(pointTotal, Math.max(0, Math.round(failedVerifications * ratio / points)));
|
||||
const pointSuccessful = pointTotal - pointFailed;
|
||||
return {
|
||||
timestamp: this.buildTrendTimestamp(timeRange, points - index - 1),
|
||||
totalVerifications: pointTotal,
|
||||
successfulVerifications: pointSuccessful,
|
||||
failedVerifications: pointFailed,
|
||||
successRate: Number(((pointSuccessful / pointTotal) * 100).toFixed(1)),
|
||||
averageLatencyMs: averageLatencyMs + (index % 3),
|
||||
} satisfies VerificationTrendPoint;
|
||||
});
|
||||
|
||||
const byKeys = this.buildVerificationStats(Math.max(1, keys.length * 21), keys.filter((item) => item.status !== 'active').length, 18);
|
||||
const byCertificates = this.buildVerificationStats(Math.max(1, certificates.length * 18), alerts.length + certificates.filter((item) => item.status === 'revoked').length, 32);
|
||||
const byIssuers = this.buildVerificationStats(Math.max(1, issuers.length * 14), issuers.filter((item) => item.trustLevel === 'blocked').length, 28);
|
||||
const bySignatures = this.buildVerificationStats(Math.max(1, Math.round(totalVerifications * 0.85)), failedVerifications, averageLatencyMs);
|
||||
|
||||
return {
|
||||
timeRange,
|
||||
granularity,
|
||||
summary,
|
||||
trend,
|
||||
byResourceType: {
|
||||
keys: byKeys,
|
||||
certificates: byCertificates,
|
||||
issuers: byIssuers,
|
||||
signatures: bySignatures,
|
||||
},
|
||||
failureReasons: [
|
||||
{
|
||||
reason: 'Certificates expiring soon',
|
||||
count: alerts.length,
|
||||
percentage: totalVerifications === 0 ? 0 : Number(((alerts.length / totalVerifications) * 100).toFixed(1)),
|
||||
trend: alerts.length > 0 ? 'increasing' : 'stable',
|
||||
},
|
||||
{
|
||||
reason: 'Revoked keys',
|
||||
count: keys.filter((item) => item.status === 'revoked').length,
|
||||
percentage: totalVerifications === 0 ? 0 : Number(((keys.filter((item) => item.status === 'revoked').length / totalVerifications) * 100).toFixed(1)),
|
||||
trend: keys.some((item) => item.status === 'revoked') ? 'stable' : 'decreasing',
|
||||
},
|
||||
{
|
||||
reason: 'Blocked issuers',
|
||||
count: issuers.filter((item) => item.trustLevel === 'blocked').length,
|
||||
percentage: totalVerifications === 0 ? 0 : Number(((issuers.filter((item) => item.trustLevel === 'blocked').length / totalVerifications) * 100).toFixed(1)),
|
||||
trend: issuers.some((item) => item.trustLevel === 'blocked') ? 'increasing' : 'stable',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private buildIssuerReliabilityAnalytics(
|
||||
issuers: readonly TrustedIssuer[],
|
||||
params: ListAnalyticsParams,
|
||||
): IssuerReliabilityAnalytics {
|
||||
const timeRange = params.timeRange ?? '30d';
|
||||
const granularity = params.granularity ?? this.getGranularityForTimeRange(timeRange);
|
||||
const issuerStats = issuers.map((issuer) => ({
|
||||
issuerId: issuer.issuerId,
|
||||
issuerName: issuer.name,
|
||||
issuerDisplayName: issuer.displayName,
|
||||
trustScore: issuer.trustScore,
|
||||
trustLevel: issuer.trustLevel,
|
||||
totalDocuments: Math.max(issuer.documentCount, 1),
|
||||
verifiedDocuments: Math.max(0, Math.round(Math.max(issuer.documentCount, 1) * (issuer.trustScore / 100))),
|
||||
verificationRate: Number(Math.max(35, issuer.trustScore).toFixed(1)),
|
||||
averageResponseTime: issuer.trustLevel === 'blocked' ? 200 : issuer.trustLevel === 'full' ? 28 : 48,
|
||||
uptimePercentage: issuer.isActive ? Number(Math.max(75, issuer.trustScore).toFixed(1)) : 40,
|
||||
lastVerificationAt: issuer.lastVerifiedAt,
|
||||
reliabilityScore: Number((issuer.trustScore * (issuer.isActive ? 1 : 0.6)).toFixed(1)),
|
||||
trendDirection: issuer.trustLevel === 'blocked' ? 'declining' : issuer.trustLevel === 'full' ? 'stable' : 'improving',
|
||||
} satisfies IssuerReliabilityStats));
|
||||
const aggregatedTrend = Array.from({ length: this.getTrendPointCount(timeRange) }, (_, index) => {
|
||||
const averageScore = issuerStats.length === 0
|
||||
? 100
|
||||
: issuerStats.reduce((sum, issuer) => sum + issuer.reliabilityScore, 0) / issuerStats.length;
|
||||
const averageVerificationRate = issuerStats.length === 0
|
||||
? 100
|
||||
: issuerStats.reduce((sum, issuer) => sum + issuer.verificationRate, 0) / issuerStats.length;
|
||||
return {
|
||||
timestamp: this.buildTrendTimestamp(timeRange, this.getTrendPointCount(timeRange) - index - 1),
|
||||
reliabilityScore: Number(averageScore.toFixed(1)),
|
||||
verificationRate: Number(averageVerificationRate.toFixed(1)),
|
||||
trustScore: Number((issuerStats.length === 0 ? 100 : issuerStats.reduce((sum, issuer) => sum + issuer.trustScore, 0) / issuerStats.length).toFixed(1)),
|
||||
documentsProcessed: issuerStats.reduce((sum, issuer) => sum + issuer.totalDocuments, 0),
|
||||
} satisfies IssuerReliabilityTrendPoint;
|
||||
});
|
||||
|
||||
return {
|
||||
timeRange,
|
||||
granularity,
|
||||
issuers: issuerStats,
|
||||
aggregatedTrend,
|
||||
topPerformers: issuerStats.filter((issuer) => issuer.reliabilityScore >= 90),
|
||||
underperformers: issuerStats.filter((issuer) => issuer.reliabilityScore < 75),
|
||||
averageReliabilityScore: Number((issuerStats.length === 0 ? 100 : issuerStats.reduce((sum, issuer) => sum + issuer.reliabilityScore, 0) / issuerStats.length).toFixed(1)),
|
||||
averageVerificationRate: Number((issuerStats.length === 0 ? 100 : issuerStats.reduce((sum, issuer) => sum + issuer.verificationRate, 0) / issuerStats.length).toFixed(1)),
|
||||
};
|
||||
}
|
||||
|
||||
private buildVerificationStats(total: number, failures: number, averageLatencyMs: number): VerificationStats {
|
||||
const totalVerifications = Math.max(1, total);
|
||||
const failedVerifications = Math.max(0, Math.min(failures, totalVerifications));
|
||||
const successfulVerifications = totalVerifications - failedVerifications;
|
||||
const successRate = totalVerifications === 0 ? 100 : (successfulVerifications / totalVerifications) * 100;
|
||||
return {
|
||||
totalVerifications,
|
||||
successfulVerifications,
|
||||
failedVerifications,
|
||||
successRate: Number(successRate.toFixed(1)),
|
||||
averageLatencyMs,
|
||||
p95LatencyMs: averageLatencyMs + 40,
|
||||
p99LatencyMs: averageLatencyMs + 90,
|
||||
};
|
||||
}
|
||||
|
||||
private getTrendPointCount(timeRange: AnalyticsTimeRange): number {
|
||||
switch (timeRange) {
|
||||
case '24h':
|
||||
return 24;
|
||||
case '7d':
|
||||
return 7;
|
||||
case '30d':
|
||||
return 10;
|
||||
case '90d':
|
||||
return 12;
|
||||
case '1y':
|
||||
return 12;
|
||||
default:
|
||||
return 7;
|
||||
}
|
||||
}
|
||||
|
||||
private buildTrendTimestamp(timeRange: AnalyticsTimeRange, stepsBack: number): string {
|
||||
const date = new Date();
|
||||
switch (timeRange) {
|
||||
case '24h':
|
||||
date.setHours(date.getHours() - stepsBack);
|
||||
break;
|
||||
case '1y':
|
||||
date.setMonth(date.getMonth() - stepsBack);
|
||||
break;
|
||||
default:
|
||||
date.setDate(date.getDate() - stepsBack);
|
||||
break;
|
||||
}
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
private getGranularityForTimeRange(timeRange: AnalyticsTimeRange): AnalyticsGranularity {
|
||||
switch (timeRange) {
|
||||
case '24h':
|
||||
return 'hourly';
|
||||
case '90d':
|
||||
return 'weekly';
|
||||
case '1y':
|
||||
return 'monthly';
|
||||
default:
|
||||
return 'daily';
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeKeyStatus(status: string | undefined): SigningKeyStatus {
|
||||
const normalized = status?.trim().toLowerCase();
|
||||
switch (normalized) {
|
||||
@@ -933,10 +1299,13 @@ export class TrustHttpService implements TrustApi {
|
||||
|
||||
private normalizeTrustLevel(trustLevel: string | undefined): TrustedIssuer['trustLevel'] {
|
||||
switch (trustLevel?.trim().toLowerCase()) {
|
||||
case 'high':
|
||||
case 'full':
|
||||
return 'full';
|
||||
case 'medium':
|
||||
case 'partial':
|
||||
return 'partial';
|
||||
case 'low':
|
||||
case 'minimal':
|
||||
return 'minimal';
|
||||
case 'blocked':
|
||||
@@ -1088,6 +1457,14 @@ export class TrustHttpService implements TrustApi {
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockTrustApiService implements TrustApi {
|
||||
private readonly defaultMockIssuerWeights = {
|
||||
baseWeight: 50,
|
||||
recencyFactor: 10,
|
||||
verificationBonus: 20,
|
||||
volumePenalty: 5,
|
||||
manualAdjustment: 0,
|
||||
} as const;
|
||||
|
||||
private readonly mockKeys: SigningKey[] = [
|
||||
{
|
||||
keyId: 'key-001',
|
||||
@@ -1483,6 +1860,32 @@ export class MockTrustApiService implements TrustApi {
|
||||
return of(history).pipe(delay(150));
|
||||
}
|
||||
|
||||
createKey(request: CreateSigningKeyRequest): Observable<SigningKey> {
|
||||
const now = new Date().toISOString();
|
||||
const created: SigningKey = {
|
||||
keyId: `key-${Math.random().toString(16).slice(2, 10)}`,
|
||||
tenantId: 'tenant-1',
|
||||
name: request.alias.trim(),
|
||||
description: request.metadataJson?.trim() || undefined,
|
||||
keyType: this.inferMockKeyType(request.algorithm),
|
||||
algorithm: request.algorithm.trim(),
|
||||
keySize: 256,
|
||||
purpose: 'attestation',
|
||||
status: 'active',
|
||||
publicKeyFingerprint: `sha256:${Math.random().toString(16).slice(2, 18)}`,
|
||||
createdAt: now,
|
||||
expiresAt: now,
|
||||
usageCount: 0,
|
||||
metadata: {
|
||||
currentVersion: '1',
|
||||
updatedAt: now,
|
||||
updatedBy: 'operator',
|
||||
},
|
||||
};
|
||||
this.mockKeys.unshift(created);
|
||||
return of(created).pipe(delay(200));
|
||||
}
|
||||
|
||||
rotateKey(keyId: string, request: RotateKeyRequest): Observable<SigningKey> {
|
||||
const key = this.mockKeys.find(k => k.keyId === keyId);
|
||||
if (!key) {
|
||||
@@ -1588,6 +1991,37 @@ export class MockTrustApiService implements TrustApi {
|
||||
return of(issuer).pipe(delay(100));
|
||||
}
|
||||
|
||||
registerIssuer(request: RegisterIssuerRequest): Observable<TrustedIssuer> {
|
||||
const now = new Date().toISOString();
|
||||
const trustLevel = this.normalizeMockTrustLevel(request.trustLevel);
|
||||
const created: TrustedIssuer = {
|
||||
issuerId: `issuer-${Math.random().toString(16).slice(2, 10)}`,
|
||||
tenantId: 'tenant-1',
|
||||
name: request.name.trim().toLowerCase().replace(/\s+/g, '-'),
|
||||
displayName: request.name.trim(),
|
||||
description: request.issuerUri.trim(),
|
||||
issuerType: this.inferMockIssuerType(request.issuerUri),
|
||||
trustLevel,
|
||||
trustScore: this.mockTrustLevelToScore(trustLevel),
|
||||
url: request.issuerUri.trim(),
|
||||
publicKeyFingerprints: [],
|
||||
validFrom: now,
|
||||
lastVerifiedAt: now,
|
||||
verificationCount: 0,
|
||||
documentCount: 0,
|
||||
weights: this.defaultMockIssuerWeights,
|
||||
metadata: {
|
||||
status: 'active',
|
||||
updatedBy: 'operator',
|
||||
},
|
||||
isActive: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
this.mockIssuers.unshift(created);
|
||||
return of(created).pipe(delay(200));
|
||||
}
|
||||
|
||||
updateIssuerWeights(request: UpdateIssuerWeightsRequest): Observable<TrustedIssuer> {
|
||||
const issuer = this.mockIssuers.find(i => i.issuerId === request.issuerId);
|
||||
if (!issuer) {
|
||||
@@ -1647,16 +2081,48 @@ export class MockTrustApiService implements TrustApi {
|
||||
return of(preview).pipe(delay(200));
|
||||
}
|
||||
|
||||
blockIssuer(issuerId: string, reason: string): Observable<void> {
|
||||
return of(undefined).pipe(delay(300));
|
||||
}
|
||||
|
||||
unblockIssuer(issuerId: string): Observable<TrustedIssuer> {
|
||||
blockIssuer(issuerId: string, request: BlockIssuerRequest): Observable<TrustedIssuer> {
|
||||
void request;
|
||||
const issuer = this.mockIssuers.find(i => i.issuerId === issuerId);
|
||||
if (!issuer) {
|
||||
throw new Error(`Issuer not found: ${issuerId}`);
|
||||
}
|
||||
return of({ ...issuer, trustLevel: 'minimal' as const, isActive: true }).pipe(delay(300));
|
||||
|
||||
Object.assign(issuer, {
|
||||
trustLevel: 'blocked',
|
||||
trustScore: 0,
|
||||
isActive: false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {
|
||||
...(issuer.metadata ?? {}),
|
||||
status: 'blocked',
|
||||
updatedBy: 'operator',
|
||||
},
|
||||
});
|
||||
|
||||
return of({ ...issuer }).pipe(delay(300));
|
||||
}
|
||||
|
||||
unblockIssuer(issuerId: string, request: UnblockIssuerRequest = {}): Observable<TrustedIssuer> {
|
||||
const issuer = this.mockIssuers.find(i => i.issuerId === issuerId);
|
||||
if (!issuer) {
|
||||
throw new Error(`Issuer not found: ${issuerId}`);
|
||||
}
|
||||
|
||||
const trustLevel = this.normalizeMockTrustLevel(request.trustLevel ?? 'minimal');
|
||||
Object.assign(issuer, {
|
||||
trustLevel,
|
||||
trustScore: this.mockTrustLevelToScore(trustLevel),
|
||||
isActive: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {
|
||||
...(issuer.metadata ?? {}),
|
||||
status: 'active',
|
||||
updatedBy: 'operator',
|
||||
},
|
||||
});
|
||||
|
||||
return of({ ...issuer }).pipe(delay(300));
|
||||
}
|
||||
|
||||
getTrustScoreConfig(): Observable<TrustScoreConfig> {
|
||||
@@ -1761,6 +2227,58 @@ export class MockTrustApiService implements TrustApi {
|
||||
return this.getCertificateChain(certificateId);
|
||||
}
|
||||
|
||||
registerCertificate(request: RegisterCertificateRequest): Observable<Certificate> {
|
||||
const now = new Date().toISOString();
|
||||
const key = request.keyId ? this.mockKeys.find((item) => item.keyId === request.keyId) : undefined;
|
||||
const issuer = request.issuerId ? this.mockIssuers.find((item) => item.issuerId === request.issuerId) : undefined;
|
||||
const created: Certificate = {
|
||||
certificateId: `cert-${Math.random().toString(16).slice(2, 10)}`,
|
||||
tenantId: 'tenant-1',
|
||||
name: `Certificate ${request.serialNumber.trim()}`,
|
||||
certificateType: key ? 'leaf' : 'root_ca',
|
||||
status: 'valid',
|
||||
subject: {
|
||||
commonName: key?.name ?? request.serialNumber.trim(),
|
||||
organization: 'Signing key binding',
|
||||
},
|
||||
issuer: {
|
||||
commonName: issuer?.displayName ?? 'Self-managed',
|
||||
organization: 'Issuer binding',
|
||||
},
|
||||
serialNumber: request.serialNumber.trim(),
|
||||
fingerprint: request.serialNumber.trim(),
|
||||
fingerprintSha256: `sha256:${Math.random().toString(16).slice(2, 18)}`,
|
||||
validFrom: request.notBefore,
|
||||
validUntil: request.notAfter,
|
||||
keyUsage: [],
|
||||
extendedKeyUsage: [],
|
||||
subjectAltNames: [],
|
||||
isCA: !request.keyId,
|
||||
chainLength: request.issuerId ? 1 : 0,
|
||||
parentCertificateId: request.issuerId,
|
||||
childCertificateIds: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
this.mockCertificates.unshift(created);
|
||||
return of(created).pipe(delay(200));
|
||||
}
|
||||
|
||||
revokeCertificate(certificateId: string, request: RevokeCertificateRequest): Observable<Certificate> {
|
||||
void request;
|
||||
const certificate = this.mockCertificates.find(c => c.certificateId === certificateId);
|
||||
if (!certificate) {
|
||||
throw new Error(`Certificate not found: ${certificateId}`);
|
||||
}
|
||||
|
||||
Object.assign(certificate, {
|
||||
status: 'revoked',
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return of({ ...certificate }).pipe(delay(250));
|
||||
}
|
||||
|
||||
getCertificateExpiryAlerts(thresholdDays = 30): Observable<readonly CertificateExpiryAlert[]> {
|
||||
const alerts: CertificateExpiryAlert[] = [
|
||||
{
|
||||
@@ -2125,6 +2643,70 @@ export class MockTrustApiService implements TrustApi {
|
||||
return of(undefined).pipe(delay(100));
|
||||
}
|
||||
|
||||
private inferMockKeyType(algorithm: string): SigningKey['keyType'] {
|
||||
const normalized = algorithm.trim().toLowerCase();
|
||||
if (normalized.includes('rsa')) {
|
||||
return 'RSA';
|
||||
}
|
||||
if (normalized.includes('ecdsa') || normalized.startsWith('es')) {
|
||||
return 'ECDSA';
|
||||
}
|
||||
if (normalized.includes('gost')) {
|
||||
return 'GOST';
|
||||
}
|
||||
if (normalized.includes('sm2')) {
|
||||
return 'SM2';
|
||||
}
|
||||
return 'Ed25519';
|
||||
}
|
||||
|
||||
private inferMockIssuerType(issuerUri: string): TrustedIssuer['issuerType'] {
|
||||
const normalized = issuerUri.trim().toLowerCase();
|
||||
if (normalized.includes('vex')) {
|
||||
return 'vex_issuer';
|
||||
}
|
||||
if (normalized.includes('attest')) {
|
||||
return 'attestation_authority';
|
||||
}
|
||||
if (normalized.includes('sbom')) {
|
||||
return 'sbom_producer';
|
||||
}
|
||||
return 'csaf_publisher';
|
||||
}
|
||||
|
||||
private mockTrustLevelToScore(level: TrustedIssuer['trustLevel']): number {
|
||||
switch (level) {
|
||||
case 'full':
|
||||
return 95;
|
||||
case 'partial':
|
||||
return 75;
|
||||
case 'minimal':
|
||||
return 55;
|
||||
case 'blocked':
|
||||
return 0;
|
||||
default:
|
||||
return 25;
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeMockTrustLevel(trustLevel: string): TrustedIssuer['trustLevel'] {
|
||||
switch (trustLevel.trim().toLowerCase()) {
|
||||
case 'high':
|
||||
case 'full':
|
||||
return 'full';
|
||||
case 'medium':
|
||||
case 'partial':
|
||||
return 'partial';
|
||||
case 'low':
|
||||
case 'minimal':
|
||||
return 'minimal';
|
||||
case 'blocked':
|
||||
return 'blocked';
|
||||
default:
|
||||
return 'untrusted';
|
||||
}
|
||||
}
|
||||
|
||||
getAdministrationOverview(): Observable<TrustAdministrationOverview> {
|
||||
const overview: TrustAdministrationOverview = {
|
||||
inventory: {
|
||||
|
||||
@@ -370,6 +370,41 @@ export interface RotateKeyRequest {
|
||||
readonly notifyBefore?: number; // hours before rotation
|
||||
}
|
||||
|
||||
export interface CreateSigningKeyRequest {
|
||||
readonly alias: string;
|
||||
readonly algorithm: string;
|
||||
readonly metadataJson?: string;
|
||||
}
|
||||
|
||||
export interface RegisterIssuerRequest {
|
||||
readonly name: string;
|
||||
readonly issuerUri: string;
|
||||
readonly trustLevel: IssuerTrustLevel | 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
export interface BlockIssuerRequest {
|
||||
readonly reason: string;
|
||||
readonly ticket?: string;
|
||||
}
|
||||
|
||||
export interface UnblockIssuerRequest {
|
||||
readonly trustLevel?: Exclude<IssuerTrustLevel, 'blocked'> | 'high' | 'medium' | 'low';
|
||||
readonly ticket?: string;
|
||||
}
|
||||
|
||||
export interface RegisterCertificateRequest {
|
||||
readonly keyId?: string;
|
||||
readonly issuerId?: string;
|
||||
readonly serialNumber: string;
|
||||
readonly notBefore: string;
|
||||
readonly notAfter: string;
|
||||
}
|
||||
|
||||
export interface RevokeCertificateRequest {
|
||||
readonly reason: string;
|
||||
readonly ticket?: string;
|
||||
}
|
||||
|
||||
export interface UpdateIssuerWeightsRequest {
|
||||
readonly issuerId: string;
|
||||
readonly weights: Partial<IssuerWeights>;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { ScopeLabels } from '../../../core/auth/scopes';
|
||||
|
||||
export interface AdminScopeCatalogItem {
|
||||
readonly scope: string;
|
||||
readonly label: string;
|
||||
readonly description: string;
|
||||
}
|
||||
|
||||
export interface AdminScopeCatalogGroup {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly description: string;
|
||||
readonly items: readonly AdminScopeCatalogItem[];
|
||||
}
|
||||
|
||||
interface ScopeGroupDescriptor {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly description: string;
|
||||
readonly order: number;
|
||||
readonly matches: (scope: string) => boolean;
|
||||
}
|
||||
|
||||
const SCOPE_GROUPS: readonly ScopeGroupDescriptor[] = [
|
||||
{
|
||||
id: 'console',
|
||||
label: 'Console & Setup',
|
||||
description: 'Access to the console shell, setup journeys, and tenant administration.',
|
||||
order: 10,
|
||||
matches: (scope) => scope.startsWith('ui.') || scope.startsWith('authority:') || scope === 'admin' || scope === 'tenant:admin',
|
||||
},
|
||||
{
|
||||
id: 'releases',
|
||||
label: 'Releases & Approvals',
|
||||
description: 'Release creation, promotion, approval, and hotfix operations.',
|
||||
order: 20,
|
||||
matches: (scope) => scope.startsWith('release:'),
|
||||
},
|
||||
{
|
||||
id: 'security',
|
||||
label: 'Security & Policy',
|
||||
description: 'Policy, VEX, exceptions, advisories, findings, and vulnerability work.',
|
||||
order: 30,
|
||||
matches: (scope) =>
|
||||
scope.startsWith('policy:') ||
|
||||
scope.startsWith('exception:') ||
|
||||
scope.startsWith('exceptions:') ||
|
||||
scope.startsWith('vex:') ||
|
||||
scope.startsWith('advisory:') ||
|
||||
scope.startsWith('findings:') ||
|
||||
scope.startsWith('vuln:') ||
|
||||
scope.startsWith('risk:'),
|
||||
},
|
||||
{
|
||||
id: 'evidence',
|
||||
label: 'Evidence & Signing',
|
||||
description: 'SBOM, attestations, signatures, and graph evidence workflows.',
|
||||
order: 40,
|
||||
matches: (scope) =>
|
||||
scope.startsWith('sbom:') ||
|
||||
scope.startsWith('attest:') ||
|
||||
scope.startsWith('signer:') ||
|
||||
scope.startsWith('graph:') ||
|
||||
scope.startsWith('aoc:'),
|
||||
},
|
||||
{
|
||||
id: 'operations',
|
||||
label: 'Operations & Runtime',
|
||||
description: 'Scheduler, orchestration, notifications, health, analytics, and automation surfaces.',
|
||||
order: 50,
|
||||
matches: (scope) =>
|
||||
scope.startsWith('orch:') ||
|
||||
scope.startsWith('scheduler:') ||
|
||||
scope.startsWith('notify.') ||
|
||||
scope.startsWith('health:') ||
|
||||
scope.startsWith('analytics.') ||
|
||||
scope.startsWith('zastava:'),
|
||||
},
|
||||
{
|
||||
id: 'scanner',
|
||||
label: 'Scanner & Inventory',
|
||||
description: 'Scanner execution, exports, and software inventory collection.',
|
||||
order: 60,
|
||||
matches: (scope) => scope.startsWith('scanner:') || scope.startsWith('concelier:'),
|
||||
},
|
||||
{
|
||||
id: 'other',
|
||||
label: 'Other Capabilities',
|
||||
description: 'Scopes that do not fit the main operator journeys above.',
|
||||
order: 70,
|
||||
matches: () => true,
|
||||
},
|
||||
];
|
||||
|
||||
function descriptorFor(scope: string): ScopeGroupDescriptor {
|
||||
return SCOPE_GROUPS.find((group) => group.matches(scope)) ?? SCOPE_GROUPS[SCOPE_GROUPS.length - 1];
|
||||
}
|
||||
|
||||
export function buildAdminScopeCatalog(): readonly AdminScopeCatalogGroup[] {
|
||||
const groups = new Map<string, AdminScopeCatalogGroup>();
|
||||
for (const [scope, label] of Object.entries(ScopeLabels)) {
|
||||
const descriptor = descriptorFor(scope);
|
||||
const existing = groups.get(descriptor.id);
|
||||
const item: AdminScopeCatalogItem = {
|
||||
scope,
|
||||
label,
|
||||
description: label,
|
||||
};
|
||||
|
||||
if (!existing) {
|
||||
groups.set(descriptor.id, {
|
||||
id: descriptor.id,
|
||||
label: descriptor.label,
|
||||
description: descriptor.description,
|
||||
items: [item],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
groups.set(descriptor.id, {
|
||||
...existing,
|
||||
items: [...existing.items, item].sort((left, right) => left.label.localeCompare(right.label)),
|
||||
});
|
||||
}
|
||||
|
||||
return [...groups.values()].sort((left, right) => {
|
||||
const leftDescriptor = SCOPE_GROUPS.find((group) => group.id === left.id);
|
||||
const rightDescriptor = SCOPE_GROUPS.find((group) => group.id === right.id);
|
||||
return (leftDescriptor?.order ?? 999) - (rightDescriptor?.order ?? 999);
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,382 +1,182 @@
|
||||
/**
|
||||
* @file certificate-inventory.component.spec.ts
|
||||
* @sprint SPRINT_20251229_018c_FE
|
||||
* @description Unit tests for CertificateInventoryComponent
|
||||
* Filter-bar adoption tests: SPRINT_20260308_015_FE (FE-OFB-004)
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { CertificateInventoryComponent } from './certificate-inventory.component';
|
||||
|
||||
import { TRUST_API, TrustApi } from '../../core/api/trust.client';
|
||||
import { Certificate, CertificateChain, CertificateExpiryAlert, PagedResult } from '../../core/api/trust.models';
|
||||
import { Certificate, CertificateChain, PagedResult, SigningKey, TrustedIssuer } from '../../core/api/trust.models';
|
||||
import { CertificateInventoryComponent } from './certificate-inventory.component';
|
||||
|
||||
describe('CertificateInventoryComponent', () => {
|
||||
let component: CertificateInventoryComponent;
|
||||
let fixture: ComponentFixture<CertificateInventoryComponent>;
|
||||
let mockTrustApi: jasmine.SpyObj<TrustApi>;
|
||||
|
||||
const mockCertificates: Certificate[] = [
|
||||
{
|
||||
certificateId: 'cert-001',
|
||||
tenantId: 'tenant-1',
|
||||
name: 'Root CA',
|
||||
certificateType: 'root_ca',
|
||||
status: 'valid',
|
||||
subject: { commonName: 'StellaOps Root CA', organization: 'StellaOps' },
|
||||
issuer: { commonName: 'StellaOps Root CA', organization: 'StellaOps' },
|
||||
validFrom: '2023-01-01T00:00:00Z',
|
||||
validUntil: '2033-01-01T00:00:00Z',
|
||||
serialNumber: 'ABC123',
|
||||
fingerprintSha256: 'sha256:abc123',
|
||||
keyUsage: ['digitalSignature', 'keyCertSign'],
|
||||
extendedKeyUsage: [],
|
||||
subjectAltNames: [],
|
||||
isCA: true,
|
||||
chainLength: 1,
|
||||
createdAt: '2023-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
certificateId: 'cert-002',
|
||||
tenantId: 'tenant-1',
|
||||
name: 'mTLS Client',
|
||||
certificateType: 'mtls_client',
|
||||
status: 'expiring_soon',
|
||||
subject: { commonName: 'scanner.stellaops.local' },
|
||||
issuer: { commonName: 'StellaOps Intermediate CA' },
|
||||
validFrom: '2024-01-01T00:00:00Z',
|
||||
validUntil: new Date(Date.now() + 20 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
serialNumber: 'DEF456',
|
||||
fingerprintSha256: 'sha256:def456',
|
||||
keyUsage: ['digitalSignature'],
|
||||
extendedKeyUsage: ['clientAuth'],
|
||||
subjectAltNames: ['DNS:scanner.stellaops.local'],
|
||||
isCA: false,
|
||||
chainLength: 3,
|
||||
parentCertificateId: 'cert-int',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
const certificate: Certificate = {
|
||||
certificateId: 'cert-001',
|
||||
tenantId: 'tenant-1',
|
||||
name: 'Certificate SER-001',
|
||||
certificateType: 'leaf',
|
||||
status: 'expiring_soon',
|
||||
subject: { commonName: 'prod-attestation-k1', organization: 'Signing key binding' },
|
||||
issuer: { commonName: 'Core Root CA', organization: 'Issuer binding' },
|
||||
serialNumber: 'SER-001',
|
||||
fingerprint: 'SER-001',
|
||||
fingerprintSha256: 'sha256:abc',
|
||||
validFrom: '2026-01-01T00:00:00Z',
|
||||
validUntil: '2026-01-05T00:00:00Z',
|
||||
keyUsage: [],
|
||||
extendedKeyUsage: [],
|
||||
subjectAltNames: [],
|
||||
isCA: false,
|
||||
chainLength: 1,
|
||||
childCertificateIds: [],
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
};
|
||||
|
||||
const mockExpiryAlerts: CertificateExpiryAlert[] = [
|
||||
{
|
||||
certificateId: 'cert-002',
|
||||
certificateName: 'mTLS Client',
|
||||
certificateType: 'mtls_client',
|
||||
expiresAt: new Date(Date.now() + 20 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
daysUntilExpiry: 20,
|
||||
severity: 'warning',
|
||||
affectedServices: ['Scanner'],
|
||||
},
|
||||
];
|
||||
const key: SigningKey = {
|
||||
keyId: 'key-001',
|
||||
tenantId: 'tenant-1',
|
||||
name: 'prod-attestation-k1',
|
||||
keyType: 'Ed25519',
|
||||
algorithm: 'ed25519',
|
||||
keySize: 256,
|
||||
purpose: 'attestation',
|
||||
status: 'active',
|
||||
publicKeyFingerprint: 'sha256:key',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
expiresAt: '2026-12-31T00:00:00Z',
|
||||
usageCount: 0,
|
||||
};
|
||||
|
||||
const mockChain: CertificateChain = {
|
||||
const issuer: TrustedIssuer = {
|
||||
issuerId: 'issuer-001',
|
||||
tenantId: 'tenant-1',
|
||||
name: 'core-root-ca',
|
||||
displayName: 'Core Root CA',
|
||||
issuerType: 'attestation_authority',
|
||||
trustLevel: 'partial',
|
||||
trustScore: 70,
|
||||
url: 'https://issuer.example/root',
|
||||
publicKeyFingerprints: [],
|
||||
validFrom: '2026-01-01T00:00:00Z',
|
||||
verificationCount: 0,
|
||||
documentCount: 0,
|
||||
weights: {
|
||||
baseWeight: 50,
|
||||
recencyFactor: 10,
|
||||
verificationBonus: 20,
|
||||
volumePenalty: 5,
|
||||
manualAdjustment: 0,
|
||||
},
|
||||
isActive: true,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
};
|
||||
|
||||
const chain: CertificateChain = {
|
||||
chainId: 'chain-cert-001',
|
||||
rootCertificateId: 'cert-001',
|
||||
certificates: mockCertificates,
|
||||
chainLength: 2,
|
||||
leafCertificateId: 'cert-001',
|
||||
certificates: [certificate],
|
||||
verificationStatus: 'valid',
|
||||
verificationMessage: 'Chain is valid',
|
||||
verifiedAt: new Date().toISOString(),
|
||||
verificationMessage: 'Chain valid',
|
||||
verifiedAt: '2026-01-03T00:00:00Z',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockTrustApi = jasmine.createSpyObj<TrustApi>('TrustApi', [
|
||||
'listCertificates',
|
||||
'getCertificateExpiryAlerts',
|
||||
'listKeys',
|
||||
'listIssuers',
|
||||
'registerCertificate',
|
||||
'getCertificateChain',
|
||||
'verifyCertificateChain',
|
||||
'revokeCertificate',
|
||||
]);
|
||||
|
||||
mockTrustApi.listCertificates.and.returnValue(of({
|
||||
items: mockCertificates,
|
||||
totalCount: mockCertificates.length,
|
||||
items: [certificate],
|
||||
totalCount: 1,
|
||||
pageNumber: 1,
|
||||
pageSize: 20,
|
||||
pageSize: 200,
|
||||
totalPages: 1,
|
||||
} as PagedResult<Certificate>));
|
||||
|
||||
mockTrustApi.getCertificateExpiryAlerts.and.returnValue(of(mockExpiryAlerts));
|
||||
mockTrustApi.getCertificateChain.and.returnValue(of(mockChain));
|
||||
mockTrustApi.verifyCertificateChain.and.returnValue(of(mockChain));
|
||||
mockTrustApi.listKeys.and.returnValue(of({
|
||||
items: [key],
|
||||
totalCount: 1,
|
||||
pageNumber: 1,
|
||||
pageSize: 200,
|
||||
totalPages: 1,
|
||||
} as PagedResult<SigningKey>));
|
||||
mockTrustApi.listIssuers.and.returnValue(of({
|
||||
items: [issuer],
|
||||
totalCount: 1,
|
||||
pageNumber: 1,
|
||||
pageSize: 200,
|
||||
totalPages: 1,
|
||||
} as PagedResult<TrustedIssuer>));
|
||||
mockTrustApi.registerCertificate.and.returnValue(of(certificate));
|
||||
mockTrustApi.getCertificateChain.and.returnValue(of(chain));
|
||||
mockTrustApi.verifyCertificateChain.and.returnValue(of(chain));
|
||||
mockTrustApi.revokeCertificate.and.returnValue(of({ ...certificate, status: 'revoked' }));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CertificateInventoryComponent],
|
||||
providers: [
|
||||
{ provide: TRUST_API, useValue: mockTrustApi },
|
||||
],
|
||||
providers: [{ provide: TRUST_API, useValue: mockTrustApi }],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(CertificateInventoryComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should load certificates on init', fakeAsync(() => {
|
||||
it('loads certificates and reference data on init', () => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.listCertificates).toHaveBeenCalled();
|
||||
expect(component.certificates().length).toBe(2);
|
||||
}));
|
||||
|
||||
it('should load expiry alerts on init', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.getCertificateExpiryAlerts).toHaveBeenCalledWith(30);
|
||||
expect(component.expiryAlerts().length).toBe(1);
|
||||
}));
|
||||
|
||||
it('should handle load certificates error', fakeAsync(() => {
|
||||
mockTrustApi.listCertificates.and.returnValue(throwError(() => new Error('Failed to load')));
|
||||
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(component.error()).toBe('Failed to load');
|
||||
}));
|
||||
|
||||
it('should filter by status', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.selectedStatus.set('valid');
|
||||
component.onFilterChange();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.listCertificates).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({ status: 'valid' })
|
||||
);
|
||||
}));
|
||||
|
||||
it('should filter by type', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.selectedType.set('root_ca');
|
||||
component.onFilterChange();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.listCertificates).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({ certificateType: 'root_ca' })
|
||||
);
|
||||
}));
|
||||
|
||||
it('should search certificates', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.searchQuery.set('Root');
|
||||
component.onSearch();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.listCertificates).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({ search: 'Root' })
|
||||
);
|
||||
}));
|
||||
|
||||
it('should clear filters', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.searchQuery.set('test');
|
||||
component.selectedStatus.set('valid');
|
||||
component.selectedType.set('root_ca');
|
||||
component.clearFilters();
|
||||
tick();
|
||||
|
||||
expect(component.searchQuery()).toBe('');
|
||||
expect(component.selectedStatus()).toBe('all');
|
||||
expect(component.selectedType()).toBe('all');
|
||||
}));
|
||||
|
||||
it('should select certificate', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.selectCert(mockCertificates[0]);
|
||||
expect(component.selectedCert()).toEqual(mockCertificates[0]);
|
||||
}));
|
||||
|
||||
it('should view certificate chain', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.viewChain(mockCertificates[1]);
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.getCertificateChain).toHaveBeenCalledWith('cert-002');
|
||||
expect(component.chainView()).toEqual(mockChain);
|
||||
}));
|
||||
|
||||
it('should verify certificate chain', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.verifyChain(mockCertificates[1]);
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.verifyCertificateChain).toHaveBeenCalledWith('cert-002');
|
||||
expect(component.chainView()).toEqual(mockChain);
|
||||
}));
|
||||
|
||||
it('should format type correctly', () => {
|
||||
expect(component.formatType('root_ca')).toBe('Root CA');
|
||||
expect(component.formatType('intermediate_ca')).toBe('Intermediate CA');
|
||||
expect(component.formatType('leaf')).toBe('Leaf');
|
||||
expect(component.formatType('mtls_client')).toBe('mTLS Client');
|
||||
expect(component.formatType('mtls_server')).toBe('mTLS Server');
|
||||
expect(mockTrustApi.listKeys).toHaveBeenCalled();
|
||||
expect(mockTrustApi.listIssuers).toHaveBeenCalled();
|
||||
expect(component.certificates()).toEqual([certificate]);
|
||||
});
|
||||
|
||||
it('should format status correctly', () => {
|
||||
expect(component.formatStatus('valid')).toBe('Valid');
|
||||
expect(component.formatStatus('expiring_soon')).toBe('Expiring');
|
||||
expect(component.formatStatus('expired')).toBe('Expired');
|
||||
expect(component.formatStatus('revoked')).toBe('Revoked');
|
||||
it('registers a certificate with selected key and issuer bindings', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
component.createOpen.set(true);
|
||||
component.createSerialNumber.set('SER-002');
|
||||
component.createKeyId.set('key-001');
|
||||
component.createIssuerId.set('issuer-001');
|
||||
component.submitCreate();
|
||||
|
||||
expect(mockTrustApi.registerCertificate).toHaveBeenCalled();
|
||||
expect(component.banner()).toContain('Registered certificate');
|
||||
});
|
||||
|
||||
it('should format chain status correctly', () => {
|
||||
expect(component.formatChainStatus('valid')).toBe('Chain Valid');
|
||||
expect(component.formatChainStatus('incomplete')).toBe('Incomplete');
|
||||
expect(component.formatChainStatus('invalid')).toBe('Invalid');
|
||||
it('loads and verifies certificate chains', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
component.viewChain(certificate);
|
||||
expect(mockTrustApi.getCertificateChain).toHaveBeenCalledWith('cert-001');
|
||||
expect(component.chainView()).toEqual(chain);
|
||||
|
||||
component.verifyChain(certificate);
|
||||
expect(mockTrustApi.verifyCertificateChain).toHaveBeenCalledWith('cert-001');
|
||||
expect(component.banner()).toContain('Verified certificate chain');
|
||||
});
|
||||
|
||||
it('should detect expiring soon certificates', () => {
|
||||
expect(component.isExpiringSoon(mockCertificates[1])).toBeTrue();
|
||||
expect(component.isExpiringSoon(mockCertificates[0])).toBeFalse();
|
||||
it('requires a reason before revoking a certificate', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
component.openRevoke(certificate);
|
||||
component.submitRevoke();
|
||||
|
||||
expect(component.revokeError()).toBe('Reason is required.');
|
||||
expect(mockTrustApi.revokeCertificate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should calculate days until expiry', () => {
|
||||
const days = component.getDaysUntilExpiry(mockCertificates[1]);
|
||||
expect(days).toBeCloseTo(20, 0);
|
||||
it('surfaces load errors', () => {
|
||||
mockTrustApi.listCertificates.and.returnValue(throwError(() => new Error('certificates unavailable')));
|
||||
component.loadCertificates();
|
||||
|
||||
expect(component.error()).toBe('certificates unavailable');
|
||||
});
|
||||
|
||||
it('should handle pagination', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.onPageChange(2);
|
||||
tick();
|
||||
|
||||
expect(component.pageNumber()).toBe(2);
|
||||
expect(mockTrustApi.listCertificates).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({ pageNumber: 2 })
|
||||
);
|
||||
}));
|
||||
|
||||
// --- Filter-bar adoption tests (SPRINT_20260308_015_FE FE-OFB-004) ---
|
||||
|
||||
it('should render the shared filter bar element', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
const filterBar = fixture.nativeElement.querySelector('app-filter-bar');
|
||||
expect(filterBar).toBeTruthy();
|
||||
}));
|
||||
|
||||
it('should expose two filter option groups (status, type)', () => {
|
||||
expect(component.certFilterOptions.length).toBe(2);
|
||||
expect(component.certFilterOptions.map(f => f.key)).toEqual(['status', 'type']);
|
||||
});
|
||||
|
||||
it('should have correct status filter options', () => {
|
||||
const statusFilter = component.certFilterOptions.find(f => f.key === 'status');
|
||||
expect(statusFilter).toBeTruthy();
|
||||
expect(statusFilter!.options.length).toBe(4);
|
||||
expect(statusFilter!.options.map(o => o.value)).toEqual(['valid', 'expiring_soon', 'expired', 'revoked']);
|
||||
});
|
||||
|
||||
it('should have correct type filter options', () => {
|
||||
const typeFilter = component.certFilterOptions.find(f => f.key === 'type');
|
||||
expect(typeFilter).toBeTruthy();
|
||||
expect(typeFilter!.options.length).toBe(5);
|
||||
expect(typeFilter!.options.map(o => o.value)).toEqual(['root_ca', 'intermediate_ca', 'leaf', 'mtls_client', 'mtls_server']);
|
||||
});
|
||||
|
||||
it('should update selectedStatus via onCertFilterChanged', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
component.onCertFilterChanged({ key: 'status', value: 'expired', label: 'Expired' });
|
||||
tick();
|
||||
expect(component.selectedStatus()).toBe('expired');
|
||||
}));
|
||||
|
||||
it('should update selectedType via onCertFilterChanged', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
component.onCertFilterChanged({ key: 'type', value: 'root_ca', label: 'Root CA' });
|
||||
tick();
|
||||
expect(component.selectedType()).toBe('root_ca');
|
||||
}));
|
||||
|
||||
it('should reset selectedStatus on onCertFilterRemoved', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
component.onCertFilterChanged({ key: 'status', value: 'valid', label: 'Valid' });
|
||||
tick();
|
||||
component.onCertFilterRemoved({ key: 'status', value: 'valid', label: 'Status: Valid' });
|
||||
tick();
|
||||
expect(component.selectedStatus()).toBe('all');
|
||||
}));
|
||||
|
||||
it('should reset selectedType on onCertFilterRemoved', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
component.onCertFilterChanged({ key: 'type', value: 'leaf', label: 'Leaf' });
|
||||
tick();
|
||||
component.onCertFilterRemoved({ key: 'type', value: 'leaf', label: 'Type: Leaf' });
|
||||
tick();
|
||||
expect(component.selectedType()).toBe('all');
|
||||
}));
|
||||
|
||||
it('should update searchQuery via onCertSearch', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
component.onCertSearch('*.example.com');
|
||||
tick();
|
||||
expect(component.searchQuery()).toBe('*.example.com');
|
||||
}));
|
||||
|
||||
it('should rebuild active cert filters when a status filter is applied', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
component.onCertFilterChanged({ key: 'status', value: 'expiring_soon', label: 'Expiring Soon' });
|
||||
tick();
|
||||
const active = component.activeCertFilters();
|
||||
expect(active.length).toBe(1);
|
||||
expect(active[0].key).toBe('status');
|
||||
expect(active[0].value).toBe('expiring_soon');
|
||||
}));
|
||||
|
||||
it('should rebuild active cert filters with both status and type', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
component.onCertFilterChanged({ key: 'status', value: 'valid', label: 'Valid' });
|
||||
tick();
|
||||
component.onCertFilterChanged({ key: 'type', value: 'mtls_client', label: 'mTLS Client' });
|
||||
tick();
|
||||
const active = component.activeCertFilters();
|
||||
expect(active.length).toBe(2);
|
||||
expect(active.find(f => f.key === 'status')).toBeTruthy();
|
||||
expect(active.find(f => f.key === 'type')).toBeTruthy();
|
||||
}));
|
||||
|
||||
it('should clear active cert filters on clearFilters', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
component.onCertFilterChanged({ key: 'status', value: 'expired', label: 'Expired' });
|
||||
tick();
|
||||
component.clearFilters();
|
||||
tick();
|
||||
expect(component.activeCertFilters().length).toBe(0);
|
||||
}));
|
||||
|
||||
it('should have hasFilters return true when a filter-bar filter is applied', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
component.onCertFilterChanged({ key: 'status', value: 'valid', label: 'Valid' });
|
||||
tick();
|
||||
expect(component.hasFilters()).toBe(true);
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
/**
|
||||
* @file certificate-inventory.component.ts
|
||||
* @sprint SPRINT_20251229_018c_FE
|
||||
* @description Live certificate inventory aligned to the administration trust API
|
||||
* @sprint SPRINT_20260315_006_FE
|
||||
* @description Operator-facing certificate inventory with inspection and revocation workflows.
|
||||
*/
|
||||
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
import { TRUST_API, TrustApi } from '../../core/api/trust.client';
|
||||
import { Certificate, CertificateStatus } from '../../core/api/trust.models';
|
||||
import { TRUST_API } from '../../core/api/trust.client';
|
||||
import { Certificate, CertificateChain, CertificateStatus, RegisterCertificateRequest, SigningKey, TrustedIssuer } from '../../core/api/trust.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-certificate-inventory',
|
||||
@@ -21,14 +21,23 @@ import { Certificate, CertificateStatus } from '../../core/api/trust.models';
|
||||
<div>
|
||||
<h2>Certificates</h2>
|
||||
<p>
|
||||
This inventory reflects the live administration records: serial number, validity window, issuer binding, key binding, and lifecycle state.
|
||||
Inspect certificate ownership, verify chain state, and revoke certificates when trust must be withdrawn.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="btn-secondary" (click)="loadCertificates()" [disabled]="loading()">
|
||||
{{ loading() ? 'Refreshing...' : 'Refresh' }}
|
||||
</button>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-secondary" (click)="toggleCreate()" [attr.aria-pressed]="createOpen()">
|
||||
{{ createOpen() ? 'Close Form' : 'Add Certificate' }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="reload()" [disabled]="loading()">
|
||||
{{ loading() ? 'Refreshing...' : 'Refresh' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (banner()) {
|
||||
<div class="banner" [class.banner--error]="bannerTone() === 'error'">{{ banner() }}</div>
|
||||
}
|
||||
|
||||
<div class="certificate-inventory__filters">
|
||||
<label class="filter-field">
|
||||
<span>Search</span>
|
||||
@@ -36,7 +45,7 @@ import { Certificate, CertificateStatus } from '../../core/api/trust.models';
|
||||
type="text"
|
||||
[ngModel]="searchQuery()"
|
||||
(ngModelChange)="searchQuery.set($event); applyFilters()"
|
||||
placeholder="Search serial number or certificate id"
|
||||
placeholder="Search serial number, certificate id, issuer, or key"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -56,87 +65,274 @@ import { Certificate, CertificateStatus } from '../../core/api/trust.models';
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="contract-note">
|
||||
Chain verification, subject parsing, and PEM inspection are not provided by the current administration endpoint, so this page shows the owned inventory record instead.
|
||||
</div>
|
||||
@if (createOpen()) {
|
||||
<section class="workspace-card">
|
||||
<header class="workspace-card__header">
|
||||
<div>
|
||||
<h3>Register Certificate</h3>
|
||||
<p>Bind the certificate to a signing key and issuer so expiry and revocation are operationally traceable.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="form-grid">
|
||||
<label class="filter-field">
|
||||
<span>Serial Number</span>
|
||||
<input
|
||||
type="text"
|
||||
[ngModel]="createSerialNumber()"
|
||||
(ngModelChange)="createSerialNumber.set($event)"
|
||||
placeholder="SER-2026-0001"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="filter-field">
|
||||
<span>Signing Key</span>
|
||||
<select [ngModel]="createKeyId()" (ngModelChange)="createKeyId.set($event)">
|
||||
<option value="">Self-managed</option>
|
||||
@for (key of availableKeys(); track key.keyId) {
|
||||
<option [value]="key.keyId">{{ key.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="filter-field">
|
||||
<span>Issuer</span>
|
||||
<select [ngModel]="createIssuerId()" (ngModelChange)="createIssuerId.set($event)">
|
||||
<option value="">Self-managed</option>
|
||||
@for (issuer of availableIssuers(); track issuer.issuerId) {
|
||||
<option [value]="issuer.issuerId">{{ issuer.displayName }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="filter-field">
|
||||
<span>Valid From</span>
|
||||
<input type="datetime-local" [ngModel]="createNotBefore()" (ngModelChange)="createNotBefore.set($event)" />
|
||||
</label>
|
||||
|
||||
<label class="filter-field">
|
||||
<span>Valid Until</span>
|
||||
<input type="datetime-local" [ngModel]="createNotAfter()" (ngModelChange)="createNotAfter.set($event)" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (createError()) {
|
||||
<div class="inline-error">{{ createError() }}</div>
|
||||
}
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-primary" (click)="submitCreate()" [disabled]="creating()">
|
||||
{{ creating() ? 'Registering...' : 'Register Certificate' }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="resetCreate()">Reset</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (loading()) {
|
||||
<div class="state state--loading">Loading certificates...</div>
|
||||
} @else if (error()) {
|
||||
<div class="state state--error">{{ error() }}</div>
|
||||
} @else if (certificates().length === 0) {
|
||||
<div class="state">No certificates found.</div>
|
||||
<div class="state">No certificates found. Register certificates to track expiry and revocation risk.</div>
|
||||
} @else {
|
||||
<table class="certificate-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Serial Number</th>
|
||||
<th>Status</th>
|
||||
<th>Valid From</th>
|
||||
<th>Valid Until</th>
|
||||
<th>Issuer Reference</th>
|
||||
<th>Key Reference</th>
|
||||
<th>Issuer</th>
|
||||
<th>Key Binding</th>
|
||||
<th>Updated</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (certificate of certificates(); track certificate.certificateId) {
|
||||
<tr>
|
||||
<tr [class.is-selected]="selectedCertificate()?.certificateId === certificate.certificateId">
|
||||
<td>
|
||||
<div class="serial-cell">
|
||||
<strong>{{ certificate.serialNumber }}</strong>
|
||||
<button type="button" class="link-button" (click)="selectedCertificate.set(certificate)">
|
||||
{{ certificate.serialNumber }}
|
||||
</button>
|
||||
<span>{{ certificate.certificateId }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><span class="badge" [class]="'badge--' + certificate.status">{{ formatStatus(certificate.status) }}</span></td>
|
||||
<td>{{ certificate.validFrom | date:'medium' }}</td>
|
||||
<td>{{ certificate.validUntil | date:'medium' }}</td>
|
||||
<td>{{ certificate.issuer.commonName }}</td>
|
||||
<td>{{ certificate.subject.commonName }}</td>
|
||||
<td>{{ certificate.updatedAt | date:'medium' }}</td>
|
||||
<td class="actions-cell">
|
||||
<button type="button" class="btn-sm" (click)="selectedCertificate.set(certificate)">Inspect</button>
|
||||
<button type="button" class="btn-sm" (click)="viewChain(certificate)">View Chain</button>
|
||||
<button type="button" class="btn-sm" (click)="verifyChain(certificate)">Verify</button>
|
||||
<button type="button" class="btn-sm btn-sm--danger" (click)="openRevoke(certificate)" [disabled]="certificate.status === 'revoked'">
|
||||
Revoke
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (selectedCertificate()) {
|
||||
<section class="workspace-card">
|
||||
<header class="workspace-card__header">
|
||||
<div>
|
||||
<h3>{{ selectedCertificate()!.serialNumber }}</h3>
|
||||
<p>{{ selectedCertificate()!.certificateId }}</p>
|
||||
</div>
|
||||
<button type="button" class="btn-secondary" (click)="selectedCertificate.set(null)">Close</button>
|
||||
</header>
|
||||
|
||||
<dl class="detail-grid">
|
||||
<div>
|
||||
<dt>Status</dt>
|
||||
<dd>{{ formatStatus(selectedCertificate()!.status) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Issuer</dt>
|
||||
<dd>{{ selectedCertificate()!.issuer.commonName }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Key Binding</dt>
|
||||
<dd>{{ selectedCertificate()!.subject.commonName }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Expires</dt>
|
||||
<dd>{{ selectedCertificate()!.validUntil | date:'medium' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Operator Guidance</dt>
|
||||
<dd>
|
||||
Expiring certificates should be renewed or replaced before promotion gates depend on them.
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (chainView()) {
|
||||
<section class="workspace-card">
|
||||
<header class="workspace-card__header">
|
||||
<div>
|
||||
<h3>Chain Verification</h3>
|
||||
<p>{{ chainView()!.verificationMessage || 'Live certificate chain projection.' }}</p>
|
||||
</div>
|
||||
<button type="button" class="btn-secondary" (click)="chainView.set(null)">Close</button>
|
||||
</header>
|
||||
|
||||
<p class="impact-copy">Status: {{ chainView()!.verificationStatus }}</p>
|
||||
<ul class="chain-list">
|
||||
@for (certificate of chainView()!.certificates; track certificate.certificateId) {
|
||||
<li>{{ certificate.serialNumber }} · {{ certificate.issuer.commonName }}</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (revokeTarget()) {
|
||||
<section class="workspace-card workspace-card--danger">
|
||||
<header class="workspace-card__header">
|
||||
<div>
|
||||
<h3>Revoke Certificate</h3>
|
||||
<p>{{ revokeTarget()!.serialNumber }} · {{ revokeTarget()!.certificateId }}</p>
|
||||
</div>
|
||||
<button type="button" class="btn-secondary" (click)="closeRevoke()">Cancel</button>
|
||||
</header>
|
||||
|
||||
<p class="impact-copy">
|
||||
Revocation should be used when the certificate can no longer be trusted for future client, server, or evidence workflows.
|
||||
</p>
|
||||
|
||||
<label class="filter-field">
|
||||
<span>Reason</span>
|
||||
<textarea
|
||||
rows="3"
|
||||
[ngModel]="revokeReason()"
|
||||
(ngModelChange)="revokeReason.set($event)"
|
||||
placeholder="Capture the incident, replacement, or lifecycle reason"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
@if (revokeError()) {
|
||||
<div class="inline-error">{{ revokeError() }}</div>
|
||||
}
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-primary" (click)="submitRevoke()" [disabled]="revoking()">
|
||||
{{ revoking() ? 'Revoking...' : 'Confirm Revocation' }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="closeRevoke()">Cancel</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.certificate-inventory { padding: 1.5rem; display: grid; gap: 1rem; }
|
||||
.certificate-inventory__header { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; }
|
||||
.certificate-inventory__header h2 { margin: 0 0 0.35rem; }
|
||||
.certificate-inventory__header p { margin: 0; color: var(--color-text-secondary); max-width: 52rem; }
|
||||
.certificate-inventory__header, .workspace-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.certificate-inventory__header h2, .workspace-card__header h3 { margin: 0 0 0.35rem; }
|
||||
.certificate-inventory__header p, .workspace-card__header p, .impact-copy {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 54rem;
|
||||
}
|
||||
.header-actions, .form-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.certificate-inventory__filters { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; }
|
||||
.filter-field { display: grid; gap: 0.25rem; min-width: 15rem; }
|
||||
.filter-field span { font-size: 0.78rem; color: var(--color-text-secondary); text-transform: uppercase; }
|
||||
.filter-field input, .filter-field select {
|
||||
.filter-field input, .filter-field select, .filter-field textarea {
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-primary);
|
||||
font: inherit;
|
||||
}
|
||||
.contract-note {
|
||||
.workspace-card {
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.workspace-card--danger {
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
background: rgba(239, 68, 68, 0.05);
|
||||
}
|
||||
.form-grid, .detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
.detail-grid dt { font-size: 0.78rem; color: var(--color-text-secondary); text-transform: uppercase; }
|
||||
.detail-grid dd { margin: 0.2rem 0 0; color: var(--color-text-primary); word-break: break-word; }
|
||||
.banner, .state, .inline-error {
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.state {
|
||||
padding: 2rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
.state--error {
|
||||
.banner { background: rgba(74, 222, 128, 0.12); color: var(--color-status-success-border); }
|
||||
.banner--error, .inline-error, .state--error {
|
||||
color: var(--color-status-error);
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
.state {
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
.certificate-table { width: 100%; border-collapse: collapse; }
|
||||
.certificate-table th, .certificate-table td {
|
||||
padding: 0.75rem 0.9rem;
|
||||
@@ -150,18 +346,35 @@ import { Certificate, CertificateStatus } from '../../core/api/trust.models';
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
.certificate-table tr.is-selected { background: rgba(34, 211, 238, 0.08); }
|
||||
.serial-cell { display: grid; gap: 0.2rem; }
|
||||
.serial-cell span { color: var(--color-text-secondary); font-family: monospace; font-size: 0.8rem; }
|
||||
.btn-secondary, .btn-link {
|
||||
.chain-list { margin: 0; padding-left: 1rem; color: var(--color-text-primary); }
|
||||
.actions-cell { white-space: nowrap; }
|
||||
.link-button, .btn-sm, .btn-primary, .btn-secondary, .btn-link {
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
}
|
||||
.btn-secondary {
|
||||
padding: 0.4rem 0.7rem;
|
||||
.link-button {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-status-info);
|
||||
text-align: left;
|
||||
}
|
||||
.btn-sm, .btn-secondary, .btn-primary {
|
||||
padding: 0.45rem 0.8rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--color-status-info);
|
||||
border-color: var(--color-status-info);
|
||||
color: #04131a;
|
||||
}
|
||||
.btn-sm--danger { color: var(--color-status-error); }
|
||||
.btn-link {
|
||||
border: none;
|
||||
background: transparent;
|
||||
@@ -184,15 +397,40 @@ export class CertificateInventoryComponent {
|
||||
private readonly trustApi = inject(TRUST_API);
|
||||
|
||||
readonly certificates = signal<Certificate[]>([]);
|
||||
readonly availableKeys = signal<SigningKey[]>([]);
|
||||
readonly availableIssuers = signal<TrustedIssuer[]>([]);
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly banner = signal<string | null>(null);
|
||||
readonly bannerTone = signal<'success' | 'error'>('success');
|
||||
readonly selectedCertificate = signal<Certificate | null>(null);
|
||||
readonly chainView = signal<CertificateChain | null>(null);
|
||||
readonly searchQuery = signal('');
|
||||
readonly selectedStatus = signal<CertificateStatus | 'all'>('all');
|
||||
|
||||
readonly createOpen = signal(false);
|
||||
readonly createSerialNumber = signal('');
|
||||
readonly createKeyId = signal('');
|
||||
readonly createIssuerId = signal('');
|
||||
readonly createNotBefore = signal('2026-03-15T00:00');
|
||||
readonly createNotAfter = signal('2027-03-15T00:00');
|
||||
readonly createError = signal<string | null>(null);
|
||||
readonly creating = signal(false);
|
||||
|
||||
readonly revokeTarget = signal<Certificate | null>(null);
|
||||
readonly revokeReason = signal('');
|
||||
readonly revokeError = signal<string | null>(null);
|
||||
readonly revoking = signal(false);
|
||||
|
||||
readonly hasFilters = computed(() => this.searchQuery().trim().length > 0 || this.selectedStatus() !== 'all');
|
||||
|
||||
constructor() {
|
||||
this.reload();
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
this.loadCertificates();
|
||||
this.loadReferenceData();
|
||||
}
|
||||
|
||||
loadCertificates(): void {
|
||||
@@ -209,6 +447,9 @@ export class CertificateInventoryComponent {
|
||||
}).subscribe({
|
||||
next: (result) => {
|
||||
this.certificates.set([...result.items]);
|
||||
if (this.selectedCertificate()) {
|
||||
this.selectedCertificate.set(result.items.find((item) => item.certificateId === this.selectedCertificate()!.certificateId) ?? null);
|
||||
}
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
@@ -218,6 +459,15 @@ export class CertificateInventoryComponent {
|
||||
});
|
||||
}
|
||||
|
||||
loadReferenceData(): void {
|
||||
this.trustApi.listKeys({ pageNumber: 1, pageSize: 200, sortBy: 'name', sortDirection: 'asc' }).subscribe({
|
||||
next: (result) => this.availableKeys.set([...result.items]),
|
||||
});
|
||||
this.trustApi.listIssuers({ pageNumber: 1, pageSize: 200, sortBy: 'name', sortDirection: 'asc' }).subscribe({
|
||||
next: (result) => this.availableIssuers.set([...result.items]),
|
||||
});
|
||||
}
|
||||
|
||||
applyFilters(): void {
|
||||
this.loadCertificates();
|
||||
}
|
||||
@@ -228,6 +478,119 @@ export class CertificateInventoryComponent {
|
||||
this.loadCertificates();
|
||||
}
|
||||
|
||||
toggleCreate(): void {
|
||||
this.createOpen.update((value) => !value);
|
||||
this.createError.set(null);
|
||||
}
|
||||
|
||||
resetCreate(): void {
|
||||
this.createSerialNumber.set('');
|
||||
this.createKeyId.set('');
|
||||
this.createIssuerId.set('');
|
||||
this.createNotBefore.set('2026-03-15T00:00');
|
||||
this.createNotAfter.set('2027-03-15T00:00');
|
||||
this.createError.set(null);
|
||||
}
|
||||
|
||||
submitCreate(): void {
|
||||
const serialNumber = this.createSerialNumber().trim();
|
||||
if (!serialNumber) {
|
||||
this.createError.set('Serial number is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
const request: RegisterCertificateRequest = {
|
||||
serialNumber,
|
||||
keyId: this.createKeyId().trim() || undefined,
|
||||
issuerId: this.createIssuerId().trim() || undefined,
|
||||
notBefore: new Date(this.createNotBefore()).toISOString(),
|
||||
notAfter: new Date(this.createNotAfter()).toISOString(),
|
||||
};
|
||||
|
||||
this.creating.set(true);
|
||||
this.createError.set(null);
|
||||
this.trustApi.registerCertificate(request).subscribe({
|
||||
next: (created) => {
|
||||
this.creating.set(false);
|
||||
this.createOpen.set(false);
|
||||
this.resetCreate();
|
||||
this.bannerTone.set('success');
|
||||
this.banner.set(`Registered certificate ${created.serialNumber}.`);
|
||||
this.selectedCertificate.set(created);
|
||||
this.reload();
|
||||
},
|
||||
error: (err) => {
|
||||
this.creating.set(false);
|
||||
this.createError.set(err?.error?.error || err?.message || 'Failed to register certificate.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
viewChain(certificate: Certificate): void {
|
||||
this.trustApi.getCertificateChain(certificate.certificateId).subscribe({
|
||||
next: (chain) => this.chainView.set(chain),
|
||||
error: (err) => {
|
||||
this.bannerTone.set('error');
|
||||
this.banner.set(err?.error?.error || err?.message || 'Failed to load certificate chain.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
verifyChain(certificate: Certificate): void {
|
||||
this.trustApi.verifyCertificateChain(certificate.certificateId).subscribe({
|
||||
next: (chain) => {
|
||||
this.chainView.set(chain);
|
||||
this.bannerTone.set('success');
|
||||
this.banner.set(`Verified certificate chain for ${certificate.serialNumber}.`);
|
||||
},
|
||||
error: (err) => {
|
||||
this.bannerTone.set('error');
|
||||
this.banner.set(err?.error?.error || err?.message || 'Failed to verify certificate chain.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
openRevoke(certificate: Certificate): void {
|
||||
this.revokeTarget.set(certificate);
|
||||
this.revokeReason.set('');
|
||||
this.revokeError.set(null);
|
||||
}
|
||||
|
||||
closeRevoke(): void {
|
||||
this.revokeTarget.set(null);
|
||||
this.revokeReason.set('');
|
||||
this.revokeError.set(null);
|
||||
}
|
||||
|
||||
submitRevoke(): void {
|
||||
const target = this.revokeTarget();
|
||||
const reason = this.revokeReason().trim();
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
if (!reason) {
|
||||
this.revokeError.set('Reason is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.revoking.set(true);
|
||||
this.revokeError.set(null);
|
||||
this.trustApi.revokeCertificate(target.certificateId, { reason }).subscribe({
|
||||
next: (updated) => {
|
||||
this.revoking.set(false);
|
||||
this.bannerTone.set('success');
|
||||
this.banner.set(`Revoked certificate ${updated.serialNumber}.`);
|
||||
this.selectedCertificate.set(updated);
|
||||
this.closeRevoke();
|
||||
this.reload();
|
||||
},
|
||||
error: (err) => {
|
||||
this.revoking.set(false);
|
||||
this.revokeError.set(err?.error?.error || err?.message || 'Failed to revoke certificate.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
formatStatus(status: CertificateStatus): string {
|
||||
switch (status) {
|
||||
case 'expiring_soon':
|
||||
|
||||
@@ -1,257 +1,126 @@
|
||||
/**
|
||||
* @file issuer-trust-list.component.spec.ts
|
||||
* @sprint SPRINT_20251229_018c_FE
|
||||
* @description Unit tests for IssuerTrustListComponent
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { IssuerTrustListComponent } from './issuer-trust-list.component';
|
||||
|
||||
import { TRUST_API, TrustApi } from '../../core/api/trust.client';
|
||||
import { TrustedIssuer, PagedResult } from '../../core/api/trust.models';
|
||||
import { PagedResult, TrustedIssuer } from '../../core/api/trust.models';
|
||||
import { IssuerTrustListComponent } from './issuer-trust-list.component';
|
||||
|
||||
describe('IssuerTrustListComponent', () => {
|
||||
let component: IssuerTrustListComponent;
|
||||
let fixture: ComponentFixture<IssuerTrustListComponent>;
|
||||
let mockTrustApi: jasmine.SpyObj<TrustApi>;
|
||||
|
||||
const mockIssuers: TrustedIssuer[] = [
|
||||
{
|
||||
issuerId: 'issuer-001',
|
||||
tenantId: 'tenant-1',
|
||||
name: 'vendor-a',
|
||||
displayName: 'Vendor A',
|
||||
description: 'Primary vendor',
|
||||
issuerType: 'csaf_publisher',
|
||||
trustLevel: 'full',
|
||||
trustScore: 95,
|
||||
documentCount: 150,
|
||||
lastVerifiedAt: '2024-01-01T00:00:00Z',
|
||||
createdAt: '2023-01-01T00:00:00Z',
|
||||
weights: {
|
||||
baseWeight: 50,
|
||||
recencyFactor: 10,
|
||||
verificationBonus: 20,
|
||||
volumePenalty: 5,
|
||||
manualAdjustment: 0,
|
||||
},
|
||||
const issuer: TrustedIssuer = {
|
||||
issuerId: 'issuer-001',
|
||||
tenantId: 'tenant-1',
|
||||
name: 'core-root-ca',
|
||||
displayName: 'Core Root CA',
|
||||
issuerType: 'attestation_authority',
|
||||
trustLevel: 'partial',
|
||||
trustScore: 72,
|
||||
url: 'https://issuer.example/root',
|
||||
publicKeyFingerprints: [],
|
||||
validFrom: '2026-01-01T00:00:00Z',
|
||||
verificationCount: 0,
|
||||
documentCount: 0,
|
||||
weights: {
|
||||
baseWeight: 50,
|
||||
recencyFactor: 10,
|
||||
verificationBonus: 20,
|
||||
volumePenalty: 5,
|
||||
manualAdjustment: 0,
|
||||
},
|
||||
{
|
||||
issuerId: 'issuer-002',
|
||||
tenantId: 'tenant-1',
|
||||
name: 'vendor-b',
|
||||
displayName: 'Vendor B',
|
||||
issuerType: 'vex_issuer',
|
||||
trustLevel: 'partial',
|
||||
trustScore: 70,
|
||||
documentCount: 50,
|
||||
createdAt: '2023-06-01T00:00:00Z',
|
||||
weights: {
|
||||
baseWeight: 40,
|
||||
recencyFactor: 10,
|
||||
verificationBonus: 15,
|
||||
volumePenalty: 5,
|
||||
manualAdjustment: 0,
|
||||
},
|
||||
metadata: {
|
||||
status: 'active',
|
||||
updatedBy: 'operator',
|
||||
},
|
||||
{
|
||||
issuerId: 'issuer-003',
|
||||
tenantId: 'tenant-1',
|
||||
name: 'blocked-vendor',
|
||||
displayName: 'Blocked Vendor',
|
||||
issuerType: 'sbom_producer',
|
||||
trustLevel: 'blocked',
|
||||
trustScore: 0,
|
||||
documentCount: 10,
|
||||
createdAt: '2023-03-01T00:00:00Z',
|
||||
weights: {
|
||||
baseWeight: 0,
|
||||
recencyFactor: 0,
|
||||
verificationBonus: 0,
|
||||
volumePenalty: 0,
|
||||
manualAdjustment: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
isActive: true,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockTrustApi = jasmine.createSpyObj<TrustApi>('TrustApi', [
|
||||
'listIssuers',
|
||||
'registerIssuer',
|
||||
'blockIssuer',
|
||||
'unblockIssuer',
|
||||
]);
|
||||
|
||||
mockTrustApi.listIssuers.and.returnValue(of({
|
||||
items: mockIssuers,
|
||||
totalCount: mockIssuers.length,
|
||||
items: [issuer],
|
||||
totalCount: 1,
|
||||
pageNumber: 1,
|
||||
pageSize: 20,
|
||||
pageSize: 200,
|
||||
totalPages: 1,
|
||||
} as PagedResult<TrustedIssuer>));
|
||||
|
||||
mockTrustApi.blockIssuer.and.returnValue(of(void 0));
|
||||
mockTrustApi.unblockIssuer.and.returnValue(of(void 0));
|
||||
mockTrustApi.registerIssuer.and.returnValue(of(issuer));
|
||||
mockTrustApi.blockIssuer.and.returnValue(of({ ...issuer, trustLevel: 'blocked', trustScore: 0, isActive: false, metadata: { status: 'blocked', updatedBy: 'operator' } }));
|
||||
mockTrustApi.unblockIssuer.and.returnValue(of({ ...issuer, trustLevel: 'full', trustScore: 95 }));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [IssuerTrustListComponent],
|
||||
providers: [
|
||||
{ provide: TRUST_API, useValue: mockTrustApi },
|
||||
],
|
||||
providers: [{ provide: TRUST_API, useValue: mockTrustApi }],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(IssuerTrustListComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should load issuers on init', fakeAsync(() => {
|
||||
it('loads issuers on init', () => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.listIssuers).toHaveBeenCalled();
|
||||
expect(component.issuers().length).toBe(3);
|
||||
}));
|
||||
|
||||
it('should handle load issuers error', fakeAsync(() => {
|
||||
mockTrustApi.listIssuers.and.returnValue(throwError(() => new Error('Failed to load')));
|
||||
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(component.error()).toBe('Failed to load');
|
||||
}));
|
||||
|
||||
it('should filter by trust level', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.selectedTrustLevel.set('full');
|
||||
component.onFilterChange();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.listIssuers).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({ trustLevel: 'full' })
|
||||
);
|
||||
}));
|
||||
|
||||
it('should filter by issuer type', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.selectedType.set('csaf_publisher');
|
||||
component.onFilterChange();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.listIssuers).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({ issuerType: 'csaf_publisher' })
|
||||
);
|
||||
}));
|
||||
|
||||
it('should search issuers', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.searchQuery.set('Vendor');
|
||||
component.onSearch();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.listIssuers).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({ search: 'Vendor' })
|
||||
);
|
||||
}));
|
||||
|
||||
it('should clear filters', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.searchQuery.set('test');
|
||||
component.selectedTrustLevel.set('full');
|
||||
component.selectedType.set('csaf_publisher');
|
||||
component.clearFilters();
|
||||
tick();
|
||||
|
||||
expect(component.searchQuery()).toBe('');
|
||||
expect(component.selectedTrustLevel()).toBe('all');
|
||||
expect(component.selectedType()).toBe('all');
|
||||
}));
|
||||
|
||||
it('should compute hasFilters correctly', () => {
|
||||
expect(component.hasFilters()).toBeFalse();
|
||||
|
||||
component.searchQuery.set('test');
|
||||
expect(component.hasFilters()).toBeTrue();
|
||||
expect(component.issuers()).toEqual([issuer]);
|
||||
});
|
||||
|
||||
it('should compute average score', fakeAsync(() => {
|
||||
it('registers an issuer through the operator form', () => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
const avg = component.averageScore();
|
||||
expect(avg).toBeCloseTo((95 + 70 + 0) / 3, 1);
|
||||
}));
|
||||
component.createOpen.set(true);
|
||||
component.createName.set('Core Root CA');
|
||||
component.createUri.set('https://issuer.example/root');
|
||||
component.createTrustLevel.set('partial');
|
||||
component.submitCreate();
|
||||
|
||||
it('should count by trust level', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(component.countByLevel('full')).toBe(1);
|
||||
expect(component.countByLevel('partial')).toBe(1);
|
||||
expect(component.countByLevel('blocked')).toBe(1);
|
||||
}));
|
||||
|
||||
it('should select issuer', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.selectIssuer(mockIssuers[0]);
|
||||
expect(component.selectedIssuer()).toEqual(mockIssuers[0]);
|
||||
}));
|
||||
|
||||
it('should toggle config panel', () => {
|
||||
expect(component.showConfig()).toBeFalse();
|
||||
component.showConfig.set(true);
|
||||
expect(component.showConfig()).toBeTrue();
|
||||
expect(mockTrustApi.registerIssuer).toHaveBeenCalledWith({
|
||||
name: 'Core Root CA',
|
||||
issuerUri: 'https://issuer.example/root',
|
||||
trustLevel: 'partial',
|
||||
});
|
||||
expect(component.banner()).toContain('Registered issuer');
|
||||
});
|
||||
|
||||
it('should format type correctly', () => {
|
||||
expect(component.formatType('csaf_publisher')).toBe('CSAF Publisher');
|
||||
expect(component.formatType('vex_issuer')).toBe('VEX Issuer');
|
||||
expect(component.formatType('sbom_producer')).toBe('SBOM Producer');
|
||||
expect(component.formatType('attestation_authority')).toBe('Attestation Authority');
|
||||
it('requires a reason before blocking an issuer', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
component.openMutation(issuer, 'block');
|
||||
component.submitMutation();
|
||||
|
||||
expect(component.mutationError()).toBe('Reason is required.');
|
||||
expect(mockTrustApi.blockIssuer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should format trust level correctly', () => {
|
||||
expect(component.formatTrustLevel('full')).toBe('Full Trust');
|
||||
expect(component.formatTrustLevel('partial')).toBe('Partial');
|
||||
expect(component.formatTrustLevel('minimal')).toBe('Minimal');
|
||||
expect(component.formatTrustLevel('untrusted')).toBe('Untrusted');
|
||||
expect(component.formatTrustLevel('blocked')).toBe('Blocked');
|
||||
it('blocks and restores issuers through explicit lifecycle actions', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
component.openMutation(issuer, 'block');
|
||||
component.mutationReason.set('publisher compromised');
|
||||
component.submitMutation();
|
||||
|
||||
expect(mockTrustApi.blockIssuer).toHaveBeenCalledWith('issuer-001', { reason: 'publisher compromised' });
|
||||
|
||||
component.openMutation({ ...issuer, trustLevel: 'blocked', isActive: false }, 'unblock');
|
||||
component.restoreTrustLevel.set('full');
|
||||
component.submitMutation();
|
||||
|
||||
expect(mockTrustApi.unblockIssuer).toHaveBeenCalledWith('issuer-001', { trustLevel: 'full' });
|
||||
});
|
||||
|
||||
it('should handle pagination', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
it('surfaces load errors', () => {
|
||||
mockTrustApi.listIssuers.and.returnValue(throwError(() => new Error('issuers unavailable')));
|
||||
component.loadIssuers();
|
||||
|
||||
component.onPageChange(2);
|
||||
tick();
|
||||
|
||||
expect(component.pageNumber()).toBe(2);
|
||||
expect(mockTrustApi.listIssuers).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({ pageNumber: 2 })
|
||||
);
|
||||
}));
|
||||
|
||||
it('should sort by column', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.onSort('trustScore');
|
||||
expect(component.sortBy()).toBe('trustScore');
|
||||
expect(component.sortDirection()).toBe('desc');
|
||||
|
||||
component.onSort('trustScore');
|
||||
expect(component.sortDirection()).toBe('asc');
|
||||
}));
|
||||
expect(component.error()).toBe('issuers unavailable');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
/**
|
||||
* @file issuer-trust-list.component.ts
|
||||
* @sprint SPRINT_20251229_018c_FE
|
||||
* @description Live trusted issuer inventory aligned to the administration trust API
|
||||
* @sprint SPRINT_20260315_006_FE
|
||||
* @description Operator-facing trusted issuer inventory with registration and lifecycle controls.
|
||||
*/
|
||||
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
import { TRUST_API, TrustApi } from '../../core/api/trust.client';
|
||||
import { TrustedIssuer, IssuerTrustLevel } from '../../core/api/trust.models';
|
||||
import { TRUST_API } from '../../core/api/trust.client';
|
||||
import { IssuerTrustLevel, RegisterIssuerRequest, TrustedIssuer } from '../../core/api/trust.models';
|
||||
|
||||
type IssuerMutationKind = 'block' | 'unblock';
|
||||
|
||||
@Component({
|
||||
selector: 'app-issuer-trust-list',
|
||||
@@ -21,14 +23,23 @@ import { TrustedIssuer, IssuerTrustLevel } from '../../core/api/trust.models';
|
||||
<div>
|
||||
<h2>Trusted Issuers</h2>
|
||||
<p>
|
||||
This view is bound to the live administration contract: issuer name, issuer URI, trust level, status, and update ownership.
|
||||
Promote, quarantine, and review publisher trust without leaving the trust workspace.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="btn-secondary" (click)="loadIssuers()" [disabled]="loading()">
|
||||
{{ loading() ? 'Refreshing...' : 'Refresh' }}
|
||||
</button>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-secondary" (click)="toggleCreate()" [attr.aria-pressed]="createOpen()">
|
||||
{{ createOpen() ? 'Close Form' : 'Add Issuer' }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="loadIssuers()" [disabled]="loading()">
|
||||
{{ loading() ? 'Refreshing...' : 'Refresh' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (banner()) {
|
||||
<div class="banner" [class.banner--error]="bannerTone() === 'error'">{{ banner() }}</div>
|
||||
}
|
||||
|
||||
<div class="issuer-list__filters">
|
||||
<label class="filter-field">
|
||||
<span>Search</span>
|
||||
@@ -57,16 +68,66 @@ import { TrustedIssuer, IssuerTrustLevel } from '../../core/api/trust.models';
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="contract-note">
|
||||
Score tuning, issuer blocking, and document-volume analytics are not exposed by the current backend contract, so they are intentionally omitted here.
|
||||
</div>
|
||||
@if (createOpen()) {
|
||||
<section class="workspace-card">
|
||||
<header class="workspace-card__header">
|
||||
<div>
|
||||
<h3>Register Issuer</h3>
|
||||
<p>Add a publisher or attestation authority with an explicit initial trust level.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="form-grid">
|
||||
<label class="filter-field">
|
||||
<span>Issuer Name</span>
|
||||
<input
|
||||
type="text"
|
||||
[ngModel]="createName()"
|
||||
(ngModelChange)="createName.set($event)"
|
||||
placeholder="Core Root CA"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="filter-field">
|
||||
<span>Trust Level</span>
|
||||
<select [ngModel]="createTrustLevel()" (ngModelChange)="createTrustLevel.set($event)">
|
||||
<option value="full">Full</option>
|
||||
<option value="partial">Partial</option>
|
||||
<option value="minimal">Minimal</option>
|
||||
<option value="untrusted">Untrusted</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="filter-field">
|
||||
<span>Issuer URI</span>
|
||||
<input
|
||||
type="url"
|
||||
[ngModel]="createUri()"
|
||||
(ngModelChange)="createUri.set($event)"
|
||||
placeholder="https://issuer.example/root"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@if (createError()) {
|
||||
<div class="inline-error">{{ createError() }}</div>
|
||||
}
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-primary" (click)="submitCreate()" [disabled]="creating()">
|
||||
{{ creating() ? 'Registering...' : 'Register Issuer' }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="resetCreate()">Reset</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (loading()) {
|
||||
<div class="state state--loading">Loading issuers...</div>
|
||||
} @else if (error()) {
|
||||
<div class="state state--error">{{ error() }}</div>
|
||||
} @else if (issuers().length === 0) {
|
||||
<div class="state">No issuers found.</div>
|
||||
<div class="state">No issuers found. Add a trusted publisher before relying on external advisory or attestation content.</div>
|
||||
} @else {
|
||||
<table class="issuer-table">
|
||||
<thead>
|
||||
@@ -75,64 +136,186 @@ import { TrustedIssuer, IssuerTrustLevel } from '../../core/api/trust.models';
|
||||
<th>Issuer URI</th>
|
||||
<th>Trust Level</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Updated</th>
|
||||
<th>Updated By</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (issuer of issuers(); track issuer.issuerId) {
|
||||
<tr>
|
||||
<td>{{ issuer.displayName }}</td>
|
||||
<tr [class.is-selected]="selectedIssuer()?.issuerId === issuer.issuerId">
|
||||
<td>
|
||||
<button type="button" class="link-button" (click)="selectedIssuer.set(issuer)">
|
||||
{{ issuer.displayName }}
|
||||
</button>
|
||||
</td>
|
||||
<td><a [href]="issuer.url" target="_blank" rel="noopener">{{ issuer.url }}</a></td>
|
||||
<td><span class="badge" [class]="'badge--' + issuer.trustLevel">{{ formatTrustLevel(issuer.trustLevel) }}</span></td>
|
||||
<td>{{ issuer.metadata?.['status'] || (issuer.isActive ? 'active' : 'inactive') }}</td>
|
||||
<td>{{ issuer.createdAt | date:'medium' }}</td>
|
||||
<td>{{ issuer.updatedAt | date:'medium' }}</td>
|
||||
<td>{{ issuer.metadata?.['updatedBy'] || 'system' }}</td>
|
||||
<td class="actions-cell">
|
||||
<button type="button" class="btn-sm" (click)="selectedIssuer.set(issuer)">View</button>
|
||||
@if (issuer.trustLevel === 'blocked') {
|
||||
<button type="button" class="btn-sm" (click)="openMutation(issuer, 'unblock')">Unblock</button>
|
||||
} @else {
|
||||
<button type="button" class="btn-sm btn-sm--danger" (click)="openMutation(issuer, 'block')">Block</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (selectedIssuer()) {
|
||||
<section class="workspace-card">
|
||||
<header class="workspace-card__header">
|
||||
<div>
|
||||
<h3>{{ selectedIssuer()!.displayName }}</h3>
|
||||
<p>{{ selectedIssuer()!.issuerId }}</p>
|
||||
</div>
|
||||
<button type="button" class="btn-secondary" (click)="selectedIssuer.set(null)">Close</button>
|
||||
</header>
|
||||
|
||||
<dl class="detail-grid">
|
||||
<div>
|
||||
<dt>Current Trust</dt>
|
||||
<dd>{{ formatTrustLevel(selectedIssuer()!.trustLevel) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Publisher URI</dt>
|
||||
<dd>{{ selectedIssuer()!.url }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Status</dt>
|
||||
<dd>{{ selectedIssuer()!.metadata?.['status'] || (selectedIssuer()!.isActive ? 'active' : 'inactive') }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Operator Guidance</dt>
|
||||
<dd>
|
||||
Block an issuer when published material must stop influencing release policy immediately.
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (mutationIssuer() && mutationKind()) {
|
||||
<section class="workspace-card workspace-card--danger">
|
||||
<header class="workspace-card__header">
|
||||
<div>
|
||||
<h3>{{ mutationKind() === 'block' ? 'Block Issuer' : 'Restore Issuer' }}</h3>
|
||||
<p>{{ mutationIssuer()!.displayName }} · {{ mutationIssuer()!.issuerId }}</p>
|
||||
</div>
|
||||
<button type="button" class="btn-secondary" (click)="closeMutation()">Cancel</button>
|
||||
</header>
|
||||
|
||||
<p class="impact-copy">
|
||||
@if (mutationKind() === 'block') {
|
||||
Blocking stops this issuer from contributing trusted evidence until it is explicitly restored.
|
||||
} @else {
|
||||
Restoring returns the issuer to active use with the selected trust level.
|
||||
}
|
||||
</p>
|
||||
|
||||
@if (mutationKind() === 'unblock') {
|
||||
<label class="filter-field">
|
||||
<span>Restored Trust Level</span>
|
||||
<select [ngModel]="restoreTrustLevel()" (ngModelChange)="restoreTrustLevel.set($event)">
|
||||
<option value="minimal">Minimal</option>
|
||||
<option value="partial">Partial</option>
|
||||
<option value="full">Full</option>
|
||||
<option value="untrusted">Untrusted</option>
|
||||
</select>
|
||||
</label>
|
||||
}
|
||||
|
||||
<label class="filter-field">
|
||||
<span>{{ mutationKind() === 'block' ? 'Reason' : 'Operator Note' }}</span>
|
||||
<textarea
|
||||
rows="3"
|
||||
[ngModel]="mutationReason()"
|
||||
(ngModelChange)="mutationReason.set($event)"
|
||||
placeholder="Capture the investigation, approval, or publisher status"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
@if (mutationError()) {
|
||||
<div class="inline-error">{{ mutationError() }}</div>
|
||||
}
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-primary" (click)="submitMutation()" [disabled]="mutating()">
|
||||
{{ mutating() ? 'Applying...' : (mutationKind() === 'block' ? 'Confirm Block' : 'Restore Issuer') }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="closeMutation()">Cancel</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.issuer-list { padding: 1.5rem; display: grid; gap: 1rem; }
|
||||
.issuer-list__header { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; }
|
||||
.issuer-list__header h2 { margin: 0 0 0.35rem; }
|
||||
.issuer-list__header p { margin: 0; color: var(--color-text-secondary); max-width: 52rem; }
|
||||
.issuer-list__header, .workspace-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.issuer-list__header h2, .workspace-card__header h3 { margin: 0 0 0.35rem; }
|
||||
.issuer-list__header p, .workspace-card__header p, .impact-copy {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 54rem;
|
||||
}
|
||||
.header-actions, .form-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.issuer-list__filters { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; }
|
||||
.filter-field { display: grid; gap: 0.25rem; min-width: 15rem; }
|
||||
.filter-field span { font-size: 0.78rem; color: var(--color-text-secondary); text-transform: uppercase; }
|
||||
.filter-field input, .filter-field select {
|
||||
.filter-field input, .filter-field select, .filter-field textarea {
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-primary);
|
||||
font: inherit;
|
||||
}
|
||||
.contract-note {
|
||||
.workspace-card {
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.workspace-card--danger {
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
background: rgba(239, 68, 68, 0.05);
|
||||
}
|
||||
.form-grid, .detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
.detail-grid dt { font-size: 0.78rem; color: var(--color-text-secondary); text-transform: uppercase; }
|
||||
.detail-grid dd { margin: 0.2rem 0 0; color: var(--color-text-primary); word-break: break-word; }
|
||||
.banner, .state, .inline-error {
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.state {
|
||||
padding: 2rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
.state--error {
|
||||
.banner { background: rgba(74, 222, 128, 0.12); color: var(--color-status-success-border); }
|
||||
.banner--error, .inline-error, .state--error {
|
||||
color: var(--color-status-error);
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
.state {
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
.issuer-table { width: 100%; border-collapse: collapse; }
|
||||
.issuer-table th, .issuer-table td {
|
||||
padding: 0.75rem 0.9rem;
|
||||
@@ -146,17 +329,33 @@ import { TrustedIssuer, IssuerTrustLevel } from '../../core/api/trust.models';
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
.issuer-table tr.is-selected { background: rgba(34, 211, 238, 0.08); }
|
||||
.issuer-table a { color: var(--color-status-info); word-break: break-word; }
|
||||
.btn-secondary, .btn-link {
|
||||
.actions-cell { white-space: nowrap; }
|
||||
.link-button, .btn-sm, .btn-primary, .btn-secondary, .btn-link {
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
}
|
||||
.btn-secondary {
|
||||
padding: 0.4rem 0.7rem;
|
||||
.link-button {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-status-info);
|
||||
text-align: left;
|
||||
}
|
||||
.btn-sm, .btn-secondary, .btn-primary {
|
||||
padding: 0.45rem 0.8rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--color-status-info);
|
||||
border-color: var(--color-status-info);
|
||||
color: #04131a;
|
||||
}
|
||||
.btn-sm--danger { color: var(--color-status-error); }
|
||||
.btn-link {
|
||||
border: none;
|
||||
background: transparent;
|
||||
@@ -182,9 +381,26 @@ export class IssuerTrustListComponent {
|
||||
readonly issuers = signal<TrustedIssuer[]>([]);
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly banner = signal<string | null>(null);
|
||||
readonly bannerTone = signal<'success' | 'error'>('success');
|
||||
readonly selectedIssuer = signal<TrustedIssuer | null>(null);
|
||||
readonly searchQuery = signal('');
|
||||
readonly selectedTrustLevel = signal<IssuerTrustLevel | 'all'>('all');
|
||||
|
||||
readonly createOpen = signal(false);
|
||||
readonly createName = signal('');
|
||||
readonly createUri = signal('');
|
||||
readonly createTrustLevel = signal<Exclude<IssuerTrustLevel, 'blocked'>>('partial');
|
||||
readonly createError = signal<string | null>(null);
|
||||
readonly creating = signal(false);
|
||||
|
||||
readonly mutationIssuer = signal<TrustedIssuer | null>(null);
|
||||
readonly mutationKind = signal<IssuerMutationKind | null>(null);
|
||||
readonly mutationReason = signal('');
|
||||
readonly restoreTrustLevel = signal<Exclude<IssuerTrustLevel, 'blocked'>>('minimal');
|
||||
readonly mutationError = signal<string | null>(null);
|
||||
readonly mutating = signal(false);
|
||||
|
||||
readonly hasFilters = computed(() => this.searchQuery().trim().length > 0 || this.selectedTrustLevel() !== 'all');
|
||||
|
||||
constructor() {
|
||||
@@ -205,6 +421,9 @@ export class IssuerTrustListComponent {
|
||||
}).subscribe({
|
||||
next: (result) => {
|
||||
this.issuers.set([...result.items]);
|
||||
if (this.selectedIssuer()) {
|
||||
this.selectedIssuer.set(result.items.find((item) => item.issuerId === this.selectedIssuer()!.issuerId) ?? null);
|
||||
}
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
@@ -224,7 +443,117 @@ export class IssuerTrustListComponent {
|
||||
this.loadIssuers();
|
||||
}
|
||||
|
||||
toggleCreate(): void {
|
||||
this.createOpen.update((value) => !value);
|
||||
this.createError.set(null);
|
||||
}
|
||||
|
||||
resetCreate(): void {
|
||||
this.createName.set('');
|
||||
this.createUri.set('');
|
||||
this.createTrustLevel.set('partial');
|
||||
this.createError.set(null);
|
||||
}
|
||||
|
||||
submitCreate(): void {
|
||||
const name = this.createName().trim();
|
||||
const issuerUri = this.createUri().trim();
|
||||
|
||||
if (!name) {
|
||||
this.createError.set('Issuer name is required.');
|
||||
return;
|
||||
}
|
||||
if (!issuerUri) {
|
||||
this.createError.set('Issuer URI is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
const request: RegisterIssuerRequest = {
|
||||
name,
|
||||
issuerUri,
|
||||
trustLevel: this.createTrustLevel(),
|
||||
};
|
||||
|
||||
this.creating.set(true);
|
||||
this.createError.set(null);
|
||||
this.trustApi.registerIssuer(request).subscribe({
|
||||
next: (created) => {
|
||||
this.creating.set(false);
|
||||
this.createOpen.set(false);
|
||||
this.resetCreate();
|
||||
this.bannerTone.set('success');
|
||||
this.banner.set(`Registered issuer ${created.displayName}.`);
|
||||
this.selectedIssuer.set(created);
|
||||
this.loadIssuers();
|
||||
},
|
||||
error: (err) => {
|
||||
this.creating.set(false);
|
||||
this.createError.set(err?.error?.error || err?.message || 'Failed to register issuer.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
openMutation(issuer: TrustedIssuer, kind: IssuerMutationKind): void {
|
||||
this.mutationIssuer.set(issuer);
|
||||
this.mutationKind.set(kind);
|
||||
this.mutationReason.set('');
|
||||
this.restoreTrustLevel.set('minimal');
|
||||
this.mutationError.set(null);
|
||||
}
|
||||
|
||||
closeMutation(): void {
|
||||
this.mutationIssuer.set(null);
|
||||
this.mutationKind.set(null);
|
||||
this.mutationReason.set('');
|
||||
this.mutationError.set(null);
|
||||
}
|
||||
|
||||
submitMutation(): void {
|
||||
const issuer = this.mutationIssuer();
|
||||
const kind = this.mutationKind();
|
||||
if (!issuer || !kind) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reason = this.mutationReason().trim();
|
||||
if (kind === 'block' && !reason) {
|
||||
this.mutationError.set('Reason is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.mutating.set(true);
|
||||
this.mutationError.set(null);
|
||||
|
||||
const request$ = kind === 'block'
|
||||
? this.trustApi.blockIssuer(issuer.issuerId, { reason })
|
||||
: this.trustApi.unblockIssuer(issuer.issuerId, { trustLevel: this.restoreTrustLevel() });
|
||||
|
||||
request$.subscribe({
|
||||
next: (updated) => {
|
||||
this.mutating.set(false);
|
||||
this.bannerTone.set('success');
|
||||
this.banner.set(kind === 'block'
|
||||
? `Blocked issuer ${updated.displayName}.`
|
||||
: `Restored issuer ${updated.displayName} at ${this.formatTrustLevel(updated.trustLevel)}.`);
|
||||
this.closeMutation();
|
||||
this.selectedIssuer.set(updated);
|
||||
this.loadIssuers();
|
||||
},
|
||||
error: (err) => {
|
||||
this.mutating.set(false);
|
||||
this.mutationError.set(err?.error?.error || err?.message || `Failed to ${kind} issuer.`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
formatTrustLevel(level: IssuerTrustLevel): string {
|
||||
return level.charAt(0).toUpperCase() + level.slice(1);
|
||||
switch (level) {
|
||||
case 'full':
|
||||
return 'Full Trust';
|
||||
case 'partial':
|
||||
return 'Partial Trust';
|
||||
default:
|
||||
return level.charAt(0).toUpperCase() + level.slice(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,270 +1,119 @@
|
||||
/**
|
||||
* @file signing-key-dashboard.component.spec.ts
|
||||
* @sprint SPRINT_20251229_018c_FE
|
||||
* @description Unit tests for SigningKeyDashboardComponent
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { SigningKeyDashboardComponent } from './signing-key-dashboard.component';
|
||||
|
||||
import { TRUST_API, TrustApi } from '../../core/api/trust.client';
|
||||
import { SigningKey, SigningKeyStatus, KeyExpiryAlert, PagedResult } from '../../core/api/trust.models';
|
||||
import { PagedResult, SigningKey } from '../../core/api/trust.models';
|
||||
import { SigningKeyDashboardComponent } from './signing-key-dashboard.component';
|
||||
|
||||
describe('SigningKeyDashboardComponent', () => {
|
||||
let component: SigningKeyDashboardComponent;
|
||||
let fixture: ComponentFixture<SigningKeyDashboardComponent>;
|
||||
let mockTrustApi: jasmine.SpyObj<TrustApi>;
|
||||
|
||||
const mockKeys: SigningKey[] = [
|
||||
{
|
||||
keyId: 'key-001',
|
||||
tenantId: 'tenant-1',
|
||||
name: 'Production Key',
|
||||
description: 'Main signing key',
|
||||
keyType: 'asymmetric',
|
||||
algorithm: 'RS256',
|
||||
keySize: 2048,
|
||||
purpose: 'attestation',
|
||||
status: 'active',
|
||||
publicKeyFingerprint: 'sha256:abc123',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
expiresAt: '2025-01-01T00:00:00Z',
|
||||
usageCount: 100,
|
||||
const key: SigningKey = {
|
||||
keyId: 'key-001',
|
||||
tenantId: 'tenant-1',
|
||||
name: 'prod-attestation-k1',
|
||||
keyType: 'Ed25519',
|
||||
algorithm: 'ed25519',
|
||||
keySize: 256,
|
||||
purpose: 'attestation',
|
||||
status: 'active',
|
||||
publicKeyFingerprint: 'sha256:abc',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
expiresAt: '2026-12-31T00:00:00Z',
|
||||
usageCount: 0,
|
||||
metadata: {
|
||||
currentVersion: '1',
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
updatedBy: 'operator',
|
||||
},
|
||||
{
|
||||
keyId: 'key-002',
|
||||
tenantId: 'tenant-1',
|
||||
name: 'Backup Key',
|
||||
description: 'Backup signing key',
|
||||
keyType: 'asymmetric',
|
||||
algorithm: 'RS256',
|
||||
keySize: 2048,
|
||||
purpose: 'sbom_signing',
|
||||
status: 'expiring_soon',
|
||||
publicKeyFingerprint: 'sha256:def456',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
expiresAt: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
usageCount: 50,
|
||||
},
|
||||
];
|
||||
|
||||
const mockExpiryAlerts: KeyExpiryAlert[] = [
|
||||
{
|
||||
keyId: 'key-002',
|
||||
keyName: 'Backup Key',
|
||||
purpose: 'sbom_signing',
|
||||
expiresAt: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
daysUntilExpiry: 15,
|
||||
severity: 'warning',
|
||||
suggestedAction: 'Rotate key soon',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockTrustApi = jasmine.createSpyObj<TrustApi>('TrustApi', [
|
||||
'listKeys',
|
||||
'getKeyExpiryAlerts',
|
||||
'revokeKey',
|
||||
'createKey',
|
||||
'rotateKey',
|
||||
'revokeKey',
|
||||
]);
|
||||
|
||||
mockTrustApi.listKeys.and.returnValue(of({
|
||||
items: mockKeys,
|
||||
totalCount: mockKeys.length,
|
||||
items: [key],
|
||||
totalCount: 1,
|
||||
pageNumber: 1,
|
||||
pageSize: 20,
|
||||
pageSize: 200,
|
||||
totalPages: 1,
|
||||
} as PagedResult<SigningKey>));
|
||||
|
||||
mockTrustApi.getKeyExpiryAlerts.and.returnValue(of(mockExpiryAlerts));
|
||||
mockTrustApi.createKey.and.returnValue(of(key));
|
||||
mockTrustApi.rotateKey.and.returnValue(of({ ...key, metadata: { ...key.metadata, currentVersion: '2' } }));
|
||||
mockTrustApi.revokeKey.and.returnValue(of(void 0));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SigningKeyDashboardComponent],
|
||||
providers: [
|
||||
{ provide: TRUST_API, useValue: mockTrustApi },
|
||||
],
|
||||
providers: [{ provide: TRUST_API, useValue: mockTrustApi }],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SigningKeyDashboardComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should load keys on init', fakeAsync(() => {
|
||||
it('loads keys on init', () => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.listKeys).toHaveBeenCalled();
|
||||
expect(component.keys().length).toBe(2);
|
||||
}));
|
||||
|
||||
it('should load expiry alerts on init', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.getKeyExpiryAlerts).toHaveBeenCalledWith(30);
|
||||
expect(component.expiryAlerts().length).toBe(1);
|
||||
}));
|
||||
|
||||
it('should handle load keys error', fakeAsync(() => {
|
||||
mockTrustApi.listKeys.and.returnValue(throwError(() => new Error('Failed to load')));
|
||||
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(component.error()).toBe('Failed to load');
|
||||
}));
|
||||
|
||||
it('should filter by status', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.selectedStatus.set('active');
|
||||
component.onFilterChange();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.listKeys).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({ status: 'active' })
|
||||
);
|
||||
}));
|
||||
|
||||
it('should filter by purpose', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.selectedPurpose.set('attestation');
|
||||
component.onFilterChange();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.listKeys).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({ purpose: 'attestation' })
|
||||
);
|
||||
}));
|
||||
|
||||
it('should search keys', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.searchQuery.set('Production');
|
||||
component.onSearch();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.listKeys).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({ search: 'Production' })
|
||||
);
|
||||
}));
|
||||
|
||||
it('should clear filters', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.searchQuery.set('test');
|
||||
component.selectedStatus.set('active');
|
||||
component.selectedPurpose.set('attestation');
|
||||
component.clearFilters();
|
||||
tick();
|
||||
|
||||
expect(component.searchQuery()).toBe('');
|
||||
expect(component.selectedStatus()).toBe('all');
|
||||
expect(component.selectedPurpose()).toBe('all');
|
||||
}));
|
||||
|
||||
it('should compute hasFilters correctly', () => {
|
||||
expect(component.hasFilters()).toBeFalse();
|
||||
|
||||
component.searchQuery.set('test');
|
||||
expect(component.hasFilters()).toBeTrue();
|
||||
|
||||
component.searchQuery.set('');
|
||||
component.selectedStatus.set('active');
|
||||
expect(component.hasFilters()).toBeTrue();
|
||||
expect(component.keys()).toEqual([key]);
|
||||
});
|
||||
|
||||
it('should toggle sort direction', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
it('validates create key input before calling the API', () => {
|
||||
component.submitCreate();
|
||||
|
||||
component.onSort('name');
|
||||
expect(component.sortBy()).toBe('name');
|
||||
expect(component.sortDirection()).toBe('asc');
|
||||
|
||||
component.onSort('name');
|
||||
expect(component.sortDirection()).toBe('desc');
|
||||
}));
|
||||
|
||||
it('should select key', () => {
|
||||
component.selectKey(mockKeys[0]);
|
||||
expect(component.selectedKey()).toEqual(mockKeys[0]);
|
||||
expect(component.createError()).toBe('Alias is required.');
|
||||
expect(mockTrustApi.createKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should open rotation wizard', fakeAsync(() => {
|
||||
it('creates a key and reloads the inventory', () => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.openRotationWizard('key-001');
|
||||
expect(component.rotatingKey()?.keyId).toBe('key-001');
|
||||
expect(component.selectedKey()).toBeNull();
|
||||
}));
|
||||
component.createOpen.set(true);
|
||||
component.createAlias.set('prod-attestation-k2');
|
||||
component.createAlgorithm.set('ecdsa-p256');
|
||||
component.submitCreate();
|
||||
|
||||
it('should close rotation wizard', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.openRotationWizard('key-001');
|
||||
component.closeRotationWizard();
|
||||
expect(component.rotatingKey()).toBeNull();
|
||||
}));
|
||||
|
||||
it('should handle pagination', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.onPageChange(2);
|
||||
tick();
|
||||
|
||||
expect(component.pageNumber()).toBe(2);
|
||||
expect(mockTrustApi.listKeys).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({ pageNumber: 2 })
|
||||
);
|
||||
}));
|
||||
|
||||
it('should format status correctly', () => {
|
||||
expect(component.formatStatus('active')).toBe('Active');
|
||||
expect(component.formatStatus('expiring_soon')).toBe('Expiring Soon');
|
||||
expect(component.formatStatus('expired')).toBe('Expired');
|
||||
expect(component.formatStatus('revoked')).toBe('Revoked');
|
||||
expect(component.formatStatus('pending_rotation')).toBe('Pending Rotation');
|
||||
expect(mockTrustApi.createKey).toHaveBeenCalledWith({
|
||||
alias: 'prod-attestation-k2',
|
||||
algorithm: 'ecdsa-p256',
|
||||
metadataJson: undefined,
|
||||
});
|
||||
expect(component.banner()).toContain('Registered signing key');
|
||||
expect(mockTrustApi.listKeys).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should format purpose correctly', () => {
|
||||
expect(component.formatPurpose('attestation')).toBe('Attestation');
|
||||
expect(component.formatPurpose('sbom_signing')).toBe('SBOM');
|
||||
expect(component.formatPurpose('vex_signing')).toBe('VEX');
|
||||
expect(component.formatPurpose('code_signing')).toBe('Code');
|
||||
expect(component.formatPurpose('tls')).toBe('TLS');
|
||||
it('requires a reason before rotating or revoking', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
component.openMutation(key, 'rotate');
|
||||
component.submitMutation();
|
||||
|
||||
expect(component.mutationError()).toBe('Reason is required.');
|
||||
expect(mockTrustApi.rotateKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should calculate days until expiry', () => {
|
||||
const futureKey: SigningKey = {
|
||||
...mockKeys[0],
|
||||
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
};
|
||||
expect(component.getDaysUntilExpiry(futureKey)).toBeCloseTo(30, 0);
|
||||
it('rotates a key through the in-app confirmation workflow', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
component.openMutation(key, 'rotate');
|
||||
component.mutationReason.set('scheduled rotation');
|
||||
component.submitMutation();
|
||||
|
||||
expect(mockTrustApi.rotateKey).toHaveBeenCalledWith('key-001', { reason: 'scheduled rotation' });
|
||||
expect(component.banner()).toContain('Rotated signing key');
|
||||
});
|
||||
|
||||
it('should detect expiring soon keys', () => {
|
||||
const expiringKey: SigningKey = {
|
||||
...mockKeys[0],
|
||||
expiresAt: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
};
|
||||
expect(component.isExpiringSoon(expiringKey)).toBeTrue();
|
||||
it('surfaces load errors', () => {
|
||||
mockTrustApi.listKeys.and.returnValue(throwError(() => new Error('inventory unavailable')));
|
||||
component.loadKeys();
|
||||
|
||||
const validKey: SigningKey = {
|
||||
...mockKeys[0],
|
||||
expiresAt: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
};
|
||||
expect(component.isExpiringSoon(validKey)).toBeFalse();
|
||||
expect(component.error()).toBe('inventory unavailable');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
/**
|
||||
* @file signing-key-dashboard.component.ts
|
||||
* @sprint SPRINT_20251229_018c_FE
|
||||
* @description Live signing key inventory aligned to the administration trust API
|
||||
* @sprint SPRINT_20260315_006_FE
|
||||
* @description Operator-facing signing key inventory with create, rotate, and revoke workflows.
|
||||
*/
|
||||
|
||||
import { Component, ChangeDetectionStrategy, computed, inject, signal } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
import { TRUST_API, TrustApi } from '../../core/api/trust.client';
|
||||
import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
|
||||
import { TRUST_API } from '../../core/api/trust.client';
|
||||
import { CreateSigningKeyRequest, SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
|
||||
|
||||
type KeyMutationKind = 'rotate' | 'revoke';
|
||||
|
||||
@Component({
|
||||
selector: 'app-signing-key-dashboard',
|
||||
@@ -21,14 +23,23 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
|
||||
<div>
|
||||
<h2>Signing Keys</h2>
|
||||
<p>
|
||||
Live administration exposes alias, algorithm, version, lifecycle state, and update ownership.
|
||||
Rotate and retire signing material with explicit operator confirmation before release evidence is affected.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="btn-secondary" (click)="loadKeys()" [disabled]="loading()">
|
||||
{{ loading() ? 'Refreshing...' : 'Refresh' }}
|
||||
</button>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-secondary" (click)="toggleCreate()" [attr.aria-pressed]="createOpen()">
|
||||
{{ createOpen() ? 'Close Form' : 'Add Key' }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="loadKeys()" [disabled]="loading()">
|
||||
{{ loading() ? 'Refreshing...' : 'Refresh' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (banner()) {
|
||||
<div class="banner" [class.banner--error]="bannerTone() === 'error'">{{ banner() }}</div>
|
||||
}
|
||||
|
||||
<div class="key-dashboard__filters">
|
||||
<label class="filter-field">
|
||||
<span>Search</span>
|
||||
@@ -57,17 +68,65 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="contract-note">
|
||||
Usage statistics, fingerprint material, and expiry policy are not part of the current administration contract,
|
||||
so this page only shows fields the backend actually owns.
|
||||
</div>
|
||||
@if (createOpen()) {
|
||||
<section class="workspace-card">
|
||||
<header class="workspace-card__header">
|
||||
<div>
|
||||
<h3>Register Signing Key</h3>
|
||||
<p>Create a key record before binding it into certificate or evidence workflows.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="form-grid">
|
||||
<label class="filter-field">
|
||||
<span>Alias</span>
|
||||
<input
|
||||
type="text"
|
||||
[ngModel]="createAlias()"
|
||||
(ngModelChange)="createAlias.set($event)"
|
||||
placeholder="prod-attestation-k1"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="filter-field">
|
||||
<span>Algorithm</span>
|
||||
<select [ngModel]="createAlgorithm()" (ngModelChange)="createAlgorithm.set($event)">
|
||||
<option value="ed25519">Ed25519</option>
|
||||
<option value="ecdsa-p256">ECDSA P-256</option>
|
||||
<option value="rsa-4096">RSA 4096</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="filter-field">
|
||||
<span>Metadata</span>
|
||||
<textarea
|
||||
rows="4"
|
||||
[ngModel]="createMetadata()"
|
||||
(ngModelChange)="createMetadata.set($event)"
|
||||
placeholder='{"owner":"security","rotation":"quarterly"}'
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
@if (createError()) {
|
||||
<div class="inline-error">{{ createError() }}</div>
|
||||
}
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-primary" (click)="submitCreate()" [disabled]="creating()">
|
||||
{{ creating() ? 'Creating...' : 'Create Key' }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="resetCreate()">Reset</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (loading()) {
|
||||
<div class="state state--loading">Loading signing keys...</div>
|
||||
} @else if (error()) {
|
||||
<div class="state state--error">{{ error() }}</div>
|
||||
} @else if (keys().length === 0) {
|
||||
<div class="state">No signing keys found.</div>
|
||||
<div class="state">No signing keys found. Register the first key to enable evidence signing and release approvals.</div>
|
||||
} @else {
|
||||
<table class="key-table">
|
||||
<thead>
|
||||
@@ -76,7 +135,6 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
|
||||
<th>Algorithm</th>
|
||||
<th>Version</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Updated</th>
|
||||
<th>Updated By</th>
|
||||
<th>Actions</th>
|
||||
@@ -93,7 +151,6 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
|
||||
<td>{{ key.algorithm }}</td>
|
||||
<td>{{ key.metadata?.['currentVersion'] || '1' }}</td>
|
||||
<td><span class="badge" [class]="'badge--' + key.status">{{ formatStatus(key.status) }}</span></td>
|
||||
<td>{{ key.createdAt | date:'medium' }}</td>
|
||||
<td>{{ (key.metadata?.['updatedAt'] || key.createdAt) | date:'medium' }}</td>
|
||||
<td>{{ key.metadata?.['updatedBy'] || 'system' }}</td>
|
||||
<td class="actions-cell">
|
||||
@@ -101,14 +158,14 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
|
||||
<button
|
||||
type="button"
|
||||
class="btn-sm"
|
||||
(click)="rotateKey(key)"
|
||||
(click)="openMutation(key, 'rotate')"
|
||||
[disabled]="key.status === 'revoked' || mutatingKeyId() === key.keyId">
|
||||
Rotate
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-sm btn-sm--danger"
|
||||
(click)="revokeKey(key)"
|
||||
(click)="openMutation(key, 'revoke')"
|
||||
[disabled]="key.status === 'revoked' || mutatingKeyId() === key.keyId">
|
||||
Revoke
|
||||
</button>
|
||||
@@ -120,8 +177,8 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
|
||||
}
|
||||
|
||||
@if (selectedKey()) {
|
||||
<section class="detail-card">
|
||||
<header class="detail-card__header">
|
||||
<section class="workspace-card">
|
||||
<header class="workspace-card__header">
|
||||
<div>
|
||||
<h3>{{ selectedKey()!.name }}</h3>
|
||||
<p>{{ selectedKey()!.keyId }}</p>
|
||||
@@ -151,50 +208,118 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
|
||||
<dd>{{ selectedKey()!.createdAt | date:'medium' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Updated</dt>
|
||||
<dd>{{ (selectedKey()!.metadata?.['updatedAt'] || selectedKey()!.createdAt) | date:'medium' }}</dd>
|
||||
<dt>Operator Guidance</dt>
|
||||
<dd>
|
||||
Rotate when custody or policy changes. Revoke only when the key must stop signing immediately.
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (mutationKey() && mutationKind()) {
|
||||
<section class="workspace-card workspace-card--danger">
|
||||
<header class="workspace-card__header">
|
||||
<div>
|
||||
<h3>{{ mutationKind() === 'rotate' ? 'Rotate Signing Key' : 'Revoke Signing Key' }}</h3>
|
||||
<p>{{ mutationKey()!.name }} · {{ mutationKey()!.keyId }}</p>
|
||||
</div>
|
||||
<button type="button" class="btn-secondary" (click)="closeMutation()">Cancel</button>
|
||||
</header>
|
||||
|
||||
<p class="impact-copy">
|
||||
@if (mutationKind() === 'rotate') {
|
||||
Rotation creates a new active version and shifts future evidence signing to the new key.
|
||||
} @else {
|
||||
Revocation immediately marks the key unusable for future signing operations.
|
||||
}
|
||||
</p>
|
||||
|
||||
<label class="filter-field">
|
||||
<span>Reason</span>
|
||||
<textarea
|
||||
rows="3"
|
||||
[ngModel]="mutationReason()"
|
||||
(ngModelChange)="mutationReason.set($event)"
|
||||
placeholder="Describe the rotation or revocation decision"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
@if (mutationError()) {
|
||||
<div class="inline-error">{{ mutationError() }}</div>
|
||||
}
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-primary" (click)="submitMutation()" [disabled]="mutating()">
|
||||
{{ mutating() ? 'Applying...' : (mutationKind() === 'rotate' ? 'Confirm Rotation' : 'Confirm Revocation') }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="closeMutation()">Cancel</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.key-dashboard { padding: 1.5rem; display: grid; gap: 1rem; }
|
||||
.key-dashboard__header { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; }
|
||||
.key-dashboard__header h2 { margin: 0 0 0.35rem; }
|
||||
.key-dashboard__header p { margin: 0; color: var(--color-text-secondary); max-width: 52rem; }
|
||||
.key-dashboard__header, .workspace-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.key-dashboard__header h2, .workspace-card__header h3 { margin: 0 0 0.35rem; }
|
||||
.key-dashboard__header p, .workspace-card__header p, .impact-copy {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 54rem;
|
||||
}
|
||||
.header-actions, .form-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.key-dashboard__filters { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; }
|
||||
.filter-field { display: grid; gap: 0.25rem; min-width: 15rem; }
|
||||
.filter-field span { font-size: 0.78rem; color: var(--color-text-secondary); text-transform: uppercase; }
|
||||
.filter-field input, .filter-field select {
|
||||
.filter-field input, .filter-field select, .filter-field textarea {
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-primary);
|
||||
font: inherit;
|
||||
}
|
||||
.contract-note {
|
||||
.workspace-card {
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.workspace-card--danger {
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
background: rgba(239, 68, 68, 0.05);
|
||||
}
|
||||
.form-grid, .detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
.detail-grid dt { font-size: 0.78rem; color: var(--color-text-secondary); text-transform: uppercase; }
|
||||
.detail-grid dd { margin: 0.2rem 0 0; color: var(--color-text-primary); word-break: break-word; }
|
||||
.banner, .state, .inline-error {
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.state {
|
||||
padding: 2rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
.state--error {
|
||||
.banner { background: rgba(74, 222, 128, 0.12); color: var(--color-status-success-border); }
|
||||
.banner--error, .inline-error, .state--error {
|
||||
color: var(--color-status-error);
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
.state {
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
.key-table { width: 100%; border-collapse: collapse; }
|
||||
.key-table th, .key-table td {
|
||||
padding: 0.75rem 0.9rem;
|
||||
@@ -210,27 +335,30 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
|
||||
}
|
||||
.key-table tr.is-selected { background: rgba(34, 211, 238, 0.08); }
|
||||
.actions-cell { white-space: nowrap; }
|
||||
.link-button, .btn-sm, .btn-secondary, .btn-link {
|
||||
.link-button, .btn-sm, .btn-primary, .btn-secondary, .btn-link {
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
}
|
||||
.link-button {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-status-info);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.btn-sm, .btn-secondary {
|
||||
padding: 0.4rem 0.7rem;
|
||||
.btn-sm, .btn-secondary, .btn-primary {
|
||||
padding: 0.45rem 0.8rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
margin-right: 0.35rem;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--color-status-info);
|
||||
border-color: var(--color-status-info);
|
||||
color: #04131a;
|
||||
}
|
||||
.btn-sm--danger { color: var(--color-status-error); }
|
||||
.btn-secondary { margin-right: 0; }
|
||||
.btn-link {
|
||||
border: none;
|
||||
background: transparent;
|
||||
@@ -248,30 +376,6 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
|
||||
.badge--expiring_soon { background: rgba(251, 191, 36, 0.16); color: var(--color-status-warning-border); }
|
||||
.badge--expired, .badge--revoked { background: rgba(239, 68, 68, 0.12); color: var(--color-status-error); }
|
||||
.badge--pending_rotation { background: rgba(167, 139, 250, 0.16); color: var(--color-status-excepted-border); }
|
||||
.detail-card {
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.detail-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.detail-card__header h3 { margin: 0 0 0.25rem; }
|
||||
.detail-card__header p { margin: 0; color: var(--color-text-secondary); font-family: monospace; }
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||
gap: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
.detail-grid dt { font-size: 0.78rem; color: var(--color-text-secondary); text-transform: uppercase; }
|
||||
.detail-grid dd { margin: 0.2rem 0 0; color: var(--color-text-primary); word-break: break-word; }
|
||||
`],
|
||||
})
|
||||
export class SigningKeyDashboardComponent {
|
||||
@@ -280,11 +384,26 @@ export class SigningKeyDashboardComponent {
|
||||
readonly keys = signal<SigningKey[]>([]);
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly banner = signal<string | null>(null);
|
||||
readonly bannerTone = signal<'success' | 'error'>('success');
|
||||
readonly selectedKey = signal<SigningKey | null>(null);
|
||||
readonly mutatingKeyId = signal<string | null>(null);
|
||||
readonly searchQuery = signal('');
|
||||
readonly selectedStatus = signal<SigningKeyStatus | 'all'>('all');
|
||||
|
||||
readonly createOpen = signal(false);
|
||||
readonly createAlias = signal('');
|
||||
readonly createAlgorithm = signal('ed25519');
|
||||
readonly createMetadata = signal('');
|
||||
readonly createError = signal<string | null>(null);
|
||||
readonly creating = signal(false);
|
||||
|
||||
readonly mutationKey = signal<SigningKey | null>(null);
|
||||
readonly mutationKind = signal<KeyMutationKind | null>(null);
|
||||
readonly mutationReason = signal('');
|
||||
readonly mutationError = signal<string | null>(null);
|
||||
readonly mutating = signal(false);
|
||||
|
||||
readonly hasFilters = computed(() => this.searchQuery().trim().length > 0 || this.selectedStatus() !== 'all');
|
||||
|
||||
constructor() {
|
||||
@@ -318,54 +437,102 @@ export class SigningKeyDashboardComponent {
|
||||
}
|
||||
|
||||
applyFilters(): void {
|
||||
this.selectedKey.set(null);
|
||||
this.loadKeys();
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.searchQuery.set('');
|
||||
this.selectedStatus.set('all');
|
||||
this.applyFilters();
|
||||
this.loadKeys();
|
||||
}
|
||||
|
||||
rotateKey(key: SigningKey): void {
|
||||
const reason = window.prompt(`Rotation reason for "${key.name}"`, 'Routine rotation');
|
||||
if (reason === null) {
|
||||
toggleCreate(): void {
|
||||
this.createOpen.update((value) => !value);
|
||||
this.createError.set(null);
|
||||
}
|
||||
|
||||
resetCreate(): void {
|
||||
this.createAlias.set('');
|
||||
this.createAlgorithm.set('ed25519');
|
||||
this.createMetadata.set('');
|
||||
this.createError.set(null);
|
||||
}
|
||||
|
||||
submitCreate(): void {
|
||||
const alias = this.createAlias().trim();
|
||||
if (!alias) {
|
||||
this.createError.set('Alias is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.mutatingKeyId.set(key.keyId);
|
||||
this.error.set(null);
|
||||
this.trustApi.rotateKey(key.keyId, { reason }).subscribe({
|
||||
next: (updated) => {
|
||||
this.keys.update((items) => items.map((item) => item.keyId === updated.keyId ? updated : item));
|
||||
this.selectedKey.set(updated);
|
||||
this.mutatingKeyId.set(null);
|
||||
const request: CreateSigningKeyRequest = {
|
||||
alias,
|
||||
algorithm: this.createAlgorithm(),
|
||||
metadataJson: this.createMetadata().trim() || undefined,
|
||||
};
|
||||
|
||||
this.creating.set(true);
|
||||
this.createError.set(null);
|
||||
this.trustApi.createKey(request).subscribe({
|
||||
next: (created) => {
|
||||
this.creating.set(false);
|
||||
this.createOpen.set(false);
|
||||
this.resetCreate();
|
||||
this.bannerTone.set('success');
|
||||
this.banner.set(`Registered signing key ${created.name}.`);
|
||||
this.selectedKey.set(created);
|
||||
this.loadKeys();
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err?.error?.error || err?.message || `Failed to rotate "${key.name}".`);
|
||||
this.mutatingKeyId.set(null);
|
||||
this.creating.set(false);
|
||||
this.createError.set(err?.error?.error || err?.message || 'Failed to register signing key.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
revokeKey(key: SigningKey): void {
|
||||
const reason = window.prompt(`Revocation reason for "${key.name}"`, 'Compromised or retired');
|
||||
if (reason === null) {
|
||||
openMutation(key: SigningKey, kind: KeyMutationKind): void {
|
||||
this.mutationKey.set(key);
|
||||
this.mutationKind.set(kind);
|
||||
this.mutationReason.set('');
|
||||
this.mutationError.set(null);
|
||||
}
|
||||
|
||||
closeMutation(): void {
|
||||
this.mutationKey.set(null);
|
||||
this.mutationKind.set(null);
|
||||
this.mutationReason.set('');
|
||||
this.mutationError.set(null);
|
||||
}
|
||||
|
||||
submitMutation(): void {
|
||||
const key = this.mutationKey();
|
||||
const kind = this.mutationKind();
|
||||
const reason = this.mutationReason().trim();
|
||||
|
||||
if (!key || !kind) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!reason) {
|
||||
this.mutationError.set('Reason is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.mutating.set(true);
|
||||
this.mutatingKeyId.set(key.keyId);
|
||||
this.error.set(null);
|
||||
this.mutationError.set(null);
|
||||
|
||||
if (kind === 'rotate') {
|
||||
this.trustApi.rotateKey(key.keyId, { reason }).subscribe({
|
||||
next: () => this.handleMutationSuccess(key.name, 'Rotated'),
|
||||
error: (err) => this.handleMutationError(err, 'rotate'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.trustApi.revokeKey(key.keyId, reason).subscribe({
|
||||
next: () => {
|
||||
this.loadKeys();
|
||||
this.mutatingKeyId.set(null);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err?.error?.error || err?.message || `Failed to revoke "${key.name}".`);
|
||||
this.mutatingKeyId.set(null);
|
||||
},
|
||||
next: () => this.handleMutationSuccess(key.name, 'Revoked'),
|
||||
error: (err) => this.handleMutationError(err, 'revoke'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -379,4 +546,20 @@ export class SigningKeyDashboardComponent {
|
||||
return status.charAt(0).toUpperCase() + status.slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
private handleMutationSuccess(keyName: string, verb: 'Rotated' | 'Revoked'): void {
|
||||
this.mutating.set(false);
|
||||
this.mutatingKeyId.set(null);
|
||||
this.bannerTone.set('success');
|
||||
this.banner.set(`${verb} signing key ${keyName}.`);
|
||||
this.closeMutation();
|
||||
this.loadKeys();
|
||||
}
|
||||
|
||||
private handleMutationError(err: unknown, kind: KeyMutationKind): void {
|
||||
const error = err as { error?: { error?: string }; message?: string } | undefined;
|
||||
this.mutating.set(false);
|
||||
this.mutatingKeyId.set(null);
|
||||
this.mutationError.set(error?.error?.error || error?.message || `Failed to ${kind} signing key.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
|
||||
<span class="summary-card__value">{{ overview()!.inventory.keys }}</span>
|
||||
<span class="summary-card__label">Signing Keys</span>
|
||||
<span class="summary-card__detail">
|
||||
Administration inventory projection
|
||||
Keys available for current signing workflows
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -86,7 +86,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
|
||||
<span class="summary-card__value">{{ overview()!.inventory.issuers }}</span>
|
||||
<span class="summary-card__label">Trusted Issuers</span>
|
||||
<span class="summary-card__detail">
|
||||
Routed from live administration projection
|
||||
Publishers currently allowed to influence trust
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -97,7 +97,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
|
||||
<span class="summary-card__value">{{ overview()!.inventory.certificates }}</span>
|
||||
<span class="summary-card__label">Certificates</span>
|
||||
<span class="summary-card__detail">
|
||||
Evidence and issuer trust consumers stay linked
|
||||
Certificate expiry and revocation stay visible here
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,248 +1,199 @@
|
||||
/**
|
||||
* @file trust-analytics.component.spec.ts
|
||||
* @sprint SPRINT_20251229_018c_FE
|
||||
* @description Unit tests for TrustAnalyticsComponent
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { TrustAnalyticsComponent } from './trust-analytics.component';
|
||||
|
||||
import { TRUST_API, TrustApi } from '../../core/api/trust.client';
|
||||
import {
|
||||
TrustAnalyticsSummary,
|
||||
VerificationMetrics,
|
||||
IssuerReliabilityData,
|
||||
FailureAnalysis,
|
||||
TrustAnalyticsAlert,
|
||||
} from '../../core/api/trust.models';
|
||||
import { IssuerReliabilityAnalytics, TrustAnalyticsSummary, VerificationAnalytics } from '../../core/api/trust.models';
|
||||
import { TrustAnalyticsComponent } from './trust-analytics.component';
|
||||
|
||||
describe('TrustAnalyticsComponent', () => {
|
||||
let component: TrustAnalyticsComponent;
|
||||
let fixture: ComponentFixture<TrustAnalyticsComponent>;
|
||||
let mockTrustApi: jasmine.SpyObj<TrustApi>;
|
||||
|
||||
const mockSummary: TrustAnalyticsSummary = {
|
||||
totalVerifications: 10000,
|
||||
successfulVerifications: 9500,
|
||||
failedVerifications: 500,
|
||||
successRate: 95.0,
|
||||
averageVerificationTime: 125,
|
||||
peakVerificationTime: 350,
|
||||
activeSigningKeys: 5,
|
||||
activeIssuers: 20,
|
||||
trendDirection: 'up',
|
||||
trendPercentage: 2.5,
|
||||
};
|
||||
|
||||
const mockVerificationMetrics: VerificationMetrics = {
|
||||
timeRange: 'last_24h',
|
||||
dataPoints: [
|
||||
{ timestamp: '2024-01-15T00:00:00Z', successCount: 400, failureCount: 20, avgTime: 120 },
|
||||
{ timestamp: '2024-01-15T01:00:00Z', successCount: 450, failureCount: 15, avgTime: 115 },
|
||||
const summary: TrustAnalyticsSummary = {
|
||||
verificationSuccessRate: 96.2,
|
||||
issuerReliabilityScore: 88.3,
|
||||
certificateHealthScore: 91.4,
|
||||
keyHealthScore: 94.8,
|
||||
overallTrustScore: 92.1,
|
||||
alerts: [
|
||||
{
|
||||
alertId: 'alert-001',
|
||||
severity: 'warning',
|
||||
category: 'certificate',
|
||||
title: 'Gateway certificate expires soon',
|
||||
message: 'Renew before the release window closes.',
|
||||
createdAt: '2026-03-15T00:00:00Z',
|
||||
acknowledged: false,
|
||||
},
|
||||
],
|
||||
aggregations: {
|
||||
totalSuccess: 9500,
|
||||
totalFailure: 500,
|
||||
avgSuccessRate: 95.0,
|
||||
avgTime: 125,
|
||||
trends: {
|
||||
verificationTrend: 'stable',
|
||||
reliabilityTrend: 'improving',
|
||||
certificateTrend: 'declining',
|
||||
keyTrend: 'stable',
|
||||
},
|
||||
};
|
||||
|
||||
const mockIssuerReliability: IssuerReliabilityData[] = [
|
||||
{
|
||||
issuerId: 'issuer-001',
|
||||
issuerName: 'Vendor A',
|
||||
totalDocuments: 500,
|
||||
verifiedDocuments: 490,
|
||||
failedDocuments: 10,
|
||||
reliabilityScore: 98.0,
|
||||
avgResponseTime: 100,
|
||||
lastVerifiedAt: '2024-01-15T10:00:00Z',
|
||||
const verificationAnalytics: VerificationAnalytics = {
|
||||
timeRange: '7d',
|
||||
granularity: 'daily',
|
||||
summary: {
|
||||
totalVerifications: 120,
|
||||
successfulVerifications: 116,
|
||||
failedVerifications: 4,
|
||||
successRate: 96.7,
|
||||
averageLatencyMs: 24,
|
||||
p95LatencyMs: 64,
|
||||
p99LatencyMs: 114,
|
||||
},
|
||||
];
|
||||
|
||||
const mockFailureAnalysis: FailureAnalysis = {
|
||||
timeRange: 'last_7d',
|
||||
totalFailures: 500,
|
||||
failuresByType: [
|
||||
{ type: 'signature_invalid', count: 200, percentage: 40 },
|
||||
{ type: 'certificate_expired', count: 150, percentage: 30 },
|
||||
{ type: 'chain_incomplete', count: 100, percentage: 20 },
|
||||
{ type: 'unknown', count: 50, percentage: 10 },
|
||||
trend: [
|
||||
{
|
||||
timestamp: '2026-03-15T00:00:00Z',
|
||||
totalVerifications: 17,
|
||||
successfulVerifications: 16,
|
||||
failedVerifications: 1,
|
||||
successRate: 94.1,
|
||||
averageLatencyMs: 23,
|
||||
},
|
||||
],
|
||||
failuresByIssuer: [
|
||||
{ issuerId: 'issuer-002', issuerName: 'Vendor B', count: 100, percentage: 20 },
|
||||
byResourceType: {
|
||||
keys: {
|
||||
totalVerifications: 40,
|
||||
successfulVerifications: 39,
|
||||
failedVerifications: 1,
|
||||
successRate: 97.5,
|
||||
averageLatencyMs: 18,
|
||||
p95LatencyMs: 58,
|
||||
p99LatencyMs: 108,
|
||||
},
|
||||
certificates: {
|
||||
totalVerifications: 50,
|
||||
successfulVerifications: 47,
|
||||
failedVerifications: 3,
|
||||
successRate: 94,
|
||||
averageLatencyMs: 31,
|
||||
p95LatencyMs: 71,
|
||||
p99LatencyMs: 121,
|
||||
},
|
||||
issuers: {
|
||||
totalVerifications: 30,
|
||||
successfulVerifications: 30,
|
||||
failedVerifications: 0,
|
||||
successRate: 100,
|
||||
averageLatencyMs: 19,
|
||||
p95LatencyMs: 59,
|
||||
p99LatencyMs: 109,
|
||||
},
|
||||
signatures: {
|
||||
totalVerifications: 100,
|
||||
successfulVerifications: 96,
|
||||
failedVerifications: 4,
|
||||
successRate: 96,
|
||||
averageLatencyMs: 26,
|
||||
p95LatencyMs: 66,
|
||||
p99LatencyMs: 116,
|
||||
},
|
||||
},
|
||||
failureReasons: [
|
||||
{
|
||||
reason: 'Certificates expiring soon',
|
||||
count: 2,
|
||||
percentage: 1.7,
|
||||
trend: 'increasing',
|
||||
},
|
||||
],
|
||||
recentFailures: [],
|
||||
};
|
||||
|
||||
const mockAlerts: TrustAnalyticsAlert[] = [
|
||||
{
|
||||
alertId: 'alert-001',
|
||||
severity: 'warning',
|
||||
title: 'Verification Rate Declining',
|
||||
description: 'Verification success rate dropped below 95%',
|
||||
timestamp: '2024-01-15T10:00:00Z',
|
||||
acknowledged: false,
|
||||
},
|
||||
];
|
||||
const issuerReliabilityAnalytics: IssuerReliabilityAnalytics = {
|
||||
timeRange: '7d',
|
||||
granularity: 'daily',
|
||||
issuers: [
|
||||
{
|
||||
issuerId: 'issuer-001',
|
||||
issuerName: 'core-root-ca',
|
||||
issuerDisplayName: 'Core Root CA',
|
||||
trustScore: 88,
|
||||
trustLevel: 'partial',
|
||||
totalDocuments: 18,
|
||||
verifiedDocuments: 16,
|
||||
verificationRate: 88.9,
|
||||
averageResponseTime: 28,
|
||||
uptimePercentage: 98.2,
|
||||
lastVerificationAt: '2026-03-15T00:00:00Z',
|
||||
reliabilityScore: 89.1,
|
||||
trendDirection: 'improving',
|
||||
},
|
||||
],
|
||||
aggregatedTrend: [
|
||||
{
|
||||
timestamp: '2026-03-15T00:00:00Z',
|
||||
reliabilityScore: 89.1,
|
||||
verificationRate: 88.9,
|
||||
trustScore: 88,
|
||||
documentsProcessed: 18,
|
||||
},
|
||||
],
|
||||
topPerformers: [],
|
||||
underperformers: [],
|
||||
averageReliabilityScore: 89.1,
|
||||
averageVerificationRate: 88.9,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockTrustApi = jasmine.createSpyObj<TrustApi>('TrustApi', [
|
||||
'getAnalyticsSummary',
|
||||
'getVerificationMetrics',
|
||||
'getIssuerReliability',
|
||||
'getFailureAnalysis',
|
||||
'getAnalyticsAlerts',
|
||||
'acknowledgeAlert',
|
||||
'dismissAlert',
|
||||
'getVerificationAnalytics',
|
||||
'getIssuerReliabilityAnalytics',
|
||||
'acknowledgeAnalyticsAlert',
|
||||
]);
|
||||
|
||||
mockTrustApi.getAnalyticsSummary.and.returnValue(of(mockSummary));
|
||||
mockTrustApi.getVerificationMetrics.and.returnValue(of(mockVerificationMetrics));
|
||||
mockTrustApi.getIssuerReliability.and.returnValue(of(mockIssuerReliability));
|
||||
mockTrustApi.getFailureAnalysis.and.returnValue(of(mockFailureAnalysis));
|
||||
mockTrustApi.getAnalyticsAlerts.and.returnValue(of(mockAlerts));
|
||||
mockTrustApi.acknowledgeAlert.and.returnValue(of(void 0));
|
||||
mockTrustApi.dismissAlert.and.returnValue(of(void 0));
|
||||
mockTrustApi.getAnalyticsSummary.and.returnValue(of(summary));
|
||||
mockTrustApi.getVerificationAnalytics.and.returnValue(of(verificationAnalytics));
|
||||
mockTrustApi.getIssuerReliabilityAnalytics.and.returnValue(of(issuerReliabilityAnalytics));
|
||||
mockTrustApi.acknowledgeAnalyticsAlert.and.returnValue(of(void 0));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TrustAnalyticsComponent],
|
||||
providers: [
|
||||
{ provide: TRUST_API, useValue: mockTrustApi },
|
||||
],
|
||||
providers: [{ provide: TRUST_API, useValue: mockTrustApi }],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(TrustAnalyticsComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should load analytics data on init', fakeAsync(() => {
|
||||
it('loads derived analytics data on init', () => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.getAnalyticsSummary).toHaveBeenCalled();
|
||||
expect(mockTrustApi.getVerificationMetrics).toHaveBeenCalled();
|
||||
expect(mockTrustApi.getIssuerReliability).toHaveBeenCalled();
|
||||
expect(mockTrustApi.getFailureAnalysis).toHaveBeenCalled();
|
||||
expect(mockTrustApi.getAnalyticsAlerts).toHaveBeenCalled();
|
||||
}));
|
||||
expect(mockTrustApi.getVerificationAnalytics).toHaveBeenCalledWith({ timeRange: '7d', granularity: 'daily' });
|
||||
expect(mockTrustApi.getIssuerReliabilityAnalytics).toHaveBeenCalledWith({ timeRange: '7d', granularity: 'daily' });
|
||||
expect(component.summary()).toEqual(summary);
|
||||
});
|
||||
|
||||
it('should display summary data', fakeAsync(() => {
|
||||
it('reloads analytics when the time range changes', () => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(component.summary()).toEqual(mockSummary);
|
||||
}));
|
||||
component.onTimeRangeChange('24h');
|
||||
|
||||
it('should display verification metrics', fakeAsync(() => {
|
||||
expect(mockTrustApi.getVerificationAnalytics).toHaveBeenCalledWith({ timeRange: '24h', granularity: 'hourly' });
|
||||
expect(mockTrustApi.getIssuerReliabilityAnalytics).toHaveBeenCalledWith({ timeRange: '24h', granularity: 'hourly' });
|
||||
});
|
||||
|
||||
it('acknowledges alerts in local state after the API call', () => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(component.verificationMetrics()).toEqual(mockVerificationMetrics);
|
||||
}));
|
||||
|
||||
it('should display issuer reliability data', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(component.issuerReliability().length).toBe(1);
|
||||
expect(component.issuerReliability()[0].issuerName).toBe('Vendor A');
|
||||
}));
|
||||
|
||||
it('should display failure analysis', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(component.failureAnalysis()).toEqual(mockFailureAnalysis);
|
||||
}));
|
||||
|
||||
it('should display alerts', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(component.alerts().length).toBe(1);
|
||||
expect(component.alerts()[0].title).toBe('Verification Rate Declining');
|
||||
}));
|
||||
|
||||
it('should change time range and reload data', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.selectedTimeRange.set('last_7d');
|
||||
component.onTimeRangeChange();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.getVerificationMetrics).toHaveBeenCalledWith('last_7d');
|
||||
expect(mockTrustApi.getFailureAnalysis).toHaveBeenCalledWith('last_7d');
|
||||
}));
|
||||
|
||||
it('should acknowledge alert', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.acknowledgeAlert('alert-001');
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.acknowledgeAlert).toHaveBeenCalledWith('alert-001');
|
||||
}));
|
||||
|
||||
it('should dismiss alert', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.dismissAlert('alert-001');
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.dismissAlert).toHaveBeenCalledWith('alert-001');
|
||||
}));
|
||||
|
||||
it('should handle loading state', () => {
|
||||
expect(component.loading()).toBeTrue();
|
||||
fixture.detectChanges();
|
||||
expect(component.loading()).toBeFalse();
|
||||
expect(mockTrustApi.acknowledgeAnalyticsAlert).toHaveBeenCalledWith('alert-001');
|
||||
expect(component.unacknowledgedAlerts()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle error state', fakeAsync(() => {
|
||||
mockTrustApi.getAnalyticsSummary.and.returnValue(throwError(() => new Error('Failed')));
|
||||
it('surfaces load failures', () => {
|
||||
mockTrustApi.getAnalyticsSummary.and.returnValue(throwError(() => new Error('analytics unavailable')));
|
||||
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(component.error()).toBe('Failed');
|
||||
}));
|
||||
|
||||
it('should format time range correctly', () => {
|
||||
expect(component.formatTimeRange('last_24h')).toBe('Last 24 Hours');
|
||||
expect(component.formatTimeRange('last_7d')).toBe('Last 7 Days');
|
||||
expect(component.formatTimeRange('last_30d')).toBe('Last 30 Days');
|
||||
expect(component.error()).toBe('Failed to load analytics data. Please try again.');
|
||||
});
|
||||
|
||||
it('should format failure type correctly', () => {
|
||||
expect(component.formatFailureType('signature_invalid')).toBe('Invalid Signature');
|
||||
expect(component.formatFailureType('certificate_expired')).toBe('Certificate Expired');
|
||||
expect(component.formatFailureType('chain_incomplete')).toBe('Incomplete Chain');
|
||||
});
|
||||
|
||||
it('should compute unacknowledged alerts count', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(component.unacknowledgedAlerts()).toBe(1);
|
||||
}));
|
||||
|
||||
it('should refresh data', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
const initialCalls = mockTrustApi.getAnalyticsSummary.calls.count();
|
||||
component.refreshData();
|
||||
tick();
|
||||
|
||||
expect(mockTrustApi.getAnalyticsSummary.calls.count()).toBe(initialCalls + 1);
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -3,8 +3,11 @@ import { of, throwError } from 'rxjs';
|
||||
|
||||
import {
|
||||
AUTHORITY_ADMIN_API,
|
||||
type AuthorityAdminApi,
|
||||
type AdminRole,
|
||||
type AdminTenant,
|
||||
type AdminUser,
|
||||
type AuthorityAdminApi,
|
||||
type RoleImpactPreview,
|
||||
} from '../../app/core/api/authority-admin.client';
|
||||
import { AdminSettingsPageComponent } from '../../app/features/settings/admin/admin-settings-page.component';
|
||||
|
||||
@@ -16,29 +19,140 @@ function createAuthorityApiSpy(): jasmine.SpyObj<AuthorityAdminApi> {
|
||||
listTokens: jasmine.createSpy('listTokens'),
|
||||
listTenants: jasmine.createSpy('listTenants'),
|
||||
createUser: jasmine.createSpy('createUser'),
|
||||
updateUser: jasmine.createSpy('updateUser'),
|
||||
disableUser: jasmine.createSpy('disableUser'),
|
||||
enableUser: jasmine.createSpy('enableUser'),
|
||||
createRole: jasmine.createSpy('createRole'),
|
||||
updateRole: jasmine.createSpy('updateRole'),
|
||||
previewRoleImpact: jasmine.createSpy('previewRoleImpact'),
|
||||
createTenant: jasmine.createSpy('createTenant'),
|
||||
updateTenant: jasmine.createSpy('updateTenant'),
|
||||
suspendTenant: jasmine.createSpy('suspendTenant'),
|
||||
resumeTenant: jasmine.createSpy('resumeTenant'),
|
||||
} as jasmine.SpyObj<AuthorityAdminApi>;
|
||||
}
|
||||
|
||||
describe('AdminSettingsPageComponent (settings)', () => {
|
||||
let api: jasmine.SpyObj<AuthorityAdminApi>;
|
||||
let component: AdminSettingsPageComponent;
|
||||
let users: AdminUser[];
|
||||
let roles: AdminRole[];
|
||||
let tenants: AdminTenant[];
|
||||
let roleImpact: RoleImpactPreview;
|
||||
|
||||
beforeEach(async () => {
|
||||
api = createAuthorityApiSpy();
|
||||
api.listUsers.and.returnValue(of([]));
|
||||
api.listRoles.and.returnValue(of([]));
|
||||
users = [{
|
||||
id: 'u-1',
|
||||
username: 'jane',
|
||||
email: 'jane@example.com',
|
||||
displayName: 'Jane Example',
|
||||
roles: ['role/console-viewer'],
|
||||
status: 'active',
|
||||
createdAt: '2026-02-19T00:00:00Z',
|
||||
}];
|
||||
roles = [
|
||||
{
|
||||
id: 'role/console-admin',
|
||||
name: 'role/console-admin',
|
||||
description: 'Full platform administration.',
|
||||
permissions: ['ui.admin', 'authority:users.write'],
|
||||
userCount: 1,
|
||||
isBuiltIn: true,
|
||||
},
|
||||
{
|
||||
id: 'role/console-viewer',
|
||||
name: 'role/console-viewer',
|
||||
description: 'Least privilege console access.',
|
||||
permissions: ['ui.read', 'authority:users.read'],
|
||||
userCount: 1,
|
||||
isBuiltIn: true,
|
||||
},
|
||||
{
|
||||
id: 'role/custom-release-operator',
|
||||
name: 'role/custom-release-operator',
|
||||
description: 'Custom release operator role.',
|
||||
permissions: ['release:read', 'release:write'],
|
||||
userCount: 0,
|
||||
isBuiltIn: false,
|
||||
},
|
||||
];
|
||||
tenants = [{
|
||||
id: 'default',
|
||||
displayName: 'Default Tenant',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
userCount: 1,
|
||||
createdAt: '2026-02-19T00:00:00Z',
|
||||
}];
|
||||
roleImpact = {
|
||||
affectedUsers: 2,
|
||||
affectedClients: 0,
|
||||
message: '2 users would be affected by changes to this role.',
|
||||
};
|
||||
|
||||
api.listUsers.and.returnValue(of(users));
|
||||
api.listRoles.and.returnValue(of(roles));
|
||||
api.listClients.and.returnValue(of([]));
|
||||
api.listTokens.and.returnValue(of([]));
|
||||
api.listTenants.and.returnValue(of([]));
|
||||
api.listTenants.and.returnValue(of(tenants));
|
||||
api.createUser.and.returnValue(of({
|
||||
id: 'u-created',
|
||||
username: 'created',
|
||||
email: 'created@example.com',
|
||||
displayName: 'Created User',
|
||||
roles: ['viewer'],
|
||||
roles: ['role/console-viewer'],
|
||||
status: 'active',
|
||||
createdAt: '2026-02-19T00:00:00Z',
|
||||
} as AdminUser));
|
||||
}));
|
||||
api.updateUser.and.callFake((userId, request) => of({
|
||||
...users.find((entry) => entry.id === userId)!,
|
||||
displayName: request.displayName ?? users[0].displayName,
|
||||
roles: request.roles ?? users[0].roles,
|
||||
}));
|
||||
api.disableUser.and.callFake((userId) => of({
|
||||
...users.find((entry) => entry.id === userId)!,
|
||||
status: 'disabled',
|
||||
}));
|
||||
api.enableUser.and.callFake((userId) => of({
|
||||
...users.find((entry) => entry.id === userId)!,
|
||||
status: 'active',
|
||||
}));
|
||||
api.createRole.and.callFake((request) => of({
|
||||
id: request.name,
|
||||
name: request.name,
|
||||
description: request.description,
|
||||
permissions: request.permissions,
|
||||
userCount: 0,
|
||||
isBuiltIn: false,
|
||||
}));
|
||||
api.updateRole.and.callFake((roleId, request) => of({
|
||||
...roles.find((entry) => entry.id === roleId)!,
|
||||
description: request.description ?? roles[2].description,
|
||||
permissions: request.permissions ?? roles[2].permissions,
|
||||
}));
|
||||
api.previewRoleImpact.and.returnValue(of(roleImpact));
|
||||
api.createTenant.and.callFake((request) => of({
|
||||
id: request.id,
|
||||
displayName: request.displayName,
|
||||
status: 'active',
|
||||
isolationMode: request.isolationMode,
|
||||
userCount: 0,
|
||||
createdAt: '2026-02-19T00:00:00Z',
|
||||
}));
|
||||
api.updateTenant.and.callFake((tenantId, request) => of({
|
||||
...tenants.find((entry) => entry.id === tenantId)!,
|
||||
displayName: request.displayName ?? tenants[0].displayName,
|
||||
isolationMode: request.isolationMode ?? tenants[0].isolationMode,
|
||||
}));
|
||||
api.suspendTenant.and.callFake((tenantId) => of({
|
||||
...tenants.find((entry) => entry.id === tenantId)!,
|
||||
status: 'disabled',
|
||||
}));
|
||||
api.resumeTenant.and.callFake((tenantId) => of({
|
||||
...tenants.find((entry) => entry.id === tenantId)!,
|
||||
status: 'active',
|
||||
}));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AdminSettingsPageComponent],
|
||||
@@ -49,6 +163,8 @@ describe('AdminSettingsPageComponent (settings)', () => {
|
||||
});
|
||||
|
||||
it('keeps empty state distinct from error state when users API returns empty list', () => {
|
||||
api.listUsers.and.returnValue(of([]));
|
||||
|
||||
component.ngOnInit();
|
||||
|
||||
expect(component.users()).toEqual([]);
|
||||
@@ -62,7 +178,99 @@ describe('AdminSettingsPageComponent (settings)', () => {
|
||||
component.ngOnInit();
|
||||
|
||||
expect(component.users()).toEqual([]);
|
||||
expect(component.error()).toContain('Failed to load users');
|
||||
expect(component.error()).toContain('authority unavailable');
|
||||
expect(component.loading()).toBeFalse();
|
||||
});
|
||||
|
||||
it('defaults new users to the least-privilege role and validates email format', () => {
|
||||
component.ngOnInit();
|
||||
|
||||
component.openCreateUserEditor();
|
||||
|
||||
expect(component.userEditor?.selectedRoles).toEqual(['role/console-viewer']);
|
||||
|
||||
component.userEditor!.username = 'sam';
|
||||
component.userEditor!.email = 'invalid-email';
|
||||
component.userEditor!.displayName = 'Sam Operator';
|
||||
component.saveUser();
|
||||
|
||||
expect(api.createUser).not.toHaveBeenCalled();
|
||||
expect(component.error()).toContain('valid email');
|
||||
});
|
||||
|
||||
it('updates users and toggles lifecycle actions from the setup surface', () => {
|
||||
component.ngOnInit();
|
||||
|
||||
component.openEditUserEditor(users[0]);
|
||||
component.userEditor!.displayName = 'Jane Updated';
|
||||
component.userEditor!.selectedRoles = ['role/console-admin'];
|
||||
component.saveUser();
|
||||
|
||||
expect(api.updateUser).toHaveBeenCalledWith('u-1', {
|
||||
displayName: 'Jane Updated',
|
||||
roles: ['role/console-admin'],
|
||||
});
|
||||
expect(component.users()[0].displayName).toBe('Jane Updated');
|
||||
expect(component.users()[0].roles).toEqual(['role/console-admin']);
|
||||
|
||||
component.disableUser(component.users()[0]);
|
||||
expect(api.disableUser).toHaveBeenCalledWith('u-1');
|
||||
expect(component.users()[0].status).toBe('disabled');
|
||||
|
||||
component.enableUser(component.users()[0]);
|
||||
expect(api.enableUser).toHaveBeenCalledWith('u-1');
|
||||
expect(component.users()[0].status).toBe('active');
|
||||
});
|
||||
|
||||
it('shows role detail impact and allows editing custom roles', () => {
|
||||
component.ngOnInit();
|
||||
|
||||
component.selectRole(roles[2]);
|
||||
|
||||
expect(api.previewRoleImpact).toHaveBeenCalledWith('role/custom-release-operator');
|
||||
expect(component.roleImpact()).toEqual(roleImpact);
|
||||
expect(component.selectedRole()?.name).toBe('role/custom-release-operator');
|
||||
|
||||
component.openEditRoleEditor(roles[2]);
|
||||
component.roleEditor!.description = 'Release operator with approvals.';
|
||||
component.roleEditor!.selectedScopes = ['release:read', 'release:write', 'release:publish'];
|
||||
component.saveRole();
|
||||
|
||||
expect(api.updateRole).toHaveBeenCalledWith('role/custom-release-operator', {
|
||||
description: 'Release operator with approvals.',
|
||||
permissions: ['release:read', 'release:write', 'release:publish'],
|
||||
});
|
||||
expect(component.successMessage()).toContain('Updated role');
|
||||
});
|
||||
|
||||
it('validates tenant ids and supports tenant lifecycle actions', () => {
|
||||
component.setTab('tenants');
|
||||
component.openCreateTenantEditor();
|
||||
|
||||
component.tenantEditor!.id = 'Bad Tenant';
|
||||
component.tenantEditor!.displayName = 'Bad Tenant';
|
||||
component.saveTenant();
|
||||
|
||||
expect(api.createTenant).not.toHaveBeenCalled();
|
||||
expect(component.error()).toContain('lowercase letters, digits, and hyphens');
|
||||
|
||||
component.tenantEditor!.id = 'sandbox';
|
||||
component.tenantEditor!.displayName = 'Sandbox';
|
||||
component.tenantEditor!.isolationMode = 'dedicated';
|
||||
component.saveTenant();
|
||||
|
||||
expect(api.createTenant).toHaveBeenCalledWith({
|
||||
id: 'sandbox',
|
||||
displayName: 'Sandbox',
|
||||
isolationMode: 'dedicated',
|
||||
});
|
||||
|
||||
component.suspendTenant(tenants[0]);
|
||||
expect(api.suspendTenant).toHaveBeenCalledWith('default');
|
||||
expect(component.tenants()[0].status).toBe('disabled');
|
||||
|
||||
component.resumeTenant(component.tenants()[0]);
|
||||
expect(api.resumeTenant).toHaveBeenCalledWith('default');
|
||||
expect(component.tenants()[0].status).toBe('active');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"src/app/types/monaco-workers.d.ts",
|
||||
"src/app/core/branding/branding.service.spec.ts",
|
||||
"src/app/core/api/first-signal.client.spec.ts",
|
||||
"src/app/core/api/trust.client.spec.ts",
|
||||
"src/app/core/api/vulnerability-http.client.spec.ts",
|
||||
"src/app/core/api/watchlist.client.spec.ts",
|
||||
"src/app/core/auth/tenant-activation.service.spec.ts",
|
||||
@@ -38,13 +39,18 @@
|
||||
"src/app/features/releases/release-ops-overview-page.component.spec.ts",
|
||||
"src/app/features/registry-admin/components/plan-audit.component.spec.ts",
|
||||
"src/app/features/registry-admin/registry-admin.component.spec.ts",
|
||||
"src/app/features/trust-admin/certificate-inventory.component.spec.ts",
|
||||
"src/app/features/trust-admin/issuer-trust-list.component.spec.ts",
|
||||
"src/app/features/trust-admin/trust-admin.component.spec.ts",
|
||||
"src/app/features/trust-admin/trust-analytics.component.spec.ts",
|
||||
"src/app/features/trust-admin/signing-key-dashboard.component.spec.ts",
|
||||
"src/app/features/triage/services/ttfs-telemetry.service.spec.ts",
|
||||
"src/app/features/triage/triage-workspace.component.spec.ts",
|
||||
"src/app/features/vex-hub/vex-hub-stats.component.spec.ts",
|
||||
"src/app/features/vex-hub/vex-hub-source-contract.spec.ts",
|
||||
"src/app/shared/ui/filter-bar/filter-bar.component.spec.ts",
|
||||
"src/app/features/watchlist/watchlist-page.component.spec.ts",
|
||||
"src/app/features/evidence-thread/__tests__/evidence-thread-view.component.spec.ts"
|
||||
"src/app/features/evidence-thread/__tests__/evidence-thread-view.component.spec.ts",
|
||||
"src/tests/settings/admin-settings-page.component.spec.ts"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user