Ctrl K
ComunidadGuías…

backend-ts/src/logic-bug-review-2026-11-04.spec.ts

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

Archivo original ↗Preguntar a KiBot
/**
 * 2026-11-04 逻辑 BUG 审查的验证用例(对应 docs/code-review/2026-11-04-logic-bug-review.md)。
 *
 * 每个用例按「正确行为」书写。初评为未修复缺陷时用 `bugIt = it.fails` 固化为
 * expected-failure(CI 绿色);**2026-11-04 当日全部修复后转正为普通 it**——
 * 本文件即 6 条缺陷的回归测试,任何修复回退都会在此显红。
 *
 * 说明:BUG-1 的修复是进程级时区对齐(main.ts 最早处 import
 * './config/process-timezone.js' → process.env.TZ='Asia/Shanghai')。本文件同款引入
 * 该生产模块复现此前提,再对真实 monthStartLocal/currentPeriodLabel 断言月初边界
 * 时刻的输出——宿主时区为何都不影响结果。vitest 3 默认 forks 池按文件隔离进程,
 * TZ 副作用不会泄漏到其他 spec。
 */
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { mkdtempSync } from 'node:fs';
import { afterEach, describe, expect, it, vi } from 'vitest';

// 与 main.ts 入口同款:先把进程时区对齐到 Asia/Shanghai(生产修复机制本身)
import './config/process-timezone.js';
import {
  APPROVAL_RULE_DENY_TOKENS_SEED,
  buildApprovalHint,
  containsDenyToken,
} from './approval-rule/approval-rule-normalize.js';
import { currentPeriodLabel, monthStartLocal } from './budget/budget.service.js';
import { EnvVariableService } from './env/env-variable.service.js';
import type { EnvVariableRepository } from './env/types.js';
import { GrepSearchTool } from './harness/tool/impl/grep-search-tool.js';
import { PathSandbox } from './harness/safety/path-sandbox.js';
import { LlmCallService } from './usage/llm-call.service.js';

// ────────────────────────────────────────────────────────────────────────────
// BUG-1 预算月度窗口时区:进程级 Asia/Shanghai 对齐后,月初边界归上海日历月
// ────────────────────────────────────────────────────────────────────────────
describe('BUG-1 预算月度窗口时区', () => {
  // 北京时间 2026-11-01 02:00 = UTC 2026-10-31 18:00:未对齐时 getMonth 取 UTC 的 10 月
  it('月初边界时刻 since 应为上海 11 月初,而非宿主本地月的 10 月初', () => {
    const boundary = new Date('2026-11-01T02:00:00+08:00');
    expect(monthStartLocal(boundary)).toBe('2026-11-01 00:00:00');
  });

  it('同一时刻的 period 标签也应为上海 11 月', () => {
    const boundary = new Date('2026-11-01T02:00:00+08:00');
    expect(currentPeriodLabel(boundary)).toBe('2026-11');
  });
});

// ────────────────────────────────────────────────────────────────────────────
// BUG-2 审批 denylist 词匹配无 basename 归一:/bin/rm 绕过
// ────────────────────────────────────────────────────────────────────────────
describe('BUG-2 denylist 绝对路径绕过', () => {
  // 注意:seed 中的 '-rf' 会命中 '/bin/rm -rf' 的第二 token,掩盖 basename 缺失;
  // 需用无 '-rf' 的命令隔离:'/bin/rm x'(仅 rm)或 '/usr/bin/sudo'(无 -rf)。
  it('containsDenyToken 应识别 /bin/rm(无 -rf 时)', () => {
    expect(containsDenyToken('/bin/rm /tmp/victim', APPROVAL_RULE_DENY_TOKENS_SEED)).toBe(true);
  });

  it('containsDenyToken 应识别 /usr/bin/sudo', () => {
    expect(containsDenyToken('/usr/bin/sudo apt update', APPROVAL_RULE_DENY_TOKENS_SEED)).toBe(true);
  });

  it('buildApprovalHint 对 /bin/rm 应返回 null(不可放行)', () => {
    expect(buildApprovalHint('shell', '{"command":"/bin/rm /tmp/victim"}', APPROVAL_RULE_DENY_TOKENS_SEED)).toBeNull();
  });
});

// ────────────────────────────────────────────────────────────────────────────
// BUG-3 denylist 扫描对象被截断为 512 字符:长命令尾部绕过
// ────────────────────────────────────────────────────────────────────────────
describe('BUG-3 denylist 512 截断绕过', () => {
  it('rm 位于第 512 字符之后时仍应命中 denylist(buildApprovalHint 必须为 null)', () => {
    const cmd = `echo ${'a'.repeat(600)} ; rm -rf /`;
    expect(cmd.indexOf('rm')).toBeGreaterThan(512);
    // 实现路径是 buildApprovalHint(先 normalize 截断再扫),直传 containsDenyToken 未截断预期为预期内 true,不作本用例证据。
    expect(buildApprovalHint('shell', JSON.stringify({ command: cmd }), APPROVAL_RULE_DENY_TOKENS_SEED)).toBeNull();
  });

  it('长命令中 rm 在 512 之后时 buildApprovalHint 必须为 null', () => {
    const cmd = `echo ${'a'.repeat(600)} ; rm -rf /tmp/x`;
    expect(buildApprovalHint('shell', JSON.stringify({ command: cmd }), APPROVAL_RULE_DENY_TOKENS_SEED)).toBeNull();
  });
});

