185 lines
8.9 KiB
TypeScript
185 lines
8.9 KiB
TypeScript
/**
|
|
* 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');
|
|
});
|
|
});
|