352 lines
14 KiB
C#
352 lines
14 KiB
C#
using Omika.Diagnostics.Models;
|
|
using Omika.Diagnostics.Prompt;
|
|
|
|
namespace Omika.Diagnostics.Tests;
|
|
|
|
/// <summary>
|
|
/// T24-A Partie A — génération LOCALE du prompt IA. Aucun appel IA externe : le builder
|
|
/// transforme un diagnostic en texte lisible. Tests : précision /dev/throw, corrélation
|
|
/// (TraceId/OperationId), données NULL, bornes, et sécurité (0 fuite de valeurs interdites,
|
|
/// réutilisation de <see cref="Omika.Core.Observability.Outbox.DiagnosticForbiddenData"/>).
|
|
/// </summary>
|
|
public class DiagnosticPromptBuilderTests
|
|
{
|
|
private static readonly DateTimeOffset Base = new(2025, 1, 1, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
private static DiagnosticEventRecord Ev(
|
|
string eventCode = "TEST",
|
|
string level = "Error",
|
|
string priority = "P2",
|
|
string? errorCode = null,
|
|
string? message = null,
|
|
string? exceptionType = null,
|
|
string? stackTrace = null,
|
|
Guid? traceId = null,
|
|
Guid? operationId = null,
|
|
Guid? sessionId = null,
|
|
string? actorKey = null,
|
|
string? clientCode = null,
|
|
string? environment = null,
|
|
string? serverInstance = null,
|
|
string? source = null,
|
|
string? component = null,
|
|
string? platform = null,
|
|
string? operatingSystem = null,
|
|
string? operatingSystemVersion = null,
|
|
string? manufacturer = null,
|
|
string? model = null,
|
|
string? appVersion = null,
|
|
string? appBuild = null,
|
|
string? screenCode = null,
|
|
string? featureCode = null,
|
|
string? licenseNumber = null,
|
|
string? deviceKey = null,
|
|
long? durationMs = null,
|
|
string? metadataJson = null) =>
|
|
new()
|
|
{
|
|
Id = 1,
|
|
EventId = Guid.NewGuid(),
|
|
EventCode = eventCode,
|
|
TimestampUtc = Base,
|
|
Level = level,
|
|
Priority = priority,
|
|
ErrorCode = errorCode,
|
|
Message = message,
|
|
ExceptionType = exceptionType,
|
|
StackTrace = stackTrace,
|
|
TraceId = traceId,
|
|
OperationId = operationId,
|
|
SessionId = sessionId,
|
|
ActorKey = actorKey,
|
|
ClientCode = clientCode,
|
|
Environment = environment,
|
|
ServerInstance = serverInstance,
|
|
Source = source ?? DiagnosticSources.Mobile,
|
|
Component = component,
|
|
Platform = platform,
|
|
OperatingSystem = operatingSystem,
|
|
OperatingSystemVersion = operatingSystemVersion,
|
|
DeviceManufacturer = manufacturer,
|
|
DeviceModel = model,
|
|
AppVersion = appVersion,
|
|
AppBuild = appBuild,
|
|
ScreenCode = screenCode,
|
|
FeatureCode = featureCode,
|
|
LicenseNumber = licenseNumber,
|
|
DeviceKey = deviceKey,
|
|
DurationMs = durationMs,
|
|
MetadataJson = metadataJson,
|
|
IngestedAtUtc = DateTimeOffset.UtcNow
|
|
};
|
|
|
|
private static DiagnosticEventRecord Correlated(long id, DateTimeOffset ts, string eventCode, string level, string component, string message) =>
|
|
new()
|
|
{
|
|
Id = id,
|
|
EventId = Guid.NewGuid(),
|
|
EventCode = eventCode,
|
|
TimestampUtc = ts,
|
|
Level = level,
|
|
Priority = "P2",
|
|
Component = component,
|
|
Message = message,
|
|
Source = DiagnosticSources.Api,
|
|
IngestedAtUtc = DateTimeOffset.UtcNow
|
|
};
|
|
|
|
// ------------------------------------------- A : /dev/throw (volontaire/test) ----
|
|
[Fact]
|
|
public void DevThrow_PromptContainsEnoughContextToInferVoluntaryTest()
|
|
{
|
|
DiagnosticEventRecord ev = Ev(
|
|
eventCode: "API_HTTP_ERROR",
|
|
errorCode: "TELEMETRY_TEST_ERROR",
|
|
exceptionType: "InvalidOperationException",
|
|
environment: "Development",
|
|
serverInstance: "API-A-01",
|
|
source: DiagnosticSources.Api,
|
|
component: "Omika.Api",
|
|
metadataJson: """{"routeTemplate":"/dev/throw","method":"GET","statusCode":500}""");
|
|
|
|
string prompt = DiagnosticPromptBuilder.Build(ev);
|
|
|
|
// Aucune règle codée en dur « /dev/throw » : le résultat vient des données.
|
|
Assert.Contains("API_HTTP_ERROR", prompt, StringComparison.Ordinal);
|
|
Assert.Contains("InvalidOperationException", prompt, StringComparison.Ordinal);
|
|
Assert.Contains("/dev/throw", prompt, StringComparison.Ordinal);
|
|
Assert.Contains("TELEMETRY_TEST_ERROR", prompt, StringComparison.Ordinal);
|
|
Assert.Contains("Development", prompt, StringComparison.Ordinal);
|
|
Assert.Contains("routeTemplate", prompt, StringComparison.Ordinal);
|
|
}
|
|
|
|
// ------------------------------------------- B : TraceId + corrélés ----
|
|
[Fact]
|
|
public void TraceId_PromptIncludesCorrelatedSection()
|
|
{
|
|
Guid traceId = Guid.NewGuid();
|
|
DiagnosticEventRecord ev = Ev(traceId: traceId, eventCode: "ERR", exceptionType: "System.Exception");
|
|
|
|
var correlated = new[]
|
|
{
|
|
Correlated(1, Base.AddMinutes(-1), "OMK-API-HTTP-001", "Info", "Omika.Api", "api request"),
|
|
Correlated(2, Base, "ERR", "Error", "Omika.Api", "boom")
|
|
};
|
|
|
|
string prompt = DiagnosticPromptBuilder.Build(ev, correlated);
|
|
|
|
Assert.Contains(traceId.ToString("D"), prompt, StringComparison.Ordinal);
|
|
Assert.Contains("ÉVÉNEMENTS CORRÉLÉS", prompt, StringComparison.Ordinal);
|
|
Assert.Contains("OMK-API-HTTP-001", prompt, StringComparison.Ordinal);
|
|
}
|
|
|
|
// ------------------------------------------- C : OperationId ----
|
|
[Fact]
|
|
public void OperationId_PromptIncludesOperationId()
|
|
{
|
|
Guid operationId = Guid.NewGuid();
|
|
DiagnosticEventRecord ev = Ev(operationId: operationId);
|
|
|
|
string prompt = DiagnosticPromptBuilder.Build(ev);
|
|
|
|
Assert.Contains(operationId.ToString("D"), prompt, StringComparison.Ordinal);
|
|
}
|
|
|
|
// ------------------------------------------- D : mobile ScreenCode/DeviceInfo ----
|
|
[Fact]
|
|
public void MobileEvent_PromptIncludesScreenAndDeviceInfo()
|
|
{
|
|
DiagnosticEventRecord ev = Ev(
|
|
screenCode: "AGENDA",
|
|
featureCode: "INTERVENTION_SEARCH_RUN",
|
|
platform: "Android",
|
|
manufacturer: "Samsung",
|
|
model: "SM-S928B",
|
|
operatingSystem: "Android",
|
|
operatingSystemVersion: "15",
|
|
appVersion: "1.1.0",
|
|
deviceKey: "devk-pseudonymized-abcdef");
|
|
|
|
string prompt = DiagnosticPromptBuilder.Build(ev);
|
|
|
|
Assert.Contains("AGENDA", prompt, StringComparison.Ordinal);
|
|
Assert.Contains("INTERVENTION_SEARCH_RUN", prompt, StringComparison.Ordinal);
|
|
Assert.Contains("Samsung", prompt, StringComparison.Ordinal);
|
|
Assert.Contains("SM-S928B", prompt, StringComparison.Ordinal);
|
|
Assert.Contains("Android", prompt, StringComparison.Ordinal);
|
|
}
|
|
|
|
// ------------------------------------------- E : beaucoup de NULL ----
|
|
[Fact]
|
|
public void AllNull_PromptStillBuildsWithoutException()
|
|
{
|
|
DiagnosticEventRecord ev = Ev(eventCode: "OLD", level: "Info");
|
|
ev.TraceId = null;
|
|
ev.OperationId = null;
|
|
ev.SessionId = null;
|
|
ev.StackTrace = null;
|
|
ev.Message = null;
|
|
ev.MetadataJson = null;
|
|
|
|
string prompt = DiagnosticPromptBuilder.Build(ev);
|
|
|
|
Assert.StartsWith("Analyse cette erreur Omika", prompt, StringComparison.Ordinal);
|
|
Assert.Contains("DEMANDE :", prompt, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("ÉVÉNEMENTS CORRÉLÉS", prompt, StringComparison.Ordinal);
|
|
}
|
|
|
|
// ------------------------------------------- F : sécurité métadonnées (0 fuite) ----
|
|
[Fact]
|
|
public void ForbiddenMetadata_IsNeverLeakedInPrompt()
|
|
{
|
|
DiagnosticEventRecord ev = Ev(
|
|
eventCode: "ERR",
|
|
metadataJson: """{"password":"secret123","token":"tok-abc","apiKey":"key-xyz","connectionString":"Server=x;Password=y","deviceId":"dev-raw-1","method":"POST","statusCode":500,"routeTemplate":"/api/v1/x"}""");
|
|
|
|
string prompt = DiagnosticPromptBuilder.Build(ev);
|
|
|
|
Assert.DoesNotContain("secret123", prompt, StringComparison.OrdinalIgnoreCase);
|
|
Assert.DoesNotContain("tok-abc", prompt, StringComparison.OrdinalIgnoreCase);
|
|
Assert.DoesNotContain("key-xyz", prompt, StringComparison.OrdinalIgnoreCase);
|
|
Assert.DoesNotContain("Password=y", prompt, StringComparison.OrdinalIgnoreCase);
|
|
Assert.DoesNotContain("dev-raw-1", prompt, StringComparison.OrdinalIgnoreCase);
|
|
Assert.DoesNotContain("password", prompt, StringComparison.OrdinalIgnoreCase);
|
|
Assert.DoesNotContain("apiKey", prompt, StringComparison.OrdinalIgnoreCase);
|
|
Assert.DoesNotContain("connectionString", prompt, StringComparison.OrdinalIgnoreCase);
|
|
Assert.DoesNotContain("deviceId", prompt, StringComparison.OrdinalIgnoreCase);
|
|
|
|
// Les clés whitelistées et saines restent présentes.
|
|
Assert.Contains("routeTemplate", prompt, StringComparison.Ordinal);
|
|
Assert.Contains("/api/v1/x", prompt, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void ForbiddenValueUnderWhitelistedKey_IsDropped()
|
|
{
|
|
DiagnosticEventRecord ev = Ev(
|
|
eventCode: "ERR",
|
|
metadataJson: """{"method":"ConnectionString=Server=x;Password=hidden","statusCode":200}""");
|
|
|
|
string prompt = DiagnosticPromptBuilder.Build(ev);
|
|
|
|
Assert.DoesNotContain("ConnectionString", prompt, StringComparison.OrdinalIgnoreCase);
|
|
Assert.DoesNotContain("Password=hidden", prompt, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Contains("statusCode", prompt, StringComparison.Ordinal);
|
|
}
|
|
|
|
// ------------------------------------------- §48 : sans corrélation ----
|
|
[Fact]
|
|
public void NoTraceOperationSession_PromptStillBuilds()
|
|
{
|
|
DiagnosticEventRecord ev = Ev();
|
|
ev.TraceId = null;
|
|
ev.OperationId = null;
|
|
ev.SessionId = null;
|
|
|
|
string prompt = DiagnosticPromptBuilder.Build(ev);
|
|
|
|
Assert.Contains("Analyse cette erreur Omika", prompt, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("TraceId", prompt, StringComparison.Ordinal);
|
|
}
|
|
|
|
// ------------------------------------------- §49 : sans stack trace ----
|
|
[Fact]
|
|
public void NoStackTrace_PromptStillBuilds()
|
|
{
|
|
DiagnosticEventRecord ev = Ev(exceptionType: "System.Exception", stackTrace: null);
|
|
|
|
string prompt = DiagnosticPromptBuilder.Build(ev);
|
|
|
|
Assert.Contains("ExceptionType", prompt, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("StackTrace", prompt, StringComparison.Ordinal);
|
|
}
|
|
|
|
// ------------------------------------------- §50 : bornes corrélés ----
|
|
[Fact]
|
|
public void CorrelatedEvents_AreCappedAtMax()
|
|
{
|
|
DiagnosticEventRecord ev = Ev(traceId: Guid.NewGuid());
|
|
var many = new List<DiagnosticEventRecord>();
|
|
for (int i = 0; i < 60; i++)
|
|
{
|
|
many.Add(Correlated(i + 1, Base.AddSeconds(i), "E", "Info", "C", "m" + i));
|
|
}
|
|
|
|
string prompt = DiagnosticPromptBuilder.Build(ev, many);
|
|
|
|
Assert.Contains("ÉVÉNEMENTS CORRÉLÉS", prompt, StringComparison.Ordinal);
|
|
// 60 événements fournis, mais la section est bornée à MaxCorrelatedEvents.
|
|
int count = prompt.Split("- ", StringSplitOptions.None).Length - 1;
|
|
Assert.Equal(DiagnosticPromptBuilder.MaxCorrelatedEvents, count);
|
|
Assert.DoesNotContain("m59", prompt, StringComparison.Ordinal); // au-delà de la borne.
|
|
}
|
|
|
|
[Fact]
|
|
public void CorrelatedEvents_SelectionneLesPlusProchesDeLErreur()
|
|
{
|
|
DiagnosticEventRecord ev = Ev(traceId: Guid.NewGuid());
|
|
var many = new List<DiagnosticEventRecord>();
|
|
|
|
// 30 événements ANTÉRIEURS (de 30 à 1 minutes avant l'erreur) : ce sont eux que
|
|
// la logique « les N premiers de la liste » reproduisait, sans jamais montrer
|
|
// ce qui s'est passé juste après l'incident.
|
|
for (int i = 30; i >= 1; i--)
|
|
{
|
|
many.Add(Correlated(100 + i, Base.AddMinutes(-i), "E", "Info", "C", $"avant{i:00}"));
|
|
}
|
|
|
|
// 5 événements POSTÉRIEURS : le contexte immédiat de l'erreur.
|
|
for (int i = 1; i <= 5; i++)
|
|
{
|
|
many.Add(Correlated(200 + i, Base.AddMinutes(i), "E", "Info", "C", $"apres{i:00}"));
|
|
}
|
|
|
|
string prompt = DiagnosticPromptBuilder.Build(ev, many);
|
|
|
|
// Retenus : les 5 postérieurs + les 20 antérieurs les plus proches.
|
|
Assert.Contains("apres05", prompt, StringComparison.Ordinal);
|
|
Assert.Contains("avant01", prompt, StringComparison.Ordinal);
|
|
Assert.Contains("avant20", prompt, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("avant21", prompt, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("avant30", prompt, StringComparison.Ordinal);
|
|
|
|
// Restitution chronologique : le passé avant le futur.
|
|
Assert.True(
|
|
prompt.IndexOf("avant01", StringComparison.Ordinal)
|
|
< prompt.IndexOf("apres01", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void CorrelatedEvents_PromptIndiqueQueLaListeNEstPasExhaustive()
|
|
{
|
|
DiagnosticEventRecord ev = Ev(traceId: Guid.NewGuid());
|
|
|
|
string prompt = DiagnosticPromptBuilder.Build(
|
|
ev,
|
|
[Correlated(1, Base.AddMinutes(-1), "E", "Info", "C", "m")]);
|
|
|
|
// Le lecteur (humain ou IA) ne doit pas conclure à une absence d'activité :
|
|
// les événements Info sont remontés après les erreurs (file prioritaire).
|
|
Assert.Contains("NON exhaustive", prompt, StringComparison.Ordinal);
|
|
Assert.Contains("remontés APRÈS les erreurs", prompt, StringComparison.Ordinal);
|
|
}
|
|
|
|
// ------------------------------------------- longueur raisonnable ----
|
|
[Fact]
|
|
public void Prompt_IsReasonablySized()
|
|
{
|
|
DiagnosticEventRecord ev = Ev(
|
|
eventCode: "API_HTTP_ERROR",
|
|
exceptionType: "InvalidOperationException",
|
|
stackTrace: new string('x', 10000),
|
|
message: new string('y', 5000),
|
|
metadataJson: """{"routeTemplate":"/dev/throw","method":"GET","statusCode":500}""");
|
|
|
|
string prompt = DiagnosticPromptBuilder.Build(ev);
|
|
|
|
Assert.True(prompt.Length < 9000, $"Prompt trop grand : {prompt.Length}");
|
|
// La stack trace est tronquée, pas reproduite intégralement.
|
|
Assert.DoesNotContain(new string('x', 10000), prompt, StringComparison.Ordinal);
|
|
}
|
|
}
|