using FluentAssertions; using Microsoft.EntityFrameworkCore; using Nostos.Backend.Data; using Nostos.Backend.Services.Portability; using Nostos.Backend.Services.Portability.Activation; using Nostos.Backend.Services.Portability.Migration; using Xunit; namespace Nostos.Backend.Tests.Portability; /// /// Slice 9 integrity: a retained copy is never trusted because its manifest /// says Available. Every component hash is re-verified before any live /// mutation, tamper and expiry fail closed, and a copy retained by an older /// schema is upgraded on a working copy (never the retained original) or /// refused when this build cannot interpret it. /// [Collection(ActivationCoordinatorCollection.Name)] public sealed class RecoveryRestoreIntegrityTests { private static async Task ActivatedBedAsync() { var bed = await ActivationCoordinatorTestBed.CreateAsync(populated: true); (await bed.ActivateAsync(confirm: true)).Outcome.Should().Be(SelfHostedActivationOutcome.Completed); await bed.WritePortableRevisionBumpAsync(); await bed.WriteHostRecordAsync(); return bed; } [Fact] public async Task OneChangedMediaByte_IsRefusedBeforeAnyLiveMutation() { await using var bed = await ActivatedBedAsync(); var before = bed.SnapshotLiveGeneration(); bed.TamperRecoveryMediaByte(); var result = await bed.RestoreAsync(); result.Outcome.Should().Be(SelfHostedRecoveryRestoreOutcome.Interrupted); result.ErrorCode.Should().Be(MigrationActivationErrorCodes.RecoveryCorrupt); bed.AssertGenerationEquals(before, "a tampered copy must be refused before any live mutation"); var manifest = bed.ReadRecoveryManifest()!; manifest.Status.Should().Be(MigrationRecoveryStatus.Failed); manifest.RestoreError.Should().Be(MigrationActivationErrorCodes.RecoveryCorrupt); } [Fact] public async Task OneChangedDatabaseByte_IsRefusedBeforeAnyLiveMutation() { await using var bed = await ActivatedBedAsync(); var before = bed.SnapshotLiveGeneration(); bed.TamperRecoveryDatabaseByte(); var result = await bed.RestoreAsync(); result.Outcome.Should().Be(SelfHostedRecoveryRestoreOutcome.Interrupted); result.ErrorCode.Should().Be(MigrationActivationErrorCodes.RecoveryCorrupt); bed.AssertGenerationEquals(before, "a tampered copy must be refused before any live mutation"); bed.ReadRecoveryManifest()!.Status.Should().Be(MigrationRecoveryStatus.Failed); } [Fact] public async Task CorruptManifest_IsRefused_AndTheLiveLibraryIsUntouched() { await using var bed = await ActivatedBedAsync(); var before = bed.SnapshotLiveGeneration(); bed.CorruptRecoveryManifest(); var failure = await FluentActions .Awaiting(() => bed.RequestRestoreOnlyAsync()) .Should().ThrowAsync(); failure.Which.Code.Should().Be(MigrationActivationErrorCodes.RecoveryCorrupt); bed.AssertGenerationEquals(before, "a corrupt manifest must fail closed without touching the library"); } [Fact] public async Task ExpiredCopy_IsRefused_AndTheLiveLibraryIsUntouched() { await using var bed = await ActivatedBedAsync(); var before = bed.SnapshotLiveGeneration(); bed.ExpireRecoveryCopy(); var failure = await FluentActions .Awaiting(() => bed.RequestRestoreOnlyAsync()) .Should().ThrowAsync(); failure.Which.Code.Should().Be(MigrationActivationErrorCodes.RecoveryExpired); bed.AssertGenerationEquals(before, "an expired copy must not be restored"); bed.ReadRecoveryManifest()!.Status.Should().Be(MigrationRecoveryStatus.Available); } [Fact] public async Task OlderSchemaCopy_IsMigratedForwardOnAWorkingCopy_AndRestored() { await using var bed = await ActivatedBedAsync(); var beforeRevision = await bed.CurrentRevisionAsync(); await bed.DowngradeRecoveryDatabaseOneMigrationAsync(); var sourceSha = bed.SourceRecoveryDatabaseSha256(); // The retained original is upgraded only on a working copy; the restore // itself materializes the current schema plus current host state. var result = await bed.RestoreAsync(); result.Outcome.Should().Be(SelfHostedRecoveryRestoreOutcome.Restored, $"older-schema restore must migrate forward; error {result.ErrorCode}"); await bed.AssertRestoredPortableGenerationAsync(); bed.SourceRecoveryDatabaseSha256().Should().Be( sourceSha, "the retained original must never be modified by the upgrade"); // The restored database carries the current schema and current host // state, never the downgraded retained schema. await using (var db = bed.OpenDatabase()) { (await db.MigrationJobRecords.CountAsync()).Should().BeGreaterThanOrEqualTo(1); var job = await db.MigrationJobRecords.FindAsync(bed.JobId); job!.State.Should().Be((int)MigrationJobState.Completed); } (await bed.CurrentRevisionAsync()).Should().Be(beforeRevision + 1); } [Theory] [InlineData("DELETE FROM Collections WHERE rowid = (SELECT MIN(rowid) FROM Collections);")] [InlineData("UPDATE Collections SET Name = Name || 'X' WHERE rowid = (SELECT MIN(rowid) FROM Collections);")] [InlineData("INSERT INTO Collections (Id, Name, ParentId) VALUES ('AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE', 'MIGRATION-GHOST', NULL);")] public async Task ALossyForwardMigration_IsRefusedBeforeAnyLiveMutation(string lossySql) { await using var bed = await ActivatedBedAsync(); var before = bed.SnapshotLiveGeneration(); await bed.DowngradeRecoveryDatabaseOneMigrationAsync(); var sourceSha = bed.SourceRecoveryDatabaseSha256(); bed.RecoveryMigrationOverride = async db => await db.Database.ExecuteSqlRawAsync(lossySql); var result = await bed.RestoreAsync(); result.Outcome.Should().Be(SelfHostedRecoveryRestoreOutcome.Interrupted); result.ErrorCode.Should().Be(MigrationActivationErrorCodes.RecoveryCorrupt); bed.AssertGenerationEquals(before, "a lossy migration must not replace the live library"); bed.SourceRecoveryDatabaseSha256().Should().Be( sourceSha, "the retained original must survive a refused upgrade byte-for-byte"); var manifest = bed.ReadRecoveryManifest()!; manifest.Status.Should().Be(MigrationRecoveryStatus.Failed); manifest.RestoreError.Should().Be(MigrationActivationErrorCodes.RecoveryCorrupt); Directory.EnumerateFiles(bed.Paths.JournalRoot, "recovery-source.db*", SearchOption.AllDirectories) .Should().BeEmpty("the refused attempt cleans its working files"); Directory.EnumerateFiles(bed.Paths.JournalRoot, "recovery-payload.json", SearchOption.AllDirectories) .Should().BeEmpty("the refused attempt cleans its payload file"); } [Fact] public async Task ALossyMigrationThatDropsAMembership_IsRefusedBeforeAnyLiveMutation() { await using var bed = await ActivatedBedAsync(); await bed.SeedRecoveryMembershipAsync(); var before = bed.SnapshotLiveGeneration(); await bed.DowngradeRecoveryDatabaseOneMigrationAsync(); var sourceSha = bed.SourceRecoveryDatabaseSha256(); bed.RecoveryMigrationOverride = async db => await db.Database.ExecuteSqlRawAsync( "DELETE FROM BookCollections WHERE rowid = (SELECT MIN(rowid) FROM BookCollections);"); var result = await bed.RestoreAsync(); result.Outcome.Should().Be(SelfHostedRecoveryRestoreOutcome.Interrupted); result.ErrorCode.Should().Be(MigrationActivationErrorCodes.RecoveryCorrupt); bed.AssertGenerationEquals(before, "a dropped membership must not replace the live library"); bed.SourceRecoveryDatabaseSha256().Should().Be(sourceSha); bed.ReadRecoveryManifest()!.Status.Should().Be(MigrationRecoveryStatus.Failed); } [Fact] public async Task DeclaredTableAndColumnRenames_AreHandledByTheBaseline() { await using var bed = await ActivatedBedAsync(); // Before the Concepts -> Topics table/column rename. await bed.DowngradeRecoveryDatabaseToAsync("20261003075922_AddEmbeddingProviderSettings"); var sourceSha = bed.SourceRecoveryDatabaseSha256(); var result = await bed.RestoreAsync(); result.Outcome.Should().Be(SelfHostedRecoveryRestoreOutcome.Restored, $"a declared rename in range must be translated, not rejected; error {result.ErrorCode}"); await bed.AssertRestoredPortableGenerationAsync(); bed.SourceRecoveryDatabaseSha256().Should().Be(sourceSha); } [Fact] public async Task CurrentSchemaCopy_TakesTheFastPath_AndRestores() { await using var bed = await ActivatedBedAsync(); var sourceSha = bed.SourceRecoveryDatabaseSha256(); var result = await bed.RestoreAsync(); result.Outcome.Should().Be(SelfHostedRecoveryRestoreOutcome.Restored); await bed.AssertRestoredPortableGenerationAsync(); bed.SourceRecoveryDatabaseSha256().Should().Be(sourceSha); } [Fact] public void ManifestCountsMustMatchTheExtractedPayload() { var expected = new MigrationExistingCounts( Works: 1, Books: 1, Notes: 2, Topics: 1, NoteTopics: 1, Writings: 1, WritingNotes: 1, Collections: 1, BookCollections: 1, Acquisitions: 1, NoteImportBookLinks: 1, AssistantSettings: 1); var matching = new MigrationArchiveCounts( Works: 1, Books: 1, Notes: 2, Topics: 1, NoteTopics: 1, Writings: 1, WritingNotes: 1, Collections: 1, CollectionMemberships: 1, Acquisitions: 1, AssistantSettings: 1, NoteImportBookLinks: 1, MediaEntries: 0); PortableLibraryRecoveryBaseline.RequireCountsPreserved(expected, matching); FluentActions .Invoking(() => PortableLibraryRecoveryBaseline.RequireCountsPreserved( expected, matching with { Notes = 1 })) .Should().Throw( "a migration that loses a portable row must be detected independently of digests"); // The assistant singleton row may exist without a portable value. PortableLibraryRecoveryBaseline.RequireCountsPreserved( expected with { AssistantSettings = 0 }, matching with { AssistantSettings = 1 }); } [Fact] public async Task UnknownNewerSchemaCopy_IsRefusedBeforeAnyLiveMutation() { await using var bed = await ActivatedBedAsync(); var before = bed.SnapshotLiveGeneration(); bed.AddUnknownMigrationToRecovery(); var result = await bed.RestoreAsync(); result.Outcome.Should().Be(SelfHostedRecoveryRestoreOutcome.Interrupted); result.ErrorCode.Should().Be(MigrationActivationErrorCodes.RecoveryCorrupt); bed.AssertGenerationEquals(before, "a newer-schema copy must be refused before any live mutation"); bed.ReadRecoveryManifest()!.Status.Should().Be(MigrationRecoveryStatus.Failed); } [Fact] public void ManifestsWrittenBeforeTheRestoreFieldsExisted_StillDecode() { var created = DateTimeOffset.UtcNow; var manifest = new SelfHostedRecoveryManifest( Guid.NewGuid(), Guid.NewGuid(), created, created.AddDays(MigrationContractLimits.RecoveryRetentionDays), MigrationRecoveryStatus.Available, "revision", new MigrationExistingCounts(), 10, 20, new string('a', 64), [], DatabaseSchemaVersion: "20260101000000_Initial", DatabaseMigrationCount: 1, DatabaseRetained: true, MediaRetained: true, RetentionReservationId: Guid.NewGuid()); var payload = System.Text.Json.JsonSerializer.Serialize(manifest); var node = System.Text.Json.Nodes.JsonNode.Parse(payload)!.AsObject(); foreach (var field in new[] { "RestoreOperationId", "RestoreDestinationRevision", "RestoreError", "RestoredAtUtc", }) { node.Remove(field); } var stripped = node.ToJsonString(); var sha = Convert.ToHexString( System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(stripped))) .ToLowerInvariant(); var envelope = System.Text.Json.JsonSerializer.Serialize( new SelfHostedActivationDocument.Envelope(1, stripped, sha)); var decoded = SelfHostedActivationDocument.Decode(envelope); decoded.Status.Should().Be(MigrationRecoveryStatus.Available); decoded.RestoreOperationId.Should().BeNull("manifests from earlier builds must keep decoding"); decoded.RestoreError.Should().BeNull(); } }