// ────────────────────────────────────────────────────────────────────────────
// BUG-4 兜底价格缓存把「查库异常」也当「模型不存在」缓存 60s
// ────────────────────────────────────────────────────────────────────────────
describe('BUG-4 价格兜底异常负缓存', () => {
  it('lookup 抛异常时不应把 null 写进价格缓存(60s 内后续调用仍应重试)', async () => {
    let calls = 0;
    const svc = new LlmCallService(
      { insert: vi.fn(async () => 1) } as never,
      undefined,
      undefined,
      async () => {
        calls++;
        if (calls === 1) throw new Error('DB connection jitter');
        return { priceInput: 1, priceCacheRead: 1, priceCacheWrite: 1, priceOutput: 1 };
      },
    );
    const base = {
      modelConfig: { id: 42, modelId: 'm-42' },
      stream: false,
      usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 } as never,
      success: true,
      errorMessage: null,
      firstTokenMs: null,
      durationMs: 10,
      retryCount: 0,
      systemPrompt: null,
      toolsJson: null,
    };
    // 第一次:lookup 抛异常 → costMicros 应为 null,且不可缓存
    await svc.record(base);
    // 第二次(同一进程、60s 窗口内):lookup 恢复,costMicros 应非 null。
    // 当前实现缓存了 null,第二次仍 null 而 lookup 只被调用 1 次。
    await svc.record(base);
    const inserted = (svc as unknown as { repo: { insert: ReturnType<typeof vi.fn> } }).repo.insert.mock.calls;
    const secondCost = inserted[1]?.[0]?.costMicros;
    expect(secondCost).not.toBeNull();
    expect(calls).toBe(2);
  });
});

// ────────────────────────────────────────────────────────────────────────────
// BUG-5 环境变量缓存把「查库失败」缓存为空数组
// ────────────────────────────────────────────────────────────────────────────
describe('BUG-5 环境变量失败缓存', () => {
  const tmp = mkdtempSync(join(tmpdir(), 'mao-env-bug-'));
  afterEach(() => { /* 单用例内自清 */ });

  it('listUser 失败时不应把空数组写进缓存(5s 内后续 resolve 仍应拿到变量)', async () => {
    let userCalls = 0;
    const { encryptAesCbc } = await import('./crypto/aes-cbc.js');
    const secret = 'test-secret-1234567890123456';
    const goodRow = {
      id: 1,
      userId: 7,
      envKey: 'MY_API_KEY',
      envValue: encryptAesCbc('sk-test', secret),
      note: null,
    };
    const repo = {
      listSystem: vi.fn(async () => []),
      listUser: vi.fn(async () => { userCalls++; if (userCalls === 1) throw new Error('db jitter'); return [goodRow]; }),
      listWorkspace: vi.fn(async () => []),
    } as unknown as EnvVariableRepository;
    const svc = new EnvVariableService(
      repo,
      secret,
      tmp,
      { shellEnvVarsEnabled: async () => true },
      undefined,
    );
    const first = await svc.resolveForSession(7, tmp);
    // 第一次失败 → {} 可接受;关键是第二次必须重试查库并注入变量,
    // 不能因缓存了 [] 而直接返回 {}
    const second = await svc.resolveForSession(7, tmp);
    expect(first).toEqual({});
    expect(second.MY_API_KEY).toBe('sk-test');
    expect(userCalls).toBe(2);
    rmSync(tmp, { recursive: true, force: true });
  });
});

// ────────────────────────────────────────────────────────────────────────────
// BUG-6 grep_search 负数参数未校验
// ────────────────────────────────────────────────────────────────────────────
describe('BUG-7 grep_search 负数参数', () => {
  it('max_output_chars=-10 应报错或钳制,而非返回干净的「0 命中」', async () => {
    const root = mkdtempSync(join(tmpdir(), 'mao-grep-neg-'));
    mkdirSync(root, { recursive: true });
    writeFileSync(join(root, 'a.txt'), 'hello');
    // JS 路径(无 rg 时)+ 负上限:charsUsed(0)+size > -10 恒成立 → matches=[] 且无 error
    const tool = new GrepSearchTool(new PathSandbox(root));
    const out = JSON.parse(await tool.execute(
      JSON.stringify({ pattern: 'hello', path: root, max_output_chars: -10 }), root,
    )) as { error?: string; matches?: unknown[]; total_matches?: number };
    // 正确行为:负数参数应 error(或钳到合法下限后仍返回命中)。当前静默 0 命中会让模型误判「无结果」。
    expect(out.error).toBeDefined();
    rmSync(root, { recursive: true, force: true });
  });
});