api-key-auth.test.js 6.97 KB
/**
 * Unit Tests — middleware/api-key-auth.js
 */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';

const { extractApiKey } = require('../../middleware/api-key-auth');

describe('api-key-auth', () => {
  function mockReq(headers = {}, query = {}) {
    return { headers, query };
  }

  describe('extractApiKey', () => {
    it('should extract from Authorization Bearer header', () => {
      const req = mockReq({ authorization: 'Bearer my-secret-key' });
      expect(extractApiKey(req)).toBe('my-secret-key');
    });

    it('should extract from X-API-Key header', () => {
      const req = mockReq({ 'x-api-key': 'another-key' });
      expect(extractApiKey(req)).toBe('another-key');
    });

    it('should extract from query param', () => {
      const req = mockReq({}, { api_key: 'query-key' });
      expect(extractApiKey(req)).toBe('query-key');
    });

    it('should prefer Bearer over X-API-Key', () => {
      const req = mockReq({
        authorization: 'Bearer bearer-key',
        'x-api-key': 'x-key',
      });
      expect(extractApiKey(req)).toBe('bearer-key');
    });

    it('should return null when no key provided', () => {
      const req = mockReq();
      expect(extractApiKey(req)).toBeNull();
    });

    it('should trim whitespace', () => {
      const req = mockReq({ authorization: 'Bearer  spaced-key  ' });
      expect(extractApiKey(req)).toBe('spaced-key');
    });

    it('should ignore non-Bearer auth headers', () => {
      const req = mockReq({ authorization: 'Basic abc123' });
      expect(extractApiKey(req)).toBeNull();
    });
  });
});

/**
 * requireApiKey —— **做鉴权决定的那一半**。
 *
 * 上面那组 7 条全在测 extractApiKey(怎么从 header/query 里取出字符串),
 * 而真正决定放行还是 401 的 requireApiKey 此前**一条用例都没有**。
 * 后果不是假设:`OPEN_API_KEYS` 那条分支从来没被执行过,于是
 * integration 测试里把变量名写成 `API_KEYS`(生产读的是 `OPEN_API_KEYS`)
 * 存活了很久也没人发现 —— 没有测试执行过的路径,喂给它什么名字都不会有人知道。
 *
 * 四条分支各一条,覆盖「拦」和「放」两侧:
 *   无 key → 401 / key 无效 → 401 / 命中系统 key 列表 → 放行 / 命中用户 key → 放行且挂 req.user
 *
 * 隔离两处,缺任一条这组用例就会打到真实环境:
 *   - `OPEN_API_KEYS`:config.js 在模块加载时求值,所以要先设 env 再清 require.cache
 *     (config 与本中间件都要清 —— 中间件在顶层 require config)
 *   - `USERS_DB_PATH`:requireApiKey 内部**惰性** require user-db 来查用户 key。
 *     这个覆盖口是刚补上的;在它存在之前,这组用例会往**共享的生产用户库**里
 *     插一个测试用户(那个库正是这么攒到 356 行夹具的)。
 */
describe('requireApiKey — 鉴权判定(此前零覆盖)', () => {
  const path = require('path');
  const fs = require('fs');

  const SYSTEM_KEY = 'sk-system-test-key';
  const TEST_DB_PATH = `/tmp/test-apikey-users-${process.pid}.sqlite`;
  const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
  const MODULES = ['../../config', '../../middleware/api-key-auth', '../../services/user-db'];

  let requireApiKey;
  let userKey;
  let prevOpenApiKeys;

  beforeAll(() => {
    for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
    prevOpenApiKeys = process.env.OPEN_API_KEYS;
    process.env.OPEN_API_KEYS = `${SYSTEM_KEY}, sk-another-one`; // 带空格:config 会 trim
    process.env.USERS_DB_PATH = TEST_DB_PATH;
    for (const m of MODULES) delete require.cache[require.resolve(m)];

    const userDb = require('../../services/user-db');
    const user = userDb.createUser({
      email: `apikey-${process.pid}@example.com`,
      passwordHash: 'x',
      displayName: 'API Key User',
      provider: 'local',
    });
    userKey = user.api_key; // createUser 自己生成 sk-user-<hex>
    expect(userKey).toMatch(/^sk-user-/);

    ({ requireApiKey } = require('../../middleware/api-key-auth'));
  });

  afterAll(() => {
    for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
    if (prevOpenApiKeys === undefined) delete process.env.OPEN_API_KEYS;
    else process.env.OPEN_API_KEYS = prevOpenApiKeys;
    delete process.env.USERS_DB_PATH;
    for (const m of MODULES) delete require.cache[require.resolve(m)];
  });

  /** 只记录结果,不真起 express */
  function run(headers) {
    const req = { headers, query: {} };
    const res = {
      statusCode: null,
      body: null,
      status(code) { this.statusCode = code; return this; },
      json(payload) { this.body = payload; return this; },
    };
    let passed = false;
    requireApiKey(req, res, () => { passed = true; });
    return { req, res, passed };
  }

  it('隔离生效:用的是临时用户库,不是共享的生产库', () => {
    expect(fs.existsSync(TEST_DB_PATH)).toBe(true);
  });

  it('① 完全不带 key → 401「需要 key」,且不调 next', () => {
    const { res, passed } = run({});
    expect(passed).toBe(false);
    expect(res.statusCode).toBe(401);
    expect(res.body.error).toBe('Unauthorized');
    // 必须断言到**文案**而不是只断言 401:去掉这道门之后,key=null 会落到
    // 下面那道「key 无效」的门,照样回 401 —— 只看状态码的话这个变异测不出来
    // (实测过:把 if (!key) 改成 if (false),只断言 401 的版本全绿)。
    // 而两者要分得开:一个是「你忘带 key」,一个是「你的 key 不对」,排查时全靠它。
    expect(res.body.message).toMatch(/API Key is required/);
  });

  it('② key 既不在系统列表也不属于任何用户 → 401', () => {
    const { res, passed } = run({ 'x-api-key': 'sk-not-a-real-key' });
    expect(passed).toBe(false);
    expect(res.statusCode).toBe(401);
    // 与「没带 key」是两种不同的失败,文案要分得开(排查时全靠这个)
    expect(res.body.message).toMatch(/Invalid API Key/);
  });

  it('③ 命中 OPEN_API_KEYS 系统列表 → 放行,挂 req.apiKey,不挂 req.user', () => {
    // 这条就是那条从来没被执行过的分支:它存在的话,把变量名写成 API_KEYS 会当场红
    const { req, res, passed } = run({ authorization: `Bearer ${SYSTEM_KEY}` });
    expect(passed).toBe(true);
    expect(res.statusCode).toBeNull();
    expect(req.apiKey).toBe(SYSTEM_KEY);
    expect(req.user).toBeUndefined(); // 系统 key 不代表任何用户,不该凭空造一个身份
  });

  it('④ 命中库里某个用户的 api_key → 放行并把该用户挂上 req.user', () => {
    const { req, passed } = run({ 'x-api-key': userKey });
    expect(passed).toBe(true);
    expect(req.apiKey).toBe(userKey);
    expect(req.user).toBeTruthy();
    expect(req.user.email).toBe(`apikey-${process.pid}@example.com`);
    // 走的是 toPublicUser,密码哈希不该跟着挂到请求上
    expect(req.user).not.toHaveProperty('password_hash');
  });
});