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/', () => { 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/ 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/.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/ (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/); }); });