using System.Net; using System.Net.Http.Json; using System.Security.Claims; using System.Text.Encodings.Web; using System.Text.Json; using FluentAssertions; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Nostos.Backend.Data; using Nostos.Backend.Endpoints; using Nostos.Backend.Services.Library; using Nostos.Backend.Services.Portability; using Nostos.Backend.Services.Portability.Migration; using Nostos.Backend.Services.Portability.Transfers; using Nostos.Backend.Tests.Support; using Nostos.Product.Composition; using Xunit; namespace Nostos.Backend.Tests.Portability; /// /// An authenticated host configures the optional migration authorization /// policy: every route requires it, and an unknown selector still answers the /// identical typed 404 shape with no ownership information. /// public sealed class MigrationHttpAuthTests { private const string PolicyName = "migration-owner"; private const string DenyPolicyName = "migration-deny-all"; [Fact] public async Task Every_migration_route_challenges_unauthenticated_requests() { await using var host = await AuthHost.StartAsync(); var job = Guid.NewGuid(); var routes = new (HttpMethod Method, string Path)[] { (HttpMethod.Post, "/api/portability/migration/preflight"), (HttpMethod.Post, "/api/portability/migration/jobs"), (HttpMethod.Get, "/api/portability/migration/active-import"), (HttpMethod.Get, $"/api/portability/migration/jobs/{job}"), (HttpMethod.Post, $"/api/portability/migration/jobs/{job}/cancel"), (HttpMethod.Post, $"/api/portability/migration/jobs/{job}/retry"), (HttpMethod.Post, $"/api/portability/migration/jobs/{job}/upload-session"), (HttpMethod.Get, $"/api/portability/migration/jobs/{job}/upload-session"), (HttpMethod.Put, $"/api/portability/migration/jobs/{job}/upload-session/chunks/0"), (HttpMethod.Post, $"/api/portability/migration/jobs/{job}/upload-session/complete"), (HttpMethod.Get, $"/api/portability/migration/jobs/{job}/export-download"), (HttpMethod.Head, $"/api/portability/migration/jobs/{job}/export-download"), (HttpMethod.Get, "/api/portability/migration/recovery"), (HttpMethod.Get, $"/api/portability/migration/recovery/{job}"), (HttpMethod.Post, $"/api/portability/migration/recovery/{job}/restore"), (HttpMethod.Post, $"/api/portability/migration/jobs/{job}/activate"), (HttpMethod.Get, $"/api/portability/migration/jobs/{job}/activation"), }; foreach (var (method, path) in routes) { using var request = new HttpRequestMessage(method, path) { Content = new ByteArrayContent([]), }; using var response = await host.Client.SendAsync(request); response.StatusCode.Should().Be( HttpStatusCode.Unauthorized, $"{method} {path} must require the configured policy"); } } [Fact] public async Task Authenticated_unknown_selectors_return_the_identical_typed_404() { await using var host = await AuthHost.StartAsync(); var unknown = Guid.NewGuid(); using var jobRequest = new HttpRequestMessage( HttpMethod.Get, $"/api/portability/migration/jobs/{unknown}"); jobRequest.Headers.Add("X-Test-Authenticated", "1"); using var jobResponse = await host.Client.SendAsync(jobRequest); jobResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); AssertNotFoundShape(await jobResponse.Content.ReadAsStringAsync()); using var chunkRequest = new HttpRequestMessage( HttpMethod.Put, $"/api/portability/migration/jobs/{unknown}/upload-session/chunks/0") { Content = new ByteArrayContent([1, 2, 3]), }; chunkRequest.Headers.Add("X-Test-Authenticated", "1"); chunkRequest.Content.Headers.TryAddWithoutValidation("Content-Range", "bytes 0-2/3"); chunkRequest.Content.Headers.TryAddWithoutValidation("X-Nostos-Chunk-SHA256", new string('a', 64)); using var chunkResponse = await host.Client.SendAsync(chunkRequest); chunkResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); AssertNotFoundShape(await chunkResponse.Content.ReadAsStringAsync()); using var downloadRequest = new HttpRequestMessage( HttpMethod.Get, $"/api/portability/migration/jobs/{unknown}/export-download"); downloadRequest.Headers.Add("X-Test-Authenticated", "1"); using var downloadResponse = await host.Client.SendAsync(downloadRequest); downloadResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); AssertNotFoundShape(await downloadResponse.Content.ReadAsStringAsync()); var activationBody = new StringContent( """{"destinationRevision":"1","confirmReplacement":true}""", System.Text.Encoding.UTF8, "application/json"); using var activateRequest = new HttpRequestMessage( HttpMethod.Post, $"/api/portability/migration/jobs/{unknown}/activate") { Content = activationBody, }; activateRequest.Headers.Add("X-Test-Authenticated", "1"); using var activateResponse = await host.Client.SendAsync(activateRequest); activateResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); AssertNotFoundShape(await activateResponse.Content.ReadAsStringAsync()); using var activationRequest = new HttpRequestMessage( HttpMethod.Get, $"/api/portability/migration/jobs/{unknown}/activation"); activationRequest.Headers.Add("X-Test-Authenticated", "1"); using var activationResponse = await host.Client.SendAsync(activationRequest); activationResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); AssertNotFoundShape(await activationResponse.Content.ReadAsStringAsync()); } [Fact] public async Task Authenticated_requests_reach_the_handler_normally() { await using var host = await AuthHost.StartAsync(); const string payload = """ {"incomingCounts":{"works":0,"books":0,"notes":0,"topics":0,"noteTopics":0, "writings":0,"writingNotes":0,"collections":0,"collectionMemberships":0, "acquisitions":0,"assistantSettings":0,"noteImportBookLinks":0,"mediaEntries":0}, "declaredArchiveBytes":1024,"declaredMediaBytes":0,"maxSingleEntryBytes":1024, "declaredFormatVersion":1,"declaredDataVersion":1,"isOperationalBackup":false} """; using var request = new HttpRequestMessage( HttpMethod.Post, "/api/portability/migration/preflight") { Content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json"), }; request.Headers.Add("X-Test-Authenticated", "1"); using var response = await host.Client.SendAsync(request); var bodyText = await response.Content.ReadAsStringAsync(); response.StatusCode.Should().Be(HttpStatusCode.OK, bodyText); using var body = JsonDocument.Parse(bodyText); body.RootElement.GetProperty("evaluation").GetProperty("decision").GetString() .Should().Be("AllowedEmpty"); } [Fact] public async Task Deny_all_activation_policy_refuses_both_routes() { await using var host = await AuthHost.StartAsync(DenyPolicyName); var job = Guid.NewGuid(); var routes = new (HttpMethod Method, string Path, HttpContent? Content)[] { (HttpMethod.Post, $"/api/portability/migration/jobs/{job}/activate", new StringContent( """{"destinationRevision":"1","confirmReplacement":true}""", System.Text.Encoding.UTF8, "application/json")), (HttpMethod.Get, $"/api/portability/migration/jobs/{job}/activation", null), }; foreach (var (method, path, content) in routes) { using var request = new HttpRequestMessage(method, path) { Content = content }; request.Headers.Add("X-Test-Authenticated", "1"); using var response = await host.Client.SendAsync(request); response.StatusCode.Should().Be( HttpStatusCode.Forbidden, $"{method} {path} must inherit the configured deny-all policy"); } } private static void AssertNotFoundShape(string payload) { using var document = JsonDocument.Parse(payload); document.RootElement.EnumerateObject().Select(property => property.Name) .Should().Equal("error", "message"); document.RootElement.GetProperty("error").GetString().Should().Be("migration_not_found"); payload.Should().NotContain("owner-a").And.NotContain("account"); } private sealed class AuthHost : IAsyncDisposable { private readonly WebApplication _app; private readonly string _root; private AuthHost(WebApplication app, string root) { _app = app; _root = root; Client = app.GetTestClient(); } public HttpClient Client { get; } public static async Task StartAsync(string policyName = PolicyName) { var root = Path.Combine(Path.GetTempPath(), "nostos-migration-auth-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); var databasePath = Path.Combine(root, "auth.db"); LibraryEndpointBootstrap.EnsureSchemaAndHistory(databasePath); var builder = WebApplication.CreateBuilder(new WebApplicationOptions { ContentRootPath = root, EnvironmentName = "Testing", }); builder.WebHost.UseTestServer(); builder.Logging.ClearProviders(); builder.Services.AddRouting(); builder.Services .AddAuthentication("Test") .AddScheme("Test", null); builder.Services.AddAuthorization(options => { options.AddPolicy(PolicyName, policy => policy.RequireAuthenticatedUser()); options.AddPolicy( DenyPolicyName, policy => policy.RequireClaim("nostos-activation", "never-granted")); }); builder.Services.AddDbContext(options => options.UseSqlite( $"Data Source={databasePath};Pooling=False")); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddSingleton(Options.Create(new TransferStorageOptions { DiskSafetyMarginBytes = 0, DiskSafetyMarginPercent = 0, })); builder.Services.AddSingleton(new TransferPathResolver( TransferPathResolver.EnsureRootDirectory(Path.Combine(root, "transfers")))); builder.Services.AddSingleton(new FakeTransferVolume { AvailableFreeSpaceBytes = 20L * 1024 * 1024 * 1024, TotalSizeBytes = 20L * 1024 * 1024 * 1024, }); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped( provider => provider.GetRequiredService()); builder.Services.AddScoped( provider => provider.GetRequiredService()); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); // The recovery routes are mapped by the migration group; this host // only exercises authorization, so a non-invoked stub is enough. builder.Services.AddScoped(); // This host only proves the transport policy; the activation driver // itself belongs to the SelfHosted engine and is exercised by // MigrationActivationHttpTests. builder.Services.AddSingleton(); var app = builder.Build(); app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.MapMigrationEndpoints(new NostosProductEndpointPolicies( MigrationAuthorizationPolicy: policyName)); await app.StartAsync(); return new AuthHost(app, root); } public async ValueTask DisposeAsync() { Client.Dispose(); await _app.DisposeAsync(); try { if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true); } catch (IOException) { } catch (UnauthorizedAccessException) { } } } private sealed class AlwaysAvailable : IMigrationPhaseAvailability { public bool IsAvailable(MigrationDirection direction) => true; } private sealed class StubRecoveryRestore : ISelfHostedRecoveryRestore { public Task> ListAsync(CancellationToken ct) => Task.FromResult>([]); public Task GetStatusAsync( Guid recoveryId, CancellationToken ct) => throw new NotSupportedException(); public Task RequestRestoreAsync( Guid recoveryId, MigrationRecoveryRestoreRequest request, CancellationToken ct) => throw new NotSupportedException(); } private sealed class NotFoundActivationDispatcher : IMigrationActivationDispatcher { public Task RequestAsync( Guid jobId, MigrationActivateRequest request, CancellationToken ct) => Task.FromResult(new MigrationActivationRequestResult( MigrationActivationRequestOutcome.NotFound)); public Task GetStatusAsync( Guid jobId, CancellationToken ct) => throw MigrationJobStoreException.NotFound(jobId); } private sealed class NoopMaintenanceGate : IMigrationMaintenanceGate { public bool IsMaintenanceRequested => false; public ValueTask EnterAsync(CancellationToken ct) => ValueTask.FromResult(new NoopLease()); private sealed class NoopLease : IAsyncDisposable { public ValueTask DisposeAsync() => ValueTask.CompletedTask; } } private sealed class TestAuthHandler( IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) : AuthenticationHandler(options, logger, encoder) { protected override Task HandleAuthenticateAsync() { if (!Request.Headers.ContainsKey("X-Test-Authenticated")) return Task.FromResult(AuthenticateResult.NoResult()); var identity = new ClaimsIdentity( [new Claim(ClaimTypes.Name, "owner-a")], Scheme.Name); return Task.FromResult(AuthenticateResult.Success( new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name))); } } }