1043 lines
44 KiB
TypeScript
1043 lines
44 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import express from 'express';
|
|
import request from 'supertest';
|
|
import { mkdtempSync, writeFileSync, mkdirSync, existsSync, readFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { mountPiecesApi } from './pieces-api.js';
|
|
|
|
function makeGeneralPieceYaml(): string {
|
|
return [
|
|
'name: general',
|
|
'description: 汎用タスク',
|
|
'max_movements: 25',
|
|
'initial_movement: understand',
|
|
'movements:',
|
|
' - name: understand',
|
|
' edit: false',
|
|
' persona: analyst',
|
|
' instruction: |',
|
|
' タスクを確認する。',
|
|
' allowed_tools: [Read, Glob]',
|
|
' default_next: execute',
|
|
' rules:',
|
|
' - condition: 方針が立った',
|
|
' next: execute',
|
|
' - name: execute',
|
|
' edit: true',
|
|
' persona: worker',
|
|
' instruction: |',
|
|
' 作業を実行する。',
|
|
' allowed_tools: [Read, Write]',
|
|
' default_next: COMPLETE',
|
|
' rules:',
|
|
' - condition: 完了',
|
|
' next: COMPLETE',
|
|
].join('\n');
|
|
}
|
|
|
|
function makeMinimalPieceYaml(name: string, description = 'x'): string {
|
|
return [
|
|
`name: ${name}`,
|
|
`description: ${description}`,
|
|
'max_movements: 1',
|
|
'initial_movement: only',
|
|
'movements:',
|
|
' - name: only',
|
|
' edit: false',
|
|
' persona: p',
|
|
' instruction: i',
|
|
' allowed_tools: [Read]',
|
|
' default_next: COMPLETE',
|
|
' rules: []',
|
|
].join('\n');
|
|
}
|
|
|
|
describe('Pieces API (no auth — legacy behavior)', () => {
|
|
let app: express.Application;
|
|
let piecesDir: string;
|
|
|
|
beforeEach(() => {
|
|
const tempDir = mkdtempSync(join(tmpdir(), 'pieces-api-'));
|
|
piecesDir = join(tempDir, 'pieces');
|
|
mkdirSync(piecesDir);
|
|
writeFileSync(join(piecesDir, 'general.yaml'), makeGeneralPieceYaml());
|
|
app = express();
|
|
app.use(express.json());
|
|
mountPiecesApi(app, { piecesDir });
|
|
});
|
|
|
|
it('GET /api/pieces returns piece list', async () => {
|
|
const res = await request(app).get('/api/pieces');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.pieces).toHaveLength(1);
|
|
expect(res.body.pieces[0].name).toBe('general');
|
|
expect(res.body.pieces[0].source).toBe('builtin');
|
|
expect(res.body.pieces[0].custom).toBe(false);
|
|
});
|
|
|
|
it('GET /api/pieces/:name returns full piece', async () => {
|
|
const res = await request(app).get('/api/pieces/general');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.piece.name).toBe('general');
|
|
expect(res.body.piece.movements).toHaveLength(2);
|
|
expect(res.body.source).toBe('builtin');
|
|
});
|
|
|
|
it('GET /api/pieces/:name returns 404 for unknown', async () => {
|
|
const res = await request(app).get('/api/pieces/nonexistent');
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it('PUT /api/pieces/:name updates piece', async () => {
|
|
const res = await request(app)
|
|
.put('/api/pieces/general')
|
|
.send({
|
|
name: 'general',
|
|
description: '更新済み',
|
|
max_movements: 30,
|
|
initial_movement: 'understand',
|
|
movements: [
|
|
{ name: 'understand', edit: false, persona: 'analyst', instruction: 'テスト', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] },
|
|
],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.ok).toBe(true);
|
|
});
|
|
|
|
it('POST /api/pieces creates new piece', async () => {
|
|
const res = await request(app)
|
|
.post('/api/pieces')
|
|
.send({
|
|
name: 'custom',
|
|
description: 'カスタム',
|
|
max_movements: 10,
|
|
initial_movement: 'work',
|
|
movements: [
|
|
{ name: 'work', edit: true, persona: 'worker', instruction: '作業する', allowed_tools: ['Read', 'Write'], default_next: 'COMPLETE', rules: [] },
|
|
],
|
|
});
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.ok).toBe(true);
|
|
});
|
|
|
|
it('POST /api/pieces rejects duplicate name', async () => {
|
|
const res = await request(app)
|
|
.post('/api/pieces')
|
|
.send({ name: 'general', description: 'x', max_movements: 1, initial_movement: 'a', movements: [{ name: 'a', edit: false, persona: 'x', instruction: 'x', allowed_tools: [], rules: [] }] });
|
|
expect(res.status).toBe(409);
|
|
});
|
|
|
|
it('POST /api/pieces rejects rules[].next: COMPLETE (Phase 6b)', async () => {
|
|
const res = await request(app)
|
|
.post('/api/pieces')
|
|
.send({
|
|
name: 'phase6b-reject',
|
|
description: 'should be rejected',
|
|
max_movements: 1,
|
|
initial_movement: 'only',
|
|
movements: [
|
|
{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'],
|
|
rules: [{ condition: 'ok', next: 'COMPLETE' }] },
|
|
],
|
|
});
|
|
expect(res.status).toBe(400);
|
|
expect(String(res.body.error ?? res.body)).toMatch(/rules\[\]\.next cannot be "COMPLETE"/);
|
|
});
|
|
|
|
it('POST /api/pieces accepts default_next: COMPLETE (engine-internal sentinel)', async () => {
|
|
const res = await request(app)
|
|
.post('/api/pieces')
|
|
.send({
|
|
name: 'phase6b-default-ok',
|
|
description: 'default_next is fine',
|
|
max_movements: 1,
|
|
initial_movement: 'only',
|
|
movements: [
|
|
{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'],
|
|
default_next: 'COMPLETE', rules: [] },
|
|
],
|
|
});
|
|
expect(res.status).toBe(201);
|
|
});
|
|
|
|
// Phase 4: per-movement SSH connection allowlist validation.
|
|
it('POST /api/pieces rejects SshExec without allowed_ssh_connections', async () => {
|
|
const res = await request(app)
|
|
.post('/api/pieces')
|
|
.send({
|
|
name: 'ssh-missing-allowlist',
|
|
description: 'x',
|
|
max_movements: 1,
|
|
initial_movement: 'only',
|
|
movements: [
|
|
{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
|
|
default_next: 'COMPLETE', rules: [] },
|
|
],
|
|
});
|
|
expect(res.status).toBe(400);
|
|
expect(String(res.body.error ?? res.body)).toMatch(/allowed_ssh_connections is required/);
|
|
});
|
|
|
|
it('POST /api/pieces accepts SshExec with UUID allowlist', async () => {
|
|
const res = await request(app)
|
|
.post('/api/pieces')
|
|
.send({
|
|
name: 'ssh-uuid-ok',
|
|
description: 'x',
|
|
max_movements: 1,
|
|
initial_movement: 'only',
|
|
movements: [
|
|
{
|
|
name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
|
|
allowed_ssh_connections: ['6f9619ff-8b86-d011-b42d-00c04fc964ff'],
|
|
default_next: 'COMPLETE', rules: [],
|
|
},
|
|
],
|
|
});
|
|
expect(res.status).toBe(201);
|
|
});
|
|
|
|
it('POST /api/pieces accepts SshExec with ["*"] wildcard', async () => {
|
|
const res = await request(app)
|
|
.post('/api/pieces')
|
|
.send({
|
|
name: 'ssh-wildcard-ok',
|
|
description: 'x',
|
|
max_movements: 1,
|
|
initial_movement: 'only',
|
|
movements: [
|
|
{
|
|
name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
|
|
allowed_ssh_connections: ['*'],
|
|
default_next: 'COMPLETE', rules: [],
|
|
},
|
|
],
|
|
});
|
|
expect(res.status).toBe(201);
|
|
});
|
|
|
|
it('POST /api/pieces accepts SshExec with empty allowlist (explicit deny)', async () => {
|
|
const res = await request(app)
|
|
.post('/api/pieces')
|
|
.send({
|
|
name: 'ssh-empty-ok',
|
|
description: 'x',
|
|
max_movements: 1,
|
|
initial_movement: 'only',
|
|
movements: [
|
|
{
|
|
name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
|
|
allowed_ssh_connections: [],
|
|
default_next: 'COMPLETE', rules: [],
|
|
},
|
|
],
|
|
});
|
|
expect(res.status).toBe(201);
|
|
});
|
|
|
|
it('POST /api/pieces rejects allowed_ssh_connections with bad format', async () => {
|
|
const res = await request(app)
|
|
.post('/api/pieces')
|
|
.send({
|
|
name: 'ssh-bad-format',
|
|
description: 'x',
|
|
max_movements: 1,
|
|
initial_movement: 'only',
|
|
movements: [
|
|
{
|
|
name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
|
|
allowed_ssh_connections: ['BAD-NOT-LOWERCASE'],
|
|
default_next: 'COMPLETE', rules: [],
|
|
},
|
|
],
|
|
});
|
|
expect(res.status).toBe(400);
|
|
expect(String(res.body.error ?? res.body)).toMatch(/must be '\*' or a lowercase hex/);
|
|
});
|
|
|
|
it('POST /api/pieces rejects non-array allowed_ssh_connections', async () => {
|
|
const res = await request(app)
|
|
.post('/api/pieces')
|
|
.send({
|
|
name: 'ssh-non-array',
|
|
description: 'x',
|
|
max_movements: 1,
|
|
initial_movement: 'only',
|
|
movements: [
|
|
{
|
|
name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
|
|
allowed_ssh_connections: 'not-an-array',
|
|
default_next: 'COMPLETE', rules: [],
|
|
},
|
|
],
|
|
});
|
|
expect(res.status).toBe(400);
|
|
expect(String(res.body.error ?? res.body)).toMatch(/must be an array/);
|
|
});
|
|
|
|
it('POST /api/pieces accepts allowed_ssh_connections without SSH tools (no-op)', async () => {
|
|
const res = await request(app)
|
|
.post('/api/pieces')
|
|
.send({
|
|
name: 'ssh-noop',
|
|
description: 'x',
|
|
max_movements: 1,
|
|
initial_movement: 'only',
|
|
movements: [
|
|
{
|
|
name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'],
|
|
allowed_ssh_connections: ['6f9619ff-8b86-d011-b42d-00c04fc964ff'],
|
|
default_next: 'COMPLETE', rules: [],
|
|
},
|
|
],
|
|
});
|
|
expect(res.status).toBe(201);
|
|
});
|
|
|
|
it('DELETE /api/pieces/:name — legacy no-auth pieces land in piecesDir (builtin source) and are non-deletable', async () => {
|
|
// In legacy no-auth mode with no customPiecesDir, POST writes to piecesDir.
|
|
// Those files are resolved as source='builtin' and are non-deletable.
|
|
await request(app).post('/api/pieces').send({
|
|
name: 'deleteme', description: 'x', max_movements: 1, initial_movement: 'a',
|
|
movements: [{ name: 'a', edit: false, persona: 'x', instruction: 'x', allowed_tools: [], default_next: 'COMPLETE', rules: [] }],
|
|
});
|
|
const res = await request(app).delete('/api/pieces/deleteme');
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toMatch(/cannot delete a built-in/i);
|
|
});
|
|
|
|
it('DELETE /api/pieces/general is forbidden', async () => {
|
|
const res = await request(app).delete('/api/pieces/general');
|
|
expect(res.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Auth-aware tests (per-user custom pieces + non-admin write authz)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type UserShape = { id: string; role: 'admin' | 'user' };
|
|
|
|
function makeAuthApp(piecesDir: string, userPiecesRootDir: string, user: UserShape | null): express.Application {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
if (user) (req as any).user = user;
|
|
next();
|
|
});
|
|
mountPiecesApi(app, { piecesDir, userPiecesRootDir });
|
|
return app;
|
|
}
|
|
|
|
describe('Pieces API (auth-aware: per-user custom + write authz)', () => {
|
|
let piecesDir: string;
|
|
let userPiecesRootDir: string;
|
|
|
|
beforeEach(() => {
|
|
const tempDir = mkdtempSync(join(tmpdir(), 'pieces-api-auth-'));
|
|
piecesDir = join(tempDir, 'pieces');
|
|
userPiecesRootDir = join(tempDir, 'users');
|
|
mkdirSync(piecesDir);
|
|
mkdirSync(userPiecesRootDir);
|
|
writeFileSync(join(piecesDir, 'general.yaml'), makeGeneralPieceYaml());
|
|
writeFileSync(join(piecesDir, 'chat.yaml'), makeMinimalPieceYaml('chat', 'built-in chat'));
|
|
});
|
|
|
|
it('GET /api/pieces returns built-ins for any authenticated non-admin', async () => {
|
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
|
.get('/api/pieces');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.pieces.map((p: any) => p.name).sort()).toEqual(['chat', 'general']);
|
|
for (const p of res.body.pieces) {
|
|
expect(p.source).toBe('builtin');
|
|
expect(p.custom).toBe(false);
|
|
}
|
|
});
|
|
|
|
it('GET /api/pieces merges caller\'s user-custom pieces (own only)', async () => {
|
|
// Alice has my-tool, Bob has bob-tool
|
|
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
|
|
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'my-tool.yaml'), makeMinimalPieceYaml('my-tool', "alice's piece"));
|
|
mkdirSync(join(userPiecesRootDir, 'bob', 'pieces'), { recursive: true });
|
|
writeFileSync(join(userPiecesRootDir, 'bob', 'pieces', 'bob-tool.yaml'), makeMinimalPieceYaml('bob-tool', "bob's piece"));
|
|
|
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
|
.get('/api/pieces');
|
|
expect(res.status).toBe(200);
|
|
const byName = Object.fromEntries(res.body.pieces.map((p: any) => [p.name, p]));
|
|
expect(Object.keys(byName).sort()).toEqual(['chat', 'general', 'my-tool']);
|
|
expect(byName['my-tool'].source).toBe('user-custom');
|
|
expect(byName['my-tool'].ownerId).toBe('alice');
|
|
expect(byName['my-tool'].custom).toBe(true);
|
|
// Bob's piece must not appear for Alice
|
|
expect(byName['bob-tool']).toBeUndefined();
|
|
});
|
|
|
|
it("GET /api/pieces — user-custom and built-in both appear when same name (no hiding)", async () => {
|
|
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
|
|
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'general.yaml'), makeMinimalPieceYaml('general', 'alice override'));
|
|
|
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
|
.get('/api/pieces');
|
|
expect(res.status).toBe(200);
|
|
const generals = res.body.pieces.filter((p: any) => p.name === 'general');
|
|
// Both builtin and user-custom should appear
|
|
expect(generals).toHaveLength(2);
|
|
const customGeneral = generals.find((p: any) => p.source === 'user-custom');
|
|
const builtinGeneral = generals.find((p: any) => p.source === 'builtin');
|
|
expect(customGeneral).toBeDefined();
|
|
expect(customGeneral.description).toBe('alice override');
|
|
expect(builtinGeneral).toBeDefined();
|
|
});
|
|
|
|
it('POST /api/pieces creates a user-custom piece for non-admin caller', async () => {
|
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
|
.post('/api/pieces')
|
|
.send({
|
|
name: 'alice-custom',
|
|
description: 'alice piece',
|
|
max_movements: 1,
|
|
initial_movement: 'only',
|
|
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
|
});
|
|
expect(res.status).toBe(201);
|
|
expect(existsSync(join(userPiecesRootDir, 'alice', 'pieces', 'alice-custom.yaml'))).toBe(true);
|
|
// Built-in dir untouched
|
|
expect(existsSync(join(piecesDir, 'alice-custom.yaml'))).toBe(false);
|
|
});
|
|
|
|
it('POST /api/pieces by admin writes to user-custom dir (not piecesDir)', async () => {
|
|
// POST always targets user-custom dir regardless of admin role.
|
|
// Admins edit built-ins via PUT on existing ones.
|
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'admin1', role: 'admin' }))
|
|
.post('/api/pieces')
|
|
.send({
|
|
name: 'admin-piece',
|
|
description: 'admin piece',
|
|
max_movements: 1,
|
|
initial_movement: 'only',
|
|
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
|
});
|
|
expect(res.status).toBe(201);
|
|
// Piece lands in admin's user-custom dir, NOT in piecesDir.
|
|
expect(existsSync(join(userPiecesRootDir, 'admin1', 'pieces', 'admin-piece.yaml'))).toBe(true);
|
|
expect(existsSync(join(piecesDir, 'admin-piece.yaml'))).toBe(false);
|
|
});
|
|
|
|
it('PUT /api/pieces/:name on built-in by non-admin returns 403', async () => {
|
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
|
.put('/api/pieces/general')
|
|
.send({
|
|
name: 'general',
|
|
description: 'should not write',
|
|
max_movements: 1,
|
|
initial_movement: 'only',
|
|
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
|
});
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it('PUT /api/pieces/:name on own user-custom by owner returns 200', async () => {
|
|
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
|
|
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'my-piece.yaml'), makeMinimalPieceYaml('my-piece', 'v1'));
|
|
|
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
|
.put('/api/pieces/my-piece')
|
|
.send({
|
|
name: 'my-piece',
|
|
description: 'v2',
|
|
max_movements: 1,
|
|
initial_movement: 'only',
|
|
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it("PUT /api/pieces/:name — non-admin cannot edit another user's piece", async () => {
|
|
// Bob has a piece. Alice tries to edit it via the same name.
|
|
mkdirSync(join(userPiecesRootDir, 'bob', 'pieces'), { recursive: true });
|
|
writeFileSync(join(userPiecesRootDir, 'bob', 'pieces', 'bob-only.yaml'), makeMinimalPieceYaml('bob-only', 'bob'));
|
|
|
|
// Alice doesn't have bob-only — the request 404s (she can't see it), proving isolation.
|
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
|
.put('/api/pieces/bob-only')
|
|
.send({
|
|
name: 'bob-only',
|
|
description: 'hijacked',
|
|
max_movements: 1,
|
|
initial_movement: 'only',
|
|
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
|
});
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it('DELETE /api/pieces/:name on built-in by non-admin returns 403', async () => {
|
|
// Use a deletable built-in (not general/chat which are protected separately)
|
|
writeFileSync(join(piecesDir, 'extra.yaml'), makeMinimalPieceYaml('extra', 'extra'));
|
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
|
.delete('/api/pieces/extra');
|
|
expect(res.status).toBe(403);
|
|
expect(existsSync(join(piecesDir, 'extra.yaml'))).toBe(true);
|
|
});
|
|
|
|
it('DELETE /api/pieces/:name on own user-custom by owner returns 200', async () => {
|
|
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
|
|
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'goner.yaml'), makeMinimalPieceYaml('goner', 'gone'));
|
|
|
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
|
.delete('/api/pieces/goner');
|
|
expect(res.status).toBe(200);
|
|
expect(existsSync(join(userPiecesRootDir, 'alice', 'pieces', 'goner.yaml'))).toBe(false);
|
|
});
|
|
|
|
it('admin can edit built-in pieces (PUT 200) but NOT delete them (DELETE 403)', async () => {
|
|
writeFileSync(join(piecesDir, 'admin-target.yaml'), makeMinimalPieceYaml('admin-target', 'before'));
|
|
const adminApp = makeAuthApp(piecesDir, userPiecesRootDir, { id: 'admin1', role: 'admin' });
|
|
|
|
// Admin CAN edit (PUT) a built-in
|
|
const putRes = await request(adminApp).put('/api/pieces/admin-target').send({
|
|
name: 'admin-target',
|
|
description: 'after',
|
|
max_movements: 1,
|
|
initial_movement: 'only',
|
|
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
|
});
|
|
expect(putRes.status).toBe(200);
|
|
|
|
// Admin CANNOT delete a built-in — 403 for everyone
|
|
const delRes = await request(adminApp).delete('/api/pieces/admin-target');
|
|
expect(delRes.status).toBe(403);
|
|
expect(delRes.body.ok).toBe(false);
|
|
expect(delRes.body.error).toMatch(/cannot delete a built-in/i);
|
|
// File must still exist
|
|
expect(existsSync(join(piecesDir, 'admin-target.yaml'))).toBe(true);
|
|
});
|
|
|
|
// --- Task 2A: built-in must not be hidden by same-named custom ---
|
|
it("GET /api/pieces — built-in is NOT hidden when user has a same-named custom", async () => {
|
|
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
|
|
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'chat.yaml'), makeMinimalPieceYaml('chat', 'alice chat override'));
|
|
|
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
|
.get('/api/pieces');
|
|
expect(res.status).toBe(200);
|
|
// Both the user-custom AND the builtin should appear
|
|
const chatPieces = res.body.pieces.filter((p: any) => p.name === 'chat');
|
|
expect(chatPieces).toHaveLength(2);
|
|
const sources = chatPieces.map((p: any) => p.source).sort();
|
|
expect(sources).toEqual(['builtin', 'user-custom']);
|
|
// Custom has the custom description, builtin has the original
|
|
const customChat = chatPieces.find((p: any) => p.source === 'user-custom');
|
|
const builtinChat = chatPieces.find((p: any) => p.source === 'builtin');
|
|
expect(customChat.description).toBe('alice chat override');
|
|
expect(builtinChat.description).toBe('built-in chat');
|
|
});
|
|
|
|
// --- Task 2B: CreatePiece rejects name collision with built-in ---
|
|
it('POST /api/pieces rejects custom creation with a built-in name', async () => {
|
|
// 'general' and 'chat' are in piecesDir (built-in)
|
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
|
.post('/api/pieces')
|
|
.send({
|
|
name: 'chat',
|
|
description: 'my chat',
|
|
max_movements: 1,
|
|
initial_movement: 'only',
|
|
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
|
});
|
|
expect(res.status).toBe(409);
|
|
expect(res.body.error).toMatch(/built-in/i);
|
|
});
|
|
|
|
it('POST /api/pieces with a fresh custom name still works for non-admin', async () => {
|
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
|
.post('/api/pieces')
|
|
.send({
|
|
name: 'fresh-custom',
|
|
description: 'fresh',
|
|
max_movements: 1,
|
|
initial_movement: 'only',
|
|
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
|
});
|
|
expect(res.status).toBe(201);
|
|
});
|
|
|
|
// --- Task 2C: regression — non-admin DELETE of built-in → 403 ---
|
|
it('non-admin DELETE of built-in returns 403', async () => {
|
|
writeFileSync(join(piecesDir, 'deletable-builtin.yaml'), makeMinimalPieceYaml('deletable-builtin', 'del'));
|
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
|
.delete('/api/pieces/deletable-builtin');
|
|
expect(res.status).toBe(403);
|
|
expect(existsSync(join(piecesDir, 'deletable-builtin.yaml'))).toBe(true);
|
|
});
|
|
|
|
// --- Fix 2: GET /api/pieces/:name?source=builtin fetches the specific source ---
|
|
it('GET /api/pieces/:name?source=builtin returns builtin even when user-custom exists with same name', async () => {
|
|
// Alice has a user-custom 'chat' that overrides by default priority
|
|
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
|
|
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'chat.yaml'), makeMinimalPieceYaml('chat', 'alice custom chat'));
|
|
|
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
|
.get('/api/pieces/chat?source=builtin');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.source).toBe('builtin');
|
|
expect(res.body.piece.description).toBe('built-in chat');
|
|
});
|
|
|
|
it('GET /api/pieces/:name without ?source uses priority resolution (user-custom first)', async () => {
|
|
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
|
|
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'chat.yaml'), makeMinimalPieceYaml('chat', 'alice custom chat'));
|
|
|
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
|
.get('/api/pieces/chat');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.source).toBe('user-custom');
|
|
expect(res.body.piece.description).toBe('alice custom chat');
|
|
});
|
|
|
|
// --- Fix 3: DELETE of a user-custom named 'chat' by its owner must succeed ---
|
|
it("DELETE /api/pieces/chat on owner's user-custom named 'chat' returns 200 (guard only blocks builtin)", async () => {
|
|
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
|
|
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'chat.yaml'), makeMinimalPieceYaml('chat', 'alice custom chat'));
|
|
|
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
|
.delete('/api/pieces/chat');
|
|
expect(res.status).toBe(200);
|
|
// User-custom file removed; built-in chat still present
|
|
expect(existsSync(join(userPiecesRootDir, 'alice', 'pieces', 'chat.yaml'))).toBe(false);
|
|
expect(existsSync(join(piecesDir, 'chat.yaml'))).toBe(true);
|
|
});
|
|
|
|
it('DELETE /api/pieces/chat on builtin by non-admin returns 403', async () => {
|
|
// No user-custom for alice, so findPieceForCaller resolves to builtin
|
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
|
.delete('/api/pieces/chat');
|
|
expect(res.status).toBe(403);
|
|
expect(existsSync(join(piecesDir, 'chat.yaml'))).toBe(true);
|
|
});
|
|
|
|
// --- Fix source param for PUT/DELETE (P1-b) ---
|
|
|
|
it('PUT /api/pieces/chat?source=builtin by admin updates the builtin, not a same-named custom', async () => {
|
|
// Alice (admin) has a user-custom chat AND there is a builtin chat.
|
|
mkdirSync(join(userPiecesRootDir, 'admin1', 'pieces'), { recursive: true });
|
|
writeFileSync(join(userPiecesRootDir, 'admin1', 'pieces', 'chat.yaml'), makeMinimalPieceYaml('chat', 'admin custom chat'));
|
|
const adminApp = makeAuthApp(piecesDir, userPiecesRootDir, { id: 'admin1', role: 'admin' });
|
|
|
|
const res = await request(adminApp).put('/api/pieces/chat?source=builtin').send({
|
|
name: 'chat',
|
|
description: 'builtin updated',
|
|
max_movements: 1,
|
|
initial_movement: 'only',
|
|
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
// Builtin was updated
|
|
const builtinContent = readFileSync(join(piecesDir, 'chat.yaml'), 'utf-8');
|
|
expect(builtinContent).toContain('builtin updated');
|
|
// Custom is untouched
|
|
const customContent = readFileSync(join(userPiecesRootDir, 'admin1', 'pieces', 'chat.yaml'), 'utf-8');
|
|
expect(customContent).toContain('admin custom chat');
|
|
});
|
|
|
|
it('DELETE /api/pieces/chat?source=builtin by admin deletes the builtin, not same-named custom', async () => {
|
|
// admin1 has a user-custom chat AND builtin chat
|
|
mkdirSync(join(userPiecesRootDir, 'admin1', 'pieces'), { recursive: true });
|
|
writeFileSync(join(userPiecesRootDir, 'admin1', 'pieces', 'chat.yaml'), makeMinimalPieceYaml('chat', 'admin custom'));
|
|
const adminApp = makeAuthApp(piecesDir, userPiecesRootDir, { id: 'admin1', role: 'admin' });
|
|
|
|
const res = await request(adminApp).delete('/api/pieces/chat?source=builtin');
|
|
// Built-in pieces are non-deletable for everyone (including admins)
|
|
expect(res.status).toBe(403);
|
|
// Both still intact
|
|
expect(existsSync(join(piecesDir, 'chat.yaml'))).toBe(true);
|
|
expect(existsSync(join(userPiecesRootDir, 'admin1', 'pieces', 'chat.yaml'))).toBe(true);
|
|
});
|
|
|
|
it('DELETE /api/pieces/extra?source=user-custom targets user-custom, not builtin', async () => {
|
|
writeFileSync(join(piecesDir, 'extra.yaml'), makeMinimalPieceYaml('extra', 'builtin extra'));
|
|
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
|
|
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'extra.yaml'), makeMinimalPieceYaml('extra', 'alice extra'));
|
|
|
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
|
.delete('/api/pieces/extra?source=user-custom');
|
|
expect(res.status).toBe(200);
|
|
// User-custom removed
|
|
expect(existsSync(join(userPiecesRootDir, 'alice', 'pieces', 'extra.yaml'))).toBe(false);
|
|
// Builtin untouched
|
|
expect(existsSync(join(piecesDir, 'extra.yaml'))).toBe(true);
|
|
});
|
|
|
|
// P2 fix: GET /api/pieces/:name (no ?source) includes source in response body
|
|
// so the UI can derive the correct read-only state regardless of URL params.
|
|
it('GET /api/pieces/general (no ?source) returns source: builtin in the body', async () => {
|
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
|
.get('/api/pieces/general');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.piece.name).toBe('general');
|
|
expect(res.body.source).toBe('builtin');
|
|
expect(res.body.custom).toBe(false);
|
|
});
|
|
|
|
it('POST /api/pieces creates in user-custom dir for admin (not piecesDir)', async () => {
|
|
// Regression for P2: POST always targets user-custom, admin is no exception.
|
|
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'admin1', role: 'admin' }))
|
|
.post('/api/pieces')
|
|
.send({
|
|
name: 'admin-new-custom',
|
|
description: 'admin custom piece',
|
|
max_movements: 1,
|
|
initial_movement: 'only',
|
|
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
|
});
|
|
expect(res.status).toBe(201);
|
|
expect(existsSync(join(userPiecesRootDir, 'admin1', 'pieces', 'admin-new-custom.yaml'))).toBe(true);
|
|
expect(existsSync(join(piecesDir, 'admin-new-custom.yaml'))).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Finding 2 regression: authenticated POST with userPiecesRootDir UNSET → 503
|
|
// Never falls through to shared/builtin dirs for an authenticated user.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Pieces API (Finding 2: POST auth fallback hole)', () => {
|
|
let piecesDir: string;
|
|
|
|
function makeAppWithAuthButNoUserPiecesRoot(user: UserShape | null): express.Application {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
if (user) (req as any).user = user;
|
|
next();
|
|
});
|
|
// Intentionally: no userPiecesRootDir configured, no customPiecesDir.
|
|
mountPiecesApi(app, { piecesDir });
|
|
return app;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
const tempDir = mkdtempSync(join(tmpdir(), 'pieces-api-f2-'));
|
|
piecesDir = join(tempDir, 'pieces');
|
|
mkdirSync(piecesDir);
|
|
writeFileSync(join(piecesDir, 'general.yaml'), makeGeneralPieceYaml());
|
|
});
|
|
|
|
it('authenticated non-admin POST with userPiecesRootDir unset returns 503 (does not write shared/builtin)', async () => {
|
|
const app = makeAppWithAuthButNoUserPiecesRoot({ id: 'alice', role: 'user' });
|
|
const res = await request(app)
|
|
.post('/api/pieces')
|
|
.send({
|
|
name: 'should-not-exist',
|
|
description: 'fallback hole test',
|
|
max_movements: 1,
|
|
initial_movement: 'only',
|
|
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
|
});
|
|
expect(res.status).toBe(503);
|
|
expect(res.body.ok).toBe(false);
|
|
expect(res.body.error).toMatch(/not configured/i);
|
|
// Must NOT have written anything to the builtin dir.
|
|
expect(existsSync(join(piecesDir, 'should-not-exist.yaml'))).toBe(false);
|
|
});
|
|
|
|
it('authenticated admin POST with userPiecesRootDir unset also returns 503', async () => {
|
|
const app = makeAppWithAuthButNoUserPiecesRoot({ id: 'admin1', role: 'admin' });
|
|
const res = await request(app)
|
|
.post('/api/pieces')
|
|
.send({
|
|
name: 'admin-fallback-hole',
|
|
description: 'admin hole test',
|
|
max_movements: 1,
|
|
initial_movement: 'only',
|
|
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
|
});
|
|
expect(res.status).toBe(503);
|
|
expect(res.body.ok).toBe(false);
|
|
// Must NOT have written anything to the builtin dir.
|
|
expect(existsSync(join(piecesDir, 'admin-fallback-hole.yaml'))).toBe(false);
|
|
});
|
|
|
|
it('unauthenticated POST with no userPiecesRootDir falls back to piecesDir (legacy no-auth mode)', async () => {
|
|
// No user: genuine no-auth legacy mode — should still work (existing behavior).
|
|
const app = makeAppWithAuthButNoUserPiecesRoot(null);
|
|
const res = await request(app)
|
|
.post('/api/pieces')
|
|
.send({
|
|
name: 'legacy-fallback',
|
|
description: 'legacy',
|
|
max_movements: 1,
|
|
initial_movement: 'only',
|
|
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
|
});
|
|
expect(res.status).toBe(201);
|
|
expect(existsSync(join(piecesDir, 'legacy-fallback.yaml'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fix 1: invalid ?source param → 400 (no fallback to priority resolution)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Pieces API (Fix 1: invalid ?source → 400, no destructive fallback)', () => {
|
|
let piecesDir: string;
|
|
let userPiecesRootDir: string;
|
|
|
|
beforeEach(() => {
|
|
const tempDir = mkdtempSync(join(tmpdir(), 'pieces-api-fix1-'));
|
|
piecesDir = join(tempDir, 'pieces');
|
|
userPiecesRootDir = join(tempDir, 'users');
|
|
mkdirSync(piecesDir);
|
|
mkdirSync(userPiecesRootDir);
|
|
// A builtin and a user-custom with the same name
|
|
writeFileSync(join(piecesDir, 'chat.yaml'), makeMinimalPieceYaml('chat', 'builtin chat'));
|
|
// Alice has a user-custom 'chat'
|
|
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
|
|
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'chat.yaml'), makeMinimalPieceYaml('chat', 'alice custom chat'));
|
|
});
|
|
|
|
function makeApp(user: UserShape | null) {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
if (user) (req as any).user = user;
|
|
next();
|
|
});
|
|
mountPiecesApi(app, { piecesDir, userPiecesRootDir });
|
|
return app;
|
|
}
|
|
|
|
it('GET /api/pieces/:name?source=builtinn (typo) → 400 (not priority-fallback)', async () => {
|
|
const res = await request(makeApp({ id: 'alice', role: 'user' }))
|
|
.get('/api/pieces/chat?source=builtinn');
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.ok).toBe(false);
|
|
expect(res.body.error).toMatch(/invalid source/i);
|
|
});
|
|
|
|
it('DELETE /api/pieces/chat?source=builtinn (typo) → 400, does NOT delete user-custom', async () => {
|
|
const res = await request(makeApp({ id: 'alice', role: 'user' }))
|
|
.delete('/api/pieces/chat?source=builtinn');
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.ok).toBe(false);
|
|
expect(res.body.error).toMatch(/invalid source/i);
|
|
// User-custom must be untouched
|
|
expect(existsSync(join(userPiecesRootDir, 'alice', 'pieces', 'chat.yaml'))).toBe(true);
|
|
});
|
|
|
|
it('PUT /api/pieces/chat?source=user_custom (underscore typo) → 400, does NOT mutate user-custom', async () => {
|
|
const res = await request(makeApp({ id: 'alice', role: 'user' }))
|
|
.put('/api/pieces/chat?source=user_custom')
|
|
.send({
|
|
name: 'chat',
|
|
description: 'hijacked',
|
|
max_movements: 1,
|
|
initial_movement: 'only',
|
|
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
|
});
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.ok).toBe(false);
|
|
expect(res.body.error).toMatch(/invalid source/i);
|
|
// User-custom description must be unchanged
|
|
const content = readFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'chat.yaml'), 'utf-8');
|
|
expect(content).toContain('alice custom chat');
|
|
expect(content).not.toContain('hijacked');
|
|
});
|
|
|
|
it('valid ?source=builtin still works (not rejected)', async () => {
|
|
const res = await request(makeApp({ id: 'alice', role: 'user' }))
|
|
.get('/api/pieces/chat?source=builtin');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.source).toBe('builtin');
|
|
});
|
|
|
|
it('absent ?source still does priority resolution (not rejected)', async () => {
|
|
const res = await request(makeApp({ id: 'alice', role: 'user' }))
|
|
.get('/api/pieces/chat');
|
|
expect(res.status).toBe(200);
|
|
// priority: user-custom wins
|
|
expect(res.body.source).toBe('user-custom');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fix 2: POST /api/pieces returns actual source in response body
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Pieces API (Fix 2: POST returns actual source)', () => {
|
|
let piecesDir: string;
|
|
let userPiecesRootDir: string;
|
|
|
|
function minimalPieceBody(name: string) {
|
|
return {
|
|
name,
|
|
description: 'test',
|
|
max_movements: 1,
|
|
initial_movement: 'only',
|
|
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
const tempDir = mkdtempSync(join(tmpdir(), 'pieces-api-fix2-'));
|
|
piecesDir = join(tempDir, 'pieces');
|
|
userPiecesRootDir = join(tempDir, 'users');
|
|
mkdirSync(piecesDir);
|
|
mkdirSync(userPiecesRootDir);
|
|
});
|
|
|
|
it('authenticated POST returns source: user-custom', async () => {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => { (req as any).user = { id: 'alice', role: 'user' }; next(); });
|
|
mountPiecesApi(app, { piecesDir, userPiecesRootDir });
|
|
const res = await request(app).post('/api/pieces').send(minimalPieceBody('my-piece'));
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.ok).toBe(true);
|
|
expect(res.body.source).toBe('user-custom');
|
|
});
|
|
|
|
it('legacy (no-auth) POST with customPiecesDir returns source: global-custom', async () => {
|
|
const tempDir = mkdtempSync(join(tmpdir(), 'pieces-api-fix2-gc-'));
|
|
const gcDir = join(tempDir, 'custom');
|
|
mkdirSync(join(tempDir, 'pieces'));
|
|
mkdirSync(gcDir);
|
|
const app = express();
|
|
app.use(express.json());
|
|
// No user set — legacy no-auth mode
|
|
mountPiecesApi(app, { piecesDir: join(tempDir, 'pieces'), customPiecesDir: gcDir });
|
|
const res = await request(app).post('/api/pieces').send(minimalPieceBody('gc-piece'));
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.ok).toBe(true);
|
|
expect(res.body.source).toBe('global-custom');
|
|
});
|
|
|
|
it('legacy (no-auth) POST without customPiecesDir returns source: builtin', async () => {
|
|
const app = express();
|
|
app.use(express.json());
|
|
// No user, no customPiecesDir
|
|
mountPiecesApi(app, { piecesDir });
|
|
const res = await request(app).post('/api/pieces').send(minimalPieceBody('legacy-piece'));
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.ok).toBe(true);
|
|
expect(res.body.source).toBe('builtin');
|
|
});
|
|
|
|
it('no-auth POST with userPiecesRootDir returns source: user-custom (regression fix)', async () => {
|
|
const app = express();
|
|
app.use(express.json());
|
|
// No user, but userPiecesRootDir is configured (the fixed path)
|
|
mountPiecesApi(app, { piecesDir, userPiecesRootDir });
|
|
const res = await request(app).post('/api/pieces').send(minimalPieceBody('noauth-custom'));
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.ok).toBe(true);
|
|
expect(res.body.source).toBe('user-custom');
|
|
// Piece is in data/users/local/pieces, NOT in piecesDir
|
|
expect(existsSync(join(userPiecesRootDir, 'local', 'pieces', 'noauth-custom.yaml'))).toBe(true);
|
|
expect(existsSync(join(piecesDir, 'noauth-custom.yaml'))).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Regression fix: no-auth + userPiecesRootDir → 'local' user-custom namespace
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Pieces API (regression: no-auth uses local user-custom, not piecesDir)', () => {
|
|
let piecesDir: string;
|
|
let userPiecesRootDir: string;
|
|
|
|
function minimalPieceBody(name: string) {
|
|
return {
|
|
name,
|
|
description: 'test',
|
|
max_movements: 1,
|
|
initial_movement: 'only',
|
|
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
const tempDir = mkdtempSync(join(tmpdir(), 'pieces-noauth-local-'));
|
|
piecesDir = join(tempDir, 'pieces');
|
|
userPiecesRootDir = join(tempDir, 'users');
|
|
mkdirSync(piecesDir);
|
|
mkdirSync(userPiecesRootDir);
|
|
writeFileSync(join(piecesDir, 'general.yaml'), makeGeneralPieceYaml());
|
|
});
|
|
|
|
function makeNoAuthApp() {
|
|
const app = express();
|
|
app.use(express.json());
|
|
// No user middleware — simulates no-auth mode
|
|
mountPiecesApi(app, { piecesDir, userPiecesRootDir });
|
|
return app;
|
|
}
|
|
|
|
it('no-auth POST creates under local user-custom dir, NOT in piecesDir', async () => {
|
|
const app = makeNoAuthApp();
|
|
const res = await request(app).post('/api/pieces').send(minimalPieceBody('my-noauth-piece'));
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.source).toBe('user-custom');
|
|
// Must be in the 'local' user-custom dir
|
|
expect(existsSync(join(userPiecesRootDir, 'local', 'pieces', 'my-noauth-piece.yaml'))).toBe(true);
|
|
// Must NOT be in bundled piecesDir
|
|
expect(existsSync(join(piecesDir, 'my-noauth-piece.yaml'))).toBe(false);
|
|
});
|
|
|
|
it('no-auth created piece (with userPiecesRootDir) is DELETABLE — DELETE returns 200', async () => {
|
|
const app = makeNoAuthApp();
|
|
// Create it first
|
|
await request(app).post('/api/pieces').send(minimalPieceBody('deletable-noauth'));
|
|
expect(existsSync(join(userPiecesRootDir, 'local', 'pieces', 'deletable-noauth.yaml'))).toBe(true);
|
|
|
|
// Now delete — must succeed (not 403)
|
|
const delRes = await request(app).delete('/api/pieces/deletable-noauth');
|
|
expect(delRes.status).toBe(200);
|
|
expect(delRes.body.ok).toBe(true);
|
|
expect(existsSync(join(userPiecesRootDir, 'local', 'pieces', 'deletable-noauth.yaml'))).toBe(false);
|
|
});
|
|
|
|
it('bundled built-in piece is still non-deletable (403) for no-auth callers', async () => {
|
|
const app = makeNoAuthApp();
|
|
const res = await request(app).delete('/api/pieces/general');
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toMatch(/cannot delete a built-in/i);
|
|
expect(existsSync(join(piecesDir, 'general.yaml'))).toBe(true);
|
|
});
|
|
|
|
it('no-auth LIST includes the local user-custom piece (source=user-custom)', async () => {
|
|
// Pre-create a piece in the 'local' user-custom dir
|
|
mkdirSync(join(userPiecesRootDir, 'local', 'pieces'), { recursive: true });
|
|
writeFileSync(
|
|
join(userPiecesRootDir, 'local', 'pieces', 'local-custom.yaml'),
|
|
makeMinimalPieceYaml('local-custom', 'no-auth local piece'),
|
|
);
|
|
|
|
const app = makeNoAuthApp();
|
|
const res = await request(app).get('/api/pieces');
|
|
expect(res.status).toBe(200);
|
|
const byName = Object.fromEntries(res.body.pieces.map((p: any) => [p.name, p]));
|
|
expect(byName['local-custom']).toBeDefined();
|
|
expect(byName['local-custom'].source).toBe('user-custom');
|
|
expect(byName['local-custom'].ownerId).toBe('local');
|
|
// Built-in also visible
|
|
expect(byName['general']).toBeDefined();
|
|
expect(byName['general'].source).toBe('builtin');
|
|
});
|
|
|
|
it('no-auth GET resolves user-custom piece from local dir by priority', async () => {
|
|
mkdirSync(join(userPiecesRootDir, 'local', 'pieces'), { recursive: true });
|
|
writeFileSync(
|
|
join(userPiecesRootDir, 'local', 'pieces', 'my-piece.yaml'),
|
|
makeMinimalPieceYaml('my-piece', 'local custom'),
|
|
);
|
|
|
|
const app = makeNoAuthApp();
|
|
const res = await request(app).get('/api/pieces/my-piece');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.source).toBe('user-custom');
|
|
expect(res.body.ownerId).toBe('local');
|
|
});
|
|
});
|