190 lines
8.4 KiB
C#
190 lines
8.4 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// Подписка нового списка данных на уведомления SharePoint (POST /Webhook/Subscribe).
|
||
///
|
||
/// Проверяется ровно то, что нельзя увидеть по типам: как складывается строка
|
||
/// запроса (ручка принимает всё параметрами, тела нет) и что сбой портала не рушит
|
||
/// провижининг — подписка следствие создания списка, а не его условие.
|
||
/// </summary>
|
||
public sealed class SharePointWebhookSubscriberTests
|
||
{
|
||
private const string SiteUrl = "https://tnsukz.sharepoint.com/sites/requests";
|
||
private const string ListTitle = "Обходной лист 3 — данные";
|
||
|
||
/// <summary>Перехватывает запрос вместо сети: запоминает его и отдаёт заданный ответ.</summary>
|
||
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
|
||
{
|
||
public HttpRequestMessage? Captured { get; private set; }
|
||
public int CallCount { get; private set; }
|
||
|
||
protected override Task<HttpResponseMessage> 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<HttpRequestMessage, HttpResponseMessage> 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<HttpSharePointWebhookSubscriber>.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);
|
||
}
|
||
}
|