using System.Security.Cryptography;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Nostos.Backend.Services.Library;
using Nostos.Backend.Services.Portability;
using Nostos.Backend.Services.Portability.Activation;
using Xunit;
namespace Nostos.Backend.Tests.Portability;
///
/// Slice 7 admission hardening: the destination revision is bound through
///
/// at both checks; "empty" is proven on disk as well as in the database; the
/// post-quiesce window never opens SQLite on the live path; and the empty
/// roll-forward never recursively deletes unverified media.
///
[Collection(ActivationCoordinatorCollection.Name)]
public sealed class ActivationCoordinatorAdmissionTests
{
[Fact]
public async Task PopulatedRevisionChangeBetweenPhases_AbortsAsStaleDestination()
{
await using var bed = await ActivationCoordinatorTestBed.CreateAsync(populated: true);
var switched = false;
bed.RevisionOverride = _ => Task.FromResult(switched ? "changed-between-phases" : bed.RevisionToken);
var failure = await FluentActions
.Awaiting(() => bed.ActivateAsync(confirm: true, observer: step =>
{
if (string.Equals(step, SelfHostedActivationSteps.AfterVerifyCandidate, StringComparison.Ordinal))
{
switched = true;
}
}))
.Should().ThrowAsync();
failure.Which.Code.Should().Be(MigrationActivationErrorCodes.DestinationConflict);
bed.AssertOriginalGeneration();
(await bed.ReadJobAsync()).State.Should().Be((int)MigrationJobState.ReadyToActivate);
}
[Fact]
public async Task EmptyDestinationRevisionChangeBetweenPhases_StillActivatesWithNothingToConfirm()
{
// Issue #681: an empty destination needs no confirmation, so a revision
// that moved while it stayed empty is not a conflict; the exclusive
// window re-proves emptiness and there is no user content to lose.
await using var bed = await ActivationCoordinatorTestBed.CreateAsync(populated: false);
var switched = false;
bed.RevisionOverride = _ => Task.FromResult(switched ? "changed-between-phases" : bed.RevisionToken);
var result = await bed.ActivateAsync(confirm: false, observer: step =>
{
if (string.Equals(step, SelfHostedActivationSteps.AfterVerifyCandidate, StringComparison.Ordinal))
{
switched = true;
}
});
result.Outcome.Should().Be(SelfHostedActivationOutcome.Completed);
result.RecoveryStatus.Should().Be(MigrationRecoveryStatus.NotRequired);
await bed.AssertImportedGenerationAsync();
}
[Fact]
public async Task PopulatedDestination_IsConfirmedAgainstTheCurrentRevision_NotTheJobBaseline()
{
await using var bed = await ActivationCoordinatorTestBed.CreateAsync(populated: true);
// A portable write after job creation (here a real saved collection,
// the same shape as a reading-progress or note-edit save) advances the
// live revision: the stored baseline is now stale.
await bed.WritePortableRevisionBumpAsync();
var current = await ReadCurrentRevisionAsync(bed);
current.Should().NotBe(bed.RevisionToken, "the fixture must prove the baseline moved");
var afterWrite = DumpAllTablesExceptActivationJob(bed);
var stale = await FluentActions
.Awaiting(() => ActivateWithRevisionAsync(bed, bed.RevisionToken, confirm: true))
.Should().ThrowAsync();
stale.Which.Code.Should().Be(MigrationActivationErrorCodes.DestinationConflict);
DumpAllTablesExceptActivationJob(bed).Should().BeEquivalentTo(afterWrite,
"a stale confirmation must mutate nothing");
(await bed.ReadJobAsync()).State.Should().Be((int)MigrationJobState.ReadyToActivate);
var result = await ActivateWithRevisionAsync(bed, current, confirm: true);
result.Outcome.Should().Be(SelfHostedActivationOutcome.Completed);
result.RecoveryStatus.Should().Be(MigrationRecoveryStatus.Available);
await bed.AssertImportedGenerationAsync();
}
[Fact]
public async Task EmptyDestinationThatBecamePopulatedInsideTheWindow_AbortsWithConfirmationRequiredAndMutatesNothing()
{
await using var bed = await ActivationCoordinatorTestBed.CreateAsync(populated: false);
var revisionAtAdmission = bed.RevisionToken;
// Admitted as empty (no confirmation asked). A real portable save lands
// after admission and before the exclusive recheck; snapshot the exact
// post-write state there so the comparison below proves the aborted
// activation added nothing of its own.
Dictionary>? afterWrite = null;
var failure = await FluentActions
.Awaiting(() => ActivateWithRevisionAsync(bed, revisionAtAdmission, confirm: false, observer: step =>
{
if (string.Equals(step, SelfHostedActivationSteps.AfterVerifyCandidate, StringComparison.Ordinal))
{
bed.WritePortableRevisionBumpAsync().GetAwaiter().GetResult();
afterWrite = DumpAllTablesExceptActivationJob(bed);
}
}))
.Should().ThrowAsync();
failure.Which.Code.Should().Be(MigrationActivationErrorCodes.ConfirmationRequired);
// The run must not have replaced content the user never confirmed:
// the destination is exactly the state the test's own write committed.
afterWrite.Should().NotBeNull("the intervening write must have been observed");
DumpAllTablesExceptActivationJob(bed).Should().BeEquivalentTo(afterWrite);
(await bed.ReadJobAsync()).State.Should().Be((int)MigrationJobState.ReadyToActivate);
// Confirming the now-populated current generation completes and retains it.
var current = await ReadCurrentRevisionAsync(bed);
current.Should().NotBe(revisionAtAdmission);
var result = await ActivateWithRevisionAsync(bed, current, confirm: true);
result.Outcome.Should().Be(SelfHostedActivationOutcome.Completed);
result.RecoveryStatus.Should().Be(MigrationRecoveryStatus.Available);
await bed.AssertImportedGenerationAsync();
}
[Fact]
public async Task ZeroPortableRowsWithOrphanMedia_IsPopulated_RequiresConfirmation_AndRetainsRecovery()
{
await using var bed = await ActivationCoordinatorTestBed.CreateAsync(populated: false);
bed.WriteOrphanMedia();
var refusal = await FluentActions
.Awaiting(() => bed.ActivatePublicAsync())
.Should().ThrowAsync();
refusal.Which.Code.Should().Be(MigrationActivationErrorCodes.ConfirmationRequired);
(await bed.ReadJobAsync()).State.Should().Be((int)MigrationJobState.ReadyToActivate);
var result = await bed.ActivateAsync(confirm: true);
result.Outcome.Should().Be(SelfHostedActivationOutcome.Completed);
result.RecoveryStatus.Should().Be(MigrationRecoveryStatus.Available);
var manifest = new SelfHostedRecoveryManifestStore(bed.Paths).Read(bed.JobId);
manifest.Should().NotBeNull("orphan media must be retained as a full recovery copy");
manifest!.Status.Should().Be(MigrationRecoveryStatus.Available);
var orphanSha = Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes("ORPHAN-USER-MEDIA"))).ToLowerInvariant();
ActivationCoordinatorHappyPathTests
.SweepRetainedMedia(bed.Paths.PreviousMedia(bed.JobId))
.Should().Contain(item => item.Sha256 == orphanSha,
"the orphan media file must be in the retained recovery copy, not deleted");
await bed.AssertImportedGenerationAsync();
}
[Fact]
public async Task NonEmptyPreviousMedia_IsNeverRecursivelyDeleted()
{
await using var bed = await ActivationCoordinatorTestBed.CreateAsync(populated: false);
var previous = bed.Paths.PreviousMedia(bed.JobId);
Directory.CreateDirectory(previous);
await File.WriteAllTextAsync(Path.Combine(previous, "orphan.bin"), "KEEP-ME");
var journal = new SelfHostedActivationJournal(
bed.JobId,
Guid.NewGuid(),
SelfHostedActivationPhase.Committed,
bed.RevisionToken,
RetainPreviousLibrary: false,
DateTimeOffset.UtcNow);
var step = new SelfHostedActivationEmptyRetentionStep(
bed.Paths, new SelfHostedRecoveryManifestStore(bed.Paths));
step.Execute(journal, SelfHostedRecoveryAction.RollForwardCandidate);
File.Exists(Path.Combine(previous, "orphan.bin")).Should().BeTrue(
"a directory with files must never be recursively deleted by the empty roll-forward");
}
[Fact]
public async Task NoSqliteConnectionToTheLiveDatabaseBetweenQuiesceAndTheSwap()
{
await using var bed = await ActivationCoordinatorTestBed.CreateAsync(populated: true);
var livePath = Path.GetFullPath(bed.Paths.LiveDatabase);
var guardActive = false;
SelfHostedSqliteFile.ConnectionOpeningForTesting = path =>
{
if (guardActive && string.Equals(Path.GetFullPath(path), livePath, StringComparison.Ordinal))
{
throw new InvalidOperationException("SQLite opened the live database after quiesce");
}
};
try
{
var result = await bed.ActivateAsync(confirm: true, observer: step =>
{
if (string.Equals(step, SelfHostedActivationSteps.AfterQuiesce, StringComparison.Ordinal))
{
guardActive = true;
}
if (string.Equals(step, SelfHostedActivationSteps.PhaseCandidateDatabaseActivated, StringComparison.Ordinal))
{
guardActive = false;
}
if (guardActive)
{
File.Exists(bed.Paths.LiveDatabase + "-wal").Should().BeFalse(
$"no WAL may exist at {step}");
File.Exists(bed.Paths.LiveDatabase + "-shm").Should().BeFalse(
$"no SHM may exist at {step}");
}
});
result.Outcome.Should().Be(SelfHostedActivationOutcome.Completed);
}
finally
{
SelfHostedSqliteFile.ConnectionOpeningForTesting = null;
}
}
private static async Task ReadCurrentRevisionAsync(ActivationCoordinatorTestBed bed)
{
await using var scope = bed.Host.CreateAsyncScope();
return await scope.ServiceProvider.GetRequiredService()
.GetCurrentAsync(default);
}
private static async Task ActivateWithRevisionAsync(
ActivationCoordinatorTestBed bed,
string revision,
bool confirm,
Action? observer = null)
{
await using var scope = bed.Host.CreateAsyncScope();
var coordinator = scope.ServiceProvider.GetRequiredService();
coordinator.StepObserverForTesting = observer;
return await coordinator.ActivateAsync(
bed.JobId, new MigrationActivateRequest(revision, confirm), default);
}
///
/// Whole-database dump excluding only the activation job's own row (whose
/// lease/state metadata the protocol legitimately changes); every other
/// row, in every table, must be identical across the aborted run.
///
private static Dictionary> DumpAllTablesExceptActivationJob(
ActivationCoordinatorTestBed bed)
{
var all = ActivationBuildFixture.DumpAllTables(bed.Paths.LiveDatabase);
all["MigrationJobRecords"] = all["MigrationJobRecords"]
.Where(row => !string.Equals(
row.Split('|', 2)[0], bed.JobId.ToString(), StringComparison.OrdinalIgnoreCase))
.ToList();
return all;
}
}