food-market/tests/food-market.IntegrationTests/PermissionTests.cs
nns 9dc033d07a
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
fix(money): кассир открывает смену без доступа к модулю «Деньги» (403)
Инцидент menu-walk 2026-10-08: demo-cashier получал 403 на
GET /api/money/accounts — страница смен (/money/shifts) дёргала его для выбора
кассы при открытии смены, но эндпоинт под MoneyView, а у кассира MoneyView=false
(«кассир не видит денег»), только CashShiftOperate.

- Новый узкий GET /api/money/shifts/accounts (RequiresPermission
  "MoneyView,CashShiftOperate"): только активные наличные кассы + эквайринг,
  без остатков и банковских счетов. Кассиру достаточно для выбора кассы/эквайринга
  при открытии смены, но денег он по-прежнему не видит.
- Веб: CashShiftsPage использует useShiftAccounts() вместо useMoneyAccounts(true).
- Регресс-тесты: кассир 200 на /shifts/accounts (только нал+эквайринг) и 403 на
  /money/accounts; /api/me permissions для кассира (advanced-only скрыт).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:28:36 +05:00

151 lines
8.3 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<JsonElement>()).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<JsonElement>()).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);
}
/// <summary>Инцидент menu-walk 2026-10-08: кассир (CashShiftOperate без
/// MoneyView — «кассир не видит денег») получал 403 на /api/money/accounts,
/// который дёргает страница смен для выбора кассы. Узкий эндпоинт
/// /api/money/shifts/accounts доступен кассиру и отдаёт только наличные/
/// эквайринг без остатков; /api/money/accounts по-прежнему 403 (инвариант).</summary>
[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<JsonElement>()).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<JsonElement>()).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<JsonElement>();
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("Расчётный счёт", "банковский счёт кассиру в списке смены не нужен");
}
/// <summary>Эффективные права в /api/me для кассира: ProductsView/StocksView
/// есть, advanced-only (DemandsView) скрыто (простой режим).</summary>
[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<JsonElement>()).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<JsonElement>()).GetProperty("generatedPassword").GetString()!;
var cashier = new ApiActor(_factory.CreateClient());
cashier.UseToken(await cashier.TokenAsync(email, pwd));
var me = await cashier.Http.GetFromJsonAsync<JsonElement>("/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();
}
}