485 lines
20 KiB
C#
485 lines
20 KiB
C#
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();
|
||
}
|
||
}
|