using System.Net; using System.Net.Http.Json; using System.Text.Json; using FluentAssertions; using foodmarket.IntegrationTests.Support; using Xunit; namespace foodmarket.IntegrationTests; [Collection(ApiCollection.Name)] public class PermissionTests { private readonly ApiFactory _factory; public PermissionTests(ApiFactory factory) => _factory = factory; [Fact] public async Task Custom_role_without_ProductsEdit_gets_403_on_put_product() { var admin = new ApiActor(_factory.CreateClient()); await admin.SignupAndLoginAsync($"permadmin-{Guid.NewGuid():N}"); // Кастомная роль: просмотр товаров, но БЕЗ права правки. var roleResp = await admin.Http.PostAsJsonAsync("/api/organization/employee-roles", new { name = $"Viewer-{Guid.NewGuid():N}", description = "view only", permissions = new { productsView = true, productsEdit = false }, }); roleResp.EnsureSuccessStatusCode(); var roleId = (await roleResp.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); // Сотрудник с логином на этой роли. var empEmail = $"viewer-{Guid.NewGuid():N}@example.kz"; var empResp = await admin.Http.PostAsJsonAsync("/api/organization/employees", new { lastName = "Просмотров", firstName = "Вью", roleId, isActive = true, createAccount = true, email = empEmail, }); empResp.EnsureSuccessStatusCode(); var pwd = (await empResp.Content.ReadFromJsonAsync()).GetProperty("generatedPassword").GetString()!; var viewer = new ApiActor(_factory.CreateClient()); viewer.UseToken(await viewer.TokenAsync(empEmail, pwd)); // Авторизация проверяется до биндинга тела: несуществующий id → 403, если нет права. using var put = await viewer.Http.PutAsJsonAsync($"/api/catalog/products/{Guid.NewGuid()}", new { }); put.StatusCode.Should().Be(HttpStatusCode.Forbidden); // Чтение (ProductsView) разрешено. using var get = await viewer.Http.GetAsync("/api/catalog/products"); get.StatusCode.Should().Be(HttpStatusCode.OK); } [Fact] public async Task Admin_passes_permission_gate() { var admin = new ApiActor(_factory.CreateClient()); await admin.SignupAndLoginAsync($"permadmin2-{Guid.NewGuid():N}"); // Админ проходит permission-гейт (дальше — 404/400 на несуществующем товаре, но НЕ 403). using var put = await admin.Http.PutAsJsonAsync($"/api/catalog/products/{Guid.NewGuid()}", new { }); put.StatusCode.Should().NotBe(HttpStatusCode.Forbidden); } /// Инцидент menu-walk 2026-10-08: кассир (CashShiftOperate без /// MoneyView — «кассир не видит денег») получал 403 на /api/money/accounts, /// который дёргает страница смен для выбора кассы. Узкий эндпоинт /// /api/money/shifts/accounts доступен кассиру и отдаёт только наличные/ /// эквайринг без остатков; /api/money/accounts по-прежнему 403 (инвариант). [Fact] public async Task Cashier_can_list_shift_accounts_but_not_money_accounts() { var admin = new ApiActor(_factory.CreateClient()); await admin.SignupAndLoginAsync($"shiftacc-{Guid.NewGuid():N}"); var refs = await admin.LoadRefsAsync(); // Счета: наличная касса, эквайринг и банковский (последний кассиру не положен). await admin.CreateMoneyAccountAsync("Касса точки", 1, refs.CurrencyId, refs.StoreId, 0m); await admin.CreateMoneyAccountAsync("Эквайринг", 4, refs.CurrencyId, refs.StoreId, 0m); await admin.CreateMoneyAccountAsync("Расчётный счёт", 3, refs.CurrencyId, null, 0m, accountNumber: "KZ123"); // Роль «как кассир»: CashShiftOperate без MoneyView. var roleResp = await admin.Http.PostAsJsonAsync("/api/organization/employee-roles", new { name = $"Касса-{Guid.NewGuid():N}", description = "shift only", permissions = new { retailSalesOperate = true, cashShiftOperate = true, moneyView = false, productsView = true }, }); roleResp.EnsureSuccessStatusCode(); var roleId = (await roleResp.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); var email = $"cashier-{Guid.NewGuid():N}@example.kz"; var empResp = await admin.Http.PostAsJsonAsync("/api/organization/employees", new { lastName = "Кассиров", firstName = "Пётр", roleId, isActive = true, createAccount = true, email, }); empResp.EnsureSuccessStatusCode(); var pwd = (await empResp.Content.ReadFromJsonAsync()).GetProperty("generatedPassword").GetString()!; var cashier = new ApiActor(_factory.CreateClient()); cashier.UseToken(await cashier.TokenAsync(email, pwd)); // Инвариант: модуль «Деньги» кассиру недоступен. using var accounts = await cashier.Http.GetAsync("/api/money/accounts?pageSize=100"); accounts.StatusCode.Should().Be(HttpStatusCode.Forbidden); // Но счета для открытия смены — доступны (только нал + эквайринг, без банка). using var shiftAccounts = await cashier.Http.GetAsync("/api/money/shifts/accounts"); shiftAccounts.StatusCode.Should().Be(HttpStatusCode.OK); var rows = await shiftAccounts.Content.ReadFromJsonAsync(); var kinds = rows.EnumerateArray().Select(r => r.GetProperty("kind").GetInt32()).ToList(); kinds.Should().OnlyContain(k => k == 1 || k == 4, "только наличные (1) и эквайринг (4)"); kinds.Should().Contain(1).And.Contain(4); rows.EnumerateArray().Select(r => r.GetProperty("name").GetString()) .Should().NotContain("Расчётный счёт", "банковский счёт кассиру в списке смены не нужен"); } /// Эффективные права в /api/me для кассира: ProductsView/StocksView /// есть, advanced-only (DemandsView) скрыто (простой режим). [Fact] public async Task Me_permissions_for_cashier_role() { var admin = new ApiActor(_factory.CreateClient()); await admin.SignupAndLoginAsync($"meperm-{Guid.NewGuid():N}"); var roleResp = await admin.Http.PostAsJsonAsync("/api/organization/employee-roles", new { name = $"Касса-{Guid.NewGuid():N}", permissions = new { productsView = true, stocksView = true, cashShiftOperate = true, demandsView = true }, }); roleResp.EnsureSuccessStatusCode(); var roleId = (await roleResp.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); var email = $"meperm-c-{Guid.NewGuid():N}@example.kz"; var empResp = await admin.Http.PostAsJsonAsync("/api/organization/employees", new { lastName = "К", firstName = "П", roleId, isActive = true, createAccount = true, email, }); empResp.EnsureSuccessStatusCode(); var pwd = (await empResp.Content.ReadFromJsonAsync()).GetProperty("generatedPassword").GetString()!; var cashier = new ApiActor(_factory.CreateClient()); cashier.UseToken(await cashier.TokenAsync(email, pwd)); var me = await cashier.Http.GetFromJsonAsync("/api/me"); var perms = me.GetProperty("permissions"); perms.GetProperty("ProductsView").GetBoolean().Should().BeTrue(); perms.GetProperty("StocksView").GetBoolean().Should().BeTrue(); perms.GetProperty("ProductsEdit").GetBoolean().Should().BeFalse(); // demandsView выдали роли, но орг в простом режиме → advanced-only скрыт. perms.GetProperty("DemandsView").GetBoolean().Should().BeFalse(); } }