From a1e6c5c8b4b86c933f1dabdd8acea2318cf7bcfd Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Wed, 19 Aug 2026 13:30:09 +0500 Subject: [PATCH] =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=BA?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Tnsu.ApprovalRoute.Api/appsettings.json | 3 +- .../CreateNextListPartitionJob.cs | 19 +- .../ListPartitioning/ListPartitionService.cs | 8 +- ...frastructureServiceCollectionExtensions.cs | 15 ++ .../SharePoint/SharePointListProvisioner.cs | 13 ++ .../SharePoint/SharePointOptions.cs | 11 + .../HttpSharePointWebhookSubscriber.cs | 94 +++++++++ .../TnsuPortal/HttpTnsuPortalClient.cs | 20 +- .../ISharePointWebhookSubscriber.cs | 16 ++ .../TnsuPortal/TnsuPortalAuthorizer.cs | 39 ++++ .../TnsuPortal/TnsuPortalCallerToken.cs | 37 ++++ .../SharePointWebhookSubscriberTests.cs | 189 ++++++++++++++++++ 12 files changed, 443 insertions(+), 21 deletions(-) create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpSharePointWebhookSubscriber.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ISharePointWebhookSubscriber.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalAuthorizer.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalCallerToken.cs create mode 100644 backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/SharePointWebhookSubscriberTests.cs diff --git a/backend/Tnsu.ApprovalRoute.Api/appsettings.json b/backend/Tnsu.ApprovalRoute.Api/appsettings.json index 184daba..d6fc1ee 100644 --- a/backend/Tnsu.ApprovalRoute.Api/appsettings.json +++ b/backend/Tnsu.ApprovalRoute.Api/appsettings.json @@ -39,7 +39,8 @@ "TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a", "ClientId": "9232d9e2-9807-4753-808f-07250e5474b0", "CertificateSettingName": "AzurePfx", - "CertificatePassword": "Rk.xDjccnfyjdktybz1" + "CertificatePassword": "Rk.xDjccnfyjdktybz1", + "NotificationUrl": "https://api.tnsu.kz/Webhook/SharePoint" }, "TnsuPortal": { "BaseUrl": "https://api.tnsu.kz", diff --git a/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/CreateNextListPartitionJob.cs b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/CreateNextListPartitionJob.cs index 9c6d1dc..06494c1 100644 --- a/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/CreateNextListPartitionJob.cs +++ b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/CreateNextListPartitionJob.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Tnsu.ApprovalRoute.Infrastructure.Persistence; using Tnsu.ApprovalRoute.Infrastructure.SharePoint; +using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; namespace Tnsu.ApprovalRoute.Application.ListPartitioning; @@ -35,11 +36,27 @@ public sealed class CreateNextListPartitionJob( ListPartitioningLock provisioningLock, ListPartitionRegistry registry, ISharePointListProvisioner provisioner, + TnsuPortalCallerToken callerToken, IOptions options, ILogger logger) { - public async Task RunAsync(int requestTypeId, CancellationToken cancellationToken) + /// + /// Заголовок Authorization пользователя, чей запрос вызвал провижининг (см. + /// ListPartitionService.TryEnqueueProvisioningAsync). Нужен, чтобы подписать + /// новый список на уведомления от имени этого пользователя: своего HttpContext + /// у фоновой задачи нет, а client_credentials сейчас не настроен (см. + /// TnsuPortalAuthorizer). Провижининг занимает секунды, поэтому токен не + /// успевает истечь — но на ПОВТОРЕ Hangfire (минуты спустя) он уже может быть + /// просроченным: тогда подписка вернёт 401 и упадёт мягко, не сорвав создание + /// списка (см. ISharePointWebhookSubscriber). + /// + /// Учтите: как аргумент задачи он попадает в хранилище Hangfire (Postgres) и + /// виден в его дашборде. + /// + public async Task RunAsync(int requestTypeId, string? authorization, CancellationToken cancellationToken) { + callerToken.Set(authorization); + // Единственная настоящая защита от параллельного запуска: атомарный захват // флага в строке request_types. Проверка перед постановкой в очередь — // только чтобы не плодить лишние задачи; гонку разруливает именно здесь. diff --git a/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionService.cs b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionService.cs index b0567c0..f7862c9 100644 --- a/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/ListPartitioning/ListPartitionService.cs @@ -7,6 +7,7 @@ using Tnsu.ApprovalRoute.Application.Common.Errors; using Tnsu.ApprovalRoute.Domain.Entities; using Tnsu.ApprovalRoute.Infrastructure.Persistence; using Tnsu.ApprovalRoute.Infrastructure.SharePoint; +using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; namespace Tnsu.ApprovalRoute.Application.ListPartitioning; @@ -19,6 +20,7 @@ public sealed class ListPartitionService( ISharePointClient sharePoint, ListPartitionRegistry registry, IBackgroundJobClient backgroundJobs, + TnsuPortalCallerToken callerToken, IOptions options, ILogger logger) { @@ -151,8 +153,12 @@ public sealed class ListPartitionService( // самой задачи (см. CreateNextListPartitionJob). if (requestType.ListPartitioningStartedAt is not null) return false; + // Токен ЭТОГО запроса уезжает в задачу: подписать новый список на + // уведомления нужно от имени пользователя, а своего токена у фоновой задачи + // нет (см. CreateNextListPartitionJob.RunAsync — там же про его хранение и + // срок жизни). backgroundJobs.Enqueue( - job => job.RunAsync(requestType.Id, CancellationToken.None)); + job => job.RunAsync(requestType.Id, callerToken.Current, CancellationToken.None)); logger.LogInformation( "В списке {ListId} типа заявок {RequestTypeId} {ItemCount} элементов (порог {Threshold}) — " + diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs index bf46e61..9d62bf5 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -31,6 +31,21 @@ public static class InfrastructureServiceCollectionExtensions services.Configure(configuration.GetSection("TnsuPortal")); services.AddHttpClient(); + // Токен вызывающего и общая авторизация запросов к порталу — одни на всех + // его клиентов (см. TnsuPortalCallerToken/TnsuPortalAuthorizer). Именно + // Scoped: Hangfire даёт задаче свою область DI, и положенный в задаче токен + // не должен протекать в соседние. + services.AddScoped(); + services.AddScoped(); + + // Подписка новых списков на уведомления SharePoint. Клиент отдельный от + // ITnsuPortalClient: там кешируемые чтения справочника, здесь команда. + // Своих опций нет — адрес приёмника лежит в SharePoint:NotificationUrl. + services.AddHttpClient((sp, client) => + { + var options = sp.GetRequiredService>().Value; + client.BaseAddress = new Uri(options.BaseUrl); + }); // ITnsuPortalClient резолвится в CachingTnsuPortalClient (кеш на 5 минут, // см. её комментарий) — сам HttpTnsuPortalClient регистрируем по // конкретному типу, а не по интерфейсу, чтобы декоратор мог его получить diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointListProvisioner.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointListProvisioner.cs index 9021951..8f7714c 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointListProvisioner.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointListProvisioner.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using Microsoft.Extensions.Logging; +using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint; @@ -16,6 +17,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint; /// public sealed class SharePointListProvisioner( ISharePointClient sharePoint, + ISharePointWebhookSubscriber webhookSubscriber, ILogger logger) : ISharePointListProvisioner { public async Task CreateNextListPairAsync( @@ -47,6 +49,17 @@ public sealed class SharePointListProvisioner( "Пара списков №{Number} готова за {ElapsedMs} мс: данные «{Title}» ({ListId}).", number, stopwatch.ElapsedMilliseconds, newData.Title, newData.ServerRelativeUrl); + // Подписка — ПОСЛЕДНИМ шагом, когда списки уже созданы: + // • подписывать нечего, пока списка нет; + // • только для списка ДАННЫХ — заявки портал отслеживает по нему, списку + // согласования подписка не нужна; + // • в самом конце, а не сразу после создания списка данных: сорвись + // дублирование пары на списке согласования, подписка была бы уже + // заведена, и повтор задачи Hangfire отправил бы второй запрос на ту же + // подписку (идемпотентна ли ручка портала — неизвестно). + // Не бросает и провижининг не рушит (см. ISharePointWebhookSubscriber). + await webhookSubscriber.TrySubscribeAsync(siteUrl, newData.Title, cancellationToken); + return new ProvisionedList(newData.ServerRelativeUrl, newData.Title); } diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointOptions.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointOptions.cs index 71186da..2a4b14c 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointOptions.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointOptions.cs @@ -20,4 +20,15 @@ public sealed class SharePointOptions /// это здесь не стоит — пароль от сертификата в исходниках не место. /// public string CertificatePassword { get; set; } = string.Empty; + + /// + /// Куда SharePoint будет присылать уведомления об изменениях списка — параметр + /// notificationUrl ручки POST /Webhook/Subscribe, которой новый список данных + /// подписывается на уведомления (см. HttpSharePointWebhookSubscriber). + /// Это приёмник на СТОРОНЕ ПОРТАЛА, а не адрес нашего сервиса. + /// + /// Пусто — подписывать не будем вовсе: без адреса приёмника подписка + /// бессмысленна, и это же единственный способ её выключить. + /// + public string NotificationUrl { get; set; } = "https://api.tnsu.kz/Webhook/SharePoint"; } diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpSharePointWebhookSubscriber.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpSharePointWebhookSubscriber.cs new file mode 100644 index 0000000..92cad00 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpSharePointWebhookSubscriber.cs @@ -0,0 +1,94 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Tnsu.ApprovalRoute.Infrastructure.SharePoint; + +namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +/// +/// POST {BaseUrl}/Webhook/Subscribe?listName=…&url=…&notificationUrl=… — просит +/// портал завести подписку SharePoint на только что созданный список данных. +/// +/// Параметры уходят строкой запроса, тела у запроса нет — так описана ручка. +/// +/// Отдельный клиент, а не метод в ITnsuPortalClient: тот про справочник сотрудников +/// и обёрнут кешем (CachingTnsuPortalClient), а здесь команда, которую кешировать +/// нельзя ни в каком виде. Авторизация общая с ним — см. TnsuPortalAuthorizer. +/// +/// Адрес приёмника уведомлений живёт в SharePoint:NotificationUrl — в одном блоке с +/// остальными настройками SharePoint, а не в своём: подписка относится к спискам, +/// а не к порталу как таковому. +/// +/// Ошибки НЕ пробрасываются наружу. Список к этому моменту уже создан, и уронить +/// провижининг из-за недоступности портала значило бы оставить партиционирование +/// нерабочим целиком (задача Hangfire ушла бы в повторы, а порог по элементам +/// продолжал бы срабатывать). Поэтому сбой — Error в лог с полным контекстом +/// (имя списка и сайт видны, подписку можно завести руками), а провижининг идёт +/// дальше. Отдельно предупреждаем про 401/403: в фоновой задаче нет входящего +/// токена, а client_credentials требует заполненных TnsuPortal:ClientSecret/Scope +/// (в конфигурации они пустые — см. TnsuPortalAuthorizer). +/// +public sealed class HttpSharePointWebhookSubscriber( + HttpClient httpClient, + TnsuPortalAuthorizer authorizer, + IOptions options, + ILogger logger) : ISharePointWebhookSubscriber +{ + public async Task TrySubscribeAsync(string siteUrl, string listTitle, CancellationToken cancellationToken) + { + var notificationUrl = options.Value.NotificationUrl; + if (string.IsNullOrWhiteSpace(notificationUrl)) + { + // Без адреса приёмника подписка бессмысленна — он же служит выключателем + // (см. SharePointOptions.NotificationUrl). + logger.LogInformation( + "SharePoint:NotificationUrl не задан — список «{ListTitle}» на уведомления не подписан.", + listTitle); + return false; + } + + if (string.IsNullOrWhiteSpace(listTitle)) + { + // Без имени списка ручке нечего подписывать: у неё нет другого способа + // его найти (адрес списка она не принимает). + logger.LogError("Заголовок нового списка пуст — подписка на уведомления невозможна."); + return false; + } + + // Полный адрес сайта целиком («https://tnsukz.sharepoint.com/sites/requests»), + // а не короткое имя — именно это ждёт ручка. + var site = siteUrl.TrimEnd('/'); + var url = + $"/Webhook/Subscribe?listName={Uri.EscapeDataString(listTitle)}" + + $"&url={Uri.EscapeDataString(site)}" + + $"¬ificationUrl={Uri.EscapeDataString(notificationUrl)}"; + + try + { + using var request = new HttpRequestMessage(HttpMethod.Post, url); + await authorizer.AuthorizeAsync(request, cancellationToken); + + using var response = await httpClient.SendAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) + { + // Тело читаем именно на неуспехе — в нём причина отказа портала. + var body = await response.Content.ReadAsStringAsync(cancellationToken); + logger.LogError( + "Портал отказал в подписке списка «{ListTitle}» (сайт {Site}): {StatusCode}. Ответ: {Body}", + listTitle, site, (int)response.StatusCode, body); + return false; + } + + logger.LogInformation( + "Список «{ListTitle}» (сайт {Site}) подписан на уведомления SharePoint.", listTitle, site); + return true; + } + catch (Exception ex) + { + logger.LogError( + ex, + "Не удалось подписать список «{ListTitle}» (сайт {Site}) на уведомления — подпишите вручную.", + listTitle, site); + return false; + } + } +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs index 9c050ac..40c5b9c 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs @@ -1,17 +1,14 @@ using System.Net; -using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; -using Microsoft.AspNetCore.Http; using Tnsu.ApprovalRoute.Domain.Enums; namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; public sealed class HttpTnsuPortalClient( HttpClient httpClient, - TnsuPortalTokenProvider tokenProvider, - IHttpContextAccessor httpContextAccessor) : ITnsuPortalClient + TnsuPortalAuthorizer authorizer) : ITnsuPortalClient { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; @@ -73,7 +70,7 @@ public sealed class HttpTnsuPortalClient( private async Task GetAsync(string requestUri, CancellationToken cancellationToken) where T : class { using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); - await AuthorizeAsync(request, cancellationToken); + await authorizer.AuthorizeAsync(request, cancellationToken); using var response = await httpClient.SendAsync(request, cancellationToken); if (response.StatusCode == HttpStatusCode.NotFound) @@ -89,19 +86,6 @@ public sealed class HttpTnsuPortalClient( executors.Add(new ProjectExecutorInfo(role, email)); } - private async Task AuthorizeAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var incoming = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString(); - if (!string.IsNullOrWhiteSpace(incoming)) - { - request.Headers.TryAddWithoutValidation("Authorization", incoming); - return; - } - - var token = await tokenProvider.GetAccessTokenAsync(cancellationToken); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - } - private sealed record EmployeeResponse( [property: JsonPropertyName("email")] string? Email, [property: JsonPropertyName("company")] string? Company, diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ISharePointWebhookSubscriber.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ISharePointWebhookSubscriber.cs new file mode 100644 index 0000000..890273d --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ISharePointWebhookSubscriber.cs @@ -0,0 +1,16 @@ +namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +/// +/// Подписывает список данных SharePoint на уведомления об изменениях через портал. +/// +public interface ISharePointWebhookSubscriber +{ + /// + /// Подписать список. НЕ бросает: подписка — следствие создания списка, а не его + /// условие (см. реализацию), поэтому сбой портала не должен рушить провижининг + /// партиции. Возвращает true, только если портал подтвердил подписку. + /// + /// Абсолютный адрес сайта, где создан список. + /// Заголовок списка данных — русское имя, как в SharePoint. + Task TrySubscribeAsync(string siteUrl, string listTitle, CancellationToken cancellationToken); +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalAuthorizer.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalAuthorizer.cs new file mode 100644 index 0000000..124f70b --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalAuthorizer.cs @@ -0,0 +1,39 @@ +using System.Net.Http.Headers; + +namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +/// +/// Авторизация запросов к api.tnsu.kz. Вынесена из HttpTnsuPortalClient, потому что +/// к порталу ходит не только справочник сотрудников (см. +/// HttpSharePointWebhookSubscriber), а правило выбора токена должно быть одно. +/// +/// Токен берётся в двух режимах, и порядок принципиален: +/// 1) Токен вызывающего пользователя (см. TnsuPortalCallerToken) — из текущего +/// HTTP-запроса либо переданный аргументом в фоновую задачу. Портал тогда +/// видит исходного пользователя, а не сервисное приложение. +/// 2) Его нет вовсе — client_credentials самого сервиса (см. +/// TnsuPortalTokenProvider). +/// +/// ВАЖНО про второй путь: TnsuPortal:ClientSecret/Scope в конфигурации пусты +/// (см. appsettings.json), поэтому он сейчас нерабочий — это именно фолбэк, а не +/// штатный режим. Вызывающая сторона обязана это учитывать: см. +/// HttpSharePointWebhookSubscriber, где сбой подписки намеренно не рушит +/// провижининг списка. +/// +public sealed class TnsuPortalAuthorizer( + TnsuPortalCallerToken callerToken, + TnsuPortalTokenProvider tokenProvider) +{ + public async Task AuthorizeAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var caller = callerToken.Current; + if (!string.IsNullOrWhiteSpace(caller)) + { + request.Headers.TryAddWithoutValidation("Authorization", caller); + return; + } + + var token = await tokenProvider.GetAccessTokenAsync(cancellationToken); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + } +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalCallerToken.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalCallerToken.cs new file mode 100644 index 0000000..882aa64 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalCallerToken.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Http; + +namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +/// +/// Authorization, с которым идти в api.tnsu.kz в пределах текущей области DI. +/// +/// Два источника, и второй нужен именно фоновым задачам: +/// • обрабатывается HTTP-запрос — заголовок берётся прямо из него; +/// • выполняется задача Hangfire — своего HttpContext'а у неё нет, поэтому +/// токен инициировавшего пользователя ей передают аргументом и кладут сюда +/// через (см. CreateNextListPartitionJob). +/// +/// Scoped: Hangfire создаёт отдельную область DI на каждое выполнение задачи, +/// поэтому положенный здесь токен не протекает между задачами. +/// +public sealed class TnsuPortalCallerToken(IHttpContextAccessor httpContextAccessor) +{ + private string? _explicit; + + /// + /// Заголовок целиком, вида «Bearer …». null — вызывающего токена нет + /// (тогда авторизация уходит на client_credentials, см. TnsuPortalAuthorizer). + /// + public string? Current + { + get + { + var incoming = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString(); + return string.IsNullOrWhiteSpace(incoming) ? _explicit : incoming; + } + } + + /// Положить токен явно — для кода без HttpContext (фоновые задачи). + public void Set(string? authorization) => + _explicit = string.IsNullOrWhiteSpace(authorization) ? null : authorization; +} diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/SharePointWebhookSubscriberTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/SharePointWebhookSubscriberTests.cs new file mode 100644 index 0000000..996c5dd --- /dev/null +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/SharePointWebhookSubscriberTests.cs @@ -0,0 +1,189 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Shouldly; +using Tnsu.ApprovalRoute.Infrastructure.SharePoint; +using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; + +/// +/// Подписка нового списка данных на уведомления SharePoint (POST /Webhook/Subscribe). +/// +/// Проверяется ровно то, что нельзя увидеть по типам: как складывается строка +/// запроса (ручка принимает всё параметрами, тела нет) и что сбой портала не рушит +/// провижининг — подписка следствие создания списка, а не его условие. +/// +public sealed class SharePointWebhookSubscriberTests +{ + private const string SiteUrl = "https://tnsukz.sharepoint.com/sites/requests"; + private const string ListTitle = "Обходной лист 3 — данные"; + + /// Перехватывает запрос вместо сети: запоминает его и отдаёт заданный ответ. + private sealed class StubHandler(Func respond) : HttpMessageHandler + { + public HttpRequestMessage? Captured { get; private set; } + public int CallCount { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + Captured = request; + CallCount++; + return Task.FromResult(respond(request)); + } + } + + // Токен вызывающего есть — тогда авторизатор пробрасывает его и не идёт за + // client_credentials (у которого в тестах нет ни секрета, ни сети). + // fromHttpContext=false — путь фоновой задачи: токен положен явно, HttpContext'а + // нет вовсе (см. TnsuPortalCallerToken.Set / CreateNextListPartitionJob). + private static TnsuPortalAuthorizer BuildAuthorizer(bool fromHttpContext = true) + { + var accessor = new HttpContextAccessor(); + var callerToken = new TnsuPortalCallerToken(accessor); + + if (fromHttpContext) + { + var context = new DefaultHttpContext(); + context.Request.Headers.Authorization = "Bearer test-token"; + accessor.HttpContext = context; + } + else + { + callerToken.Set("Bearer test-token"); + } + + return new TnsuPortalAuthorizer( + callerToken, + new TnsuPortalTokenProvider(new HttpClient(), Options.Create(new TnsuPortalOptions()))); + } + + private static (HttpSharePointWebhookSubscriber Subscriber, StubHandler Handler) Build( + Func respond, + SharePointOptions? options = null, + bool tokenFromHttpContext = true) + { + var handler = new StubHandler(respond); + var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.tnsu.kz") }; + + var subscriber = new HttpSharePointWebhookSubscriber( + httpClient, + BuildAuthorizer(tokenFromHttpContext), + Options.Create(options ?? new SharePointOptions()), + NullLogger.Instance); + + return (subscriber, handler); + } + + private static HttpResponseMessage Ok() => new(System.Net.HttpStatusCode.OK); + + [Fact(DisplayName = "Подписка уходит POST'ом со всеми тремя параметрами в строке запроса")] + public async Task TrySubscribeAsync_SendsPostWithQueryParameters() + { + // Arrange + var (subscriber, handler) = Build(_ => Ok()); + + // Action + var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None); + + // Assert + subscribed.ShouldBeTrue(); + handler.Captured!.Method.ShouldBe(HttpMethod.Post); + handler.Captured.Content.ShouldBeNull(); // тела у ручки нет + + var query = Uri.UnescapeDataString(handler.Captured.RequestUri!.Query); + handler.Captured.RequestUri.AbsolutePath.ShouldBe("/Webhook/Subscribe"); + query.ShouldContain($"listName={ListTitle}"); + query.ShouldContain("notificationUrl=https://api.tnsu.kz/Webhook/SharePoint"); + // Токен из входящего запроса пробрасывается как есть (см. TnsuPortalAuthorizer). + handler.Captured.Headers.Authorization?.ToString().ShouldBe("Bearer test-token"); + } + + [Fact(DisplayName = "В url уходит ПОЛНЫЙ адрес сайта, а не короткое имя")] + public async Task TrySubscribeAsync_SendsFullSiteUrl() + { + // Arrange + var (subscriber, handler) = Build(_ => Ok()); + + // Action + await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None); + + // Assert + var query = Uri.UnescapeDataString(handler.Captured!.RequestUri!.Query); + query.ShouldContain($"url={SiteUrl}"); + } + + [Fact(DisplayName = "Токен фоновой задачи (без HttpContext) тоже уходит в заголовок")] + public async Task TrySubscribeAsync_TokenSetExplicitly_IsSentAsAuthorization() + { + // Arrange: так работает провижининг — HttpContext'а нет, токен передан + // аргументом задачи (см. CreateNextListPartitionJob). + var (subscriber, handler) = Build(_ => Ok(), tokenFromHttpContext: false); + + // Action + var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None); + + // Assert + subscribed.ShouldBeTrue(); + handler.Captured!.Headers.Authorization?.ToString().ShouldBe("Bearer test-token"); + } + + [Fact(DisplayName = "Отказ портала не бросает исключение — провижининг списка не рушится")] + public async Task TrySubscribeAsync_PortalRejects_ReturnsFalseWithoutThrowing() + { + // Arrange + var (subscriber, _) = Build(_ => new HttpResponseMessage(System.Net.HttpStatusCode.Unauthorized) + { + Content = new StringContent("no token") + }); + + // Action + var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None); + + // Assert + subscribed.ShouldBeFalse(); + } + + [Fact(DisplayName = "Недоступный портал (исключение в сети) тоже не бросает наружу")] + public async Task TrySubscribeAsync_NetworkFailure_ReturnsFalseWithoutThrowing() + { + // Arrange + var (subscriber, _) = Build(_ => throw new HttpRequestException("портал недоступен")); + + // Action + var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None); + + // Assert + subscribed.ShouldBeFalse(); + } + + [Fact(DisplayName = "NotificationUrl не задан — запрос не отправляется вовсе")] + public async Task TrySubscribeAsync_NoNotificationUrl_DoesNotCallPortal() + { + // Arrange: пустой адрес приёмника — единственный выключатель подписки + // (отдельного флага Enabled нет, см. SharePointOptions.NotificationUrl). + var (subscriber, handler) = Build(_ => Ok(), new SharePointOptions { NotificationUrl = "" }); + + // Action + var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None); + + // Assert + subscribed.ShouldBeFalse(); + handler.CallCount.ShouldBe(0); + } + + [Fact(DisplayName = "Пустой заголовок списка — подписывать нечего, запроса нет")] + public async Task TrySubscribeAsync_EmptyListTitle_DoesNotCallPortal() + { + // Arrange + var (subscriber, handler) = Build(_ => Ok()); + + // Action + var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, " ", CancellationToken.None); + + // Assert + subscribed.ShouldBeFalse(); + handler.CallCount.ShouldBe(0); + } +}