Premiere commit - Omika

This commit is contained in:
2026-10-23 18:46:50 +02:00
commit 8ea76c1b24
2155 changed files with 984301 additions and 0 deletions
@@ -0,0 +1,484 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Omika.Core.Abstractions;
using Omika.Core.Compatibility;
using Omika.Core.Configuration;
using Omika.Core.Interfaces.Mobile;
using Omika.Core.Observability.Deployment;
using Omika.Core.Observability.Identity;
using Omika.Core.Observability.Outbox;
using Omika.Core.Runtime;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.Runtime;
namespace Omika.Tests.Api;
/// <summary>
/// Tâche 13-C — Intégration du contexte de déploiement dans Omika.Api :
/// <list type="bullet">
/// <item>C1 : événement API interne → contexte du provider (ClientCode serveur).</item>
/// <item>C2/C3 : diagnostic mobile → ClientCode = JWT authentifié (autoritaire), jamais payload/provider.</item>
/// <item>C4/C5 : ServerInstance/Environment mobile = serveur ingérant (provider), jamais le téléphone.</item>
/// <item>C6 : payload falsifié (ClientId/ClientCode) ne remplace jamais le JWT.</item>
/// <item>C7 : requêtes concurrentes → aucune contamination inter-requêtes.</item>
/// <item>C8 : provider sans ClientCode → null sans exception (fail-open).</item>
/// <item>C9 : aucun secret dans ce que les producteurs transmettent.</item>
/// </list>
/// Provider et store sont des fakes en mémoire : l'objectif est de vérifier QUE le
/// bon <see cref="DiagnosticDeploymentContext"/> est transmis à
/// <see cref="IRemoteDiagnosticIngestionStore.StoreAsync"/> (le provider lui-même est
/// couvert par les tests D1–D16 de T13-D ; le JSONL par B1–B24 de T13-B).
/// </summary>
[Collection("ApplicationRuntimeContext")]
public class DiagnosticDeploymentContextApiTests : IDisposable
{
private const string SigningKey = "test-signing-key-for-integration-tests-only";
private readonly FakeIngestionStore _store = new();
private readonly FakeDeploymentContextProvider _provider = new();
private readonly WebApplicationFactory<Program> _factory;
public DiagnosticDeploymentContextApiTests()
{
ApplicationRuntimeContext.Reset();
_provider.Context = new DiagnosticDeploymentContext
{
ClientCode = "CLIENT-SERVEUR",
ServerInstance = "API-SRV-01",
Environment = "Production"
};
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Hfsql:Server"] = "test-server",
["Hfsql:DatabaseName"] = "test-db",
["MobileAuth:SigningKey"] = SigningKey
})
.Build();
services.AddSingleton<IConfiguration>(config);
services.RemoveAll<IDatabaseRuntimeInitializer>();
services.AddScoped<IDatabaseRuntimeInitializer, FakeRuntimeInitializer>();
services.RemoveAll<IHfsqlConnection>();
services.AddScoped<IHfsqlConnection, FakeHfsqlConnection>();
// Store fake : capture le contexte de déploiement transmis.
services.RemoveAll<IRemoteDiagnosticIngestionStore>();
services.AddSingleton<IRemoteDiagnosticIngestionStore>(_store);
// Provider fake : contexte serveur déterministe (pas de HFSQL).
services.RemoveAll<IDiagnosticDeploymentContextProvider>();
services.AddSingleton<IDiagnosticDeploymentContextProvider>(_provider);
// Active le forwarder serveur (4xx/5xx) pour C1/C8.
services.Configure<ServerDiagnosticOptions>(o => o.Enabled = true);
});
});
}
public void Dispose()
{
_factory.Dispose();
ApplicationRuntimeContext.Reset();
}
// --------------------------- C1 : événement API interne -----------------------
[Fact]
public async Task C1_InternalApiEvent_UsesProviderContext()
{
var client = _factory.CreateClient();
// 404 (hors /health et /api/diagnostics) → le ServerDiagnosticMiddleware
// enregistre un événement API_HTTP_4XX avec le contexte du provider.
var response = await client.GetAsync($"/does-not-exist-{Guid.NewGuid():N}");
Assert.Equal(404, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls);
AssertContext(call.Context, clientCode: "CLIENT-SERVEUR", serverInstance: "API-SRV-01", environment: "Production");
}
// --------------------------- C2 : diagnostic mobile ---------------------------
[Fact]
public async Task C2_MobileDiagnostic_UsesJwtCodeClientOverPayload()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken("CLIENT-JWT"));
// Payload mobile : ClientId falsifiable = FAUX-CLIENT.
var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch(clientId: "FAUX-CLIENT"));
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls);
Assert.Equal("CLIENT-JWT", call.Context!.ClientCode);
}
// --------------------------- C3 : priorité JWT > provider > payload -----------
[Fact]
public async Task C3_MobileDiagnostic_PriorityJwtOverProviderOverPayload()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken("CLIENT-JWT"));
var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch(clientId: "FAUX-CLIENT"));
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls);
// JWT gagne ; le provider ("CLIENT-SERVEUR") ne gagne PAS pour le mobile ;
// le payload ("FAUX-CLIENT") n'est JAMAIS candidat.
Assert.Equal("CLIENT-JWT", call.Context!.ClientCode);
Assert.NotEqual("CLIENT-SERVEUR", call.Context.ClientCode);
Assert.NotEqual("FAUX-CLIENT", call.Context.ClientCode);
}
// --------------------------- C4 : ServerInstance mobile = provider ------------
[Fact]
public async Task C4_MobileDiagnostic_ServerInstanceFromProviderNotPhone()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken("CLIENT-JWT"));
// DeviceModel du téléphone : ne doit jamais devenir ServerInstance.
var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch(deviceModel: "iPhone 15"));
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls);
Assert.Equal("API-SRV-01", call.Context!.ServerInstance);
Assert.NotEqual("iPhone 15", call.Context.ServerInstance);
}
// --------------------------- C5 : Environment mobile = provider ---------------
[Fact]
public async Task C5_MobileDiagnostic_EnvironmentFromProvider()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken("CLIENT-JWT"));
var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch());
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls);
Assert.Equal("Production", call.Context!.Environment);
}
// --------------------------- C6 : payload falsifié ----------------------------
[Fact]
public async Task C6_FalsifiedPayload_NeverReplacesJwtCodeClient()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken("CLIENT-JWT"));
// Payload brut contenant clientId + un champ inconnu "clientCode" (ignoré par
// la désérialisation typée) : aucun des deux ne doit devenir le ClientCode.
string rawJson = $$"""
{
"schemaVersion": 1,
"batchId": "{{Guid.NewGuid():D}}",
"sentAtUtc": "2025-01-01T00:00:00+00:00",
"events": [
{
"eventId": "{{Guid.NewGuid():D}}",
"eventCode": "OMK-MOB-APP-001",
"timestampUtc": "2025-01-01T00:00:00+00:00",
"level": "Info",
"priority": "P3",
"clientId": "AUTRE-CLIENT",
"clientCode": "AUTRE-CLIENT"
}
]
}
""";
using var content = new StringContent(rawJson, Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/diagnostics", content);
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls);
Assert.Equal("CLIENT-JWT", call.Context!.ClientCode);
}
// --------------------------- C7 : requêtes concurrentes -----------------------
[Fact]
public async Task C7_ConcurrentRequests_NoContextContamination()
{
var client = _factory.CreateClient();
(string Code, string EventCode)[] cases =
[
("CLIENT-A", "T7-A"),
("CLIENT-B", "T7-B"),
("CLIENT-C", "T7-C")
];
// Construction explicite des requêtes concurrentes avec des tokens distincts.
Task<HttpResponseMessage>[] requests = cases.Select(c =>
{
var req = new HttpRequestMessage(HttpMethod.Post, "/api/diagnostics")
{
Content = JsonContent.Create(ValidBatch(eventCode: c.EventCode))
};
req.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken(c.Code));
return client.SendAsync(req);
}).ToArray();
HttpResponseMessage[] responses = await Task.WhenAll(requests);
Assert.All(responses, r => Assert.Equal(200, (int)r.StatusCode));
Assert.Equal(3, _store.Calls.Count);
foreach ((string Code, string EventCode) c in cases)
{
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call =
Assert.Single(_store.Calls, x => x.Batch.Events[0].EventCode == c.EventCode);
Assert.Equal(c.Code, call.Context!.ClientCode);
// ServerInstance/Environment identiques pour tous les clients.
Assert.Equal("API-SRV-01", call.Context.ServerInstance);
Assert.Equal("Production", call.Context.Environment);
}
}
// --------------------------- C8 : provider sans ClientCode --------------------
[Fact]
public async Task C8_ProviderWithoutClientCode_NullWithoutException()
{
_provider.Context = new DiagnosticDeploymentContext
{
ClientCode = null,
ServerInstance = "API-SRV-01",
Environment = "Production"
};
var client = _factory.CreateClient();
var response = await client.GetAsync($"/does-not-exist-{Guid.NewGuid():N}");
// Fail-open : le 404 est toujours produit, l'événement est enregistré avec
// ClientCode = null, aucune exception.
Assert.Equal(404, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls);
Assert.Null(call.Context!.ClientCode);
Assert.Equal("API-SRV-01", call.Context.ServerInstance);
Assert.Equal("Production", call.Context.Environment);
}
// --------------------------- C9 : absence de secret ---------------------------
[Fact]
public async Task C9_NoSecretsInProducedContextOrBatch()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", CreateToken("CLIENT-JWT"));
var response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch(clientId: "FAUX-CLIENT"));
Assert.Equal(200, (int)response.StatusCode);
(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context) call = Assert.Single(_store.Calls);
string contextJson = JsonSerializer.Serialize(call.Context!);
string batchJson = JsonSerializer.Serialize(call.Batch);
foreach (string secret in new[] { "Authorization", "Bearer", "HmacKey", "ConnectionString", "password", "CL_CleAPIInstall" })
{
Assert.DoesNotContain(secret, contextJson, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(secret, batchJson, StringComparison.OrdinalIgnoreCase);
}
}
// --------------------------- Helpers ------------------------------------------
private static DiagnosticBatchDto ValidBatch(
string eventCode = "OMK-MOB-APP-001",
string? clientId = null,
string? deviceModel = null) => new()
{
BatchId = Guid.NewGuid(),
Events =
[
new DiagnosticEventDto
{
EventId = Guid.NewGuid(),
EventCode = eventCode,
TimestampUtc = DateTimeOffset.UtcNow,
Level = "Info",
Priority = "P3",
ClientId = clientId,
DeviceModel = deviceModel
}
]
};
private string CreateToken(string codeClient)
{
using var scope = _factory.Services.CreateScope();
var tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
return tokenService.GenerateToken(new MobileTokenIdentity
{
CodeClient = codeClient,
DeviceId = "device-test",
UserId = "user-test",
CollaborateurId = "7",
NumeroLicence = "licence-test"
});
}
private static void AssertContext(
DiagnosticDeploymentContext? context,
string? clientCode,
string? serverInstance,
string? environment)
{
Assert.NotNull(context);
Assert.Equal(clientCode, context!.ClientCode);
Assert.Equal(serverInstance, context.ServerInstance);
Assert.Equal(environment, context.Environment);
}
// ===== Fakes =====
private sealed class FakeIngestionStore : IRemoteDiagnosticIngestionStore
{
private readonly object _gate = new();
private readonly List<(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context)> _calls = [];
public IReadOnlyList<(DiagnosticBatchDto Batch, DiagnosticDeploymentContext? Context)> Calls
{
get
{
lock (_gate)
{
return _calls.ToArray();
}
}
}
public Task<int> StoreAsync(
DiagnosticBatchDto batch,
DiagnosticActorKey actorKey,
string ingestionTraceId,
DateTimeOffset receivedAtUtc,
CancellationToken cancellationToken = default,
DiagnosticDeploymentContext? deploymentContext = null,
DiagnosticDeviceKey? deviceKey = null,
string? licenseNumber = null)
{
lock (_gate)
{
_calls.Add((batch, deploymentContext));
}
return Task.FromResult(batch.Events.Count);
}
}
private sealed class FakeDeploymentContextProvider : IDiagnosticDeploymentContextProvider
{
public DiagnosticDeploymentContext Context { get; set; } = DiagnosticDeploymentContext.Empty;
public Task<DiagnosticDeploymentContext> GetAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(Context);
}
private sealed class FakeRuntimeInitializer : IDatabaseRuntimeInitializer
{
public Task<Result> InitializeAsync(CancellationToken cancellationToken = default)
{
ApplicationRuntimeContext.SetDetectedVersion(DatabaseSchemaVersion.Batik);
return Task.FromResult(Result.Success());
}
}
private sealed class FakeHfsqlConnection : IHfsqlConnection, IDisposable
{
public HfsqlOptions Options => new();
public HfsqlConnectionState State => HfsqlConnectionState.Closed;
public void Dispose() { }
public Task OpenAsync(CancellationToken ct = default) => Task.CompletedTask;
public Task CloseAsync() => Task.CompletedTask;
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult<object?>(null);
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult(0);
public Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<IReadOnlyDictionary<string, object?>>>([]);
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(string tableName, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<HfsqlColumnMetadata>>([]);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
/// <summary>
/// Tâche 13-C C10 — Régression : les anciens appels <c>StoreAsync</c> (sans
/// <c>deploymentContext</c>) et les anciens JSONL restent compatibles. Utilise le
/// vrai <see cref="RemoteDiagnosticIngestionStore"/> (aucun HTTP requis).
/// </summary>
public class DiagnosticDeploymentContextBackwardCompatibilityTests : IDisposable
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), "omika-t13c-compat", Guid.NewGuid().ToString("N"));
public DiagnosticDeploymentContextBackwardCompatibilityTests() => Directory.CreateDirectory(_dir);
public void Dispose()
{
try
{
if (Directory.Exists(_dir))
{
Directory.Delete(_dir, recursive: true);
}
}
catch
{
// Best-effort.
}
}
[Fact]
public async Task C10_LegacyStoreAsyncCall_WritesJsonlWithoutContextFields()
{
var store = new RemoteDiagnosticIngestionStore(new RemoteDiagnosticOptions { Directory = _dir });
var evt = new DiagnosticEventDto
{
EventId = Guid.NewGuid(),
EventCode = "OMK-MOB-APP-001",
TimestampUtc = DateTimeOffset.UtcNow,
Level = "Info",
Priority = "P3"
};
var batch = new DiagnosticBatchDto { Events = [evt] };
// Appel hérité : AUCUN deploymentContext.
int accepted = await store.StoreAsync(batch, DiagnosticActorKey.Empty, "trace-id", DateTimeOffset.UtcNow);
Assert.Equal(1, accepted);
string content = ReadAllTextShared(Assert.Single(Directory.EnumerateFiles(_dir, "*.jsonl")));
using JsonDocument doc = JsonDocument.Parse(content);
Assert.False(doc.RootElement.TryGetProperty("clientCode", out _));
Assert.False(doc.RootElement.TryGetProperty("serverInstance", out _));
Assert.False(doc.RootElement.TryGetProperty("environment", out _));
}
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();
}
}