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): 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); }); }); });