maestro/src/bridge/local-tasks-api.spaces.test.ts
oss-sync 29ccaf1e92
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (dfadcd5f)
2026-06-23 06:38:48 +00:00

323 lines
16 KiB
TypeScript

// 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<string, unknown> = {}): 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');
});
});