ci / build-test (push) Canceled after 0s
SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/ Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue, контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер), Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог). Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0, тесты 1340/130/52/38/9 зелёные.
231 lines
9.4 KiB
C#
231 lines
9.4 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// Интеграционные тесты хоста ml-service.
|
||
/// </summary>
|
||
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;
|
||
|
||
/// <summary>
|
||
/// HealthCheck (grpc.health.v1.Health/Check) отвечает SERVING — хост поднялся, gRPC-инфраструктура и health-сервис работают.
|
||
/// </summary>
|
||
[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);
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Запрос без metadata «service-token» → UNAUTHENTICATED.
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task Status_WithoutToken_IsUnauthenticated()
|
||
{
|
||
await AssertDealRpcRejectedAsync(
|
||
ValidToken,
|
||
tokenHeader: null,
|
||
expected: StatusCode.Unauthenticated);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Запрос с неверным токеном → UNAUTHENTICATED.
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task Status_WithWrongToken_IsUnauthenticated()
|
||
{
|
||
await AssertDealRpcRejectedAsync(
|
||
ValidToken,
|
||
tokenHeader: "wrong-token",
|
||
expected: StatusCode.Unauthenticated);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Верный токен проходит интерцептор к методу Status
|
||
/// </summary>
|
||
[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);
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Fail-closed: DEAL_SERVICE_TOKEN не задан — Deal-RPC отклоняется всегда, в т.ч.
|
||
/// </summary>
|
||
[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<StatusReply> call = client.StatusAsync(new StatusRequest(), callOptions);
|
||
RpcException exception = await Assert.ThrowsAsync<RpcException>(() => call.ResponseAsync);
|
||
|
||
Assert.Equal(expected, exception.StatusCode);
|
||
}
|
||
|
||
// Поднимает хост на эфемерном порту с заданным env-токеном, выполняет сценарий и гарантированно
|
||
// гасит хост/канал и восстанавливает исходный env.
|
||
// serviceToken: Значение env DEAL_SERVICE_TOKEN для сценария (null — убрать).
|
||
// scenario: Сценарий с каналом к поднятому хосту.
|
||
private static async Task RunHostScenarioAsync(string? serviceToken, Func<GrpcChannel, Task> 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;
|
||
}
|
||
}
|