using System.Net; using UniVerse.Api.Tests.Helpers; using Xunit; namespace UniVerse.Api.Tests.Authorization; /// /// Интеграционные тесты для ролевого контроля доступа ко всем конечным точкам API. /// /// Каждый тестовый случай представляет собой кортеж: /// (description, method, url, requiredRole, forbiddenRoles[]) /// /// Три типа сценариев для каждой конечной точки: /// A) Анонимный → 401 Unauthorized /// B) Неправильная роль → 403 Forbidden /// C) Правильная роль → не 401 / не 403 (зависит от бизнес-логики: успех или доменная ошибка) /// public class EndpointAuthorizationTests : IClassFixture { private readonly HttpClient _client; public EndpointAuthorizationTests(ApiWebApplicationFactory factory) { _client = factory.CreateClient(); } // ───────────────────────────────────────────────────────────────────────── // Тестовые данные // ───────────────────────────────────────────────────────────────────────── /// /// Конечные точки, требующие аутентификации (не анонимные). /// Формат: (description, method, url, correctRole, forbiddenRoles[]) /// /// "AnyAuth" означает, что достаточно любого валидного JWT — без ограничения по роли. /// Для конечных точек с несколькими ролями (Admin,Teacher) обе роли указаны как правильные. /// public static IEnumerable 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/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/pending GET [Admin]", "GET", "api/v1/reviews/pending","Admin", forbidden: ["Student", "Teacher"]); 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}}"}"""); } /// /// Анонимные конечные точки — запросы без токена НЕ должны возвращать 401. /// (они могут делать перенаправление или возвращать 500 из-за отсутствия конфигурации, но не 401) /// public static IEnumerable 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; } /// Вспомогательный метод для компактного создания массивов объектов [MemberData]. private static object[] E( string description, string method, string url, string correctRole, string[]? forbidden = null, string? body = null) => [description, method, url, correctRole, forbidden ?? [], body]; }