Sube carpetas API, API.Tests, Application y Domain del Backend
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
using API.Utils;
|
||||
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
using Infrastructure.Backup;
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace API.Tests.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// Tests StartupExtensions.AddBackupConfig's storage-target
|
||||
/// branching by inspecting the resulting ServiceDescriptor for
|
||||
/// IBackupStorage — never calling BuildServiceProvider
|
||||
/// or resolving anything. This keeps the test free of real Supabase network
|
||||
/// I/O (a SupabaseBackupStorage registration is type-based, so
|
||||
/// its ServiceDescriptor.ImplementationType is inspectable
|
||||
/// without constructing it) and free of local-filesystem side effects (a
|
||||
/// LocalDirectoryBackupStorage registration is factory-based and is
|
||||
/// never invoked here either).
|
||||
/// </summary>
|
||||
public sealed class AddBackupConfigStorageSelectionTests
|
||||
{
|
||||
private static IServiceCollection BuildServices(string storageTarget)
|
||||
{
|
||||
Dictionary<string, string?> values = new()
|
||||
{
|
||||
["Backup:Enabled"] = "false",
|
||||
["Backup:StorageTarget"] = storageTarget,
|
||||
["Backup:LocalStoragePath"] = "backups",
|
||||
["Backup:PgDumpPath"] = "pg_dump",
|
||||
};
|
||||
IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(values).Build();
|
||||
|
||||
ServiceCollection services = new();
|
||||
services.AddBackupConfig(configuration);
|
||||
return services;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddBackupConfig_StorageTargetSupabase_RegistersSupabaseBackupStorage()
|
||||
{
|
||||
IServiceCollection services = BuildServices("Supabase");
|
||||
|
||||
ServiceDescriptor descriptor = services.Single(d => d.ServiceType == typeof(IBackupStorage));
|
||||
|
||||
Assert.Equal(typeof(SupabaseBackupStorage), descriptor.ImplementationType);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("supabase")]
|
||||
[InlineData("SUPABASE")]
|
||||
[InlineData("SupaBase")]
|
||||
public void AddBackupConfig_StorageTargetSupabase_CaseInsensitive_RegistersSupabaseBackupStorage(string storageTarget)
|
||||
{
|
||||
IServiceCollection services = BuildServices(storageTarget);
|
||||
|
||||
ServiceDescriptor descriptor = services.Single(d => d.ServiceType == typeof(IBackupStorage));
|
||||
|
||||
Assert.Equal(typeof(SupabaseBackupStorage), descriptor.ImplementationType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Local storage is registered via a factory (it needs the plain
|
||||
/// LocalStoragePath string, not a DI-resolvable type), so this asserts
|
||||
/// it is NOT the type-based Supabase registration rather than invoking
|
||||
/// the factory, which would touch the local filesystem.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("Local")]
|
||||
[InlineData("")]
|
||||
[InlineData("SomethingElse")]
|
||||
public void AddBackupConfig_StorageTargetNotSupabase_RegistersLocalDirectoryBackupStorage_NotSupabase(string storageTarget)
|
||||
{
|
||||
IServiceCollection services = BuildServices(storageTarget);
|
||||
|
||||
ServiceDescriptor descriptor = services.Single(d => d.ServiceType == typeof(IBackupStorage));
|
||||
|
||||
Assert.NotEqual(typeof(SupabaseBackupStorage), descriptor.ImplementationType);
|
||||
Assert.Null(descriptor.ImplementationType);
|
||||
Assert.NotNull(descriptor.ImplementationFactory);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
using API.Controllers;
|
||||
using API.Tests.Backup.Fakes;
|
||||
|
||||
using Application.DTOs.Backup.Response;
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
using Domain.Constants;
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace API.Tests.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// Pure unit tests for BackupController's outcome-to-status-code
|
||||
/// mapping (design.md's "Controllers return an explicit outcome, not
|
||||
/// exception-mapped status codes" decision) — GET/POST/DELETE against a fake
|
||||
/// IBackupOperationsService, no HTTP pipeline
|
||||
/// involved. Non-Admin/anonymous 401/403 gating (which only takes effect via
|
||||
/// [Authorize] in the real MVC pipeline) is covered separately by
|
||||
/// BackupAuthorizationTests.
|
||||
/// </summary>
|
||||
public class BackupControllerTests
|
||||
{
|
||||
private static BackupController CreateSut(IBackupOperationsService? operations = null)
|
||||
{
|
||||
return new BackupController(operations ?? new FakeBackupOperationsService());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAll_ReturnsOkWithCatalogRecords()
|
||||
{
|
||||
BackupRecord record = new()
|
||||
{
|
||||
CreatedBy = AuditConstants.SystemUser,
|
||||
StoragePath = "a.sql",
|
||||
SizeBytes = 10,
|
||||
Origin = BackupOrigin.Manual,
|
||||
};
|
||||
FakeBackupOperationsService operations = new()
|
||||
{
|
||||
NextListResult = [record],
|
||||
};
|
||||
BackupController sut = CreateSut(operations: operations);
|
||||
|
||||
ActionResult<IReadOnlyList<BackupRecordResponse>> result = await sut.GetAll(CancellationToken.None);
|
||||
|
||||
OkObjectResult ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
IReadOnlyList<BackupRecordResponse> body = Assert.IsAssignableFrom<IReadOnlyList<BackupRecordResponse>>(ok.Value);
|
||||
Assert.Single(body);
|
||||
Assert.Equal("a.sql", body[0].StoragePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAll_EmptyCatalog_ReturnsOkWithEmptyList()
|
||||
{
|
||||
BackupController sut = CreateSut();
|
||||
|
||||
ActionResult<IReadOnlyList<BackupRecordResponse>> result = await sut.GetAll(CancellationToken.None);
|
||||
|
||||
OkObjectResult ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
IReadOnlyList<BackupRecordResponse> body = Assert.IsAssignableFrom<IReadOnlyList<BackupRecordResponse>>(ok.Value);
|
||||
Assert.Empty(body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_Completed_ReturnsOkWithRecord()
|
||||
{
|
||||
BackupRecordResponse expected = new(Guid.NewGuid(), DateTime.UtcNow, 42, "Manual", "a.sql");
|
||||
FakeBackupOperationsService operations = new()
|
||||
{
|
||||
NextCreateResult = new BackupOperationResult(BackupOperationOutcome.Completed, expected, null),
|
||||
};
|
||||
BackupController sut = CreateSut(operations: operations);
|
||||
|
||||
IActionResult result = await sut.Create(CancellationToken.None);
|
||||
|
||||
OkObjectResult ok = Assert.IsType<OkObjectResult>(result);
|
||||
BackupRecordResponse body = Assert.IsType<BackupRecordResponse>(ok.Value);
|
||||
Assert.Equal(expected, body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_Busy_ReturnsConflict()
|
||||
{
|
||||
FakeBackupOperationsService operations = new()
|
||||
{
|
||||
NextCreateResult = new BackupOperationResult(BackupOperationOutcome.Busy, null, "busy"),
|
||||
};
|
||||
BackupController sut = CreateSut(operations: operations);
|
||||
|
||||
IActionResult result = await sut.Create(CancellationToken.None);
|
||||
|
||||
Assert.IsType<ConflictObjectResult>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_Failed_ReturnsInternalServerError()
|
||||
{
|
||||
FakeBackupOperationsService operations = new()
|
||||
{
|
||||
NextCreateResult = new BackupOperationResult(BackupOperationOutcome.Failed, null, "boom"),
|
||||
};
|
||||
BackupController sut = CreateSut(operations: operations);
|
||||
|
||||
IActionResult result = await sut.Create(CancellationToken.None);
|
||||
|
||||
ObjectResult obj = Assert.IsType<ObjectResult>(result);
|
||||
Assert.Equal(StatusCodes.Status500InternalServerError, obj.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_Completed_ReturnsNoContent()
|
||||
{
|
||||
FakeBackupOperationsService operations = new()
|
||||
{
|
||||
NextDeleteResult = new BackupOperationResult(BackupOperationOutcome.Completed, null, null),
|
||||
};
|
||||
BackupController sut = CreateSut(operations: operations);
|
||||
|
||||
IActionResult result = await sut.Delete(Guid.NewGuid(), CancellationToken.None);
|
||||
|
||||
Assert.IsType<NoContentResult>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_NotFound_ReturnsNotFound()
|
||||
{
|
||||
FakeBackupOperationsService operations = new()
|
||||
{
|
||||
NextDeleteResult = new BackupOperationResult(BackupOperationOutcome.NotFound, null, null),
|
||||
};
|
||||
BackupController sut = CreateSut(operations: operations);
|
||||
|
||||
IActionResult result = await sut.Delete(Guid.NewGuid(), CancellationToken.None);
|
||||
|
||||
Assert.IsType<NotFoundResult>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_Busy_ReturnsConflict()
|
||||
{
|
||||
FakeBackupOperationsService operations = new()
|
||||
{
|
||||
NextDeleteResult = new BackupOperationResult(BackupOperationOutcome.Busy, null, "busy"),
|
||||
};
|
||||
BackupController sut = CreateSut(operations: operations);
|
||||
|
||||
IActionResult result = await sut.Delete(Guid.NewGuid(), CancellationToken.None);
|
||||
|
||||
Assert.IsType<ConflictObjectResult>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_Failed_ReturnsInternalServerError()
|
||||
{
|
||||
FakeBackupOperationsService operations = new()
|
||||
{
|
||||
NextDeleteResult = new BackupOperationResult(BackupOperationOutcome.Failed, null, "boom"),
|
||||
};
|
||||
BackupController sut = CreateSut(operations: operations);
|
||||
|
||||
IActionResult result = await sut.Delete(Guid.NewGuid(), CancellationToken.None);
|
||||
|
||||
ObjectResult obj = Assert.IsType<ObjectResult>(result);
|
||||
Assert.Equal(StatusCodes.Status500InternalServerError, obj.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// threat-matrix "Restore of foreign/uploaded dumps": Restore's only
|
||||
/// input parameter is the route Guid — no [FromBody] parameter
|
||||
/// exists on the action, so no request body is ever bound/deserialized
|
||||
/// into a path or dump payload. Confirming the exact id reaches
|
||||
/// IBackupOperationsService.RestoreBackupAsync is the closest
|
||||
/// unit-level proof of that (no separate body-binding surface to probe).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Restore_Completed_ReturnsOkWithRecord_PassesOnlyRouteId()
|
||||
{
|
||||
Guid id = Guid.NewGuid();
|
||||
BackupRecordResponse expected = new(Guid.NewGuid(), DateTime.UtcNow, 99, "Job", "safety.sql");
|
||||
FakeBackupOperationsService operations = new()
|
||||
{
|
||||
NextRestoreResult = new BackupOperationResult(BackupOperationOutcome.Completed, expected, null),
|
||||
};
|
||||
BackupController sut = CreateSut(operations: operations);
|
||||
|
||||
IActionResult result = await sut.Restore(id, CancellationToken.None);
|
||||
|
||||
OkObjectResult ok = Assert.IsType<OkObjectResult>(result);
|
||||
BackupRecordResponse body = Assert.IsType<BackupRecordResponse>(ok.Value);
|
||||
Assert.Equal(expected, body);
|
||||
Assert.Equal(id, operations.LastRestoreId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restore_NotFound_ReturnsNotFound()
|
||||
{
|
||||
FakeBackupOperationsService operations = new()
|
||||
{
|
||||
NextRestoreResult = new BackupOperationResult(BackupOperationOutcome.NotFound, null, null),
|
||||
};
|
||||
BackupController sut = CreateSut(operations: operations);
|
||||
|
||||
IActionResult result = await sut.Restore(Guid.NewGuid(), CancellationToken.None);
|
||||
|
||||
Assert.IsType<NotFoundResult>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restore_Busy_ReturnsConflict()
|
||||
{
|
||||
FakeBackupOperationsService operations = new()
|
||||
{
|
||||
NextRestoreResult = new BackupOperationResult(BackupOperationOutcome.Busy, null, "busy"),
|
||||
};
|
||||
BackupController sut = CreateSut(operations: operations);
|
||||
|
||||
IActionResult result = await sut.Restore(Guid.NewGuid(), CancellationToken.None);
|
||||
|
||||
Assert.IsType<ConflictObjectResult>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restore_Failed_ReturnsInternalServerError()
|
||||
{
|
||||
FakeBackupOperationsService operations = new()
|
||||
{
|
||||
NextRestoreResult = new BackupOperationResult(BackupOperationOutcome.Failed, null, "boom"),
|
||||
};
|
||||
BackupController sut = CreateSut(operations: operations);
|
||||
|
||||
IActionResult result = await sut.Restore(Guid.NewGuid(), CancellationToken.None);
|
||||
|
||||
ObjectResult obj = Assert.IsType<ObjectResult>(result);
|
||||
Assert.Equal(StatusCodes.Status500InternalServerError, obj.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Application.Backup;
|
||||
|
||||
namespace API.Tests.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for BackupOperationLock: the process-wide single-flight
|
||||
/// guard shared by manual (BackupController) and scheduled
|
||||
/// (DatabaseBackupHostedService) backup/restore attempts. Covers
|
||||
/// backup-catalog#Single-Shared-Write-Path.
|
||||
/// </summary>
|
||||
public class BackupOperationLockTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task WaitAsync_SecondCallWhileHeld_ReturnsFalse()
|
||||
{
|
||||
BackupOperationLock sut = new();
|
||||
|
||||
bool first = await sut.WaitAsync(TimeSpan.Zero);
|
||||
bool second = await sut.WaitAsync(TimeSpan.Zero);
|
||||
|
||||
Assert.True(first, "First acquisition of an unheld lock must succeed.");
|
||||
Assert.False(second, "A second acquisition attempt while the lock is held must fail immediately.");
|
||||
|
||||
sut.Release();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitAsync_AfterRelease_AllowsAcquisitionAgain()
|
||||
{
|
||||
BackupOperationLock sut = new();
|
||||
bool first = await sut.WaitAsync(TimeSpan.Zero);
|
||||
sut.Release();
|
||||
|
||||
bool acquiredAgain = await sut.WaitAsync(TimeSpan.Zero);
|
||||
|
||||
Assert.True(first);
|
||||
Assert.True(acquiredAgain, "Once released, the lock must be acquirable again.");
|
||||
|
||||
sut.Release();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
using API.Tests.Backup.Fakes;
|
||||
|
||||
using Application.Backup;
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
using Domain.Constants;
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace API.Tests.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for BackupOperationsService — the one shared write
|
||||
/// path used by both BackupController (manual) and
|
||||
/// DatabaseBackupHostedService (scheduled, via a DI scope). All
|
||||
/// dependencies are fakes; no real pg_dump, storage I/O, or database is
|
||||
/// involved. Covers backup-catalog#Single-Shared-Write-Path,
|
||||
/// backup-catalog#Failed-Backups-Are-Not-Catalogued,
|
||||
/// backup-catalog#Delete-Removes-Both-Stored-File-and-Catalog-Record, and
|
||||
/// scheduled-database-backups#Keep-Last-N-Retention-Pruning (shared
|
||||
/// Manual+Job pool).
|
||||
/// </summary>
|
||||
public class BackupOperationsServiceTests
|
||||
{
|
||||
private static BackupOptions Options(int retentionCount = 7)
|
||||
{
|
||||
return new BackupOptions { RetentionCount = retentionCount };
|
||||
}
|
||||
|
||||
private static BackupOperationsService CreateSut(
|
||||
FakeBackupCatalog? catalog = null,
|
||||
FakeBackupStorage? storage = null,
|
||||
FakeDatabaseBackupService? backupService = null,
|
||||
FakeDatabaseRestoreService? restoreService = null,
|
||||
IBackupRetentionPolicy? retentionPolicy = null,
|
||||
BackupOptions? options = null,
|
||||
BackupOperationLock? operationLock = null,
|
||||
IMaintenanceModeState? maintenanceModeState = null,
|
||||
FakeAuditService? auditService = null,
|
||||
ILogger<BackupOperationsService>? logger = null)
|
||||
{
|
||||
return new BackupOperationsService(
|
||||
catalog ?? new FakeBackupCatalog(),
|
||||
storage ?? new FakeBackupStorage(),
|
||||
backupService ?? new FakeDatabaseBackupService(),
|
||||
restoreService ?? new FakeDatabaseRestoreService(),
|
||||
retentionPolicy ?? new KeepLastNRetentionPolicy(),
|
||||
options ?? Options(),
|
||||
operationLock ?? new BackupOperationLock(),
|
||||
maintenanceModeState ?? new MaintenanceModeState(),
|
||||
auditService ?? new FakeAuditService(),
|
||||
logger ?? NullLogger<BackupOperationsService>.Instance);
|
||||
}
|
||||
|
||||
private static BackupRecord NewRecord(string storagePath, BackupOrigin origin, DateTime dateCreated)
|
||||
{
|
||||
return new BackupRecord
|
||||
{
|
||||
CreatedBy = AuditConstants.SystemUser,
|
||||
StoragePath = storagePath,
|
||||
SizeBytes = 1,
|
||||
Origin = origin,
|
||||
DateCreated = dateCreated,
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBackupAsync_Succeeds_AddsCatalogRecord()
|
||||
{
|
||||
FakeBackupCatalog catalog = new();
|
||||
BackupOperationsService sut = CreateSut(catalog: catalog);
|
||||
|
||||
BackupOperationResult result = await sut.CreateBackupAsync(BackupOrigin.Manual);
|
||||
|
||||
Assert.Equal(BackupOperationOutcome.Completed, result.Outcome);
|
||||
Assert.NotNull(result.Record);
|
||||
Assert.Equal("Manual", result.Record!.Origin);
|
||||
Assert.Equal(1, catalog.AddCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBackupAsync_ConcurrentCalls_SecondReturnsBusy_NoSecondCatalogRow()
|
||||
{
|
||||
FakeBackupCatalog catalog = new();
|
||||
FakeDatabaseBackupService backupService = new()
|
||||
{
|
||||
Gate = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously),
|
||||
};
|
||||
BackupOperationsService sut = CreateSut(catalog: catalog, backupService: backupService);
|
||||
|
||||
Task<BackupOperationResult> firstCall = sut.CreateBackupAsync(BackupOrigin.Manual);
|
||||
bool startedFirstCall = await TestTiming.WaitUntilAsync(() => backupService.CallCount >= 1, TimeSpan.FromSeconds(2));
|
||||
Assert.True(startedFirstCall, "Expected the first call to reach the (gated) dump step before starting the second.");
|
||||
|
||||
BackupOperationResult second = await sut.CreateBackupAsync(BackupOrigin.Job);
|
||||
|
||||
backupService.Gate.SetResult(true);
|
||||
BackupOperationResult first = await firstCall;
|
||||
|
||||
Assert.Equal(BackupOperationOutcome.Busy, second.Outcome);
|
||||
Assert.Equal(BackupOperationOutcome.Completed, first.Outcome);
|
||||
Assert.Equal(1, catalog.AddCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBackupAsync_DumpFails_NoCatalogRecordWritten()
|
||||
{
|
||||
FakeBackupCatalog catalog = new();
|
||||
FakeDatabaseBackupService backupService = new() { FailFirstCalls = 1 };
|
||||
BackupOperationsService sut = CreateSut(catalog: catalog, backupService: backupService);
|
||||
|
||||
BackupOperationResult result = await sut.CreateBackupAsync(BackupOrigin.Manual);
|
||||
|
||||
Assert.Equal(BackupOperationOutcome.Failed, result.Outcome);
|
||||
Assert.Equal(0, catalog.AddCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteBackupAsync_StorageFileMissing_CatalogRowStillRemoved_WarningLogged()
|
||||
{
|
||||
FakeBackupCatalog catalog = new();
|
||||
FakeBackupStorage storage = new() { DeleteException = new FileNotFoundException("missing") };
|
||||
CapturingLogger<BackupOperationsService> logger = new();
|
||||
BackupOperationsService sut = CreateSut(catalog: catalog, storage: storage, logger: logger);
|
||||
BackupRecord seeded = await catalog.AddAsync(NewRecord("backups/to-delete.sql", BackupOrigin.Manual, DateTime.UtcNow));
|
||||
|
||||
BackupOperationResult result = await sut.DeleteBackupAsync(seeded.Id);
|
||||
|
||||
Assert.Equal(BackupOperationOutcome.Completed, result.Outcome);
|
||||
Assert.Null(await catalog.GetByIdAsync(seeded.Id));
|
||||
Assert.Contains(logger.Entries, e => e.Level == LogLevel.Warning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteBackupAsync_UnknownId_ReturnsNotFound()
|
||||
{
|
||||
BackupOperationsService sut = CreateSut();
|
||||
|
||||
BackupOperationResult result = await sut.DeleteBackupAsync(Guid.NewGuid());
|
||||
|
||||
Assert.Equal(BackupOperationOutcome.NotFound, result.Outcome);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// scheduled-database-backups#Keep-Last-N-Retention-Pruning: retention
|
||||
/// now reads the catalog (Manual+Job combined), not
|
||||
/// IBackupStorage.ListAsync(), so pruning applies across both
|
||||
/// origins with no per-origin cap.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateBackupAsync_RetentionAppliesAcrossSharedManualAndJobPool_PrunesOldestRegardlessOfOrigin()
|
||||
{
|
||||
FakeBackupCatalog catalog = new();
|
||||
FakeBackupStorage storage = new();
|
||||
BackupOperationsService sut = CreateSut(catalog: catalog, storage: storage, options: Options(retentionCount: 2));
|
||||
|
||||
DateTime baseline = DateTime.UtcNow.AddDays(-1);
|
||||
await catalog.AddAsync(NewRecord("job-old.sql", BackupOrigin.Job, baseline));
|
||||
await catalog.AddAsync(NewRecord("manual-mid.sql", BackupOrigin.Manual, baseline.AddHours(1)));
|
||||
|
||||
BackupOperationResult result = await sut.CreateBackupAsync(BackupOrigin.Manual);
|
||||
|
||||
Assert.Equal(BackupOperationOutcome.Completed, result.Outcome);
|
||||
IReadOnlyList<BackupRecord> remaining = await catalog.ListNewestFirstAsync();
|
||||
Assert.Equal(2, remaining.Count);
|
||||
Assert.DoesNotContain(remaining, r => r.StoragePath == "job-old.sql");
|
||||
Assert.Contains(remaining, r => r.StoragePath == "manual-mid.sql");
|
||||
Assert.Contains(storage.DeletedNames, n => n == "job-old.sql");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBackupAsync_WithinRetentionLimit_PrunesNothing()
|
||||
{
|
||||
FakeBackupCatalog catalog = new();
|
||||
FakeBackupStorage storage = new();
|
||||
BackupOperationsService sut = CreateSut(catalog: catalog, storage: storage, options: Options(retentionCount: 5));
|
||||
|
||||
await catalog.AddAsync(NewRecord("job-old.sql", BackupOrigin.Job, DateTime.UtcNow.AddDays(-1)));
|
||||
|
||||
await sut.CreateBackupAsync(BackupOrigin.Manual);
|
||||
|
||||
IReadOnlyList<BackupRecord> remaining = await catalog.ListNewestFirstAsync();
|
||||
Assert.Equal(2, remaining.Count);
|
||||
Assert.Empty(storage.DeletedNames);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// database-restore#Automatic-Pre-Restore-Safety-Backup: every restore
|
||||
/// takes an automatic backup of the current state first, catalogued with
|
||||
/// Origin = Job and applyRetention: false — so it is kept
|
||||
/// even if the catalog is already at (or past) RetentionCount.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RestoreBackupAsync_TakesSafetyBackupWithJobOriginAndNoRetention_EvenPastRetentionLimit()
|
||||
{
|
||||
FakeBackupCatalog catalog = new();
|
||||
FakeBackupStorage storage = new();
|
||||
MaintenanceModeState maintenanceModeState = new();
|
||||
BackupOperationsService sut = CreateSut(
|
||||
catalog: catalog,
|
||||
storage: storage,
|
||||
options: Options(retentionCount: 1),
|
||||
maintenanceModeState: maintenanceModeState);
|
||||
BackupRecord target = await catalog.AddAsync(NewRecord("existing.sql", BackupOrigin.Manual, DateTime.UtcNow.AddDays(-1)));
|
||||
|
||||
BackupOperationResult result = await sut.RestoreBackupAsync(target.Id);
|
||||
|
||||
Assert.Equal(BackupOperationOutcome.Completed, result.Outcome);
|
||||
Assert.NotNull(result.Record);
|
||||
Assert.Equal("Job", result.Record!.Origin);
|
||||
|
||||
// RetentionCount is 1, but the safety backup uses applyRetention: false,
|
||||
// so both the pre-existing target AND the new safety backup survive.
|
||||
IReadOnlyList<BackupRecord> all = await catalog.ListNewestFirstAsync();
|
||||
Assert.Equal(2, all.Count);
|
||||
BackupRecord safety = Assert.Single(all, r => r.Id != target.Id);
|
||||
Assert.Equal(BackupOrigin.Job, safety.Origin);
|
||||
Assert.False(maintenanceModeState.IsActive);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HU-101: a successful restore must be auditable — AuditAction.BackupRestore
|
||||
/// existed in the enum but nothing ever logged it (a real gap found while
|
||||
/// auditing historias-de-usuario.md against the actual code).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RestoreBackupAsync_Succeeds_LogsBackupRestoreAuditEntry()
|
||||
{
|
||||
FakeBackupCatalog catalog = new();
|
||||
FakeAuditService auditService = new();
|
||||
BackupOperationsService sut = CreateSut(catalog: catalog, auditService: auditService);
|
||||
BackupRecord target = await catalog.AddAsync(NewRecord("existing.sql", BackupOrigin.Manual, DateTime.UtcNow));
|
||||
|
||||
BackupOperationResult result = await sut.RestoreBackupAsync(target.Id);
|
||||
|
||||
Assert.Equal(BackupOperationOutcome.Completed, result.Outcome);
|
||||
Assert.Contains(AuditAction.BackupRestore, auditService.LoggedActions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreBackupAsync_UnknownId_ReturnsNotFound()
|
||||
{
|
||||
BackupOperationsService sut = CreateSut();
|
||||
|
||||
BackupOperationResult result = await sut.RestoreBackupAsync(Guid.NewGuid());
|
||||
|
||||
Assert.Equal(BackupOperationOutcome.NotFound, result.Outcome);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// database-restore#Restore-Failure-Is-Logged-and-Isolated +
|
||||
/// threat-matrix "Temp-file handling during restore": a restore failure
|
||||
/// must still clear maintenance mode, delete the temp dump file, and
|
||||
/// never throw out of RestoreBackupAsync (no host crash).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RestoreBackupAsync_RestoreServiceThrows_MaintenanceExited_TempFileDeleted_NoCrash()
|
||||
{
|
||||
FakeBackupCatalog catalog = new();
|
||||
FakeBackupStorage storage = new();
|
||||
FakeDatabaseRestoreService restoreService = new() { ExceptionToThrow = new BackupExecutionException("psql failed") };
|
||||
MaintenanceModeState maintenanceModeState = new();
|
||||
BackupOperationsService sut = CreateSut(
|
||||
catalog: catalog,
|
||||
storage: storage,
|
||||
restoreService: restoreService,
|
||||
maintenanceModeState: maintenanceModeState);
|
||||
BackupRecord target = await catalog.AddAsync(NewRecord("existing.sql", BackupOrigin.Manual, DateTime.UtcNow));
|
||||
|
||||
BackupOperationResult result = await sut.RestoreBackupAsync(target.Id);
|
||||
|
||||
Assert.Equal(BackupOperationOutcome.Failed, result.Outcome);
|
||||
Assert.False(maintenanceModeState.IsActive);
|
||||
Assert.NotNull(restoreService.CapturedDumpFilePath);
|
||||
Assert.False(File.Exists(restoreService.CapturedDumpFilePath));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// threat-matrix "Denial of service via repeated restore": the same
|
||||
/// single-flight BackupOperationLock used by create/delete also guards
|
||||
/// restore, so a concurrent restore attempt returns Busy (409 at the
|
||||
/// controller) instead of running alongside another restore.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RestoreBackupAsync_ConcurrentCalls_SecondReturnsBusy_OnlyOneRestoreRuns()
|
||||
{
|
||||
FakeBackupCatalog catalog = new();
|
||||
FakeBackupStorage storage = new();
|
||||
FakeDatabaseBackupService backupService = new()
|
||||
{
|
||||
Gate = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously),
|
||||
};
|
||||
FakeDatabaseRestoreService restoreService = new();
|
||||
BackupOperationsService sut = CreateSut(
|
||||
catalog: catalog, storage: storage, backupService: backupService, restoreService: restoreService);
|
||||
BackupRecord target = await catalog.AddAsync(NewRecord("existing.sql", BackupOrigin.Manual, DateTime.UtcNow));
|
||||
|
||||
Task<BackupOperationResult> firstCall = sut.RestoreBackupAsync(target.Id);
|
||||
bool startedFirstCall = await TestTiming.WaitUntilAsync(() => backupService.CallCount >= 1, TimeSpan.FromSeconds(2));
|
||||
Assert.True(startedFirstCall, "Expected the first restore to reach the (gated) safety-backup dump step before starting the second.");
|
||||
|
||||
BackupOperationResult second = await sut.RestoreBackupAsync(target.Id);
|
||||
|
||||
backupService.Gate.SetResult(true);
|
||||
BackupOperationResult first = await firstCall;
|
||||
|
||||
Assert.Equal(BackupOperationOutcome.Busy, second.Outcome);
|
||||
Assert.Equal(BackupOperationOutcome.Completed, first.Outcome);
|
||||
Assert.Equal(1, restoreService.CallCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using API.BackgroundServices;
|
||||
using API.Tests.Backup.Fakes;
|
||||
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace API.Tests.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for DatabaseBackupHostedService: interval-trigger
|
||||
/// logic, the Backup:Enabled gate, and failure isolation. Since the
|
||||
/// PR2 refactor, the service resolves IBackupOperationsService from a
|
||||
/// DI scope per tick and no longer owns its own single-flight flag — that
|
||||
/// guard now lives in BackupOperationLock, shared with the manual
|
||||
/// endpoint, and is exercised at that layer
|
||||
/// (BackupOperationsServiceTests). All tests use
|
||||
/// DatabaseBackupHostedService.IntervalOverride (a short,
|
||||
/// deterministic interval) instead of sleeping for real
|
||||
/// IntervalHours-scale durations, and poll via TestTiming
|
||||
/// rather than fixed sleeps to avoid flakiness.
|
||||
/// </summary>
|
||||
public class DatabaseBackupHostedServiceTests
|
||||
{
|
||||
private static BackupOptions EnabledOptions()
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Enabled = true,
|
||||
IntervalHours = 24,
|
||||
RetentionCount = 7,
|
||||
};
|
||||
}
|
||||
|
||||
private static IServiceScopeFactory ScopeFactoryFor(IBackupOperationsService operations)
|
||||
{
|
||||
ServiceCollection services = new();
|
||||
services.AddSingleton(operations);
|
||||
ServiceProvider provider = services.BuildServiceProvider();
|
||||
return provider.GetRequiredService<IServiceScopeFactory>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_IntervalElapses_TriggersOneBackupAttempt()
|
||||
{
|
||||
FakeBackupOperationsService operations = new();
|
||||
DatabaseBackupHostedService service = new(
|
||||
ScopeFactoryFor(operations), EnabledOptions(), NullLogger<DatabaseBackupHostedService>.Instance)
|
||||
{
|
||||
IntervalOverride = TimeSpan.FromMilliseconds(30),
|
||||
};
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
bool triggered = await TestTiming.WaitUntilAsync(() => operations.CreateCallCount >= 1, TimeSpan.FromSeconds(2));
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(triggered, "Expected at least one backup attempt after the interval elapsed.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The 100ms delay is well short of the 5s interval used here.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_IntervalNotYetElapsed_NoBackupAttemptTriggered()
|
||||
{
|
||||
FakeBackupOperationsService operations = new();
|
||||
DatabaseBackupHostedService service = new(
|
||||
ScopeFactoryFor(operations), EnabledOptions(), NullLogger<DatabaseBackupHostedService>.Instance)
|
||||
{
|
||||
IntervalOverride = TimeSpan.FromSeconds(5),
|
||||
};
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
await Task.Delay(100);
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, operations.CreateCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Disabled_NeverCallsOperationsService()
|
||||
{
|
||||
BackupOptions options = new() { Enabled = false, IntervalHours = 24, RetentionCount = 7 };
|
||||
FakeBackupOperationsService operations = new();
|
||||
DatabaseBackupHostedService service = new(
|
||||
ScopeFactoryFor(operations), options, NullLogger<DatabaseBackupHostedService>.Instance)
|
||||
{
|
||||
IntervalOverride = TimeSpan.FromMilliseconds(20),
|
||||
};
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
await Task.Delay(150); // several would-be intervals, to prove no scheduling ever starts
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, operations.CreateCallCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Proves the hosted service no longer guards overlapping ticks itself —
|
||||
/// it simply calls CreateBackupAsync every tick and tolerates a
|
||||
/// Busy outcome (from the shared BackupOperationLock)
|
||||
/// without throwing or stalling later ticks.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_OperationReturnsBusy_LogsAndContinuesTicking_NoThrow()
|
||||
{
|
||||
FakeBackupOperationsService operations = new()
|
||||
{
|
||||
NextCreateResult = new BackupOperationResult(BackupOperationOutcome.Busy, null, "busy"),
|
||||
};
|
||||
CapturingLogger<DatabaseBackupHostedService> logger = new();
|
||||
DatabaseBackupHostedService service = new(
|
||||
ScopeFactoryFor(operations), EnabledOptions(), logger)
|
||||
{
|
||||
IntervalOverride = TimeSpan.FromMilliseconds(20),
|
||||
};
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
bool calledTwice = await TestTiming.WaitUntilAsync(() => operations.CreateCallCount >= 2, TimeSpan.FromSeconds(2));
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(calledTwice, "A Busy outcome must not stop later ticks from also attempting a backup.");
|
||||
Assert.Contains(
|
||||
logger.Entries,
|
||||
e => e.Level == LogLevel.Warning && e.Message.Contains("progress", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_BackupFails_LoggedAndHostSurvives_NextTickStillRuns()
|
||||
{
|
||||
FakeBackupOperationsService operations = new() { FailFirstCalls = 1 };
|
||||
CapturingLogger<DatabaseBackupHostedService> logger = new();
|
||||
DatabaseBackupHostedService service = new(
|
||||
ScopeFactoryFor(operations), EnabledOptions(), logger)
|
||||
{
|
||||
IntervalOverride = TimeSpan.FromMilliseconds(25),
|
||||
};
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
bool secondCallHappened = await TestTiming.WaitUntilAsync(() => operations.CreateCallCount >= 2, TimeSpan.FromSeconds(2));
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(secondCallHappened, "A failed attempt must not stop the host from ticking again.");
|
||||
Assert.Contains(
|
||||
logger.Entries,
|
||||
e => e.Level == LogLevel.Error && e.Message.Contains("backup", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using Domain.Constants;
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Persistance;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace API.Tests.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies EfBackupCatalog's CRUD surface (IBackupCatalog) against a real
|
||||
/// EF Core context backed by SQLite in-memory, via
|
||||
/// CustomWebApplicationFactory (same harness used by
|
||||
/// DataMaintenanceServiceTests). Covers spec
|
||||
/// backup-catalog#Catalog-Powers-the-Admin-Backup-Listing: AddAsync
|
||||
/// persists a record and assigns an Id, GetByIdAsync resolves a single
|
||||
/// record (or null when missing), ListNewestFirstAsync orders the full
|
||||
/// catalog by creation date descending, and RemoveAsync deletes a record
|
||||
/// and is idempotent when the id is already gone.
|
||||
/// </summary>
|
||||
public sealed class EfBackupCatalogTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
|
||||
public EfBackupCatalogTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
private static BackupRecord NewRecord(string storagePath, BackupOrigin origin, DateTime dateCreated)
|
||||
{
|
||||
return new BackupRecord
|
||||
{
|
||||
CreatedBy = AuditConstants.SystemUser,
|
||||
StoragePath = storagePath,
|
||||
SizeBytes = 1024,
|
||||
Origin = origin,
|
||||
DateCreated = dateCreated,
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAsync_PersistsRecord_AndAssignsId()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
EfBackupCatalog catalog = new(db);
|
||||
|
||||
BackupRecord record = NewRecord("backups/manual-1.sql", BackupOrigin.Manual, DateTime.UtcNow);
|
||||
|
||||
BackupRecord added = await catalog.AddAsync(record);
|
||||
|
||||
Assert.NotEqual(Guid.Empty, added.Id);
|
||||
|
||||
BackupRecord? persisted = await db.BackupRecords.FindAsync(added.Id);
|
||||
Assert.NotNull(persisted);
|
||||
Assert.Equal("backups/manual-1.sql", persisted!.StoragePath);
|
||||
Assert.Equal(BackupOrigin.Manual, persisted.Origin);
|
||||
Assert.Equal(1024, persisted.SizeBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ReturnsMatchingRecord()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
EfBackupCatalog catalog = new(db);
|
||||
|
||||
BackupRecord added = await catalog.AddAsync(NewRecord("backups/job-1.sql", BackupOrigin.Job, DateTime.UtcNow));
|
||||
|
||||
BackupRecord? found = await catalog.GetByIdAsync(added.Id);
|
||||
|
||||
Assert.NotNull(found);
|
||||
Assert.Equal(added.Id, found!.Id);
|
||||
Assert.Equal(BackupOrigin.Job, found.Origin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ReturnsNull_WhenRecordDoesNotExist()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
EfBackupCatalog catalog = new(db);
|
||||
|
||||
BackupRecord? found = await catalog.GetByIdAsync(Guid.NewGuid());
|
||||
|
||||
Assert.Null(found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListNewestFirstAsync_OrdersRecordsByDateCreatedDescending()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
EfBackupCatalog catalog = new(db);
|
||||
|
||||
// xUnit does not guarantee fact execution order and
|
||||
// CustomWebApplicationFactory shares one database per class, so
|
||||
// earlier facts in this class may have left rows behind — establish
|
||||
// a known-empty fixture before asserting exact ordering.
|
||||
List<BackupRecord> existing = await db.BackupRecords.ToListAsync();
|
||||
db.BackupRecords.RemoveRange(existing);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
DateTime baseline = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
BackupRecord oldest = await catalog.AddAsync(NewRecord("backups/oldest.sql", BackupOrigin.Job, baseline));
|
||||
BackupRecord middle = await catalog.AddAsync(NewRecord("backups/middle.sql", BackupOrigin.Manual, baseline.AddHours(1)));
|
||||
BackupRecord newest = await catalog.AddAsync(NewRecord("backups/newest.sql", BackupOrigin.Job, baseline.AddHours(2)));
|
||||
|
||||
IReadOnlyList<BackupRecord> result = await catalog.ListNewestFirstAsync();
|
||||
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal(newest.Id, result[0].Id);
|
||||
Assert.Equal(middle.Id, result[1].Id);
|
||||
Assert.Equal(oldest.Id, result[2].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveAsync_DeletesRecord()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
EfBackupCatalog catalog = new(db);
|
||||
|
||||
BackupRecord added = await catalog.AddAsync(NewRecord("backups/to-delete.sql", BackupOrigin.Manual, DateTime.UtcNow));
|
||||
|
||||
await catalog.RemoveAsync(added.Id);
|
||||
|
||||
BackupRecord? persisted = await db.BackupRecords.FindAsync(added.Id);
|
||||
Assert.Null(persisted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveAsync_IsIdempotent_WhenRecordAlreadyMissing()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
ApplicationDBContext db = scope.ServiceProvider.GetRequiredService<ApplicationDBContext>();
|
||||
EfBackupCatalog catalog = new(db);
|
||||
Guid missingId = Guid.NewGuid();
|
||||
|
||||
// Should not throw even though nothing exists at this id.
|
||||
await catalog.RemoveAsync(missingId);
|
||||
|
||||
BackupRecord? stillMissing = await catalog.GetByIdAsync(missingId);
|
||||
Assert.Null(stillMissing);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Tests.Backup.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// A single log call captured by CapturingLogger{T}.
|
||||
/// </summary>
|
||||
public sealed record CapturingLogEntry(LogLevel Level, string Message, Exception? Exception);
|
||||
@@ -0,0 +1,38 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Tests.Backup.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal ILogger{T} test double that records every log entry
|
||||
/// so tests can assert a failure was actually logged (spec requirement),
|
||||
/// without pulling in a logging test-framework dependency.
|
||||
/// </summary>
|
||||
public sealed class CapturingLogger<T> : ILogger<T>
|
||||
{
|
||||
private readonly List<CapturingLogEntry> _entries = [];
|
||||
|
||||
public IReadOnlyList<CapturingLogEntry> Entries => _entries;
|
||||
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull
|
||||
{
|
||||
return NullScope.Instance;
|
||||
}
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
lock (_entries)
|
||||
{
|
||||
_entries.Add(new CapturingLogEntry(logLevel, formatter(state, exception), exception));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Application.DTOs.Abstract.Response;
|
||||
using Application.DTOs.AuditLogs.Request;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
namespace API.Tests.Backup.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Test double for IAuditService. Records every call so tests can assert on
|
||||
/// what got logged without a real repository/database.
|
||||
/// </summary>
|
||||
public sealed class FakeAuditService : IAuditService
|
||||
{
|
||||
public List<AuditAction> LoggedActions { get; } = [];
|
||||
|
||||
public Task LogAsync(
|
||||
AuditAction action,
|
||||
string? targetType = null,
|
||||
string? targetId = null,
|
||||
string? targetName = null,
|
||||
string? detail = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
LoggedActions.Add(action);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<PaginatedResponse<AuditLog>> GetAuditLogsAsync(AuditLogFilteredRequest filter)
|
||||
{
|
||||
return Task.FromResult(new PaginatedResponse<AuditLog>
|
||||
{
|
||||
Page = filter.PageNumber,
|
||||
PageSize = filter.PageSize,
|
||||
TotalCount = 0,
|
||||
Items = [],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
|
||||
namespace API.Tests.Backup.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory test double for IBackupCatalog. Lets
|
||||
/// BackupOperationsServiceTests and BackupControllerTests exercise
|
||||
/// the shared create/delete/retention logic without a real database.
|
||||
/// </summary>
|
||||
public sealed class FakeBackupCatalog : IBackupCatalog
|
||||
{
|
||||
private readonly List<BackupRecord> _records = [];
|
||||
|
||||
public int AddCallCount { get; private set; }
|
||||
public int RemoveCallCount { get; private set; }
|
||||
|
||||
public Task<BackupRecord> AddAsync(BackupRecord record, CancellationToken ct = default)
|
||||
{
|
||||
AddCallCount++;
|
||||
if (record.Id == Guid.Empty)
|
||||
{
|
||||
record.Id = Guid.NewGuid();
|
||||
}
|
||||
|
||||
_records.Add(record);
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
|
||||
public Task<BackupRecord?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(_records.Find(r => r.Id == id));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<BackupRecord>> ListNewestFirstAsync(CancellationToken ct = default)
|
||||
{
|
||||
IReadOnlyList<BackupRecord> ordered = _records
|
||||
.OrderByDescending(r => r.DateCreated)
|
||||
.ToList();
|
||||
return Task.FromResult(ordered);
|
||||
}
|
||||
|
||||
public Task RemoveAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
RemoveCallCount++;
|
||||
_records.RemoveAll(r => r.Id == id);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Application.DTOs.Backup.Response;
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
namespace API.Tests.Backup.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Test double for IBackupOperationsService. Used both by
|
||||
/// BackupControllerTests (configuring the exact outcome an endpoint
|
||||
/// call should map to an HTTP status) and by
|
||||
/// DatabaseBackupHostedServiceTests (using Gate/FailFirstCalls to
|
||||
/// simulate a slow/failing scheduled attempt).
|
||||
/// </summary>
|
||||
public sealed class FakeBackupOperationsService : IBackupOperationsService
|
||||
{
|
||||
private int _createCallCount;
|
||||
|
||||
public int CreateCallCount => _createCallCount;
|
||||
|
||||
/// <summary>
|
||||
/// When set, CreateBackupAsync awaits this before returning —
|
||||
/// simulates a still-running attempt for concurrency-related tests.
|
||||
/// </summary>
|
||||
public TaskCompletionSource<bool>? Gate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The first N calls to CreateBackupAsync return
|
||||
/// Failed instead of NextCreateResult.
|
||||
/// </summary>
|
||||
public int FailFirstCalls { get; set; }
|
||||
|
||||
public BackupOperationResult NextCreateResult { get; set; } =
|
||||
new(BackupOperationOutcome.Completed, new BackupRecordResponse(Guid.NewGuid(), DateTime.UtcNow, 0, "Manual", "fake.sql"), null);
|
||||
|
||||
public BackupOperationResult NextDeleteResult { get; set; } =
|
||||
new(BackupOperationOutcome.Completed, null, null);
|
||||
|
||||
public BackupOperationResult NextRestoreResult { get; set; } =
|
||||
new(BackupOperationOutcome.Completed, new BackupRecordResponse(Guid.NewGuid(), DateTime.UtcNow, 0, "Job", "safety.sql"), null);
|
||||
|
||||
public Guid? LastRestoreId { get; private set; }
|
||||
|
||||
public IReadOnlyList<BackupRecord> NextListResult { get; set; } = [];
|
||||
|
||||
public Task<IReadOnlyList<BackupRecord>> ListNewestFirstAsync(CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(NextListResult);
|
||||
}
|
||||
|
||||
public async Task<BackupOperationResult> CreateBackupAsync(BackupOrigin origin, CancellationToken ct = default)
|
||||
{
|
||||
int call = Interlocked.Increment(ref _createCallCount);
|
||||
|
||||
if (Gate is not null)
|
||||
{
|
||||
await Gate.Task;
|
||||
}
|
||||
|
||||
return call <= FailFirstCalls
|
||||
? new BackupOperationResult(BackupOperationOutcome.Failed, null, $"Simulated failure on call {call}.")
|
||||
: NextCreateResult;
|
||||
}
|
||||
|
||||
public Task<BackupOperationResult> DeleteBackupAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(NextDeleteResult);
|
||||
}
|
||||
|
||||
public Task<BackupOperationResult> RestoreBackupAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
LastRestoreId = id;
|
||||
return Task.FromResult(NextRestoreResult);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
namespace API.Tests.Backup.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Test double for IBackupStorage. Records call counts and
|
||||
/// deleted names so hosted-service tests can assert on storage/retention
|
||||
/// interaction without any real I/O.
|
||||
/// </summary>
|
||||
public sealed class FakeBackupStorage : IBackupStorage
|
||||
{
|
||||
private int _storeCallCount;
|
||||
private int _listCallCount;
|
||||
private int _deleteCallCount;
|
||||
|
||||
public int StoreCallCount => _storeCallCount;
|
||||
public int ListCallCount => _listCallCount;
|
||||
public int DeleteCallCount => _deleteCallCount;
|
||||
|
||||
public IReadOnlyList<BackupFile> FilesToList { get; set; } = Array.Empty<BackupFile>();
|
||||
public List<string> DeletedNames { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// When set, DeleteAsync throws this instead of succeeding —
|
||||
/// simulates the stored file already being missing out-of-band.
|
||||
/// </summary>
|
||||
public Exception? DeleteException { get; set; }
|
||||
|
||||
public Task StoreAsync(string name, Stream content, CancellationToken ct = default)
|
||||
{
|
||||
Interlocked.Increment(ref _storeCallCount);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<BackupFile>> ListAsync(CancellationToken ct = default)
|
||||
{
|
||||
Interlocked.Increment(ref _listCallCount);
|
||||
return Task.FromResult(FilesToList);
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string name, CancellationToken ct = default)
|
||||
{
|
||||
Interlocked.Increment(ref _deleteCallCount);
|
||||
lock (DeletedNames)
|
||||
{
|
||||
DeletedNames.Add(name);
|
||||
}
|
||||
|
||||
if (DeleteException is not null)
|
||||
{
|
||||
throw DeleteException;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When set, OpenReadAsync throws this instead of succeeding —
|
||||
/// simulates a missing/unreadable stored file.
|
||||
/// </summary>
|
||||
public Exception? OpenReadException { get; set; }
|
||||
|
||||
public Stream ContentToOpen { get; set; } = new MemoryStream();
|
||||
|
||||
public Task<Stream> OpenReadAsync(string name, CancellationToken ct = default)
|
||||
{
|
||||
if (OpenReadException is not null)
|
||||
{
|
||||
throw OpenReadException;
|
||||
}
|
||||
|
||||
return Task.FromResult(ContentToOpen);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
using System.Text;
|
||||
|
||||
namespace API.Tests.Backup.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Test double for IDatabaseBackupService. Supports simulating
|
||||
/// a slow/still-running dump (via Gate) for single-flight tests,
|
||||
/// and simulating the first N calls failing (via FailFirstCalls)
|
||||
/// for failure-isolation tests.
|
||||
/// </summary>
|
||||
public sealed class FakeDatabaseBackupService : IDatabaseBackupService
|
||||
{
|
||||
private int _callCount;
|
||||
|
||||
public int CallCount => _callCount;
|
||||
|
||||
/// <summary>
|
||||
/// When set, CreateDumpAsync awaits this before returning/throwing.
|
||||
/// </summary>
|
||||
public TaskCompletionSource<bool>? Gate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The first N calls throw BackupExecutionException; subsequent calls succeed.
|
||||
/// </summary>
|
||||
public int FailFirstCalls { get; set; }
|
||||
|
||||
public string DumpContent { get; set; } = "-- fake dump --";
|
||||
|
||||
public async Task<Stream> CreateDumpAsync(CancellationToken ct = default)
|
||||
{
|
||||
int call = Interlocked.Increment(ref _callCount);
|
||||
|
||||
if (Gate is not null)
|
||||
{
|
||||
await Gate.Task;
|
||||
}
|
||||
|
||||
return call <= FailFirstCalls
|
||||
? throw new BackupExecutionException($"Simulated backup failure on call {call}.")
|
||||
: (Stream) new MemoryStream(Encoding.UTF8.GetBytes(DumpContent));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
namespace API.Tests.Backup.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Test double for IDatabaseRestoreService. No real psql binary
|
||||
/// involved — records the dump file path it was invoked with (so tests can
|
||||
/// assert the temp file cleanup happens after the call) and can be
|
||||
/// configured to throw to simulate a failed restore (spec
|
||||
/// database-restore#Restore-Failure-Is-Logged-and-Isolated).
|
||||
/// </summary>
|
||||
public sealed class FakeDatabaseRestoreService : IDatabaseRestoreService
|
||||
{
|
||||
private int _callCount;
|
||||
|
||||
public int CallCount => _callCount;
|
||||
|
||||
public string? CapturedDumpFilePath { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// When set, RestoreAsync throws this instead of succeeding.
|
||||
/// </summary>
|
||||
public Exception? ExceptionToThrow { get; set; }
|
||||
|
||||
public Task RestoreAsync(string dumpFilePath, CancellationToken ct = default)
|
||||
{
|
||||
Interlocked.Increment(ref _callCount);
|
||||
CapturedDumpFilePath = dumpFilePath;
|
||||
|
||||
if (ExceptionToThrow is not null)
|
||||
{
|
||||
throw ExceptionToThrow;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
namespace API.Tests.Backup.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Test double for IProcessRunner. Records exactly what it was
|
||||
/// invoked with (file name, argument vector, environment variables) so tests
|
||||
/// can assert on those without a real subprocess, and returns a
|
||||
/// pre-configured ProcessResult.
|
||||
/// </summary>
|
||||
public sealed class FakeProcessRunner : IProcessRunner
|
||||
{
|
||||
public string? CapturedFileName { get; private set; }
|
||||
public IReadOnlyList<string>? CapturedArgs { get; private set; }
|
||||
public IReadOnlyDictionary<string, string>? CapturedEnvironmentVariables { get; private set; }
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public ProcessResult ResultToReturn { get; set; } = new(0, string.Empty, string.Empty);
|
||||
|
||||
public Task<ProcessResult> RunAsync(
|
||||
string fileName,
|
||||
IReadOnlyList<string> args,
|
||||
IReadOnlyDictionary<string, string>? environmentVariables = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
CallCount++;
|
||||
CapturedFileName = fileName;
|
||||
CapturedArgs = args;
|
||||
CapturedEnvironmentVariables = environmentVariables;
|
||||
|
||||
return Task.FromResult(ResultToReturn);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using Application.Utils.Helper.SupabaseHelper;
|
||||
|
||||
namespace API.Tests.Backup.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Test double for ISupabaseRawStorage. Records the exact
|
||||
/// object paths (and, per HU medical-records-storage-eligibility, the target
|
||||
/// bucket) passed to each raw call and can be configured to throw (simulating
|
||||
/// a network/auth error from the real Supabase client), so
|
||||
/// SupabaseBackupStorage and SupabaseMedicalRecordStorage can be unit tested
|
||||
/// without a real SupabaseHelper (whose constructor performs real network
|
||||
/// initialization) or any network call — per this change's spec Non-Goal on
|
||||
/// actual Supabase upload verification.
|
||||
/// </summary>
|
||||
public sealed class FakeSupabaseRawStorage : ISupabaseRawStorage
|
||||
{
|
||||
public List<string> UploadedPaths { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The <c>bucket</c> argument passed to each <see cref="UploadRawAsync"/>
|
||||
/// call, in order — <see langword="null"/> when the caller did not pass a
|
||||
/// bucket (i.e. relied on the configured default).
|
||||
/// </summary>
|
||||
public List<string?> UploadedBuckets { get; } = [];
|
||||
|
||||
public List<string> RemovedPaths { get; } = [];
|
||||
public string? LastListedPrefix { get; private set; }
|
||||
public string? LastDownloadedPath { get; private set; }
|
||||
|
||||
/// <summary>The <c>bucket</c> argument passed to the last <see cref="DownloadRawAsync"/> call.</summary>
|
||||
public string? DownloadedBucket { get; private set; }
|
||||
|
||||
public IReadOnlyList<SupabaseStorageEntry> EntriesToList { get; set; } = Array.Empty<SupabaseStorageEntry>();
|
||||
|
||||
public byte[] BytesToDownload { get; set; } = Array.Empty<byte>();
|
||||
|
||||
/// <summary>
|
||||
/// When set, every raw call throws this exception instead of succeeding —
|
||||
/// simulates a network error, auth error, or any other Supabase client
|
||||
/// failure.
|
||||
/// </summary>
|
||||
public Exception? ExceptionToThrow { get; set; }
|
||||
|
||||
public Task UploadRawAsync(string objectPath, Stream content, string? bucket = null)
|
||||
{
|
||||
if (ExceptionToThrow is not null)
|
||||
{
|
||||
throw ExceptionToThrow;
|
||||
}
|
||||
|
||||
UploadedPaths.Add(objectPath);
|
||||
UploadedBuckets.Add(bucket);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<SupabaseStorageEntry>> ListRawAsync(string prefix, string? bucket = null)
|
||||
{
|
||||
if (ExceptionToThrow is not null)
|
||||
{
|
||||
throw ExceptionToThrow;
|
||||
}
|
||||
|
||||
LastListedPrefix = prefix;
|
||||
return Task.FromResult(EntriesToList);
|
||||
}
|
||||
|
||||
public Task RemoveRawAsync(string objectPath, string? bucket = null)
|
||||
{
|
||||
if (ExceptionToThrow is not null)
|
||||
{
|
||||
throw ExceptionToThrow;
|
||||
}
|
||||
|
||||
RemovedPaths.Add(objectPath);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<byte[]> DownloadRawAsync(string objectPath, string? bucket = null)
|
||||
{
|
||||
if (ExceptionToThrow is not null)
|
||||
{
|
||||
throw ExceptionToThrow;
|
||||
}
|
||||
|
||||
LastDownloadedPath = objectPath;
|
||||
DownloadedBucket = bucket;
|
||||
return Task.FromResult(BytesToDownload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace API.Tests.Backup.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// No-op IDisposable returned from ILogger.BeginScope, shared by every
|
||||
/// CapturingLogger{T} instance regardless of T.
|
||||
/// </summary>
|
||||
public sealed class NullScope : IDisposable
|
||||
{
|
||||
public static readonly NullScope Instance = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace API.Tests.Backup.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Polls a condition instead of sleeping for a fixed real-time duration, so
|
||||
/// timing-sensitive hosted-service tests stay fast on quick machines and
|
||||
/// resilient (non-flaky) on slow/loaded CI runners.
|
||||
/// </summary>
|
||||
internal static class TestTiming
|
||||
{
|
||||
public static async Task<bool> WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
|
||||
{
|
||||
DateTime deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (condition())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
await Task.Delay(10);
|
||||
}
|
||||
return condition();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using Application.Backup;
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
namespace API.Tests.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the pure keep-last-N retention decision. No I/O — the
|
||||
/// policy only selects which BackupFile entries to delete
|
||||
/// given an already-known list and a retain count.
|
||||
/// </summary>
|
||||
public class KeepLastNRetentionPolicyTests
|
||||
{
|
||||
private static readonly KeepLastNRetentionPolicy Policy = new();
|
||||
|
||||
private static BackupFile File(string name, int minutesAgo)
|
||||
{
|
||||
return new(name, DateTimeOffset.UtcNow.AddMinutes(-minutesAgo));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectForDeletion_CountWithinLimit_SelectsNone()
|
||||
{
|
||||
List<BackupFile> existing =
|
||||
[
|
||||
File("backup-1", minutesAgo: 30),
|
||||
File("backup-2", minutesAgo: 20),
|
||||
File("backup-3", minutesAgo: 10),
|
||||
];
|
||||
|
||||
IReadOnlyList<BackupFile> result = Policy.SelectForDeletion(existing, retainCount: 5);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectForDeletion_CountEqualsLimit_SelectsNone()
|
||||
{
|
||||
List<BackupFile> existing =
|
||||
[
|
||||
File("backup-1", minutesAgo: 30),
|
||||
File("backup-2", minutesAgo: 20),
|
||||
];
|
||||
|
||||
IReadOnlyList<BackupFile> result = Policy.SelectForDeletion(existing, retainCount: 2);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entries are oldest-to-newest by minutesAgo: backup-5 (50m) ... backup-1 (10m).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SelectForDeletion_CountExceedsLimit_SelectsOldestExcess_RetainsNewestN()
|
||||
{
|
||||
List<BackupFile> existing =
|
||||
[
|
||||
File("backup-1", minutesAgo: 10),
|
||||
File("backup-2", minutesAgo: 20),
|
||||
File("backup-3", minutesAgo: 30),
|
||||
File("backup-4", minutesAgo: 40),
|
||||
File("backup-5", minutesAgo: 50),
|
||||
];
|
||||
|
||||
IReadOnlyList<BackupFile> result = Policy.SelectForDeletion(existing, retainCount: 2);
|
||||
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal(
|
||||
new[] { "backup-3", "backup-4", "backup-5" },
|
||||
result.Select(f => f.Name).OrderBy(n => n, StringComparer.Ordinal).ToArray());
|
||||
Assert.DoesNotContain(result, f => f.Name is "backup-1" or "backup-2");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per the documented tie-break rule (design.md Open Questions), among
|
||||
/// entries with identical timestamps the lexically-smallest name
|
||||
/// (ordinal) is retained.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SelectForDeletion_IdenticalTimestampsAtBoundary_IsDeterministicAndOrderIndependent()
|
||||
{
|
||||
DateTimeOffset tiedTimestamp = DateTimeOffset.UtcNow.AddMinutes(-10);
|
||||
|
||||
List<BackupFile> existing =
|
||||
[
|
||||
new BackupFile("backup-b", tiedTimestamp),
|
||||
new BackupFile("backup-a", tiedTimestamp),
|
||||
new BackupFile("backup-c", tiedTimestamp),
|
||||
];
|
||||
|
||||
IReadOnlyList<BackupFile> firstRun = Policy.SelectForDeletion(existing, retainCount: 1);
|
||||
|
||||
List<BackupFile> reordered = [existing[2], existing[0], existing[1]];
|
||||
IReadOnlyList<BackupFile> secondRun = Policy.SelectForDeletion(reordered, retainCount: 1);
|
||||
|
||||
Assert.Equal(2, firstRun.Count);
|
||||
Assert.Equal(
|
||||
firstRun.Select(f => f.Name).OrderBy(n => n, StringComparer.Ordinal),
|
||||
secondRun.Select(f => f.Name).OrderBy(n => n, StringComparer.Ordinal));
|
||||
|
||||
Assert.DoesNotContain(firstRun, f => f.Name == "backup-a");
|
||||
Assert.Contains(firstRun, f => f.Name == "backup-b");
|
||||
Assert.Contains(firstRun, f => f.Name == "backup-c");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectForDeletion_EmptyList_SelectsNone()
|
||||
{
|
||||
IReadOnlyList<BackupFile> result = Policy.SelectForDeletion([], retainCount: 7);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectForDeletion_RetainCountZero_SelectsAll()
|
||||
{
|
||||
List<BackupFile> existing =
|
||||
[
|
||||
File("backup-1", minutesAgo: 10),
|
||||
File("backup-2", minutesAgo: 20),
|
||||
];
|
||||
|
||||
IReadOnlyList<BackupFile> result = Policy.SelectForDeletion(existing, retainCount: 0);
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using Application.Backup;
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
using Infrastructure.Backup;
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
using System.Text;
|
||||
|
||||
namespace API.Tests.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// Tests LocalDirectoryBackupStorage against a real temporary
|
||||
/// directory (safe: local filesystem only, no external dependency), plus an
|
||||
/// end-to-end integration with the real KeepLastNRetentionPolicy
|
||||
/// to prove retention actually deletes the correct files when invoked
|
||||
/// through the storage adapter.
|
||||
/// </summary>
|
||||
public sealed class LocalDirectoryBackupStorageTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly LocalDirectoryBackupStorage _storage;
|
||||
|
||||
public LocalDirectoryBackupStorageTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), "club12-backup-tests-" + Guid.NewGuid().ToString("N"));
|
||||
_storage = new LocalDirectoryBackupStorage(_tempDir, NullLogger<LocalDirectoryBackupStorage>.Instance);
|
||||
}
|
||||
|
||||
private static Stream ContentStream(string text)
|
||||
{
|
||||
return new MemoryStream(Encoding.UTF8.GetBytes(text));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_ThenListAsync_ReturnsStoredFile()
|
||||
{
|
||||
await _storage.StoreAsync("backup-1.sql", ContentStream("dump-1"));
|
||||
|
||||
IReadOnlyList<BackupFile> files = await _storage.ListAsync();
|
||||
|
||||
Assert.Single(files);
|
||||
Assert.Equal("backup-1.sql", files[0].Name);
|
||||
Assert.Equal("dump-1", await File.ReadAllTextAsync(Path.Combine(_tempDir, "backup-1.sql")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_RemovesFile_ListNoLongerContainsIt()
|
||||
{
|
||||
await _storage.StoreAsync("backup-1.sql", ContentStream("dump-1"));
|
||||
await _storage.StoreAsync("backup-2.sql", ContentStream("dump-2"));
|
||||
|
||||
await _storage.DeleteAsync("backup-1.sql");
|
||||
IReadOnlyList<BackupFile> files = await _storage.ListAsync();
|
||||
|
||||
Assert.Single(files);
|
||||
Assert.Equal("backup-2.sql", files[0].Name);
|
||||
Assert.False(File.Exists(Path.Combine(_tempDir, "backup-1.sql")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenReadAsync_StoredFile_ReturnsReadableStreamWithContent()
|
||||
{
|
||||
await _storage.StoreAsync("backup-1.sql", ContentStream("dump-1"));
|
||||
|
||||
await using Stream stream = await _storage.OpenReadAsync("backup-1.sql");
|
||||
using StreamReader reader = new(stream);
|
||||
string content = await reader.ReadToEndAsync();
|
||||
|
||||
Assert.Equal("dump-1", content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A catalog row's StoragePath is expected to be server-generated
|
||||
/// and safe, but OpenReadAsync must still independently
|
||||
/// re-validate it via the same ResolveSafePath guard used by
|
||||
/// StoreAsync/DeleteAsync — a malformed/malicious catalog value must
|
||||
/// never reach a file read (threat: storage path traversal).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task OpenReadAsync_TraversalName_ThrowsArgumentException_NoFileOpened()
|
||||
{
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => _storage.OpenReadAsync("../../etc/passwd"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenReadAsync_RootedPathName_ThrowsArgumentException()
|
||||
{
|
||||
string rooted = OperatingSystem.IsWindows() ? "C:\\evil.sql" : "/etc/passwd";
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => _storage.OpenReadAsync(rooted));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_NameEscapingConfiguredDirectory_ThrowsArgumentException()
|
||||
{
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => _storage.StoreAsync("../escape.sql", ContentStream("evil")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_RootedPathName_ThrowsArgumentException()
|
||||
{
|
||||
string rooted = OperatingSystem.IsWindows() ? "C:\\evil.sql" : "/etc/evil.sql";
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => _storage.StoreAsync(rooted, ContentStream("evil")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_NameEscapingConfiguredDirectory_ThrowsArgumentException()
|
||||
{
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => _storage.DeleteAsync("../../escape.sql"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// retainCount = 2; 4 files stored (N+2). One tied pair straddles the
|
||||
/// retention boundary to exercise the documented tie-break rule (newest
|
||||
/// timestamp first, then lexically-smallest name retained).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RetentionIntegration_StoresNPlus2_RetainsNewestN_DeletesExactlyTwoPerTieBreak()
|
||||
{
|
||||
const int retainCount = 2;
|
||||
DateTime tied = DateTime.UtcNow.AddMinutes(-10);
|
||||
|
||||
await StoreWithTimestamp("file-newest.sql", DateTime.UtcNow.AddMinutes(-5));
|
||||
await StoreWithTimestamp("file-tied-a.sql", tied);
|
||||
await StoreWithTimestamp("file-tied-b.sql", tied);
|
||||
await StoreWithTimestamp("file-oldest.sql", DateTime.UtcNow.AddMinutes(-40));
|
||||
|
||||
IReadOnlyList<BackupFile> existing = await _storage.ListAsync();
|
||||
Assert.Equal(4, existing.Count);
|
||||
|
||||
KeepLastNRetentionPolicy retention = new();
|
||||
IReadOnlyList<BackupFile> toDelete = retention.SelectForDeletion(existing, retainCount);
|
||||
|
||||
Assert.Equal(2, toDelete.Count);
|
||||
Assert.Equal(
|
||||
new[] { "file-oldest.sql", "file-tied-b.sql" },
|
||||
toDelete.Select(f => f.Name).OrderBy(n => n, StringComparer.Ordinal).ToArray());
|
||||
|
||||
foreach (BackupFile stale in toDelete)
|
||||
{
|
||||
await _storage.DeleteAsync(stale.Name);
|
||||
}
|
||||
|
||||
IReadOnlyList<BackupFile> remaining = await _storage.ListAsync();
|
||||
Assert.Equal(2, remaining.Count);
|
||||
Assert.Contains(remaining, f => f.Name == "file-newest.sql");
|
||||
Assert.Contains(remaining, f => f.Name == "file-tied-a.sql");
|
||||
}
|
||||
|
||||
private async Task StoreWithTimestamp(string name, DateTime timestampUtc)
|
||||
{
|
||||
await _storage.StoreAsync(name, ContentStream("dump-content-for-" + name));
|
||||
File.SetLastWriteTimeUtc(Path.Combine(_tempDir, name), timestampUtc);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using API.Utils.Middlewares;
|
||||
|
||||
using Application.Backup;
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace API.Tests.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for MaintenanceModeMiddleware (design.md's "Maintenance
|
||||
/// gate is middleware placed after UseCors, before
|
||||
/// UseAuthentication" decision — covers every request shape,
|
||||
/// including unmatched routes and Swagger, unlike an MVC filter). Exercises
|
||||
/// InvokeAsync directly against a DefaultHttpContext and a real
|
||||
/// MaintenanceModeState — no HTTP pipeline/host required.
|
||||
/// </summary>
|
||||
public class MaintenanceModeMiddlewareTests
|
||||
{
|
||||
private sealed class CallCounter
|
||||
{
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
private static (MaintenanceModeMiddleware Middleware, CallCounter Counter) CreateSut(IMaintenanceModeState state)
|
||||
{
|
||||
CallCounter counter = new();
|
||||
RequestDelegate next = _ =>
|
||||
{
|
||||
counter.Count++;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
return (new MaintenanceModeMiddleware(next, state), counter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// threat-matrix "Routing gate bypass (maintenance 503)": /api/backups,
|
||||
/// /swagger, and an unmatched route all must return 503 while
|
||||
/// maintenance is active — the middleware runs before endpoint routing,
|
||||
/// so this must not depend on any route being matched.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("/api/backups")]
|
||||
[InlineData("/swagger")]
|
||||
[InlineData("/this-route-does-not-exist")]
|
||||
public async Task InvokeAsync_MaintenanceActive_NonAllowedPath_Returns503_DoesNotCallNext(string path)
|
||||
{
|
||||
MaintenanceModeState state = new();
|
||||
state.Enter("restore in progress");
|
||||
(MaintenanceModeMiddleware sut, CallCounter counter) = CreateSut(state);
|
||||
DefaultHttpContext context = new();
|
||||
context.Request.Path = path;
|
||||
|
||||
await sut.InvokeAsync(context);
|
||||
|
||||
Assert.Equal(StatusCodes.Status503ServiceUnavailable, context.Response.StatusCode);
|
||||
Assert.Equal(0, counter.Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/health")]
|
||||
[InlineData("/health/ready")]
|
||||
[InlineData("/api/maintenance")]
|
||||
public async Task InvokeAsync_MaintenanceActive_AllowedPath_CallsNext(string path)
|
||||
{
|
||||
MaintenanceModeState state = new();
|
||||
state.Enter("restore in progress");
|
||||
(MaintenanceModeMiddleware sut, CallCounter counter) = CreateSut(state);
|
||||
DefaultHttpContext context = new();
|
||||
context.Request.Path = path;
|
||||
|
||||
await sut.InvokeAsync(context);
|
||||
|
||||
Assert.Equal(1, counter.Count);
|
||||
Assert.NotEqual(StatusCodes.Status503ServiceUnavailable, context.Response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_MaintenanceInactive_CallsNext_ForAnyPath()
|
||||
{
|
||||
MaintenanceModeState state = new();
|
||||
(MaintenanceModeMiddleware sut, CallCounter counter) = CreateSut(state);
|
||||
DefaultHttpContext context = new();
|
||||
context.Request.Path = "/api/backups";
|
||||
|
||||
await sut.InvokeAsync(context);
|
||||
|
||||
Assert.Equal(1, counter.Count);
|
||||
Assert.NotEqual(StatusCodes.Status503ServiceUnavailable, context.Response.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Application.Backup;
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
namespace API.Tests.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for MaintenanceModeState — pure in-memory process
|
||||
/// state (design.md: "Maintenance state lives in Application/Backup, not
|
||||
/// Infrastructure", registered as a singleton). Covers the
|
||||
/// Enter/Exit transitions and the IsActive/Reason/EnteredAtUtc
|
||||
/// shape MaintenanceStatusResponse projects.
|
||||
/// </summary>
|
||||
public sealed class MaintenanceModeStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void InitialState_IsNotActive_ReasonAndEnteredAtUtcAreNull()
|
||||
{
|
||||
IMaintenanceModeState state = new MaintenanceModeState();
|
||||
|
||||
Assert.False(state.IsActive);
|
||||
Assert.Null(state.Reason);
|
||||
Assert.Null(state.EnteredAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enter_SetsIsActiveTrue_AndReason_AndEnteredAtUtc()
|
||||
{
|
||||
IMaintenanceModeState state = new MaintenanceModeState();
|
||||
DateTimeOffset before = DateTimeOffset.UtcNow;
|
||||
|
||||
state.Enter("Restoring backup abc123");
|
||||
|
||||
DateTimeOffset after = DateTimeOffset.UtcNow;
|
||||
Assert.True(state.IsActive);
|
||||
Assert.Equal("Restoring backup abc123", state.Reason);
|
||||
Assert.NotNull(state.EnteredAtUtc);
|
||||
Assert.InRange(state.EnteredAtUtc!.Value, before, after);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Exit_AfterEnter_ClearsIsActive_ReasonAndEnteredAtUtc()
|
||||
{
|
||||
IMaintenanceModeState state = new MaintenanceModeState();
|
||||
state.Enter("Restoring backup abc123");
|
||||
|
||||
state.Exit();
|
||||
|
||||
Assert.False(state.IsActive);
|
||||
Assert.Null(state.Reason);
|
||||
Assert.Null(state.EnteredAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The manual escape hatch (DELETE api/maintenance) must be able
|
||||
/// to clear a stuck window even when no restore is in flight — Exit()
|
||||
/// has no precondition on a prior Enter().
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Exit_WithoutPriorEnter_DoesNotThrow_StaysInactive()
|
||||
{
|
||||
IMaintenanceModeState state = new MaintenanceModeState();
|
||||
|
||||
state.Exit();
|
||||
|
||||
Assert.False(state.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enter_TwiceWithDifferentReasons_LatestReasonWins()
|
||||
{
|
||||
IMaintenanceModeState state = new MaintenanceModeState();
|
||||
|
||||
state.Enter("First reason");
|
||||
state.Enter("Second reason");
|
||||
|
||||
Assert.True(state.IsActive);
|
||||
Assert.Equal("Second reason", state.Reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
using API.Tests.Backup.Fakes;
|
||||
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
using Infrastructure.Backup;
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace API.Tests.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for PgDumpBackupService using a
|
||||
/// FakeProcessRunner — no real pg_dump binary involved.
|
||||
/// Covers: correct argument-vector construction from the connection string,
|
||||
/// subprocess argument-injection resistance, and failure handling
|
||||
/// (non-zero exit / missing binary → logged, handled exception, not a crash).
|
||||
/// </summary>
|
||||
public class PgDumpBackupServiceTests
|
||||
{
|
||||
private static IConfiguration BuildConfiguration(string connectionString, string? pgDumpPath = null)
|
||||
{
|
||||
Dictionary<string, string?> values = new()
|
||||
{
|
||||
["ConnectionStrings:DbConnection"] = connectionString,
|
||||
};
|
||||
if (pgDumpPath is not null)
|
||||
{
|
||||
values["Backup:PgDumpPath"] = pgDumpPath;
|
||||
}
|
||||
|
||||
return new ConfigurationBuilder().AddInMemoryCollection(values).Build();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateDumpAsync_SuccessfulExit_ReturnsStdOutAsStream()
|
||||
{
|
||||
FakeProcessRunner runner = new()
|
||||
{
|
||||
ResultToReturn = new ProcessResult(0, "-- pg_dump output --", string.Empty),
|
||||
};
|
||||
IConfiguration configuration = BuildConfiguration(
|
||||
"Host=localhost;Port=5432;Database=club12;Username=app;Password=secret");
|
||||
PgDumpBackupService service = new(runner, configuration, NullLogger<PgDumpBackupService>.Instance);
|
||||
|
||||
await using Stream dump = await service.CreateDumpAsync();
|
||||
|
||||
using StreamReader reader = new(dump);
|
||||
string content = await reader.ReadToEndAsync();
|
||||
Assert.Equal("-- pg_dump output --", content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The password must never appear in the argument vector, since it would
|
||||
/// otherwise leak via `ps`/task list; it is instead passed through the
|
||||
/// PGPASSWORD environment variable.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateDumpAsync_BuildsArgumentVectorFromConnectionString_PasswordNeverInArgs()
|
||||
{
|
||||
FakeProcessRunner runner = new() { ResultToReturn = new ProcessResult(0, "ok", string.Empty) };
|
||||
IConfiguration configuration = BuildConfiguration(
|
||||
"Host=db.internal;Port=5433;Database=club12;Username=app_user;Password=s3cret");
|
||||
|
||||
PgDumpBackupService service = new(runner, configuration, NullLogger<PgDumpBackupService>.Instance);
|
||||
|
||||
await service.CreateDumpAsync();
|
||||
|
||||
Assert.NotNull(runner.CapturedArgs);
|
||||
Assert.Contains("db.internal", runner.CapturedArgs!);
|
||||
Assert.Contains("5433", runner.CapturedArgs!);
|
||||
Assert.Contains("app_user", runner.CapturedArgs!);
|
||||
Assert.Contains("club12", runner.CapturedArgs!);
|
||||
Assert.DoesNotContain("s3cret", runner.CapturedArgs!);
|
||||
Assert.NotNull(runner.CapturedEnvironmentVariables);
|
||||
Assert.Equal("s3cret", runner.CapturedEnvironmentVariables!["PGPASSWORD"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// maliciousDbName is crafted to look like a shell-injection payload and
|
||||
/// must survive as one literal argument-vector element, never concatenated
|
||||
/// into a shell command string that a shell could re-tokenize/expand. ';'
|
||||
/// and '=' are reserved separators in the ADO connection-string format
|
||||
/// itself — a different boundary than the subprocess argument vector under
|
||||
/// test here — so the payload avoids those two characters and instead uses
|
||||
/// shell metacharacters: $(), backticks, pipes, and &.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateDumpAsync_ArgumentInjectionAttempt_PassedAsLiteralArgVectorElement()
|
||||
{
|
||||
FakeProcessRunner runner = new() { ResultToReturn = new ProcessResult(0, "ok", string.Empty) };
|
||||
const string maliciousDbName = "club12$(touch pwned)`whoami`|evil&";
|
||||
IConfiguration configuration = BuildConfiguration(
|
||||
$"Host=localhost;Port=5432;Database={maliciousDbName};Username=app;Password=x");
|
||||
|
||||
PgDumpBackupService service = new(runner, configuration, NullLogger<PgDumpBackupService>.Instance);
|
||||
|
||||
await service.CreateDumpAsync();
|
||||
|
||||
Assert.Contains(maliciousDbName, runner.CapturedArgs!);
|
||||
Assert.All(runner.CapturedArgs!, a => Assert.DoesNotContain("&&", a));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restoring with psql -f against a Supabase-managed database can
|
||||
/// fail on ownership/role statements captured by a plain pg_dump;
|
||||
/// these flags move that safety onto the dump side (design.md's
|
||||
/// "Keep plain-SQL dumps; restore with psql" decision).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateDumpAsync_IncludesCleanAndOwnershipSafetyFlags()
|
||||
{
|
||||
FakeProcessRunner runner = new() { ResultToReturn = new ProcessResult(0, "ok", string.Empty) };
|
||||
IConfiguration configuration = BuildConfiguration(
|
||||
"Host=localhost;Port=5432;Database=club12;Username=app;Password=x");
|
||||
PgDumpBackupService service = new(runner, configuration, NullLogger<PgDumpBackupService>.Instance);
|
||||
|
||||
await service.CreateDumpAsync();
|
||||
|
||||
Assert.NotNull(runner.CapturedArgs);
|
||||
Assert.Contains("--clean", runner.CapturedArgs!);
|
||||
Assert.Contains("--if-exists", runner.CapturedArgs!);
|
||||
Assert.Contains("--no-owner", runner.CapturedArgs!);
|
||||
Assert.Contains("--no-privileges", runner.CapturedArgs!);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// pg_dump with no schema restriction captures the WHOLE database,
|
||||
/// including Supabase-platform-owned tables/views/functions the app's
|
||||
/// connection role never owns (e.g. a "vector_indexes" table from
|
||||
/// Supabase's own tooling) — --clean's DROP for those then fails with
|
||||
/// "must be owner of table X" on restore. The app's own data lives in
|
||||
/// exactly two schemas: "public" (ASP.NET Core Identity's default,
|
||||
/// unconfigured schema) and "Club12" (every domain entity, via
|
||||
/// EntityConstants.Schema) — restricting the dump to just those excludes
|
||||
/// every Supabase-managed schema at once, rather than reacting to each
|
||||
/// foreign object name as it surfaces one restore attempt at a time.
|
||||
/// The "Club12" pattern MUST be double-quoted (as a literal, embedded in
|
||||
/// the arg-vector element itself — no shell is involved, so no outer
|
||||
/// single-quoting is needed): pg_dump's -n pattern matching folds an
|
||||
/// unquoted pattern to lowercase before comparing, and the real schema
|
||||
/// is mixed-case, so an unquoted "Club12" pattern silently matches
|
||||
/// nothing and pg_dump dumps zero tables from it — confirmed by
|
||||
/// inspecting a real dump taken with the unquoted form, which contained
|
||||
/// only "public" content, not one line of "Club12".
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateDumpAsync_RestrictsDumpToAppOwnedSchemas()
|
||||
{
|
||||
FakeProcessRunner runner = new() { ResultToReturn = new ProcessResult(0, "ok", string.Empty) };
|
||||
IConfiguration configuration = BuildConfiguration(
|
||||
"Host=localhost;Port=5432;Database=club12;Username=app;Password=x");
|
||||
PgDumpBackupService service = new(runner, configuration, NullLogger<PgDumpBackupService>.Instance);
|
||||
|
||||
await service.CreateDumpAsync();
|
||||
|
||||
Assert.NotNull(runner.CapturedArgs);
|
||||
IReadOnlyList<string> args = runner.CapturedArgs!;
|
||||
Assert.Contains("public", args);
|
||||
Assert.Contains("\"Club12\"", args);
|
||||
Assert.Equal(2, args.Count(a => a == "-n"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Supabase-managed databases carry platform-internal event triggers
|
||||
/// (PostgREST's schema-cache-reload hooks, pgsodium's mask-update hook,
|
||||
/// etc.) that a plain pg_dump captures because event triggers are
|
||||
/// database-wide, not schema-scoped — no `-n`/`--exclude-schema` filters
|
||||
/// them out. On restore, `--clean`'s `DROP EVENT TRIGGER` for one of
|
||||
/// these fails with "must be owner of event trigger", since the app's
|
||||
/// connection role never owns Supabase's own infrastructure objects. The
|
||||
/// app never defines its own event triggers, so any EVENT TRIGGER
|
||||
/// statement in the dump is guaranteed to be Supabase-owned and safe to
|
||||
/// drop from the dump entirely (not just the one named in the incident —
|
||||
/// psql aborts at the first one, so others further down the dump would
|
||||
/// never even surface).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateDumpAsync_StripsEventTriggerStatements_SupabaseInternalObjectsNotOwnedByAppRole()
|
||||
{
|
||||
const string rawDump = """
|
||||
SET statement_timeout = 0;
|
||||
DROP EVENT TRIGGER IF EXISTS pgrst_drop_watch;
|
||||
DROP EVENT TRIGGER IF EXISTS pgrst_ddl_watch;
|
||||
CREATE TABLE "Club12"."BackupRecords" (
|
||||
"Id" uuid NOT NULL
|
||||
);
|
||||
CREATE EVENT TRIGGER pgrst_drop_watch ON sql_drop
|
||||
EXECUTE FUNCTION extensions.pgrst_drop_watch();
|
||||
CREATE EVENT TRIGGER pgrst_ddl_watch ON ddl_command_end
|
||||
EXECUTE FUNCTION extensions.pgrst_ddl_watch();
|
||||
COMMENT ON EVENT TRIGGER pgrst_drop_watch IS 'notify PostgREST of DDL changes';
|
||||
INSERT INTO "Club12"."BackupRecords" ("Id") VALUES ('11111111-1111-1111-1111-111111111111');
|
||||
""";
|
||||
FakeProcessRunner runner = new() { ResultToReturn = new ProcessResult(0, rawDump, string.Empty) };
|
||||
IConfiguration configuration = BuildConfiguration(
|
||||
"Host=localhost;Port=5432;Database=club12;Username=app;Password=x");
|
||||
PgDumpBackupService service = new(runner, configuration, NullLogger<PgDumpBackupService>.Instance);
|
||||
|
||||
await using Stream dump = await service.CreateDumpAsync();
|
||||
|
||||
using StreamReader reader = new(dump);
|
||||
string content = await reader.ReadToEndAsync();
|
||||
Assert.DoesNotContain("EVENT TRIGGER", content, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("CREATE TABLE \"Club12\".\"BackupRecords\"", content);
|
||||
Assert.Contains("INSERT INTO \"Club12\".\"BackupRecords\"", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateDumpAsync_NonZeroExitCode_ThrowsBackupExecutionException_NotUncaught()
|
||||
{
|
||||
FakeProcessRunner runner = new()
|
||||
{
|
||||
ResultToReturn = new ProcessResult(1, string.Empty, "pg_dump: error: connection failed"),
|
||||
};
|
||||
IConfiguration configuration = BuildConfiguration(
|
||||
"Host=localhost;Port=5432;Database=club12;Username=app;Password=x");
|
||||
PgDumpBackupService service = new(runner, configuration, NullLogger<PgDumpBackupService>.Instance);
|
||||
|
||||
BackupExecutionException ex = await Assert.ThrowsAsync<BackupExecutionException>(
|
||||
() => service.CreateDumpAsync());
|
||||
|
||||
Assert.Contains("exit code 1", ex.Message);
|
||||
Assert.Contains("connection failed", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simulates what ProcessRunner returns when Process.Start fails for a
|
||||
/// missing executable: sentinel exit code -1, detail in StdErr.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateDumpAsync_MissingBinary_ThrowsHandledExceptionWithActionableMessage()
|
||||
{
|
||||
FakeProcessRunner runner = new()
|
||||
{
|
||||
ResultToReturn = new ProcessResult(
|
||||
-1, string.Empty, "Failed to start process 'pg_dump': The system cannot find the file specified."),
|
||||
};
|
||||
IConfiguration configuration = BuildConfiguration(
|
||||
"Host=localhost;Port=5432;Database=club12;Username=app;Password=x",
|
||||
pgDumpPath: "pg_dump");
|
||||
PgDumpBackupService service = new(runner, configuration, NullLogger<PgDumpBackupService>.Instance);
|
||||
|
||||
BackupExecutionException ex = await Assert.ThrowsAsync<BackupExecutionException>(
|
||||
() => service.CreateDumpAsync());
|
||||
|
||||
Assert.Contains("pg_dump", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("PATH", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateDumpAsync_MissingConnectionString_ThrowsBackupExecutionException()
|
||||
{
|
||||
FakeProcessRunner runner = new();
|
||||
IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(
|
||||
[]).Build();
|
||||
PgDumpBackupService service = new(runner, configuration, NullLogger<PgDumpBackupService>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<BackupExecutionException>(() => service.CreateDumpAsync());
|
||||
|
||||
Assert.Equal(0, runner.CallCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
using Infrastructure.Backup;
|
||||
|
||||
namespace API.Tests.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises the real ProcessRunner adapter (not a fake) —
|
||||
/// proves the missing-binary path degrades to a failed
|
||||
/// ProcessResult instead of throwing an unhandled exception,
|
||||
/// and that a real successful invocation is captured correctly.
|
||||
/// </summary>
|
||||
public class ProcessRunnerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RunAsync_MissingExecutable_ReturnsFailedResult_DoesNotThrow()
|
||||
{
|
||||
ProcessRunner runner = new();
|
||||
|
||||
ProcessResult result = await runner.RunAsync(
|
||||
"club12-definitely-not-a-real-executable-name",
|
||||
args: []);
|
||||
|
||||
Assert.NotEqual(0, result.ExitCode);
|
||||
Assert.False(string.IsNullOrEmpty(result.StdErr));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// "dotnet --version" is used because it is a safe, always-available
|
||||
/// executable in this test environment (the test host itself runs via
|
||||
/// dotnet), exits 0, and writes a version string to stdout.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_SuccessfulProcess_CapturesExitCodeAndStdOut()
|
||||
{
|
||||
ProcessRunner runner = new();
|
||||
|
||||
ProcessResult result = await runner.RunAsync("dotnet", args: ["--version"]);
|
||||
|
||||
Assert.Equal(0, result.ExitCode);
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.StdOut));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using API.Tests.Backup.Fakes;
|
||||
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
using Infrastructure.Backup;
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace API.Tests.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for PsqlDatabaseRestoreService using a
|
||||
/// FakeProcessRunner — no real psql binary involved.
|
||||
/// Covers: correct argument-vector construction (design.md's
|
||||
/// "psql -v ON_ERROR_STOP=1 -f <tmp>" decision — plain-SQL restore, not
|
||||
/// pg_restore), subprocess argument-injection resistance for a
|
||||
/// dump-file path crafted to look like shell/psql-flag injection, and
|
||||
/// failure handling (non-zero exit / missing binary → logged, handled
|
||||
/// exception, not a crash).
|
||||
/// </summary>
|
||||
public class PsqlDatabaseRestoreServiceTests
|
||||
{
|
||||
private static IConfiguration BuildConfiguration(string connectionString, string? psqlPath = null)
|
||||
{
|
||||
Dictionary<string, string?> values = new()
|
||||
{
|
||||
["ConnectionStrings:DbConnection"] = connectionString,
|
||||
};
|
||||
if (psqlPath is not null)
|
||||
{
|
||||
values["Backup:PsqlPath"] = psqlPath;
|
||||
}
|
||||
|
||||
return new ConfigurationBuilder().AddInMemoryCollection(values).Build();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreAsync_SuccessfulExit_CompletesWithoutThrowing()
|
||||
{
|
||||
FakeProcessRunner runner = new() { ResultToReturn = new ProcessResult(0, string.Empty, string.Empty) };
|
||||
IConfiguration configuration = BuildConfiguration(
|
||||
"Host=localhost;Port=5432;Database=club12;Username=app;Password=secret");
|
||||
PsqlDatabaseRestoreService service = new(runner, configuration, NullLogger<PsqlDatabaseRestoreService>.Instance);
|
||||
|
||||
await service.RestoreAsync("/tmp/backup-restore-test.sql");
|
||||
|
||||
Assert.Equal(1, runner.CallCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The password must never appear in the argument vector, since it would
|
||||
/// otherwise leak via `ps`/task list; it is instead passed through the
|
||||
/// PGPASSWORD environment variable — identical convention to
|
||||
/// PgDumpBackupService.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RestoreAsync_BuildsArgumentVectorFromConnectionString_PasswordNeverInArgs()
|
||||
{
|
||||
FakeProcessRunner runner = new() { ResultToReturn = new ProcessResult(0, string.Empty, string.Empty) };
|
||||
IConfiguration configuration = BuildConfiguration(
|
||||
"Host=db.internal;Port=5433;Database=club12;Username=app_user;Password=s3cret");
|
||||
PsqlDatabaseRestoreService service = new(runner, configuration, NullLogger<PsqlDatabaseRestoreService>.Instance);
|
||||
|
||||
await service.RestoreAsync("/tmp/backup-restore-test.sql");
|
||||
|
||||
Assert.NotNull(runner.CapturedArgs);
|
||||
Assert.Contains("db.internal", runner.CapturedArgs!);
|
||||
Assert.Contains("5433", runner.CapturedArgs!);
|
||||
Assert.Contains("app_user", runner.CapturedArgs!);
|
||||
Assert.Contains("club12", runner.CapturedArgs!);
|
||||
Assert.DoesNotContain("s3cret", runner.CapturedArgs!);
|
||||
Assert.NotNull(runner.CapturedEnvironmentVariables);
|
||||
Assert.Equal("s3cret", runner.CapturedEnvironmentVariables!["PGPASSWORD"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts the exact arg vector for design.md's chosen restore invocation:
|
||||
/// "-v", "ON_ERROR_STOP=1", "-f", <dumpFilePath> — ON_ERROR_STOP=1 is what
|
||||
/// turns the first SQL error inside the dump into a non-zero psql exit
|
||||
/// code instead of silently continuing past it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RestoreAsync_UsesPsqlWithOnErrorStopAndDumpFilePathFlag()
|
||||
{
|
||||
FakeProcessRunner runner = new() { ResultToReturn = new ProcessResult(0, string.Empty, string.Empty) };
|
||||
IConfiguration configuration = BuildConfiguration(
|
||||
"Host=localhost;Port=5432;Database=club12;Username=app;Password=x", psqlPath: "/usr/bin/psql");
|
||||
PsqlDatabaseRestoreService service = new(runner, configuration, NullLogger<PsqlDatabaseRestoreService>.Instance);
|
||||
|
||||
await service.RestoreAsync("/tmp/backup-restore-test.sql");
|
||||
|
||||
Assert.Equal("/usr/bin/psql", runner.CapturedFileName);
|
||||
Assert.NotNull(runner.CapturedArgs);
|
||||
Assert.Contains("-v", runner.CapturedArgs!);
|
||||
Assert.Contains("ON_ERROR_STOP=1", runner.CapturedArgs!);
|
||||
Assert.Contains("-f", runner.CapturedArgs!);
|
||||
Assert.Contains("/tmp/backup-restore-test.sql", runner.CapturedArgs!);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// maliciousDumpPath is crafted to look like a subprocess argument-injection
|
||||
/// payload (extra flags via ';'/'--', shell metacharacters) and must
|
||||
/// survive as one literal argument-vector element passed to psql's -f
|
||||
/// flag, never reinterpreted/split — arguments go through
|
||||
/// ProcessStartInfo.ArgumentList (never a concatenated shell command
|
||||
/// string), matching PgDumpBackupService's existing defense.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("/tmp/evil;rm -rf /.sql")]
|
||||
[InlineData("/tmp/evil --set=malicious.sql")]
|
||||
[InlineData("/tmp/evil`whoami`.sql")]
|
||||
public async Task RestoreAsync_DumpFilePathInjectionAttempt_PassedAsLiteralArgVectorElement(string maliciousDumpPath)
|
||||
{
|
||||
FakeProcessRunner runner = new() { ResultToReturn = new ProcessResult(0, string.Empty, string.Empty) };
|
||||
IConfiguration configuration = BuildConfiguration(
|
||||
"Host=localhost;Port=5432;Database=club12;Username=app;Password=x");
|
||||
PsqlDatabaseRestoreService service = new(runner, configuration, NullLogger<PsqlDatabaseRestoreService>.Instance);
|
||||
|
||||
await service.RestoreAsync(maliciousDumpPath);
|
||||
|
||||
Assert.NotNull(runner.CapturedArgs);
|
||||
Assert.Single(runner.CapturedArgs!, a => a == maliciousDumpPath);
|
||||
Assert.Equal(1, runner.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreAsync_NonZeroExitCode_ThrowsBackupExecutionException_NotUncaught()
|
||||
{
|
||||
FakeProcessRunner runner = new()
|
||||
{
|
||||
ResultToReturn = new ProcessResult(1, string.Empty, "psql: error: syntax error at or near \"BOGUS\""),
|
||||
};
|
||||
IConfiguration configuration = BuildConfiguration(
|
||||
"Host=localhost;Port=5432;Database=club12;Username=app;Password=x");
|
||||
PsqlDatabaseRestoreService service = new(runner, configuration, NullLogger<PsqlDatabaseRestoreService>.Instance);
|
||||
|
||||
BackupExecutionException ex = await Assert.ThrowsAsync<BackupExecutionException>(
|
||||
() => service.RestoreAsync("/tmp/backup-restore-test.sql"));
|
||||
|
||||
Assert.Contains("exit code 1", ex.Message);
|
||||
Assert.Contains("syntax error", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simulates what ProcessRunner returns when Process.Start fails for a
|
||||
/// missing executable: sentinel exit code -1, detail in StdErr.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RestoreAsync_MissingBinary_ThrowsHandledExceptionWithActionableMessage()
|
||||
{
|
||||
FakeProcessRunner runner = new()
|
||||
{
|
||||
ResultToReturn = new ProcessResult(
|
||||
-1, string.Empty, "Failed to start process 'psql': The system cannot find the file specified."),
|
||||
};
|
||||
IConfiguration configuration = BuildConfiguration(
|
||||
"Host=localhost;Port=5432;Database=club12;Username=app;Password=x", psqlPath: "psql");
|
||||
PsqlDatabaseRestoreService service = new(runner, configuration, NullLogger<PsqlDatabaseRestoreService>.Instance);
|
||||
|
||||
BackupExecutionException ex = await Assert.ThrowsAsync<BackupExecutionException>(
|
||||
() => service.RestoreAsync("/tmp/backup-restore-test.sql"));
|
||||
|
||||
Assert.Contains("psql", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("PATH", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreAsync_MissingConnectionString_ThrowsBackupExecutionException()
|
||||
{
|
||||
FakeProcessRunner runner = new();
|
||||
IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection([]).Build();
|
||||
PsqlDatabaseRestoreService service = new(runner, configuration, NullLogger<PsqlDatabaseRestoreService>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<BackupExecutionException>(
|
||||
() => service.RestoreAsync("/tmp/backup-restore-test.sql"));
|
||||
|
||||
Assert.Equal(0, runner.CallCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
using API.Tests.Backup.Fakes;
|
||||
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
using Infrastructure.Backup;
|
||||
|
||||
using System.Text;
|
||||
|
||||
namespace API.Tests.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for SupabaseBackupStorage against a
|
||||
/// FakeSupabaseRawStorage — no real Supabase client, no
|
||||
/// network call (per this change's spec Non-Goal: actual Supabase upload is
|
||||
/// staging/manual verification only). Covers: the backups/
|
||||
/// object-path builder, traversal rejection, list translation, and wrapping
|
||||
/// of raw-storage failures (network/auth errors) into
|
||||
/// BackupExecutionException — the same failure type
|
||||
/// PgDumpBackupService throws and DatabaseBackupHostedService
|
||||
/// already catches and logs without crashing the host.
|
||||
/// </summary>
|
||||
public sealed class SupabaseBackupStorageTests
|
||||
{
|
||||
private static Stream ContentStream(string text)
|
||||
{
|
||||
return new MemoryStream(Encoding.UTF8.GetBytes(text));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_ValidName_UploadsUnderBackupsPrefix()
|
||||
{
|
||||
FakeSupabaseRawStorage raw = new();
|
||||
SupabaseBackupStorage storage = new(raw);
|
||||
|
||||
await storage.StoreAsync("backup-1.sql", ContentStream("dump"));
|
||||
|
||||
Assert.Single(raw.UploadedPaths);
|
||||
Assert.Equal("backups/backup-1.sql", raw.UploadedPaths[0]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("../escape.sql")]
|
||||
[InlineData("../../escape.sql")]
|
||||
[InlineData("nested/../../escape.sql")]
|
||||
public async Task StoreAsync_RelativeTraversalName_ThrowsArgumentException_NoUploadAttempted(string maliciousName)
|
||||
{
|
||||
FakeSupabaseRawStorage raw = new();
|
||||
SupabaseBackupStorage storage = new(raw);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => storage.StoreAsync(maliciousName, ContentStream("evil")));
|
||||
|
||||
Assert.Empty(raw.UploadedPaths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_RootedPathName_ThrowsArgumentException_NoUploadAttempted()
|
||||
{
|
||||
FakeSupabaseRawStorage raw = new();
|
||||
SupabaseBackupStorage storage = new(raw);
|
||||
string rooted = OperatingSystem.IsWindows() ? "C:\\evil.sql" : "/etc/evil.sql";
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => storage.StoreAsync(rooted, ContentStream("evil")));
|
||||
|
||||
Assert.Empty(raw.UploadedPaths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenReadAsync_ValidName_DownloadsFromBackupsPrefix_ReturnsStreamWithContent()
|
||||
{
|
||||
FakeSupabaseRawStorage raw = new() { BytesToDownload = Encoding.UTF8.GetBytes("dump-1") };
|
||||
SupabaseBackupStorage storage = new(raw);
|
||||
|
||||
await using Stream stream = await storage.OpenReadAsync("backup-1.sql");
|
||||
using StreamReader reader = new(stream);
|
||||
string content = await reader.ReadToEndAsync();
|
||||
|
||||
Assert.Equal("backups/backup-1.sql", raw.LastDownloadedPath);
|
||||
Assert.Equal("dump-1", content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A catalog row's StoragePath is expected to be server-generated
|
||||
/// and safe, but OpenReadAsync must still independently
|
||||
/// re-validate it via the same ToObjectPath guard used by
|
||||
/// StoreAsync/DeleteAsync — a malformed/malicious catalog value must
|
||||
/// never reach a raw download call (threat: storage path traversal).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task OpenReadAsync_TraversalName_ThrowsArgumentException_NoDownloadAttempted()
|
||||
{
|
||||
FakeSupabaseRawStorage raw = new();
|
||||
SupabaseBackupStorage storage = new(raw);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => storage.OpenReadAsync("../../etc/passwd"));
|
||||
|
||||
Assert.Null(raw.LastDownloadedPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenReadAsync_RawStorageThrows_WrapsIntoBackupExecutionException()
|
||||
{
|
||||
FakeSupabaseRawStorage raw = new() { ExceptionToThrow = new InvalidOperationException("network unreachable") };
|
||||
SupabaseBackupStorage storage = new(raw);
|
||||
|
||||
BackupExecutionException ex = await Assert.ThrowsAsync<BackupExecutionException>(
|
||||
() => storage.OpenReadAsync("backup-1.sql"));
|
||||
|
||||
Assert.Contains("backup-1.sql", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_ValidName_RemovesUnderBackupsPrefix()
|
||||
{
|
||||
FakeSupabaseRawStorage raw = new();
|
||||
SupabaseBackupStorage storage = new(raw);
|
||||
|
||||
await storage.DeleteAsync("backup-1.sql");
|
||||
|
||||
Assert.Single(raw.RemovedPaths);
|
||||
Assert.Equal("backups/backup-1.sql", raw.RemovedPaths[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_TraversalName_ThrowsArgumentException_NoRemoveAttempted()
|
||||
{
|
||||
FakeSupabaseRawStorage raw = new();
|
||||
SupabaseBackupStorage storage = new(raw);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => storage.DeleteAsync("../escape.sql"));
|
||||
|
||||
Assert.Empty(raw.RemovedPaths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_TranslatesRawEntries_UsingBackupsPrefix()
|
||||
{
|
||||
DateTimeOffset updated = DateTimeOffset.UtcNow.AddMinutes(-5);
|
||||
FakeSupabaseRawStorage raw = new()
|
||||
{
|
||||
EntriesToList =
|
||||
[
|
||||
new("backup-1.sql", updated),
|
||||
new("backup-2.sql", null),
|
||||
],
|
||||
};
|
||||
SupabaseBackupStorage storage = new(raw);
|
||||
|
||||
IReadOnlyList<BackupFile> files = await storage.ListAsync();
|
||||
|
||||
Assert.Equal("backups/", raw.LastListedPrefix);
|
||||
Assert.Equal(2, files.Count);
|
||||
Assert.Contains(files, f => f.Name == "backup-1.sql" && f.Timestamp == updated);
|
||||
Assert.Contains(files, f => f.Name == "backup-2.sql");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_RawStorageThrowsNetworkError_WrapsIntoBackupExecutionException()
|
||||
{
|
||||
FakeSupabaseRawStorage raw = new() { ExceptionToThrow = new HttpRequestException("network unreachable") };
|
||||
SupabaseBackupStorage storage = new(raw);
|
||||
|
||||
BackupExecutionException ex = await Assert.ThrowsAsync<BackupExecutionException>(
|
||||
() => storage.StoreAsync("backup-1.sql", ContentStream("dump")));
|
||||
|
||||
Assert.Contains("backup-1.sql", ex.Message);
|
||||
Assert.IsType<HttpRequestException>(ex.InnerException);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_RawStorageThrowsAuthError_WrapsIntoBackupExecutionException()
|
||||
{
|
||||
FakeSupabaseRawStorage raw = new() { ExceptionToThrow = new InvalidOperationException("401 unauthorized") };
|
||||
SupabaseBackupStorage storage = new(raw);
|
||||
|
||||
BackupExecutionException ex = await Assert.ThrowsAsync<BackupExecutionException>(() => storage.ListAsync());
|
||||
|
||||
Assert.Contains("401 unauthorized", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_RawStorageThrows_WrapsIntoBackupExecutionException()
|
||||
{
|
||||
FakeSupabaseRawStorage raw = new() { ExceptionToThrow = new InvalidOperationException("boom") };
|
||||
SupabaseBackupStorage storage = new(raw);
|
||||
|
||||
await Assert.ThrowsAsync<BackupExecutionException>(() => storage.DeleteAsync("backup-1.sql"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user