背景:A3 需把行业 / 客户 / 客户组 / 客户级字段立为资产根节点;用户确认两级行业树与混搭 UI(行业 B / 客户列表 A / 详情 A / 新建 B / 客户组 B / 字段 A)。 改动: - 新增 ADR-002,行业改为两级树,客户挂末级行业;同步 PRD / 技术架构 / 开发计划。 - 新增 client 模块:industries / customers / customer-groups / customer-fields 接口、真实 PrismaCustomerDirectory、客户状态机与客户转交、客户组数据范围解析、跨客户 404。 - 新增迁移与幂等种子(电商/金融两级行业、2 客户、1 客户组、4 条字段)。 - 前端新增行业/客户/客户组/字段审批页面与路由、导航更名(客户管理 / 行业管理 / 客户组),复用现有 token。 - 补充 shared 契约单测、client 集成用例、A3 浏览器 e2e。 影响面:client 模块与 client 表、shared 契约、web 路由/导航、ScopeContext 数据范围、OpenAPI 与种子;A1 客户转交占位由 client 实现替换。 回滚:回滚本提交;数据库保留新增表或按报告执行 down SQL;CUSTOMER_DIRECTORY 可切回空集实现。 验证:format:check / lint / typecheck / test 全绿(shared 131 / api 90 / web 125,共 346);test:int 5 文件 39 条;A3 e2e 1/1;build 8/8;真实浏览器六页验证通过。
648 lines
24 KiB
TypeScript
648 lines
24 KiB
TypeScript
import { API_PREFIX, ErrorCode, ulid } from '@lingxi/shared';
|
||
import { PrismaClient } from '@prisma/client';
|
||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||
|
||
import { createApp, type CreatedApiApp } from '../../bootstrap';
|
||
import { loadDotenv } from '../../config/load-dotenv';
|
||
|
||
/** 与 prisma/seed.ts 一致的示例账号初始密码 lingxi2026(Argon2id) */
|
||
const PASSWORD_HASH =
|
||
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
|
||
const PASSWORD = 'lingxi2026';
|
||
const ACCOUNT_PREFIX = 'it.';
|
||
const API = API_PREFIX;
|
||
|
||
const prisma = new PrismaClient();
|
||
|
||
interface FixtureUser {
|
||
id: string;
|
||
account: string;
|
||
}
|
||
|
||
interface MeBody {
|
||
permissions: string[];
|
||
dataScopes: { type: string; customerIds?: string[] }[];
|
||
user: { id: string; phone?: string };
|
||
mustChangePassword: boolean;
|
||
}
|
||
|
||
interface TokenBody {
|
||
accessToken: string;
|
||
refreshToken: string;
|
||
expiresIn: number;
|
||
me: MeBody;
|
||
}
|
||
|
||
interface ErrorBody {
|
||
code: number;
|
||
message: string;
|
||
}
|
||
|
||
async function createFixtureUser(suffix: string, roleCode: string): Promise<FixtureUser> {
|
||
const [department, role] = await Promise.all([
|
||
prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } }),
|
||
prisma.role.findUniqueOrThrow({ where: { code: roleCode } }),
|
||
]);
|
||
const account = `${ACCOUNT_PREFIX}${suffix}`;
|
||
const id = ulid();
|
||
await prisma.user.create({
|
||
data: {
|
||
id,
|
||
name: `集成测试${suffix}`,
|
||
account,
|
||
email: `${account}@lingxi.cn`,
|
||
phone: `137${String(Date.now()).slice(-8)}`,
|
||
passwordHash: PASSWORD_HASH,
|
||
departmentId: department.id,
|
||
mustChangePassword: false,
|
||
roles: { create: [{ roleId: role.id }] },
|
||
},
|
||
});
|
||
return { id, account };
|
||
}
|
||
|
||
async function login(
|
||
app: CreatedApiApp['app'],
|
||
account: string,
|
||
): Promise<{ status: number; body: TokenBody }> {
|
||
const response = await app.inject({
|
||
method: 'POST',
|
||
url: `${API}/auth/login`,
|
||
payload: { account, password: PASSWORD },
|
||
});
|
||
return { status: response.statusCode, body: response.json<TokenBody>() };
|
||
}
|
||
|
||
async function authed(
|
||
app: CreatedApiApp['app'],
|
||
method: 'GET' | 'POST' | 'PATCH',
|
||
url: string,
|
||
token: string,
|
||
payload?: unknown,
|
||
): Promise<number> {
|
||
const response = await app.inject({
|
||
method,
|
||
url: `${API}${url}`,
|
||
headers: { authorization: `Bearer ${token}` },
|
||
...(payload === undefined ? {} : { payload: payload as Record<string, unknown> }),
|
||
});
|
||
return response.statusCode;
|
||
}
|
||
|
||
async function setRole(userId: string, roleCode: string): Promise<void> {
|
||
const role = await prisma.role.findUniqueOrThrow({ where: { code: roleCode } });
|
||
await prisma.userRole.deleteMany({ where: { userId } });
|
||
await prisma.userRole.create({ data: { userId, roleId: role.id } });
|
||
}
|
||
|
||
interface JsonRequestResult<T> {
|
||
status: number;
|
||
body: T;
|
||
}
|
||
|
||
async function callJson<T>(
|
||
app: CreatedApiApp['app'],
|
||
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
|
||
url: string,
|
||
token: string,
|
||
payload?: unknown,
|
||
): Promise<JsonRequestResult<T>> {
|
||
const response = await app.inject({
|
||
method,
|
||
url: `${API}${url}`,
|
||
headers: { authorization: `Bearer ${token}` },
|
||
...(payload === undefined ? {} : { payload: payload as Record<string, unknown> }),
|
||
});
|
||
return { status: response.statusCode, body: response.json<T>() };
|
||
}
|
||
|
||
const ROLE_NAME_PREFIX = 'IT-角色-';
|
||
|
||
/** 集成测试创建的角色 id:afterAll 清理 outbox 占位事件,避免污染本地库 */
|
||
const createdRoleIds: string[] = [];
|
||
|
||
/** 集成测试专用角色(名称前缀隔离,afterAll 清理;删除 cascades user_roles / role_permissions) */
|
||
async function createTestRole(input: {
|
||
name: string;
|
||
permissions?: string[];
|
||
isSystem?: boolean;
|
||
dataScope?: { type: string; targetIds?: string[] };
|
||
status?: 'ACTIVE' | 'DISABLED';
|
||
}): Promise<{ id: string; name: string }> {
|
||
const id = ulid();
|
||
const role = await prisma.role.create({
|
||
data: {
|
||
id,
|
||
code: `it_role_${id.toLowerCase()}`,
|
||
name: `${ROLE_NAME_PREFIX}${input.name}`,
|
||
description: '集成测试角色',
|
||
isSystem: input.isSystem ?? false,
|
||
status: input.status ?? 'ACTIVE',
|
||
permissions: {
|
||
create: (input.permissions ?? []).map((permissionCode) => ({ permissionCode })),
|
||
},
|
||
dataScopes: {
|
||
create: [
|
||
{
|
||
id: ulid(),
|
||
scopeType: input.dataScope?.type ?? 'created_by_me',
|
||
targetId: input.dataScope?.targetIds?.[0] ?? null,
|
||
},
|
||
],
|
||
},
|
||
},
|
||
});
|
||
createdRoleIds.push(role.id);
|
||
return { id: role.id, name: role.name };
|
||
}
|
||
|
||
async function createFixtureUserWithRoleId(suffix: string, roleId: string): Promise<FixtureUser> {
|
||
const department = await prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } });
|
||
const account = `${ACCOUNT_PREFIX}${suffix}`;
|
||
const id = ulid();
|
||
await prisma.user.create({
|
||
data: {
|
||
id,
|
||
name: `集成测试${suffix}`,
|
||
account,
|
||
email: `${account}@lingxi.cn`,
|
||
phone: `138${String(Date.now()).slice(-8)}`,
|
||
passwordHash: PASSWORD_HASH,
|
||
departmentId: department.id,
|
||
mustChangePassword: false,
|
||
roles: { create: [{ roleId }] },
|
||
},
|
||
});
|
||
return { id, account };
|
||
}
|
||
|
||
describe('identity 集成(真实 PG / Redis)', () => {
|
||
let created: CreatedApiApp;
|
||
let manager: FixtureUser;
|
||
let viewer: FixtureUser;
|
||
|
||
beforeAll(async () => {
|
||
loadDotenv();
|
||
created = await createApp();
|
||
await created.app.init();
|
||
await prisma.user.deleteMany({ where: { account: { startsWith: ACCOUNT_PREFIX } } });
|
||
await prisma.role.deleteMany({ where: { name: { startsWith: ROLE_NAME_PREFIX } } });
|
||
manager = await createFixtureUser('manager', 'platform_admin');
|
||
viewer = await createFixtureUser('viewer', 'designer');
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await prisma.user.deleteMany({ where: { account: { startsWith: ACCOUNT_PREFIX } } });
|
||
await prisma.role.deleteMany({ where: { name: { startsWith: ROLE_NAME_PREFIX } } });
|
||
if (createdRoleIds.length > 0) {
|
||
await prisma.outboxEvent.deleteMany({ where: { aggregateId: { in: createdRoleIds } } });
|
||
}
|
||
await prisma.$disconnect();
|
||
await created.app.close();
|
||
});
|
||
|
||
it('登录返回 tokenPair + me,且权限点来自角色', async () => {
|
||
const { status, body } = await login(created.app, manager.account);
|
||
expect(status).toBe(200);
|
||
expect(body.refreshToken.length).toBeGreaterThan(0);
|
||
expect(body.expiresIn).toBeGreaterThan(0);
|
||
expect(body.me.permissions).toContain('user:manage');
|
||
expect(body.me.mustChangePassword).toBe(false);
|
||
expect(body.me.user.phone).toBeDefined();
|
||
});
|
||
|
||
it('/me 与登录上下文一致;列表只回 phoneMasked,详情对 user:manage 回全号', async () => {
|
||
const { body } = await login(created.app, manager.account);
|
||
const meResponse = await created.app.inject({
|
||
method: 'GET',
|
||
url: `${API}/me`,
|
||
headers: { authorization: `Bearer ${body.accessToken}` },
|
||
});
|
||
expect(meResponse.statusCode).toBe(200);
|
||
const me = meResponse.json<{ user: { account: string } }>();
|
||
expect(me.user.account).toBe(manager.account);
|
||
|
||
const listResponse = await created.app.inject({
|
||
method: 'GET',
|
||
url: `${API}/users?limit=5`,
|
||
headers: { authorization: `Bearer ${body.accessToken}` },
|
||
});
|
||
expect(listResponse.statusCode).toBe(200);
|
||
const list = listResponse.json<{
|
||
items: Record<string, unknown>[];
|
||
next_cursor: string | null;
|
||
}>();
|
||
expect(list.items.length).toBeGreaterThan(0);
|
||
expect(list.items[0]?.phoneMasked).toMatch(/^\d{3}\*{4}\d{4}$/);
|
||
expect(list.items[0]?.phone).toBeUndefined();
|
||
expect(list.next_cursor === null || typeof list.next_cursor === 'string').toBe(true);
|
||
|
||
const detailResponse = await created.app.inject({
|
||
method: 'GET',
|
||
url: `${API}/users/${manager.id}`,
|
||
headers: { authorization: `Bearer ${body.accessToken}` },
|
||
});
|
||
expect(detailResponse.statusCode).toBe(200);
|
||
const detail = detailResponse.json<{ phone?: string }>();
|
||
expect(detail.phone).toBeDefined();
|
||
});
|
||
|
||
it('无 user:view 权限返回 40301;授予角色后下一次请求立即生效', async () => {
|
||
const { body } = await login(created.app, viewer.account);
|
||
expect(await authed(created.app, 'GET', '/users', body.accessToken)).toBe(403);
|
||
await setRole(viewer.id, 'platform_admin');
|
||
expect(await authed(created.app, 'GET', '/users', body.accessToken)).toBe(200);
|
||
});
|
||
|
||
it('刷新旋转:旧 refresh 立即失效,重放触发整族吊销', async () => {
|
||
const { body } = await login(created.app, manager.account);
|
||
const refreshed = await created.app.inject({
|
||
method: 'POST',
|
||
url: `${API}/auth/refresh`,
|
||
payload: { refreshToken: body.refreshToken },
|
||
});
|
||
expect(refreshed.statusCode).toBe(200);
|
||
const next = refreshed.json<{ refreshToken: string }>();
|
||
expect(next.refreshToken).not.toBe(body.refreshToken);
|
||
const replay = await created.app.inject({
|
||
method: 'POST',
|
||
url: `${API}/auth/refresh`,
|
||
payload: { refreshToken: body.refreshToken },
|
||
});
|
||
expect(replay.statusCode).toBe(401);
|
||
const revoked = replay.json<ErrorBody>();
|
||
expect(revoked.code).toBe(ErrorCode.UNAUTHENTICATED);
|
||
});
|
||
|
||
it('停用后旧 access token 立即失效,且无法再登录', async () => {
|
||
const { body } = await login(created.app, viewer.account);
|
||
const managerLogin = await login(created.app, manager.account);
|
||
const managerDisabled = await created.app.inject({
|
||
method: 'POST',
|
||
url: `${API}/users/${viewer.id}/disable`,
|
||
headers: { authorization: `Bearer ${managerLogin.body.accessToken}` },
|
||
payload: { reason: '集成测试停用' },
|
||
});
|
||
expect(managerDisabled.statusCode).toBe(200);
|
||
const meAfter = await created.app.inject({
|
||
method: 'GET',
|
||
url: `${API}/me`,
|
||
headers: { authorization: `Bearer ${body.accessToken}` },
|
||
});
|
||
expect(meAfter.statusCode).toBe(403);
|
||
expect(meAfter.json<ErrorBody>().code).toBe(ErrorCode.PERMISSION_DENIED);
|
||
const relogin = await login(created.app, viewer.account);
|
||
expect(relogin.status).toBe(403);
|
||
});
|
||
|
||
it('登出后 refresh 不可用;部门树与角色列表可读', async () => {
|
||
const { body } = await login(created.app, manager.account);
|
||
const logout = await created.app.inject({
|
||
method: 'POST',
|
||
url: `${API}/auth/logout`,
|
||
payload: { refreshToken: body.refreshToken },
|
||
});
|
||
expect(logout.statusCode).toBe(204);
|
||
const refreshAfter = await created.app.inject({
|
||
method: 'POST',
|
||
url: `${API}/auth/refresh`,
|
||
payload: { refreshToken: body.refreshToken },
|
||
});
|
||
expect(refreshAfter.statusCode).toBe(401);
|
||
|
||
const tree = await created.app.inject({
|
||
method: 'GET',
|
||
url: `${API}/departments/tree`,
|
||
headers: { authorization: `Bearer ${body.accessToken}` },
|
||
});
|
||
expect(tree.statusCode).toBe(200);
|
||
const treeBody = tree.json<{ items: { name: string; children: unknown[] }[] }>();
|
||
expect(treeBody.items.length).toBeGreaterThanOrEqual(3);
|
||
// 根部门按 sortOrder 排序,管理员可新建无子级的根部门并排在最前;
|
||
// 这里只校验树里存在带子级的根部门,不依赖当前第一项(本地手工数据会让测试失真)。
|
||
expect(treeBody.items.some((item) => item.children.length > 0)).toBe(true);
|
||
|
||
const roles = await created.app.inject({
|
||
method: 'GET',
|
||
url: `${API}/roles`,
|
||
headers: { authorization: `Bearer ${body.accessToken}` },
|
||
});
|
||
expect(roles.statusCode).toBe(200);
|
||
expect(roles.json<{ items: unknown[] }>().items.length).toBeGreaterThanOrEqual(8);
|
||
});
|
||
|
||
// -------------------------------------------------------------------------
|
||
// A2:角色权限、权限树、SoD、数据范围、停用即时生效
|
||
// -------------------------------------------------------------------------
|
||
|
||
it('GET /permissions:10 模块树 + 6 特殊权限 + 3 对 SoD;无 role:view 返回 40301', async () => {
|
||
const { body } = await login(created.app, manager.account);
|
||
const response = await callJson<{
|
||
modules: { module: string; moduleLabel: string }[];
|
||
specialPermissions: { code: string }[];
|
||
sodPairs: { id: string }[];
|
||
totalPermissions: number;
|
||
}>(created.app, 'GET', '/permissions', body.accessToken);
|
||
expect(response.status).toBe(200);
|
||
expect(response.body.totalPermissions).toBe(30);
|
||
expect(response.body.modules.map((module) => module.module)).toEqual([
|
||
'script',
|
||
'material',
|
||
'asset',
|
||
'delivery',
|
||
'customer',
|
||
'customer_group',
|
||
'identity',
|
||
'industry',
|
||
'platform',
|
||
'audit',
|
||
]);
|
||
expect(response.body.modules.map((module) => module.moduleLabel)).toContain('客户组');
|
||
expect(response.body.specialPermissions).toHaveLength(6);
|
||
expect(response.body.sodPairs).toHaveLength(3);
|
||
|
||
// 三态之一:无 role:view(设计师)→ 40301(用独立 fixture,避免受前面停用用例影响)
|
||
const designerRole = await prisma.role.findUniqueOrThrow({ where: { code: 'designer' } });
|
||
const designerOnly = await createFixtureUserWithRoleId('permissions-denied', designerRole.id);
|
||
const designerLogin = await login(created.app, designerOnly.account);
|
||
const denied = await callJson<{ code: number }>(
|
||
created.app,
|
||
'GET',
|
||
'/permissions',
|
||
designerLogin.body.accessToken,
|
||
);
|
||
expect(denied.status).toBe(403);
|
||
expect(denied.body.code).toBe(ErrorCode.PERMISSION_DENIED);
|
||
});
|
||
|
||
it('GET /roles:默认只返回启用角色(A1 多选兼容),字段含统计与 dataScope', async () => {
|
||
const { body } = await login(created.app, manager.account);
|
||
const response = await callJson<{
|
||
items: {
|
||
id: string;
|
||
code: string;
|
||
name: string;
|
||
isSystem: boolean;
|
||
permissionCount: number;
|
||
specialPermissionCount: number;
|
||
userCount: number;
|
||
dataScope: { type: string };
|
||
}[];
|
||
}>(created.app, 'GET', '/roles', body.accessToken);
|
||
expect(response.status).toBe(200);
|
||
const codes = response.body.items.map((item) => item.code);
|
||
expect(codes).not.toContain('review_lead');
|
||
expect(codes).toContain('material_operator');
|
||
const superAdmin = response.body.items.find((item) => item.code === 'super_admin');
|
||
expect(superAdmin?.isSystem).toBe(true);
|
||
expect(superAdmin?.permissionCount).toBe(24);
|
||
expect(superAdmin?.specialPermissionCount).toBe(6);
|
||
expect(superAdmin?.dataScope).toEqual({ type: 'all' });
|
||
|
||
const all = await callJson<{ items: { code: string }[] }>(
|
||
created.app,
|
||
'GET',
|
||
'/roles?status=all',
|
||
body.accessToken,
|
||
);
|
||
expect(all.body.items.map((item) => item.code)).toContain('review_lead');
|
||
|
||
// A1 兼容:只有 user:view 的角色也能读取角色列表(用于用户管理的角色多选)
|
||
const onlyUserView = await createTestRole({ name: '用户查看', permissions: ['user:view'] });
|
||
const selector = await createFixtureUserWithRoleId('role-selector', onlyUserView.id);
|
||
const selectorLogin = await login(created.app, selector.account);
|
||
const selectorRoles = await callJson<{ items: { code: string }[] }>(
|
||
created.app,
|
||
'GET',
|
||
'/roles',
|
||
selectorLogin.body.accessToken,
|
||
);
|
||
expect(selectorRoles.status).toBe(200);
|
||
expect(selectorRoles.body.items.length).toBeGreaterThan(0);
|
||
const selectorPermissions = await callJson<{ code: number }>(
|
||
created.app,
|
||
'GET',
|
||
'/permissions',
|
||
selectorLogin.body.accessToken,
|
||
);
|
||
expect(selectorPermissions.status).toBe(403);
|
||
});
|
||
|
||
it('POST /roles:权限默认全空、code 自动生成、数据范围单选;SoD 命中 40304', async () => {
|
||
const { body } = await login(created.app, manager.account);
|
||
const createdRole = await callJson<{
|
||
role: {
|
||
id: string;
|
||
code: string;
|
||
status: string;
|
||
permissions: string[];
|
||
specialPermissions: string[];
|
||
dataScope: { type: string };
|
||
boundUsers: unknown[];
|
||
};
|
||
affectedUserCount: number;
|
||
warnings: string[];
|
||
}>(created.app, 'POST', '/roles', body.accessToken, {
|
||
name: `${ROLE_NAME_PREFIX}内容运营`,
|
||
permissions: ['script:read'],
|
||
dataScope: { type: 'department' },
|
||
});
|
||
expect(createdRole.status).toBe(201);
|
||
createdRoleIds.push(createdRole.body.role.id);
|
||
expect(createdRole.body.role.code).toMatch(/^custom_[0-9a-z]{26}$/);
|
||
expect(createdRole.body.role.permissions).toEqual(['script:read']);
|
||
expect(createdRole.body.role.specialPermissions).toEqual([]);
|
||
expect(createdRole.body.role.dataScope).toEqual({ type: 'department' });
|
||
expect(createdRole.body.affectedUserCount).toBe(0);
|
||
|
||
const sod = await callJson<{ code: number; details: { conflicts: { pairId: string }[] } }>(
|
||
created.app,
|
||
'POST',
|
||
'/roles',
|
||
body.accessToken,
|
||
{
|
||
name: `${ROLE_NAME_PREFIX}互斥冲突`,
|
||
permissions: ['script:write', 'script:review'],
|
||
dataScope: { type: 'all' },
|
||
},
|
||
);
|
||
expect(sod.status).toBe(403);
|
||
expect(sod.body.code).toBe(ErrorCode.SOD_VIOLATION);
|
||
expect(sod.body.details.conflicts[0]?.pairId).toBe('script-write-review');
|
||
|
||
const specialInNormal = await callJson<{ code: number }>(
|
||
created.app,
|
||
'POST',
|
||
'/roles',
|
||
body.accessToken,
|
||
{
|
||
name: `${ROLE_NAME_PREFIX}特殊权限错位`,
|
||
permissions: ['delivery:export'],
|
||
dataScope: { type: 'all' },
|
||
},
|
||
);
|
||
expect(specialInNormal.status).toBe(400);
|
||
expect(specialInNormal.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||
});
|
||
|
||
it('PATCH /roles:保存权限返回影响用户数、写 outbox 审计事件;系统角色仅显示名可改', async () => {
|
||
const { body } = await login(created.app, manager.account);
|
||
const role = await createTestRole({
|
||
name: '待编辑',
|
||
permissions: ['script:read'],
|
||
dataScope: { type: 'department' },
|
||
});
|
||
const boundUser = await createFixtureUserWithRoleId('role-edit', role.id);
|
||
|
||
const patched = await callJson<{
|
||
role: {
|
||
permissions: string[];
|
||
specialPermissions: string[];
|
||
dataScope: { type: string };
|
||
status: string;
|
||
};
|
||
affectedUserCount: number;
|
||
warnings: string[];
|
||
}>(created.app, 'PATCH', `/roles/${role.id}`, body.accessToken, {
|
||
permissions: ['script:read', 'user:view'],
|
||
specialPermissions: ['delivery:export'],
|
||
dataScope: { type: 'owned_customers' },
|
||
});
|
||
expect(patched.status).toBe(200);
|
||
expect(patched.body.role.permissions).toEqual(['script:read', 'user:view']);
|
||
expect(patched.body.role.specialPermissions).toEqual(['delivery:export']);
|
||
expect(patched.body.role.dataScope).toEqual({ type: 'owned_customers' });
|
||
expect(patched.body.affectedUserCount).toBe(1);
|
||
|
||
const outbox = await prisma.outboxEvent.findFirst({
|
||
where: { aggregateId: role.id, eventType: 'role.permissions.changed' },
|
||
orderBy: { occurredAt: 'desc' },
|
||
});
|
||
expect(outbox).not.toBeNull();
|
||
const payload = outbox?.payloadJson as {
|
||
roleId: string;
|
||
affectedUserIds: string[];
|
||
changes: { permissions: { added: string[]; removed: string[] } };
|
||
};
|
||
expect(payload.roleId).toBe(role.id);
|
||
expect(payload.affectedUserIds).toEqual([boundUser.id]);
|
||
expect(payload.changes.permissions.added).toEqual(['user:view']);
|
||
|
||
// 系统角色保护:改权限 40903,仅显示名可改
|
||
const superAdminRole = await prisma.role.findUniqueOrThrow({ where: { code: 'super_admin' } });
|
||
const protectedUpdate = await callJson<{ code: number }>(
|
||
created.app,
|
||
'PATCH',
|
||
`/roles/${superAdminRole.id}`,
|
||
body.accessToken,
|
||
{ permissions: ['user:view'] },
|
||
);
|
||
expect(protectedUpdate.status).toBe(409);
|
||
expect(protectedUpdate.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
|
||
const renamed = await callJson<{ role: { name: string } }>(
|
||
created.app,
|
||
'PATCH',
|
||
`/roles/${superAdminRole.id}`,
|
||
body.accessToken,
|
||
{ name: '超级管理员' },
|
||
);
|
||
expect(renamed.status).toBe(200);
|
||
expect(renamed.body.role.name).toBe('超级管理员');
|
||
});
|
||
|
||
it('DELETE /roles:系统角色 / 有绑定用户拒绝(40903),无绑定用户删除成功', async () => {
|
||
const { body } = await login(created.app, manager.account);
|
||
const superAdminRole = await prisma.role.findUniqueOrThrow({ where: { code: 'super_admin' } });
|
||
const systemDelete = await callJson<{ code: number }>(
|
||
created.app,
|
||
'DELETE',
|
||
`/roles/${superAdminRole.id}`,
|
||
body.accessToken,
|
||
);
|
||
expect(systemDelete.status).toBe(409);
|
||
expect(systemDelete.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
|
||
|
||
const bound = await createTestRole({ name: '有绑定' });
|
||
await createFixtureUserWithRoleId('role-bound', bound.id);
|
||
const boundDelete = await callJson<{ code: number; details: { userCount: number } }>(
|
||
created.app,
|
||
'DELETE',
|
||
`/roles/${bound.id}`,
|
||
body.accessToken,
|
||
);
|
||
expect(boundDelete.status).toBe(409);
|
||
expect(boundDelete.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
|
||
expect(boundDelete.body.details.userCount).toBe(1);
|
||
|
||
const free = await createTestRole({ name: '无绑定' });
|
||
const freeDelete = await created.app.inject({
|
||
method: 'DELETE',
|
||
url: `${API}/roles/${free.id}`,
|
||
headers: { authorization: `Bearer ${body.accessToken}` },
|
||
});
|
||
expect(freeDelete.statusCode).toBe(204);
|
||
const gone = await prisma.role.findUnique({ where: { id: free.id } });
|
||
expect(gone).toBeNull();
|
||
});
|
||
|
||
it('角色停用后,绑定用户下一次请求立即失效(40301)', async () => {
|
||
const { body } = await login(created.app, manager.account);
|
||
const role = await createTestRole({ name: '待停用', permissions: ['user:view'] });
|
||
const user = await createFixtureUserWithRoleId('role-disable', role.id);
|
||
const userLogin = await login(created.app, user.account);
|
||
expect(await authed(created.app, 'GET', '/users', userLogin.body.accessToken)).toBe(200);
|
||
|
||
const disabled = await callJson<{ role: { status: string }; affectedUserCount: number }>(
|
||
created.app,
|
||
'PATCH',
|
||
`/roles/${role.id}`,
|
||
body.accessToken,
|
||
{ status: 'disabled', reason: '集成测试停用' },
|
||
);
|
||
expect(disabled.status).toBe(200);
|
||
expect(disabled.body.role.status).toBe('disabled');
|
||
expect(disabled.body.affectedUserCount).toBe(1);
|
||
expect(await authed(created.app, 'GET', '/users', userLogin.body.accessToken)).toBe(403);
|
||
|
||
const enabled = await callJson<{ role: { status: string } }>(
|
||
created.app,
|
||
'PATCH',
|
||
`/roles/${role.id}`,
|
||
body.accessToken,
|
||
{ status: 'enabled' },
|
||
);
|
||
expect(enabled.body.role.status).toBe('active');
|
||
// 再启用后同一 access token 恢复权限(每请求解析,无缓存)
|
||
expect(await authed(created.app, 'GET', '/users', userLogin.body.accessToken)).toBe(200);
|
||
});
|
||
|
||
it('/me:dataScopes 多角色并集;超管固定 all;A3 接入真实客户目录', async () => {
|
||
const adminLogin = await login(created.app, manager.account);
|
||
expect(adminLogin.body.me.dataScopes).toEqual([{ type: 'all' }]);
|
||
const adminMe = await callJson<MeBody>(created.app, 'GET', '/me', adminLogin.body.accessToken);
|
||
expect(adminMe.body.dataScopes).toEqual([{ type: 'all' }]);
|
||
|
||
const ownedRole = await createTestRole({
|
||
name: '负责客户范围',
|
||
permissions: ['user:view'],
|
||
dataScope: { type: 'owned_customers' },
|
||
});
|
||
const deptRole = await createTestRole({
|
||
name: '部门范围',
|
||
permissions: ['customer:read'],
|
||
dataScope: { type: 'department' },
|
||
});
|
||
const user = await createFixtureUserWithRoleId('me-scopes', ownedRole.id);
|
||
await prisma.userRole.create({ data: { userId: user.id, roleId: deptRole.id } });
|
||
const userLogin = await login(created.app, user.account);
|
||
expect(userLogin.status).toBe(200);
|
||
const scopes = userLogin.body.me.dataScopes;
|
||
expect(scopes.map((scope) => scope.type)).toEqual(['department', 'owned_customers']);
|
||
for (const scope of scopes) {
|
||
expect(Array.isArray(scope.customerIds)).toBe(true);
|
||
}
|
||
// A3 起 CustomerDirectory 返回真实客户;本用例用户没有负责 / 创建的客户
|
||
expect(scopes.find((scope) => scope.type === 'owned_customers')?.customerIds).toEqual([]);
|
||
});
|
||
});
|