Files
git.stella-ops.org/src/Registry/__Tests/StellaOps.Registry.TokenService.Tests/RegistryTokenDurableRuntimeTests.cs
master 302826aedb 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>
2026-04-19 14:36:05 +03:00

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();
}
}