// Space-scoped task operations exercised through the HTTP API. // // The personal-task auth matrix (owner/admin/non-member) is already covered in // local-tasks-api.test.ts. Here we prove the SAME routes behave correctly when // the task lives INSIDE a case space (space_id set): the operation succeeds for // owner/admin, is denied (404) for a non-member, and — critically — that the // HTTP /continue route propagates space_id onto the spawned child job (the // regression that would silently route a space job through the wrong // MCP/SSH/folder set). import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import express from 'express'; import request from 'supertest'; import { mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { Repository, localTaskRepoName } from '../db/repository.js'; import { mountLocalTasksApi } from './local-tasks-api.js'; function user(id: string, role: 'user' | 'admin' = 'user', orgIds: string[] = []): Express.User { return { id, email: `${id}@x.com`, name: id, avatarUrl: null, role, status: 'active', orgIds, defaultVisibility: 'private', defaultVisibilityOrgId: null, }; } describe('space-scoped task operations (HTTP)', () => { let dir = ''; let repo: Repository; let ownerId = ''; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'lt-space-')); repo = new Repository(join(dir, 'db.sqlite')); ownerId = repo.createUser({ email: 'o@x.com', name: 'o', role: 'user', status: 'active' }).id; }); afterEach(() => { repo.close(); rmSync(dir, { recursive: true, force: true }); }); function appAs(u: Express.User, extra: Record = {}): express.Application { const app = express(); app.use(express.json()); app.use((req, _res, next) => { (req as unknown as { user: Express.User }).user = u; next(); }); mountLocalTasksApi(app, { repo, worktreeDir: join(dir, 'workspaces'), ...extra }); return app; } async function spaceTaskWithTerminalJob(spaceId: string, status = 'succeeded') { const task = await repo.createLocalTask({ title: 't', body: 'b', pieceName: 'manual-writer', ownerId, spaceId, }); const prev = await repo.createJob({ repo: localTaskRepoName(task.id), issueNumber: task.id, instruction: 'go', pieceName: 'manual-writer', ownerId, spaceId, }); await repo.updateJob(prev.id, { status: status as never }); return { task, prev }; } // ── DELETE ────────────────────────────────────────────────────────────────── it('owner can DELETE a private space task; it disappears', async () => { const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId }); const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId, spaceId: space.id, visibility: 'private' }); const res = await request(appAs(user(ownerId))).delete(`/api/local/tasks/${task.id}`); expect(res.status).toBe(200); expect(await repo.getLocalTask(task.id)).toBeNull(); }); it('a non-member (non-owner, non-admin) gets 404 on DELETE of a private space task; task survives', async () => { const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId }); const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId, spaceId: space.id, visibility: 'private' }); const res = await request(appAs(user('intruder'))).delete(`/api/local/tasks/${task.id}`); expect(res.status).toBe(404); expect(await repo.getLocalTask(task.id)).not.toBeNull(); }); it('admin can DELETE any space task', async () => { const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId }); const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId, spaceId: space.id, visibility: 'private' }); const res = await request(appAs(user('root', 'admin'))).delete(`/api/local/tasks/${task.id}`); expect(res.status).toBe(200); expect(await repo.getLocalTask(task.id)).toBeNull(); }); // ── CANCEL ────────────────────────────────────────────────────────────────── it('owner can cancel a running space task; the job goes cancelled and keeps its space_id', async () => { const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId }); const { prev } = await spaceTaskWithTerminalJob(space.id, 'running'); const res = await request(appAs(user(ownerId))).post(`/api/local/tasks/${prev.issueNumber}/cancel`); expect(res.status).toBe(200); expect(res.body.jobId).toBe(prev.id); const after = await repo.getJob(prev.id); expect(after?.status).toBe('cancelled'); expect(after?.spaceId).toBe(space.id); }); it('non-member gets 404 on cancel of a private space task (job left running)', async () => { const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId }); const { task, prev } = await spaceTaskWithTerminalJob(space.id, 'running'); const res = await request(appAs(user('intruder'))).post(`/api/local/tasks/${task.id}/cancel`); expect(res.status).toBe(404); expect((await repo.getJob(prev.id))?.status).toBe('running'); }); // ── CONTINUE-WITH-PIECE: space_id propagation through the HTTP route ───────── it('continue: the HTTP route spawns a child job carrying the parent space_id', async () => { const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId }); const { task, prev } = await spaceTaskWithTerminalJob(space.id, 'succeeded'); const app = appAs(user(ownerId), { pieceExists: (n: string) => n === 'research' || n === 'manual-writer' }); const res = await request(app) .post(`/api/local/tasks/${task.id}/continue`) .send({ piece: 'research', instruction: 'keep going in this space' }); expect(res.status).toBe(201); const child = await repo.getJob(res.body.jobId); expect(child?.pieceName).toBe('research'); expect(child?.continuedFromJobId).toBe(prev.id); // The whole point: a space task's continuation must stay in the same space. expect(child?.spaceId).toBe(space.id); }); it('continue: a non-member gets 404 and no child job is created', async () => { const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId }); const { task } = await spaceTaskWithTerminalJob(space.id, 'succeeded'); const app = appAs(user('intruder'), { pieceExists: () => true }); const res = await request(app) .post(`/api/local/tasks/${task.id}/continue`) .send({ piece: 'research', instruction: 'sneak in' }); expect(res.status).toBe(404); // Only the original starter job exists (no continuation spawned). const jobCount = (repo.getDb() .prepare('SELECT COUNT(*) AS c FROM jobs WHERE repo = ?') .get(localTaskRepoName(task.id)) as { c: number }).c; expect(jobCount).toBe(1); }); // ── CREATE: space tasks are FORCED private (no org/public leak) ────────────── it('create: a task with spaceId set is stored private even when body asks for public', async () => { const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId }); const app = appAs(user(ownerId), { selectPiece: async () => 'chat' }); const res = await request(app) .post('/api/local/tasks') .send({ body: 'hello space', spaceId: space.id, visibility: 'public' }); expect(res.status).toBe(201); const created = await repo.getLocalTask(res.body.task.id); expect(created?.spaceId).toBe(space.id); // body said 'public' but a space task is always private — access is membership-only. expect(created?.visibility).toBe('private'); expect(created?.visibilityScopeOrgId).toBeNull(); }); it('create: spaceId set + body org → still private (org scope ignored)', async () => { const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId }); const app = appAs(user(ownerId, 'user', ['acme']), { selectPiece: async () => 'chat' }); const res = await request(app) .post('/api/local/tasks') .send({ body: 'hi', spaceId: space.id, visibility: 'org', visibilityScopeOrgId: 'acme' }); expect(res.status).toBe(201); const created = await repo.getLocalTask(res.body.task.id); expect(created?.visibility).toBe('private'); expect(created?.visibilityScopeOrgId).toBeNull(); }); it('create: a non-space (personal) task keeps the requested public visibility', async () => { const app = appAs(user(ownerId), { selectPiece: async () => 'chat' }); const res = await request(app) .post('/api/local/tasks') .send({ body: 'personal', visibility: 'public' }); expect(res.status).toBe(201); const created = await repo.getLocalTask(res.body.task.id); expect(created?.spaceId).toBeNull(); expect(created?.visibility).toBe('public'); }); it('end-to-end: a forced-private space chat is invisible to non-members, visible to members', async () => { const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId }); const memberId = repo.createUser({ email: 'm@x.com', name: 'm', role: 'user', status: 'active' }).id; const strangerId = repo.createUser({ email: 's@x.com', name: 's', role: 'user', status: 'active' }).id; await repo.addSpaceMember({ spaceId: space.id, userId: memberId, role: 'editor', invitedBy: ownerId }); // Create through the API as the owner, asking (futilely) for public. const app = appAs(user(ownerId), { selectPiece: async () => 'chat' }); const cres = await request(app) .post('/api/local/tasks') .send({ body: 'space chat', spaceId: space.id, visibility: 'public' }); expect(cres.status).toBe(201); const taskId = cres.body.task.id; expect((await repo.getLocalTask(taskId))?.visibility).toBe('private'); // Access control runs through buildVisibilityWhere (the membership OR-branch), // which is where space membership grants/denies read. Assert it directly at // the repository layer — the single source of truth for who can see a row. // Member (non-owner) of the space CAN see the private space chat. const seenByMember = await repo.getLocalTask(taskId, { viewer: user(memberId) }); expect(seenByMember).not.toBeNull(); expect(seenByMember?.spaceId).toBe(space.id); // Non-member CANNOT (private + not a member → denied → null). const seenByStranger = await repo.getLocalTask(taskId, { viewer: user(strangerId) }); expect(seenByStranger).toBeNull(); // And the same denial surfaces end-to-end through the HTTP GET for a stranger. const strangerRes = await request(appAs(user(strangerId))).get(`/api/local/tasks/${taskId}`); expect(strangerRes.status).toBe(404); // The task list (also membership-gated via buildVisibilityWhere) includes it // for the member and excludes it for the stranger. const memberList = await repo.listLocalTasks({ viewer: user(memberId) }); expect(memberList.some(t => t.id === taskId)).toBe(true); const strangerList = await repo.listLocalTasks({ viewer: user(strangerId) }); expect(strangerList.some(t => t.id === taskId)).toBe(false); }); // ── VISIBILITY ────────────────────────────────────────────────────────────── it('owner can PATCH visibility of a space task; persists and keeps the space tag', async () => { const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId }); const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId, spaceId: space.id, visibility: 'private' }); const res = await request(appAs(user(ownerId))) .patch(`/api/local/tasks/${task.id}`) .send({ visibility: 'public' }); expect(res.status).toBe(200); expect(res.body.task.visibility).toBe('public'); const after = await repo.getLocalTask(task.id); expect(after?.visibility).toBe('public'); expect(after?.spaceId).toBe(space.id); }); it('non-member gets 404 on PATCH visibility of a private space task', async () => { const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId }); const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId, spaceId: space.id, visibility: 'private' }); const res = await request(appAs(user('intruder'))) .patch(`/api/local/tasks/${task.id}`) .send({ visibility: 'public' }); expect(res.status).toBe(404); expect((await repo.getLocalTask(task.id))?.visibility).toBe('private'); }); // ── FEEDBACK ────────────────────────────────────────────────────────────── it('owner can submit feedback on a space task; it persists', async () => { const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId }); const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId, spaceId: space.id, visibility: 'private' }); const res = await request(appAs(user(ownerId))) .put(`/api/local/tasks/${task.id}/feedback`) .send({ rating: 'good', tags: ['useful'] }); expect(res.status).toBe(200); const after = await repo.getLocalTask(task.id); expect(after?.feedbackRating).toBe('good'); expect(after?.feedbackTags).toEqual(['useful']); expect(after?.spaceId).toBe(space.id); }); it('non-member gets 404 on feedback for a private space task', async () => { const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId }); const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId, spaceId: space.id, visibility: 'private' }); const res = await request(appAs(user('intruder'))) .put(`/api/local/tasks/${task.id}/feedback`) .send({ rating: 'bad', tags: [] }); expect(res.status).toBe(404); expect((await repo.getLocalTask(task.id))?.feedbackRating).toBeNull(); }); // ── TITLE REGENERATE ──────────────────────────────────────────────────────── it('owner can regenerate the title of a space task; updateLocalTask persists agent title', async () => { const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId }); const task = await repo.createLocalTask({ title: 'old', titleSource: 'auto', body: 'long body to summarize', ownerId, spaceId: space.id, }); const app = appAs(user(ownerId), { generateTitle: async () => 'A Crisp New Title' }); const res = await request(app).post(`/api/local/tasks/${task.id}/regenerate-title`); expect(res.status).toBe(200); expect(res.body.title).toBe('A Crisp New Title'); const after = await repo.getLocalTask(task.id); expect(after?.title).toBe('A Crisp New Title'); expect(after?.titleSource).toBe('agent'); expect(after?.spaceId).toBe(space.id); }); it('non-member gets 404 on title regeneration of a private space task', async () => { const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId }); const task = await repo.createLocalTask({ title: 'old', body: 'b', ownerId, spaceId: space.id, visibility: 'private' }); const app = appAs(user('intruder'), { generateTitle: async () => 'nope' }); const res = await request(app).post(`/api/local/tasks/${task.id}/regenerate-title`); expect(res.status).toBe(404); expect((await repo.getLocalTask(task.id))?.title).toBe('old'); }); });