tests/tickets.integration.test.ts
此代码来自构建时的仓库快照。修改源码后重新构建即可更新。
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '@/generated/prisma/client';
import { appRouter } from '@/server/api/router';
import { randomUUID, randomBytes } from 'node:crypto';
import { createAuth } from '@/server/auth';
vi.mock('server-only', () => ({}));
// 必须显式提供测试连接。只清理本轮测试自己的团队、账号和工单,不清空其他数据。
describe.skipIf(!process.env.TEST_DATABASE_URL)(
'认证、角色与团队隔离(真实数据库)',
() => {
const db = new PrismaClient({
adapter: new PrismaPg({
connectionString: process.env.TEST_DATABASE_URL ?? '',
max: 5,
}),
});
const ids: string[] = [];
const teamIds: string[] = [];
const ticketIds: string[] = [];
let a: string,
b: string,
agent: string,
customer: string,
otherCustomer: string;
let teamA: string, teamB: string;
const caller = (userId: string | null) =>
appRouter.createCaller({ db, userId });
const input = {
title: '团队工单',
description: '验证权限边界',
priority: 'NORMAL' as const,
};
beforeAll(async () => {
for (let n = 0; n < 5; n++) {
const id = randomUUID();
ids.push(id);
await db.user.create({
data: { id, name: `测试账号${n}`, email: `${id}@example.com` },
});
}
[a, b, agent, customer, otherCustomer] = ids;
const first = await caller(a).team.create({ name: '测试团队甲' });
teamA = first.id;
teamIds.push(teamA);
const second = await caller(b).team.create({ name: '测试团队乙' });
teamB = second.id;
teamIds.push(teamB);
for (const [userId, role] of [
[agent, 'AGENT'],
[customer, 'CUSTOMER'],
[otherCustomer, 'CUSTOMER'],
] as const) {
const user = await db.user.findUniqueOrThrow({ where: { id: userId } });
await caller(a).team.addMember({
teamId: teamA,
email: user.email,
role,
});
}
});
afterAll(async () => {
try {
await db.ticket.deleteMany({
where: {
OR: [{ teamId: { in: teamIds } }, { id: { in: ticketIds } }],
},
});
await db.team.deleteMany({ where: { id: { in: teamIds } } });
await db.user.deleteMany({ where: { id: { in: ids } } });
} finally {
await db.$disconnect();
}
});
it('未登录用户不能读取或创建业务数据', async () => {
await expect(caller(null).team.list()).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
await expect(
caller(null).ticket.list({ teamId: teamA }),
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
await expect(
caller(null).ticket.create({ ...input, teamId: teamA }),
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
});
it('团队创建者成为管理员,其他用户看不到该团队', async () => {
expect(await caller(a).team.list()).toEqual([
{ id: teamA, name: '测试团队甲', role: 'ADMIN' },
]);
expect(await caller(b).team.list()).toEqual([
{ id: teamB, name: '测试团队乙', role: 'ADMIN' },
]);
});
it('跨团队伪造 teamId 或工单 ID 无法读取、创建或更新', async () => {
const ticket = await caller(a).ticket.create({ ...input, teamId: teamA });
await expect(
caller(b).ticket.list({ teamId: teamA }),
).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(
caller(b).ticket.create({ ...input, teamId: teamA }),
).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(
caller(b).ticket.detail({ teamId: teamA, id: ticket.id }),
).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(
caller(b).ticket.detail({ teamId: teamB, id: ticket.id }),
).rejects.toMatchObject({ code: 'NOT_FOUND' });
await expect(
caller(b).ticket.updateStatus({
teamId: teamB,
id: ticket.id,
version: 0,
status: 'CLOSED',
reason: '测试关闭说明',
}),
).rejects.toMatchObject({ code: 'NOT_FOUND' });
expect((await caller(b).ticket.list({ teamId: teamB })).total).toBe(0);
});
it('客户只能读自己的工单,客服可处理团队工单', async () => {
const mine = await caller(customer).ticket.create({
...input,
teamId: teamA,
});
const other = await caller(otherCustomer).ticket.create({
...input,
teamId: teamA,
});
const list = await caller(customer).ticket.list({ teamId: teamA });
expect(list.total).toBe(1);
expect(list.items[0].id).toBe(mine.id);
expect(
(await caller(customer).ticket.detail({ teamId: teamA, id: mine.id }))
.id,
).toBe(mine.id);
await expect(
caller(customer).ticket.detail({ teamId: teamA, id: other.id }),
).rejects.toMatchObject({ code: 'NOT_FOUND' });
await expect(
caller(customer).ticket.updateStatus({
teamId: teamA,
id: mine.id,
version: 0,
status: 'CLOSED',
reason: '测试关闭说明',
}),
).rejects.toMatchObject({ code: 'FORBIDDEN' });
expect(
(
await caller(agent).ticket.updateStatus({
teamId: teamA,
id: mine.id,
version: 0,
status: 'IN_PROGRESS',
})
).version,
).toBe(1);
});
it('只有管理员能添加、修改和移除成员,不能操作其他团队成员', async () => {
await expect(
caller(agent).team.changeRole({
teamId: teamA,
userId: customer,
role: 'ADMIN',
}),
).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(
caller(customer).team.addMember({
teamId: teamA,
email: 'nobody@example.com',
role: 'ADMIN',
}),
).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(
caller(customer).team.removeMember({ teamId: teamA, userId: agent }),
).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(
caller(b).team.members({ teamId: teamA }),
).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(
caller(b).team.changeRole({ teamId: teamA, userId: b, role: 'ADMIN' }),
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('角色更新在下次请求生效,移除成员后旧请求身份无法继续访问', async () => {
await caller(a).team.changeRole({
teamId: teamA,
userId: otherCustomer,
role: 'AGENT',
});
const ticket = await caller(a).ticket.create({ ...input, teamId: teamA });
await caller(otherCustomer).ticket.updateStatus({
teamId: teamA,
id: ticket.id,
version: 0,
status: 'IN_PROGRESS',
});
await caller(a).team.removeMember({
teamId: teamA,
userId: otherCustomer,
});
await expect(
caller(otherCustomer).ticket.detail({ teamId: teamA, id: ticket.id }),
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('无法移除或降级最后一个管理员', async () => {
await expect(
caller(a).team.removeMember({ teamId: teamA, userId: a }),
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
await expect(
caller(a).team.changeRole({
teamId: teamA,
userId: a,
role: 'CUSTOMER',
}),
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
});
it('并发管理员降级不会产生无人管理的团队', async () => {
await caller(a).team.changeRole({
teamId: teamA,
userId: agent,
role: 'ADMIN',
});
const results = await Promise.allSettled([
caller(a).team.changeRole({ teamId: teamA, userId: a, role: 'AGENT' }),
caller(agent).team.changeRole({
teamId: teamA,
userId: agent,
role: 'AGENT',
}),
]);
expect(
results.filter((result) => result.status === 'fulfilled'),
).toHaveLength(1);
expect(
await db.membership.count({ where: { teamId: teamA, role: 'ADMIN' } }),
).toBe(1);
// 后续测试仍使用 a 作为管理员,恢复测试自己的初始状态。
await db.membership.updateMany({
where: { teamId: teamA, userId: a },
data: { role: 'ADMIN' },
});
});
it('同一用户在不同团队的角色独立,不能借用管理员身份提权', async () => {
const user = await db.user.findUniqueOrThrow({ where: { id: a } });
await caller(b).team.addMember({
teamId: teamB,
email: user.email,
role: 'CUSTOMER',
});
await expect(
caller(a).team.changeRole({ teamId: teamB, userId: a, role: 'ADMIN' }),
).rejects.toMatchObject({ code: 'FORBIDDEN' });
const ticket = await caller(b).ticket.create({ ...input, teamId: teamB });
await expect(
caller(a).ticket.detail({ teamId: teamB, id: ticket.id }),
).rejects.toMatchObject({ code: 'NOT_FOUND' });
await expect(
caller(a).ticket.updateStatus({
teamId: teamB,
id: ticket.id,
version: 0,
status: 'CLOSED',
reason: '测试关闭说明',
}),
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('状态流转、非法输入与并发版本保护保持有效', async () => {
await expect(
caller(a).ticket.create({ ...input, title: ' ', teamId: teamA }),
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
const ticket = await caller(a).ticket.create({ ...input, teamId: teamA });
const results = await Promise.allSettled([
caller(a).ticket.updateStatus({
teamId: teamA,
id: ticket.id,
version: 0,
status: 'IN_PROGRESS',
}),
caller(a).ticket.updateStatus({
teamId: teamA,
id: ticket.id,
version: 0,
status: 'CLOSED',
reason: '测试关闭说明',
}),
]);
expect(
results.filter((result) => result.status === 'fulfilled'),
).toHaveLength(1);
const failure = results.find((result) => result.status === 'rejected');
expect(failure?.status === 'rejected' && failure.reason.code).toBe(
'CONFLICT',
);
const final = await caller(a).ticket.detail({
teamId: teamA,
id: ticket.id,
});
if (final.status !== 'CLOSED')
await caller(a).ticket.updateStatus({
teamId: teamA,
id: ticket.id,
version: final.version,
status: 'CLOSED',
reason: '测试关闭说明',
});
await expect(
caller(a).ticket.updateStatus({
teamId: teamA,
id: ticket.id,
version: 2,
status: 'OPEN',
}),
).rejects.toThrow();
});
it('Better Auth 注册、密码验证、Cookie 会话与退出失效', async () => {
const auth = createAuth(db, {
secret: randomBytes(48).toString('hex'),
baseURL: 'http://localhost:3000',
});
const email = `${randomUUID()}@example.com`;
const password = randomBytes(24).toString('hex');
const request = (path: string, body: object, cookie = '') =>
auth.handler(
new Request(`http://localhost:3000/api/auth/${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Origin: 'http://localhost:3000',
Cookie: cookie,
},
body: JSON.stringify(body),
}),
);
const registered = await request('sign-up/email', {
name: '认证测试',
email,
password,
});
expect(registered.status).toBe(200);
const user = await db.user.findUniqueOrThrow({ where: { email } });
ids.push(user.id);
const account = await db.account.findFirstOrThrow({
where: { userId: user.id },
});
expect(account.password).not.toBe(password);
const wrong = await request('sign-in/email', {
email,
password: 'wrong-password',
});
expect(wrong.status).toBe(401);
const signed = await request('sign-in/email', { email, password });
expect(signed.status).toBe(200);
const cookies = signed.headers.getSetCookie();
expect(cookies.some((cookie) => /httponly/i.test(cookie))).toBe(true);
const cookie = cookies.map((item) => item.split(';')[0]).join('; ');
const session = await auth.api.getSession({
headers: new Headers({ cookie }),
});
expect(session?.user.id).toBe(user.id);
const signedOut = await request('sign-out', {}, cookie);
expect(signedOut.status).toBe(200);
expect(
await auth.api.getSession({ headers: new Headers({ cookie }) }),
).toBeNull();
});
},
);