using System.IdentityModel.Tokens.Jwt; using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Security.Claims; using System.Security.Cryptography; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Registry.TokenService.Admin; using StellaOps.TestKit.Fixtures; namespace StellaOps.Registry.TokenService.Tests; public sealed class RegistryTokenDurableRuntimeTests : IClassFixture { private readonly PostgresFixture _postgres; private const string TenantId = "registry-durable-proof"; public RegistryTokenDurableRuntimeTests(PostgresFixture postgres) { _postgres = postgres; } [Fact] public async Task TokenEndpoint_UsesPersistedPlanRules() { var schemaName = $"registry_token_{Guid.NewGuid():N}"; var planName = $"enterprise-{Guid.NewGuid():N}"; using var factory = new DurableRegistryTokenFactory(_postgres.ConnectionString, schemaName); using var adminClient = factory.CreateAuthenticatedClient(); var createResponse = await adminClient.PostAsJsonAsync( "/api/admin/plans", new CreatePlanRequest { Name = planName, Description = "durable runtime proof", Repositories = [ new RepositoryRuleDto { Pattern = "stella-ops/private/*", Actions = ["pull"] } ] }); Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); using var tokenClient = factory.CreateAuthenticatedClient(planName); var tokenResponse = await tokenClient.GetAsync("/token?service=registry.localhost&scope=repository:stella-ops/private/cache:pull"); Assert.Equal(HttpStatusCode.OK, tokenResponse.StatusCode); var tokenPayload = await tokenResponse.Content.ReadFromJsonAsync(); Assert.NotNull(tokenPayload); Assert.False(string.IsNullOrWhiteSpace(tokenPayload!.Token)); var handler = new JwtSecurityTokenHandler(); var jwt = handler.ReadJwtToken(tokenPayload.Token); Assert.Equal("registry.localhost", jwt.Audiences.Single()); Assert.Equal("test-user", jwt.Subject); var deniedResponse = await tokenClient.GetAsync("/token?service=registry.localhost&scope=repository:stella-ops/private/cache:push"); Assert.Equal(HttpStatusCode.Forbidden, deniedResponse.StatusCode); } private sealed class TokenResponsePayload { public string Token { get; init; } = string.Empty; } private sealed class DurableRegistryTokenFactory : WebApplicationFactory { private readonly string _connectionString; private readonly string _schemaName; private readonly string _tempKeyPath; public DurableRegistryTokenFactory(string connectionString, string schemaName) { _connectionString = connectionString; _schemaName = schemaName; _tempKeyPath = Path.Combine(Path.GetTempPath(), $"registry-token-test-key-{Guid.NewGuid():N}.pem"); File.WriteAllText(_tempKeyPath, CreatePemKey()); } protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseEnvironment("Production"); builder.UseSetting($"{RegistryTokenServiceOptions.SectionName}:Authority:Issuer", "https://localhost:5001"); builder.UseSetting($"{RegistryTokenServiceOptions.SectionName}:Authority:RequireHttpsMetadata", "false"); builder.UseSetting($"{RegistryTokenServiceOptions.SectionName}:Signing:Issuer", "https://registry.test.local"); builder.UseSetting($"{RegistryTokenServiceOptions.SectionName}:Signing:KeyPath", _tempKeyPath); builder.UseSetting($"{RegistryTokenServiceOptions.SectionName}:Signing:Lifetime", "00:05:00"); builder.UseSetting($"{RegistryTokenServiceOptions.SectionName}:Registry:Realm", "https://registry.test.local/v2/token"); builder.UseSetting($"{RegistryTokenServiceOptions.SectionName}:Postgres:ConnectionString", _connectionString); builder.UseSetting($"{RegistryTokenServiceOptions.SectionName}:Postgres:SchemaName", _schemaName); builder.UseSetting("Telemetry:Collector:Enabled", "false"); builder.ConfigureTestServices(services => { services.AddAuthentication("Test") .AddScheme("Test", _ => { }); services.PostConfigure(options => { foreach (var policyName in new[] { "registry.admin", "registry.token.issue" }) { var newPolicy = new AuthorizationPolicyBuilder() .RequireAuthenticatedUser() .AddAuthenticationSchemes("Test") .Build(); options.AddPolicy(policyName, newPolicy); } }); }); } protected override void Dispose(bool disposing) { base.Dispose(disposing); if (File.Exists(_tempKeyPath)) { try { File.Delete(_tempKeyPath); } catch { // Best-effort cleanup. } } } public HttpClient CreateAuthenticatedClient(string? planName = null) { var client = CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Test"); client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", TenantId); if (!string.IsNullOrWhiteSpace(planName)) { client.DefaultRequestHeaders.Add("X-Test-Plan", planName); } return client; } } private sealed class DurableTestAuthHandler : AuthenticationHandler { public DurableTestAuthHandler( IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) : base(options, logger, encoder) { } protected override Task HandleAuthenticateAsync() { if (!Request.Headers.Authorization.Any()) { return Task.FromResult(AuthenticateResult.NoResult()); } var claims = new List { new(ClaimTypes.NameIdentifier, "test-user"), new(ClaimTypes.Name, "Test User"), new("scope", "registry.admin registry.token.issue"), new("stellaops:tenant", TenantId), }; var plan = Request.Headers["X-Test-Plan"].ToString(); if (!string.IsNullOrWhiteSpace(plan)) { claims.Add(new Claim("stellaops:plan", plan)); } var identity = new ClaimsIdentity(claims, "Test"); var principal = new ClaimsPrincipal(identity); var ticket = new AuthenticationTicket(principal, "Test"); return Task.FromResult(AuthenticateResult.Success(ticket)); } } private static string CreatePemKey() { using var rsa = RSA.Create(2048); using var writer = new StringWriter(); writer.WriteLine("-----BEGIN PRIVATE KEY-----"); writer.WriteLine(Convert.ToBase64String(rsa.ExportPkcs8PrivateKey(), Base64FormattingOptions.InsertLineBreaks)); writer.WriteLine("-----END PRIVATE KEY-----"); return writer.ToString(); } }