1110 lines
44 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|