Ctrl K
ComunidadGuías…

backend-ts/src/app-hooks.disabled-account.bugcheck.spec.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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);
  });
});