sync: update from private repo (6fcb0d0)
This commit is contained in:
+556
-13
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { mkdtempSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
||||
import { mkdtempSync, writeFileSync, mkdirSync, existsSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { mountPiecesApi } from './pieces-api.js';
|
||||
@@ -295,13 +295,16 @@ describe('Pieces API (no auth — legacy behavior)', () => {
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it('DELETE /api/pieces/:name deletes piece', async () => {
|
||||
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: [], rules: [] }],
|
||||
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(200);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toMatch(/cannot delete a built-in/i);
|
||||
});
|
||||
|
||||
it('DELETE /api/pieces/general is forbidden', async () => {
|
||||
@@ -371,16 +374,21 @@ describe('Pieces API (auth-aware: per-user custom + write authz)', () => {
|
||||
expect(byName['bob-tool']).toBeUndefined();
|
||||
});
|
||||
|
||||
it("GET /api/pieces — user-custom shadows built-in with the same name", async () => {
|
||||
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 general = res.body.pieces.find((p: any) => p.name === 'general');
|
||||
expect(general.source).toBe('user-custom');
|
||||
expect(general.description).toBe('alice override');
|
||||
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 () => {
|
||||
@@ -399,7 +407,9 @@ describe('Pieces API (auth-aware: per-user custom + write authz)', () => {
|
||||
expect(existsSync(join(piecesDir, 'alice-custom.yaml'))).toBe(false);
|
||||
});
|
||||
|
||||
it('POST /api/pieces by admin writes to piecesDir (legacy behavior)', async () => {
|
||||
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({
|
||||
@@ -410,7 +420,9 @@ describe('Pieces API (auth-aware: per-user custom + write authz)', () => {
|
||||
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, 'admin-piece.yaml'))).toBe(true);
|
||||
// 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 () => {
|
||||
@@ -479,10 +491,11 @@ describe('Pieces API (auth-aware: per-user custom + write authz)', () => {
|
||||
expect(existsSync(join(userPiecesRootDir, 'alice', 'pieces', 'goner.yaml'))).toBe(false);
|
||||
});
|
||||
|
||||
it('admin can edit and delete built-in pieces', async () => {
|
||||
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',
|
||||
@@ -492,8 +505,538 @@ describe('Pieces API (auth-aware: per-user custom + write authz)', () => {
|
||||
});
|
||||
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(200);
|
||||
expect(existsSync(join(piecesDir, 'admin-target.yaml'))).toBe(false);
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user