f6aaf0b923
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 11s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 1m17s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 29s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 9s
310 lines
24 KiB
C#
310 lines
24 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 — any auth ──────────────────────────────────────────────────
|
|
yield return E("users/{id} GET [AnyAuth]", "GET", "api/v1/users/1", "Student");
|
|
yield return E("users/{id} PUT [AnyAuth/self]", "PUT", "api/v1/users/1", "Student",
|
|
body: """{"displayName":"Test","avatarUrl":null}""");
|
|
yield return E("users/{id}/stats [AnyAuth]", "GET", "api/v1/users/1/stats", "Student");
|
|
yield return E("users/{id}/enrollments [AnyAuth]", "GET", "api/v1/users/1/enrollments", "Student");
|
|
yield return E("users/{id}/achievements [AnyAuth]","GET", "api/v1/users/1/achievements","Student");
|
|
yield return E("users/{id}/transactions [AnyAuth/self]","GET","api/v1/users/1/transactions","Student");
|
|
|
|
// ── Users — Admin OR Teacher ─────────────────────────────────────────
|
|
yield return E("users/{id}/reviews [Admin]", "GET", "api/v1/users/1/reviews","Admin", forbidden: ["Student"]);
|
|
yield return E("users/{id}/reviews [Teacher]", "GET", "api/v1/users/1/reviews","Teacher", forbidden: ["Student"]);
|
|
|
|
// ── Users — Admin only ────────────────────────────────────────────────
|
|
yield return E("users GET [Admin]", "GET", "api/v1/users", "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/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}}"}""");
|
|
}
|
|
|
|
/// <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];
|
|
}
|