Ctrl K
ComunidadGuías…

Nostos.Backend.Tests/Portability/MigrationHttpAuthTests.cs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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;

/// <summary>
/// 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.
/// </summary>
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<AuthHost> 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<AuthenticationSchemeOptions, TestAuthHandler>("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<NostosDbContext>(options => options.UseSqlite(
                $"Data Source={databasePath};Pooling=False"));
            builder.Services.AddSingleton<TimeProvider>(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<ITransferVolume>(new FakeTransferVolume
            {
                AvailableFreeSpaceBytes = 20L * 1024 * 1024 * 1024,
                TotalSizeBytes = 20L * 1024 * 1024 * 1024,
            });
            builder.Services.AddScoped<ITransferStorageCapacity, TransferStorageCapacity>();
            builder.Services.AddScoped<IMigrationJobStore, EfMigrationJobStore>();
            builder.Services.AddSingleton<MigrationFileMutex>();
            builder.Services.AddSingleton<MigrationJobCancellationRegistry>();
            builder.Services.AddSingleton<IMigrationMaintenanceGate, NoopMaintenanceGate>();
            builder.Services.AddScoped<FileMigrationUploadStore>();
            builder.Services.AddScoped<MigrationTransferCleanup>();
            builder.Services.AddScoped<SelfHostedMigrationTransferService>();
            builder.Services.AddScoped<ISelfHostedMigrationUploads>(
                provider => provider.GetRequiredService<SelfHostedMigrationTransferService>());
            builder.Services.AddScoped<IMigrationTransferService>(
                provider => provider.GetRequiredService<SelfHostedMigrationTransferService>());
            builder.Services.AddScoped<IMigrationPreflightService, SelfHostedMigrationPreflightService>();
            builder.Services.AddScoped<SelfHostedMigrationJobService>();
            builder.Services.AddScoped<ILibraryDestinationRevisionProvider, LibraryStateDestinationRevisionProvider>();
            builder.Services.AddSingleton<IMigrationPhaseAvailability, AlwaysAvailable>();
            // The recovery routes are mapped by the migration group; this host
            // only exercises authorization, so a non-invoked stub is enough.
            builder.Services.AddScoped<ISelfHostedRecoveryRestore, StubRecoveryRestore>();
            // This host only proves the transport policy; the activation driver
            // itself belongs to the SelfHosted engine and is exercised by
            // MigrationActivationHttpTests.
            builder.Services.AddSingleton<IMigrationActivationDispatcher, NotFoundActivationDispatcher>();

            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<IReadOnlyList<MigrationRecoveryStatusResponse>> ListAsync(CancellationToken ct) =>
            Task.FromResult<IReadOnlyList<MigrationRecoveryStatusResponse>>([]);

        public Task<MigrationRecoveryRestoreStatusResponse> GetStatusAsync(
            Guid recoveryId,
            CancellationToken ct) => throw new NotSupportedException();

        public Task<MigrationRecoveryRestoreStatusResponse> RequestRestoreAsync(
            Guid recoveryId,
            MigrationRecoveryRestoreRequest request,
            CancellationToken ct) => throw new NotSupportedException();
    }

    private sealed class NotFoundActivationDispatcher : IMigrationActivationDispatcher
    {
        public Task<MigrationActivationRequestResult> RequestAsync(
            Guid jobId,
            MigrationActivateRequest request,
            CancellationToken ct) =>
            Task.FromResult(new MigrationActivationRequestResult(
                MigrationActivationRequestOutcome.NotFound));

        public Task<MigrationActivationStatusResponse> GetStatusAsync(
            Guid jobId,
            CancellationToken ct) =>
            throw MigrationJobStoreException.NotFound(jobId);
    }

    private sealed class NoopMaintenanceGate : IMigrationMaintenanceGate
    {
        public bool IsMaintenanceRequested => false;

        public ValueTask<IAsyncDisposable> EnterAsync(CancellationToken ct) =>
            ValueTask.FromResult<IAsyncDisposable>(new NoopLease());

        private sealed class NoopLease : IAsyncDisposable
        {
            public ValueTask DisposeAsync() => ValueTask.CompletedTask;
        }
    }

    private sealed class TestAuthHandler(
        IOptionsMonitor<AuthenticationSchemeOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
    {
        protected override Task<AuthenticateResult> 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)));
        }
    }
}