302 lines
11 KiB
TypeScript
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/);
|
|
});
|
|
});
|