food-market/tests/food-market.IntegrationTests/PermissionTests.cs
nns ff69dec527 feat(pos): регистрация устройства на точке продаж + привязка кассира
- Сервер: /api/me отдаёт retailPointIds (точки, к которым привязан сотрудник).
- Касса: DeviceRegistration (чистая логика, 5 unit-тестов) — первый запуск:
  админ выбирает точку из списка активных (сохраняется на устройстве, kv);
  кассир на незарегистрированном устройстве → «обратитесь к администратору»;
  кассир, не привязанный к точке устройства → «вы не привязаны к этой кассе»;
  админ работает на любой точке. Смена точки — кнопка-сброс (новую выберет
  только админ). Экран входа: шаг выбора точки (ComboBox активных точек).
- Заменяет прежний «берётся первая активная» (ошибка при двух точках).
- Тесты: unit (5 решений гейта) + integration (/api/me retailPointIds кассира).

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

186 lines
10 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();
}
/// <summary>Касса пускает кассира только на «свою» точку: /api/me отдаёт
/// retailPointIds сотрудника (привязка в админке). Касса сверяет с точкой
/// устройства (DeviceRegistration).</summary>
[Fact]
public async Task Me_returns_assigned_retail_points_for_cashier()
{
var admin = new ApiActor(_factory.CreateClient());
await admin.SignupAndLoginAsync($"rp-{Guid.NewGuid():N}");
var refs = await admin.LoadRefsAsync();
var point = await admin.CreateRetailPointAsync(refs.StoreId, $"Точка-{Guid.NewGuid():N}");
var roleResp = await admin.Http.PostAsJsonAsync("/api/organization/employee-roles", new
{
name = $"Касса-{Guid.NewGuid():N}",
permissions = new { retailSalesOperate = true, cashShiftOperate = true, productsView = true },
});
roleResp.EnsureSuccessStatusCode();
var roleId = (await roleResp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
var email = $"rp-cashier-{Guid.NewGuid():N}@example.kz";
var empResp = await admin.Http.PostAsJsonAsync("/api/organization/employees", new
{
lastName = "К", firstName = "П", roleId, isActive = true, createAccount = true, email,
retailPointIds = new[] { point },
});
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 ids = me.GetProperty("retailPointIds").EnumerateArray().Select(x => x.GetString()).ToList();
ids.Should().Contain(point);
}
}