323 lines
16 KiB
TypeScript
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');
|
|
});
|
|
});
|