diff --git a/src/core/Deal.Api/Endpoints/AiCheckEndpoint.cs b/src/core/Deal.Api/Endpoints/AiCheckEndpoint.cs deleted file mode 100644 index c459027..0000000 --- a/src/core/Deal.Api/Endpoints/AiCheckEndpoint.cs +++ /dev/null @@ -1,153 +0,0 @@ -using System.Text.Json; -using Deal.Api.Extensions; -using Deal.Api.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; - -namespace Deal.Api.Endpoints; - -/// -/// Эндпоинт проверки подключения AI-провайдера -/// -public static class AiCheckEndpoint -{ - private const string ApiGroupPrefix = "/api"; - - // Путь проверки подключения AI-провайдера. - private const string AiCheckPath = "/ai/check"; - - private const string OpenApiTag = "settings"; - - /// - /// Регистрирует POST /api/ai/check. - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - public static IEndpointRouteBuilder MapAiCheckEndpoint(this IEndpointRouteBuilder app) - { - var group = app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag); - group.MapPost(AiCheckPath, CheckAsync); - return app; - } - - // POST /api/ai/check: проверка соединения с активным AI-провайдером тенанта. - private static async Task CheckAsync(HttpContext context, CancellationToken ct) - { - if (context.GetCurrentUser() is null) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - // Резолв после 401-гейта: ISettingsStore — scoped на TenantDbContext (tenant-контекст запроса). - ISettingsStore store = context.RequestServices.GetRequiredService(); - ISecretCipher secretCipher = context.RequestServices.GetRequiredService(); - IAiConnectionChecker checker = context.RequestServices.GetRequiredService(); - - AiCheckRequest checkRequest = await BuildActiveCheckRequestAsync(store, secretCipher, ct); - AiCheckResultDto result = await checker.CheckAsync(checkRequest, ct); - return Results.Ok(result); - } - - private static async Task BuildActiveCheckRequestAsync( - ISettingsStore store, - ISecretCipher secretCipher, - CancellationToken ct) - { - string providerId = await ReadActiveProviderIdAsync(store, ct); - AiProviderDefinition? meta = AiProviders.All.FirstOrDefault(provider => provider.Id == providerId); - - // Неизвестный id (ручное вмешательство в БД — PATCH-гейт SettingsService не даёт сохранить): - // HTTP не выполняется — ответит SSRF-гейт checker (allowlist). - if (meta is null) - { - return new AiCheckRequest(providerId, string.Empty, string.Empty, string.Empty, IsLocal: false, ApiStyle: null); - } - - AiConfigSetting config = await ReadEffectiveConfigAsync(store, providerId, ct); - string apiKey = secretCipher.Decrypt(config.ApiKey); - string baseUrl = string.IsNullOrEmpty(config.BaseUrl) ? meta.Base : config.BaseUrl; - string model = string.IsNullOrEmpty(config.Model) - ? meta.Models.FirstOrDefault() ?? string.Empty - : config.Model; - - return new AiCheckRequest(providerId, baseUrl, model, apiKey, meta.Local, meta.ApiStyle); - } - - // Читает активный провайдер: сохранённый aiProvider или дефолт (повреждённое значение — дефолт). - // store: KV-хранилище настроек тенанта. - // ct: Токен отмены. - // Возвращает: id провайдера. - private static async Task ReadActiveProviderIdAsync(ISettingsStore store, CancellationToken ct) - { - SettingValue? row = await store.GetAsync(SettingsKeys.AiProvider, ct); - if (row is null) - { - return SettingsDefaults.AiProvider; - } - - try - { - using JsonDocument document = JsonDocument.Parse(row.ValueJson); - if (document.RootElement.ValueKind == JsonValueKind.String) - { - return document.RootElement.GetString() ?? SettingsDefaults.AiProvider; - } - } - catch (JsonException) - { - // Повреждённая строка — дефолт (мягкая семантика, как в SettingsService). - } - - return SettingsDefaults.AiProvider; - } - - // Эффективный конфиг провайдера: дефолт SettingsDefaults, перекрытый сохранённым aiConfigs. - // store: KV-хранилище настроек тенанта. - // providerId: Активный провайдер (id из каталога). - // ct: Токен отмены. - // Возвращает: Конфиг {apiKey, baseUrl, model}; повреждённая строка aiConfigs — дефолт. - private static async Task ReadEffectiveConfigAsync( - ISettingsStore store, - string providerId, - CancellationToken ct) - { - AiConfigSetting defaults = SettingsDefaults.AiConfigs[providerId]; - SettingValue? row = await store.GetAsync(SettingsKeys.AiConfigs, ct); - if (row is null) - { - return defaults; - } - - try - { - using JsonDocument document = JsonDocument.Parse(row.ValueJson); - JsonElement root = document.RootElement; - if (root.ValueKind == JsonValueKind.Object - && root.TryGetProperty(providerId, out JsonElement entry) - && entry.ValueKind == JsonValueKind.Object) - { - return new AiConfigSetting( - ApiKey: ReadField(entry, SettingsFieldKeys.ApiKey) ?? defaults.ApiKey, - BaseUrl: ReadField(entry, SettingsFieldKeys.BaseUrl) ?? defaults.BaseUrl, - Model: ReadField(entry, SettingsFieldKeys.Model) ?? defaults.Model); - } - } - catch (JsonException) - { - // Повреждённая строка — дефолт (не роняем проверку). - } - - return defaults; - } - - // Читает строковое поле объекта конфигурации (имена полей camelCase, как пишет SettingsService). - // entry: JSON-объект конфигурации провайдера. - // field: Имя поля (apiKey/baseUrl/model). - // Возвращает: Значение или null, если поле отсутствует/не строка. - private static string? ReadField(JsonElement entry, string field) - { - return entry.TryGetProperty(field, out JsonElement value) && value.ValueKind == JsonValueKind.String - ? value.GetString() - : null; - } -} diff --git a/src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs index 8a324f4..7d19481 100644 --- a/src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs @@ -2,6 +2,9 @@ using Deal.Api.Endpoints.RequestModels; using Deal.Api.Extensions; using Deal.Api.Services; using Deal.Api.Telegram; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Services; using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Services; @@ -21,6 +24,9 @@ public static class OperatorSettingsEndpoints // Относительный путь глобальных ключей Telegram (GET/PUT). private const string TelegramKeysPath = "/telegram-keys"; + // Относительный путь глобальной конфигурации ИИ (GET/PUT/POST-check). + private const string AiConfigPath = "/ai-config"; + // Текст 400: пустое тело PUT (ни одного поля). private const string EmptyBodyDetail = "Укажите api_id и api_hash"; @@ -33,6 +39,24 @@ public static class OperatorSettingsEndpoints // Текст 400: api_hash пустой/маска/с префиксом enc:. private const string InvalidApiHashDetail = "Укажите непустой api_hash"; + // Текст 400: пустое тело PUT конфига ИИ (ни одного поля). + private const string EmptyAiConfigBodyDetail = "Укажите хотя бы одно поле (providerId, baseUrl, model, apiKey)"; + + // Текст 400: провайдер не из каталога AiProviders. + private const string InvalidProviderDetail = "Провайдер не из списка разрешённых"; + + // Текст 400: api-ключ короче 8 символов/маска/с префиксом enc:. + private const string InvalidAiKeyDetail = "API-ключ должен быть не короче 8 символов, без маски"; + + // Текст 400: конфигурации ИИ ещё нет, а провайдер в PUT не передан. + private const string MissingProviderDetail = "Конфигурация ИИ ещё не задана — укажите providerId"; + + // Текст 400: custom-провайдер без модели (в каталоге нет моделей-дефолтов). + private const string MissingAiModelDetail = "Укажите model — у выбранного провайдера нет моделей по умолчанию"; + + // Текст 400: проверка связи при незаданной конфигурации ИИ. + private const string AiConfigNotSetDetail = "Сначала сохраните конфигурацию ИИ"; + /// /// Регистрирует группу /api/operator/settings /// @@ -43,6 +67,9 @@ public static class OperatorSettingsEndpoints var group = app.MapGroup(SettingsGroupPrefix).WithTags(SettingsOpenApiTag); group.MapGet(TelegramKeysPath, GetTelegramKeysAsync); group.MapPut(TelegramKeysPath, PutTelegramKeysAsync); + group.MapGet(AiConfigPath, GetAiConfigAsync); + group.MapPut(AiConfigPath, PutAiConfigAsync); + group.MapPost(AiConfigPath + "/check", CheckAiConfigAsync); return app; } @@ -135,6 +162,152 @@ public static class OperatorSettingsEndpoints return Results.Ok(snapshot); } + // GET /api/operator/settings/ai-config: маскированная глобальная конфигурация ИИ. + // context: Контекст запроса. + // config: Сервис глобальной конфигурации ИИ (scoped). + // ct: Токен отмены. + // Возвращает: 200 маскированный снимок или 401 без операторской сессии. + private static async Task GetAiConfigAsync( + HttpContext context, + AiGlobalConfigService config, + CancellationToken ct) + { + if (context.GetCurrentOperator() is null) + { + return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); + } + + AiGlobalConfigMaskedDto snapshot = await config.GetMaskedAsync(ct); + return Results.Ok(snapshot); + } + + // PUT /api/operator/settings/ai-config: частичное сохранение глобальной конфигурации ИИ. + // Поля передаются по отдельности: непереданное (null) сохраняет текущее значение. Если + // конфигурации ещё нет, providerId обязателен. Ключ передаётся только при смене (маска не + // принимается). Смена провайдера сохраняет ключ, только если передан явно. + // body: Тело {providerId?, baseUrl?, model?, apiKey?} (хотя бы одно поле). + // context: Контекст запроса. + // config: Сервис глобальной конфигурации ИИ (scoped). + // auditService: Сервис аудита (событие ai_config_changed). + // ct: Токен отмены. + // Возвращает: 200 маскированный снимок, 400 при невалидных/недостающих полях или 401 без операторской сессии. + private static async Task PutAiConfigAsync( + OperatorAiConfigRequest? body, + HttpContext context, + AiGlobalConfigService config, + AuditService auditService, + CancellationToken ct) + { + var operatorIdentity = context.GetCurrentOperator(); + if (operatorIdentity is null) + { + return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); + } + + if (body is null) + { + return EndpointResults.BadRequest(EmptyAiConfigBodyDetail); + } + + // null — поле не передано (сохраняем текущее); явное значение валидируется ниже. + string? providerId = body.ProviderId?.Trim(); + string? baseUrl = body.BaseUrl?.Trim(); + string? model = body.Model?.Trim(); + string? apiKey = body.ApiKey?.Trim(); + if (providerId is null && baseUrl is null && model is null && apiKey is null) + { + return EndpointResults.BadRequest(EmptyAiConfigBodyDetail); + } + + if (providerId is not null + && AiProviders.All.All(provider => provider.Id != providerId)) + { + return EndpointResults.BadRequest(InvalidProviderDetail); + } + + if (apiKey is not null && apiKey.Length > 0 && !AiGlobalConfigService.IsValidApiKey(apiKey)) + { + return EndpointResults.BadRequest(InvalidAiKeyDetail); + } + + // Частичное обновление: недостающие поля берём из текущей конфигурации. При смене + // провайдера поля не переносятся от старого (дефолты каталога), ключ — только если + // передан явно. + AiGlobalConfigSnapshot current = await config.GetSnapshotAsync(ct); + string effectiveProviderId = providerId ?? current.ProviderId; + if (effectiveProviderId.Length == 0) + { + return EndpointResults.BadRequest(MissingProviderDetail); + } + + bool providerChanged = providerId is not null && providerId != current.ProviderId; + string effectiveBaseUrl = baseUrl ?? (providerChanged ? string.Empty : current.BaseUrl); + string effectiveModel = model ?? (providerChanged ? string.Empty : current.Model); + string effectiveApiKey = apiKey ?? (providerChanged ? string.Empty : current.ApiKey); + + try + { + await config.SaveAsync(effectiveProviderId, effectiveBaseUrl, effectiveModel, effectiveApiKey, ct); + } + catch (ArgumentException) + { + // Остаточный случай валидации: custom-провайдер без модели (провайдер и ключ проверены выше). + return EndpointResults.BadRequest(MissingAiModelDetail); + } + + AiGlobalConfigSnapshot saved = await config.GetSnapshotAsync(ct); + await auditService.AppendAsync(new AuditRecordDto( + AuditEvents.AiConfigChanged, + AuditActorTypes.Operator, + ActorId: operatorIdentity.OperatorId, + TenantId: null, + Ip: ClientIp(context), + DetailJson: AuditService.ToDetailJson( + [ + AuditDetails.Set(AuditFields.ProviderId, saved.ProviderId), + AuditDetails.Set(AuditFields.BaseUrl, saved.BaseUrl), + AuditDetails.Set(AuditFields.Model, saved.Model), + AuditDetails.Set(AuditFields.KeySet, saved.ApiKey.Length > 0), + ])), ct); + + AiGlobalConfigMaskedDto snapshot = await config.GetMaskedAsync(ct); + return Results.Ok(snapshot); + } + + // POST /api/operator/settings/ai-config/check: проверка связи с сохранённым ИИ-провайдером. + // context: Контекст запроса. + // config: Сервис глобальной конфигурации ИИ (scoped). + // checker: Проверка подключения провайдера (HTTP к списку моделей провайдера). + // ct: Токен отмены. + // Возвращает: 200 результат проверки, 400 без сохранённой конфигурации или 401 без операторской сессии. + private static async Task CheckAiConfigAsync( + HttpContext context, + AiGlobalConfigService config, + IAiConnectionChecker checker, + CancellationToken ct) + { + if (context.GetCurrentOperator() is null) + { + return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); + } + + AiGlobalConfigSnapshot snapshot = await config.GetSnapshotAsync(ct); + if (snapshot.ProviderId.Length == 0 || snapshot.Meta is null) + { + return EndpointResults.BadRequest(AiConfigNotSetDetail); + } + + var request = new AiCheckRequest( + snapshot.ProviderId, + snapshot.BaseUrl, + snapshot.Model, + snapshot.ApiKey, + snapshot.Meta.Local, + snapshot.Meta.ApiStyle); + AiCheckResultDto result = await checker.CheckAsync(request, ct); + return Results.Ok(result); + } + // IP-адрес клиента для аудита (без порта; null, если недоступен). // context: Контекст запроса. // Возвращает: Строковое представление IP или null. diff --git a/src/core/Deal.Api/Endpoints/RequestModels/OperatorAiConfigRequest.cs b/src/core/Deal.Api/Endpoints/RequestModels/OperatorAiConfigRequest.cs new file mode 100644 index 0000000..41947a0 --- /dev/null +++ b/src/core/Deal.Api/Endpoints/RequestModels/OperatorAiConfigRequest.cs @@ -0,0 +1,14 @@ +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело PUT /api/operator/settings/ai-config +/// +/// Id провайдера из каталога AiProviders; null — не менялся. +/// Базовый URL API (учитывается для local/custom провайдеров); null — не менялся. +/// Активная модель; null — не менялась (у каталогных провайдеров пустая = первая из каталога). +/// API-ключ открытым текстом (хранится зашифрованным); null — не менялся (маска не принимается). +public sealed record OperatorAiConfigRequest( + string? ProviderId, + string? BaseUrl, + string? Model, + string? ApiKey); diff --git a/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs b/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs index 3270eea..d936b12 100644 --- a/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs @@ -113,7 +113,7 @@ public static class SettingsEndpoints { keyList.Add(key); SettingKind? kind = SettingsKeys.FindPublicKind(key); - if (kind is SettingKind.Dict or SettingKind.MyPrompts or SettingKind.AiConfigs) + if (kind is SettingKind.Dict or SettingKind.MyPrompts) { changes.Add(AuditDetails.Set(key, ChangedMarker)); continue; diff --git a/src/core/Deal.Api/Program.cs b/src/core/Deal.Api/Program.cs index f683551..018127f 100644 --- a/src/core/Deal.Api/Program.cs +++ b/src/core/Deal.Api/Program.cs @@ -25,6 +25,7 @@ using Deal.Modules.Kanban.Application.Registrars; using Deal.Modules.Pipeline.Application.Registrars; using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; using Deal.Modules.Telegram.Application; using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Registrars; @@ -160,6 +161,7 @@ builder.Services.AddDiscoveryModule(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); @@ -356,7 +358,6 @@ app.MapOperatorSettingsEndpoints(); app.MapOperatorMaintenanceEndpoints(); app.MapJoinEndpoint(); app.MapSettingsEndpoints(); -app.MapAiCheckEndpoint(); app.MapRatesEndpoints(); app.MapMlEndpoints(); app.MapFilterTesterEndpoints(); diff --git a/src/core/Deal.Infrastructure/Integrations/Services/AiConnectionChecker.cs b/src/core/Deal.Infrastructure/Integrations/Services/AiConnectionChecker.cs index 5af4c4f..7714f57 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/AiConnectionChecker.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/AiConnectionChecker.cs @@ -83,8 +83,8 @@ public sealed class AiConnectionChecker : IAiConnectionChecker string name = meta?.Name ?? request.ProviderId; // SSRF-гейт (allowlist, preflight): проверка возможна только для провайдера фиксированного - // каталога AiProviders. В штатном потоке недостижимо (PATCH-гейт aiProvider/aiConfigs в - // SettingsService) — защита от ручного изменения БД/повреждённого хранилища. + // каталога AiProviders. В штатном потоке недостижимо (конфигурацию задаёт оператор из + // каталога через OperatorSettingsEndpoints) — защита от ручного изменения БД. if (meta is null) { return BuildResult(request, name, ok: false, message: ProviderNotAllowedMessage); diff --git a/src/core/Deal.Infrastructure/Integrations/Services/AiProviderConfigBuilder.cs b/src/core/Deal.Infrastructure/Integrations/Services/AiProviderConfigBuilder.cs index 0c450e6..827fd9e 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/AiProviderConfigBuilder.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/AiProviderConfigBuilder.cs @@ -1,150 +1,49 @@ -using System.Text.Json.Nodes; using Deal.Grpc.Ai; -using Deal.Modules.Settings.Application.Abstractions; +using Deal.Infrastructure.Integrations.Exceptions; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Services; namespace Deal.Infrastructure.Integrations.Services; /// -/// Собирает конфиг активного ИИ-провайдера для запросов ai-service. +/// Собирает конфиг ИИ-провайдера для запросов ai-service из глобальной конфигурации +/// оператора (общая для всех тенантов; провайдера, модель и ключ задаёт оператор). /// -public sealed class AiProviderConfigBuilder +/// Сервис глобальной конфигурации ИИ (ключ aiConfig). +public sealed class AiProviderConfigBuilder(AiGlobalConfigService configService) { - // Ключ aiConfigs: поле apiKey переопределения провайдера. - private const string ApiKeyField = "apiKey"; - - // Ключ aiConfigs: поле baseUrl переопределения провайдера. - private const string BaseUrlField = "baseUrl"; - - // Ключ aiConfigs: поле model переопределения провайдера. - private const string ModelField = "model"; - - private const string EncryptedPrefix = "enc:"; - - private readonly ISettingsStore _store; - private readonly ISecretCipher _secretCipher; + // Текст AiUnavailableException, когда оператор ещё не сохранил конфигурацию ИИ. + private const string AiNotConfiguredMessage = "ИИ не настроен оператором системы"; /// - /// Создаёт сборщик конфига провайдера. + /// Собирает ProviderConfig для тела запроса ai-service /// - /// KV-хранилище настроек тенанта (aiProvider/aiConfigs). - /// Расшифровка секрета aiConfigs.apiKey. - public AiProviderConfigBuilder(ISettingsStore store, ISecretCipher secretCipher) - { - ArgumentNullException.ThrowIfNull(store); - ArgumentNullException.ThrowIfNull(secretCipher); - _store = store; - _secretCipher = secretCipher; - } - - /// - /// Собирает ProviderConfig активного провайдера для тела запроса ai-service. - /// - /// Конфиг: provider_id/base/model/api_key (расшифрованный)/api_style (см. ai.proto). + /// Конфиг: provider_id/base/model/api_key/api_style (см. ai.proto). + /// Конфигурация ИИ не задана оператором. public async Task BuildAsync(CancellationToken ct) { - string providerId = await ReadProviderIdAsync(ct); - AiProviderDefinition meta = AiProviders.All.FirstOrDefault(provider => provider.Id == providerId) - ?? AiProviders.All[0]; // неизвестный id — дефолтный провайдер (python L28) - - JsonObject? overrides = await ReadAiConfigsOverrideAsync(ct); - JsonObject? raw = overrides?[meta.Id] as JsonObject; - - string apiKey = ReadField(raw, ApiKeyField); - if (apiKey.StartsWith(EncryptedPrefix, StringComparison.Ordinal)) + AiGlobalConfigSnapshot snapshot = await configService.GetSnapshotAsync(ct); + if (!snapshot.Configured) { - apiKey = _secretCipher.Decrypt(apiKey); - } - - string baseUrl = ReadField(raw, BaseUrlField); - if (baseUrl.Length == 0) - { - baseUrl = meta.Base; // python L90: cfg.baseUrl or meta.base - } - - string model = ReadField(raw, ModelField); - if (model.Length == 0) - { - model = meta.Models.FirstOrDefault() ?? string.Empty; // python L91: cfg.model or models[0] + throw new AiUnavailableException(AiNotConfiguredMessage); } var config = new ProviderConfig { - ProviderId = meta.Id, - BaseUrl = baseUrl, - Model = model, + ProviderId = snapshot.ProviderId, + BaseUrl = snapshot.BaseUrl, + Model = snapshot.Model, }; - if (apiKey.Length > 0) + if (snapshot.ApiKey.Length > 0) { - config.ApiKey = apiKey; + config.ApiKey = snapshot.ApiKey; } - if (meta.ApiStyle is { Length: > 0 } apiStyle) + if (snapshot.Meta?.ApiStyle is { Length: > 0 } apiStyle) { config.ApiStyle = apiStyle; } return config; } - - private async Task ReadProviderIdAsync(CancellationToken ct) - { - SettingValue? row = await _store.GetAsync(SettingsKeys.AiProvider, ct); - if (row is null) - { - return SettingsDefaults.AiProvider; - } - - try - { - JsonNode? value = JsonNode.Parse(row.ValueJson); - if (value is JsonValue scalar && scalar.TryGetValue(out string? providerId) - && !string.IsNullOrWhiteSpace(providerId)) - { - return providerId; - } - } - catch (System.Text.Json.JsonException) - { - // Повреждённая строка — дефолт (мягкая семантика, как в SettingsService). - } - - return SettingsDefaults.AiProvider; - } - - // Переопределение aiConfigs тенанта (JSON-объект «id провайдера → конфиг»); null — дефолты. - // ct: Токен отмены. - // Возвращает: Объект переопределения или null. - private async Task ReadAiConfigsOverrideAsync(CancellationToken ct) - { - SettingValue? row = await _store.GetAsync(SettingsKeys.AiConfigs, ct); - if (row is null) - { - return null; - } - - try - { - return JsonNode.Parse(row.ValueJson) as JsonObject; - } - catch (System.Text.Json.JsonException) - { - // Повреждённая строка — дефолты (мягкая семантика, как в SettingsService). - return null; - } - } - - // Строковое поле конфига провайдера (отсутствие/null/не-строка → пустая строка). - // config: Объект конфига провайдера (может быть null — дефолты). - // field: Имя поля (apiKey/baseUrl/model). - // Возвращает: Значение строкой или пустая строка. - private static string ReadField(JsonObject? config, string field) - { - if (config is null || !config.TryGetPropertyValue(field, out JsonNode? node) || node is not JsonValue value) - { - return string.Empty; - } - - return value.TryGetValue(out string? text) ? text ?? string.Empty : string.Empty; - } } diff --git a/src/core/Deal.Modules.Settings/Application/Models/AiCheckRequest.cs b/src/core/Deal.Modules.Settings/Application/Models/AiCheckRequest.cs index e970229..60dcd5b 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/AiCheckRequest.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/AiCheckRequest.cs @@ -3,9 +3,9 @@ namespace Deal.Modules.Settings.Application.Models; /// /// Данные проверки подключения AI-провайдера. /// -/// Идентификатор провайдера (id из каталога AiProviders, ключ в aiConfigs). -/// Эффективный базовый URL: переопределение aiConfigs или дефолт каталога. -/// Активная модель: переопределение aiConfigs или первая модель каталога. +/// Идентификатор провайдера (id из каталога AiProviders). +/// Эффективный базовый URL провайдера. +/// Активная модель. /// API-ключ открытым текстом (пустая строка — ключ не задан). /// True — локальный сервер (Ollama/LM Studio): ключ не требуется, HTTP не выполняется. /// Стиль API: null — OpenAI-совместимый, "anthropic" — Messages API. diff --git a/src/core/Deal.Modules.Settings/Application/Models/AiCheckResultDto.cs b/src/core/Deal.Modules.Settings/Application/Models/AiCheckResultDto.cs index ede2c56..87a1847 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/AiCheckResultDto.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/AiCheckResultDto.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Settings.Application.Models; /// -/// Результат проверки подключения AI-провайдера — тело POST /api/ai/check. +/// Результат проверки подключения AI-провайдера — тело проверки связи операторской конфигурации ИИ. /// /// True — подключение успешно (включая локальные серверы). /// Сообщение для UI. diff --git a/src/core/Deal.Modules.Settings/Application/Models/AiConfigPublicDto.cs b/src/core/Deal.Modules.Settings/Application/Models/AiConfigPublicDto.cs deleted file mode 100644 index 829a820..0000000 --- a/src/core/Deal.Modules.Settings/Application/Models/AiConfigPublicDto.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Deal.Modules.Settings.Application.Models; - -/// -/// Публичная форма конфигурации AI-провайдера в aiConfigs. -/// -/// Базовый URL API провайдера. -/// Активная модель. -/// True — API-ключ задан (после расшифровки не пустой). -/// Маскированный ключ: пусто, как есть (len ≤ 8) или «1234…5678». -public sealed record AiConfigPublicDto(string BaseUrl, string Model, bool KeySet, string KeyMasked); diff --git a/src/core/Deal.Modules.Settings/Application/Models/AiConfigSetting.cs b/src/core/Deal.Modules.Settings/Application/Models/AiConfigSetting.cs deleted file mode 100644 index 507aded..0000000 --- a/src/core/Deal.Modules.Settings/Application/Models/AiConfigSetting.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Deal.Modules.Settings.Application.Models; - -/// -/// Сохранённая конфигурация AI-провайдера в настройке aiConfigs -/// -/// API-ключ: пустая строка или зашифрованное значение с префиксом enc:. -/// Базовый URL API (переопределение дефолта провайдера). -/// Активная модель. -public sealed record AiConfigSetting(string ApiKey, string BaseUrl, string Model); diff --git a/src/core/Deal.Modules.Settings/Application/Models/AiGlobalConfigMaskedDto.cs b/src/core/Deal.Modules.Settings/Application/Models/AiGlobalConfigMaskedDto.cs new file mode 100644 index 0000000..3bacb40 --- /dev/null +++ b/src/core/Deal.Modules.Settings/Application/Models/AiGlobalConfigMaskedDto.cs @@ -0,0 +1,18 @@ +namespace Deal.Modules.Settings.Application.Models; + +/// +/// Маскированная форма глобальной конфигурации ИИ — тело ответа операторской ручки GET/PUT ai-config. +/// +/// Id провайдера из каталога ; пусто — конфигурация не задана. +/// Базовый URL API. +/// Активная модель. +/// True — API-ключ задан (после расшифровки не пустой). +/// Маскированный ключ: пусто, как есть (len ≤ 8) или «1234…5678». +/// Каталог провайдеров для выбора оператором. +public sealed record AiGlobalConfigMaskedDto( + string ProviderId, + string BaseUrl, + string Model, + bool KeySet, + string KeyMasked, + IReadOnlyList Providers); diff --git a/src/core/Deal.Modules.Settings/Application/Models/AiGlobalConfigSnapshot.cs b/src/core/Deal.Modules.Settings/Application/Models/AiGlobalConfigSnapshot.cs new file mode 100644 index 0000000..d99122c --- /dev/null +++ b/src/core/Deal.Modules.Settings/Application/Models/AiGlobalConfigSnapshot.cs @@ -0,0 +1,29 @@ +namespace Deal.Modules.Settings.Application.Models; + +/// +/// Снимок глобальной конфигурации ИИ-провайдера с расшифрованным ключом (только для внутреннего использования). +/// +/// Id провайдера из каталога ; пусто — конфигурация не задана. +/// Базовый URL API (эффективный: сохранённый или дефолт каталога). +/// Активная модель (эффективная: сохранённая или первая из каталога). +/// API-ключ открытым текстом (пусто — не задан; локальным провайдерам не нужен). +public sealed record AiGlobalConfigSnapshot( + string ProviderId, + string BaseUrl, + string Model, + string ApiKey) +{ + /// + /// Провайдер каталога по или null для неизвестного/незаданного id + /// + public AiProviderDefinition? Meta => AiProviders.All.FirstOrDefault(provider => provider.Id == ProviderId); + + /// + /// Конфигурация готова к вызовам ИИ: провайдер известен, адрес и модель непустые, + /// ключ задан (или не нужен локальному провайдеру) + /// + public bool Configured => Meta is not null + && BaseUrl.Length > 0 + && Model.Length > 0 + && (Meta.Local || ApiKey.Length > 0); +} diff --git a/src/core/Deal.Modules.Settings/Application/Models/AiGlobalConfigValue.cs b/src/core/Deal.Modules.Settings/Application/Models/AiGlobalConfigValue.cs new file mode 100644 index 0000000..d8ea11c --- /dev/null +++ b/src/core/Deal.Modules.Settings/Application/Models/AiGlobalConfigValue.cs @@ -0,0 +1,14 @@ +namespace Deal.Modules.Settings.Application.Models; + +/// +/// Сериализуемая форма значения глобальной конфигурации ИИ (ключ — зашифрованный). +/// +/// Id провайдера. +/// Зашифрованный ключ (префикс enc:) или пусто. +/// Базовый URL. +/// Модель. +public sealed record AiGlobalConfigValue( + string ProviderId, + string ApiKey, + string BaseUrl, + string Model); diff --git a/src/core/Deal.Modules.Settings/Application/Models/AiProviderPublicDto.cs b/src/core/Deal.Modules.Settings/Application/Models/AiProviderPublicDto.cs new file mode 100644 index 0000000..d0e6ba0 --- /dev/null +++ b/src/core/Deal.Modules.Settings/Application/Models/AiProviderPublicDto.cs @@ -0,0 +1,16 @@ +namespace Deal.Modules.Settings.Application.Models; + +/// +/// Провайдер ИИ для выбора оператором +/// +/// Идентификатор провайдера из каталога. +/// Человекочитаемое имя. +/// Базовый URL API по умолчанию. +/// True — локальный сервер (ключ не нужен). +/// Доступные модели каталога; пусто — оператор задаёт модель вручную. +public sealed record AiProviderPublicDto( + string Id, + string Name, + string Base, + bool Local, + IReadOnlyList Models); diff --git a/src/core/Deal.Modules.Settings/Application/Models/GlobalSettingsKeys.cs b/src/core/Deal.Modules.Settings/Application/Models/GlobalSettingsKeys.cs index 3185601..6ce64ed 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/GlobalSettingsKeys.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/GlobalSettingsKeys.cs @@ -6,4 +6,9 @@ namespace Deal.Modules.Settings.Application.Models; public static class GlobalSettingsKeys { public const string TelegramKeys = "telegramKeys"; + + /// + /// Глобальная конфигурация ИИ-провайдера (задаётся оператором, общая для всех тенантов) + /// + public const string AiConfig = "aiConfig"; } diff --git a/src/core/Deal.Modules.Settings/Application/Models/ProviderPublicDto.cs b/src/core/Deal.Modules.Settings/Application/Models/ProviderPublicDto.cs deleted file mode 100644 index a1326cb..0000000 --- a/src/core/Deal.Modules.Settings/Application/Models/ProviderPublicDto.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Deal.Modules.Settings.Application.Models; - -/// -/// Провайдер ИИ в public-снимке настроек. -/// -/// Идентификатор провайдера. -/// Человекочитаемое имя. -/// Базовый URL API. -/// True — локальный сервер (ключ не нужен). -/// Доступные модели. -public sealed record ProviderPublicDto(string Id, string Name, string Base, bool Local, IReadOnlyList Models); diff --git a/src/core/Deal.Modules.Settings/Application/Models/PublicSettingsDto.cs b/src/core/Deal.Modules.Settings/Application/Models/PublicSettingsDto.cs index 435b8ae..39c1181 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/PublicSettingsDto.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/PublicSettingsDto.cs @@ -210,20 +210,4 @@ public sealed record PublicSettingsDto /// public IReadOnlyDictionary ColState { get; init; } = new Dictionary(); - - /// - /// Активный AI-провайдер - /// - public string AiProvider { get; init; } = string.Empty; - - /// - /// Публичные конфигурации AI-провайдеров - /// - public IReadOnlyDictionary AiConfigs { get; init; } = - new Dictionary(); - - /// - /// Статический список AI-провайдеров. - /// - public IReadOnlyList Providers { get; init; } = Array.Empty(); } diff --git a/src/core/Deal.Modules.Settings/Application/Models/SettingKind.cs b/src/core/Deal.Modules.Settings/Application/Models/SettingKind.cs index 24c365c..afa84da 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/SettingKind.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/SettingKind.cs @@ -35,11 +35,6 @@ public enum SettingKind /// MyPrompts, - /// - /// Конфигурации AI-провайдеров с ключами - /// - AiConfigs, - /// /// Внутренний (непубличный) ключ /// diff --git a/src/core/Deal.Modules.Settings/Application/Models/SettingsDefaults.cs b/src/core/Deal.Modules.Settings/Application/Models/SettingsDefaults.cs index d9b7648..d79d13a 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/SettingsDefaults.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/SettingsDefaults.cs @@ -207,18 +207,6 @@ public static class SettingsDefaults /// public static readonly IReadOnlyDictionary ColState = new Dictionary(); - // ── ИИ ── - - /// - /// Дефолт «aiProvider» - /// - public const string AiProvider = "deepseek"; - - /// - /// Дефолт «aiConfigs» - /// - public static readonly IReadOnlyDictionary AiConfigs = BuildDefaultAiConfigs(); - // ── Telegram / Discovery ── /// @@ -255,16 +243,4 @@ public static class SettingsDefaults /// Дефолт «discPaused» /// public const bool DiscPaused = false; - - private static IReadOnlyDictionary BuildDefaultAiConfigs() - { - var result = new Dictionary(AiProviders.All.Count); - foreach (AiProviderDefinition provider in AiProviders.All) - { - string firstModel = provider.Models.FirstOrDefault() ?? string.Empty; - result.Add(provider.Id, new AiConfigSetting(ApiKey: "", BaseUrl: provider.Base, Model: firstModel)); - } - - return result; - } } diff --git a/src/core/Deal.Modules.Settings/Application/Models/SettingsKeys.cs b/src/core/Deal.Modules.Settings/Application/Models/SettingsKeys.cs index 35ca284..ba2a7a2 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/SettingsKeys.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/SettingsKeys.cs @@ -104,8 +104,6 @@ public static class SettingsKeys public const string RateSource = "rateSource"; - public const string AiProvider = "aiProvider"; - public const string AiPrompt = "aiPrompt"; public const string AiFilterPrompt = "aiFilterPrompt"; @@ -147,8 +145,6 @@ public static class SettingsKeys public const string MyPrompts = "myPrompts"; - public const string AiConfigs = "aiConfigs"; - /// /// Каталог публичных ключей /// @@ -181,7 +177,6 @@ public static class SettingsKeys // String [TargetCurrency] = SettingKind.String, [RateSource] = SettingKind.String, - [AiProvider] = SettingKind.String, [AiPrompt] = SettingKind.String, [AiFilterPrompt] = SettingKind.String, [CardPrompt] = SettingKind.String, @@ -202,7 +197,6 @@ public static class SettingsKeys [ColState] = SettingKind.Dict, // special [MyPrompts] = SettingKind.MyPrompts, - [AiConfigs] = SettingKind.AiConfigs, }; /// diff --git a/src/core/Deal.Modules.Settings/Application/Services/AiGlobalConfigService.cs b/src/core/Deal.Modules.Settings/Application/Services/AiGlobalConfigService.cs new file mode 100644 index 0000000..28c029e --- /dev/null +++ b/src/core/Deal.Modules.Settings/Application/Services/AiGlobalConfigService.cs @@ -0,0 +1,226 @@ +using System.Text.Json; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; + +namespace Deal.Modules.Settings.Application.Services; + +/// +/// Читает, расшифровывает и сохраняет глобальную конфигурацию ИИ-провайдера (общую для всех +/// тенантов) из хранилища глобальных настроек. Провайдера, модель и ключ задаёт оператор — +/// пользователи тенантов модели не настраивают. +/// +/// KV-хранилище глобальных настроек оператора (таблица public.global_settings). +/// Шифр секретов (AES-256-GCM, формат enc:). +public sealed class AiGlobalConfigService(IGlobalSettingsStore store, ISecretCipher cipher) +{ + /// + /// Минимальная длина API-ключа провайдера ИИ + /// + public const int ApiKeyMinLength = 8; + + // Символ-заполнитель маски секрета (U+2026, «1234…5678»). + private const string MaskEllipsis = "…"; + + private const string EncryptedPrefix = "enc:"; + + // Id провайдера «Другой (OpenAI-совместимый)» — единственный не-local, чей baseUrl + // задаёт оператор (остальным облачным адрес фиксирован каталогом, SSRF-гейт). + private const string CustomProviderId = "custom"; + + // Ключи JSON значения aiConfig (camelCase, как пишет SaveAsync). + private const string ProviderIdProperty = "providerId"; + private const string ApiKeyProperty = "apiKey"; + private const string BaseUrlProperty = "baseUrl"; + private const string ModelProperty = "model"; + + // Геометрия маски секрета: короткий (≤8) → «x…»; иначе «1234…5678». + private const int MaskShortMaxLength = 8; + private const int MaskShortVisibleChars = 1; + private const int MaskEdgeVisibleChars = 4; + + // Каталог провайдеров для выбора оператором (не зависит от сохранённой конфигурации). + private static readonly IReadOnlyList ProviderCatalog = + AiProviders.All + .Select(provider => new AiProviderPublicDto( + provider.Id, provider.Name, provider.Base, provider.Local, provider.Models)) + .ToList(); + + // Опции JSON значения aiConfig: camelCase (как пишет SaveAsync) + терпимость регистра. + private static readonly JsonSerializerOptions ConfigJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + /// + /// Читает текущую конфигурацию с эффективными значениями + /// + /// Снимок; повреждённая строка или неизвестный провайдер — конфигурация не задана. + public async Task GetSnapshotAsync(CancellationToken ct) + { + SettingValue? row = await store.GetAsync(GlobalSettingsKeys.AiConfig, ct).ConfigureAwait(false); + if (row is null) + { + return EmptySnapshot(); + } + + try + { + using JsonDocument document = JsonDocument.Parse(row.ValueJson); + JsonElement root = document.RootElement; + string providerId = ReadString(root, ProviderIdProperty); + if (providerId.Length == 0) + { + return EmptySnapshot(); + } + + AiProviderDefinition? meta = AiProviders.All.FirstOrDefault(provider => provider.Id == providerId); + if (meta is null) + { + // Неизвестный id (ручное вмешательство в БД) — считаем конфигурацию незаданной. + return EmptySnapshot(); + } + + string baseUrl = ReadString(root, BaseUrlProperty); + if (baseUrl.Length == 0 || !IsBaseUrlMutable(meta)) + { + baseUrl = meta.Base; + } + + string model = ReadString(root, ModelProperty); + if (model.Length == 0) + { + model = meta.Models.FirstOrDefault() ?? string.Empty; + } + + string apiKey = cipher.Decrypt(ReadString(root, ApiKeyProperty)); + return new AiGlobalConfigSnapshot(providerId, baseUrl, model, apiKey); + } + catch (JsonException) + { + // Повреждённая строка aiConfig — конфигурация не задана (мягкая семантика, как SettingsService). + return EmptySnapshot(); + } + } + + /// + /// Маскированная форма конфигурации для операторской ручки + /// + /// DTO с флагом keySet и маской ключа. + public async Task GetMaskedAsync(CancellationToken ct) + { + AiGlobalConfigSnapshot snapshot = await GetSnapshotAsync(ct).ConfigureAwait(false); + return new AiGlobalConfigMaskedDto( + ProviderId: snapshot.ProviderId, + BaseUrl: snapshot.BaseUrl, + Model: snapshot.Model, + KeySet: snapshot.ApiKey.Length > 0, + KeyMasked: MaskSecret(snapshot.ApiKey), + Providers: ProviderCatalog); + } + + /// + /// Сохраняет глобальную конфигурацию ИИ (эффективные значения; ключ шифруется) + /// + /// Id провайдера из каталога . + /// Базовый URL (учитывается только для local/custom провайдеров). + /// Модель (у каталогных провайдеров пустая заменяется первой из каталога). + /// API-ключ открытым текстом (не нужен локальным провайдерам). + /// Значения не прошли валидацию (см. ). + public async Task SaveAsync( + string providerId, + string baseUrl, + string model, + string apiKey, + CancellationToken ct) + { + string trimmedProviderId = (providerId ?? string.Empty).Trim(); + string trimmedBaseUrl = (baseUrl ?? string.Empty).Trim(); + string trimmedModel = (model ?? string.Empty).Trim(); + string trimmedApiKey = (apiKey ?? string.Empty).Trim(); + + AiProviderDefinition? meta = AiProviders.All.FirstOrDefault(provider => provider.Id == trimmedProviderId); + if (meta is null) + { + throw new ArgumentException("Провайдер не из списка разрешённых.", nameof(providerId)); + } + + if (!IsBaseUrlMutable(meta)) + { + // Адрес каталогных облачных провайдеров фиксирован каталогом (SSRF-гейт). + trimmedBaseUrl = meta.Base; + } + + if (trimmedModel.Length == 0) + { + trimmedModel = meta.Models.FirstOrDefault() ?? string.Empty; + } + + if (trimmedModel.Length == 0) + { + throw new ArgumentException("Модель обязательна (в каталоге провайдера нет моделей).", nameof(model)); + } + + if (trimmedApiKey.Length > 0 && !IsValidApiKey(trimmedApiKey)) + { + throw new ArgumentException("API-ключ непустой, ≥8 симв., без маски и без префикса enc:.", nameof(apiKey)); + } + + var value = new AiGlobalConfigValue( + ProviderId: trimmedProviderId, + ApiKey: cipher.Encrypt(trimmedApiKey), + BaseUrl: trimmedBaseUrl, + Model: trimmedModel); + string valueJson = JsonSerializer.Serialize(value, ConfigJsonOptions); + await store.SetAsync(GlobalSettingsKeys.AiConfig, valueJson, ct).ConfigureAwait(false); + } + + /// + /// Валиден ли API-ключ к шифрованию и сохранению + /// + /// Проверяемое значение (уже без пробелов). + /// True — значение допустимо. + public static bool IsValidApiKey(string apiKey) => + apiKey.Length >= ApiKeyMinLength + && !apiKey.Contains(MaskEllipsis, StringComparison.Ordinal) + && !apiKey.StartsWith(EncryptedPrefix, StringComparison.Ordinal); + + // Пустой снимок «конфигурация не задана». + private static AiGlobalConfigSnapshot EmptySnapshot() => + new(string.Empty, string.Empty, string.Empty, string.Empty); + + // Адрес мутабелен только для локальных провайдеров и custom (остальным — фиксирован каталогом). + private static bool IsBaseUrlMutable(AiProviderDefinition meta) => meta.Local || meta.Id == CustomProviderId; + + // Читает строковое поле JSON-объекта (пусто при отсутствии/не-строке). + // root: Корень JSON значения aiConfig. + // propertyName: Имя поля (camelCase). + // Возвращает: Значение поля (trim) или пустая строка. + private static string ReadString(JsonElement root, string propertyName) + { + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty(propertyName, out JsonElement element) + || element.ValueKind != JsonValueKind.String) + { + return string.Empty; + } + + return (element.GetString() ?? string.Empty).Trim(); + } + + // Маска СЕКРЕТА (apiKey): всегда скрывает, кроме пустого. Длина ≤ 8 → «x…», иначе «1234…5678». + // value: Открытый секрет (не null). + // Возвращает: Маскированная строка. + private static string MaskSecret(string value) + { + return value.Length switch + { + 0 => string.Empty, + <= MaskShortMaxLength => string.Concat(value.AsSpan(0, MaskShortVisibleChars), MaskEllipsis), + _ => string.Concat( + value.AsSpan(0, MaskEdgeVisibleChars), + MaskEllipsis, + value.AsSpan(value.Length - MaskEdgeVisibleChars)), + }; + } +} diff --git a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchScalarKeys.cs b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchScalarKeys.cs index 37dfd35..38e38af 100644 --- a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchScalarKeys.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchScalarKeys.cs @@ -92,7 +92,7 @@ public sealed partial class SettingsService writes[key] = JsonSerializer.Serialize(number); } - // Применяет строковый ключ: targetCurrency — Upper; aiProvider — только из каталога провайдеров. + // Применяет строковый ключ: targetCurrency — Upper. // key: Имя ключа. // value: JSON-значение из тела PATCH. // writes: Накопитель записей (key → valueJson). @@ -111,11 +111,6 @@ public sealed partial class SettingsService text = text.ToUpperInvariant(); } - if (key == SettingsKeys.AiProvider && !ProviderIds.Contains(text)) - { - return; // aiProvider вне списка провайдеров — пропуск (L134–135) - } - writes[key] = JsonSerializer.Serialize(text); } diff --git a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchSecrets.cs b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchSecrets.cs deleted file mode 100644 index 975eb36..0000000 --- a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchSecrets.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System.Text.Json; -using Deal.Modules.Settings.Application.Models; - -namespace Deal.Modules.Settings.Application.Services; - -// Часть SettingsService: PATCH-ключ секрета aiConfigs (ApplyAiConfigsKey, SSRF-гейт baseUrl, шифрование -// apiKey), включая удаление переопределения, вернувшегося к дефолту (EqualsDefault). -public sealed partial class SettingsService -{ - private void ApplyAiConfigsKey( - JsonElement value, - Dictionary overrides, - Dictionary writes, - List removals) - { - if (value.ValueKind != JsonValueKind.Object) - { - return; - } - - // База — эффективные конфиги (дефолты + сохранённые переопределения): PATCH дописывает поля. - var effective = new Dictionary( - MergeAiConfigs(SettingsDefaults.AiConfigs, overrides), - StringComparer.Ordinal); - - foreach (JsonProperty provider in value.EnumerateObject()) - { - if (!effective.ContainsKey(provider.Name) || provider.Value.ValueKind != JsonValueKind.Object) - { - continue; - } - - AiConfigSetting config = effective[provider.Name]; - string providerId = provider.Name; - string apiKey = config.ApiKey; - string baseUrl = config.BaseUrl; - string model = config.Model; - JsonElement entry = provider.Value; - - // Каталог провайдеров: для baseUrl-гейта нужен признак local и фиксированный адрес каталога. - AiProviderDefinition? meta = AiProviders.All.FirstOrDefault(p => p.Id == providerId); - - // baseUrl: переопределение принимается ТОЛЬКО для локальных провайдеров (Ollama/LM Studio — свой - // адрес на машине пользователя) и custom (OpenAI-совместимый — адрес задаёт сам пользователь). - // У каталоговых облачных провайдеров (deepseek/openai/openrouter/anthropic) адрес фиксирован - // каталогом — иначе тенант мог бы перенаправить ключ/запрос на произвольный внутренний адрес - // (SSRF, Security review) и проверка подключения ушла бы на него. - bool baseUrlMutable = meta is { Local: true } || providerId == CustomProviderId; - if (baseUrlMutable && TryReadFieldText(entry, SettingsFieldKeys.BaseUrl, out string newBaseUrl)) - { - baseUrl = newBaseUrl; - } - - if (TryReadFieldText(entry, SettingsFieldKeys.Model, out string newModel)) - { - model = newModel; - } - - // apiKey: непустой, ≥8 симв., без префикса enc: и без маски (содержит "…") → шифруется; - // иначе (пустой/маска) ключ не меняется. - if (TryReadFieldText(entry, SettingsFieldKeys.ApiKey, out string newKey) - && newKey.Length >= ApiKeyMinLength - && !newKey.StartsWith(EncryptedValuePrefix, StringComparison.Ordinal) - && !newKey.Contains(MaskEllipsis, StringComparison.Ordinal)) - { - apiKey = secretCipher.Encrypt(newKey); - } - - effective[provider.Name] = new AiConfigSetting(apiKey, baseUrl, model); - } - - if (EqualsDefault(effective)) - { - removals.Add(SettingsKeys.AiConfigs); - } - else - { - writes[SettingsKeys.AiConfigs] = JsonSerializer.Serialize(effective, JsonOptions); - } - } - - // True — эффективный aiConfigs совпадает с дефолтами (переопределение можно не хранить). - // configs: Эффективные конфиги. - // Возвращает: True — хранить переопределение не нужно. - private static bool EqualsDefault(IReadOnlyDictionary configs) - { - if (configs.Count != SettingsDefaults.AiConfigs.Count) - { - return false; - } - - foreach ((string providerId, AiConfigSetting config) in configs) - { - if (!SettingsDefaults.AiConfigs.ContainsKey(providerId) || config != SettingsDefaults.AiConfigs[providerId]) - { - return false; - } - } - - return true; - } -} diff --git a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PublicForms.cs b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PublicForms.cs deleted file mode 100644 index 5df5241..0000000 --- a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PublicForms.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Deal.Modules.Settings.Application.Models; - -namespace Deal.Modules.Settings.Application.Services; - -public sealed partial class SettingsService -{ - private AiConfigPublicDto ToPublic(AiConfigSetting config) - { - string plainKey = secretCipher.Decrypt(config.ApiKey); - return new AiConfigPublicDto( - BaseUrl: config.BaseUrl, - Model: config.Model, - KeySet: !string.IsNullOrEmpty(plainKey), - KeyMasked: MaskSecret(plainKey)); - } - - private static string MaskSecret(string value) - { - return value.Length switch - { - 0 => string.Empty, - <= 8 => string.Concat(value.AsSpan(0, 1), MaskEllipsis), - _ => string.Concat(value.AsSpan(0, 4), MaskEllipsis, value.AsSpan(value.Length - 4)), - }; - } -} diff --git a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.ReadMerge.cs b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.ReadMerge.cs index 76f5e0f..d9063db 100644 --- a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.ReadMerge.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.ReadMerge.cs @@ -201,34 +201,6 @@ public sealed partial class SettingsService return new Dictionary(StringComparer.Ordinal); } - // Объединяет дефолты aiConfigs и сохранённое переопределение (переопределение — на провайдера). - // defaults: Дефолтные конфиги на всех провайдеров каталога. - // overrides: Сохранённые переопределения. - // Возвращает: Эффективные конфиги: провайдеры дефолтов + перекрытия из хранилища (неизвестные пропускаются). - private static IReadOnlyDictionary MergeAiConfigs(IReadOnlyDictionary defaults, Dictionary overrides) - { - var merged = defaults.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); - - if (overrides.TryGetValue(SettingsKeys.AiConfigs, out JsonElement element) && element.ValueKind == JsonValueKind.Object) - { - foreach (JsonProperty provider in element.EnumerateObject()) - { - // «Только существующие провайдеры»: неизвестные id из хранилища не подмешиваются. - if (!merged.ContainsKey(provider.Name) || provider.Value.ValueKind != JsonValueKind.Object) - { - continue; - } - - string apiKey = TryReadFieldText(provider.Value, SettingsFieldKeys.ApiKey, out string storedKey) ? storedKey : string.Empty; - string baseUrl = TryReadFieldText(provider.Value, SettingsFieldKeys.BaseUrl, out string storedBase) ? storedBase : string.Empty; - string model = TryReadFieldText(provider.Value, SettingsFieldKeys.Model, out string storedModel) ? storedModel : string.Empty; - merged[provider.Name] = new AiConfigSetting(apiKey, baseUrl, model); - } - } - - return merged; - } - // Читает сохранённые «Мои промпты»; повреждённое значение → пустой список (дефолт). // overrides: Сохранённые переопределения. // Возвращает: Список промптов из хранилища или дефолт (пустой). diff --git a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.cs b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.cs index eb8a3ee..b8b08f0 100644 --- a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.cs @@ -8,30 +8,14 @@ namespace Deal.Modules.Settings.Application.Services; /// Сервис настроек тенанта /// /// KV-хранилище настроек тенанта (таблица settings). -/// Шифр секретов. /// Слушатели смены настроек конверсии; пусто — no-op. public sealed partial class SettingsService( ISettingsStore store, - ISecretCipher secretCipher, IEnumerable listeners) { - - private const string EncryptedValuePrefix = "enc:"; - - // Символ-заполнитель маски секрета (U+2026, «1234…5678»): строка, содержащая его, - // — это маска из public-снимка, а не новый секрет (Security review: не шифровать маску). - private const string MaskEllipsis = "…"; - - // Id провайдера «Другой (OpenAI-совместимый)» — единственный не-local, чей baseUrl - // задаёт пользователь (остальным адрес фиксирован каталогом, SSRF-гейт). - private const string CustomProviderId = "custom"; - // Максимум элементов списка-настройки при записи из PATCH (срез 200). private const int MaxListItems = 200; - // Минимальная длина нового API-ключа, который принимает PATCH (≥8 симв.). - private const int ApiKeyMinLength = 8; - // Максимум элементов «Моих промптов» при записи из PATCH (≤100). private const int MaxMyPrompts = 100; @@ -65,16 +49,6 @@ public sealed partial class SettingsService( [SettingsKeys.DiscEvalThreshold] = (1, 100), }; - // Идентификаторы известных провайдеров (валидация aiProvider/aiConfigs). - private static readonly IReadOnlySet ProviderIds = - AiProviders.All.Select(provider => provider.Id).ToHashSet(StringComparer.Ordinal); - - private static readonly IReadOnlyList PublicProviders = - AiProviders.All - .Select(provider => new ProviderPublicDto( - provider.Id, provider.Name, provider.Base, provider.Local, provider.Models)) - .ToList(); - private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, @@ -105,7 +79,6 @@ public sealed partial class SettingsService( Dictionary preparedDelays = PrepareDelayClamps(body, overrides); var writes = new Dictionary(StringComparer.Ordinal); - var removals = new List(); foreach ((string key, JsonElement value) in body) { @@ -162,10 +135,6 @@ public sealed partial class SettingsService( } break; - - case SettingKind.AiConfigs: - ApplyAiConfigsKey(value, overrides, writes, removals); - break; } } @@ -174,11 +143,6 @@ public sealed partial class SettingsService( await store.SetAsync(key, valueJson, ct); } - foreach (string key in removals) - { - await store.RemoveAsync(key, ct); - } - if (AffectsConversion(body)) { await NotifyRatesChangedAsync(ct); @@ -214,8 +178,6 @@ public sealed partial class SettingsService( // Возвращает: DTO public-снимка. private PublicSettingsDto BuildSnapshot(Dictionary overrides) { - IReadOnlyDictionary aiConfigs = MergeAiConfigs(SettingsDefaults.AiConfigs, overrides); - var result = new PublicSettingsDto { // Хранилище / обработка @@ -269,11 +231,6 @@ public sealed partial class SettingsService( DiscEvalThreshold = MergeInt(overrides, SettingsKeys.DiscEvalThreshold, SettingsDefaults.DiscEvalThreshold), DiscPaused = MergeBool(overrides, SettingsKeys.DiscPaused, SettingsDefaults.DiscPaused), ColState = MergeDict(overrides, SettingsKeys.ColState), - - // ИИ-провайдеры (публичная форма) - AiProvider = MergeString(overrides, SettingsKeys.AiProvider, SettingsDefaults.AiProvider), - AiConfigs = aiConfigs.ToDictionary(pair => pair.Key, pair => ToPublic(pair.Value), StringComparer.Ordinal), - Providers = PublicProviders, }; return result; diff --git a/src/core/Deal.Modules.Tenants/Application/Models/AuditEvents.cs b/src/core/Deal.Modules.Tenants/Application/Models/AuditEvents.cs index 518e87c..571d3eb 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/AuditEvents.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/AuditEvents.cs @@ -154,4 +154,9 @@ public static class AuditEvents /// Оператор изменил глобальные ключи Telegram api_id/api_hash. /// public const string TelegramKeysChanged = "telegram_keys_changed"; + + /// + /// Оператор изменил глобальную конфигурацию ИИ-провайдера. + /// + public const string AiConfigChanged = "ai_config_changed"; } diff --git a/src/core/Deal.Modules.Tenants/Application/Models/AuditFields.cs b/src/core/Deal.Modules.Tenants/Application/Models/AuditFields.cs index 8503abb..e25b25b 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/AuditFields.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/AuditFields.cs @@ -119,4 +119,24 @@ public static class AuditFields /// Диалог Telegram /// public const string DialogId = "dialogId"; + + /// + /// Id ИИ-провайдера + /// + public const string ProviderId = "providerId"; + + /// + /// Базовый URL ИИ-провайдера + /// + public const string BaseUrl = "baseUrl"; + + /// + /// Модель ИИ-провайдера + /// + public const string Model = "model"; + + /// + /// Признак заданного API-ключа ИИ-провайдера + /// + public const string KeySet = "keySet"; } diff --git a/src/core/tests/Deal.Tests.Unit/Api/AiGlobalConfigServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Api/AiGlobalConfigServiceTests.cs new file mode 100644 index 0000000..a7c3a76 --- /dev/null +++ b/src/core/tests/Deal.Tests.Unit/Api/AiGlobalConfigServiceTests.cs @@ -0,0 +1,128 @@ +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Services; +using Deal.Tests.Unit.Modules.Settings; +using Deal.Tests.Unit.Support; + +namespace Deal.Tests.Unit.Api; + +/// +/// Тесты сервиса глобальной конфигурации ИИ оператора +/// +public sealed class AiGlobalConfigServiceTests +{ + private const string ApiKey = "sk-operator-key-123"; + private const string DeepSeekBase = "https://api.deepseek.com"; + + private readonly TestGlobalSettingsStore _store = new(); + private readonly ISecretCipher _cipher = TestCiphers.New(); + private readonly AiGlobalConfigService _service; + + public AiGlobalConfigServiceTests() + { + _service = new AiGlobalConfigService(_store.Store, _cipher); + } + + [Fact] + public async Task GetMaskedAsync_NoConfig_ReturnsEmptySnapshotWithProviderCatalog() + { + AiGlobalConfigMaskedDto masked = await _service.GetMaskedAsync(CancellationToken.None); + + Assert.Equal(string.Empty, masked.ProviderId); + Assert.Equal(string.Empty, masked.KeyMasked); + Assert.False(masked.KeySet); + Assert.Equal(AiProviders.All.Count, masked.Providers.Count); + } + + [Fact] + public async Task SaveAsync_EncryptsApiKeyAndMasquesOnRead() + { + await _service.SaveAsync("deepseek", string.Empty, "deepseek-v4-pro", ApiKey, CancellationToken.None); + + string storedJson = _store.GetStoredJson(GlobalSettingsKeys.AiConfig)!; + Assert.Contains("enc:", storedJson); + Assert.DoesNotContain(ApiKey, storedJson); + + AiGlobalConfigMaskedDto masked = await _service.GetMaskedAsync(CancellationToken.None); + Assert.Equal("deepseek", masked.ProviderId); + Assert.Equal("deepseek-v4-pro", masked.Model); + Assert.True(masked.KeySet); + Assert.Equal("sk-o…-123", masked.KeyMasked); + } + + [Fact] + public async Task SaveAsync_CloudProviderBaseUrl_IsFixedByCatalog() + { + await _service.SaveAsync("deepseek", "https://evil.example/v1", string.Empty, ApiKey, CancellationToken.None); + + AiGlobalConfigSnapshot snapshot = await _service.GetSnapshotAsync(CancellationToken.None); + + Assert.Equal(DeepSeekBase, snapshot.BaseUrl); + Assert.Equal("deepseek-v4-flash", snapshot.Model); // первая модель каталога + Assert.True(snapshot.Configured); + } + + [Fact] + public async Task SaveAsync_CustomProvider_KeepsOperatorBaseUrlAndModel() + { + await _service.SaveAsync("custom", "https://llm.local/v1", "my-model", ApiKey, CancellationToken.None); + + AiGlobalConfigSnapshot snapshot = await _service.GetSnapshotAsync(CancellationToken.None); + + Assert.Equal("https://llm.local/v1", snapshot.BaseUrl); + Assert.Equal("my-model", snapshot.Model); + Assert.True(snapshot.Configured); + } + + [Fact] + public async Task SaveAsync_LocalProvider_ConfiguredWithoutApiKey() + { + await _service.SaveAsync("ollama", "http://127.0.0.1:11435/v1", string.Empty, string.Empty, CancellationToken.None); + + AiGlobalConfigSnapshot snapshot = await _service.GetSnapshotAsync(CancellationToken.None); + + Assert.Equal("http://127.0.0.1:11435/v1", snapshot.BaseUrl); + Assert.True(snapshot.Configured); + } + + [Fact] + public async Task SaveAsync_UnknownProvider_Throws() + { + await Assert.ThrowsAsync( + () => _service.SaveAsync("несуществующий", string.Empty, string.Empty, ApiKey, CancellationToken.None)); + } + + [Fact] + public async Task SaveAsync_CustomWithoutModel_Throws() + { + await Assert.ThrowsAsync( + () => _service.SaveAsync("custom", "https://llm.local/v1", string.Empty, ApiKey, CancellationToken.None)); + } + + [Fact] + public async Task SaveAsync_ShortApiKey_Throws() + { + await Assert.ThrowsAsync( + () => _service.SaveAsync("deepseek", string.Empty, string.Empty, "short", CancellationToken.None)); + } + + [Fact] + public async Task GetSnapshotAsync_MalformedStoredJson_IsNotConfigured() + { + _store.Preload(GlobalSettingsKeys.AiConfig, "not-json"); + + AiGlobalConfigSnapshot snapshot = await _service.GetSnapshotAsync(CancellationToken.None); + + Assert.False(snapshot.Configured); + } + + [Theory] + [InlineData("sk-operator-key-123", true)] + [InlineData("sk-1…-123", false)] // маска из снимка + [InlineData("enc:sk-operator-key", false)] + [InlineData("short", false)] + public void IsValidApiKey_RejectsMaskEncryptedAndShort(string apiKey, bool expected) + { + Assert.Equal(expected, AiGlobalConfigService.IsValidApiKey(apiKey)); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs index 437011a..b81a3e0 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs @@ -139,7 +139,7 @@ public sealed class GrpcAiToolsTests return new GrpcAiTools( tenantContext, connection, - new AiProviderConfigBuilder(settings.Store, cipher), + new AiProviderConfigBuilder(TestAiConfig.New(new TestGlobalSettingsStore(), cipher)), new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)), NullLogger.Instance); } diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/IntegrationsDiTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/IntegrationsDiTests.cs index e30468d..9594547 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/IntegrationsDiTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/IntegrationsDiTests.cs @@ -9,6 +9,7 @@ using Deal.Modules.Kanban.Application.Abstractions; using Deal.Modules.Pipeline.Application.Parse; using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Services; using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants.Abstractions; @@ -144,7 +145,9 @@ public sealed class IntegrationsDiTests tenantContext.SetTenant(new TenantId(Guid.NewGuid().ToString("N"))); services.AddSingleton(tenantContext); services.AddScoped(_ => new TestSettingsStore().Store); + services.AddScoped(_ => new TestGlobalSettingsStore().Store); services.AddScoped(_ => TestCiphers.New()); + services.AddScoped(); services.AddScoped(_ => new TestKanjStore().Store); services.AddScoped(_ => new TestMlLearningStore().Store); services.AddScoped(_ => new TestTenantLimitStore().Store); diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs index 4cea455..15bc9c7 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs @@ -178,7 +178,7 @@ public sealed class PipelineWorkerGrpcAiTests var grpcClassifier = new GrpcAiClassifier( tenantContext, connection, - new AiProviderConfigBuilder(settings.Store, TestCiphers.New()), + new AiProviderConfigBuilder(TestAiConfig.New(new TestGlobalSettingsStore(), TestCiphers.New())), new AiClassifyContextBuilder(settings.Store, kanjStore.Store), new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)), NullLogger.Instance); diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Settings/SettingsCatalogTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Settings/SettingsCatalogTests.cs index 9158bec..db00a19 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Settings/SettingsCatalogTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Settings/SettingsCatalogTests.cs @@ -38,7 +38,6 @@ public sealed class SettingsCatalogTests // String ["targetCurrency"] = SettingKind.String, ["rateSource"] = SettingKind.String, - ["aiProvider"] = SettingKind.String, ["aiPrompt"] = SettingKind.String, ["aiFilterPrompt"] = SettingKind.String, ["cardPrompt"] = SettingKind.String, @@ -59,10 +58,9 @@ public sealed class SettingsCatalogTests ["colState"] = SettingKind.Dict, // special ["myPrompts"] = SettingKind.MyPrompts, - ["aiConfigs"] = SettingKind.AiConfigs, }; - private const int ExpectedPublicKeysCount = 43; + private const int ExpectedPublicKeysCount = 41; [Fact] public void PublicKeys_CoverAllApiMapKeysWithCorrectCategories() @@ -169,26 +167,11 @@ public sealed class SettingsCatalogTests Assert.True(SettingsDefaults.BlockResumes); Assert.Equal("RUB", SettingsDefaults.TargetCurrency); Assert.Equal("cbr", SettingsDefaults.RateSource); - Assert.Equal("deepseek", SettingsDefaults.AiProvider); Assert.False(SettingsDefaults.DiscPaused); Assert.Empty(SettingsDefaults.ColState); Assert.Empty(SettingsDefaults.MyPrompts); } - [Fact] - public void Defaults_AiConfigsSeededForEveryProviderWithEmptyKey() - { - Assert.Equal(AiProviders.All.Count, SettingsDefaults.AiConfigs.Count); - - foreach (AiProviderDefinition provider in AiProviders.All) - { - AiConfigSetting config = SettingsDefaults.AiConfigs[provider.Id]; - Assert.Equal("", config.ApiKey); - Assert.Equal(provider.Base, config.BaseUrl); - Assert.Equal(provider.Models.FirstOrDefault() ?? string.Empty, config.Model); - } - } - [Fact] public void DefaultPrompts_ContainKeyMarkersFromDataJs() { diff --git a/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs b/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs index 5457266..834c1a5 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs @@ -12,6 +12,7 @@ using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Services; using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants.Models; using Deal.Tests.Unit.Grpc; @@ -71,7 +72,8 @@ public sealed class GrpcAiClassifierTests Assert.Equal("deepseek", service.LastFilter.ProviderConfig.ProviderId); Assert.Equal("https://api.deepseek.com", service.LastFilter.ProviderConfig.BaseUrl); Assert.Equal("deepseek-v4-flash", service.LastFilter.ProviderConfig.Model); - Assert.False(service.LastFilter.ProviderConfig.HasApiKey); + Assert.True(service.LastFilter.ProviderConfig.HasApiKey); + Assert.Equal(TestAiConfig.DefaultApiKey, service.LastFilter.ProviderConfig.ApiKey); Assert.False(service.LastFilter.ProviderConfig.HasApiStyle); Assert.Equal(TenantIdValue, Assert.Single(service.RequestTenantIds)); @@ -220,34 +222,24 @@ public sealed class GrpcAiClassifierTests } [Fact] - public async Task ClassifyAsync_ProviderConfig_ReadsDecryptedKeyAndOverridesFromSettings() + public async Task ClassifyAsync_ProviderConfig_UsesGlobalOperatorConfig() { await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => { service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Заголовок","stack":[],"is_spam":false}""" }; (TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port); - settings.Preload(SettingsKeys.AiProvider, Json("anthropic")); - settings.Preload( - SettingsKeys.AiConfigs, - new JsonObject - { - ["anthropic"] = new JsonObject - { - ["apiKey"] = cipher.Encrypt("sk-ant-test-secret"), - ["baseUrl"] = "https://llm.local", - ["model"] = "claude-custom", - }, - }.ToJsonString()); + AiGlobalConfigService aiConfig = TestAiConfig.New( + new TestGlobalSettingsStore(), cipher, providerId: "anthropic", apiKey: "sk-ant-test-secret"); - IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore); + IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, aiConfig: aiConfig); await classifier.ClassifyAsync("текст", CancellationToken.None); - // ProviderConfig из настроек: id/base/model — переопределение, apiKey расшифрован, api_style — из каталога. + // ProviderConfig глобальной конфигурации: id/apiKey, адрес каталога фиксирован, api_style — из каталога. Assert.NotNull(service.LastClassify); ProviderConfig config = service.LastClassify!.ProviderConfig; Assert.Equal("anthropic", config.ProviderId); - Assert.Equal("https://llm.local", config.BaseUrl); - Assert.Equal("claude-custom", config.Model); + Assert.Equal("https://api.anthropic.com", config.BaseUrl); + Assert.Equal("claude-sonnet-5", config.Model); Assert.True(config.HasApiKey); Assert.Equal("sk-ant-test-secret", config.ApiKey); Assert.True(config.HasApiStyle); @@ -255,6 +247,33 @@ public sealed class GrpcAiClassifierTests }); } + [Fact] + public async Task ClassifyAsync_ProviderConfig_HonorsCustomBaseUrlAndModel() + { + await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => + { + service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Заголовок","stack":[],"is_spam":false}""" }; + (TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port); + AiGlobalConfigService aiConfig = TestAiConfig.New( + new TestGlobalSettingsStore(), + cipher, + providerId: "custom", + baseUrl: "https://llm.local/v1", + model: "my-model", + apiKey: "sk-custom-key-123"); + + IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, aiConfig: aiConfig); + await classifier.ClassifyAsync("текст", CancellationToken.None); + + // OpenAI-совместимый провайдер: адрес и модель задаёт оператор. + ProviderConfig config = service.LastClassify!.ProviderConfig; + Assert.Equal("custom", config.ProviderId); + Assert.Equal("https://llm.local/v1", config.BaseUrl); + Assert.Equal("my-model", config.Model); + Assert.Equal("sk-custom-key-123", config.ApiKey); + }); + } + [Fact] public async Task ClassifyAsync_LongMessage_TruncatesUserContextTo5000CodePoints() { @@ -277,25 +296,28 @@ public sealed class GrpcAiClassifierTests // Создаёт GrpcAiClassifier к хосту-фейку на эфемерном порту в tenant-контексте теста. // port: Порт хоста-фейка ai-service. // settings: KV-хранилище тенанта (дефолты при пустом). - // cipher: Шифр секретов (расшифровка apiKey). + // cipher: Шифр секретов. // kanjStore: Канбан-фейк (доски/примеры контекста классификации). // limits: Фейк-хранилище лимитов (списание usage; при null — собственный экземпляр). + // aiConfig: Глобальная конфигурация ИИ (при null — провайдер по умолчанию с ключом). // Возвращает: Экземпляр GrpcAiClassifier. private static GrpcAiClassifier CreateClassifier( int port, TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore, - TestTenantLimitStore? limits = null) + TestTenantLimitStore? limits = null, + AiGlobalConfigService? aiConfig = null) { limits ??= new TestTenantLimitStore(); + aiConfig ??= TestAiConfig.New(new TestGlobalSettingsStore(), cipher); ITenantContext tenantContext = new TenantContext(); tenantContext.SetTenant(new TenantId(TenantIdValue)); var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" }); return new GrpcAiClassifier( tenantContext, connection, - new AiProviderConfigBuilder(settings.Store, cipher), + new AiProviderConfigBuilder(aiConfig), new AiClassifyContextBuilder(settings.Store, kanjStore.Store), new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)), NullLogger.Instance); diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorAiConfigEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorAiConfigEndpointsHttpTests.cs new file mode 100644 index 0000000..a4b0ece --- /dev/null +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorAiConfigEndpointsHttpTests.cs @@ -0,0 +1,263 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Tenants.Application.Models; +using Deal.Tests.Unit.Modules.Settings; +using Deal.Tests.Unit.Modules.Tenants; +using Deal.Tests.Unit.Support; + +namespace Deal.Tests.Unit.Support; + +/// +/// HTTP-тесты операторских ручек глобальной конфигурации ИИ +/// +public sealed class OperatorAiConfigEndpointsHttpTests +{ + private const string OperatorLogin = "operator"; + private const string OperatorPassword = "operator"; + private const string InvalidProviderDetail = "Провайдер не из списка разрешённых"; + private const string MissingAiModelDetail = "Укажите model — у выбранного провайдера нет моделей по умолчанию"; + private const string AiConfigNotSetDetail = "Сначала сохраните конфигурацию ИИ"; + private const string ApiKey = "sk-operator-key-123"; + + [Fact] + public async Task AiConfig_WithoutOperatorSession_Returns401() + { + await RunAsync( + async (baseAddress, _, _, _, _) => + { + using HttpClient anonymous = CreateClient(baseAddress); + + using HttpResponseMessage get = await anonymous.GetAsync(AiConfigUrl(baseAddress)); + Assert.Equal(HttpStatusCode.Unauthorized, get.StatusCode); + + using HttpResponseMessage put = await PutJsonAsync( + anonymous, AiConfigUrl(baseAddress), new { providerId = "deepseek", apiKey = ApiKey }); + Assert.Equal(HttpStatusCode.Unauthorized, put.StatusCode); + }); + } + + [Fact] + public async Task GetAiConfig_NoConfig_ReturnsEmptySnapshotWithProviderCatalog() + { + await RunAsync( + async (baseAddress, _, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage response = await operatorClient.GetAsync(AiConfigUrl(baseAddress)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + JsonElement body = await ReadJsonAsync(response); + Assert.Equal(string.Empty, body.GetProperty("providerId").GetString()); + Assert.Equal(string.Empty, body.GetProperty("keyMasked").GetString()); + Assert.False(body.GetProperty("keySet").GetBoolean()); + Assert.Equal(AiProviders.All.Count, body.GetProperty("providers").GetArrayLength()); + }); + } + + [Fact] + public async Task PutAiConfig_EncryptsKeyMasksResponseAndWritesAudit() + { + var auditStore = new TestAuditLogStore(); + + await RunAsync( + async (baseAddress, operatorStore, _, globalSettings, audit) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage put = await PutJsonAsync( + operatorClient, AiConfigUrl(baseAddress), new { providerId = "deepseek", model = "deepseek-v4-pro", apiKey = ApiKey }); + Assert.Equal(HttpStatusCode.OK, put.StatusCode); + + JsonElement body = await ReadJsonAsync(put); + Assert.Equal("deepseek", body.GetProperty("providerId").GetString()); + Assert.Equal("deepseek-v4-pro", body.GetProperty("model").GetString()); + Assert.Equal("sk-o…-123", body.GetProperty("keyMasked").GetString()); + Assert.True(body.GetProperty("keySet").GetBoolean()); + + // В хранилище ключ только в enc:-форме, открытого секрета нет. + string storedJson = globalSettings.GetStoredJson(GlobalSettingsKeys.AiConfig)!; + Assert.Contains("enc:", storedJson); + Assert.DoesNotContain(ApiKey, storedJson); + + // Аудит: актор-оператор, детали без секрета. + AuditRecordDto record = Assert.Single(audit.Records, r => r.EventType == AuditEvents.AiConfigChanged); + Assert.Equal(AuditActorTypes.Operator, record.ActorType); + Assert.Equal(operatorStore.Operators.Single().Id, record.ActorId); + Assert.Null(record.TenantId); + Assert.DoesNotContain(ApiKey, record.DetailJson); + Assert.Equal("deepseek", record.DetailValue(AuditFields.ProviderId)); + Assert.Equal("deepseek-v4-pro", record.DetailValue(AuditFields.Model)); + Assert.Equal("true", record.DetailValue(AuditFields.KeySet)); + }, + auditStore); + } + + [Fact] + public async Task PutAiConfig_UnknownProvider_Returns400() + { + await RunAsync( + async (baseAddress, _, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage response = await PutJsonAsync( + operatorClient, AiConfigUrl(baseAddress), new { providerId = "несуществующий", apiKey = ApiKey }); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(InvalidProviderDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task PutAiConfig_CustomWithoutModel_Returns400() + { + await RunAsync( + async (baseAddress, _, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage response = await PutJsonAsync( + operatorClient, AiConfigUrl(baseAddress), new { providerId = "custom", baseUrl = "https://llm.local/v1", apiKey = ApiKey }); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(MissingAiModelDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task PutAiConfig_ShortApiKey_Returns400() + { + await RunAsync( + async (baseAddress, _, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage response = await PutJsonAsync( + operatorClient, AiConfigUrl(baseAddress), new { providerId = "deepseek", apiKey = "short" }); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + }); + } + + [Fact] + public async Task CheckAiConfig_NoConfig_Returns400() + { + await RunAsync( + async (baseAddress, _, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage response = await PostJsonAsync( + operatorClient, AiConfigCheckUrl(baseAddress), new { }); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(AiConfigNotSetDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task CheckAiConfig_LocalProvider_ReturnsOkWithoutHttp() + { + await RunAsync( + async (baseAddress, _, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage put = await PutJsonAsync( + operatorClient, AiConfigUrl(baseAddress), new { providerId = "ollama", baseUrl = "http://127.0.0.1:11434/v1" }); + Assert.Equal(HttpStatusCode.OK, put.StatusCode); + + using HttpResponseMessage check = await PostJsonAsync( + operatorClient, AiConfigCheckUrl(baseAddress), new { }); + Assert.Equal(HttpStatusCode.OK, check.StatusCode); + + JsonElement body = await ReadJsonAsync(check); + Assert.True(body.GetProperty("ok").GetBoolean()); + Assert.Equal("ollama", body.GetProperty("provider").GetString()); + }); + } + + // ─── Хелперы ───────────────────────────────────────────────────────── + + // URL глобальной конфигурации ИИ (GET/PUT /api/operator/settings/ai-config). + private static string AiConfigUrl(string baseAddress) => + $"{baseAddress}/api/operator/settings/ai-config"; + + // URL проверки связи (POST /api/operator/settings/ai-config/check). + private static string AiConfigCheckUrl(string baseAddress) => + $"{baseAddress}/api/operator/settings/ai-config/check"; + + // Прогоняет сценарий на хосте с фейком глобального хранилища и аудита. + private static Task RunAsync( + Func scenario, + TestAuditLogStore? auditStore = null) => + OperatorAuthHttpHost.RunWithGlobalSettingsAsync( + NewOperatorStore(), + new TestAuthStore(), + scenario, + auditStore); + + // Логинит оператора (ожидается 200). + private static async Task LoginOperatorAsync(HttpClient client, string baseAddress) + { + using HttpResponseMessage login = await PostJsonAsync( + client, $"{baseAddress}/api/operator/auth/login", new { login = OperatorLogin, password = OperatorPassword }); + Assert.Equal(HttpStatusCode.OK, login.StatusCode); + } + + // Фейк-хранилище оператора с активным оператором operator/operator. + private static TestOperatorAuthStore NewOperatorStore() + { + var passwordHasher = TestHashers.New(); + var store = new TestOperatorAuthStore(); + store.AddOperator(new StoredOperatorDto( + Guid.NewGuid(), + OperatorLogin, + Status: "active", + PasswordHash: passwordHasher.Hash(OperatorPassword))); + return store; + } + + // HTTP-клиент с собственным CookieContainer (изоляция сценариев кук). + private static HttpClient CreateClient(string baseAddress) => + new(new HttpClientHandler { UseCookies = true, CookieContainer = new CookieContainer() }) + { + BaseAddress = new Uri(baseAddress), + }; + + // POST JSON-тела и возврат ответа. + private static async Task PostJsonAsync( + HttpClient client, + string url, + object body) + { + string json = JsonSerializer.Serialize(body); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + return await client.PostAsync(url, content); + } + + // PUT JSON-тела и возврат ответа. + private static async Task PutJsonAsync( + HttpClient client, + string url, + object body) + { + string json = JsonSerializer.Serialize(body); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + return await client.PutAsync(url, content); + } + + // Читает тело ответа как JSON-документ (корень, отвязанный от документа). + private static async Task ReadJsonAsync(HttpResponseMessage response) + { + await using Stream stream = await response.Content.ReadAsStreamAsync(); + using var document = await JsonDocument.ParseAsync(stream); + return document.RootElement.Clone(); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs index d603551..beaafff 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs @@ -10,6 +10,7 @@ using Deal.Infrastructure.Integrations.Services; using Deal.Infrastructure.Persistence; using Deal.Infrastructure.Tenancy; using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Services; using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Registrars; using Deal.SharedKernel.Tenants.Abstractions; @@ -196,6 +197,8 @@ internal static class OperatorAuthHttpHost builder.Services.AddSingleton(TestCiphers.New()); // Сервис глобальных ключей Telegram (операторские ручки /api/operator/settings/telegram-keys). builder.Services.AddScoped(); + // Сервис глобальной конфигурации ИИ (операторские ручки /api/operator/settings/ai-config). + builder.Services.AddScoped(); builder.Services.AddSingleton(effectiveTokenUsageStore.Store); builder.Services.AddSingleton(_ => new TestTenantProvisioner().Provisioner); builder.Services.AddScoped(); @@ -213,6 +216,9 @@ internal static class OperatorAuthHttpHost builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(new TestAuditReferenceResolver()); + // Проверка связи с ИИ-провайдером (операторская ручка ai-config/check): локальные + // провайдеры отвечают без HTTP, поэтому клиента достаточно с таймаутом по умолчанию. + builder.Services.AddSingleton(new AiConnectionChecker(new HttpClient())); WebApplication app = builder.Build(); app.UseMiddleware(); diff --git a/src/core/tests/Deal.Tests.Unit/Support/SettingsServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Support/SettingsServiceTests.cs index d0c8c13..23c81cd 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/SettingsServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/SettingsServiceTests.cs @@ -14,12 +14,11 @@ namespace Deal.Tests.Unit.Support; public sealed class SettingsServiceTests { private readonly TestSettingsStore _store = new(); - private readonly ISecretCipher _cipher = TestCiphers.New(); private readonly SettingsService _service; public SettingsServiceTests() { - _service = new SettingsService(_store.Store, _cipher, Array.Empty()); + _service = new SettingsService(_store.Store, Array.Empty()); } // ─── GET: снимок ───────────────────────────────────────────────────────── @@ -39,7 +38,6 @@ public sealed class SettingsServiceTests Assert.Equal("both", snapshot.WantedType); Assert.Equal("RUB", snapshot.TargetCurrency); Assert.Equal("cbr", snapshot.RateSource); - Assert.Equal("deepseek", snapshot.AiProvider); Assert.True(snapshot.ConversionOn); Assert.True(snapshot.RemindersEnabled); Assert.Equal(50, snapshot.DiscJoinLimit); @@ -47,26 +45,18 @@ public sealed class SettingsServiceTests Assert.Equal(70, snapshot.DiscJoinDelayMax); Assert.False(snapshot.DiscPaused); - // Пустые коллекции и секреты. + // Пустые коллекции. Assert.Empty(snapshot.MyPrompts); Assert.Empty(snapshot.ColState); - Assert.Equal(7, snapshot.Providers.Count); - Assert.Equal(7, snapshot.AiConfigs.Count); - Assert.All(snapshot.AiConfigs, pair => Assert.False(pair.Value.KeySet)); - Assert.All(snapshot.AiConfigs, pair => Assert.Equal(string.Empty, pair.Value.KeyMasked)); } [Fact] public async Task GetPublicAsync_StoredOverrides_OverlapDefaults() { - // Переопределения: int, список, colState, aiConfigs (с секретом). + // Переопределения: int, список, colState. _store.Preload(SettingsKeys.MinLen, "100"); _store.Preload(SettingsKeys.StopPhrases, "[\"фраза один\",\"фраза два\"]"); _store.Preload(SettingsKeys.ColState, "{\"colA\":{\"collapsed\":true},\"num\":42}"); - string aiToken = _cipher.Encrypt("sk-1234567890ab"); - _store.Preload( - SettingsKeys.AiConfigs, - JsonSerializer.Serialize(new { deepseek = new { apiKey = aiToken, baseUrl = "https://x.example/v1", model = "m1" } })); PublicSettingsDto snapshot = await _service.GetPublicAsync(CancellationToken.None); @@ -79,29 +69,6 @@ public sealed class SettingsServiceTests Assert.Equal(2, snapshot.ColState.Count); Assert.True(((JsonElement)snapshot.ColState["colA"]!).GetProperty("collapsed").GetBoolean()); Assert.Equal(42, ((JsonElement)snapshot.ColState["num"]!).GetInt32()); - - // aiConfigs: только переопределённый провайдер; дефолты остальных не тронуты. - AiConfigPublicDto deepseek = snapshot.AiConfigs["deepseek"]; - Assert.True(deepseek.KeySet); - Assert.Equal("sk-1…90ab", deepseek.KeyMasked); - Assert.Equal("https://x.example/v1", deepseek.BaseUrl); - Assert.Equal("m1", deepseek.Model); - Assert.False(snapshot.AiConfigs["openai"].KeySet); - } - - [Fact] - public async Task GetPublicAsync_MalformedEncryptedSecret_ReturnsMaskEmpty() - { - _store.Preload( - SettingsKeys.AiConfigs, - JsonSerializer.Serialize(new { deepseek = new { apiKey = "enc:не-base64!", baseUrl = "https://x", model = "m" } })); - - PublicSettingsDto snapshot = await _service.GetPublicAsync(CancellationToken.None); - - AiConfigPublicDto deepseek = snapshot.AiConfigs["deepseek"]; - Assert.False(deepseek.KeySet); - Assert.Equal(string.Empty, deepseek.KeyMasked); - Assert.Equal(7, snapshot.AiConfigs.Count); } // ─── PATCH: общее поведение ────────────────────────────────────────────── @@ -113,7 +80,6 @@ public sealed class SettingsServiceTests Assert.Equal(100, snapshot.MinLen); Assert.Equal(14, snapshot.ArchiveAfterDays); - Assert.Equal("deepseek", snapshot.AiProvider); Assert.Equal("100", _store.GetStoredJson(SettingsKeys.MinLen)); Assert.Single(_store.Keys); // записано только переданное поле } @@ -310,17 +276,6 @@ public sealed class SettingsServiceTests Assert.Equal("\"USD\"", _store.GetStoredJson(SettingsKeys.TargetCurrency)); } - [Fact] - public async Task ApplyPatchAsync_AiProvider_UnknownIsSkippedKnownIsApplied() - { - PublicSettingsDto first = await PatchAsync(new { aiProvider = "неизвестный" }); - Assert.Equal("deepseek", first.AiProvider); - Assert.Empty(_store.Keys); - - PublicSettingsDto second = await PatchAsync(new { aiProvider = "openai" }); - Assert.Equal("openai", second.AiProvider); - } - [Fact] public async Task ApplyPatchAsync_List_ConvertsScalarsAndSlicesTo200() { @@ -402,160 +357,6 @@ public sealed class SettingsServiceTests Assert.Empty(_store.Keys); } - // ─── PATCH: aiConfigs (секреты) ────────────────────────────────────────── - - [Fact] - public async Task ApplyPatchAsync_AiConfigs_ApiKeyIsEncryptedInStoreAndMaskedOutside() - { - const string apiKey = "sk-1234567890ab"; - - PublicSettingsDto snapshot = await PatchAsync(new Dictionary - { - ["aiConfigs"] = new Dictionary { ["deepseek"] = new { apiKey } }, - }); - - string storedJson = _store.GetStoredJson(SettingsKeys.AiConfigs)!; - Assert.NotNull(storedJson); - Assert.Contains("enc:", storedJson); - Assert.DoesNotContain(apiKey, storedJson); - - AiConfigPublicDto deepseek = snapshot.AiConfigs["deepseek"]; - Assert.True(deepseek.KeySet); - Assert.Equal("sk-1…90ab", deepseek.KeyMasked); - Assert.Equal(SettingsDefaults.AiConfigs["deepseek"].BaseUrl, deepseek.BaseUrl); - Assert.Equal(SettingsDefaults.AiConfigs["deepseek"].Model, deepseek.Model); - } - - [Fact] - public async Task ApplyPatchAsync_AiConfigs_ShortApiKeyIsNotEncrypted() - { - PublicSettingsDto snapshot = await PatchAsync(new Dictionary - { - ["aiConfigs"] = new Dictionary { ["deepseek"] = new { apiKey = "short" } }, - }); - - Assert.False(snapshot.AiConfigs["deepseek"].KeySet); - Assert.Null(_store.GetStoredJson(SettingsKeys.AiConfigs)); // дефолт не переопределён — строки нет - } - - [Fact] - public async Task ApplyPatchAsync_AiConfigs_UnknownProviderIsIgnored() - { - PublicSettingsDto snapshot = await PatchAsync(new Dictionary - { - ["aiConfigs"] = new Dictionary - { - ["несуществующий"] = new { apiKey = "sk-1234567890ab" }, - }, - }); - - Assert.All(snapshot.AiConfigs.Values, config => Assert.False(config.KeySet)); - Assert.Null(_store.GetStoredJson(SettingsKeys.AiConfigs)); - } - - [Fact] - public async Task ApplyPatchAsync_AiConfigs_BaseUrlMutableOnlyForLocalAndCustomProviders() - { - // Каталоговый облачный провайдер (deepseek): baseUrl фиксирован каталогом — переопределение - // игнорируется (SSRF-гейт, Security review); model при этом применяется. - PublicSettingsDto snapshot = await PatchAsync(new Dictionary - { - ["aiConfigs"] = new Dictionary - { - ["deepseek"] = new { baseUrl = "http://local.example/v1", model = "my-model" }, - }, - }); - - AiConfigPublicDto deepseek = snapshot.AiConfigs["deepseek"]; - Assert.Equal(SettingsDefaults.AiConfigs["deepseek"].BaseUrl, deepseek.BaseUrl); - Assert.Equal("my-model", deepseek.Model); - Assert.False(deepseek.KeySet); - - // Локальный провайдер (ollama) и custom: свой baseUrl разрешён (это их назначение). - PublicSettingsDto local = await PatchAsync(new Dictionary - { - ["aiConfigs"] = new Dictionary - { - ["ollama"] = new { baseUrl = "http://127.0.0.1:11435/v1" }, - ["custom"] = new { baseUrl = "https://my-gw.example/v1" }, - }, - }); - - Assert.Equal("http://127.0.0.1:11435/v1", local.AiConfigs["ollama"].BaseUrl); - Assert.Equal("https://my-gw.example/v1", local.AiConfigs["custom"].BaseUrl); - } - - [Fact] - public async Task ApplyPatchAsync_AiConfigs_ExistingKeySurvivesFollowUpPatchWithoutApiKey() - { - const string apiKey = "sk-1234567890ab"; - await PatchAsync(new Dictionary - { - ["aiConfigs"] = new Dictionary { ["deepseek"] = new { apiKey } }, - }); - string firstStored = _store.GetStoredJson(SettingsKeys.AiConfigs)!; - - PublicSettingsDto snapshot = await PatchAsync(new Dictionary - { - ["aiConfigs"] = new Dictionary - { - // baseUrl каталогового провайдера не переопределяется (SSRF-гейт) — перезаписи нет. - ["deepseek"] = new { baseUrl = "http://other.example/v1" }, - }, - }); - - AiConfigPublicDto deepseek = snapshot.AiConfigs["deepseek"]; - Assert.True(deepseek.KeySet); // ключ сохранён прежним (apiKey не передан) - Assert.Equal("sk-1…90ab", deepseek.KeyMasked); - Assert.Equal(SettingsDefaults.AiConfigs["deepseek"].BaseUrl, deepseek.BaseUrl); - Assert.Equal(firstStored, _store.GetStoredJson(SettingsKeys.AiConfigs)); // значение не изменилось - } - - [Fact] - public async Task ApplyPatchAsync_AiConfigs_MaskedApiKeyIsNotEncryptedAgain() - { - const string apiKey = "sk-1234567890ab"; - await PatchAsync(new Dictionary - { - ["aiConfigs"] = new Dictionary { ["deepseek"] = new { apiKey } }, - }); - - // Повторный PATCH с маской из public-снимка («sk-1…90ab») не должен зашифровать маску как новый ключ - // (Security review: иначе реальный ключ теряется безвозвратно). - PublicSettingsDto snapshot = await PatchAsync(new Dictionary - { - ["aiConfigs"] = new Dictionary - { - ["deepseek"] = new { apiKey = "sk-1…90ab" }, - }, - }); - - AiConfigPublicDto deepseek = snapshot.AiConfigs["deepseek"]; - Assert.True(deepseek.KeySet); // ключ прежний, маска НЕ зашифрована - Assert.Equal("sk-1…90ab", deepseek.KeyMasked); - - // Сохранённый ключ всё ещё исходный (не «enc:маска»): расшифровка даёт оригинал. - string storedJson = _store.GetStoredJson(SettingsKeys.AiConfigs)!; - using var document = JsonDocument.Parse(storedJson); - string storedKey = document.RootElement.GetProperty("deepseek").GetProperty("apiKey").GetString()!; - Assert.Equal(apiKey, _cipher.Decrypt(storedKey)); - } - - [Fact] - public async Task ApplyPatchAsync_AiConfigs_EightCharKeyIsFullyMaskedOnOutput() - { - // Короткий (8 симв.) ключ в public-снимке не раскрывается целиком (Security review: echo-маска). - PublicSettingsDto snapshot = await PatchAsync(new Dictionary - { - ["aiConfigs"] = new Dictionary { ["deepseek"] = new { apiKey = "12345678" } }, - }); - - AiConfigPublicDto deepseek = snapshot.AiConfigs["deepseek"]; - Assert.True(deepseek.KeySet); - Assert.Equal("1…", deepseek.KeyMasked); - } - - [Fact] public async Task ApplyPatchAsync_ColState_PassedThroughAsIs() { @@ -593,7 +394,7 @@ public sealed class SettingsServiceTests public async Task ApplyPatchAsync_TargetCurrencyInBody_NotifiesListenersAfterSave() { var listener = Substitute.For(); - SettingsService local = new(_store.Store, _cipher, new[] { listener }); + SettingsService local = new(_store.Store, new[] { listener }); PublicSettingsDto snapshot = await local.ApplyPatchAsync(JsonBody(new { targetCurrency = "usd" }), CancellationToken.None); @@ -606,7 +407,7 @@ public sealed class SettingsServiceTests public async Task ApplyPatchAsync_ConversionOnInBody_NotifiesListeners() { var listener = Substitute.For(); - SettingsService local = new(_store.Store, _cipher, new[] { listener }); + SettingsService local = new(_store.Store, new[] { listener }); await local.ApplyPatchAsync(JsonBody(new { conversionOn = false }), CancellationToken.None); @@ -618,7 +419,7 @@ public sealed class SettingsServiceTests public async Task ApplyPatchAsync_UnrelatedKeys_DoesNotNotifyListeners() { var listener = Substitute.For(); - SettingsService local = new(_store.Store, _cipher, new[] { listener }); + SettingsService local = new(_store.Store, new[] { listener }); await local.ApplyPatchAsync(JsonBody(new { minLen = 100, rateSource = "mock" }), CancellationToken.None); @@ -629,7 +430,7 @@ public sealed class SettingsServiceTests public async Task ApplyPatchAsync_TargetCurrencyJsonNull_DoesNotNotifyListeners() { var listener = Substitute.For(); - SettingsService local = new(_store.Store, _cipher, new[] { listener }); + SettingsService local = new(_store.Store, new[] { listener }); await local.ApplyPatchAsync(JsonBody(new Dictionary { ["targetCurrency"] = null }), CancellationToken.None); diff --git a/src/core/tests/Deal.Tests.Unit/Support/TestAiConfig.cs b/src/core/tests/Deal.Tests.Unit/Support/TestAiConfig.cs new file mode 100644 index 0000000..a9139dc --- /dev/null +++ b/src/core/tests/Deal.Tests.Unit/Support/TestAiConfig.cs @@ -0,0 +1,51 @@ +using System.Text.Json; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Services; + +namespace Deal.Tests.Unit.Support; + +/// +/// Глобальная конфигурация ИИ для тестов ИИ-вызовов: провайдер каталога с заданным ключом. +/// +internal static class TestAiConfig +{ + /// + /// Провайдер по умолчанию для тестов ИИ-вызовов. + /// + public const string DefaultProviderId = "deepseek"; + + /// + /// API-ключ по умолчанию: конфигурация считается заданной. + /// + public const string DefaultApiKey = "sk-test-deepseek-key"; + + /// + /// Кладёт глобальную конфигурацию ИИ и создаёт поверх неё сервис. + /// + /// Подставка хранилища глобальных настроек. + /// Шифр секретов. + /// Id провайдера каталога. + /// Базовый URL (пусто — дефолт каталога). + /// Модель (пусто — первая из каталога). + /// API-ключ открытым текстом (пусто — без ключа). + /// Сервис глобальной конфигурации ИИ. + public static AiGlobalConfigService New( + TestGlobalSettingsStore store, + ISecretCipher cipher, + string providerId = DefaultProviderId, + string baseUrl = "", + string model = "", + string apiKey = DefaultApiKey) + { + store.Preload(GlobalSettingsKeys.AiConfig, JsonSerializer.Serialize(new + { + providerId, + baseUrl, + model, + apiKey = apiKey.Length == 0 ? string.Empty : cipher.Encrypt(apiKey), + })); + + return new AiGlobalConfigService(store.Store, cipher); + } +}