374 lines
15 KiB
C#
374 lines
15 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
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.Contracts.Mobile.Bootstrap;
|
|
using Omika.Core.Contracts.Mobile.License;
|
|
using Omika.Core.Interfaces.Mobile;
|
|
using Omika.Core.Observability.Diagnostic;
|
|
using Omika.Core.Observability.Outbox;
|
|
using Omika.Core.Runtime;
|
|
using Omika.Data.Configuration;
|
|
using Omika.Data.Connection;
|
|
using Omika.Data.Runtime;
|
|
|
|
namespace Omika.Tests.Observability.Outbox;
|
|
|
|
/// <summary>
|
|
/// Tests d'intégration mobile → API (Phase 4) : authentification, validation stricte,
|
|
/// persistance durable avant ACK, données interdites, anti-boucle, panne stockage.
|
|
/// </summary>
|
|
[Collection("ApplicationRuntimeContext")]
|
|
public class DiagnosticIngestionIntegrationTests : IDisposable
|
|
{
|
|
private const string SigningKey = "test-signing-key-for-integration-tests-only";
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
|
|
|
private readonly string _localDir = CreateTempDirectory();
|
|
private readonly string _remoteDir = CreateTempDirectory();
|
|
private readonly WebApplicationFactory<Program> _factory;
|
|
|
|
public DiagnosticIngestionIntegrationTests()
|
|
{
|
|
ApplicationRuntimeContext.Reset();
|
|
_factory = CreateFactory(_localDir, _remoteDir);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_factory.Dispose();
|
|
ApplicationRuntimeContext.Reset();
|
|
DeleteDirectory(_localDir);
|
|
DeleteDirectory(_remoteDir);
|
|
}
|
|
|
|
private string NewToken()
|
|
{
|
|
using IServiceScope scope = _factory.Services.CreateScope();
|
|
ITokenService tokenService = scope.ServiceProvider.GetRequiredService<ITokenService>();
|
|
return tokenService.GenerateToken(new MobileTokenIdentity
|
|
{
|
|
CodeClient = "BATIK.01",
|
|
DeviceId = "device-1",
|
|
UserId = "user-1",
|
|
CollaborateurId = Guid.NewGuid().ToString(),
|
|
NumeroLicence = "lic-1"
|
|
});
|
|
}
|
|
|
|
private static DiagnosticBatchDto ValidBatch(string component = "test") =>
|
|
new()
|
|
{
|
|
BatchId = Guid.NewGuid(),
|
|
Events =
|
|
[
|
|
new DiagnosticEventDto
|
|
{
|
|
EventId = Guid.NewGuid(),
|
|
EventCode = "OMK-MOB-APP-001",
|
|
TimestampUtc = DateTimeOffset.UtcNow,
|
|
Level = "Info",
|
|
Priority = "P3",
|
|
TraceId = Guid.NewGuid(),
|
|
SessionId = Guid.NewGuid(),
|
|
Component = component
|
|
}
|
|
]
|
|
};
|
|
|
|
[Fact]
|
|
public async Task WithoutToken_ReturnsUnauthorized()
|
|
{
|
|
using HttpClient client = _factory.CreateClient();
|
|
|
|
HttpResponseMessage response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch());
|
|
|
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidBatch_IsPersisted_AndAcknowledged()
|
|
{
|
|
using HttpClient client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", NewToken());
|
|
|
|
DiagnosticBatchDto batch = ValidBatch("télémétrie");
|
|
HttpResponseMessage response = await client.PostAsJsonAsync("/api/diagnostics", batch);
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
DiagnosticBatchResponseDto? body = await response.Content.ReadFromJsonAsync<DiagnosticBatchResponseDto>();
|
|
Assert.Equal(batch.BatchId, body!.BatchId);
|
|
Assert.Equal(1, body.AcceptedCount);
|
|
|
|
await WaitUntilAsync(() => Directory.EnumerateFiles(_remoteDir, "*.jsonl")
|
|
.Any(f => ReadAllTextShared(f).Contains("\"eventId\"", StringComparison.Ordinal)));
|
|
|
|
using JsonDocument document = JsonDocument.Parse(ReadFirstLine(_remoteDir));
|
|
JsonElement evt = document.RootElement.GetProperty("event");
|
|
Assert.Equal(batch.Events[0].EventId.ToString(), evt.GetProperty("eventId").GetString());
|
|
Assert.Equal(batch.Events[0].TraceId!.Value.ToString(), evt.GetProperty("traceId").GetString());
|
|
Assert.Equal(batch.Events[0].SessionId!.Value.ToString(), evt.GetProperty("sessionId").GetString());
|
|
Assert.Equal("télémétrie", evt.GetProperty("component").GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ForbiddenMetadata_ReturnsBadRequest_NothingPersisted()
|
|
{
|
|
using HttpClient client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", NewToken());
|
|
|
|
string json = """
|
|
{
|
|
"schemaVersion": 1,
|
|
"batchId": "00000000-0000-0000-0000-000000000001",
|
|
"events": [{
|
|
"eventId": "00000000-0000-0000-0000-000000000002",
|
|
"eventCode": "OMK-MOB-APP-001",
|
|
"level": "Info",
|
|
"priority": "P3",
|
|
"metadata": { "password": "secret" }
|
|
}]
|
|
}
|
|
""";
|
|
HttpResponseMessage response = await client.PostAsync(
|
|
"/api/diagnostics",
|
|
new StringContent(json, Encoding.UTF8, "application/json"));
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
Assert.Empty(Directory.EnumerateFiles(_remoteDir, "*.jsonl"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnknownField_IsNotPersisted()
|
|
{
|
|
using HttpClient client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", NewToken());
|
|
|
|
// Le champ "password" n'est pas dans le DTO : il est ignoré par la
|
|
// désérialisation typée et ne doit jamais apparaître dans le fichier serveur.
|
|
string json = """
|
|
{
|
|
"schemaVersion": 1,
|
|
"batchId": "00000000-0000-0000-0000-000000000001",
|
|
"password": "secret-password-value",
|
|
"events": [{
|
|
"eventId": "00000000-0000-0000-0000-000000000002",
|
|
"eventCode": "OMK-MOB-APP-001",
|
|
"level": "Info",
|
|
"priority": "P3",
|
|
"component": "ok"
|
|
}]
|
|
}
|
|
""";
|
|
HttpResponseMessage response = await client.PostAsync(
|
|
"/api/diagnostics",
|
|
new StringContent(json, Encoding.UTF8, "application/json"));
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
await WaitUntilAsync(() => Directory.EnumerateFiles(_remoteDir, "*.jsonl")
|
|
.Any(f => ReadAllTextShared(f).Contains("\"component\"", StringComparison.Ordinal)));
|
|
|
|
string content = string.Join("\n", Directory.EnumerateFiles(_remoteDir, "*.jsonl").Select(ReadAllTextShared));
|
|
Assert.DoesNotContain("secret-password-value", content, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("password", content, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StorageFailure_Returns503_DoesNotCrash()
|
|
{
|
|
string blocker = Path.Combine(_localDir, "blocker");
|
|
File.WriteAllText(blocker, "x");
|
|
string inaccessible = Path.Combine(blocker, "sub");
|
|
|
|
using var factory = CreateFactory(_localDir, inaccessible);
|
|
using HttpClient client = factory.CreateClient();
|
|
|
|
string token;
|
|
using (IServiceScope scope = factory.Services.CreateScope())
|
|
{
|
|
token = scope.ServiceProvider.GetRequiredService<ITokenService>()
|
|
.GenerateToken(new MobileTokenIdentity { CodeClient = "BATIK.01", DeviceId = "d", UserId = "u" });
|
|
}
|
|
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
HttpResponseMessage response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch());
|
|
|
|
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DiagnosticsEndpoint_DoesNotEmitHttpEvents_OrLogBody()
|
|
{
|
|
using HttpClient client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", NewToken());
|
|
|
|
HttpResponseMessage response = await client.PostAsJsonAsync("/api/diagnostics", ValidBatch("MARKER-BODY-123"));
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
|
|
await Task.Delay(100);
|
|
|
|
// Le fichier d'ingestion serveur contient la donnée (légitime).
|
|
await WaitUntilAsync(() => Directory.EnumerateFiles(_remoteDir, "*.jsonl")
|
|
.Any(f => ReadAllTextShared(f).Contains("MARKER-BODY-123", StringComparison.Ordinal)));
|
|
|
|
// Le journal local de l'API ne doit pas contenir le corps ni un événement
|
|
// HTTP_REQUEST_* pour /api/diagnostics (anti-boucle, sans body).
|
|
string local = string.Join("\n", Directory.EnumerateFiles(_localDir, "*.jsonl").Select(ReadAllTextShared));
|
|
Assert.DoesNotContain("MARKER-BODY-123", local, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("/api/diagnostics", local, StringComparison.Ordinal);
|
|
}
|
|
|
|
// ===== Helpers =====
|
|
|
|
private static WebApplicationFactory<Program> CreateFactory(
|
|
string localDir,
|
|
string remoteDir,
|
|
Action<IServiceCollection>? configureServices = null)
|
|
{
|
|
var 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>();
|
|
|
|
services.RemoveAll<ILicenseService>();
|
|
services.AddScoped<ILicenseService>(_ => new FakeLicenseService());
|
|
|
|
services.RemoveAll<IMobileBootstrapService>();
|
|
services.AddScoped<IMobileBootstrapService>(_ => new FakeBootstrapService());
|
|
|
|
services.Configure<LocalDiagnosticOptions>(options => options.Directory = localDir);
|
|
services.Configure<RemoteDiagnosticOptions>(options => options.Directory = remoteDir);
|
|
|
|
configureServices?.Invoke(services);
|
|
});
|
|
});
|
|
|
|
return factory;
|
|
}
|
|
|
|
private static string ReadFirstLine(string dir)
|
|
{
|
|
string file = Assert.Single(Directory.EnumerateFiles(dir, "*.jsonl"));
|
|
string content = ReadAllTextShared(file);
|
|
int newline = content.IndexOf('\n');
|
|
return (newline >= 0 ? content[..newline] : content).TrimEnd('\r');
|
|
}
|
|
|
|
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 string CreateTempDirectory()
|
|
{
|
|
string dir = Path.Combine(Path.GetTempPath(), "omika-diag-ingestion-tests", Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(dir);
|
|
return dir;
|
|
}
|
|
|
|
private static void DeleteDirectory(string dir)
|
|
{
|
|
try
|
|
{
|
|
if (Directory.Exists(dir))
|
|
{
|
|
Directory.Delete(dir, recursive: true);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Best-effort.
|
|
}
|
|
}
|
|
|
|
private static async Task WaitUntilAsync(Func<bool> condition, int timeoutMs = 3000)
|
|
{
|
|
DateTimeOffset deadline = DateTimeOffset.UtcNow.AddMilliseconds(timeoutMs);
|
|
while (!condition() && DateTimeOffset.UtcNow < deadline)
|
|
{
|
|
await Task.Delay(10);
|
|
}
|
|
|
|
Assert.True(condition(), "La condition n'a pas été atteinte dans le délai imparti.");
|
|
}
|
|
|
|
// ===== Fakes =====
|
|
|
|
private sealed class FakeLicenseService : ILicenseService
|
|
{
|
|
public Task<LicenseResponse> ActivateAsync(LicenseActivateRequest request, CancellationToken cancellationToken = default)
|
|
=> Task.FromResult(new LicenseResponse());
|
|
|
|
public Task<LicenseResponse> ValidateAsync(LicenseValidateRequest request, CancellationToken cancellationToken = default)
|
|
=> Task.FromResult(new LicenseResponse());
|
|
|
|
public Task<CollaborateurLicenceDto?> GetMobileLicenceForCollaborateurAsync(string collaborateurId, string codeClient, CancellationToken cancellationToken = default)
|
|
=> Task.FromResult<CollaborateurLicenceDto?>(null);
|
|
|
|
public Task<CollaborateurLicenceDto?> AssignMobileLicenceAsync(string collaborateurId, string codeClient, CancellationToken cancellationToken = default)
|
|
=> Task.FromResult<CollaborateurLicenceDto?>(null);
|
|
|
|
public Task<bool> ReleaseMobileLicenceAsync(string collaborateurId, CancellationToken cancellationToken = default)
|
|
=> Task.FromResult(true);
|
|
}
|
|
|
|
private sealed class FakeBootstrapService : IMobileBootstrapService
|
|
{
|
|
public Task<MobileBootstrapResponse> GetBootstrapAsync(MobileTokenIdentity identity, CancellationToken cancellationToken = default)
|
|
=> Task.FromResult(new MobileBootstrapResponse());
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|