using Deal.Grpc.Ai; using Deal.Ai; using Microsoft.AspNetCore.Builder; using Grpc.Core; using Grpc.Health.V1; using Grpc.Net.Client; using System.Net; using System.Net.Sockets; using Xunit; namespace Deal.Ai.Tests; /// /// Интеграционные тесты хоста ai-service (каркас Task 4 + логика Task 8). /// /// Хост поднимается В процессе теста (Kestrel HTTP/2, эфемерный порт) через AiServiceHost.Create — /// ту же сборку хоста, что использует Program.cs, поэтому тесты покрывают реальную настройку /// Kestrel/AddGrpc/health, а не её копию. Проверки: gRPC-health → SERVING; ServiceTokenInterceptor /// (Ruling 1): запрос без токена и с неверным токеном → UNAUTHENTICATED; верный токен проходит к /// методу (реализация Task 8: пустой запрос без конфига провайдера → INVALID_ARGUMENT); при /// незаданном DEAL_SERVICE_TOKEN — fail-closed. /// /// Токен интерцептор читает из конфигурации (env DEAL_SERVICE_TOKEN) — тесты выставляют env на время /// сценария и восстанавливают исходное значение. Все тесты класса живут в одном процессе/классе, /// чтобы env и свободные порты не конфликтовали (xunit исполняет методы класса последовательно). /// public sealed class AiServiceHostTests { // Env-ключ ожидаемого токена (зеркало ServiceTokenInterceptor). private const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; // Ключ gRPC-metadata (зеркало ServiceTokenInterceptor.ServiceTokenMetadataKey). private const string ServiceTokenMetadataKey = "service-token"; // Токен сценариев теста. private const string ValidToken = "task4-test-token"; // Id тенанта запросов (доходит до метода при верном токене; Ruling 1). private const string TenantId = "tenant-test"; // Deadline RPC-вызовов теста (сек). private const int RpcDeadlineSeconds = 10; /// /// HealthCheck (grpc.health.v1.Health/Check) отвечает SERVING — хост поднялся, gRPC-инфраструктура /// и health-сервис работают (Ruling 12; health освобождён от service-token). /// [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 (Ruling 1). /// [Fact] public async Task Classify_WithoutToken_IsUnauthenticated() { await AssertDealRpcRejectedAsync( ValidToken, tokenHeader: null, expected: StatusCode.Unauthenticated); } /// /// Запрос с неверным токеном → UNAUTHENTICATED (Ruling 1). /// [Fact] public async Task Classify_WithWrongToken_IsUnauthenticated() { await AssertDealRpcRejectedAsync( ValidToken, tokenHeader: "wrong-token", expected: StatusCode.Unauthenticated); } /// /// Верный токен проходит интерцептор к методу — кодогенерация и маппинг сервиса работают: /// пустой запрос (без конфига провайдера) доходит до реализации Classify (Task 8) и отклоняется /// валидацией INVALID_ARGUMENT, а не UNIMPLEMENTED. /// [Fact] public async Task Classify_WithValidToken_ReachesServiceAndValidatesConfig() { await AssertDealRpcRejectedAsync( ValidToken, tokenHeader: ValidToken, expected: StatusCode.InvalidArgument); } /// /// Fail-closed (шаблон Task 3): DEAL_SERVICE_TOKEN не задан — Deal-RPC отклоняется всегда, /// в т.ч. запрос с «пустым» значением metadata (без гарда сравнение «» == «» пропустило бы его); /// health при этом продолжает отвечать SERVING (инфраструктурный liveness не ломается). /// [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); }); } // Прогоняет Classify с заданным заголовком «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)); } // Вызывает Classify и проверяет, что сервер ответил ожидаемым кодом статуса. // Запрос несёт полный metadata (service-token + tenant-id), чтобы верный токен доходил // до реализации метода (заглушки больше нет — Task 8), а не падал на tenant-проверке. // channel: Канал к хосту ai-service. // tokenHeader: Значение metadata «service-token» либо null (нет заголовка). // expected: Ожидаемый StatusCode. private static async Task AssertRejectedAsync( GrpcChannel channel, string? tokenHeader, StatusCode expected) { var client = new AiService.AiServiceClient(channel); Metadata metadata = new() { { "tenant-id", TenantId } }; if (tokenHeader is not null) { metadata.Add(ServiceTokenMetadataKey, tokenHeader); } var callOptions = new CallOptions( metadata, deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds)); AsyncUnaryCall call = client.ClassifyAsync(new ClassifyRequest(), 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); Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken); WebApplication? app = null; GrpcChannel? channel = null; try { int port = FreeTcpPort(); app = AiServiceHost.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); } } // Возвращает свободный 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; } }