fix(marketplace): own-KU ownership scoping in read/payload resolvers

Operators and КУ-chairmen could read inventory, issuances and acceptance
acts (АПП) of other branches: list resolvers returned coop-wide data and
signable-payload resolvers fetched documents by client-supplied id without
membership checks. Add own-KU scoping (isMemberOfBranch / orderer ownership)
across marketplaceListInventory, marketplaceListIssuancesByBraname,
issuance + apl-reception signable payloads, and tighten canAccess so that
an :all qualifier subsumes :own/:own-KU rather than requiring literal match.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ant
2026-05-25 03:28:40 +00:00
parent 3edfa08090
commit 782d4d4668
10 changed files with 561 additions and 25 deletions
@@ -101,12 +101,24 @@ export const marketplaceAccessMatrix: Record<MarketplaceRole, Record<string, str
/**
* Проверяет, что хотя бы одна из `roles` пайщика имеет в матрице запись
* `resource` → `action`. Ownership-квалификаторы (`:own`/`:all`/...)
* матчатся буквально — это значит resolver должен передать ровно тот
* action, что записан в матрице (например, `'read:own'`, не `'read'`).
* Для проверки «может ли роль вообще читать resource в любой форме» —
* используйте `roleHasAnyAction(role, resource, baseAction)`.
* `resource` → `action`. Ownership-квалификаторы матчатся буквально, с одним
* исключением — иерархией охвата: право `<base>:all` (над всеми объектами в
* скоупе) удовлетворяет требование `<base>:own` / `<base>:own-KU` /
* `<base>:to-self` того же базового action, потому что «все объекты» — это
* надмножество «своих / своего КУ / адресованных себе». Обратное неверно:
* `<base>:own*` НЕ удовлетворяет требование `<base>:all`.
*
* Зачем: резолверы декларируют узкий action (например, `Warehouse:read:own-KU`
* для оператора), а роль с широким правом (`admin` → `Warehouse:read:all`)
* должна проходить тот же гейт — иначе председатель кооператива получает
* Forbidden на сводном складе, имея более широкое право.
*
* Ownership-фильтрацию данных (вернуть ровно свои/свой-КУ записи) матрица НЕ
* делает — это ответственность resolver'а. Для проверки «может ли роль вообще
* читать resource в любой форме» используйте `roleHasAnyAction`.
*/
const SUBSET_QUALIFIERS = new Set(['own', 'own-KU', 'to-self']);
export function canAccess(
roles: MarketplaceRole[],
resource: string,
@@ -114,7 +126,18 @@ export function canAccess(
): boolean {
return roles.some((role) => {
const resourceActions = marketplaceAccessMatrix[role]?.[resource];
return resourceActions?.includes(action) ?? false;
if (!resourceActions) return false;
if (resourceActions.includes(action)) return true;
const colon = action.indexOf(':');
if (colon > 0) {
const base = action.slice(0, colon);
const qualifier = action.slice(colon + 1);
if (SUBSET_QUALIFIERS.has(qualifier)) {
return resourceActions.includes(`${base}:all`);
}
}
return false;
});
}
@@ -1,4 +1,4 @@
import { Inject, Injectable, UseGuards } from '@nestjs/common';
import { ForbiddenException, Inject, Injectable, NotFoundException, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import config from '~/config/config';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
@@ -6,6 +6,12 @@ import { CurrentMarketplaceMember } from '../decorators/current-marketplace-memb
import { RequireMarketplaceAccess } from '../decorators/marketplace-access.decorator';
import { MarketplaceMembershipGuard } from '../guards/marketplace-membership.guard';
import { MarketplaceRoleGuard } from '../guards/marketplace-role.guard';
import { canAccess } from '../access/marketplace-access-matrix';
import type { MarketplaceRole } from '../membership/marketplace-roles.mapper';
import {
MARKETPLACE_KU_CHAIRMEN_SERVICE,
type MarketplaceKuChairmenService,
} from '../services/marketplace-ku-chairmen.service';
import type { IMarketplaceCurrentMember } from '../dto/marketplace-current-member.dto';
import {
MarketplaceAplReceptionByIdInputDTO,
@@ -45,7 +51,9 @@ export class MarketplaceAplReceptionResolver {
@Inject(MARKETPLACE_APL_RECEPTION_SERVICE)
private readonly service: MarketplaceAplReceptionService,
@Inject(MARKETPLACE_APL_RECEPTION_REPOSITORY)
private readonly receptionRepo: MarketplaceAplReceptionDomainRepository
private readonly receptionRepo: MarketplaceAplReceptionDomainRepository,
@Inject(MARKETPLACE_KU_CHAIRMEN_SERVICE)
private readonly kuChairmenService: MarketplaceKuChairmenService
) {}
@Mutation(() => MarketplaceAplReceptionResultDTO, {
@@ -122,10 +130,30 @@ export class MarketplaceAplReceptionResolver {
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Receiving', 'sign:first')
async marketplaceAplReceptionSupplierSignablePayloads(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceAplReceptionByIdInputDTO
): Promise<GeneratedDocumentDTO[]> {
const coopname = config.coopname;
const roles = member.marketplace_roles as MarketplaceRole[];
// Ownership-фильтрация — ответственность резолвера. `sign:first` есть у
// роли offerer (поставщика), поэтому без проверки владельца любой поставщик
// прочитал бы акт приёмки чужой партии по подставленному apl_reception_id.
// Превью подписи поставщика доступно только поставщику этой приёмки.
if (!canAccess(roles, 'Receiving', 'read:all')) {
const reception = await this.receptionRepo.findById(data.apl_reception_id);
if (!reception || reception.coopname !== coopname) {
throw new NotFoundException('Акт приёмки не найден.');
}
if (reception.offerer_account !== member.username) {
throw new ForbiddenException(
'Превью акта приёмки доступно только поставщику этой партии.'
);
}
}
const docs = await this.service.getSupplierSignablePayloads(
config.coopname,
coopname,
data.apl_reception_id
);
return docs.map(toGeneratedDocumentDTO);
@@ -142,8 +170,32 @@ export class MarketplaceAplReceptionResolver {
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceAplReceptionByIdInputDTO
): Promise<DocumentAggregateDTO[]> {
const coopname = config.coopname;
const roles = member.marketplace_roles as MarketplaceRole[];
// Ownership-фильтрация — ответственность резолвера (сервис игнорирует
// chairman_account). `sign:closing` есть у роли operator, поэтому оператор
// только с правами своего КУ обязан быть членом КУ приёмки, иначе утечёт
// акт чужого участка по подставленному apl_reception_id.
if (!canAccess(roles, 'Receiving', 'read:all')) {
const reception = await this.receptionRepo.findById(data.apl_reception_id);
if (!reception || reception.coopname !== coopname) {
throw new NotFoundException('Акт приёмки не найден.');
}
const isMember = await this.kuChairmenService.isMemberOfBranch(
coopname,
reception.braname,
member.username
);
if (!isMember) {
throw new ForbiddenException(
'Превью акта приёмки доступно только по участку, на котором вы являетесь председателем или доверенным лицом.'
);
}
}
const aggregates = await this.service.getChairmanSignablePayloads(
config.coopname,
coopname,
data.apl_reception_id,
member.username
);
@@ -157,10 +209,29 @@ export class MarketplaceAplReceptionResolver {
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Receiving', 'create')
async marketplaceListAplReceptionsByBraname(
@CurrentMarketplaceMember() _member: IMarketplaceCurrentMember,
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceListAplReceptionsByBranameInputDTO
): Promise<MarketplaceAplReceptionDTO[]> {
const list = await this.receptionRepo.listByBraname(config.coopname, data.braname);
const coopname = config.coopname;
const roles = member.marketplace_roles as MarketplaceRole[];
// Ownership-фильтрация — ответственность резолвера (matrix даёт только
// capability `Receiving:create`). Оператор только с правами своего КУ обязан
// быть членом запрашиваемого участка, иначе утечёт лента приёмок чужого КУ.
if (!canAccess(roles, 'Receiving', 'read:all')) {
const isMember = await this.kuChairmenService.isMemberOfBranch(
coopname,
data.braname,
member.username
);
if (!isMember) {
throw new ForbiddenException(
'Лента приёмок доступна только по участку, на котором вы являетесь председателем или доверенным лицом.'
);
}
}
const list = await this.receptionRepo.listByBraname(coopname, data.braname);
return list.map(toMarketplaceAplReceptionDTO);
}
@@ -1,4 +1,4 @@
import { Inject, Injectable, UseGuards } from '@nestjs/common';
import { ForbiddenException, Inject, Injectable, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import config from '~/config/config';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
@@ -6,6 +6,12 @@ import { CurrentMarketplaceMember } from '../decorators/current-marketplace-memb
import { RequireMarketplaceAccess } from '../decorators/marketplace-access.decorator';
import { MarketplaceMembershipGuard } from '../guards/marketplace-membership.guard';
import { MarketplaceRoleGuard } from '../guards/marketplace-role.guard';
import { canAccess } from '../access/marketplace-access-matrix';
import type { MarketplaceRole } from '../membership/marketplace-roles.mapper';
import {
MARKETPLACE_KU_CHAIRMEN_SERVICE,
type MarketplaceKuChairmenService,
} from '../services/marketplace-ku-chairmen.service';
import type { IMarketplaceCurrentMember } from '../dto/marketplace-current-member.dto';
import {
MarketplaceInventoryItemDTO,
@@ -38,7 +44,9 @@ export class MarketplaceInventoryResolver {
@Inject(MARKETPLACE_INVENTORY_LABEL_SERVICE)
private readonly labelService: MarketplaceInventoryLabelService,
@Inject(MARKETPLACE_INVENTORY_REPOSITORY)
private readonly inventoryRepo: MarketplaceInventoryDomainRepository
private readonly inventoryRepo: MarketplaceInventoryDomainRepository,
@Inject(MARKETPLACE_KU_CHAIRMEN_SERVICE)
private readonly kuChairmenService: MarketplaceKuChairmenService
) {}
@Mutation(() => MarketplaceLabelInventoryResultDTO, {
@@ -102,14 +110,42 @@ export class MarketplaceInventoryResolver {
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Warehouse', 'read:own-KU')
async marketplaceListInventory(
@CurrentMarketplaceMember() _member: IMarketplaceCurrentMember,
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data', { nullable: true }) data?: MarketplaceListInventoryInputDTO
): Promise<MarketplaceInventoryItemDTO[]> {
const coopname = config.coopname;
const roles = member.marketplace_roles as MarketplaceRole[];
// Ownership-фильтрация данных — ответственность резолвера, а не матрицы
// (matrix отвечает только за capability). Роль с `Warehouse:read:all`
// (admin/совет) видит склад всего кооператива; роль только с
// `read:own-KU` (оператор/председатель КУ) ограничивается своими КУ.
let branameFilter: string | string[] | undefined = data?.braname;
if (!canAccess(roles, 'Warehouse', 'read:all')) {
const ownBranames = await this.kuChairmenService.listBranamesForMember(
coopname,
member.username
);
if (ownBranames.length === 0) {
return [];
}
if (data?.braname) {
if (!ownBranames.includes(data.braname)) {
throw new ForbiddenException(
'Склад доступен только по участку, на котором вы являетесь председателем или доверенным лицом.'
);
}
branameFilter = data.braname;
} else {
branameFilter = ownBranames;
}
}
const filter: MarketplaceInventoryListFilter = {
coopname: config.coopname,
coopname,
order_id: data?.order_id,
shipment_id: data?.shipment_id,
braname: data?.braname,
braname: branameFilter,
status: data?.statuses?.length
? (data.statuses as MarketplaceInventoryStatus[])
: undefined,
@@ -1,4 +1,4 @@
import { Inject, Injectable, UseGuards } from '@nestjs/common';
import { ForbiddenException, Inject, Injectable, NotFoundException, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import config from '~/config/config';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
@@ -6,6 +6,12 @@ import { CurrentMarketplaceMember } from '../decorators/current-marketplace-memb
import { RequireMarketplaceAccess } from '../decorators/marketplace-access.decorator';
import { MarketplaceMembershipGuard } from '../guards/marketplace-membership.guard';
import { MarketplaceRoleGuard } from '../guards/marketplace-role.guard';
import { canAccess } from '../access/marketplace-access-matrix';
import type { MarketplaceRole } from '../membership/marketplace-roles.mapper';
import {
MARKETPLACE_KU_CHAIRMEN_SERVICE,
type MarketplaceKuChairmenService,
} from '../services/marketplace-ku-chairmen.service';
import type { IMarketplaceCurrentMember } from '../dto/marketplace-current-member.dto';
import {
MarketplaceFinalizeIssuanceInputDTO,
@@ -44,7 +50,9 @@ export class MarketplaceIssuanceResolver {
@Inject(MARKETPLACE_ISSUANCE_SERVICE)
private readonly service: MarketplaceIssuanceService,
@Inject(MARKETPLACE_ORDER_REPOSITORY)
private readonly orderRepo: MarketplaceOrderDomainRepository
private readonly orderRepo: MarketplaceOrderDomainRepository,
@Inject(MARKETPLACE_KU_CHAIRMEN_SERVICE)
private readonly kuChairmenService: MarketplaceKuChairmenService
) {}
@Mutation(() => MarketplaceIssuanceResultDTO, {
@@ -106,8 +114,32 @@ export class MarketplaceIssuanceResolver {
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceIssueActPayloadInputDTO
): Promise<GeneratedDocumentDTO> {
const coopname = config.coopname;
const roles = member.marketplace_roles as MarketplaceRole[];
// Ownership-фильтрация — ответственность резолвера (matrix даёт только
// capability). Превью акта раскрывает ФИО/состав заказа, поэтому оператор
// только с `read:own-KU` обязан быть членом КУ выдачи запрашиваемого заказа,
// иначе утечёт акт чужого участка по подставленному order_id.
if (!canAccess(roles, 'Issuance', 'read:all')) {
const order = await this.orderRepo.findById(data.order_id);
if (!order || order.coopname !== coopname) {
throw new NotFoundException('Заказ не найден.');
}
const isMember = await this.kuChairmenService.isMemberOfBranch(
coopname,
order.delivery_braname,
member.username
);
if (!isMember) {
throw new ForbiddenException(
'Превью акта выдачи доступно только по участку, на котором вы являетесь председателем или доверенным лицом.'
);
}
}
const doc = await this.service.getOpenIssuanceSignablePayload(
config.coopname,
coopname,
data.order_id,
member.username
);
@@ -122,10 +154,30 @@ export class MarketplaceIssuanceResolver {
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Issuance', 'sign:final')
async marketplaceIssueActOrdererSignablePayload(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceIssueActPayloadInputDTO
): Promise<DocumentAggregateDTO> {
const coopname = config.coopname;
const roles = member.marketplace_roles as MarketplaceRole[];
// Ownership-фильтрация — ответственность резолвера. `sign:final` есть у
// каждого пайщика (роль orderer), поэтому без проверки владельца любой
// пайщик прочитал бы акт чужого заказа по подставленному order_id. Превью
// финальной подписи доступно только заказчику этого заказа.
if (!canAccess(roles, 'Issuance', 'read:all')) {
const order = await this.orderRepo.findById(data.order_id);
if (!order || order.coopname !== coopname) {
throw new NotFoundException('Заказ не найден.');
}
if (order.orderer_account !== member.username) {
throw new ForbiddenException(
'Превью акта выдачи доступно только заказчику этого заказа.'
);
}
}
const aggregate = await this.service.getFinalizeIssuanceSignablePayload(
config.coopname,
coopname,
data.order_id
);
return new DocumentAggregateDTO(aggregate);
@@ -139,10 +191,31 @@ export class MarketplaceIssuanceResolver {
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Issuance', 'read:own-KU')
async marketplaceListIssuancesByBraname(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceListIssuancesByBranameInputDTO
): Promise<MarketplaceOrderDTO[]> {
const coopname = config.coopname;
const roles = member.marketplace_roles as MarketplaceRole[];
// Ownership-фильтрация — ответственность резолвера (matrix даёт только
// capability). Роль с `Issuance:read:all` видит ленту любого КУ; роль
// только с `read:own-KU` (оператор/председатель КУ) обязана быть членом
// запрашиваемого участка, иначе утечёт лента выдач чужого КУ.
if (!canAccess(roles, 'Issuance', 'read:all')) {
const isMember = await this.kuChairmenService.isMemberOfBranch(
coopname,
data.delivery_braname,
member.username
);
if (!isMember) {
throw new ForbiddenException(
'Лента выдач доступна только по участку, на котором вы являетесь председателем или доверенным лицом.'
);
}
}
const orders = await this.orderRepo.listForIssuanceByBraname(
config.coopname,
coopname,
data.delivery_braname
);
return orders.map(toMarketplaceOrderDTO);
@@ -26,7 +26,8 @@ export interface MarketplaceInventoryListFilter {
coopname: string;
order_id?: string;
shipment_id?: string;
braname?: string;
// Массив branames — для ownership-скоупинга оператора по нескольким своим КУ.
braname?: string | string[];
status?: MarketplaceInventoryStatus | MarketplaceInventoryStatus[];
}
@@ -60,7 +60,9 @@ export class MarketplaceInventoryRepositoryAdapter implements MarketplaceInvento
const where: Record<string, unknown> = { coopname: filter.coopname };
if (filter.order_id) where.order_id = filter.order_id;
if (filter.shipment_id) where.shipment_id = filter.shipment_id;
if (filter.braname) where.braname = filter.braname;
if (filter.braname) {
where.braname = Array.isArray(filter.braname) ? In(filter.braname) : filter.braname;
}
if (filter.status) {
where.status = Array.isArray(filter.status) ? In(filter.status) : filter.status;
}
@@ -52,11 +52,30 @@ describe('canAccess', () => {
expect(canAccess(['orderer', 'board_readonly'], 'Order', 'read:all')).toBe(true);
});
it('admin может KU:manage, но не KU:read:own-KU (operator-specific)', () => {
it('admin может KU:manage, но не KU:read:own-KU (нет KU:read:all → иерархия не применяется)', () => {
expect(canAccess(['admin'], 'KU', 'manage')).toBe(true);
expect(canAccess(['admin'], 'KU', 'read:own-KU')).toBe(false);
});
it('иерархия охвата: admin с Warehouse:read:all проходит гейт Warehouse:read:own-KU', () => {
// admin имеет Warehouse:['read:all']; резолвер склада декларирует read:own-KU.
expect(canAccess(['admin'], 'Warehouse', 'read:own-KU')).toBe(true);
expect(canAccess(['board_readonly'], 'Warehouse', 'read:own-KU')).toBe(true);
});
it('иерархия охвата: Order:read:all удовлетворяет Order:read:own / read:to-self', () => {
expect(canAccess(['admin'], 'Order', 'read:own')).toBe(true);
expect(canAccess(['board_readonly'], 'Order', 'read:to-self')).toBe(true);
});
it('иерархия НЕ работает в обратную сторону: orderer с read:own не получает read:all', () => {
expect(canAccess(['orderer'], 'Order', 'read:all')).toBe(false);
});
it('operator с read:own-KU НЕ проходит гейт read:all (нет :all → не суперсет)', () => {
expect(canAccess(['operator'], 'Warehouse', 'read:all')).toBe(false);
});
it('roles=[] → false для любого resource:action', () => {
expect(canAccess([], 'Order', 'create')).toBe(false);
});
@@ -0,0 +1,103 @@
/**
* Unit-тесты ownership-скоупинга apl-reception-резолверов (#208).
*
* Зеркало #205/#206/#207 на стороне приёмки. Инвариант: matrix даёт
* capability, скоуп ДАННЫХ — ответственность резолвера:
* - listByBraname (operator, Receiving:create) → член запрашиваемого КУ;
* - supplierSignablePayloads (offerer, sign:first) → поставщик этой приёмки;
* - chairmanSignablePayloads (operator, sign:closing) → член КУ приёмки
* (сервис делает `void chairman_account`, скоупа сам не делает).
* В каждом блоке: владелец/член → метод дёргается; чужой → ForbiddenException,
* нижележащий метод не дёргается.
*/
jest.mock('~/config/config', () => ({
__esModule: true,
default: { coopname: 'voskhod' },
}));
import { ForbiddenException } from '@nestjs/common';
import { MarketplaceAplReceptionResolver } from '~/extensions/marketplace/application/resolvers/marketplace-apl-reception.resolver';
const receptionOf = (overrides: Record<string, unknown>) =>
({ coopname: 'voskhod', braname: 'krg', offerer_account: 'op', ...overrides } as any);
const makeResolver = (reception: any, isMember: boolean) => {
const service = {
getSupplierSignablePayloads: jest.fn().mockResolvedValue([]),
getChairmanSignablePayloads: jest.fn().mockResolvedValue([]),
} as any;
const receptionRepo = {
findById: jest.fn().mockResolvedValue(reception),
listByBraname: jest.fn().mockResolvedValue([]),
} as any;
const kuChairmenService = {
isMemberOfBranch: jest.fn().mockResolvedValue(isMember),
} as any;
const resolver = new MarketplaceAplReceptionResolver(service, receptionRepo, kuChairmenService);
return { resolver, service, receptionRepo, kuChairmenService };
};
const asMember = (roles: string[]) =>
({ username: 'op', core_roles: ['User'], marketplace_roles: roles } as any);
describe('marketplaceListAplReceptionsByBraname ownership-scoping', () => {
it('operator-член запрашиваемого КУ → лента отдаётся', async () => {
const { resolver, receptionRepo, kuChairmenService } = makeResolver(receptionOf({}), true);
await resolver.marketplaceListAplReceptionsByBraname(asMember(['operator']), {
braname: 'krg',
} as any);
expect(kuChairmenService.isMemberOfBranch).toHaveBeenCalledWith('voskhod', 'krg', 'op');
expect(receptionRepo.listByBraname).toHaveBeenCalledWith('voskhod', 'krg');
});
it('operator НЕ член запрашиваемого КУ → ForbiddenException, репозиторий не дёргается', async () => {
const { resolver, receptionRepo } = makeResolver(receptionOf({}), false);
await expect(
resolver.marketplaceListAplReceptionsByBraname(asMember(['operator']), {
braname: 'msk',
} as any)
).rejects.toBeInstanceOf(ForbiddenException);
expect(receptionRepo.listByBraname).not.toHaveBeenCalled();
});
});
describe('marketplaceAplReceptionSupplierSignablePayloads ownership-scoping', () => {
it('поставщик своей приёмки → превью отдаётся', async () => {
const { resolver, service } = makeResolver(receptionOf({ offerer_account: 'op' }), false);
await resolver.marketplaceAplReceptionSupplierSignablePayloads(asMember(['offerer']), {
apl_reception_id: 'r1',
} as any);
expect(service.getSupplierSignablePayloads).toHaveBeenCalledWith('voskhod', 'r1');
});
it('НЕ поставщик приёмки → ForbiddenException, сервис не дёргается', async () => {
const { resolver, service } = makeResolver(receptionOf({ offerer_account: 'someoneelse' }), false);
await expect(
resolver.marketplaceAplReceptionSupplierSignablePayloads(asMember(['offerer']), {
apl_reception_id: 'r1',
} as any)
).rejects.toBeInstanceOf(ForbiddenException);
expect(service.getSupplierSignablePayloads).not.toHaveBeenCalled();
});
});
describe('marketplaceAplReceptionChairmanSignablePayloads ownership-scoping', () => {
it('operator-член КУ приёмки → превью отдаётся', async () => {
const { resolver, service, kuChairmenService } = makeResolver(receptionOf({}), true);
await resolver.marketplaceAplReceptionChairmanSignablePayloads(asMember(['operator']), {
apl_reception_id: 'r1',
} as any);
expect(kuChairmenService.isMemberOfBranch).toHaveBeenCalledWith('voskhod', 'krg', 'op');
expect(service.getChairmanSignablePayloads).toHaveBeenCalledWith('voskhod', 'r1', 'op');
});
it('operator НЕ член КУ приёмки → ForbiddenException, сервис не дёргается', async () => {
const { resolver, service } = makeResolver(receptionOf({ braname: 'msk' }), false);
await expect(
resolver.marketplaceAplReceptionChairmanSignablePayloads(asMember(['operator']), {
apl_reception_id: 'r1',
} as any)
).rejects.toBeInstanceOf(ForbiddenException);
expect(service.getChairmanSignablePayloads).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,93 @@
/**
* Unit-тесты ownership-скоупинга marketplaceListInventory (#205).
*
* Инвариант: matrix даёт capability (`Warehouse:read:own-KU` оператору,
* `read:all` админу/совету), а скоуп ДАННЫХ — ответственность резолвера.
* - admin/совет (read:all) → склад всего кооператива, braname как пришёл;
* - оператор (только read:own-KU) → ограничивается своими КУ:
* • без braname → IN (все свои КУ);
* • свой braname → этот braname;
* • чужой braname → ForbiddenException;
* • ноль своих КУ → пустой результат без запроса в репозиторий.
*/
jest.mock('~/config/config', () => ({
__esModule: true,
default: { coopname: 'voskhod' },
}));
import { ForbiddenException } from '@nestjs/common';
import { MarketplaceInventoryResolver } from '~/extensions/marketplace/application/resolvers/marketplace-inventory.resolver';
const makeResolver = (ownBranames: string[]) => {
const inventoryRepo = { list: jest.fn().mockResolvedValue([]) } as any;
const labelService = {} as any;
const kuChairmenService = {
listBranamesForMember: jest.fn().mockResolvedValue(ownBranames),
} as any;
const resolver = new MarketplaceInventoryResolver(
labelService,
inventoryRepo,
kuChairmenService
);
return { resolver, inventoryRepo, kuChairmenService };
};
const asMember = (roles: string[]) =>
({ username: 'op', core_roles: ['User'], marketplace_roles: roles } as any);
describe('marketplaceListInventory ownership-scoping', () => {
it('admin (read:all) без braname → НЕ скоупит, braname=undefined, КУ-сервис не дёргается', async () => {
const { resolver, inventoryRepo, kuChairmenService } = makeResolver(['krg']);
await resolver.marketplaceListInventory(asMember(['admin']), undefined);
expect(kuChairmenService.listBranamesForMember).not.toHaveBeenCalled();
expect(inventoryRepo.list).toHaveBeenCalledWith(
expect.objectContaining({ coopname: 'voskhod', braname: undefined })
);
});
it('board_readonly (read:all) с braname → braname как пришёл, без скоупа', async () => {
const { resolver, inventoryRepo, kuChairmenService } = makeResolver(['krg']);
await resolver.marketplaceListInventory(asMember(['board_readonly']), {
braname: 'krg',
} as any);
expect(kuChairmenService.listBranamesForMember).not.toHaveBeenCalled();
expect(inventoryRepo.list).toHaveBeenCalledWith(
expect.objectContaining({ braname: 'krg' })
);
});
it('operator без braname → скоуп IN по всем своим КУ', async () => {
const { resolver, inventoryRepo } = makeResolver(['krg', 'msk']);
await resolver.marketplaceListInventory(asMember(['operator']), undefined);
expect(inventoryRepo.list).toHaveBeenCalledWith(
expect.objectContaining({ braname: ['krg', 'msk'] })
);
});
it('operator со своим braname → этот braname', async () => {
const { resolver, inventoryRepo } = makeResolver(['krg', 'msk']);
await resolver.marketplaceListInventory(asMember(['operator']), {
braname: 'krg',
} as any);
expect(inventoryRepo.list).toHaveBeenCalledWith(
expect.objectContaining({ braname: 'krg' })
);
});
it('operator с ЧУЖИМ braname → ForbiddenException, репозиторий не дёргается', async () => {
const { resolver, inventoryRepo } = makeResolver(['krg']);
await expect(
resolver.marketplaceListInventory(asMember(['operator']), {
braname: 'msk',
} as any)
).rejects.toBeInstanceOf(ForbiddenException);
expect(inventoryRepo.list).not.toHaveBeenCalled();
});
it('operator без своих КУ → пустой массив, репозиторий не дёргается', async () => {
const { resolver, inventoryRepo } = makeResolver([]);
const res = await resolver.marketplaceListInventory(asMember(['operator']), undefined);
expect(res).toEqual([]);
expect(inventoryRepo.list).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,115 @@
/**
* Unit-тесты ownership-скоупинга marketplaceListIssuancesByBraname (#206).
*
* Сиблинг #205. Инвариант: matrix даёт capability (`Issuance:read:own-KU`
* оператору), а скоуп ДАННЫХ — ответственность резолвера:
* - роль с `Issuance:read:all` → лента любого КУ, КУ-сервис не дёргается;
* - оператор (только read:own-KU) обязан быть членом запрашиваемого КУ:
* • член КУ → лента этого КУ;
* • чужой КУ → ForbiddenException, репозиторий не дёргается.
*/
jest.mock('~/config/config', () => ({
__esModule: true,
default: { coopname: 'voskhod' },
}));
import { ForbiddenException } from '@nestjs/common';
import { MarketplaceIssuanceResolver } from '~/extensions/marketplace/application/resolvers/marketplace-issuance.resolver';
const makeResolver = (isMember: boolean) => {
const service = {} as any;
const orderRepo = { listForIssuanceByBraname: jest.fn().mockResolvedValue([]) } as any;
const kuChairmenService = {
isMemberOfBranch: jest.fn().mockResolvedValue(isMember),
} as any;
const resolver = new MarketplaceIssuanceResolver(service, orderRepo, kuChairmenService);
return { resolver, orderRepo, kuChairmenService };
};
const asMember = (roles: string[]) =>
({ username: 'op', core_roles: ['User'], marketplace_roles: roles } as any);
describe('marketplaceListIssuancesByBraname ownership-scoping', () => {
it('operator-член запрашиваемого КУ → лента отдаётся', async () => {
const { resolver, orderRepo, kuChairmenService } = makeResolver(true);
await resolver.marketplaceListIssuancesByBraname(asMember(['operator']), {
delivery_braname: 'krg',
} as any);
expect(kuChairmenService.isMemberOfBranch).toHaveBeenCalledWith('voskhod', 'krg', 'op');
expect(orderRepo.listForIssuanceByBraname).toHaveBeenCalledWith('voskhod', 'krg');
});
it('operator НЕ член запрашиваемого КУ → ForbiddenException, репозиторий не дёргается', async () => {
const { resolver, orderRepo } = makeResolver(false);
await expect(
resolver.marketplaceListIssuancesByBraname(asMember(['operator']), {
delivery_braname: 'msk',
} as any)
).rejects.toBeInstanceOf(ForbiddenException);
expect(orderRepo.listForIssuanceByBraname).not.toHaveBeenCalled();
});
});
/**
* Сиблинги #207. Те же инварианты для payload-резолверов (превью акта по
* order_id раскрывает ФИО/состав заказа):
* - chairman-payload (sign:first, operator) → член КУ заказа;
* - orderer-payload (sign:final, есть у каждого пайщика) → заказчик заказа.
*/
const orderOf = (overrides: Record<string, unknown>) =>
({ coopname: 'voskhod', orderer_account: 'owner', delivery_braname: 'krg', ...overrides } as any);
const makePayloadResolver = (order: any, isMember: boolean) => {
const service = {
getOpenIssuanceSignablePayload: jest.fn().mockResolvedValue({}),
getFinalizeIssuanceSignablePayload: jest.fn().mockResolvedValue({
hash: 'h',
document: { version: '1', hash: 'dh', doc_hash: 'dh', meta_hash: 'mh', meta: '{}', signatures: [] },
}),
} as any;
const orderRepo = { findById: jest.fn().mockResolvedValue(order) } as any;
const kuChairmenService = { isMemberOfBranch: jest.fn().mockResolvedValue(isMember) } as any;
const resolver = new MarketplaceIssuanceResolver(service, orderRepo, kuChairmenService);
return { resolver, service, orderRepo, kuChairmenService };
};
describe('marketplaceIssueActChairmanSignablePayload ownership-scoping', () => {
it('operator-член КУ заказа → превью отдаётся', async () => {
const { resolver, service, kuChairmenService } = makePayloadResolver(orderOf({}), true);
await resolver.marketplaceIssueActChairmanSignablePayload(asMember(['operator']), {
order_id: 'o1',
} as any);
expect(kuChairmenService.isMemberOfBranch).toHaveBeenCalledWith('voskhod', 'krg', 'op');
expect(service.getOpenIssuanceSignablePayload).toHaveBeenCalledWith('voskhod', 'o1', 'op');
});
it('operator НЕ член КУ заказа → ForbiddenException, сервис не дёргается', async () => {
const { resolver, service } = makePayloadResolver(orderOf({ delivery_braname: 'msk' }), false);
await expect(
resolver.marketplaceIssueActChairmanSignablePayload(asMember(['operator']), {
order_id: 'o1',
} as any)
).rejects.toBeInstanceOf(ForbiddenException);
expect(service.getOpenIssuanceSignablePayload).not.toHaveBeenCalled();
});
});
describe('marketplaceIssueActOrdererSignablePayload ownership-scoping', () => {
it('заказчик своего заказа → превью отдаётся', async () => {
const { resolver, service } = makePayloadResolver(orderOf({ orderer_account: 'op' }), false);
await resolver.marketplaceIssueActOrdererSignablePayload(asMember(['orderer']), {
order_id: 'o1',
} as any);
expect(service.getFinalizeIssuanceSignablePayload).toHaveBeenCalledWith('voskhod', 'o1');
});
it('НЕ заказчик заказа → ForbiddenException, сервис не дёргается', async () => {
const { resolver, service } = makePayloadResolver(orderOf({ orderer_account: 'someoneelse' }), false);
await expect(
resolver.marketplaceIssueActOrdererSignablePayload(asMember(['orderer']), {
order_id: 'o1',
} as any)
).rejects.toBeInstanceOf(ForbiddenException);
expect(service.getFinalizeIssuanceSignablePayload).not.toHaveBeenCalled();
});
});