/** * Authz + gating tests for /api/local/tasks/:taskId/package-requests(/:id/decide). * * These cover the paths that DO NOT trigger a real pip install (view gate, * write gate, feature gate, deny, already-decided) so the suite stays hermetic * — no network / no bwrap / no python3. The install/overlay mechanics are * unit-tested in engine/python-packages.test.ts, and the shared add path in * space-api.python-packages.test.ts. */ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { randomUUID } from 'node:crypto'; import express from 'express'; import request from 'supertest'; import { Repository, localTaskRepoName } from '../db/repository.js'; import { registerLocalTaskPackageRequestRoutes } from './local-tasks-package-requests-api.js'; import type { LocalTasksDeps } from './local-tasks-shared.js'; import type { PythonPackagesConfigShape } from '../config.js'; function asUser(u: { id: string; role: 'admin' | 'user' }): Express.User { return { id: u.id, role: u.role, orgIds: [] } as unknown as Express.User; } function makeApp(repo: Repository, currentUser: Express.User | undefined, pkgCfg?: PythonPackagesConfigShape) { const app = express(); app.use((req: express.Request, _res, next) => { if (currentUser) (req as { user?: Express.User }).user = currentUser; next(); }); const deps = { repo, userFolderRoot: tmpdir(), noAuthOwner: undefined, opts: { repo, authActive: true, getPythonPackagesConfig: () => pkgCfg }, } as unknown as LocalTasksDeps; registerLocalTaskPackageRequestRoutes(app, deps); return app; } let repo: Repository; let dir: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'pkgreq-api-')); repo = new Repository(join(dir, `${randomUUID()}.db`)); }); afterEach(() => { repo.close(); rmSync(dir, { recursive: true, force: true }); }); async function seed() { const owner = repo.createUser({ email: 'o@e.com', name: 'O', role: 'user', status: 'active' }); const viewer = repo.createUser({ email: 'v@e.com', name: 'V', role: 'user', status: 'active' }); const stranger = repo.createUser({ email: 's@e.com', name: 'S', role: 'user', status: 'active' }); // Private space so a non-member truly cannot see the task (→ 404), while a // viewer member can see but not edit (→ 403). const space = await repo.createSpace({ kind: 'case', title: 'T', ownerId: owner.id, visibility: 'private' }); await repo.addSpaceMember({ spaceId: space.id, userId: viewer.id, role: 'viewer' }); const task = await repo.createLocalTask({ title: 't', body: 'b', pieceName: 'chat', ownerId: owner.id, visibility: 'private', spaceId: space.id, }); const reqId = repo.recordPackageRequest({ taskId: String(task.id), jobId: null, spaceId: space.id, spec: 'requests==2.32.3', normalizedName: 'requests', reason: 'http', }); return { owner, viewer, stranger, space, task, reqId }; } const ENABLED: PythonPackagesConfigShape = { enabled: true } as PythonPackagesConfigShape; const DISABLED: PythonPackagesConfigShape = { enabled: false } as PythonPackagesConfigShape; describe('package-requests API', () => { it('GET requires view permission (stranger gets 404/403)', async () => { const fx = await seed(); const app = makeApp(repo, asUser(fx.stranger), ENABLED); const res = await request(app).get(`/api/local/tasks/${fx.task.id}/package-requests`); expect([403, 404]).toContain(res.status); }); it('GET lists requests for the owner', async () => { const fx = await seed(); const app = makeApp(repo, asUser(fx.owner), ENABLED); const res = await request(app).get(`/api/local/tasks/${fx.task.id}/package-requests`); expect(res.status).toBe(200); expect(res.body.packageRequests).toHaveLength(1); expect(res.body.packageRequests[0].spec).toBe('requests==2.32.3'); }); it('decide rejects a bad decision value', async () => { const fx = await seed(); const app = makeApp(repo, asUser(fx.owner), ENABLED); const res = await request(app).post(`/api/local/tasks/${fx.task.id}/package-requests/${fx.reqId}/decide`).send({ decision: 'maybe' }); expect(res.status).toBe(400); }); it('a space viewer (editor未満) cannot decide → 403', async () => { const fx = await seed(); const app = makeApp(repo, asUser(fx.viewer), ENABLED); const res = await request(app).post(`/api/local/tasks/${fx.task.id}/package-requests/${fx.reqId}/decide`).send({ decision: 'approve' }); expect(res.status).toBe(403); // untouched expect(repo.getPackageRequest(fx.reqId)?.status).toBe('pending'); }); it('a non-member cannot decide → 404 (task not visible)', async () => { const fx = await seed(); const app = makeApp(repo, asUser(fx.stranger), ENABLED); const res = await request(app).post(`/api/local/tasks/${fx.task.id}/package-requests/${fx.reqId}/decide`).send({ decision: 'deny' }); expect(res.status).toBe(404); }); it('owner can deny → request denied, resume no-ops without a job', async () => { const fx = await seed(); const app = makeApp(repo, asUser(fx.owner), ENABLED); const res = await request(app).post(`/api/local/tasks/${fx.task.id}/package-requests/${fx.reqId}/decide`).send({ decision: 'deny' }); expect(res.status).toBe(200); expect(res.body.decision).toBe('deny'); expect(repo.getPackageRequest(fx.reqId)?.status).toBe('denied'); }); it('deciding an already-decided request → 409', async () => { const fx = await seed(); repo.decidePackageRequest(fx.reqId, { status: 'denied', decidedBy: fx.owner.id }); const app = makeApp(repo, asUser(fx.owner), ENABLED); const res = await request(app).post(`/api/local/tasks/${fx.task.id}/package-requests/${fx.reqId}/decide`).send({ decision: 'approve' }); expect(res.status).toBe(409); }); it('approve with the feature disabled → 400 (no install attempted)', async () => { const fx = await seed(); const app = makeApp(repo, asUser(fx.owner), DISABLED); const res = await request(app).post(`/api/local/tasks/${fx.task.id}/package-requests/${fx.reqId}/decide`).send({ decision: 'approve' }); expect(res.status).toBe(400); expect(repo.getPackageRequest(fx.reqId)?.status).toBe('pending'); }); it('admin (not a member) can decide', async () => { const fx = await seed(); const admin = repo.createUser({ email: 'a@e.com', name: 'A', role: 'admin', status: 'active' }); const app = makeApp(repo, asUser(admin), ENABLED); const res = await request(app).post(`/api/local/tasks/${fx.task.id}/package-requests/${fx.reqId}/decide`).send({ decision: 'deny' }); expect(res.status).toBe(200); expect(repo.getPackageRequest(fx.reqId)?.status).toBe('denied'); }); it('deny re-queues a job parked with wait_reason=package_request (park/resume contract)', async () => { // Regression guard for the worker-park contract: a package-approval pause // MUST be persisted as wait_reason='package_request' for resume to work. const fx = await seed(); const job = await repo.createJob({ repo: localTaskRepoName(fx.task.id), issueNumber: fx.task.id, instruction: 'x', pieceName: 'chat', ownerId: fx.owner.id, spaceId: fx.space.id, }); repo.getDb().prepare(`UPDATE jobs SET status='waiting_human', wait_reason='package_request' WHERE id = ?`).run(job.id); const reqId = repo.recordPackageRequest({ taskId: String(fx.task.id), jobId: job.id, spaceId: fx.space.id, spec: 'httpx', normalizedName: 'httpx', }); const app = makeApp(repo, asUser(fx.owner), ENABLED); const res = await request(app).post(`/api/local/tasks/${fx.task.id}/package-requests/${reqId}/decide`).send({ decision: 'deny' }); expect(res.status).toBe(200); expect(res.body.resumed).toBe(true); expect((await repo.getJob(job.id))?.status).toBe('queued'); }); it('does NOT resume a job parked without wait_reason=package_request', async () => { // The wait_reason gate must be exact: a job parked for a different reason is // never re-queued by resumePackageRequestJob. const fx = await seed(); const job = await repo.createJob({ repo: localTaskRepoName(fx.task.id), issueNumber: fx.task.id, instruction: 'x', pieceName: 'chat', ownerId: fx.owner.id, spaceId: fx.space.id, }); repo.getDb().prepare(`UPDATE jobs SET status='waiting_human', wait_reason='tool_request' WHERE id = ?`).run(job.id); const reqId = repo.recordPackageRequest({ taskId: String(fx.task.id), jobId: job.id, spaceId: fx.space.id, spec: 'httpx', normalizedName: 'httpx', }); const app = makeApp(repo, asUser(fx.owner), ENABLED); const res = await request(app).post(`/api/local/tasks/${fx.task.id}/package-requests/${reqId}/decide`).send({ decision: 'deny' }); expect(res.status).toBe(200); expect(res.body.resumed).toBe(false); // wait_reason mismatch → not re-queued expect((await repo.getJob(job.id))?.status).toBe('waiting_human'); }); });