Files
serega404 8ac593d36f
Backend CI / build-and-test (push) Failing after 14m19s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Failing after 12m5s
Frontend CI / build-and-check (push) Failing after 17m58s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Failing after 10m11s
🚀 Create and publish a Docker image / Build & publish backend image (push) Failing after 11m3s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Failing after 14m58s
feat: изменил логику анализа отзывов
2026-05-22 01:30:41 +03:00

318 lines
26 KiB
C#

using System.Net;
using UniVerse.Api.Tests.Helpers;
using Xunit;
namespace UniVerse.Api.Tests.Authorization;
/// <summary>
/// Интеграционные тесты для ролевого контроля доступа ко всем конечным точкам API.
///
/// Каждый тестовый случай представляет собой кортеж:
/// (description, method, url, requiredRole, forbiddenRoles[])
///
/// Три типа сценариев для каждой конечной точки:
/// A) Анонимный → 401 Unauthorized
/// B) Неправильная роль → 403 Forbidden
/// C) Правильная роль → не 401 / не 403 (зависит от бизнес-логики: успех или доменная ошибка)
/// </summary>
public class EndpointAuthorizationTests : IClassFixture<ApiWebApplicationFactory>
{
private readonly HttpClient _client;
public EndpointAuthorizationTests(ApiWebApplicationFactory factory)
{
_client = factory.CreateClient();
}
// ─────────────────────────────────────────────────────────────────────────
// Тестовые данные
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// Конечные точки, требующие аутентификации (не анонимные).
/// Формат: (description, method, url, correctRole, forbiddenRoles[])
///
/// "AnyAuth" означает, что достаточно любого валидного JWT — без ограничения по роли.
/// Для конечных точек с несколькими ролями (Admin,Teacher) обе роли указаны как правильные.
/// </summary>
public static IEnumerable<object[]> AuthenticatedEndpoints()
{
// ── Auth ─────────────────────────────────────────────────────────────
yield return E("auth/logout [AnyAuth]", "POST", "api/v1/auth/logout", "Student");
yield return E("auth/me [AnyAuth]", "GET", "api/v1/auth/me", "Student");
// ── Users — current user ──────────────────────────────────────────────
yield return E("users/me GET [AnyAuth]", "GET", "api/v1/users/me", "Student");
yield return E("users/me PUT [AnyAuth]", "PUT", "api/v1/users/me", "Student",
body: """{"displayName":"Test","avatarUrl":null}""");
yield return E("users/me/stats [AnyAuth]", "GET", "api/v1/users/me/stats", "Student");
yield return E("users/me/enrollments [AnyAuth]", "GET", "api/v1/users/me/enrollments", "Student");
yield return E("users/me/reviews [AnyAuth]", "GET", "api/v1/users/me/reviews", "Student");
yield return E("users/me/achievements [AnyAuth]", "GET", "api/v1/users/me/achievements", "Student");
yield return E("users/me/transactions [AnyAuth]", "GET", "api/v1/users/me/transactions", "Student");
// ── Users — Admin only ────────────────────────────────────────────────
yield return E("users GET [Admin]", "GET", "api/v1/users", "Admin", forbidden: ["Student", "Teacher"]);
yield return E("users/{id} GET [Admin]", "GET", "api/v1/users/1", "Admin", forbidden: ["Student", "Teacher"]);
yield return E("users/{id} PUT [Admin]", "PUT", "api/v1/users/1", "Admin", forbidden: ["Student", "Teacher"],
body: """{"displayName":"Test","avatarUrl":null}""");
yield return E("users/{id}/stats [Admin]", "GET", "api/v1/users/1/stats", "Admin", forbidden: ["Student", "Teacher"]);
yield return E("users/{id}/enrollments [Admin]", "GET", "api/v1/users/1/enrollments", "Admin", forbidden: ["Student", "Teacher"]);
yield return E("users/{id}/reviews [Admin]", "GET", "api/v1/users/1/reviews","Admin", forbidden: ["Student", "Teacher"]);
yield return E("users/{id}/achievements [Admin]", "GET", "api/v1/users/1/achievements","Admin", forbidden: ["Student", "Teacher"]);
yield return E("users/{id}/transactions [Admin]", "GET", "api/v1/users/1/transactions","Admin", forbidden: ["Student", "Teacher"]);
yield return E("users/{id}/role PATCH [Admin]", "PATCH", "api/v1/users/1/role", "Admin", forbidden: ["Student", "Teacher"],
body: "\"Student\"");
yield return E("users/{id}/active PATCH [Admin]", "PATCH", "api/v1/users/1/active", "Admin", forbidden: ["Student", "Teacher"],
body: "true");
// ── Courses — any auth ────────────────────────────────────────────────
yield return E("courses GET [AnyAuth]", "GET", "api/v1/courses", "Student");
yield return E("courses/{id} GET [AnyAuth]", "GET", "api/v1/courses/1", "Student");
// ── Courses — Admin only ──────────────────────────────────────────────
yield return E("courses POST [Admin]", "POST", "api/v1/courses", "Admin", forbidden: ["Student", "Teacher"],
body: """{"name":"Course","description":null}""");
yield return E("courses/{id} PUT [Admin]", "PUT", "api/v1/courses/1", "Admin", forbidden: ["Student", "Teacher"],
body: """{"name":"Course","description":null}""");
yield return E("courses/{id} DELETE [Admin]", "DELETE", "api/v1/courses/1", "Admin", forbidden: ["Student", "Teacher"]);
yield return E("courses/{id}/tags POST [Admin]", "POST", "api/v1/courses/1/tags", "Admin", forbidden: ["Student", "Teacher"],
body: "1");
yield return E("courses/{id}/tags/{tagId} DELETE [Admin]","DELETE","api/v1/courses/1/tags/1","Admin",forbidden: ["Student", "Teacher"]);
// ── Lectures — any auth ───────────────────────────────────────────────
yield return E("lectures GET [AnyAuth]", "GET", "api/v1/lectures", "Student");
yield return E("lectures/{id} GET [AnyAuth]", "GET", "api/v1/lectures/1", "Student");
// ── Lectures — Admin only ─────────────────────────────────────────────
yield return E("lectures POST [Admin]", "POST", "api/v1/lectures", "Admin", forbidden: ["Student", "Teacher"],
body: """{"courseId":1,"teacherId":null,"locationId":null,"title":"T","description":null,"format":"Offline","startsAt":"2026-09-01T10:00:00Z","endsAt":"2026-09-01T12:00:00Z","isOpen":true,"maxEnrollments":30,"onlineUrl":null}""");
yield return E("lectures/{id} DELETE [Admin]", "DELETE", "api/v1/lectures/1", "Admin", forbidden: ["Student", "Teacher"]);
// ── Lectures — Admin OR Teacher ───────────────────────────────────────
yield return E("lectures/{id} PUT [Admin]", "PUT", "api/v1/lectures/1", "Admin", forbidden: ["Student"],
body: """{"teacherId":null,"locationId":null,"title":"T","description":null,"format":"Offline","startsAt":"2026-09-01T10:00:00Z","endsAt":"2026-09-01T12:00:00Z","isOpen":true,"maxEnrollments":30,"onlineUrl":null}""");
yield return E("lectures/{id} PUT [Teacher]", "PUT", "api/v1/lectures/1", "Teacher", forbidden: ["Student"],
body: """{"teacherId":null,"locationId":null,"title":"T","description":null,"format":"Offline","startsAt":"2026-09-01T10:00:00Z","endsAt":"2026-09-01T12:00:00Z","isOpen":true,"maxEnrollments":30,"onlineUrl":null}""");
yield return E("lectures/{id}/attendance PATCH [Admin]", "PATCH","api/v1/lectures/1/attendance/2","Admin", forbidden: ["Student"],
body: "true");
yield return E("lectures/{id}/attendance PATCH [Teacher]","PATCH","api/v1/lectures/1/attendance/2","Teacher",forbidden: ["Student"],
body: "true");
yield return E("lectures/{id}/enrollments GET [Admin]", "GET","api/v1/lectures/1/enrollments","Admin", forbidden: ["Student"]);
yield return E("lectures/{id}/enrollments GET [Teacher]","GET","api/v1/lectures/1/enrollments","Teacher",forbidden: ["Student"]);
yield return E("lectures/{id}/reviews GET [Admin]", "GET","api/v1/lectures/1/reviews","Admin", forbidden: ["Student"]);
yield return E("lectures/{id}/reviews GET [Teacher]", "GET","api/v1/lectures/1/reviews","Teacher",forbidden: ["Student"]);
// ── Lectures — Student only ───────────────────────────────────────────
yield return E("lectures/{id}/enroll POST [Student]", "POST", "api/v1/lectures/1/enroll", "Student", forbidden: ["Admin", "Teacher"]);
yield return E("lectures/{id}/enroll DELETE [Student]", "DELETE","api/v1/lectures/1/enroll", "Student", forbidden: ["Admin", "Teacher"]);
// ── Reviews — any auth ────────────────────────────────────────────────
yield return E("reviews/{id} PUT [AnyAuth]", "PUT", "api/v1/reviews/1", "Student",
body: """{"rating":"Like","text":"Updated"}""");
yield return E("reviews/{id} DELETE [AnyAuth]", "DELETE", "api/v1/reviews/1", "Student");
// ── Reviews — Admin OR Teacher ───────────────────────────────────────
yield return E("reviews/{id} GET [Admin]", "GET", "api/v1/reviews/1", "Admin", forbidden: ["Student"]);
yield return E("reviews/{id} GET [Teacher]", "GET", "api/v1/reviews/1", "Teacher", forbidden: ["Student"]);
// ── Reviews — Student only ────────────────────────────────────────────
yield return E("reviews POST [Student]", "POST", "api/v1/reviews", "Student", forbidden: ["Admin", "Teacher"],
body: """{"lectureId":1,"rating":"Like","text":"Great lecture!"}""");
// ── Reviews — Admin only ──────────────────────────────────────────────
yield return E("reviews GET [Admin]", "GET", "api/v1/reviews", "Admin", forbidden: ["Student", "Teacher"]);
yield return E("reviews/llm-prompt GET [Admin]", "GET", "api/v1/reviews/llm-prompt","Admin", forbidden: ["Student", "Teacher"]);
yield return E("reviews/llm-prompt PUT [Admin]", "PUT", "api/v1/reviews/llm-prompt","Admin", forbidden: ["Student", "Teacher"],
body: """{"prompt":"Analyze {lectureContext}. Review: {reviewText}"}""");
yield return E("reviews/{id}/reanalyze POST [Admin]","POST", "api/v1/reviews/1/reanalyze","Admin",forbidden: ["Student", "Teacher"]);
// ── Tags — any auth ───────────────────────────────────────────────────
yield return E("tags GET [AnyAuth]", "GET", "api/v1/tags", "Student");
yield return E("tags/{id} GET [AnyAuth]", "GET", "api/v1/tags/1", "Student");
yield return E("tags/tree GET [AnyAuth]", "GET", "api/v1/tags/tree", "Student");
// ── Tags — Admin only ─────────────────────────────────────────────────
yield return E("tags POST [Admin]", "POST", "api/v1/tags", "Admin", forbidden: ["Student", "Teacher"],
body: """{"name":"Tag","type":"Topic","parentId":null}""");
yield return E("tags/{id} PUT [Admin]", "PUT", "api/v1/tags/1", "Admin", forbidden: ["Student", "Teacher"],
body: """{"name":"Tag","type":"Topic","parentId":null}""");
yield return E("tags/{id} DELETE [Admin]", "DELETE", "api/v1/tags/1", "Admin", forbidden: ["Student", "Teacher"]);
// ── Locations — any auth ──────────────────────────────────────────────
yield return E("locations GET [AnyAuth]", "GET", "api/v1/locations", "Student");
yield return E("locations/{id} GET [AnyAuth]", "GET", "api/v1/locations/1", "Student");
// ── Locations — Admin only ────────────────────────────────────────────
yield return E("locations POST [Admin]", "POST", "api/v1/locations", "Admin", forbidden: ["Student", "Teacher"],
body: """{"name":"Room 1","building":null,"room":null,"address":null}""");
yield return E("locations/{id} PUT [Admin]", "PUT", "api/v1/locations/1", "Admin", forbidden: ["Student", "Teacher"],
body: """{"name":"Room 1","building":null,"room":null,"address":null}""");
yield return E("locations/{id} DELETE [Admin]", "DELETE", "api/v1/locations/1", "Admin", forbidden: ["Student", "Teacher"]);
// ── Achievements — any auth ───────────────────────────────────────────
yield return E("achievements GET [AnyAuth]", "GET", "api/v1/achievements", "Student");
yield return E("achievements/{id} GET [AnyAuth]", "GET", "api/v1/achievements/1", "Student");
// ── Achievements — Admin only ─────────────────────────────────────────
yield return E("achievements POST [Admin]", "POST", "api/v1/achievements", "Admin", forbidden: ["Student", "Teacher"],
body: """{"name":"Ach","description":null,"iconUrl":null,"xpReward":10,"coinReward":5,"condition":null}""");
yield return E("achievements/{id} PUT [Admin]", "PUT", "api/v1/achievements/1", "Admin", forbidden: ["Student", "Teacher"],
body: """{"name":"Ach","description":null,"iconUrl":null,"xpReward":10,"coinReward":5,"condition":null}""");
yield return E("achievements/{id} DELETE [Admin]", "DELETE", "api/v1/achievements/1", "Admin", forbidden: ["Student", "Teacher"]);
// ── Sync — Admin only ─────────────────────────────────────────────────
yield return E("sync/schedule POST [Admin]", "POST", "api/v1/sync/schedule", "Admin", forbidden: ["Student", "Teacher"],
body: """{"specialtyCode":null,"timeMin":null,"timeMax":null,"typeId":null}""");
yield return E("sync/status GET [Admin]", "GET", "api/v1/sync/status", "Admin", forbidden: ["Student", "Teacher"]);
yield return E("sync/rooms POST [Admin]", "POST", "api/v1/sync/rooms", "Admin", forbidden: ["Student", "Teacher"]);
yield return E("sync/employees POST [Admin]", "POST", "api/v1/sync/employees?fullname=test","Admin",forbidden: ["Student", "Teacher"]);
// ── Notifications — any auth ───────────────────────────────────────────
yield return E("notifications GET [AnyAuth]", "GET", "api/v1/notifications", "Student");
yield return E("notifications/read-all PATCH [AnyAuth]", "PATCH", "api/v1/notifications/read-all", "Student");
// ── Notifications — Admin only ─────────────────────────────────────────
yield return E("notifications/send POST [Admin]", "POST", "api/v1/notifications/send", "Admin", forbidden: ["Student", "Teacher"],
body: """{"channel":"email","recipient":"user@test.com","subject":"Test","body":"Hello"}""");
yield return E("notifications/schedule POST [Admin]", "POST", "api/v1/notifications/schedule", "Admin", forbidden: ["Student", "Teacher"],
body: $$"""{"channel":"email","recipient":"user@test.com","subject":"Test","body":"Hello","sendAt":"{{DateTimeOffset.UtcNow.AddMinutes(5):O}}"}""");
}
/// <summary>
/// Анонимные конечные точки — запросы без токена НЕ должны возвращать 401.
/// (они могут делать перенаправление или возвращать 500 из-за отсутствия конфигурации, но не 401)
/// </summary>
public static IEnumerable<object[]> AnonymousEndpoints()
{
// login/microsoft GET перенаправляет на Microsoft — AzureAd настроен в фабрике
yield return new object[] { "auth/login/microsoft GET", "GET", "api/v1/auth/login/microsoft" };
// callback разрешает анонимный доступ — возвращает 400, если отсутствует параметр code
yield return new object[] { "auth/callback/microsoft GET", "GET", "api/v1/auth/callback/microsoft" };
// dev login доступен в окружении Development
yield return new object[] { "auth/login/dev POST", "POST", "api/v1/auth/login/dev",
"""{"email":"test@test.com","displayName":"Test","role":"Student"}""" };
// refresh читает из cookie — возвращает 401, если нет cookie, но это не 401 от промежуточного ПО авторизации
// (он возвращает 401 явно в теле действия, что отличается от Auth Challenge)
// Мы тестируем это отдельно, чтобы убедиться, что заголовок JWT не требуется
}
// ─────────────────────────────────────────────────────────────────────────
// Тест: анонимный → 401
// ─────────────────────────────────────────────────────────────────────────
[Theory]
[MemberData(nameof(AuthenticatedEndpoints))]
public async Task Endpoint_Anonymous_Returns401(
string description, string method, string url,
string correctRole, string[] forbiddenRoles, string? body)
{
// Подготовка — без заголовка аутентификации
var request = BuildRequest(method, url, body, authHeader: null);
// Действие
var response = await _client.SendAsync(request);
// Проверка
Assert.True(
response.StatusCode == HttpStatusCode.Unauthorized,
$"[{description}] Ожидался ответ 401 Unauthorized для анонимного запроса, получено {(int)response.StatusCode} {response.StatusCode}");
}
// ─────────────────────────────────────────────────────────────────────────
// Тест: неправильная роль → 403
// ─────────────────────────────────────────────────────────────────────────
[Theory]
[MemberData(nameof(AuthenticatedEndpoints))]
public async Task Endpoint_WrongRole_Returns403(
string description, string method, string url,
string correctRole, string[] forbiddenRoles, string? body)
{
foreach (var forbidden in forbiddenRoles)
{
// Подготовка
var request = BuildRequest(method, url, body,
authHeader: TestJwtFactory.BearerHeader(forbidden));
// Действие
var response = await _client.SendAsync(request);
// Проверка
Assert.True(
response.StatusCode == HttpStatusCode.Forbidden,
$"[{description}] Ожидался ответ 403 Forbidden для роли '{forbidden}', получено {(int)response.StatusCode} {response.StatusCode}");
}
}
// ─────────────────────────────────────────────────────────────────────────
// Тест: правильная роль → не 401 и не 403
// ─────────────────────────────────────────────────────────────────────────
[Theory]
[MemberData(nameof(AuthenticatedEndpoints))]
public async Task Endpoint_CorrectRole_PassesAuthz(
string description, string method, string url,
string correctRole, string[] forbiddenRoles, string? body)
{
// Подготовка
var request = BuildRequest(method, url, body,
authHeader: TestJwtFactory.BearerHeader(correctRole));
// Действие
var response = await _client.SendAsync(request);
// Проверка — принимается любой ответ, который НЕ 401/403
Assert.True(
response.StatusCode != HttpStatusCode.Unauthorized &&
response.StatusCode != HttpStatusCode.Forbidden,
$"[{description}] Роль '{correctRole}' должна успешно пройти авторизацию, получено {(int)response.StatusCode} {response.StatusCode}");
}
// ─────────────────────────────────────────────────────────────────────────
// Тест: анонимные конечные точки не должны возвращать 401
// ─────────────────────────────────────────────────────────────────────────
[Theory]
[MemberData(nameof(AnonymousEndpoints))]
public async Task AnonymousEndpoint_NoToken_DoesNotReturn401(
string description, string method, string url, string? body = null)
{
var request = BuildRequest(method, url, body, authHeader: null);
var response = await _client.SendAsync(request);
Assert.True(
response.StatusCode != HttpStatusCode.Unauthorized,
$"[{description}] Анонимная конечная точка не должна возвращать 401, получено {(int)response.StatusCode} {response.StatusCode}");
}
// ─────────────────────────────────────────────────────────────────────────
// Вспомогательные методы
// ─────────────────────────────────────────────────────────────────────────
private static HttpRequestMessage BuildRequest(
string method, string url, string? body, string? authHeader)
{
var request = new HttpRequestMessage(new HttpMethod(method), url);
if (authHeader != null)
request.Headers.Add("Authorization", authHeader);
if (body != null)
request.Content = new StringContent(body,
System.Text.Encoding.UTF8, "application/json");
return request;
}
/// <summary>Вспомогательный метод для компактного создания массивов объектов [MemberData].</summary>
private static object[] E(
string description,
string method,
string url,
string correctRole,
string[]? forbidden = null,
string? body = null)
=> [description, method, url, correctRole, forbidden ?? [], body];
}