695 lines
25 KiB
TypeScript
695 lines
25 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||
import express from 'express';
|
||
import request from 'supertest';
|
||
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs';
|
||
import { join } from 'path';
|
||
import { tmpdir } from 'os';
|
||
import { createDelegateRunsRouter } from './delegate-runs-api.js';
|
||
import type { Repository } from '../db/repository.js';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Subtask-event helper: write events.jsonl to {dir}/logs/events.jsonl
|
||
// ---------------------------------------------------------------------------
|
||
function writeSubEvents(dir: string, lines: object[]) {
|
||
mkdirSync(join(dir, 'logs'), { recursive: true });
|
||
writeFileSync(
|
||
join(dir, 'logs', 'events.jsonl'),
|
||
lines.map((l) => JSON.stringify(l)).join('\n') + '\n',
|
||
'utf-8',
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Minimal repo mock — mirrors subtask-activity-api.test.ts pattern
|
||
// ---------------------------------------------------------------------------
|
||
function makeRepo(overrides: Partial<Repository> = {}): Repository {
|
||
return {
|
||
getLocalTask: vi.fn(),
|
||
getLatestJobForIssue: vi.fn(),
|
||
getSubJobs: vi.fn(),
|
||
getJob: vi.fn(),
|
||
userCanViewSpace: vi.fn().mockReturnValue(false),
|
||
...overrides,
|
||
} as unknown as Repository;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Seed helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Write lines to {workspacePath}/logs/events.jsonl (logRoot fallback path). */
|
||
function writeEventsJsonl(workspacePath: string, lines: object[]) {
|
||
mkdirSync(join(workspacePath, 'logs'), { recursive: true });
|
||
writeFileSync(
|
||
join(workspacePath, 'logs', 'events.jsonl'),
|
||
lines.map((l) => JSON.stringify(l)).join('\n') + '\n',
|
||
'utf-8',
|
||
);
|
||
}
|
||
|
||
// Canonical minimal event factories
|
||
const START = (id: string) => ({
|
||
v: 1,
|
||
ts: '2026-06-25T00:00:01.000Z',
|
||
seq: 1,
|
||
eventId: 's',
|
||
runId: 'r',
|
||
kind: 'delegate_start',
|
||
payload: { delegateRunId: id, parentRunId: null, description: 'd', depth: 1 },
|
||
});
|
||
|
||
const TOOL = (id: string) => ({
|
||
v: 1,
|
||
ts: '2026-06-25T00:00:02.000Z',
|
||
seq: 2,
|
||
eventId: 't',
|
||
runId: 'r',
|
||
kind: 'tool_call',
|
||
correlationId: id,
|
||
payload: { toolName: 'Read', toolCallId: 'tc1' },
|
||
});
|
||
|
||
const DONE = (id: string) => ({
|
||
v: 1,
|
||
ts: '2026-06-25T00:00:03.000Z',
|
||
seq: 3,
|
||
eventId: 'c',
|
||
runId: 'r',
|
||
kind: 'delegate_complete',
|
||
payload: { delegateRunId: id, parentRunId: null, description: 'd', depth: 1, next: 'COMPLETE', status: 'success' },
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// seedTaskWithWorkspace: build a test Express app + mocked repo + a task that
|
||
// has a non-null workspacePath backed by a real tmp directory.
|
||
// Also provides otherUsersTaskId whose getLocalTask mock returns null (viewer
|
||
// cannot see it), to test the visibility gate.
|
||
// ---------------------------------------------------------------------------
|
||
async function seedTaskWithWorkspace() {
|
||
const workspacePath = mkdtempSync(join(tmpdir(), 'delegate-runs-test-'));
|
||
|
||
const TASK = {
|
||
id: 1,
|
||
title: 'test task',
|
||
workspacePath,
|
||
runtimeDir: null,
|
||
ownerId: 'alice-id',
|
||
visibility: 'private' as const,
|
||
visibilityScopeOrgId: null,
|
||
spaceId: null,
|
||
};
|
||
|
||
const repo = makeRepo();
|
||
// By default task 1 is visible (no user set on req, so canViewTask returns true)
|
||
vi.mocked(repo.getLocalTask).mockImplementation(async (id: number) => {
|
||
if (id === 1) return TASK as never;
|
||
return null as never;
|
||
});
|
||
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use('/api/local/tasks', createDelegateRunsRouter(repo));
|
||
|
||
return { app, repo, taskId: 1, workspacePath, otherUsersTaskId: 99 };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('GET /api/local/tasks/:id/delegate-runs', () => {
|
||
let tmpDirs: string[] = [];
|
||
|
||
afterEach(() => {
|
||
for (const dir of tmpDirs) {
|
||
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||
}
|
||
tmpDirs = [];
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
it('events.jsonl から run 一覧を返す', async () => {
|
||
const { app, taskId, workspacePath } = await seedTaskWithWorkspace();
|
||
tmpDirs.push(workspacePath);
|
||
writeEventsJsonl(workspacePath, [START('R1'), TOOL('R1'), DONE('R1')]);
|
||
|
||
const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs`);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.runs).toHaveLength(1);
|
||
expect(res.body.runs[0]).toMatchObject({ delegateRunId: 'R1', status: 'success', toolCalls: 1 });
|
||
});
|
||
|
||
it('events.jsonl 不在なら空配列(エラーにしない)', async () => {
|
||
const { app, taskId, workspacePath } = await seedTaskWithWorkspace();
|
||
tmpDirs.push(workspacePath);
|
||
// No events.jsonl written
|
||
|
||
const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs`);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.runs).toEqual([]);
|
||
});
|
||
|
||
it('複数 run を delegateRunId で区別して返す', async () => {
|
||
const { app, taskId, workspacePath } = await seedTaskWithWorkspace();
|
||
tmpDirs.push(workspacePath);
|
||
writeEventsJsonl(workspacePath, [
|
||
START('R1'), TOOL('R1'), DONE('R1'),
|
||
START('R2'), DONE('R2'),
|
||
]);
|
||
|
||
const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs`);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.runs).toHaveLength(2);
|
||
expect(res.body.runs.map((r: { delegateRunId: string }) => r.delegateRunId)).toEqual(['R1', 'R2']);
|
||
});
|
||
|
||
it('非可視タスクは 404 を返す', async () => {
|
||
const { app, otherUsersTaskId } = await seedTaskWithWorkspace();
|
||
|
||
const res = await request(app).get(`/api/local/tasks/${otherUsersTaskId}/delegate-runs`);
|
||
|
||
expect([403, 404]).toContain(res.status);
|
||
});
|
||
|
||
it('authenticated non-owner viewer blocked from private task (IDOR protection)', async () => {
|
||
const { repo, taskId, workspacePath } = await seedTaskWithWorkspace();
|
||
const tmpDirs = [workspacePath];
|
||
const mockGetLocalTask = vi.mocked(repo.getLocalTask);
|
||
|
||
// Configure mock: when viewer is passed (authenticated request),
|
||
// return null (simulating DB-level visibility filter)
|
||
mockGetLocalTask.mockImplementation(async (id: number, opts?: { viewer?: Express.User }) => {
|
||
if (id === taskId) {
|
||
if (opts?.viewer) {
|
||
// Non-owner viewer: visibility filter returns null
|
||
return null as never;
|
||
}
|
||
// No viewer (unauthenticated): allowed to view
|
||
return {
|
||
id: taskId,
|
||
title: 'test task',
|
||
workspacePath,
|
||
runtimeDir: null,
|
||
ownerId: 'alice-id',
|
||
visibility: 'private' as const,
|
||
visibilityScopeOrgId: null,
|
||
spaceId: null,
|
||
} as never;
|
||
}
|
||
return null as never;
|
||
});
|
||
|
||
// Create app with middleware that injects non-owner viewer
|
||
const nonOwnerUser: Express.User = { id: 'bob-id', name: 'Bob' } as Express.User;
|
||
const app = express();
|
||
app.use((req: any, _res: any, next: any) => {
|
||
req.user = nonOwnerUser;
|
||
next();
|
||
});
|
||
app.use(express.json());
|
||
app.use('/api/local/tasks', createDelegateRunsRouter(repo));
|
||
|
||
const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs`);
|
||
|
||
// Verify: request is blocked (404 or 403) and no data is leaked
|
||
expect([403, 404]).toContain(res.status);
|
||
expect(res.body.runs).toBeUndefined();
|
||
expect(res.body.error).toBeDefined();
|
||
|
||
// Verify: getLocalTask was called WITH the viewer object
|
||
expect(mockGetLocalTask).toHaveBeenCalledWith(
|
||
taskId,
|
||
expect.objectContaining({ viewer: nonOwnerUser })
|
||
);
|
||
|
||
for (const dir of tmpDirs) {
|
||
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('GET /api/local/tasks/:id/delegate-runs/:delegateRunId/timeline', () => {
|
||
let tmpDirs: string[] = [];
|
||
|
||
afterEach(() => {
|
||
for (const dir of tmpDirs) {
|
||
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||
}
|
||
tmpDirs = [];
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
it('drill-down: その run の内部イベントのみ返す', async () => {
|
||
const { app, taskId, workspacePath } = await seedTaskWithWorkspace();
|
||
tmpDirs.push(workspacePath);
|
||
writeEventsJsonl(workspacePath, [START('R1'), TOOL('R1'), DONE('R1')]);
|
||
|
||
const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs/R1/timeline`);
|
||
|
||
expect(res.status).toBe(200);
|
||
// Events that have correlationId === 'R1' — TOOL has it; START/DONE do not since they
|
||
// are keyed by kind not correlationId in the filter
|
||
expect(Array.isArray(res.body.events)).toBe(true);
|
||
// The TOOL event has correlationId === 'R1', others do not
|
||
expect(res.body.events.some((e: { correlationId?: string }) => e.correlationId === 'R1')).toBe(true);
|
||
});
|
||
|
||
it('回帰: tool_call が独自 correlationId でも delegateRunId でタイムラインに含まれる', async () => {
|
||
// 本番形状: tool_call は per-tool UUID で correlationId を上書きし、実行帰属は
|
||
// delegateRunId タグで持つ。旧実装は correlationId フィルタだったため、展開時の
|
||
// タイムラインからツールイベントが丸ごと落ちていた(サマリの「ツール N 回」と矛盾)。
|
||
const { app, taskId, workspacePath } = await seedTaskWithWorkspace();
|
||
tmpDirs.push(workspacePath);
|
||
const REAL_TOOL = {
|
||
v: 1, ts: '2026-06-25T00:00:02.000Z', seq: 2, eventId: 't-real', runId: 'r',
|
||
kind: 'tool_call', correlationId: 'per-tool-uuid-xyz', delegateRunId: 'R1',
|
||
movement: 'delegate:d', payload: { tool: 'Read' },
|
||
};
|
||
writeEventsJsonl(workspacePath, [START('R1'), REAL_TOOL, DONE('R1')]);
|
||
|
||
const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs/R1/timeline`);
|
||
|
||
expect(res.status).toBe(200);
|
||
// ツールイベントが(correlation 上書きされていても)返る
|
||
expect(res.body.events.some((e: { kind: string }) => e.kind === 'tool_call')).toBe(true);
|
||
});
|
||
|
||
it('timeline: events.jsonl 不在なら空配列', async () => {
|
||
const { app, taskId, workspacePath } = await seedTaskWithWorkspace();
|
||
tmpDirs.push(workspacePath);
|
||
|
||
const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs/R1/timeline`);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.events).toEqual([]);
|
||
});
|
||
|
||
it('timeline: 非可視タスクは 404', async () => {
|
||
const { app, otherUsersTaskId } = await seedTaskWithWorkspace();
|
||
|
||
const res = await request(app).get(`/api/local/tasks/${otherUsersTaskId}/delegate-runs/R1/timeline`);
|
||
|
||
expect([403, 404]).toContain(res.status);
|
||
});
|
||
|
||
it('timeline: authenticated non-owner viewer blocked from private task (IDOR protection)', async () => {
|
||
const { repo, taskId, workspacePath } = await seedTaskWithWorkspace();
|
||
const tmpDirs = [workspacePath];
|
||
const mockGetLocalTask = vi.mocked(repo.getLocalTask);
|
||
|
||
// Configure mock: when viewer is passed (authenticated request),
|
||
// return null (simulating DB-level visibility filter)
|
||
mockGetLocalTask.mockImplementation(async (id: number, opts?: { viewer?: Express.User }) => {
|
||
if (id === taskId) {
|
||
if (opts?.viewer) {
|
||
// Non-owner viewer: visibility filter returns null
|
||
return null as never;
|
||
}
|
||
// No viewer (unauthenticated): allowed to view
|
||
return {
|
||
id: taskId,
|
||
title: 'test task',
|
||
workspacePath,
|
||
runtimeDir: null,
|
||
ownerId: 'alice-id',
|
||
visibility: 'private' as const,
|
||
visibilityScopeOrgId: null,
|
||
spaceId: null,
|
||
} as never;
|
||
}
|
||
return null as never;
|
||
});
|
||
|
||
// Create app with middleware that injects non-owner viewer
|
||
const nonOwnerUser: Express.User = { id: 'bob-id', name: 'Bob' } as Express.User;
|
||
const app = express();
|
||
app.use((req: any, _res: any, next: any) => {
|
||
req.user = nonOwnerUser;
|
||
next();
|
||
});
|
||
app.use(express.json());
|
||
app.use('/api/local/tasks', createDelegateRunsRouter(repo));
|
||
|
||
const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs/R1/timeline`);
|
||
|
||
// Verify: request is blocked (404 or 403) and no data is leaked
|
||
expect([403, 404]).toContain(res.status);
|
||
expect(res.body.events).toBeUndefined();
|
||
expect(res.body.error).toBeDefined();
|
||
|
||
// Verify: getLocalTask was called WITH the viewer object
|
||
expect(mockGetLocalTask).toHaveBeenCalledWith(
|
||
taskId,
|
||
expect.objectContaining({ viewer: nonOwnerUser })
|
||
);
|
||
|
||
for (const dir of tmpDirs) {
|
||
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||
}
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Timeline jobId scope tests (Task 3)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('GET /api/local/tasks/:id/delegate-runs/:delegateRunId/timeline?jobId=', () => {
|
||
let tmpDirs: string[] = [];
|
||
|
||
afterEach(() => {
|
||
for (const dir of tmpDirs) {
|
||
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||
}
|
||
tmpDirs = [];
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
it('timeline は jobId 指定でサブジョブの events を読む', async () => {
|
||
const parentDir = mkdtempSync(join(tmpdir(), 'parent-'));
|
||
const subDir = join(parentDir, 'subtasks', 'sub1');
|
||
tmpDirs.push(parentDir);
|
||
|
||
// Only write events in the sub-job dir (not the parent)
|
||
writeSubEvents(subDir, [
|
||
{ v: 1, ts: '2026-06-29T00:00:00.000Z', seq: 1, eventId: 's1', runId: 'r', kind: 'tool_call', correlationId: 'subRun', payload: { toolName: 'Read', toolCallId: 'tc1' } },
|
||
]);
|
||
|
||
const repo = makeRepo({
|
||
getLocalTask: vi.fn().mockResolvedValue({
|
||
id: 1,
|
||
workspacePath: parentDir,
|
||
runtimeDir: null,
|
||
ownerId: 'alice-id',
|
||
visibility: 'private' as const,
|
||
visibilityScopeOrgId: null,
|
||
spaceId: null,
|
||
}),
|
||
getJob: vi.fn().mockImplementation(async (id: string) => {
|
||
if (id === 'sub1') {
|
||
return { id: 'sub1', issueNumber: 1, status: 'succeeded', worktreePath: subDir, parentJobId: 'root' };
|
||
}
|
||
return null;
|
||
}),
|
||
});
|
||
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use('/api/local/tasks', createDelegateRunsRouter(repo));
|
||
|
||
const res = await request(app).get('/api/local/tasks/1/delegate-runs/subRun/timeline?jobId=sub1');
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(Array.isArray(res.body.events)).toBe(true);
|
||
expect(res.body.events.length).toBeGreaterThan(0);
|
||
expect(res.body.events.every((e: { correlationId?: string }) => e.correlationId === 'subRun')).toBe(true);
|
||
});
|
||
|
||
it('timeline jobId: ジョブが見つからない場合は 404', async () => {
|
||
const parentDir = mkdtempSync(join(tmpdir(), 'parent-'));
|
||
tmpDirs.push(parentDir);
|
||
|
||
const repo = makeRepo({
|
||
getLocalTask: vi.fn().mockResolvedValue({
|
||
id: 1,
|
||
workspacePath: parentDir,
|
||
runtimeDir: null,
|
||
ownerId: 'alice-id',
|
||
visibility: 'private' as const,
|
||
visibilityScopeOrgId: null,
|
||
spaceId: null,
|
||
}),
|
||
getJob: vi.fn().mockResolvedValue(null),
|
||
});
|
||
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use('/api/local/tasks', createDelegateRunsRouter(repo));
|
||
|
||
const res = await request(app).get('/api/local/tasks/1/delegate-runs/subRun/timeline?jobId=nonexistent');
|
||
|
||
expect(res.status).toBe(404);
|
||
});
|
||
|
||
it('timeline jobId: workspacePath 外の worktree は fail-closed で 404', async () => {
|
||
const parentDir = mkdtempSync(join(tmpdir(), 'parent-'));
|
||
const outsideDir = mkdtempSync(join(tmpdir(), 'evil-'));
|
||
tmpDirs.push(parentDir, outsideDir);
|
||
|
||
writeSubEvents(outsideDir, [
|
||
{ v: 1, ts: '2026-06-29T00:00:00.000Z', seq: 1, eventId: 's1', runId: 'r', kind: 'tool_call', correlationId: 'evilRun', payload: { toolName: 'Read', toolCallId: 'tc1' } },
|
||
]);
|
||
|
||
const repo = makeRepo({
|
||
getLocalTask: vi.fn().mockResolvedValue({
|
||
id: 1,
|
||
workspacePath: parentDir,
|
||
runtimeDir: null,
|
||
ownerId: 'alice-id',
|
||
visibility: 'private' as const,
|
||
visibilityScopeOrgId: null,
|
||
spaceId: null,
|
||
}),
|
||
getJob: vi.fn().mockResolvedValue({
|
||
id: 'evil', issueNumber: 1, status: 'succeeded', worktreePath: outsideDir, parentJobId: 'root',
|
||
}),
|
||
});
|
||
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use('/api/local/tasks', createDelegateRunsRouter(repo));
|
||
|
||
const res = await request(app).get('/api/local/tasks/1/delegate-runs/evilRun/timeline?jobId=evil');
|
||
|
||
expect(res.status).toBe(404);
|
||
});
|
||
|
||
it('timeline jobId 未指定: 親 events に correlationId 一致なければサブジョブを線形探索', async () => {
|
||
const parentDir = mkdtempSync(join(tmpdir(), 'parent-'));
|
||
const subDir = join(parentDir, 'subtasks', 'sub1');
|
||
tmpDirs.push(parentDir);
|
||
|
||
// Parent has no matching correlationId events; sub-job does
|
||
writeSubEvents(subDir, [
|
||
{ v: 1, ts: '2026-06-29T00:00:00.000Z', seq: 1, eventId: 's1', runId: 'r', kind: 'tool_call', correlationId: 'subRun', payload: { toolName: 'Read', toolCallId: 'tc1' } },
|
||
]);
|
||
|
||
const repo = makeRepo({
|
||
getLocalTask: vi.fn().mockResolvedValue({
|
||
id: 1,
|
||
workspacePath: parentDir,
|
||
runtimeDir: null,
|
||
ownerId: 'alice-id',
|
||
visibility: 'private' as const,
|
||
visibilityScopeOrgId: null,
|
||
spaceId: null,
|
||
}),
|
||
getLatestJobForIssue: vi.fn().mockResolvedValue({ id: 'root' }),
|
||
getSubJobs: vi.fn().mockImplementation(async (pid: string) => {
|
||
if (pid === 'root') {
|
||
return [{ id: 'sub1', issueNumber: 1, status: 'succeeded', worktreePath: subDir, parentJobId: 'root' }];
|
||
}
|
||
return [];
|
||
}),
|
||
});
|
||
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use('/api/local/tasks', createDelegateRunsRouter(repo));
|
||
|
||
const res = await request(app).get('/api/local/tasks/1/delegate-runs/subRun/timeline');
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.events.length).toBeGreaterThan(0);
|
||
expect(res.body.events.every((e: { correlationId?: string }) => e.correlationId === 'subRun')).toBe(true);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Subtask aggregation tests (Task 2)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('GET /api/local/tasks/:id/delegate-runs — subtask aggregation', () => {
|
||
let tmpDirs: string[] = [];
|
||
|
||
afterEach(() => {
|
||
for (const dir of tmpDirs) {
|
||
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||
}
|
||
tmpDirs = [];
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
// Event factories reusing the same shape as START/DONE above
|
||
const mkStart = (id: string) => ({
|
||
v: 1, ts: '2026-06-29T00:00:00.000Z', seq: 1, eventId: `s-${id}`, runId: 'r',
|
||
kind: 'delegate_start',
|
||
payload: { delegateRunId: id, parentRunId: null, description: 'x', depth: 1 },
|
||
});
|
||
const mkDone = (id: string) => ({
|
||
v: 1, ts: '2026-06-29T00:00:01.000Z', seq: 2, eventId: `c-${id}`, runId: 'r',
|
||
kind: 'delegate_complete',
|
||
payload: { delegateRunId: id, parentRunId: null, description: 'x', depth: 1, next: 'COMPLETE', status: 'success' },
|
||
});
|
||
|
||
it('親 run とサブタスク run を分けて返す', async () => {
|
||
const parentDir = mkdtempSync(join(tmpdir(), 'parent-'));
|
||
const subDir = join(parentDir, 'subtasks', 'sub1');
|
||
tmpDirs.push(parentDir);
|
||
|
||
// Write parent events
|
||
writeSubEvents(parentDir, [mkStart('parentRun'), mkDone('parentRun')]);
|
||
// Write sub-job events
|
||
writeSubEvents(subDir, [mkStart('subRun'), mkDone('subRun')]);
|
||
|
||
const repo = makeRepo({
|
||
getLocalTask: vi.fn().mockResolvedValue({
|
||
id: 1,
|
||
workspacePath: parentDir,
|
||
runtimeDir: null,
|
||
ownerId: 'alice-id',
|
||
visibility: 'private' as const,
|
||
visibilityScopeOrgId: null,
|
||
spaceId: null,
|
||
}),
|
||
getLatestJobForIssue: vi.fn().mockResolvedValue({ id: 'root' }),
|
||
getSubJobs: vi.fn().mockImplementation(async (pid: string) => {
|
||
if (pid === 'root') {
|
||
return [{
|
||
id: 'sub1',
|
||
issueNumber: 1,
|
||
status: 'succeeded',
|
||
worktreePath: subDir,
|
||
parentJobId: 'root',
|
||
}];
|
||
}
|
||
return [];
|
||
}),
|
||
});
|
||
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use('/api/local/tasks', createDelegateRunsRouter(repo));
|
||
|
||
const res = await request(app).get('/api/local/tasks/1/delegate-runs');
|
||
|
||
expect(res.status).toBe(200);
|
||
// Parent runs
|
||
expect(res.body.runs).toHaveLength(1);
|
||
expect(res.body.runs[0].delegateRunId).toBe('parentRun');
|
||
// Subtask runs
|
||
expect(res.body.subtasks).toHaveLength(1);
|
||
expect(res.body.subtasks[0].jobId).toBe('sub1');
|
||
expect(res.body.subtasks[0].depth).toBe(1);
|
||
expect(res.body.subtasks[0].status).toBe('succeeded');
|
||
expect(res.body.subtasks[0].runs).toHaveLength(1);
|
||
expect(res.body.subtasks[0].runs[0].delegateRunId).toBe('subRun');
|
||
});
|
||
|
||
it('親ワークスペース外の worktree を持つサブジョブは除外 (auth containment)', async () => {
|
||
const parentDir = mkdtempSync(join(tmpdir(), 'parent-'));
|
||
const outsideDir = mkdtempSync(join(tmpdir(), 'evil-'));
|
||
tmpDirs.push(parentDir, outsideDir);
|
||
|
||
// Write parent events (no delegate runs → empty)
|
||
// Write events to the outside dir to prove it's excluded, not missing
|
||
writeSubEvents(outsideDir, [mkStart('evilRun'), mkDone('evilRun')]);
|
||
|
||
const repo = makeRepo({
|
||
getLocalTask: vi.fn().mockResolvedValue({
|
||
id: 1,
|
||
workspacePath: parentDir,
|
||
runtimeDir: null,
|
||
ownerId: 'alice-id',
|
||
visibility: 'private' as const,
|
||
visibilityScopeOrgId: null,
|
||
spaceId: null,
|
||
}),
|
||
getLatestJobForIssue: vi.fn().mockResolvedValue({ id: 'root' }),
|
||
getSubJobs: vi.fn().mockImplementation(async (pid: string) => {
|
||
if (pid === 'root') {
|
||
return [{
|
||
id: 'sub1',
|
||
issueNumber: 1,
|
||
status: 'succeeded',
|
||
worktreePath: outsideDir,
|
||
parentJobId: 'root',
|
||
}];
|
||
}
|
||
return [];
|
||
}),
|
||
});
|
||
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use('/api/local/tasks', createDelegateRunsRouter(repo));
|
||
|
||
const res = await request(app).get('/api/local/tasks/1/delegate-runs');
|
||
|
||
expect(res.status).toBe(200);
|
||
// Outside sub-job must be excluded
|
||
expect(res.body.subtasks).toHaveLength(0);
|
||
});
|
||
|
||
it('workspacePath が null のタスクはサブジョブ containment を評価できないため subtasks が空 (fail-closed regression)', async () => {
|
||
// Regression: old code used `task.workspacePath && !isJobWithinWorkspace(...)` — when
|
||
// workspacePath is null the && short-circuits and the check is BYPASSED, leaking events.
|
||
// Fixed to: `!isJobWithinWorkspace(null, path)` which returns false → skips the sub-job.
|
||
const subDir = mkdtempSync(join(tmpdir(), 'sub-'));
|
||
tmpDirs.push(subDir);
|
||
|
||
// Write delegate events into the sub-job dir so it would appear if included
|
||
writeSubEvents(subDir, [mkStart('leakedRun'), mkDone('leakedRun')]);
|
||
|
||
const repo = makeRepo({
|
||
getLocalTask: vi.fn().mockResolvedValue({
|
||
id: 1,
|
||
workspacePath: null, // ← null triggers the bypass in old code
|
||
runtimeDir: null,
|
||
ownerId: 'alice-id',
|
||
visibility: 'private' as const,
|
||
visibilityScopeOrgId: null,
|
||
spaceId: null,
|
||
}),
|
||
getLatestJobForIssue: vi.fn().mockResolvedValue({ id: 'root' }),
|
||
getSubJobs: vi.fn().mockImplementation(async (pid: string) => {
|
||
if (pid === 'root') {
|
||
return [{
|
||
id: 'sub1',
|
||
issueNumber: 1,
|
||
status: 'succeeded',
|
||
worktreePath: subDir,
|
||
parentJobId: 'root',
|
||
}];
|
||
}
|
||
return [];
|
||
}),
|
||
});
|
||
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use('/api/local/tasks', createDelegateRunsRouter(repo));
|
||
|
||
const res = await request(app).get('/api/local/tasks/1/delegate-runs');
|
||
|
||
expect(res.status).toBe(200);
|
||
// Sub-job must be skipped because workspacePath is null (containment cannot be evaluated)
|
||
expect(res.body.subtasks).toHaveLength(0);
|
||
});
|
||
|
||
it('既存テスト互換: サブジョブ無し時は subtasks が空配列', async () => {
|
||
const { app, taskId, workspacePath } = await seedTaskWithWorkspace();
|
||
tmpDirs.push(workspacePath);
|
||
writeSubEvents(workspacePath, [mkStart('R1'), mkDone('R1')]);
|
||
|
||
const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs`);
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.runs).toHaveLength(1);
|
||
// getLatestJobForIssue is vi.fn() returning undefined → latestJob falsy → empty subtasks
|
||
expect(res.body.subtasks).toEqual([]);
|
||
});
|
||
});
|