archive audit attempts

This commit is contained in:
master
2026-02-19 22:00:31 +02:00
parent c2f13fe588
commit b5829dce5c
19638 changed files with 6366 additions and 7 deletions

View File

@@ -0,0 +1,395 @@
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using OpenIddict.Abstractions;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Authority.Console.Admin;
using StellaOps.Authority.Persistence.Postgres.Models;
using StellaOps.Authority.Persistence.Postgres.Repositories;
using StellaOps.Cryptography.Audit;
using Xunit;
namespace StellaOps.Authority.Tests.Console;
public sealed class ConsoleAdminEndpointsTests
{
[Fact]
public async Task UsersList_ReturnsBootstrapAdminUser()
{
var now = new DateTimeOffset(2026, 2, 20, 12, 0, 0, TimeSpan.Zero);
var timeProvider = new FakeTimeProvider(now);
var sink = new RecordingAuthEventSink();
var users = new InMemoryUserRepository();
users.Seed(new UserEntity
{
Id = Guid.Parse("8f29238e-2956-49e6-a1f2-4b58af324d63"),
TenantId = "default",
Username = "admin",
Email = "admin@stella-ops.local",
DisplayName = "Bootstrap Admin",
Enabled = true,
Metadata = """{"subjectId":"bootstrap-admin","roles":["admin","operator"]}""",
CreatedAt = now.AddDays(-3),
UpdatedAt = now.AddDays(-1)
});
await using var app = await CreateApplicationAsync(timeProvider, sink, users);
var principalAccessor = app.Services.GetRequiredService<AdminTestPrincipalAccessor>();
principalAccessor.Principal = CreatePrincipal(
tenant: "default",
scopes: new[] { StellaOpsScopes.UiAdmin, StellaOpsScopes.AuthorityUsersRead },
expiresAt: now.AddMinutes(10));
using var client = CreateTestClient(app);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(AdminAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "default");
var response = await client.GetAsync("/console/admin/users");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<UserListPayload>();
Assert.NotNull(payload);
Assert.True(payload!.Count >= 1);
var admin = Assert.Single(payload.Users.Where(static user => user.Username == "admin"));
Assert.Equal("bootstrap-admin", admin.Id);
Assert.Contains("admin", admin.Roles);
Assert.Equal("active", admin.Status);
}
[Fact]
public async Task CreateUser_CreatesUser_AndListReturnsIt()
{
var now = new DateTimeOffset(2026, 2, 20, 13, 0, 0, TimeSpan.Zero);
var timeProvider = new FakeTimeProvider(now);
var sink = new RecordingAuthEventSink();
var users = new InMemoryUserRepository();
await using var app = await CreateApplicationAsync(timeProvider, sink, users);
var principalAccessor = app.Services.GetRequiredService<AdminTestPrincipalAccessor>();
principalAccessor.Principal = CreatePrincipal(
tenant: "default",
scopes: new[] { StellaOpsScopes.UiAdmin, StellaOpsScopes.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 = "alice",
email = "alice@example.com",
displayName = "Alice",
roles = new[] { "reviewer" }
});
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
var createdUser = await createResponse.Content.ReadFromJsonAsync<UserSummary>();
Assert.NotNull(createdUser);
Assert.Equal("alice", createdUser!.Username);
Assert.Equal("alice@example.com", createdUser.Email);
Assert.Contains("reviewer", createdUser.Roles);
var listResponse = await client.GetAsync("/console/admin/users");
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
var listed = await listResponse.Content.ReadFromJsonAsync<UserListPayload>();
Assert.NotNull(listed);
Assert.Contains(listed!.Users, static user => user.Username == "alice");
}
private static async Task<WebApplication> CreateApplicationAsync(
FakeTimeProvider timeProvider,
RecordingAuthEventSink sink,
IUserRepository userRepository)
{
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{
EnvironmentName = Environments.Development
});
builder.WebHost.UseTestServer();
builder.Services.AddSingleton<TimeProvider>(timeProvider);
builder.Services.AddSingleton<IAuthEventSink>(sink);
builder.Services.AddSingleton(userRepository);
builder.Services.AddSingleton<AdminTestPrincipalAccessor>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<StellaOpsBypassEvaluator>();
var authBuilder = builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = AdminAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = AdminAuthenticationDefaults.AuthenticationScheme;
});
authBuilder.AddScheme<AuthenticationSchemeOptions, AdminTestAuthenticationHandler>(
AdminAuthenticationDefaults.AuthenticationScheme,
static _ => { });
authBuilder.AddScheme<AuthenticationSchemeOptions, AdminTestAuthenticationHandler>(
StellaOpsAuthenticationDefaults.AuthenticationScheme,
static _ => { });
builder.Services.AddAuthorization();
builder.Services.AddStellaOpsScopeHandler();
builder.Services.AddOptions<StellaOpsResourceServerOptions>()
.Configure(options =>
{
options.Authority = "https://authority.integration.test";
})
.PostConfigure(static options => options.Validate());
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapConsoleAdminEndpoints();
await app.StartAsync();
return app;
}
private static ClaimsPrincipal CreatePrincipal(
string tenant,
IReadOnlyCollection<string> scopes,
DateTimeOffset expiresAt)
{
var claims = new List<Claim>
{
new(StellaOpsClaimTypes.Tenant, tenant),
new(StellaOpsClaimTypes.Scope, string.Join(' ', scopes)),
new("exp", expiresAt.ToUnixTimeSeconds().ToString()),
new(OpenIddictConstants.Claims.Audience, "console")
};
var identity = new ClaimsIdentity(claims, AdminAuthenticationDefaults.AuthenticationScheme);
return new ClaimsPrincipal(identity);
}
private static HttpClient CreateTestClient(WebApplication app)
{
var server = app.Services.GetRequiredService<IServer>() as TestServer
?? throw new InvalidOperationException("TestServer is not available.");
return server.CreateClient();
}
private sealed class RecordingAuthEventSink : IAuthEventSink
{
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
}
private sealed class AdminTestPrincipalAccessor
{
public ClaimsPrincipal? Principal { get; set; }
}
private sealed class AdminTestAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public AdminTestAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var accessor = Context.RequestServices.GetRequiredService<AdminTestPrincipalAccessor>();
if (accessor.Principal is null)
{
return Task.FromResult(AuthenticateResult.Fail("No test principal configured."));
}
var ticket = new AuthenticationTicket(accessor.Principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
private static class AdminAuthenticationDefaults
{
public const string AuthenticationScheme = "AuthorityAdminConsoleTests";
}
private sealed class InMemoryUserRepository : IUserRepository
{
private readonly object _sync = new();
private readonly List<UserEntity> _users = new();
public void Seed(UserEntity entity)
{
lock (_sync)
{
_users.Add(entity);
}
}
public Task<UserEntity> CreateAsync(UserEntity user, CancellationToken cancellationToken = default)
{
lock (_sync)
{
var now = DateTimeOffset.UtcNow;
var created = new UserEntity
{
Id = user.Id == Guid.Empty ? Guid.NewGuid() : user.Id,
TenantId = user.TenantId,
Username = user.Username,
Email = user.Email,
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 = user.Metadata,
CreatedAt = user.CreatedAt == default ? now : user.CreatedAt,
UpdatedAt = now,
CreatedBy = user.CreatedBy
};
_users.Add(created);
return Task.FromResult(created);
}
}
public Task<UserEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
lock (_sync)
{
var user = _users.FirstOrDefault(user => user.TenantId == tenantId && user.Id == id);
return Task.FromResult(user);
}
}
public Task<UserEntity?> GetByUsernameAsync(string tenantId, string username, CancellationToken cancellationToken = default)
{
lock (_sync)
{
var user = _users.FirstOrDefault(user =>
user.TenantId == tenantId &&
string.Equals(user.Username, username, StringComparison.OrdinalIgnoreCase));
return Task.FromResult(user);
}
}
public Task<UserEntity?> GetBySubjectIdAsync(string tenantId, string subjectId, CancellationToken cancellationToken = default)
{
lock (_sync)
{
var user = _users.FirstOrDefault(user =>
user.TenantId == tenantId &&
user.Metadata.Contains(subjectId, StringComparison.OrdinalIgnoreCase));
return Task.FromResult(user);
}
}
public Task<UserEntity?> GetByEmailAsync(string tenantId, string email, CancellationToken cancellationToken = default)
{
lock (_sync)
{
var user = _users.FirstOrDefault(user =>
user.TenantId == tenantId &&
string.Equals(user.Email, email, StringComparison.OrdinalIgnoreCase));
return Task.FromResult(user);
}
}
public Task<IReadOnlyList<UserEntity>> GetAllAsync(
string tenantId,
bool? enabled = null,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default)
{
lock (_sync)
{
IEnumerable<UserEntity> query = _users.Where(user => user.TenantId == tenantId);
if (enabled.HasValue)
{
query = query.Where(user => user.Enabled == enabled.Value);
}
var result = query
.Skip(offset)
.Take(limit)
.ToList();
return Task.FromResult((IReadOnlyList<UserEntity>)result);
}
}
public Task<bool> UpdateAsync(UserEntity user, CancellationToken cancellationToken = default)
{
return Task.FromResult(false);
}
public Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
lock (_sync)
{
var removed = _users.RemoveAll(user => user.TenantId == tenantId && user.Id == id);
return Task.FromResult(removed > 0);
}
}
public Task<bool> UpdatePasswordAsync(
string tenantId,
Guid userId,
string passwordHash,
string passwordSalt,
CancellationToken cancellationToken = default)
{
return Task.FromResult(false);
}
public Task<int> RecordFailedLoginAsync(
string tenantId,
Guid userId,
DateTimeOffset? lockUntil = null,
CancellationToken cancellationToken = default)
{
return Task.FromResult(0);
}
public Task RecordSuccessfulLoginAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
}
private sealed record UserListPayload(IReadOnlyList<UserSummary> Users, int Count);
private sealed record UserSummary(
string Id,
string Username,
string Email,
string DisplayName,
IReadOnlyList<string> Roles,
string Status,
DateTimeOffset CreatedAt,
DateTimeOffset? LastLoginAt);
}

