maestro/src/engine/tools/app-docs.test.ts
2026-06-03 05:08:00 +00:00

302 lines
11 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import Database from 'better-sqlite3';
import { TOOL_DEFS, executeTool, resolveAppDocPath, setAppDocsDeps } from './app-docs.js';
import type { ToolContext } from './core.js';
const baseCtx: ToolContext = {
workspacePath: '/tmp/app-docs-test',
editAllowed: false,
};
describe('app-docs: TOOL_DEFS', () => {
it('exposes ReadAppDoc, ListAppDocs, GetMyOrchestratorState', () => {
expect(TOOL_DEFS).toHaveProperty('ReadAppDoc');
expect(TOOL_DEFS).toHaveProperty('ListAppDocs');
expect(TOOL_DEFS).toHaveProperty('GetMyOrchestratorState');
});
});
describe('app-docs: resolveAppDocPath (path safety)', () => {
it('rejects claude-md (internal doc)', () => {
expect(resolveAppDocPath('claude-md')).toBeNull();
});
it('rejects CLAUDE.md (internal doc)', () => {
expect(resolveAppDocPath('CLAUDE.md')).toBeNull();
});
it('rejects agents-md (internal doc)', () => {
expect(resolveAppDocPath('agents-md')).toBeNull();
});
it('rejects AGENTS.md (internal doc)', () => {
expect(resolveAppDocPath('AGENTS.md')).toBeNull();
});
it('rejects readme / README.md (internal doc)', () => {
expect(resolveAppDocPath('readme')).toBeNull();
expect(resolveAppDocPath('README.md')).toBeNull();
});
it('rejects docs/superpowers/* (internal implementation plans)', () => {
expect(resolveAppDocPath('docs/superpowers/plans/foo')).toBeNull();
expect(resolveAppDocPath('docs/superpowers/anything')).toBeNull();
});
it('rejects docs/maintenance-checklist (internal ops reference)', () => {
expect(resolveAppDocPath('docs/maintenance-checklist')).toBeNull();
expect(resolveAppDocPath('docs/maintenance-checklist.md')).toBeNull();
});
it('still resolves docs/mcp (allowed user-facing doc)', () => {
const r = resolveAppDocPath('docs/mcp');
expect(r).not.toBeNull();
expect(r!.label).toBe('docs/mcp');
expect(r!.path).toMatch(/docs\/mcp\.md$/);
});
it('resolves piece/<name>', () => {
const r = resolveAppDocPath('piece/chat');
expect(r).not.toBeNull();
expect(r!.label).toBe('pieces/chat.yaml');
expect(r!.path).toMatch(/pieces\/chat\.yaml$/);
});
it('resolves docs/<path> with auto .md suffix', () => {
const r = resolveAppDocPath('docs/architecture');
expect(r).not.toBeNull();
expect(r!.label).toBe('docs/architecture');
expect(r!.path).toMatch(/docs\/architecture\.md$/);
});
it('resolves docs/<path>.md without doubling extension', () => {
const r = resolveAppDocPath('docs/architecture.md');
expect(r).not.toBeNull();
expect(r!.path).toMatch(/docs\/architecture\.md$/);
expect(r!.path).not.toMatch(/\.md\.md$/);
});
it('resolves tool/<name> (lowercased)', () => {
const r = resolveAppDocPath('tool/BrowseWeb');
expect(r).not.toBeNull();
expect(r!.label).toBe('docs/tools/browseweb.md');
});
it('rejects path traversal via ..', () => {
expect(resolveAppDocPath('../etc/passwd')).toBeNull();
expect(resolveAppDocPath('docs/../../etc/passwd')).toBeNull();
expect(resolveAppDocPath('piece/../secret')).toBeNull();
});
it('rejects names with invalid characters', () => {
expect(resolveAppDocPath('piece/with space')).toBeNull();
expect(resolveAppDocPath('piece/$shell')).toBeNull();
expect(resolveAppDocPath('docs/with;semicolon')).toBeNull();
});
it('rejects unknown top-level names', () => {
expect(resolveAppDocPath('random-name')).toBeNull();
expect(resolveAppDocPath('config-yaml')).toBeNull();
});
it('rejects empty / non-string', () => {
expect(resolveAppDocPath('')).toBeNull();
expect(resolveAppDocPath(null as unknown as string)).toBeNull();
expect(resolveAppDocPath(undefined as unknown as string)).toBeNull();
});
});
describe('app-docs: ReadAppDoc execution', () => {
it('rejects claude-md with an error (internal doc blocked)', async () => {
const res = await executeTool('ReadAppDoc', { name: 'claude-md' }, baseCtx);
expect(res).not.toBeNull();
expect(res!.isError).toBe(true);
expect(res!.output).toContain('不正な name');
});
it('returns error with hint when name is missing', async () => {
const res = await executeTool('ReadAppDoc', {}, baseCtx);
expect(res!.isError).toBe(true);
expect(res!.output).toContain('name パラメータ');
});
it('returns error with hint when name is invalid', async () => {
const res = await executeTool('ReadAppDoc', { name: 'bogus name with space' }, baseCtx);
expect(res!.isError).toBe(true);
expect(res!.output).toContain('不正な name');
expect(res!.output).toContain('ListAppDocs');
});
it('returns error when name resolves but file does not exist', async () => {
const res = await executeTool('ReadAppDoc', { name: 'docs/no-such-doc' }, baseCtx);
expect(res!.isError).toBe(true);
expect(res!.output).toContain('存在しません');
});
it('reads an existing piece YAML', async () => {
const res = await executeTool('ReadAppDoc', { name: 'piece/chat' }, baseCtx);
expect(res!.isError).toBe(false);
expect(res!.output).toContain('pieces/chat.yaml');
expect(res!.output).toContain('name: chat');
});
it('returns null for unrelated tool name', async () => {
const res = await executeTool('SomeOtherTool', {}, baseCtx);
expect(res).toBeNull();
});
});
describe('app-docs: ListAppDocs', () => {
it('groups output into piece / docs / tools sections (no project overview)', async () => {
const res = await executeTool('ListAppDocs', {}, baseCtx);
expect(res!.isError).toBe(false);
const out = res!.output;
// Removed section
expect(out).not.toContain('# プロジェクト概要');
// Remaining sections
expect(out).toContain('# Piece 一覧');
expect(out).toContain('# ドキュメント');
expect(out).toContain('# ツール参照');
// Should mention at least one known piece
expect(out).toContain('piece/chat');
});
it('does not list CLAUDE.md / AGENTS.md / README.md', async () => {
const res = await executeTool('ListAppDocs', {}, baseCtx);
const out = res!.output;
expect(out).not.toContain('claude-md');
expect(out).not.toContain('agents-md');
expect(out).not.toContain('readme');
expect(out).not.toContain('CLAUDE.md');
expect(out).not.toContain('AGENTS.md');
});
it('does not list docs/superpowers or docs/maintenance-checklist', async () => {
const res = await executeTool('ListAppDocs', {}, baseCtx);
const out = res!.output;
expect(out).not.toContain('superpowers');
expect(out).not.toContain('maintenance-checklist');
});
});
describe('app-docs: GetMyOrchestratorState', () => {
let tmpDir: string;
let db: Database.Database;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'app-docs-state-'));
db = new Database(':memory:');
// Minimal schema: just the columns we read
db.exec(`
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT,
name TEXT,
role TEXT NOT NULL DEFAULT 'user',
status TEXT NOT NULL DEFAULT 'active'
);
CREATE TABLE local_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT,
piece_name TEXT,
owner_id TEXT,
state TEXT,
created_at TEXT
);
CREATE TABLE jobs (
id TEXT PRIMARY KEY,
repo TEXT,
issue_number INTEGER,
status TEXT,
created_at TEXT
);
CREATE TABLE mcp_servers (
id TEXT PRIMARY KEY,
name TEXT,
auth_kind TEXT,
owner_id TEXT,
enabled INTEGER
);
CREATE TABLE user_mcp_tokens (
user_id TEXT,
server_id TEXT,
expires_at TEXT
);
`);
db.prepare('INSERT INTO users (id, email, name, role) VALUES (?, ?, ?, ?)').run('alice', 'a@example.com', 'Alice', 'admin');
db.prepare('INSERT INTO local_tasks (title, piece_name, owner_id, state, created_at) VALUES (?, ?, ?, ?, ?)')
.run('Hello task', 'chat', 'alice', 'open', '2026-05-10 09:00:00');
db.prepare('INSERT INTO local_tasks (title, piece_name, owner_id, state, created_at) VALUES (?, ?, ?, ?, ?)')
.run('Other task', 'research', 'alice', 'open', '2026-05-09 09:00:00');
db.prepare('INSERT INTO mcp_servers (id, name, auth_kind, owner_id, enabled) VALUES (?, ?, ?, ?, ?)')
.run('canva', 'Canva', 'oauth', null, 1);
db.prepare('INSERT INTO mcp_servers (id, name, auth_kind, owner_id, enabled) VALUES (?, ?, ?, ?, ?)')
.run('tundoc', 'Tundoc', 'api_key', 'alice', 1);
db.prepare('INSERT INTO user_mcp_tokens (user_id, server_id, expires_at) VALUES (?, ?, ?)')
.run('alice', 'canva', '2027-01-01 00:00:00');
setAppDocsDeps({ db, userFolderRoot: tmpDir });
});
afterEach(() => {
db.close();
rmSync(tmpDir, { recursive: true, force: true });
setAppDocsDeps(null);
});
it('requires userId in ctx', async () => {
const res = await executeTool('GetMyOrchestratorState', {}, baseCtx);
expect(res!.isError).toBe(true);
expect(res!.output).toContain('authenticated user');
});
it('returns sections covering user / tasks / MCP / user folder', async () => {
const ctx: ToolContext = { ...baseCtx, userId: 'alice' };
const res = await executeTool('GetMyOrchestratorState', {}, ctx);
expect(res!.isError).toBe(false);
const out = res!.output;
expect(out).toContain('## ユーザー');
expect(out).toContain('Alice');
expect(out).toContain('## 最近のタスク');
expect(out).toContain('Hello task');
expect(out).toContain('## MCP サーバー');
expect(out).toContain('canva');
expect(out).toContain('連携済み');
expect(out).toContain('tundoc');
expect(out).toContain('api_key');
expect(out).toContain('## ユーザーフォルダ');
});
it('handles empty memory/scripts dirs cleanly', async () => {
// Build empty user folder
mkdirSync(join(tmpDir, 'alice'));
mkdirSync(join(tmpDir, 'alice', 'memory'));
mkdirSync(join(tmpDir, 'alice', 'scripts'));
writeFileSync(join(tmpDir, 'alice', 'AGENTS.md'), 'hello');
const ctx: ToolContext = { ...baseCtx, userId: 'alice' };
const res = await executeTool('GetMyOrchestratorState', {}, ctx);
expect(res!.isError).toBe(false);
expect(res!.output).toContain('AGENTS.md: 5 bytes');
expect(res!.output).toContain('memory/: 0 件');
expect(res!.output).toContain('scripts/: 0 件');
});
it('does not leak OAuth secrets or tokens in output', async () => {
const ctx: ToolContext = { ...baseCtx, userId: 'alice' };
const res = await executeTool('GetMyOrchestratorState', {}, ctx);
const out = res!.output;
// Should NOT contain any of the encrypted blob / token columns
expect(out).not.toMatch(/oauth_client_secret/);
expect(out).not.toMatch(/static_token/);
expect(out).not.toMatch(/access_token/);
});
});