using System; using System.IO; using System.Threading; using System.Threading.Tasks; using Omika.App.Local.Abstractions; using Omika.App.Local.Security; namespace Omika.Tests.Mobile.Local; /// /// Tests de la migration idempotente et crash-safe de l'ancien token en clair /// (session.token) vers un stockage sécurisé (). /// Aucun runtime Android/iOS requis : le store est simulé, le fichier legacy est /// dans un répertoire temporaire. /// public class LegacyTokenMigratorTests { private static string NewTempDir() { string dir = Path.Combine(Path.GetTempPath(), "omika-migrate-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); return dir; } private static string LegacyPath(string dir) => Path.Combine(dir, "session.token"); private static void WriteLegacy(string dir, string token) => File.WriteAllText(LegacyPath(dir), token); private static void DeleteDir(string dir) { try { Directory.Delete(dir, recursive: true); } catch { /* best-effort */ } } [Fact] public async Task Migrate_SecureAbsentLegacyAbsent_NoSession() { string dir = NewTempDir(); try { var store = new InMemoryStore(); var migrator = new LegacyTokenMigrator(store, LegacyPath(dir)); var outcome = await migrator.MigrateAsync(); Assert.Equal(LegacyTokenMigrationOutcome.NoSession, outcome); Assert.Null(store.Value); } finally { DeleteDir(dir); } } [Fact] public async Task Migrate_SecureAbsentLegacyPresent_MigratedEtLegacySupprime() { string dir = NewTempDir(); try { WriteLegacy(dir, "TOKEN_1"); var store = new InMemoryStore(); var migrator = new LegacyTokenMigrator(store, LegacyPath(dir)); var outcome = await migrator.MigrateAsync(); Assert.Equal(LegacyTokenMigrationOutcome.Migrated, outcome); Assert.Equal("TOKEN_1", store.Value); Assert.False(File.Exists(LegacyPath(dir))); } finally { DeleteDir(dir); } } [Fact] public async Task Migrate_SecurePresentLegacyAbsent_SecureOnly() { string dir = NewTempDir(); try { var store = new InMemoryStore { Value = "TOKEN_SECURE" }; var migrator = new LegacyTokenMigrator(store, LegacyPath(dir)); var outcome = await migrator.MigrateAsync(); Assert.Equal(LegacyTokenMigrationOutcome.SecureOnly, outcome); Assert.Equal("TOKEN_SECURE", store.Value); } finally { DeleteDir(dir); } } [Fact] public async Task Migrate_SecurePresentLegacyPresentIdentique_SecurePrioritaireEtLegacySupprime() { string dir = NewTempDir(); try { WriteLegacy(dir, "TOKEN_1"); var store = new InMemoryStore { Value = "TOKEN_1" }; var migrator = new LegacyTokenMigrator(store, LegacyPath(dir)); var outcome = await migrator.MigrateAsync(); Assert.Equal(LegacyTokenMigrationOutcome.SecurePrioritaire, outcome); Assert.Equal("TOKEN_1", store.Value); Assert.False(File.Exists(LegacyPath(dir))); } finally { DeleteDir(dir); } } [Fact] public async Task Migrate_SecurePresentLegacyPresentDifferent_SecurePrioritaireLegacyConserve() { string dir = NewTempDir(); try { WriteLegacy(dir, "TOKEN_ANCIEN"); var store = new InMemoryStore { Value = "TOKEN_SECURE" }; var migrator = new LegacyTokenMigrator(store, LegacyPath(dir)); var outcome = await migrator.MigrateAsync(); // Le sécurisé fait foi ; l'ancien n'est PAS supprimé (incohérent). Assert.Equal(LegacyTokenMigrationOutcome.SecurePrioritaire, outcome); Assert.Equal("TOKEN_SECURE", store.Value); Assert.True(File.Exists(LegacyPath(dir))); } finally { DeleteDir(dir); } } [Fact] public async Task Migrate_LegacyVide_TraiteCommeAbsentEtSupprime() { string dir = NewTempDir(); try { WriteLegacy(dir, " "); var store = new InMemoryStore(); var migrator = new LegacyTokenMigrator(store, LegacyPath(dir)); var outcome = await migrator.MigrateAsync(); Assert.Equal(LegacyTokenMigrationOutcome.NoSession, outcome); Assert.False(File.Exists(LegacyPath(dir))); } finally { DeleteDir(dir); } } [Fact] public async Task Migrate_EcritureSecureEchoue_MigrationFailedLegacyConserve() { string dir = NewTempDir(); try { WriteLegacy(dir, "TOKEN_1"); var store = new InMemoryStore { ThrowOnSave = true }; var migrator = new LegacyTokenMigrator(store, LegacyPath(dir)); var outcome = await migrator.MigrateAsync(); Assert.Equal(LegacyTokenMigrationOutcome.MigrationFailed, outcome); Assert.True(File.Exists(LegacyPath(dir))); Assert.Equal("TOKEN_1", File.ReadAllText(LegacyPath(dir))); } finally { DeleteDir(dir); } } [Fact] public async Task Migrate_VerificationEchoue_MigrationFailedLegacyConserve() { string dir = NewTempDir(); try { WriteLegacy(dir, "TOKEN_1"); // SaveAsync ne persiste rien : la relecture renvoie null ≠ TOKEN_1. var store = new NonPersistingStore(); var migrator = new LegacyTokenMigrator(store, LegacyPath(dir)); var outcome = await migrator.MigrateAsync(); Assert.Equal(LegacyTokenMigrationOutcome.MigrationFailed, outcome); Assert.True(File.Exists(LegacyPath(dir))); } finally { DeleteDir(dir); } } [Fact] public async Task Migrate_Idempotent_DixAppelsNeChangentRien() { string dir = NewTempDir(); try { WriteLegacy(dir, "TOKEN_1"); var store = new InMemoryStore(); var migrator = new LegacyTokenMigrator(store, LegacyPath(dir)); var first = await migrator.MigrateAsync(); for (int i = 0; i < 9; i++) { await migrator.MigrateAsync(); } Assert.Equal(LegacyTokenMigrationOutcome.Migrated, first); Assert.Equal("TOKEN_1", store.Value); Assert.False(File.Exists(LegacyPath(dir))); // Après migration, un appel supplémentaire est stable (SecureOnly). Assert.Equal(LegacyTokenMigrationOutcome.SecureOnly, await migrator.MigrateAsync()); } finally { DeleteDir(dir); } } [Fact] public async Task Migrate_CrashApresEcritureSecureAvantSuppressionLegacy_RecupereSansPerte() { string dir = NewTempDir(); try { // Simule le crash : le secure a été écrit mais le legacy subsiste. WriteLegacy(dir, "TOKEN_1"); var store = new InMemoryStore { Value = "TOKEN_1" }; var migrator = new LegacyTokenMigrator(store, LegacyPath(dir)); var outcome = await migrator.MigrateAsync(); // SecurePrioritaire : secure lu, legacy identique supprimé, session intacte. Assert.Equal(LegacyTokenMigrationOutcome.SecurePrioritaire, outcome); Assert.Equal("TOKEN_1", store.Value); Assert.False(File.Exists(LegacyPath(dir))); } finally { DeleteDir(dir); } } /// Store en mémoire qui persiste correctement. private sealed class InMemoryStore : ISecureTokenStore { public string? Value { get; set; } public bool ThrowOnSave { get; set; } public Task SaveAsync(string token, CancellationToken cancellationToken = default) { if (ThrowOnSave) { throw new InvalidOperationException("save failed"); } Value = token; return Task.CompletedTask; } public Task GetAsync(CancellationToken cancellationToken = default) => Task.FromResult(Value); public Task ClearAsync(CancellationToken cancellationToken = default) { Value = null; return Task.CompletedTask; } } /// Store qui accepte l'écriture mais ne la persiste pas (simule une vérification échouée). private sealed class NonPersistingStore : ISecureTokenStore { public Task SaveAsync(string token, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task GetAsync(CancellationToken cancellationToken = default) => Task.FromResult(null); public Task ClearAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; } }