View File

@@ -6,6 +6,8 @@ using OpenIddict.Abstractions;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Authority.Persistence.Documents;
using StellaOps.Authority.Persistence.Postgres.Models;
using StellaOps.Authority.Persistence.Postgres.Repositories;
using StellaOps.Authority.Tenants;
using StellaOps.Cryptography.Audit;
using System;
@@ -14,6 +16,7 @@ using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Security.Claims;
using System.Text.Json;
namespace StellaOps.Authority.Console.Admin;
@@ -284,13 +287,23 @@ internal static class ConsoleAdminEndpointExtensions
private static async Task<IResult> ListUsers(
HttpContext httpContext,
IUserRepository userRepository,
IAuthEventSink auditSink,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
var tenantId = httpContext.Request.Query.TryGetValue("tenantId", out var tenantValues)
? tenantValues.FirstOrDefault()
: null;
var tenantId = ResolveTenantId(httpContext);
var users = await userRepository.GetAllAsync(
tenantId,
enabled: null,
limit: 500,
offset: 0,
cancellationToken).ConfigureAwait(false);
var userSummaries = users
.OrderBy(static user => user.Username, StringComparer.OrdinalIgnoreCase)
.Select(user => ToAdminUserSummary(user, timeProvider.GetUtcNow()))
.ToList();
await WriteAdminAuditAsync(
httpContext,
@@ -299,19 +312,69 @@ internal static class ConsoleAdminEndpointExtensions
"authority.admin.users.list",
AuthEventOutcome.Success,
null,
BuildProperties(("tenant.id", tenantId ?? "all")),
BuildProperties(("tenant.id", tenantId), ("users.count", userSummaries.Count.ToString(CultureInfo.InvariantCulture))),
cancellationToken).ConfigureAwait(false);
return Results.Ok(new { users = Array.Empty<object>(), message = "User list: implementation pending" });
return Results.Ok(new { users = userSummaries, count = userSummaries.Count });
}
private static async Task<IResult> CreateUser(
HttpContext httpContext,
CreateUserRequest request,
IUserRepository userRepository,
IAuthEventSink auditSink,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
if (request is null
|| string.IsNullOrWhiteSpace(request.Username)
|| string.IsNullOrWhiteSpace(request.Email))
{
return Results.BadRequest(new { error = "username_and_email_required" });
}
var tenantId = ResolveTenantId(httpContext);
var normalizedUsername = request.Username.Trim().ToLowerInvariant();
var normalizedEmail = request.Email.Trim();
var existing = await userRepository.GetByUsernameAsync(tenantId, normalizedUsername, cancellationToken)
.ConfigureAwait(false);
if (existing is not null)
{
return Results.Conflict(new { error = "user_already_exists", username = normalizedUsername });
}
var normalizedRoles = NormalizeRoles(request.Roles);
var metadata = new Dictionary<string, object?>
{
["subjectId"] = Guid.NewGuid().ToString("N"),
["roles"] = normalizedRoles
};
var createdBy = httpContext.User.FindFirstValue(OpenIddictConstants.Claims.PreferredUsername)
?? httpContext.User.FindFirstValue(StellaOpsClaimTypes.Subject)
?? "console-admin";
var newUser = new UserEntity
{
Id = Guid.NewGuid(),
TenantId = tenantId,
Username = normalizedUsername,
Email = normalizedEmail,
DisplayName = string.IsNullOrWhiteSpace(request.DisplayName) ? null : request.DisplayName.Trim(),
PasswordHash = null,
PasswordSalt = null,
Enabled = true,
EmailVerified = false,
MfaEnabled = false,
Settings = "{}",
Metadata = JsonSerializer.Serialize(metadata),
CreatedBy = createdBy
};
var created = await userRepository.CreateAsync(newUser, cancellationToken).ConfigureAwait(false);
var createdSummary = ToAdminUserSummary(created, timeProvider.GetUtcNow());
await WriteAdminAuditAsync(
httpContext,
auditSink,
@@ -319,10 +382,13 @@ internal static class ConsoleAdminEndpointExtensions
"authority.admin.users.create",
AuthEventOutcome.Success,
null,
BuildProperties(("username", request?.Username ?? "unknown")),
BuildProperties(
("tenant.id", tenantId),
("username", createdSummary.Username),
("user.id", createdSummary.Id)),
cancellationToken).ConfigureAwait(false);
return Results.Created("/console/admin/users/new", new { message = "User creation: implementation pending" });
return Results.Created($"/console/admin/users/{createdSummary.Id}", createdSummary);
}
private static async Task<IResult> UpdateUser(
@@ -687,6 +753,128 @@ internal static class ConsoleAdminEndpointExtensions
return list.Count == 0 ? Array.Empty<AuthEventProperty>() : list;
}
private static string ResolveTenantId(HttpContext httpContext)
{
var resolved = TenantHeaderFilter.GetTenant(httpContext);
if (!string.IsNullOrWhiteSpace(resolved))
{
return resolved;
}
if (httpContext.Request.Query.TryGetValue("tenantId", out var tenantValues))
{
var fromQuery = tenantValues.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(fromQuery))
{
return fromQuery.Trim().ToLowerInvariant();
}
}
return "default";
}
private static IReadOnlyList<string> NormalizeRoles(IReadOnlyList<string>? roles)
{
if (roles is null || roles.Count == 0)
{
return Array.Empty<string>();
}
return roles
.Where(static role => !string.IsNullOrWhiteSpace(role))
.Select(static role => role.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static role => role, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static AdminUserSummary ToAdminUserSummary(UserEntity user, DateTimeOffset now)
{
var metadata = ParseMetadata(user.Metadata);
var subjectId = metadata.TryGetValue("subjectId", out var parsedSubjectId)
? parsedSubjectId?.ToString()
: null;
string status = user.Enabled ? "active" : "disabled";
if (user.LockedUntil is { } lockUntil && lockUntil > now)
{
status = "locked";
}
return new AdminUserSummary(
Id: string.IsNullOrWhiteSpace(subjectId) ? user.Id.ToString("N") : subjectId!,
Username: user.Username,
Email: user.Email,
DisplayName: user.DisplayName ?? user.Username,
Roles: ReadRoles(metadata),
Status: status,
CreatedAt: user.CreatedAt,
LastLoginAt: user.LastLoginAt);
}
private static Dictionary<string, object?> ParseMetadata(string? metadataJson)
{
if (string.IsNullOrWhiteSpace(metadataJson))
{
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
}
try
{
return JsonSerializer.Deserialize<Dictionary<string, object?>>(metadataJson)
?? new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
}
catch
{
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
}
}
private static IReadOnlyList<string> ReadRoles(IReadOnlyDictionary<string, object?> metadata)
{
if (!metadata.TryGetValue("roles", out var rolesValue) || rolesValue is null)
{
return Array.Empty<string>();
}
if (rolesValue is JsonElement jsonElement)
{
if (jsonElement.ValueKind == JsonValueKind.Array)
{
return jsonElement.EnumerateArray()
.Where(static item => item.ValueKind == JsonValueKind.String)
.Select(static item => item.GetString())
.Where(static role => !string.IsNullOrWhiteSpace(role))
.Select(static role => role!)
.OrderBy(static role => role, StringComparer.OrdinalIgnoreCase)
.ToList();
}
if (jsonElement.ValueKind == JsonValueKind.String)
{
var singleRole = jsonElement.GetString();
return string.IsNullOrWhiteSpace(singleRole)
? Array.Empty<string>()
: new[] { singleRole };
}
}
if (rolesValue is IEnumerable<object?> objects)
{
return objects
.Select(static value => value?.ToString())
.Where(static role => !string.IsNullOrWhiteSpace(role))
.Select(static role => role!)
.OrderBy(static role => role, StringComparer.OrdinalIgnoreCase)
.ToList();
}
var fallback = rolesValue.ToString();
return string.IsNullOrWhiteSpace(fallback)
? Array.Empty<string>()
: new[] { fallback };
}
private static IReadOnlyList<RoleBundle> GetDefaultRoles()
{
// Default role catalog based on console-admin-rbac.md
@@ -726,6 +914,15 @@ internal sealed record CreateClientRequest(string ClientId, string DisplayName,
internal sealed record UpdateClientRequest(string? DisplayName, List<string>? Scopes);
internal sealed record RevokeTokensRequest(List<string> TokenIds, string? Reason);
internal sealed record RoleBundle(string RoleId, string DisplayName, IReadOnlyList<string> Scopes);
internal sealed record AdminUserSummary(
string Id,
string Username,
string Email,
string DisplayName,
IReadOnlyList<string> Roles,
string Status,
DateTimeOffset CreatedAt,
DateTimeOffset? LastLoginAt);
// ========== FILTERS ==========

View File

@@ -0,0 +1,103 @@
using System.Net;
using System.Net.Http.Json;
using StellaOps.Platform.WebService.Contracts;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Platform.WebService.Tests;
public sealed class FederationTelemetryEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
{
private readonly PlatformWebApplicationFactory _factory;
public FederationTelemetryEndpointsTests(PlatformWebApplicationFactory factory)
{
_factory = factory;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ConsentGrantAndRevokeLifecycle_Works()
{
var tenantId = Guid.NewGuid().ToString("D");
using var client = CreateTenantClient(tenantId);
var before = await client.GetFromJsonAsync<FederationConsentStateResponse>(
"/api/v1/telemetry/federation/consent",
TestContext.Current.CancellationToken);
Assert.NotNull(before);
Assert.False(before!.Granted);
var grantResponse = await client.PostAsJsonAsync(
"/api/v1/telemetry/federation/consent/grant",
new FederationGrantConsentRequest("qa-admin", 12),
TestContext.Current.CancellationToken);
grantResponse.EnsureSuccessStatusCode();
var proof = await grantResponse.Content.ReadFromJsonAsync<FederationConsentProofResponse>(
TestContext.Current.CancellationToken);
Assert.NotNull(proof);
Assert.Equal(tenantId, proof!.TenantId);
Assert.Equal("qa-admin", proof.GrantedBy);
Assert.StartsWith("sha256:", proof.DsseDigest, StringComparison.Ordinal);
var afterGrant = await client.GetFromJsonAsync<FederationConsentStateResponse>(
"/api/v1/telemetry/federation/consent",
TestContext.Current.CancellationToken);
Assert.NotNull(afterGrant);
Assert.True(afterGrant!.Granted);
Assert.Equal("qa-admin", afterGrant.GrantedBy);
var revokeResponse = await client.PostAsJsonAsync(
"/api/v1/telemetry/federation/consent/revoke",
new FederationRevokeConsentRequest("qa-admin"),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, revokeResponse.StatusCode);
var afterRevoke = await client.GetFromJsonAsync<FederationConsentStateResponse>(
"/api/v1/telemetry/federation/consent",
TestContext.Current.CancellationToken);
Assert.NotNull(afterRevoke);
Assert.False(afterRevoke!.Granted);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ListBundles_ReturnsDeterministicEmptyCollection_WhenNoBundlesExist()
{
var tenantId = Guid.NewGuid().ToString("D");
using var client = CreateTenantClient(tenantId);
var bundles = await client.GetFromJsonAsync<IReadOnlyList<FederationBundleSummary>>(
"/api/v1/telemetry/federation/bundles",
TestContext.Current.CancellationToken);
Assert.NotNull(bundles);
Assert.Empty(bundles!);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task PrivacyBudgetSnapshot_ReturnsExpectedShape()
{
var tenantId = Guid.NewGuid().ToString("D");
using var client = CreateTenantClient(tenantId);
var snapshot = await client.GetFromJsonAsync<FederationPrivacyBudgetResponse>(
"/api/v1/telemetry/federation/privacy-budget",
TestContext.Current.CancellationToken);
Assert.NotNull(snapshot);
Assert.True(snapshot!.Total > 0);
Assert.True(snapshot.Remaining <= snapshot.Total);
Assert.True(snapshot.NextReset >= snapshot.PeriodStart);
}
private HttpClient CreateTenantClient(string tenantId)
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "federation-test");
return client;
}
}