fix(money): кассир открывает смену без доступа к модулю «Деньги» (403)
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
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
Инцидент 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>
This commit is contained in:
parent
bd5d43fdf5
commit
9dc033d07a
|
|
@ -34,6 +34,26 @@ public CashShiftsController(AppDbContext db, ITenantContext tenant, IMoneyServic
|
||||||
_money = money;
|
_money = money;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Минимальный список счетов для выбора при открытии смены:
|
||||||
|
/// только активные наличные кассы и эквайринг, без остатков и банковских
|
||||||
|
/// счетов. Доступно и кассиру (право CashShiftOperate) — он открывает смену,
|
||||||
|
/// но модуль «Деньги» (счета/остатки) ему недоступен, поэтому отдельный
|
||||||
|
/// узкий эндпоинт вместо /api/money/accounts (тот под MoneyView).</summary>
|
||||||
|
public record ShiftAccountRow(Guid Id, string Name, MoneyAccountKind Kind,
|
||||||
|
Guid? StoreId, Guid CurrencyId, string CurrencyCode);
|
||||||
|
|
||||||
|
[HttpGet("accounts"), RequiresPermission("MoneyView,CashShiftOperate")]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<ShiftAccountRow>>> ShiftAccounts(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var rows = await (from a in _db.MoneyAccounts.AsNoTracking()
|
||||||
|
join cu in _db.Currencies on a.CurrencyId equals cu.Id
|
||||||
|
where a.IsActive && (a.Kind == MoneyAccountKind.Cash || a.Kind == MoneyAccountKind.Acquiring)
|
||||||
|
orderby a.Kind, a.Name
|
||||||
|
select new ShiftAccountRow(a.Id, a.Name, a.Kind, a.StoreId, cu.Id, cu.Code))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
return Ok(rows);
|
||||||
|
}
|
||||||
|
|
||||||
public record CashShiftRow(
|
public record CashShiftRow(
|
||||||
Guid Id, string Number, CashShiftStatus Status,
|
Guid Id, string Number, CashShiftStatus Status,
|
||||||
Guid RetailPointId, string RetailPointName,
|
Guid RetailPointId, string RetailPointName,
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,23 @@ export function useMoneyAccounts(activeOnly = false) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Счета для выбора при открытии смены (наличные + эквайринг, без остатков).
|
||||||
|
* Доступно кассиру (CashShiftOperate) — в отличие от useMoneyAccounts, который
|
||||||
|
* под MoneyView и кассиру отдаёт 403 (кассир «не видит денег»). */
|
||||||
|
export interface ShiftAccountRow {
|
||||||
|
id: string; name: string; kind: MoneyAccountKind;
|
||||||
|
storeId: string | null; currencyId: string; currencyCode: string
|
||||||
|
}
|
||||||
|
export function useShiftAccounts() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['lookup:shift-accounts'],
|
||||||
|
queryFn: async () =>
|
||||||
|
(await api.get<ShiftAccountRow[]>('/api/money/shifts/accounts')).data,
|
||||||
|
staleTime: 0,
|
||||||
|
refetchOnMount: 'always',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** Активные статьи расходов (для расходных документов). */
|
/** Активные статьи расходов (для расходных документов). */
|
||||||
export function useExpenseItems(activeOnly = true) {
|
export function useExpenseItems(activeOnly = true) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,14 @@ import { api } from '@/lib/api'
|
||||||
import { useFormatCurrency } from '@/lib/useFormatCurrency'
|
import { useFormatCurrency } from '@/lib/useFormatCurrency'
|
||||||
import {
|
import {
|
||||||
CashShiftStatus, DrawerDirection, MoneyAccountKind,
|
CashShiftStatus, DrawerDirection, MoneyAccountKind,
|
||||||
useMoneyAccounts, useRetailPointsLookup, type CashShiftRow, type DrawerOpRow,
|
useShiftAccounts, useRetailPointsLookup, type CashShiftRow, type DrawerOpRow,
|
||||||
} from '@/lib/money'
|
} from '@/lib/money'
|
||||||
|
|
||||||
const URL = '/api/money/shifts'
|
const URL = '/api/money/shifts'
|
||||||
|
|
||||||
export function CashShiftsPage() {
|
export function CashShiftsPage() {
|
||||||
const { data, isLoading, page, setPage } = useCatalogList<CashShiftRow>(URL)
|
const { data, isLoading, page, setPage } = useCatalogList<CashShiftRow>(URL)
|
||||||
const accounts = useMoneyAccounts(true)
|
const accounts = useShiftAccounts()
|
||||||
const retailPoints = useRetailPointsLookup()
|
const retailPoints = useRetailPointsLookup()
|
||||||
const fmt = useFormatCurrency()
|
const fmt = useFormatCurrency()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
|
||||||
|
|
@ -61,4 +61,90 @@ public async Task Admin_passes_permission_gate()
|
||||||
using var put = await admin.Http.PutAsJsonAsync($"/api/catalog/products/{Guid.NewGuid()}", new { });
|
using var put = await admin.Http.PutAsJsonAsync($"/api/catalog/products/{Guid.NewGuid()}", new { });
|
||||||
put.StatusCode.Should().NotBe(HttpStatusCode.Forbidden);
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue