314 lines
13 KiB
C#
314 lines
13 KiB
C#
using System.Text.Json;
|
||
using Omika.Core.Observability.External;
|
||
using Omika.Core.Observability.Identity;
|
||
using Omika.Core.Observability.Outbox;
|
||
using Omika.Diagnostics.Import;
|
||
using Omika.Diagnostics.Models;
|
||
using Omika.Diagnostics.Tests.Fakes;
|
||
|
||
namespace Omika.Diagnostics.Tests;
|
||
|
||
/// <summary>
|
||
/// Tâche 13-B — Propagation ClientCode / ServerInstance / Environment dans le pipeline
|
||
/// JSONL (transport uniquement, aucune résolution réelle) :
|
||
/// <list type="bullet">
|
||
/// <item>B1–B5 : compatibilité ancien JSONL + round-trip des nouveaux champs.</item>
|
||
/// <item>B6/B7 : le mapper (remote ET server via le store) conserve les 3 valeurs.</item>
|
||
/// <item>B8/B24 : le ClientId mobile ne devient JAMAIS le ClientCode autoritaire.</item>
|
||
/// <item>B9/B10 : StripIdentity épure les identités brutes et conserve l'ActorKey.</item>
|
||
/// <item>B11/B12 : le ClientCode autoritaire fourni au store est conservé ; l'absence → null.</item>
|
||
/// </list>
|
||
/// Aucun PostgreSQL réel requis (fake + store fichier en temp, §51).
|
||
/// </summary>
|
||
public class DiagnosticJsonlContextTests : IDisposable
|
||
{
|
||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||
|
||
private readonly string _dir = CreateTempDirectory();
|
||
|
||
public void Dispose() => DeleteDirectory(_dir);
|
||
|
||
// --------------------------- B1 : ancien JSONL sans nouveaux champs ----------
|
||
[Fact]
|
||
public void B1_OldJsonlWithoutNewFields_DeserializesToNullContext()
|
||
{
|
||
const string line = """
|
||
{"receivedAtUtc":"2025-01-01T00:00:00+00:00","batchId":"11111111-1111-1111-1111-111111111111","ingestionTraceId":"22222222-2222-2222-2222-222222222222","event":{"eventId":"33333333-3333-3333-3333-333333333333","eventCode":"OMK-MOB-APP-001","timestampUtc":"2025-01-01T00:00:00+00:00","level":"Info"}}
|
||
""";
|
||
|
||
RemoteDiagnosticRecord? parsed = JsonSerializer.Deserialize<RemoteDiagnosticRecord>(line, JsonOptions);
|
||
|
||
Assert.NotNull(parsed);
|
||
Assert.Null(parsed!.ClientCode);
|
||
Assert.Null(parsed.ServerInstance);
|
||
Assert.Null(parsed.Environment);
|
||
}
|
||
|
||
// --------------------------- B2/B3/B4/B5 : round-trip ------------------------
|
||
[Fact]
|
||
public void B2_RecordWithClientCode_RoundTrips()
|
||
{
|
||
RemoteDiagnosticRecord record = DiagnosticTestData.Record(
|
||
Guid.NewGuid(), "OMK-MOB-APP-001", clientCode: "CLIENT-TEST-A");
|
||
|
||
RemoteDiagnosticRecord? back = RoundTrip(record);
|
||
|
||
Assert.Equal("CLIENT-TEST-A", back!.ClientCode);
|
||
}
|
||
|
||
[Fact]
|
||
public void B3_RecordWithServerInstance_RoundTrips()
|
||
{
|
||
RemoteDiagnosticRecord record = DiagnosticTestData.Record(
|
||
Guid.NewGuid(), "OMK-MOB-APP-001", serverInstance: "API-TEST-A1");
|
||
|
||
RemoteDiagnosticRecord? back = RoundTrip(record);
|
||
|
||
Assert.Equal("API-TEST-A1", back!.ServerInstance);
|
||
}
|
||
|
||
[Fact]
|
||
public void B4_RecordWithEnvironment_RoundTrips()
|
||
{
|
||
RemoteDiagnosticRecord record = DiagnosticTestData.Record(
|
||
Guid.NewGuid(), "OMK-MOB-APP-001", environment: "Development");
|
||
|
||
RemoteDiagnosticRecord? back = RoundTrip(record);
|
||
|
||
Assert.Equal("Development", back!.Environment);
|
||
}
|
||
|
||
[Fact]
|
||
public void B5_RecordWithAllThreeFields_RoundTrips()
|
||
{
|
||
RemoteDiagnosticRecord record = DiagnosticTestData.Record(
|
||
Guid.NewGuid(),
|
||
"OMK-MOB-APP-001",
|
||
clientCode: "CLIENT-TEST-A",
|
||
serverInstance: "API-TEST-A1",
|
||
environment: "Development");
|
||
|
||
RemoteDiagnosticRecord? back = RoundTrip(record);
|
||
|
||
Assert.Equal("CLIENT-TEST-A", back!.ClientCode);
|
||
Assert.Equal("API-TEST-A1", back!.ServerInstance);
|
||
Assert.Equal("Development", back!.Environment);
|
||
}
|
||
|
||
// --------------------------- B6 : mapper remote ------------------------------
|
||
[Fact]
|
||
public void B6_Mapper_RemoteRecord_PreservesThreeValues()
|
||
{
|
||
RemoteDiagnosticRecord record = DiagnosticTestData.Record(
|
||
Guid.NewGuid(),
|
||
"OMK-MOB-APP-001",
|
||
clientCode: "CLIENT-TEST-A",
|
||
serverInstance: "API-TEST-A1",
|
||
environment: "Development");
|
||
|
||
DiagnosticEventRecord? mapped = DiagnosticEventRecordMapper.ToRecord(record, "mobile");
|
||
|
||
Assert.NotNull(mapped);
|
||
Assert.Equal("CLIENT-TEST-A", mapped!.ClientCode);
|
||
Assert.Equal("API-TEST-A1", mapped.ServerInstance);
|
||
Assert.Equal("Development", mapped.Environment);
|
||
}
|
||
|
||
// --------------------------- B7 : mapper server (via le store) ---------------
|
||
[Fact]
|
||
public async Task B7_ServerRecordThroughStoreAndMapper_PreservesValues()
|
||
{
|
||
// ServerDiagnosticRecord (privé) n'est jamais mappé directement : il est écrit
|
||
// par le store puis relu comme RemoteDiagnosticRecord. Le cycle complet doit
|
||
// conserver les 3 valeurs jusqu'au DiagnosticEventRecord.
|
||
var context = new DiagnosticDeploymentContext
|
||
{
|
||
ClientCode = "CLIENT-TEST-A",
|
||
ServerInstance = "API-TEST-A1",
|
||
Environment = "Development"
|
||
};
|
||
Guid eventId = Guid.NewGuid();
|
||
|
||
string content = await WriteAsync(NewEvent(eventId), DiagnosticActorKey.Empty, context);
|
||
|
||
RemoteDiagnosticRecord? record = JsonSerializer.Deserialize<RemoteDiagnosticRecord>(content, JsonOptions);
|
||
Assert.NotNull(record);
|
||
|
||
DiagnosticEventRecord? mapped = DiagnosticEventRecordMapper.ToRecord(record, "mobile");
|
||
|
||
Assert.NotNull(mapped);
|
||
Assert.Equal("CLIENT-TEST-A", mapped!.ClientCode);
|
||
Assert.Equal("API-TEST-A1", mapped.ServerInstance);
|
||
Assert.Equal("Development", mapped.Environment);
|
||
}
|
||
|
||
// --------------------------- B8 : ClientId mobile ≠ ClientCode ----------------
|
||
[Fact]
|
||
public async Task B8_MobileClientId_NeverBecomesClientCodeAutomatically()
|
||
{
|
||
// Identité transport mobile brute : NE DOIT PAS devenir clientCode.
|
||
DiagnosticEventDto evt = NewEvent(Guid.NewGuid(), clientId: "FAUX-CLIENT");
|
||
|
||
string content = await WriteAsync(evt, DiagnosticActorKey.Empty, context: null);
|
||
|
||
using JsonDocument doc = JsonDocument.Parse(content);
|
||
// Contexte serveur absent → clientCode non écrit (WhenWritingNull), et le
|
||
// ClientId mobile est épuré par StripIdentity : "FAUX-CLIENT" n'apparaît pas.
|
||
Assert.False(doc.RootElement.TryGetProperty("clientCode", out _));
|
||
Assert.DoesNotContain("FAUX-CLIENT", content, StringComparison.Ordinal);
|
||
}
|
||
|
||
// --------------------------- B9 : StripIdentity -------------------------------
|
||
[Fact]
|
||
public async Task B9_StripIdentity_StillRemovesForbiddenIdentities()
|
||
{
|
||
DiagnosticEventDto evt = NewEvent(
|
||
Guid.NewGuid(),
|
||
deviceId: "device-1",
|
||
clientId: "BATIK.01",
|
||
userId: "user-1",
|
||
collaborateurId: "42");
|
||
|
||
string content = await WriteAsync(evt, DiagnosticActorKey.Empty, context: null);
|
||
|
||
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);
|
||
}
|
||
|
||
// --------------------------- B10 : ActorKey conservé --------------------------
|
||
[Fact]
|
||
public async Task B10_ActorKey_IsPreserved()
|
||
{
|
||
const string actorKeyValue = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||
var actorKey = new DiagnosticActorKey(actorKeyValue, 1);
|
||
|
||
string content = await WriteAsync(NewEvent(Guid.NewGuid()), actorKey, context: null);
|
||
|
||
using JsonDocument doc = JsonDocument.Parse(content);
|
||
Assert.Equal(actorKeyValue, doc.RootElement.GetProperty("actorKey").GetString());
|
||
Assert.Equal(1, doc.RootElement.GetProperty("actorKeyVersion").GetInt32());
|
||
}
|
||
|
||
// --------------------------- B11 : ClientCode autoritaire conservé ------------
|
||
[Fact]
|
||
public async Task B11_AuthoritativeClientCode_ProvidedToStore_IsPreserved()
|
||
{
|
||
var context = new DiagnosticDeploymentContext { ClientCode = "CLIENT-AUTORITAIRE" };
|
||
|
||
string content = await WriteAsync(NewEvent(Guid.NewGuid()), DiagnosticActorKey.Empty, context);
|
||
|
||
using JsonDocument doc = JsonDocument.Parse(content);
|
||
Assert.Equal("CLIENT-AUTORITAIRE", doc.RootElement.GetProperty("clientCode").GetString());
|
||
}
|
||
|
||
// --------------------------- B12 : valeur absente → null ----------------------
|
||
[Fact]
|
||
public async Task B12_AbsentContext_WritesNoContextFields_AndDeserializesToNull()
|
||
{
|
||
string content = await WriteAsync(NewEvent(Guid.NewGuid()), DiagnosticActorKey.Empty, context: null);
|
||
|
||
using (JsonDocument doc = JsonDocument.Parse(content))
|
||
{
|
||
// Champs null omis (WhenWritingNull) : compatibilité ancien JSONL.
|
||
Assert.False(doc.RootElement.TryGetProperty("clientCode", out _));
|
||
Assert.False(doc.RootElement.TryGetProperty("serverInstance", out _));
|
||
Assert.False(doc.RootElement.TryGetProperty("environment", out _));
|
||
}
|
||
|
||
RemoteDiagnosticRecord? record = JsonSerializer.Deserialize<RemoteDiagnosticRecord>(content, JsonOptions);
|
||
Assert.NotNull(record);
|
||
Assert.Null(record!.ClientCode);
|
||
Assert.Null(record.ServerInstance);
|
||
Assert.Null(record.Environment);
|
||
}
|
||
|
||
// --------------------------- B24 : sécurité ----------------------------------
|
||
[Fact]
|
||
public async Task B24_MobileClientId_WithAuthoritativeContext_UsesAuthoritativeCode()
|
||
{
|
||
// DTO mobile : identité transport falsifiable.
|
||
DiagnosticEventDto evt = NewEvent(Guid.NewGuid(), clientId: "FAUX-CLIENT");
|
||
// Contexte serveur : ClientCode AUTORITAIRE (T13-C le fournira réellement).
|
||
var context = new DiagnosticDeploymentContext { ClientCode = "CLIENT-AUTORITAIRE" };
|
||
|
||
string content = await WriteAsync(evt, DiagnosticActorKey.Empty, context);
|
||
|
||
using JsonDocument doc = JsonDocument.Parse(content);
|
||
Assert.Equal("CLIENT-AUTORITAIRE", doc.RootElement.GetProperty("clientCode").GetString());
|
||
Assert.DoesNotContain("FAUX-CLIENT", content, StringComparison.Ordinal);
|
||
Assert.DoesNotContain("\"clientId\"", content, StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
|
||
// --------------------------- Helpers ------------------------------------------
|
||
private static DiagnosticEventDto NewEvent(
|
||
Guid eventId,
|
||
string? deviceId = null,
|
||
string? clientId = null,
|
||
string? userId = null,
|
||
string? collaborateurId = null) => new()
|
||
{
|
||
EventId = eventId,
|
||
EventCode = "OMK-MOB-APP-001",
|
||
TimestampUtc = DateTimeOffset.UtcNow,
|
||
Level = "Info",
|
||
Priority = "P3",
|
||
DeviceId = deviceId,
|
||
ClientId = clientId,
|
||
UserId = userId,
|
||
CollaborateurId = collaborateurId
|
||
};
|
||
|
||
private static RemoteDiagnosticRecord? RoundTrip(RemoteDiagnosticRecord record)
|
||
{
|
||
string json = DiagnosticTestData.SerializeLine(record);
|
||
return JsonSerializer.Deserialize<RemoteDiagnosticRecord>(json, JsonOptions);
|
||
}
|
||
|
||
private async Task<string> WriteAsync(
|
||
DiagnosticEventDto evt,
|
||
DiagnosticActorKey actorKey,
|
||
DiagnosticDeploymentContext? context)
|
||
{
|
||
var store = new RemoteDiagnosticIngestionStore(new RemoteDiagnosticOptions { Directory = _dir });
|
||
var batch = new DiagnosticBatchDto { Events = [evt] };
|
||
|
||
await store.StoreAsync(batch, actorKey, "trace-id", DateTimeOffset.UtcNow, CancellationToken.None, context);
|
||
|
||
return ReadAllTextShared(Assert.Single(Directory.EnumerateFiles(_dir, "*.jsonl")));
|
||
}
|
||
|
||
private static string CreateTempDirectory()
|
||
{
|
||
string dir = Path.Combine(Path.GetTempPath(), "omika-jsonl-context-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.
|
||
}
|
||
}
|
||
}
|