feat(scheduler,packsregistry,registry): postgres backend cutover
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>
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user