using System.Net; using System.Net.Sockets; using Deal.Grpc.Ai; using Grpc.Core; using Grpc.Health.V1; using Grpc.Net.Client; using Microsoft.AspNetCore.Builder; namespace Deal.Ai.Tests.Grpc; /// /// Интеграционные тесты хоста ai-service. /// 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"; private const string TenantId = "tenant-test"; // 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 Classify_WithoutToken_IsUnauthenticated() { await AssertDealRpcRejectedAsync( ValidToken, tokenHeader: null, expected: StatusCode.Unauthenticated); } /// /// Запрос с неверным токеном → UNAUTHENTICATED. /// [Fact] public async Task Classify_WithWrongToken_IsUnauthenticated() { await AssertDealRpcRejectedAsync( ValidToken, tokenHeader: "wrong-token", expected: StatusCode.Unauthenticated); } /// /// Верный токен проходит интерцептор к методу — кодогенерация и маппинг сервиса работают /// [Fact] public async Task Classify_WithValidToken_ReachesServiceAndValidatesConfig() { await AssertDealRpcRejectedAsync( ValidToken, tokenHeader: ValidToken, expected: StatusCode.InvalidArgument); } /// /// 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); }); } // Прогоняет 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)); } 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; } }