Sprint SPRINT_20260415_003_DOCS_scheduler_registry_real_backend_cutover. - Scheduler WebService: Postgres-backed audit service + resolver job service, system schedule bootstrap, durable host tests, jwt app factory - PacksRegistry: persistence extensions + migration 002 runtime pack repo, durable runtime + startup contract tests - Registry.TokenService: Postgres plan rule store + admin endpoints, migration 001 initial schema, durable runtime + persistence tests - Scheduler.Plugin.Doctor: wiring for doctor job plugin - Sprint _019 (webhook rate limiter) and _002 (compose storage compat) land separately. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
205 lines
8.1 KiB
C#
205 lines
8.1 KiB
C#
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<PostgresFixture>
|
|
{
|
|
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<TokenResponsePayload>();
|
|
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<Program>
|
|
{
|
|
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<AuthenticationSchemeOptions, DurableTestAuthHandler>("Test", _ => { });
|
|
|
|
services.PostConfigure<AuthorizationOptions>(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<AuthenticationSchemeOptions>
|
|
{
|
|
public DurableTestAuthHandler(
|
|
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
|
ILoggerFactory logger,
|
|
UrlEncoder encoder)
|
|
: base(options, logger, encoder)
|
|
{
|
|
}
|
|
|
|
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
|
{
|
|
if (!Request.Headers.Authorization.Any())
|
|
{
|
|
return Task.FromResult(AuthenticateResult.NoResult());
|
|
}
|
|
|
|
var claims = new List<Claim>
|
|
{
|
|
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();
|
|
}
|
|
}
|