path-resolver.test.js 10.8 KB
/**
 * pathResolver 单测
 */
const fs = require('fs');
const path = require('path');
const os = require('os');

const pathResolver = require('../../services/devloop/path-resolver');

// 用 tmpdir 隔离测试
const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-test-'));
process.env.HOME = TEST_HOME; // 让 LINKED_REGISTRY 写到 tmp

describe('pathResolver', () => {
  beforeEach(() => {
    pathResolver._resetForTesting();
  });

  describe('isSensitivePath', () => {
    it('rejects /etc', () => {
      expect(pathResolver.isSensitivePath('/etc/passwd')).toBe(true);
    });
    it('rejects /usr/local/bin', () => {
      expect(pathResolver.isSensitivePath('/usr/local/bin')).toBe(true);
    });
    it('rejects /etc/passwd absolute', () => {
      expect(pathResolver.isSensitivePath('/etc/passwd')).toBe(true);
    });
    it('rejects /var/log/something', () => {
      expect(pathResolver.isSensitivePath('/var/log/anything')).toBe(true);
    });
    it('accepts ~/code/project', () => {
      expect(pathResolver.isSensitivePath(path.join(os.homedir(), 'code', 'project'))).toBe(false);
    });
    it('accepts /tmp/anything', () => {
      expect(pathResolver.isSensitivePath('/tmp/anything')).toBe(false);
    });
  });

  describe('allocateProjectRoot · sandbox', () => {
    it('creates root under data/companies/<cid>/projects/<pid>', () => {
      const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' });
      expect(r.pid).toMatch(/^p-[a-z0-9]{6}$/);
      expect(r.mode).toBe('sandbox');
      expect(r.root_path).toContain('data/companies/c-test/projects/');
      expect(fs.existsSync(r.root_path)).toBe(true);
      // cleanup
      fs.rmSync(r.root_path, { recursive: true, force: true });
    });
  });

  describe('allocateProjectRoot · linked-new', () => {
    it('rejects when target_path missing', () => {
      expect(() =>
        pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'linked-new' })
      ).toThrow(/target_path required/);
    });

    it('rejects relative target_path', () => {
      expect(() =>
        pathResolver.allocateProjectRoot({
          company_id: 'c-test', mode: 'linked-new', target_path: 'relative/path',
        })
      ).toThrow(/must be absolute/);
    });

    it('rejects sensitive path', () => {
      expect(() =>
        pathResolver.allocateProjectRoot({
          company_id: 'c-test', mode: 'linked-new', target_path: '/etc/foo',
        })
      ).toThrow(/sensitive_path/);
    });

    it('rejects when parent does not exist', () => {
      expect(() =>
        pathResolver.allocateProjectRoot({
          company_id: 'c-test', mode: 'linked-new',
          target_path: '/tmp/devloop-test-nonexistent-parent/proj',
        })
      ).toThrow(/parent dir does not exist/);
    });

    it('rejects when target_path is non-empty dir', () => {
      const target = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-linked-'));
      fs.writeFileSync(path.join(target, 'existing.txt'), 'x');
      expect(() =>
        pathResolver.allocateProjectRoot({
          company_id: 'c-test', mode: 'linked-new', target_path: target,
        })
      ).toThrow(/not empty/);
      fs.rmSync(target, { recursive: true, force: true });
    });

    it('accepts empty existing dir', () => {
      const target = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-linked-'));
      const r = pathResolver.allocateProjectRoot({
        company_id: 'c-test', mode: 'linked-new', target_path: target,
      });
      expect(r.mode).toBe('linked-new');
      expect(r.root_path).toBe(target);
      // 检查 linked-registry
      const reg = JSON.parse(fs.readFileSync(pathResolver.LINKED_REGISTRY, 'utf8'));
      expect(reg[r.pid]).toBeDefined();
      expect(reg[r.pid].mode).toBe('linked-new');

      fs.rmSync(target, { recursive: true, force: true });
      pathResolver.unregisterLinkedProject(r.pid);
    });
  });

  describe('allocateProjectRoot · linked-existing', () => {
    it('rejects nonexistent target_path', () => {
      // 固定路径 + 先删一遍:随机名只是「大概率不存在」,删一遍是「一定不存在」
      const fake = path.join(os.tmpdir(), 'devloop-le-nope');
      fs.rmSync(fake, { recursive: true, force: true });
      expect(() =>
        pathResolver.allocateProjectRoot({
          company_id: 'c-test', mode: 'linked-existing', target_path: fake,
        })
      ).toThrow(/does not exist/);
    });

    it('rejects target_path that already contains a DevLoop project.json', () => {
      const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-le-dup-'));
      fs.writeFileSync(path.join(dir, 'project.json'), '{}');
      expect(() =>
        pathResolver.allocateProjectRoot({
          company_id: 'c-test', mode: 'linked-existing', target_path: dir,
        })
      ).toThrow(/already contains/);
    });

    it('accepts existing non-empty plain dir (no .git)', () => {
      const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-le-plain-'));
      fs.writeFileSync(path.join(dir, 'README.md'), '# user');
      const r = pathResolver.allocateProjectRoot({
        company_id: 'c-test', mode: 'linked-existing', target_path: dir,
      });
      expect(r.pid).toMatch(/^p-/);
      expect(r.root_path).toBe(dir);
      expect(r.mode).toBe('linked-existing');
      pathResolver.unregisterLinkedProject(r.pid);
    });

    it('rejects existing git repo with dirty worktree', () => {
      const { execFileSync } = require('child_process');
      const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-le-dirty-'));
      execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: dir });
      execFileSync('git', ['config', 'user.email', 't@l'], { cwd: dir });
      execFileSync('git', ['config', 'user.name', 't'], { cwd: dir });
      fs.writeFileSync(path.join(dir, 'a.txt'), 'one');
      execFileSync('git', ['add', '-A'], { cwd: dir });
      execFileSync('git', ['commit', '-q', '-m', 'init'], { cwd: dir });
      // dirty: untracked
      fs.writeFileSync(path.join(dir, 'b.txt'), 'untracked');
      expect(() =>
        pathResolver.allocateProjectRoot({
          company_id: 'c-test', mode: 'linked-existing', target_path: dir,
        })
      ).toThrow(/uncommitted changes/);
    });
  });

  describe('resolveFixedTarget(39 号文档:公司级目标代码目录,不进 registry)', () => {
    it('linked-existing:校验通过时返回 root_path,不写 linked-projects registry', () => {
      const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-fixed-le-'));
      fs.writeFileSync(path.join(dir, 'README.md'), '# user');
      const before = fs.existsSync(pathResolver.LINKED_REGISTRY)
        ? fs.readFileSync(pathResolver.LINKED_REGISTRY, 'utf8') : null;
      const root = pathResolver.resolveFixedTarget({ target_path: dir, mode: 'linked-existing' });
      expect(root).toBe(dir);
      const after = fs.existsSync(pathResolver.LINKED_REGISTRY)
        ? fs.readFileSync(pathResolver.LINKED_REGISTRY, 'utf8') : null;
      expect(after).toBe(before); // registry 文件内容没变——没有 pid 被登记进去
      expect(pathResolver.findProjectByPath(dir)).toBe(null); // 也没有进内存 cache
    });

    it('linked-existing:git 树不干净照样拒绝(复用同一套校验,报同一个 error.code)', () => {
      const { execFileSync } = require('child_process');
      const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-fixed-dirty-'));
      execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: dir });
      execFileSync('git', ['config', 'user.email', 't@l'], { cwd: dir });
      execFileSync('git', ['config', 'user.name', 't'], { cwd: dir });
      fs.writeFileSync(path.join(dir, 'a.txt'), 'one');
      execFileSync('git', ['add', '-A'], { cwd: dir });
      execFileSync('git', ['commit', '-q', '-m', 'init'], { cwd: dir });
      fs.writeFileSync(path.join(dir, 'b.txt'), 'untracked');
      let caught = null;
      try {
        pathResolver.resolveFixedTarget({ target_path: dir, mode: 'linked-existing' });
      } catch (e) { caught = e; }
      expect(caught).not.toBe(null);
      expect(caught.message).toMatch(/uncommitted changes/);
      expect(caught.code).toBe('DIRTY_WORKTREE');
    });

    it('linked-existing:路径不存在时报错,不静默创建/降级', () => {
      const fake = path.join(os.tmpdir(), 'devloop-fixed-le-nope');
      fs.rmSync(fake, { recursive: true, force: true });
      expect(() =>
        pathResolver.resolveFixedTarget({ target_path: fake, mode: 'linked-existing' })
      ).toThrow(/does not exist/);
      expect(fs.existsSync(fake)).toBe(false); // 没有被顺手 mkdir 出来
    });

    it('linked-new:空目录直接接受,同样不进 registry', () => {
      const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-fixed-new-'));
      fs.rmSync(dir, { recursive: true, force: true }); // 留空壳测试「不存在则新建」
      const root = pathResolver.resolveFixedTarget({ target_path: dir, mode: 'linked-new' });
      expect(root).toBe(dir);
      expect(fs.existsSync(dir)).toBe(true);
      expect(pathResolver.findProjectByPath(dir)).toBe(null);
    });

    it('拒绝未知 mode', () => {
      expect(() =>
        pathResolver.resolveFixedTarget({ target_path: '/tmp/x', mode: 'sandbox' })
      ).toThrow(/unknown mode/);
    });

    it('敏感路径黑名单同样生效', () => {
      expect(() =>
        pathResolver.resolveFixedTarget({ target_path: '/etc/whatever', mode: 'linked-existing' })
      ).toThrow(/sensitive_path/);
    });
  });

  describe('resolveProjectRoot + 派生路径', () => {
    it('throws on unknown pid', () => {
      expect(() => pathResolver.resolveProjectRoot('p-nonexistent')).toThrow(/Unknown project/);
    });

    it('after allocate, all resolvers work', () => {
      const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' });
      expect(pathResolver.resolveProjectRoot(r.pid)).toBe(r.root_path);
      expect(pathResolver.resolveUnitDir(r.pid, 'auth-be'))
        .toBe(path.join(r.root_path, 'units', 'auth-be'));
      expect(pathResolver.resolvePrdSite(r.pid)).toBe(path.join(r.root_path, 'prd'));
      expect(pathResolver.resolveSddSite(r.pid)).toBe(path.join(r.root_path, 'sdd'));
      expect(pathResolver.resolveTimelineLog(r.pid)).toBe(path.join(r.root_path, 'timeline.ndjson'));
      expect(pathResolver.resolveBugsLog(r.pid)).toBe(path.join(r.root_path, 'bugs.ndjson'));
      expect(pathResolver.resolveAgentState(r.pid, 'pm'))
        .toBe(path.join(r.root_path, 'agent-state', 'pm.json'));
      fs.rmSync(r.root_path, { recursive: true, force: true });
    });
  });

  describe('memory dir 在 company 级', () => {
    it('does not depend on pid', () => {
      const m1 = pathResolver.resolveMemoryDir('c-test', 'pm');
      expect(m1).toContain('companies/c-test/memory/devloop/pm');
      expect(m1).not.toContain('projects/');
    });
  });
});