backend-ts/src/app-hooks.disabled-account.bugcheck.spec.ts
Rama main · réplica de lectura del repositorio original.
import Fastify from 'fastify';
import { describe, expect, it, vi } from 'vitest';
import { registerAppHooks } from './app-hooks.js';
import type { JwtService } from './crypto/jwt.service.js';
import type { ApiTokenService } from './openapi/api-token.service.js';
import type { AuditLogService } from './audit/audit.service.js';
import type { MysqlUserRepository } from './user/user.repository.js';
import type { OpenApiCallLogService } from './openapi/open-api-call-log.service.js';
/**
* BUGCHECK(审查新增,非产品代码):账号被禁用后,已签发的 access token 仍可继续调用接口。
*
* 根因:请求鉴权链路 `app-hooks.ts` 的 preHandler → `auth/jwt-hook.ts`
* 的 `authenticateRequest()` 只做「验签 + 过期」判断,直接把 token 里的 userId
* 写进 `request.userId`,**从不查用户账号状态**。
* 同仓库其它入口都拒绝停用账号:
* - 本地密码登录 / 刷新 token:`auth/auth.service.ts:34,50`(status === 0 → ACCOUNT_DISABLED)
* - LDAP / 飞书 / ECP 登录:各自校验 status === 0
* - 分享链接:`session/session-share.service.ts:202`(属主 status === 0 → 链接失效)
* 口径不一致 → 管理员「禁用」用户后,该用户的存量 access token 在其有效期内
* (jwt.expiration,默认 24h)仍可读写他人数据、建终端、调模型。
*
* 断言按「本应如此」书写:停用账号的请求必须被拒绝(响应体 code 不得为 0),
* 启用账号不受影响(对照组)。当前源码下第一条断言失败即证明 BUG 真实存在。
*/
function buildApp(userStatus: number | null) {
const app = Fastify();
const jwt = {
validateAccessToken: (token: string) => token === 'valid-access-token',
getUserIdFromToken: () => 42,
getTokenType: () => 'access',
} as unknown as JwtService;
const apiTokenService = {
resolveByToken: vi.fn(async () => ({ ok: false, reason: 'not_found' })),
} as unknown as ApiTokenService;
const userRepo = {
findById: vi.fn(async () => ({ id: 42, username: 'disabled-user', status: userStatus, deleted: 0 })),
} as unknown as MysqlUserRepository;
registerAppHooks({
app,
jwt,
apiTokenService,
openCallLogHolder: { current: null as OpenApiCallLogService | null },
auditService: { record: vi.fn(async () => undefined) } as unknown as AuditLogService,
userRepo,
ssoExchangePath: '/api/v1/auth/sso/exchange',
});
app.get('/v1/ping', async (_request, reply) => {
return reply.status(200).send({ code: 0, message: 'success', data: 'pong' });
});
return { app, userRepo };
}
describe('BUGCHECK disabled account must lose access with its still-valid access token', () => {
it('rejects a request carrying the access token of a disabled (status = 0) user', async () => {
const { app, userRepo } = buildApp(0);
const res = await app.inject({
method: 'GET',
url: '/v1/ping',
headers: { authorization: 'Bearer valid-access-token' },
});
const body = res.json() as { code: number };
// 停用账号的存量 token 必须失效:成功码(0)不应出现
// (拒绝时可能是 1001 未登录 / 1002 无权限 / 1006 账号已禁用)
expect(body.code === 0 ? '放行' : '拒绝').toBe('拒绝');
// 鉴权阶段查一次;停用请求 userId 为空,onResponse 不再查
expect(userRepo.findById).toHaveBeenCalledTimes(1);
});
it('still accepts a request from an enabled user (control group)', async () => {
const { app, userRepo } = buildApp(1);
const res = await app.inject({
method: 'GET',
url: '/v1/ping',
headers: { authorization: 'Bearer valid-access-token' },
});
const body = res.json() as { code: number };
expect(body.code).toBe(0);
// 鉴权阶段查一次,onResponse 审计复用,不再打第二次 user 表
expect(userRepo.findById).toHaveBeenCalledTimes(1);
});
});
/**
* API Token 与 JWT 同口径:账号被停用 / 删除后,已签发的 mao_ token 也不得再调用 /v1/open/**。
* 之前 authenticateRequest 在 API Token 分支提前 return,账号状态检查只覆盖 JWT。
*/
function buildApiTokenApp(userStatus: number | null) {
const app = Fastify();
const jwt = {
validateAccessToken: () => false,
getUserIdFromToken: () => 42,
getTokenType: () => 'access',
} as unknown as JwtService;
const apiTokenService = {
resolveByToken: vi.fn(async () => ({ ok: true, userId: 42, scopes: [], tokenId: 7 })),
} as unknown as ApiTokenService;
const userRepo = {
findById: vi.fn(async () => ({ id: 42, username: 'api-user', status: userStatus, deleted: 0 })),
} as unknown as MysqlUserRepository;
registerAppHooks({
app,
jwt,
apiTokenService,
openCallLogHolder: { current: null as OpenApiCallLogService | null },
auditService: { record: vi.fn(async () => undefined) } as unknown as AuditLogService,
userRepo,
ssoExchangePath: '/api/v1/auth/sso/exchange',
});
app.get('/v1/open/ping', async (_request, reply) => {
return reply.status(200).send({ code: 0, message: 'success', data: 'pong' });
});
return { app, userRepo };
}
describe('BUGCHECK disabled account must also lose API token access', () => {
it('rejects a mao_ API token whose owner is disabled', async () => {
const { app, userRepo } = buildApiTokenApp(0);
const res = await app.inject({
method: 'GET',
url: '/v1/open/ping',
headers: { authorization: 'Bearer mao_test_token' },
});
expect((res.json() as { code: number }).code).not.toBe(0);
expect(userRepo.findById).toHaveBeenCalledTimes(1);
});
it('still accepts the API token of an enabled user (control group)', async () => {
const { app, userRepo } = buildApiTokenApp(1);
const res = await app.inject({
method: 'GET',
url: '/v1/open/ping',
headers: { authorization: 'Bearer mao_test_token' },
});
expect((res.json() as { code: number }).code).toBe(0);
expect(userRepo.findById).toHaveBeenCalledTimes(1);
});
});