maestro/src/bridge/user-folder-api.test.ts
2026-06-03 05:08:00 +00:00

1110 lines
44 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, writeFileSync, readFileSync, mkdirSync, rmSync, existsSync, readdirSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import Database from 'better-sqlite3';
import { createUserFolderApi } from './user-folder-api.js';
import { recorder } from '../engine/browser-recorder.js';
import AdmZip from 'adm-zip';
import { runMigrations } from '../db/migrate.js';
import { NotesRepository } from '../notes/notes-repository.js';
import { NotesService } from '../notes/notes-service.js';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeApp(userId: string, userFolderRoot: string): express.Application {
const tmpDb = new Database(':memory:');
runMigrations(tmpDb);
tmpDb.prepare(`INSERT OR IGNORE INTO users (id, email) VALUES (?, ?)`).run(userId, `${userId}@x.com`);
const repo = new NotesRepository(tmpDb);
const notesService = new NotesService({ db: tmpDb, repo, userFolderRoot, getUserOrgIds: () => ['team1'] });
const app = express();
// Inject a fake req.user
app.use((req, _res, next) => {
(req as any).user = { id: userId, role: 'user', orgIds: ['team1'] };
next();
});
app.use('/api/users/me', createUserFolderApi({ userFolderRoot, notesService }));
return app;
}
function makeUnauthApp(userFolderRoot: string): express.Application {
const app = express();
// No req.user set
app.use('/api/users/me', createUserFolderApi({ userFolderRoot }));
return app;
}
/** authActive=false, no req.user injection — synthetic 'local' user should be used */
function makeNoAuthModeApp(userFolderRoot: string): express.Application {
const app = express();
app.use('/api/users/me', createUserFolderApi({ userFolderRoot, authActive: false }));
return app;
}
function makePetZip(files: Record<string, Buffer | string>): Buffer {
const zip = new AdmZip();
for (const [name, content] of Object.entries(files)) {
zip.addFile(name, Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf-8'));
}
return zip.toBuffer();
}
// ---------------------------------------------------------------------------
// Setup / Teardown
// ---------------------------------------------------------------------------
describe('User Folder API', () => {
let tmpRoot: string;
let app: express.Application;
const USER_A = 'user-a';
const USER_B = 'user-b';
beforeEach(() => {
tmpRoot = mkdtempSync(join(tmpdir(), 'user-folder-api-test-'));
app = makeApp(USER_A, tmpRoot);
});
afterEach(() => {
rmSync(tmpRoot, { recursive: true, force: true });
});
// -------------------------------------------------------------------------
// GET /folder/list
// -------------------------------------------------------------------------
describe('GET /folder/list', () => {
it('returns files in the requested subdir', async () => {
// Write 2 files directly into the subdir
const scriptsDir = join(tmpRoot, USER_A, 'scripts');
mkdirSync(scriptsDir, { recursive: true });
writeFileSync(join(scriptsDir, 'hello.js'), 'console.log("hi")');
writeFileSync(join(scriptsDir, 'world.ts'), 'export {}');
const res = await request(app).get('/api/users/me/folder/list?subdir=scripts');
expect(res.status).toBe(200);
const names = (res.body.files as Array<{ name: string }>).map(f => f.name).sort();
expect(names).toEqual(['hello.js', 'world.ts']);
// Each file entry must have name, size, mtime
const file = (res.body.files as Array<{ name: string; size: number; mtime: string }>)[0]!;
expect(typeof file.size).toBe('number');
expect(typeof file.mtime).toBe('string');
});
it('returns 400 for invalid subdir', async () => {
const res = await request(app).get('/api/users/me/folder/list?subdir=invalid');
expect(res.status).toBe(400);
});
it('does not return hidden files (starting with .)', async () => {
const scriptsDir = join(tmpRoot, USER_A, 'scripts');
mkdirSync(scriptsDir, { recursive: true });
writeFileSync(join(scriptsDir, 'visible.js'), 'ok');
writeFileSync(join(scriptsDir, '.hidden'), 'secret');
const res = await request(app).get('/api/users/me/folder/list?subdir=scripts');
expect(res.status).toBe(200);
const names = (res.body.files as Array<{ name: string }>).map(f => f.name);
expect(names).toContain('visible.js');
expect(names).not.toContain('.hidden');
});
it('returns 401 when req.user is missing', async () => {
const unauthApp = makeUnauthApp(tmpRoot);
const res = await request(unauthApp).get('/api/users/me/folder/list?subdir=scripts');
expect(res.status).toBe(401);
});
it('GET list of trash subdir works (so users can see deleted files)', async () => {
// Create a trash dir with a file to simulate a previous delete
const trashDir = join(tmpRoot, USER_A, 'trash');
mkdirSync(trashDir, { recursive: true });
writeFileSync(join(trashDir, '20260101-000000-abcd-deleted.js'), 'old');
const res = await request(app).get('/api/users/me/folder/list?subdir=trash');
expect(res.status).toBe(200);
const names = (res.body.files as Array<{ name: string }>).map(f => f.name);
expect(names).toContain('20260101-000000-abcd-deleted.js');
});
});
describe('pets API', () => {
it('imports a pet zip, lists it, and serves its asset', async () => {
const zip = makePetZip({
'pet.json': JSON.stringify({ name: 'Lumi', description: 'small companion', spritesheet: 'spritesheet.webp' }),
'spritesheet.webp': Buffer.from('RIFFxxxxWEBPfake'),
});
const importRes = await request(app)
.post('/api/users/me/pets/import?filename=lumi.zip')
.set('Content-Type', 'application/zip')
.send(zip);
expect(importRes.status).toBe(200);
expect(importRes.body.pet.id).toBe('lumi');
expect(importRes.body.pet.name).toBe('Lumi');
const listRes = await request(app).get('/api/users/me/pets');
expect(listRes.status).toBe(200);
expect(listRes.body.pets).toHaveLength(1);
expect(listRes.body.pets[0].spriteFile).toBe('spritesheet.webp');
expect(listRes.body.settings.enabled).toBe(true);
const assetRes = await request(app).get('/api/users/me/pets/lumi/assets/spritesheet.webp');
expect(assetRes.status).toBe(200);
expect(assetRes.headers['content-type']).toMatch(/image\/webp/);
});
it('reads Codex Pets schema fields (displayName + spritesheetPath)', async () => {
const zip = makePetZip({
'pet.json': JSON.stringify({
id: 'bolt',
displayName: 'Bolt',
description: 'A cute compact robot companion.',
spritesheetPath: 'spritesheet.webp',
}),
'spritesheet.webp': Buffer.from('RIFFxxxxWEBPfake'),
});
const res = await request(app)
.post('/api/users/me/pets/import?filename=bolt.zip')
.set('Content-Type', 'application/zip')
.send(zip);
expect(res.status).toBe(200);
expect(res.body.pet.id).toBe('bolt');
expect(res.body.pet.name).toBe('Bolt');
expect(res.body.pet.spriteFile).toBe('spritesheet.webp');
});
it('reads optional gridCols/gridRows from manifest', async () => {
const zip = makePetZip({
'pet.json': JSON.stringify({
displayName: 'Grid',
spritesheetPath: 'spritesheet.webp',
gridCols: 8,
gridRows: 9,
}),
'spritesheet.webp': Buffer.from('RIFFxxxxWEBPfake'),
});
const res = await request(app)
.post('/api/users/me/pets/import?filename=grid.zip')
.set('Content-Type', 'application/zip')
.send(zip);
expect(res.status).toBe(200);
expect(res.body.pet.gridCols).toBe(8);
expect(res.body.pet.gridRows).toBe(9);
});
it('extracts frameWidth/frameHeight when both are valid integers', async () => {
const zip = makePetZip({
'pet.json': JSON.stringify({ name: 'Pixel', spritesheet: 'spritesheet.webp', frameWidth: 64, frameHeight: 48 }),
'spritesheet.webp': Buffer.from('RIFFxxxxWEBPfake'),
});
const res = await request(app)
.post('/api/users/me/pets/import?filename=pixel.zip')
.set('Content-Type', 'application/zip')
.send(zip);
expect(res.status).toBe(200);
expect(res.body.pet.frameWidth).toBe(64);
expect(res.body.pet.frameHeight).toBe(48);
});
it('reads frameWidth/frameHeight from nested spritesheet object', async () => {
const zip = makePetZip({
'pet.json': JSON.stringify({
name: 'Nested',
spritesheet: { file: 'spritesheet.webp', frameWidth: 32, frameHeight: 32 },
}),
'spritesheet.webp': Buffer.from('RIFFxxxxWEBPfake'),
});
const res = await request(app)
.post('/api/users/me/pets/import?filename=nested.zip')
.set('Content-Type', 'application/zip')
.send(zip);
expect(res.status).toBe(200);
expect(res.body.pet.frameWidth).toBe(32);
expect(res.body.pet.frameHeight).toBe(32);
});
it('returns null frame dimensions when only one of width/height is set', async () => {
const zip = makePetZip({
'pet.json': JSON.stringify({ name: 'Half', spritesheet: 'spritesheet.webp', frameWidth: 64 }),
'spritesheet.webp': Buffer.from('RIFFxxxxWEBPfake'),
});
const res = await request(app)
.post('/api/users/me/pets/import?filename=half.zip')
.set('Content-Type', 'application/zip')
.send(zip);
expect(res.status).toBe(200);
expect(res.body.pet.frameWidth).toBeNull();
expect(res.body.pet.frameHeight).toBeNull();
});
it('returns null frame dimensions when value is out of range', async () => {
const zip = makePetZip({
'pet.json': JSON.stringify({ name: 'Huge', spritesheet: 'spritesheet.webp', frameWidth: 99999, frameHeight: 64 }),
'spritesheet.webp': Buffer.from('RIFFxxxxWEBPfake'),
});
const res = await request(app)
.post('/api/users/me/pets/import?filename=huge.zip')
.set('Content-Type', 'application/zip')
.send(zip);
expect(res.status).toBe(200);
expect(res.body.pet.frameWidth).toBeNull();
expect(res.body.pet.frameHeight).toBeNull();
});
it('rejects a pet zip without pet.json', async () => {
const zip = makePetZip({ 'spritesheet.webp': Buffer.from('RIFFxxxxWEBPfake') });
const res = await request(app)
.post('/api/users/me/pets/import?filename=nope.zip')
.set('Content-Type', 'application/zip')
.send(zip);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/pet\.json/);
});
it('rejects hidden zip paths', async () => {
const zip = makePetZip({
'pet.json': JSON.stringify({ name: 'Bad' }),
'.hidden.png': 'x',
});
const res = await request(app)
.post('/api/users/me/pets/import?filename=bad.zip')
.set('Content-Type', 'application/zip')
.send(zip);
expect(res.status).toBe(400);
});
it('keeps pets isolated by user', async () => {
const zip = makePetZip({
'pet.json': JSON.stringify({ name: 'Private' }),
'spritesheet.png': Buffer.from([0x89, 0x50, 0x4e, 0x47]),
});
await request(app)
.post('/api/users/me/pets/import?filename=private.zip')
.set('Content-Type', 'application/zip')
.send(zip);
const appB = makeApp(USER_B, tmpRoot);
const res = await request(appB).get('/api/users/me/pets/private');
expect(res.status).toBe(404);
});
it('updates pet settings with validation', async () => {
const ok = await request(app)
.put('/api/users/me/pets/settings')
.send({ enabled: false, activePetId: null, size: 48, toolSparkEnabled: false });
expect(ok.status).toBe(200);
expect(ok.body.settings.enabled).toBe(false);
expect(ok.body.settings.size).toBe(48);
const bad = await request(app)
.put('/api/users/me/pets/settings')
.send({ size: 13 });
expect(bad.status).toBe(400);
});
it('accepts a valid workerPets mapping and defaults to an empty map', async () => {
const defaults = await request(app).get('/api/users/me/pets');
expect(defaults.body.settings.workerPets).toEqual({});
const ok = await request(app)
.put('/api/users/me/pets/settings')
.send({ workerPets: { gpu1: 'lumi', gpu2: 'mio' } });
expect(ok.status).toBe(200);
expect(ok.body.settings.workerPets).toEqual({ gpu1: 'lumi', gpu2: 'mio' });
});
it('rejects workerPets with invalid key or value', async () => {
const badKey = await request(app)
.put('/api/users/me/pets/settings')
.send({ workerPets: { 'spaces are bad': 'lumi' } });
expect(badKey.status).toBe(400);
const badValue = await request(app)
.put('/api/users/me/pets/settings')
.send({ workerPets: { gpu1: 'Not A Pet Id!' } });
expect(badValue.status).toBe(400);
});
it('treats empty / null workerPets values as removals', async () => {
await request(app)
.put('/api/users/me/pets/settings')
.send({ workerPets: { gpu1: 'lumi', gpu2: 'mio' } });
const cleared = await request(app)
.put('/api/users/me/pets/settings')
.send({ workerPets: { gpu1: '', gpu2: 'mio' } });
expect(cleared.status).toBe(200);
expect(cleared.body.settings.workerPets).toEqual({ gpu2: 'mio' });
});
it('clears workerPets entries when the mapped pet is deleted', async () => {
const zip = makePetZip({
'pet.json': JSON.stringify({ name: 'Removable', spritesheet: 'spritesheet.webp' }),
'spritesheet.webp': Buffer.from('RIFFxxxxWEBPfake'),
});
await request(app)
.post('/api/users/me/pets/import?filename=removable.zip')
.set('Content-Type', 'application/zip')
.send(zip);
await request(app)
.put('/api/users/me/pets/settings')
.send({ workerPets: { gpu1: 'removable', gpu2: 'removable' } });
const del = await request(app).delete('/api/users/me/pets/removable');
expect(del.status).toBe(200);
const after = await request(app).get('/api/users/me/pets');
expect(after.body.settings.workerPets).toEqual({});
});
});
// -------------------------------------------------------------------------
// GET /folder/file
// -------------------------------------------------------------------------
describe('GET /folder/file', () => {
it('returns file contents as text', async () => {
const scriptsDir = join(tmpRoot, USER_A, 'scripts');
mkdirSync(scriptsDir, { recursive: true });
writeFileSync(join(scriptsDir, 'test.js'), 'console.log("hello")');
const res = await request(app).get('/api/users/me/folder/file?subdir=scripts&path=test.js');
expect(res.status).toBe(200);
expect(res.text).toBe('console.log("hello")');
});
it('returns 400 for path traversal attempt', async () => {
const res = await request(app).get(
'/api/users/me/folder/file?subdir=scripts&path=../../etc/passwd',
);
expect(res.status).toBe(400);
});
it('returns 404 for a missing file', async () => {
const res = await request(app).get('/api/users/me/folder/file?subdir=scripts&path=nope.js');
expect(res.status).toBe(404);
});
it('returns 413 for a file larger than 1 MB', async () => {
const scriptsDir = join(tmpRoot, USER_A, 'scripts');
mkdirSync(scriptsDir, { recursive: true });
// Write a 1.1 MB file
const big = Buffer.alloc(1024 * 1024 + 100, 'x');
writeFileSync(join(scriptsDir, 'big.txt'), big);
const res = await request(app).get('/api/users/me/folder/file?subdir=scripts&path=big.txt');
expect(res.status).toBe(413);
});
it('returns 401 when req.user is missing', async () => {
const unauthApp = makeUnauthApp(tmpRoot);
const res = await request(unauthApp).get('/api/users/me/folder/file?subdir=scripts&path=x.js');
expect(res.status).toBe(401);
});
});
// -------------------------------------------------------------------------
// PUT /folder/file
// -------------------------------------------------------------------------
describe('PUT /folder/file', () => {
it('writes the file (verifiable via direct fs read)', async () => {
const content = 'export const x = 1;';
const res = await request(app)
.put('/api/users/me/folder/file?subdir=scripts&path=new.js')
.set('Content-Type', 'text/plain')
.send(content);
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(typeof res.body.size).toBe('number');
expect(typeof res.body.mtime).toBe('string');
const written = readFileSync(join(tmpRoot, USER_A, 'scripts', 'new.js'), 'utf-8');
expect(written).toBe(content);
});
it('is atomic: a follow-up GET sees the new content', async () => {
const content = 'const y = 42;';
await request(app)
.put('/api/users/me/folder/file?subdir=scripts&path=atomic.js')
.set('Content-Type', 'text/plain')
.send(content);
const res = await request(app).get('/api/users/me/folder/file?subdir=scripts&path=atomic.js');
expect(res.status).toBe(200);
expect(res.text).toBe(content);
});
it('returns 413 when body exceeds 1 MB', async () => {
const big = Buffer.alloc(1024 * 1024 + 100, 'a').toString();
const res = await request(app)
.put('/api/users/me/folder/file?subdir=scripts&path=big.js')
.set('Content-Type', 'text/plain')
.send(big);
expect(res.status).toBe(413);
expect(res.body.error).toMatch(/1 MB/);
});
it('PUT to trash subdir returns 400', async () => {
const res = await request(app)
.put('/api/users/me/folder/file?subdir=trash&path=sneaky.js')
.set('Content-Type', 'text/plain')
.send('evil');
expect(res.status).toBe(400);
});
it('returns 401 when req.user is missing', async () => {
const unauthApp = makeUnauthApp(tmpRoot);
const res = await request(unauthApp)
.put('/api/users/me/folder/file?subdir=scripts&path=x.js')
.set('Content-Type', 'text/plain')
.send('hi');
expect(res.status).toBe(401);
});
});
// -------------------------------------------------------------------------
// DELETE /folder/file
// -------------------------------------------------------------------------
describe('DELETE /folder/file', () => {
it('moves the file into trash/ with a timestamp prefix', async () => {
const scriptsDir = join(tmpRoot, USER_A, 'scripts');
mkdirSync(scriptsDir, { recursive: true });
writeFileSync(join(scriptsDir, 'to-delete.js'), 'bye');
const res = await request(app).delete(
'/api/users/me/folder/file?subdir=scripts&path=to-delete.js',
);
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(typeof res.body.trashedAs).toBe('string');
expect(res.body.trashedAs as string).toContain('to-delete.js');
// Original file must be gone
expect(existsSync(join(scriptsDir, 'to-delete.js'))).toBe(false);
// File must exist in trash/
const trashDir = join(tmpRoot, USER_A, 'trash');
const trashFiles = readdirSync(trashDir);
expect(trashFiles.some(f => f.endsWith('to-delete.js'))).toBe(true);
});
it('returns 401 when req.user is missing', async () => {
const unauthApp = makeUnauthApp(tmpRoot);
const res = await request(unauthApp).delete(
'/api/users/me/folder/file?subdir=scripts&path=x.js',
);
expect(res.status).toBe(401);
});
it('DELETE from trash subdir returns 400', async () => {
const res = await request(app).delete(
'/api/users/me/folder/file?subdir=trash&path=some-trashed-file.js',
);
expect(res.status).toBe(400);
});
it('returns 404 when DELETE targets a missing file', async () => {
const res = await request(app).delete(
'/api/users/me/folder/file?subdir=scripts&path=ghost.js',
);
expect(res.status).toBe(404);
});
it('handles two same-name deletes in quick succession without data loss', async () => {
const scriptsDir = join(tmpRoot, USER_A, 'scripts');
mkdirSync(scriptsDir, { recursive: true });
// First file
writeFileSync(join(scriptsDir, 'dup.js'), 'first');
const res1 = await request(app).delete(
'/api/users/me/folder/file?subdir=scripts&path=dup.js',
);
expect(res1.status).toBe(200);
const trashedAs1 = res1.body.trashedAs as string;
// Second file with same name
writeFileSync(join(scriptsDir, 'dup.js'), 'second');
const res2 = await request(app).delete(
'/api/users/me/folder/file?subdir=scripts&path=dup.js',
);
expect(res2.status).toBe(200);
const trashedAs2 = res2.body.trashedAs as string;
// Both trash names must be distinct
expect(trashedAs1).not.toBe(trashedAs2);
// Both files must exist in trash
const trashDir = join(tmpRoot, USER_A, 'trash');
expect(existsSync(join(trashDir, trashedAs1))).toBe(true);
expect(existsSync(join(trashDir, trashedAs2))).toBe(true);
});
});
// -------------------------------------------------------------------------
// Cross-user isolation
// -------------------------------------------------------------------------
describe('Cross-user isolation', () => {
it('user A cannot read files belonging to user B', async () => {
// Write a file under user B's folder directly
const bScriptsDir = join(tmpRoot, USER_B, 'scripts');
mkdirSync(bScriptsDir, { recursive: true });
writeFileSync(join(bScriptsDir, 'secret.js'), 'b-secret');
// app is authed as USER_A; try to reach USER_B's file via traversal
const res = await request(app).get(
`/api/users/me/folder/file?subdir=scripts&path=../../${USER_B}/scripts/secret.js`,
);
// Must be 400 (traversal blocked) — NOT 200
expect(res.status).toBe(400);
});
it('user A list only sees their own files, not user B files', async () => {
// Create scripts for both users
const aDir = join(tmpRoot, USER_A, 'scripts');
const bDir = join(tmpRoot, USER_B, 'scripts');
mkdirSync(aDir, { recursive: true });
mkdirSync(bDir, { recursive: true });
writeFileSync(join(aDir, 'a-only.js'), 'a');
writeFileSync(join(bDir, 'b-only.js'), 'b');
const res = await request(app).get('/api/users/me/folder/list?subdir=scripts');
expect(res.status).toBe(200);
const names = (res.body.files as Array<{ name: string }>).map(f => f.name);
expect(names).toContain('a-only.js');
expect(names).not.toContain('b-only.js');
});
});
// -------------------------------------------------------------------------
// POST /browser-macros/compile
// -------------------------------------------------------------------------
// Minimal valid recording fixture
const MINIMAL_RECORDING = JSON.stringify({
recordTo: 'test-rec',
capturedAt: '2026-01-01T00:00:00.000Z',
actions: [
{ type: 'goto', url: 'https://example.com', ts: '2026-01-01T00:00:00.000Z' },
],
});
describe('POST /browser-macros/compile', () => {
it('compiles a recording and writes a script with frontmatter + body', async () => {
const recDir = join(tmpRoot, USER_A, 'recordings');
mkdirSync(recDir, { recursive: true });
writeFileSync(join(recDir, 'my-rec.json'), MINIMAL_RECORDING);
const res = await request(app)
.post('/api/users/me/browser-macros/compile')
.send({ recordingName: 'my-rec', scriptName: 'my-script', description: 'A test script' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(res.body.scriptName).toBe('my-script.js');
expect(typeof res.body.source).toBe('string');
expect(typeof res.body.size).toBe('number');
// File must exist on disk
const scriptPath = join(tmpRoot, USER_A, 'browser-macros', 'my-script.js');
expect(existsSync(scriptPath)).toBe(true);
const written = readFileSync(scriptPath, 'utf-8');
// Should have frontmatter (gray-matter header)
expect(written).toContain('---');
// Should have the goto call in the body
expect(written).toContain('page.goto');
});
it('returns 404 when recording is missing', async () => {
const res = await request(app)
.post('/api/users/me/browser-macros/compile')
.send({ recordingName: 'no-such-rec', scriptName: 'out', description: 'X' });
expect(res.status).toBe(404);
});
it('returns 400 when recording file is malformed JSON', async () => {
const recDir = join(tmpRoot, USER_A, 'recordings');
mkdirSync(recDir, { recursive: true });
writeFileSync(join(recDir, 'bad.json'), 'not json {{');
const res = await request(app)
.post('/api/users/me/browser-macros/compile')
.send({ recordingName: 'bad', scriptName: 'out', description: 'X' });
expect(res.status).toBe(400);
});
it('returns 400 when recording is valid JSON but missing actions', async () => {
const recDir = join(tmpRoot, USER_A, 'recordings');
mkdirSync(recDir, { recursive: true });
writeFileSync(join(recDir, 'no-actions.json'), JSON.stringify({ recordTo: 'x', capturedAt: 'y' }));
const res = await request(app)
.post('/api/users/me/browser-macros/compile')
.send({ recordingName: 'no-actions', scriptName: 'out', description: 'X' });
expect(res.status).toBe(400);
});
it('returns 409 when script already exists and no ?overwrite=true', async () => {
const recDir = join(tmpRoot, USER_A, 'recordings');
const macrosDir = join(tmpRoot, USER_A, 'browser-macros');
mkdirSync(recDir, { recursive: true });
mkdirSync(macrosDir, { recursive: true });
writeFileSync(join(recDir, 'my-rec2.json'), MINIMAL_RECORDING);
writeFileSync(join(macrosDir, 'existing.js'), '// already here');
const res = await request(app)
.post('/api/users/me/browser-macros/compile')
.send({ recordingName: 'my-rec2', scriptName: 'existing', description: 'X' });
expect(res.status).toBe(409);
expect(res.body.error).toMatch(/overwrite=true/);
});
it('overwrites script when ?overwrite=true is passed', async () => {
const recDir = join(tmpRoot, USER_A, 'recordings');
const macrosDir = join(tmpRoot, USER_A, 'browser-macros');
mkdirSync(recDir, { recursive: true });
mkdirSync(macrosDir, { recursive: true });
writeFileSync(join(recDir, 'my-rec3.json'), MINIMAL_RECORDING);
writeFileSync(join(macrosDir, 'will-replace.js'), '// old content');
const res = await request(app)
.post('/api/users/me/browser-macros/compile?overwrite=true')
.send({ recordingName: 'my-rec3', scriptName: 'will-replace', description: 'replaced' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
const written = readFileSync(join(macrosDir, 'will-replace.js'), 'utf-8');
expect(written).not.toBe('// old content');
});
it('applies paramHints: param name appears in body, param appears in frontmatter', async () => {
const recDir = join(tmpRoot, USER_A, 'recordings');
mkdirSync(recDir, { recursive: true });
const recWithFill = JSON.stringify({
recordTo: 'fill-rec',
capturedAt: '2026-01-01T00:00:00.000Z',
actions: [
{ type: 'goto', url: 'https://example.com', ts: '2026-01-01T00:00:01.000Z' },
{ type: 'fill', selector: '#email', value: 'user@example.com', ts: '2026-01-01T00:00:02.000Z' },
],
});
writeFileSync(join(recDir, 'fill-rec.json'), recWithFill);
const res = await request(app)
.post('/api/users/me/browser-macros/compile')
.send({
recordingName: 'fill-rec',
scriptName: 'fill-script',
description: 'Fill example',
paramHints: [{ name: 'email', valueToReplace: 'user@example.com', type: 'string' }],
});
expect(res.status).toBe(200);
const { source } = res.body as { source: string };
// Body should use params.email instead of literal string
expect(source).toContain('params.email');
// Frontmatter should declare the param
expect(source).toContain('email');
});
});
// -------------------------------------------------------------------------
// POST /scripts/:name/run
// -------------------------------------------------------------------------
// A minimal script that doesn't require Playwright — uses the raw module.exports form
// so the child process can execute it without a real browser.
const SIMPLE_SCRIPT_BODY = `module.exports = async function main() { return 42; };`;
describe('POST /scripts/:name/run', () => {
it('runs a script that returns 42', async () => {
const scriptDir = join(tmpRoot, USER_A, 'scripts');
mkdirSync(scriptDir, { recursive: true });
writeFileSync(join(scriptDir, 'simple.js'), SIMPLE_SCRIPT_BODY);
const res = await request(app)
.post('/api/users/me/scripts/simple/run')
.send({});
expect(res.status).toBe(200);
expect(res.body.result).toBe(42);
expect(Array.isArray(res.body.logs)).toBe(true);
expect(typeof res.body.durationMs).toBe('number');
});
it('returns 404 when script is missing', async () => {
const res = await request(app)
.post('/api/users/me/scripts/ghost/run')
.send({});
expect(res.status).toBe(404);
});
it('returns 500 with "param" in error when params are bad', async () => {
const scriptDir = join(tmpRoot, USER_A, 'scripts');
mkdirSync(scriptDir, { recursive: true });
// Script with a declared param of type string
const scriptWithParam = `\
---
description: Needs a string param
params:
- name: username
type: string
---
module.exports = async function main({ params }) { return params.username; };
`;
writeFileSync(join(scriptDir, 'needs-param.js'), scriptWithParam);
const res = await request(app)
.post('/api/users/me/scripts/needs-param/run')
.send({ params: { username: 12345 } }); // wrong type: number instead of string
expect(res.status).toBe(500);
expect(res.body.error.toLowerCase()).toContain('param');
});
it('clamps timeoutMs to 5 minutes max', async () => {
const scriptDir = join(tmpRoot, USER_A, 'scripts');
mkdirSync(scriptDir, { recursive: true });
writeFileSync(join(scriptDir, 'fast.js'), SIMPLE_SCRIPT_BODY);
// POST /run with extreme timeoutMs value
const res = await request(app)
.post('/api/users/me/scripts/fast/run')
.send({ timeoutMs: 999_999_999 });
// If clamping is working, the request succeeds (doesn't exceed actual limit).
// No way to verify the cap directly from the API response, so we just confirm
// the run succeeds without error (which it wouldn't if the cap wasn't applied).
expect(res.status).toBe(200);
expect(res.body.result).toBe(42);
});
});
// -------------------------------------------------------------------------
// POST /recordings/flush
// -------------------------------------------------------------------------
describe('POST /recordings/flush', () => {
afterEach(() => {
// Ensure any leftover buffers are cancelled after each test
recorder.cancel('flush-test-task');
recorder.cancel('flush-test-empty');
});
it('returns 400 without taskId', async () => {
const res = await request(app).post('/api/users/me/recordings/flush');
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/taskId/);
});
it('returns 404 when no buffer exists for taskId', async () => {
const res = await request(app).post('/api/users/me/recordings/flush?taskId=no-such-task');
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/no active recording/);
});
it('flushes the buffer and returns recordingName', async () => {
const taskId = 'flush-test-task';
// Enable recording and record one action
recorder.enable(taskId, 'test-rec');
recorder.record(taskId, { type: 'goto', url: 'https://x.com', frameChain: [] });
const res = await request(app)
.post(`/api/users/me/recordings/flush?taskId=${taskId}`);
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(res.body.recordingName).toBe('test-rec');
expect(typeof res.body.path).toBe('string');
expect(res.body.path).toContain('test-rec.json');
// Assert the file was written to disk
const recFile = join(tmpRoot, USER_A, 'recordings', 'test-rec.json');
expect(existsSync(recFile)).toBe(true);
const written = JSON.parse(readFileSync(recFile, 'utf-8'));
expect(written.recordTo).toBe('test-rec');
expect(Array.isArray(written.actions)).toBe(true);
expect(written.actions[0].type).toBe('goto');
});
it('returns 404 when buffer is enabled but empty', async () => {
const taskId = 'flush-test-empty';
// Enable but don't record anything — flush returns null for empty buffer
recorder.enable(taskId, 'empty-rec');
const res = await request(app)
.post(`/api/users/me/recordings/flush?taskId=${taskId}`);
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/no active recording/);
});
});
// -------------------------------------------------------------------------
// GET /browser-macros/:name/diff | POST /browser-macros/:name/accept | POST /browser-macros/:name/reject
// -------------------------------------------------------------------------
describe('GET /browser-macros/:name/diff', () => {
it('returns current + candidate contents when both exist', async () => {
const macrosDir = join(tmpRoot, USER_A, 'browser-macros');
mkdirSync(macrosDir, { recursive: true });
writeFileSync(join(macrosDir, 'myscript.js'), '// original');
writeFileSync(join(macrosDir, 'myscript.next.js'), '// patched');
const res = await request(app).get('/api/users/me/browser-macros/myscript/diff');
expect(res.status).toBe(200);
expect(res.body.current).toBe('// original');
expect(res.body.candidate).toBe('// patched');
expect(typeof res.body.candidateMtime).toBe('string');
});
it('returns current: null when only .next.js exists (orphaned candidate)', async () => {
const macrosDir = join(tmpRoot, USER_A, 'browser-macros');
mkdirSync(macrosDir, { recursive: true });
writeFileSync(join(macrosDir, 'orphan.next.js'), '// orphan patch');
const res = await request(app).get('/api/users/me/browser-macros/orphan/diff');
expect(res.status).toBe(200);
expect(res.body.current).toBeNull();
expect(res.body.candidate).toBe('// orphan patch');
expect(typeof res.body.candidateMtime).toBe('string');
});
it('returns 404 when .next.js is absent', async () => {
const macrosDir = join(tmpRoot, USER_A, 'browser-macros');
mkdirSync(macrosDir, { recursive: true });
writeFileSync(join(macrosDir, 'existing.js'), '// only original');
const res = await request(app).get('/api/users/me/browser-macros/existing/diff');
expect(res.status).toBe(404);
});
it('accepts name with .js suffix (normalizes it)', async () => {
const macrosDir = join(tmpRoot, USER_A, 'browser-macros');
mkdirSync(macrosDir, { recursive: true });
writeFileSync(join(macrosDir, 'normalize.js'), '// orig');
writeFileSync(join(macrosDir, 'normalize.next.js'), '// next');
const res = await request(app).get('/api/users/me/browser-macros/normalize.js/diff');
expect(res.status).toBe(200);
expect(res.body.current).toBe('// orig');
expect(res.body.candidate).toBe('// next');
});
});
describe('POST /browser-macros/:name/accept', () => {
it('archives the original to trash and renames .next.js into place', async () => {
const macrosDir = join(tmpRoot, USER_A, 'browser-macros');
const trashDir = join(tmpRoot, USER_A, 'trash');
mkdirSync(macrosDir, { recursive: true });
mkdirSync(trashDir, { recursive: true });
writeFileSync(join(macrosDir, 'foo.js'), '// old version');
writeFileSync(join(macrosDir, 'foo.next.js'), '// new version');
const res = await request(app).post('/api/users/me/browser-macros/foo/accept');
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(res.body.accepted).toBe('foo.js');
expect(typeof res.body.archivedAs).toBe('string');
expect(res.body.archivedAs).toContain('foo.js');
// .next.js must be gone; .js must have the new content
expect(existsSync(join(macrosDir, 'foo.next.js'))).toBe(false);
expect(readFileSync(join(macrosDir, 'foo.js'), 'utf-8')).toBe('// new version');
// Old version must be in trash
expect(existsSync(join(trashDir, res.body.archivedAs))).toBe(true);
expect(readFileSync(join(trashDir, res.body.archivedAs), 'utf-8')).toBe('// old version');
});
it('returns 404 when .next.js is absent', async () => {
const macrosDir = join(tmpRoot, USER_A, 'browser-macros');
mkdirSync(macrosDir, { recursive: true });
const res = await request(app).post('/api/users/me/browser-macros/nopatch/accept');
expect(res.status).toBe(404);
});
it('works when no original .js exists (orphan candidate)', async () => {
const macrosDir = join(tmpRoot, USER_A, 'browser-macros');
mkdirSync(macrosDir, { recursive: true });
writeFileSync(join(macrosDir, 'new-script.next.js'), '// brand new');
const res = await request(app).post('/api/users/me/browser-macros/new-script/accept');
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(res.body.archivedAs).toBeNull();
// .next.js gone; .js has the content
expect(existsSync(join(macrosDir, 'new-script.next.js'))).toBe(false);
expect(readFileSync(join(macrosDir, 'new-script.js'), 'utf-8')).toBe('// brand new');
});
});
describe('POST /browser-macros/:name/reject', () => {
it('moves .next.js to trash; original stays unchanged', async () => {
const macrosDir = join(tmpRoot, USER_A, 'browser-macros');
const trashDir = join(tmpRoot, USER_A, 'trash');
mkdirSync(macrosDir, { recursive: true });
mkdirSync(trashDir, { recursive: true });
writeFileSync(join(macrosDir, 'bar.js'), '// keep me');
writeFileSync(join(macrosDir, 'bar.next.js'), '// unwanted patch');
const res = await request(app).post('/api/users/me/browser-macros/bar/reject');
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(res.body.rejected).toBe('bar.next.js');
expect(typeof res.body.trashedAs).toBe('string');
expect(res.body.trashedAs).toContain('bar.next.js');
// .next.js must be gone; original must still have original content
expect(existsSync(join(macrosDir, 'bar.next.js'))).toBe(false);
expect(readFileSync(join(macrosDir, 'bar.js'), 'utf-8')).toBe('// keep me');
// Rejected candidate must be in trash
expect(existsSync(join(trashDir, res.body.trashedAs))).toBe(true);
});
it('returns 404 when .next.js is absent', async () => {
const macrosDir = join(tmpRoot, USER_A, 'browser-macros');
mkdirSync(macrosDir, { recursive: true });
const res = await request(app).post('/api/users/me/browser-macros/nopatch/reject');
expect(res.status).toBe(404);
});
});
// -------------------------------------------------------------------------
// POST /browser-macros/compile — paramHints validation
// -------------------------------------------------------------------------
describe('POST /browser-macros/compile — paramHints validation', () => {
it('returns 400 when paramHints is not an array', async () => {
const recDir = join(tmpRoot, USER_A, 'recordings');
mkdirSync(recDir, { recursive: true });
writeFileSync(join(recDir, 'rec.json'), MINIMAL_RECORDING);
const res = await request(app)
.post('/api/users/me/browser-macros/compile')
.send({
recordingName: 'rec',
scriptName: 'test',
description: 'Test',
paramHints: { foo: 'bar' }, // Object instead of array
});
expect(res.status).toBe(400);
expect(res.body.error).toContain('paramHints must be an array');
});
it('returns 400 when paramHints entry is missing name', async () => {
const recDir = join(tmpRoot, USER_A, 'recordings');
mkdirSync(recDir, { recursive: true });
writeFileSync(join(recDir, 'rec.json'), MINIMAL_RECORDING);
const res = await request(app)
.post('/api/users/me/browser-macros/compile')
.send({
recordingName: 'rec',
scriptName: 'test',
description: 'Test',
paramHints: [{ valueToReplace: 'x', type: 'string' }], // Missing name
});
expect(res.status).toBe(400);
expect(res.body.error).toContain('paramHints[0]');
expect(res.body.error).toContain('name');
});
it('returns 400 when paramHints entry has invalid type', async () => {
const recDir = join(tmpRoot, USER_A, 'recordings');
mkdirSync(recDir, { recursive: true });
writeFileSync(join(recDir, 'rec.json'), MINIMAL_RECORDING);
const res = await request(app)
.post('/api/users/me/browser-macros/compile')
.send({
recordingName: 'rec',
scriptName: 'test',
description: 'Test',
paramHints: [{ name: 'a', valueToReplace: 'x', type: 'date' }], // Invalid type: 'date'
});
expect(res.status).toBe(400);
expect(res.body.error).toContain('paramHints[0]');
expect(res.body.error).toContain('type');
});
});
// -------------------------------------------------------------------------
// Auth gate fallback (authActive flag)
// -------------------------------------------------------------------------
describe('auth gate fallback', () => {
it('returns 401 when authActive=true (default) and no user', async () => {
// makeUnauthApp uses the default (authActive not passed → defaults to true)
const unauthApp = makeUnauthApp(tmpRoot);
const res = await request(unauthApp).get('/api/users/me/folder/list?subdir=scripts');
expect(res.status).toBe(401);
expect(res.body.error).toMatch(/Unauthenticated/i);
});
it('falls back to synthetic local user when authActive=false', async () => {
const noAuthApp = makeNoAuthModeApp(tmpRoot);
// Pre-create the 'local' user scripts dir so list returns 200 rather than 500
const localScriptsDir = join(tmpRoot, 'local', 'scripts');
mkdirSync(localScriptsDir, { recursive: true });
const res = await request(noAuthApp).get('/api/users/me/folder/list?subdir=scripts');
expect(res.status).toBe(200);
expect(Array.isArray(res.body.files)).toBe(true);
});
});
// -------------------------------------------------------------------------
// PUT /folder/file with subdir=notes
// -------------------------------------------------------------------------
describe('PUT /folder/file with subdir=notes', () => {
it('writes note and creates DB index row via notes-service', async () => {
const noteContent = `---\ntitle: Test\nvisibility: public\n---\nbody content`;
const res = await request(app)
.put('/api/users/me/folder/file?subdir=notes&path=cve/foo.md')
.set('Content-Type', 'text/plain')
.send(noteContent);
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(res.body.indexed).toBe(true);
const filePath = join(tmpRoot, USER_A, 'notes', 'cve', 'foo.md');
expect(existsSync(filePath)).toBe(true);
});
it('rejects PUT with depth-1 path (no folder)', async () => {
const res = await request(app)
.put('/api/users/me/folder/file?subdir=notes&path=foo.md')
.set('Content-Type', 'text/plain')
.send('body');
expect(res.status).toBe(400);
});
it('rejects PUT with depth-3 path', async () => {
const res = await request(app)
.put('/api/users/me/folder/file?subdir=notes&path=a/b/c.md')
.set('Content-Type', 'text/plain')
.send('body');
expect(res.status).toBe(400);
});
});
});