using System.Text.Json; using Microsoft.Extensions.Options; using Omika.Core.Observability.Identity; using Omika.Core.Observability.Outbox; using Omika.Diagnostics.Import; using Omika.Diagnostics.Models; using Omika.Diagnostics.Queries; using Omika.Diagnostics.Tests.Fakes; namespace Omika.Diagnostics.Tests; /// /// Tests d'intégration ActorKey (§19/§20) : identité authentifiée → ActorKey → /// événement canonique → JSONL → importeur → repository → filtre par ActorKey. /// Vérifie aussi qu'aucun identifiant brut n'est persisté dans les données de /// diagnostic (hors fixtures strictement internes au test). /// public class DiagnosticActorKeyIntegrationTests : IDisposable { private static string TestKey() => Convert.ToBase64String(new byte[32]); private readonly string _dir = CreateTempDirectory(); public void Dispose() => DeleteDirectory(_dir); [Fact] public async Task FullFlow_ComputesActorKey_AndPersistsIt_WithoutRawIdentity() { // Contexte : Tenant A = "BATIK.01", Collaborateur 42 (identité authentifiée). const string tenant = "BATIK.01"; const string collaborateur = "42"; DiagnosticActorKey actorKey = new DiagnosticActorKeyProvider(TestKey()).Compute(tenant, collaborateur); Assert.True(actorKey.HasValue); Guid eventId = Guid.NewGuid(); var record = DiagnosticTestData.Record( eventId, "OMK-MOB-APP-001", actorKey: actorKey.Value, actorKeyVersion: actorKey.Version); var source = new FakeDiagnosticJsonlSource(new[] { record }); var repository = new FakeDiagnosticEventRepository(); var importer = new DiagnosticImporter(source, repository, Options.Create(new DiagnosticImportOptions { BatchSize = 100 })); DiagnosticImportResult result = await importer.ImportOnceAsync(); Assert.True(result.Succeeded); Assert.Equal(1, result.ImportedCount); DiagnosticEventRecord persisted = (await repository.QueryAsync(new DiagnosticEventQuery())).Items.Single(); Assert.Equal(actorKey.Value, persisted.ActorKey); Assert.Equal(actorKey.Version, persisted.ActorKeyVersion); // Aucun identifiant brut dans les champs persistés (le JSONL/MetadataJson // n'est pas scruté ici : il n'y en a pas dans ce cas de figure). Assert.DoesNotContain(collaborateur, persisted.EventCode, StringComparison.Ordinal); Assert.Null(persisted.MetadataJson); } [Fact] public async Task Repository_FiltersByActorKey() { const string actorA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const string actorB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; var repository = new FakeDiagnosticEventRepository(); await repository.InsertBatchAsync(new[] { DiagnosticTestData.Event(Guid.NewGuid(), actorKey: actorA), DiagnosticTestData.Event(Guid.NewGuid(), actorKey: actorB), DiagnosticTestData.Event(Guid.NewGuid(), actorKey: null) // système / sans utilisateur }); DiagnosticQueryResult byA = await repository.QueryAsync(new DiagnosticEventQuery { ActorKey = actorA }); Assert.Single(byA.Items); Assert.Equal(actorA, byA.Items[0].ActorKey); } [Fact] public async Task Store_PersistsActorKey_AndStripsRawIdentity() { var store = new RemoteDiagnosticIngestionStore(new RemoteDiagnosticOptions { Directory = _dir }); DiagnosticActorKey actorKey = new DiagnosticActorKeyProvider(TestKey()).Compute("BATIK.01", "42"); var batch = new DiagnosticBatchDto { BatchId = Guid.NewGuid(), Events = [ new DiagnosticEventDto { EventId = Guid.NewGuid(), EventCode = "OMK-MOB-APP-001", TimestampUtc = DateTimeOffset.UtcNow, Level = "Info", Priority = "P3", // Identifiants bruts fournis par le mobile (transport) : DeviceId = "device-1", ClientId = "BATIK.01", UserId = "user-1", CollaborateurId = "42", Component = "télémétrie" } ] }; await store.StoreAsync(batch, actorKey, "trace-id", DateTimeOffset.UtcNow); string content = ReadAllTextShared(Assert.Single(Directory.EnumerateFiles(_dir, "*.jsonl"))); // ActorKey + version présents dans l'enregistrement canonique. using (JsonDocument doc = JsonDocument.Parse(content)) { JsonElement root = doc.RootElement; Assert.Equal(actorKey.Value, root.GetProperty("actorKey").GetString()); Assert.Equal(actorKey.Version, root.GetProperty("actorKeyVersion").GetInt32()); } // Les identifiants bruts ne sont PAS persistés dans le JSONL (§9). Assert.DoesNotContain("\"deviceId\"", content, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("\"clientId\"", content, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("\"userId\"", content, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("\"collaborateurId\"", content, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("device-1", content, StringComparison.Ordinal); Assert.DoesNotContain("user-1", content, StringComparison.Ordinal); Assert.DoesNotContain("\"42\"", content, StringComparison.Ordinal); } [Fact] public async Task Store_WithoutActorKey_WritesNullActorKey() { var store = new RemoteDiagnosticIngestionStore(new RemoteDiagnosticOptions { Directory = _dir }); await store.StoreAsync( new DiagnosticBatchDto { Events = [new DiagnosticEventDto { EventId = Guid.NewGuid(), EventCode = "OMK-MOB-APP-001" }] }, DiagnosticActorKey.Empty, "trace-id", DateTimeOffset.UtcNow); string content = ReadAllTextShared(Assert.Single(Directory.EnumerateFiles(_dir, "*.jsonl"))); using JsonDocument doc = JsonDocument.Parse(content); // Champs null omis (WhenWritingNull) : ActorKey absent → propriété absente. Assert.False(doc.RootElement.TryGetProperty("actorKey", out _)); Assert.False(doc.RootElement.TryGetProperty("actorKeyVersion", out _)); } private static string CreateTempDirectory() { string dir = Path.Combine(Path.GetTempPath(), "omika-actor-key-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); return dir; } private static string ReadAllTextShared(string path) { using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); using var reader = new StreamReader(stream); return reader.ReadToEnd(); } private static void DeleteDirectory(string dir) { try { if (Directory.Exists(dir)) { Directory.Delete(dir, recursive: true); } } catch { // Best-effort. } } }