using System.Net; using System.Net.Sockets; using Deal.Grpc.Ml; using Grpc.Core; using Grpc.Health.V1; using Grpc.Net.Client; using Microsoft.AspNetCore.Builder; namespace Deal.Ml.Tests.Grpc; /// /// Интеграционные тесты хоста ml-service. /// public sealed class MlServiceHostTests { // Env-ключ ожидаемого токена (зеркало ServiceTokenInterceptor). private const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; // Env-ключ каталога моделей (зеркало MlOptions; Status создаёт модель тенанта). private const string DataDirEnvKey = "DEAL_ML_DATA_DIR"; // Id тенанта сценариев теста (Status реализован задачами 5–6). private const string TestTenantId = "tenant-test"; // Ключ gRPC-metadata с tenant-id (зеркало MlServiceImpl.TenantIdMetadataKey). private const string TenantIdMetadataKey = "tenant-id"; // Ключ gRPC-metadata (зеркало ServiceTokenInterceptor.ServiceTokenMetadataKey). private const string ServiceTokenMetadataKey = "service-token"; // Токен сценариев теста. private const string ValidToken = "task3-test-token"; // Deadline RPC-вызовов теста (сек). private const int RpcDeadlineSeconds = 10; /// /// HealthCheck (grpc.health.v1.Health/Check) отвечает SERVING — хост поднялся, gRPC-инфраструктура и health-сервис работают. /// [Fact] public async Task HealthCheck_ReturnsServing() { await RunHostScenarioAsync( ValidToken, async channel => { var health = new Health.HealthClient(channel); HealthCheckResponse response = await health.CheckAsync( new HealthCheckRequest(), deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds)); Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, response.Status); }); } /// /// Запрос без metadata «service-token» → UNAUTHENTICATED. /// [Fact] public async Task Status_WithoutToken_IsUnauthenticated() { await AssertDealRpcRejectedAsync( ValidToken, tokenHeader: null, expected: StatusCode.Unauthenticated); } /// /// Запрос с неверным токеном → UNAUTHENTICATED. /// [Fact] public async Task Status_WithWrongToken_IsUnauthenticated() { await AssertDealRpcRejectedAsync( ValidToken, tokenHeader: "wrong-token", expected: StatusCode.Unauthenticated); } /// /// Верный токен проходит интерцептор к методу Status /// [Fact] public async Task Status_WithValidToken_ReturnsEmptyStatus() { await RunHostScenarioAsync( ValidToken, async channel => { var client = new MlService.MlServiceClient(channel); Metadata metadata = new(); metadata.Add(ServiceTokenMetadataKey, ValidToken); metadata.Add(TenantIdMetadataKey, TestTenantId); StatusReply response = await client.StatusAsync( new StatusRequest(), new CallOptions(metadata, deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds))); Assert.False(response.Ready); Assert.Empty(response.Classes); Assert.Equal(0, response.Learned); }); } /// /// Fail-closed: DEAL_SERVICE_TOKEN не задан — Deal-RPC отклоняется всегда, в т.ч. /// [Fact] public async Task WithoutConfiguredToken_DealRpcFailsClosed_HealthStillServing() { await RunHostScenarioAsync( serviceToken: null, async channel => { var health = new Health.HealthClient(channel); HealthCheckResponse healthResponse = await health.CheckAsync( new HealthCheckRequest(), deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds)); Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, healthResponse.Status); // Пустое значение metadata — попытка обойти fail-closed (равно незаданному env-токену). await AssertRejectedAsync(channel, string.Empty, StatusCode.Unauthenticated); await AssertRejectedAsync(channel, ValidToken, StatusCode.Unauthenticated); }); } // Прогоняет Status с заданным заголовком «service-token» (null — без заголовка) и проверяет // ожидаемый код статуса RPC-исключения. // serviceToken: Ожидаемый токен хоста (env DEAL_SERVICE_TOKEN). // tokenHeader: Значение metadata «service-token» запроса либо null (нет заголовка). // expected: Ожидаемый StatusCode ответа. private static async Task AssertDealRpcRejectedAsync( string? serviceToken, string? tokenHeader, StatusCode expected) { await RunHostScenarioAsync( serviceToken, channel => AssertRejectedAsync(channel, tokenHeader, expected)); } // Вызывает Status и проверяет, что сервер ответил ожидаемым кодом статуса. // channel: Канал к хосту ml-service. // tokenHeader: Значение metadata «service-token» либо null (нет заголовка). // expected: Ожидаемый StatusCode. private static async Task AssertRejectedAsync( GrpcChannel channel, string? tokenHeader, StatusCode expected) { var client = new MlService.MlServiceClient(channel); Metadata metadata = new(); if (tokenHeader is not null) { metadata.Add(ServiceTokenMetadataKey, tokenHeader); } var callOptions = new CallOptions( metadata, deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds)); AsyncUnaryCall call = client.StatusAsync(new StatusRequest(), callOptions); RpcException exception = await Assert.ThrowsAsync(() => call.ResponseAsync); Assert.Equal(expected, exception.StatusCode); } // Поднимает хост на эфемерном порту с заданным env-токеном, выполняет сценарий и гарантированно // гасит хост/канал и восстанавливает исходный env. // serviceToken: Значение env DEAL_SERVICE_TOKEN для сценария (null — убрать). // scenario: Сценарий с каналом к поднятому хосту. private static async Task RunHostScenarioAsync(string? serviceToken, Func scenario) { string? originalToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey); string? originalDataDir = Environment.GetEnvironmentVariable(DataDirEnvKey); // Status реализован (задачи 5–6): модель тенанта создаёт SQLite-файл — каталог теста // направляем во временную папку, чтобы файлы не попали в data/ml репозитория. string dataDir = Path.Combine(Path.GetTempPath(), "deal-ml-host-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dataDir); Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken); Environment.SetEnvironmentVariable(DataDirEnvKey, dataDir); WebApplication? app = null; GrpcChannel? channel = null; try { int port = FreeTcpPort(); app = MlServiceHost.Create(port); await app.StartAsync(); channel = GrpcChannel.ForAddress($"http://127.0.0.1:{port}"); await scenario(channel); } finally { if (channel is not null) { channel.Dispose(); } if (app is not null) { await app.StopAsync(); await app.DisposeAsync(); } Environment.SetEnvironmentVariable(ServiceTokenEnvKey, originalToken); Environment.SetEnvironmentVariable(DataDirEnvKey, originalDataDir); try { Directory.Delete(dataDir, recursive: true); } catch (IOException) { // Каталог мог быть занят на момент удаления — тестовый мусор в temp допустим. } } } // Возвращает свободный TCP-порт (127.0.0.1:0 → освобождение перед биндом хоста). private static int FreeTcpPort() { using var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); return ((IPEndPoint)listener.LocalEndpoint).Port; } }