Premiere commit - Omika
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user