Sube carpetas API, API.Tests, Application y Domain del Backend

This commit is contained in:
FrancoRu
2026-09-18 13:35:59 -03:00
parent bbb09554bb
commit 2eb4eeef03
572 changed files with 56405 additions and 0 deletions
@@ -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 &lt;tmp&gt;" 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", &lt;dumpFilePath&gt; — 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"));
}
}