This commit is contained in:
@@ -0,0 +1,616 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readdirSync, existsSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import AdmZip from 'adm-zip';
|
||||
import {
|
||||
DEFAULT_PET_SETTINGS,
|
||||
PetConflictError,
|
||||
PetValidationError,
|
||||
slugifyPetId,
|
||||
readPetSettings,
|
||||
writePetSettings,
|
||||
listPets,
|
||||
getPet,
|
||||
importPetZip,
|
||||
deletePet,
|
||||
resolvePetAsset,
|
||||
} from './pets.js';
|
||||
import { ensureUserFolder, userRoot } from './paths.js';
|
||||
|
||||
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let root: string;
|
||||
const USER = 'test-user';
|
||||
|
||||
function petsDir(): string {
|
||||
return join(userRoot(root, USER), 'pets');
|
||||
}
|
||||
|
||||
function settingsFile(): string {
|
||||
return join(userRoot(root, USER), 'pet-settings.json');
|
||||
}
|
||||
|
||||
function trashDir(): string {
|
||||
return join(userRoot(root, USER), 'trash');
|
||||
}
|
||||
|
||||
/** Create a pet directory on disk with a manifest and optional extra files. */
|
||||
function makePetDir(
|
||||
petId: string,
|
||||
manifest: Record<string, unknown>,
|
||||
files: Record<string, Buffer | string> = {},
|
||||
): void {
|
||||
const dir = join(petsDir(), petId);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, 'pet.json'), JSON.stringify(manifest));
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
writeFileSync(join(dir, name), content);
|
||||
}
|
||||
}
|
||||
|
||||
/** Build an in-memory zip from a name → content map. */
|
||||
function makeZip(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));
|
||||
}
|
||||
return zip.toBuffer();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'pets-test-'));
|
||||
ensureUserFolder(root, USER);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── slugifyPetId ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('slugifyPetId', () => {
|
||||
it('lowercases and replaces invalid characters with hyphens', () => {
|
||||
expect(slugifyPetId('My Cool Pet!')).toBe('my-cool-pet');
|
||||
});
|
||||
|
||||
it('strips a trailing file extension', () => {
|
||||
expect(slugifyPetId('Sprite Cat.zip')).toBe('sprite-cat');
|
||||
});
|
||||
|
||||
it('keeps already-valid ids unchanged', () => {
|
||||
expect(slugifyPetId('cat_01-x')).toBe('cat_01-x');
|
||||
});
|
||||
|
||||
it('trims leading/trailing hyphens produced by replacement', () => {
|
||||
expect(slugifyPetId(' ---hello--- ')).toBe('hello');
|
||||
});
|
||||
|
||||
it('truncates to 64 characters', () => {
|
||||
const out = slugifyPetId('a'.repeat(100));
|
||||
expect(out).toBe('a'.repeat(64));
|
||||
});
|
||||
|
||||
it('falls back to a generated id for empty input', () => {
|
||||
expect(slugifyPetId('')).toMatch(/^pet-[a-z0-9]+$/);
|
||||
expect(slugifyPetId(null)).toMatch(/^pet-[a-z0-9]+$/);
|
||||
expect(slugifyPetId(undefined)).toMatch(/^pet-[a-z0-9]+$/);
|
||||
});
|
||||
|
||||
it('falls back when input reduces to nothing', () => {
|
||||
expect(slugifyPetId('!!!')).toMatch(/^pet-[a-z0-9]+$/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── readPetSettings / writePetSettings ────────────────────────────────────────
|
||||
|
||||
describe('readPetSettings', () => {
|
||||
it('returns defaults when the settings file is missing', () => {
|
||||
const s = readPetSettings(root, USER);
|
||||
expect(s).toEqual(DEFAULT_PET_SETTINGS);
|
||||
// and it must be a copy, not the shared default object
|
||||
expect(s).not.toBe(DEFAULT_PET_SETTINGS);
|
||||
});
|
||||
|
||||
it('reads back persisted settings', () => {
|
||||
writePetSettings(root, USER, { enabled: false, size: 48, activePetId: 'cat' });
|
||||
const s = readPetSettings(root, USER);
|
||||
expect(s.enabled).toBe(false);
|
||||
expect(s.size).toBe(48);
|
||||
expect(s.activePetId).toBe('cat');
|
||||
});
|
||||
|
||||
it('quarantines a corrupted settings file to trash and returns defaults', () => {
|
||||
writeFileSync(settingsFile(), 'not json at all{');
|
||||
const s = readPetSettings(root, USER);
|
||||
expect(s).toEqual(DEFAULT_PET_SETTINGS);
|
||||
expect(existsSync(settingsFile())).toBe(false);
|
||||
const trashed = readdirSync(trashDir());
|
||||
expect(trashed.some(f => f.endsWith('-pet-settings.json'))).toBe(true);
|
||||
});
|
||||
|
||||
it('quarantines a settings file with invalid values and returns defaults', () => {
|
||||
writeFileSync(settingsFile(), JSON.stringify({ size: 999 }));
|
||||
const s = readPetSettings(root, USER);
|
||||
expect(s).toEqual(DEFAULT_PET_SETTINGS);
|
||||
expect(existsSync(settingsFile())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('writePetSettings', () => {
|
||||
it('merges a patch over the previous settings', () => {
|
||||
writePetSettings(root, USER, { enabled: false });
|
||||
const s = writePetSettings(root, USER, { size: 32 });
|
||||
expect(s.enabled).toBe(false); // preserved from earlier write
|
||||
expect(s.size).toBe(32);
|
||||
expect(s.position).toBe('bottom-right');
|
||||
});
|
||||
|
||||
it('persists valid JSON to disk', () => {
|
||||
writePetSettings(root, USER, { sound: true });
|
||||
const onDisk = JSON.parse(readFileSync(settingsFile(), 'utf-8'));
|
||||
expect(onDisk.sound).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a non-object patch', () => {
|
||||
expect(() => writePetSettings(root, USER, null)).toThrow(PetValidationError);
|
||||
expect(() => writePetSettings(root, USER, [])).toThrow(PetValidationError);
|
||||
expect(() => writePetSettings(root, USER, 'x')).toThrow(PetValidationError);
|
||||
});
|
||||
|
||||
it('rejects non-boolean values for boolean fields', () => {
|
||||
expect(() => writePetSettings(root, USER, { enabled: 1 })).toThrow(/enabled must be boolean/);
|
||||
expect(() => writePetSettings(root, USER, { sound: 'yes' })).toThrow(/sound must be boolean/);
|
||||
expect(() => writePetSettings(root, USER, { reducedMotion: 0 })).toThrow(/reducedMotion must be boolean/);
|
||||
expect(() => writePetSettings(root, USER, { toolSparkEnabled: 'on' })).toThrow(/toolSparkEnabled must be boolean/);
|
||||
});
|
||||
|
||||
it('rejects an invalid size', () => {
|
||||
expect(() => writePetSettings(root, USER, { size: 100 })).toThrow(/size must be one of/);
|
||||
expect(() => writePetSettings(root, USER, { size: '64' })).toThrow(/size must be one of/);
|
||||
});
|
||||
|
||||
it('accepts each allowed size', () => {
|
||||
for (const size of [32, 48, 64, 80] as const) {
|
||||
expect(writePetSettings(root, USER, { size }).size).toBe(size);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects any position other than bottom-right', () => {
|
||||
expect(() => writePetSettings(root, USER, { position: 'top-left' })).toThrow(/position must be bottom-right/);
|
||||
expect(writePetSettings(root, USER, { position: 'bottom-right' }).position).toBe('bottom-right');
|
||||
});
|
||||
|
||||
it('rejects an invalid activePetId', () => {
|
||||
expect(() => writePetSettings(root, USER, { activePetId: 'UPPER' })).toThrow(/activePetId is invalid/);
|
||||
expect(() => writePetSettings(root, USER, { activePetId: 42 })).toThrow(/activePetId must be string or null/);
|
||||
});
|
||||
|
||||
it('normalizes empty-string activePetId to null', () => {
|
||||
writePetSettings(root, USER, { activePetId: 'cat' });
|
||||
const s = writePetSettings(root, USER, { activePetId: '' });
|
||||
expect(s.activePetId).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts null activePetId', () => {
|
||||
expect(writePetSettings(root, USER, { activePetId: null }).activePetId).toBeNull();
|
||||
});
|
||||
|
||||
it('validates workerPets keys and values', () => {
|
||||
expect(() => writePetSettings(root, USER, { workerPets: { 'bad key!': 'cat' } }))
|
||||
.toThrow(/workerPets key is invalid/);
|
||||
expect(() => writePetSettings(root, USER, { workerPets: { worker1: 'BAD ID' } }))
|
||||
.toThrow(/must be a valid pet id/);
|
||||
expect(() => writePetSettings(root, USER, { workerPets: [] }))
|
||||
.toThrow(/workerPets must be an object/);
|
||||
expect(() => writePetSettings(root, USER, { workerPets: null }))
|
||||
.toThrow(/workerPets must be an object/);
|
||||
});
|
||||
|
||||
it('drops workerPets entries with empty or null values (explicit removal)', () => {
|
||||
const s = writePetSettings(root, USER, {
|
||||
workerPets: { keep: 'cat', dropEmpty: '', dropNull: null },
|
||||
});
|
||||
expect(s.workerPets).toEqual({ keep: 'cat' });
|
||||
});
|
||||
|
||||
it('replaces (not merges) the whole workerPets map', () => {
|
||||
writePetSettings(root, USER, { workerPets: { a: 'cat' } });
|
||||
const s = writePetSettings(root, USER, { workerPets: { b: 'dog' } });
|
||||
expect(s.workerPets).toEqual({ b: 'dog' });
|
||||
});
|
||||
|
||||
it('rejects workerPets with too many entries', () => {
|
||||
const wp: Record<string, string> = {};
|
||||
for (let i = 0; i < 65; i++) wp[`worker-${i}`] = 'cat';
|
||||
expect(() => writePetSettings(root, USER, { workerPets: wp }))
|
||||
.toThrow(/too many entries/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── listPets / getPet ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('listPets', () => {
|
||||
it('returns an empty array when there are no pets', () => {
|
||||
expect(listPets(root, USER)).toEqual([]);
|
||||
});
|
||||
|
||||
it('lists pets sorted by display name and omits the manifest', () => {
|
||||
makePetDir('zebra', { name: 'Aardvark' });
|
||||
makePetDir('cat', { name: 'Whiskers' });
|
||||
const pets = listPets(root, USER);
|
||||
expect(pets.map(p => p.id)).toEqual(['zebra', 'cat']); // sorted by name, not id
|
||||
expect(pets.map(p => p.name)).toEqual(['Aardvark', 'Whiskers']);
|
||||
expect((pets[0] as Record<string, unknown>)['manifest']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips directories without a pet.json and ones with invalid ids', () => {
|
||||
makePetDir('valid', { name: 'Valid' });
|
||||
mkdirSync(join(petsDir(), 'no-manifest'), { recursive: true });
|
||||
mkdirSync(join(petsDir(), 'Invalid ID'), { recursive: true });
|
||||
const pets = listPets(root, USER);
|
||||
expect(pets.map(p => p.id)).toEqual(['valid']);
|
||||
});
|
||||
|
||||
it('skips plain files in the pets directory', () => {
|
||||
writeFileSync(join(petsDir(), 'stray.txt'), 'x');
|
||||
expect(listPets(root, USER)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPet', () => {
|
||||
it('returns null for an invalid pet id', () => {
|
||||
expect(getPet(root, USER, '../escape')).toBeNull();
|
||||
expect(getPet(root, USER, 'UPPER')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a missing pet', () => {
|
||||
expect(getPet(root, USER, 'nope')).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers displayName over name over id', () => {
|
||||
makePetDir('p1', { displayName: 'Display', name: 'Plain' });
|
||||
makePetDir('p2', { name: 'Plain' });
|
||||
makePetDir('p3', {});
|
||||
expect(getPet(root, USER, 'p1')!.name).toBe('Display');
|
||||
expect(getPet(root, USER, 'p2')!.name).toBe('Plain');
|
||||
expect(getPet(root, USER, 'p3')!.name).toBe('p3');
|
||||
});
|
||||
|
||||
it('picks sprite and preview files only when they exist on disk', () => {
|
||||
makePetDir(
|
||||
'p1',
|
||||
{ spritesheet: 'sheet.png', preview: 'thumb.png' },
|
||||
{ 'sheet.png': Buffer.from('png') },
|
||||
);
|
||||
const pet = getPet(root, USER, 'p1')!;
|
||||
expect(pet.spriteFile).toBe('sheet.png');
|
||||
expect(pet.previewFile).toBeNull(); // thumb.png referenced but absent
|
||||
});
|
||||
|
||||
it('falls back to conventional sprite/preview filenames', () => {
|
||||
makePetDir('p1', {}, {
|
||||
'spritesheet.webp': Buffer.from('w'),
|
||||
'preview.png': Buffer.from('p'),
|
||||
});
|
||||
const pet = getPet(root, USER, 'p1')!;
|
||||
expect(pet.spriteFile).toBe('spritesheet.webp');
|
||||
expect(pet.previewFile).toBe('preview.png');
|
||||
});
|
||||
|
||||
it('resolves sprite from a nested manifest object with a file key', () => {
|
||||
makePetDir(
|
||||
'p1',
|
||||
{ spritesheet: { file: 'art.png', frameWidth: 32, frameHeight: 32 } },
|
||||
{ 'art.png': Buffer.from('x') },
|
||||
);
|
||||
const pet = getPet(root, USER, 'p1')!;
|
||||
expect(pet.spriteFile).toBe('art.png');
|
||||
expect(pet.frameWidth).toBe(32);
|
||||
expect(pet.frameHeight).toBe(32);
|
||||
});
|
||||
|
||||
it('reads frame dimensions and grid from top-level manifest keys', () => {
|
||||
makePetDir('p1', { frameWidth: 64, frameHeight: 48, gridCols: 4, gridRows: 2 });
|
||||
const pet = getPet(root, USER, 'p1')!;
|
||||
expect(pet.frameWidth).toBe(64);
|
||||
expect(pet.frameHeight).toBe(48);
|
||||
expect(pet.gridCols).toBe(4);
|
||||
expect(pet.gridRows).toBe(2);
|
||||
});
|
||||
|
||||
it('supports snake_case frame keys', () => {
|
||||
makePetDir('p1', { frame_width: 16, frame_height: 24 });
|
||||
const pet = getPet(root, USER, 'p1')!;
|
||||
expect(pet.frameWidth).toBe(16);
|
||||
expect(pet.frameHeight).toBe(24);
|
||||
});
|
||||
|
||||
it('nulls both frame dimensions when only one is given', () => {
|
||||
makePetDir('p1', { frameWidth: 64 });
|
||||
const pet = getPet(root, USER, 'p1')!;
|
||||
expect(pet.frameWidth).toBeNull();
|
||||
expect(pet.frameHeight).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores out-of-range or non-integer dimensions', () => {
|
||||
makePetDir('p1', { frameWidth: 0, frameHeight: 5000, gridCols: 1.5, gridRows: -1 });
|
||||
const pet = getPet(root, USER, 'p1')!;
|
||||
expect(pet.frameWidth).toBeNull();
|
||||
expect(pet.gridCols).toBeNull();
|
||||
expect(pet.gridRows).toBeNull();
|
||||
});
|
||||
|
||||
it('exposes the full manifest and an ISO updatedAt', () => {
|
||||
makePetDir('p1', { name: 'X', custom: true });
|
||||
const pet = getPet(root, USER, 'p1')!;
|
||||
expect(pet.manifest).toEqual({ name: 'X', custom: true });
|
||||
expect(() => new Date(pet.updatedAt).toISOString()).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws PetValidationError for a corrupt manifest', () => {
|
||||
const dir = join(petsDir(), 'bad');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, 'pet.json'), '{broken');
|
||||
expect(() => getPet(root, USER, 'bad')).toThrow(PetValidationError);
|
||||
});
|
||||
|
||||
it('throws PetValidationError for an array manifest', () => {
|
||||
const dir = join(petsDir(), 'arr');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, 'pet.json'), '[1,2]');
|
||||
expect(() => getPet(root, USER, 'arr')).toThrow(/manifest must be an object/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── importPetZip ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('importPetZip', () => {
|
||||
it('imports a root-level pet package', () => {
|
||||
const zip = makeZip({
|
||||
'pet.json': JSON.stringify({ name: 'Whiskers', frameWidth: 32, frameHeight: 32 }),
|
||||
'spritesheet.png': Buffer.from('fakepng'),
|
||||
});
|
||||
const detail = importPetZip(root, USER, zip);
|
||||
expect(detail.id).toBe('whiskers');
|
||||
expect(detail.name).toBe('Whiskers');
|
||||
expect(detail.spriteFile).toBe('spritesheet.png');
|
||||
expect(existsSync(join(petsDir(), 'whiskers', 'spritesheet.png'))).toBe(true);
|
||||
});
|
||||
|
||||
it('strips a single base directory prefix', () => {
|
||||
const zip = makeZip({
|
||||
'mypet/pet.json': JSON.stringify({}),
|
||||
'mypet/spritesheet.png': Buffer.from('x'),
|
||||
});
|
||||
const detail = importPetZip(root, USER, zip);
|
||||
expect(detail.id).toBe('mypet'); // derived from the base dir name
|
||||
expect(detail.spriteFile).toBe('spritesheet.png');
|
||||
});
|
||||
|
||||
it('uses manifest id over name over base dir for the pet id', () => {
|
||||
const zip = makeZip({
|
||||
'pet.json': JSON.stringify({ id: 'Custom-ID', name: 'Other' }),
|
||||
});
|
||||
expect(importPetZip(root, USER, zip).id).toBe('custom-id');
|
||||
});
|
||||
|
||||
it('prefers options.preferredId over the manifest', () => {
|
||||
const zip = makeZip({ 'pet.json': JSON.stringify({ id: 'from-manifest' }) });
|
||||
const detail = importPetZip(root, USER, zip, { preferredId: 'Forced Name' });
|
||||
expect(detail.id).toBe('forced-name');
|
||||
});
|
||||
|
||||
it('throws PetConflictError on duplicate import without overwrite', () => {
|
||||
const zip = makeZip({ 'pet.json': JSON.stringify({ id: 'dup' }) });
|
||||
importPetZip(root, USER, zip);
|
||||
expect(() => importPetZip(root, USER, zip)).toThrow(PetConflictError);
|
||||
try {
|
||||
importPetZip(root, USER, zip);
|
||||
} catch (err) {
|
||||
expect((err as PetConflictError).petId).toBe('dup');
|
||||
}
|
||||
});
|
||||
|
||||
it('overwrites and trashes the previous version with overwrite: true', () => {
|
||||
importPetZip(root, USER, makeZip({
|
||||
'pet.json': JSON.stringify({ id: 'dup', name: 'Old' }),
|
||||
}));
|
||||
const detail = importPetZip(root, USER, makeZip({
|
||||
'pet.json': JSON.stringify({ id: 'dup', name: 'New' }),
|
||||
}), { overwrite: true });
|
||||
expect(detail.name).toBe('New');
|
||||
const trashed = readdirSync(trashDir());
|
||||
expect(trashed.some(f => f.endsWith('-dup-pet'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an empty body and an oversized body without parsing', () => {
|
||||
expect(() => importPetZip(root, USER, Buffer.alloc(0))).toThrow(/zip body is empty/);
|
||||
expect(() => importPetZip(root, USER, Buffer.alloc(12 * 1024 * 1024 + 1)))
|
||||
.toThrow(/zip body is too large/);
|
||||
});
|
||||
|
||||
it('rejects a zip without entries', () => {
|
||||
expect(() => importPetZip(root, USER, new AdmZip().toBuffer())).toThrow(/zip is empty/);
|
||||
});
|
||||
|
||||
it('requires pet.json', () => {
|
||||
const zip = makeZip({ 'spritesheet.png': Buffer.from('x') });
|
||||
expect(() => importPetZip(root, USER, zip)).toThrow(/pet\.json is required/);
|
||||
});
|
||||
|
||||
it('rejects path traversal entry names', () => {
|
||||
// adm-zip's addFile canonicalizes '../', so byte-patch a same-length
|
||||
// placeholder to get a genuinely hostile entry name into the archive.
|
||||
const zip = makeZip({
|
||||
'pet.json': JSON.stringify({}),
|
||||
'AA/evil.json': '{}',
|
||||
});
|
||||
const patched = Buffer.from(
|
||||
zip.toString('latin1').split('AA/evil.json').join('../evil.json'),
|
||||
'latin1',
|
||||
);
|
||||
expect(() => importPetZip(root, USER, patched)).toThrow(/unsafe path/);
|
||||
});
|
||||
|
||||
it('rejects dotfile entries', () => {
|
||||
const zip = makeZip({
|
||||
'pet.json': JSON.stringify({}),
|
||||
'.hidden.json': '{}',
|
||||
});
|
||||
expect(() => importPetZip(root, USER, zip)).toThrow(/unsafe path/);
|
||||
});
|
||||
|
||||
it('rejects unsupported file types', () => {
|
||||
const zip = makeZip({
|
||||
'pet.json': JSON.stringify({}),
|
||||
'run.sh': 'echo pwned',
|
||||
});
|
||||
expect(() => importPetZip(root, USER, zip)).toThrow(/unsupported file type/);
|
||||
});
|
||||
|
||||
it('rejects nested files below the package root', () => {
|
||||
const zip = makeZip({
|
||||
'pet.json': JSON.stringify({}),
|
||||
'sub/inner.png': Buffer.from('x'),
|
||||
});
|
||||
expect(() => importPetZip(root, USER, zip)).toThrow(/nested files are not supported/);
|
||||
});
|
||||
|
||||
it('rejects symlink entries', () => {
|
||||
const zip = new AdmZip();
|
||||
zip.addFile('pet.json', Buffer.from('{}'));
|
||||
zip.addFile('link.json', Buffer.from('/etc/passwd'));
|
||||
// addFile's attr argument is not preserved through toBuffer; set the unix
|
||||
// mode (high 16 bits, 0o120000 = symlink) directly on the entry header.
|
||||
const link = zip.getEntry('link.json');
|
||||
if (!link) throw new Error('fixture entry missing');
|
||||
link.header.attr = (0o120777 << 16) >>> 0;
|
||||
expect(() => importPetZip(root, USER, zip.toBuffer())).toThrow(/symlinks are not allowed/);
|
||||
});
|
||||
|
||||
it('rejects too many files', () => {
|
||||
const files: Record<string, string> = { 'pet.json': '{}' };
|
||||
for (let i = 0; i < 32; i++) files[`extra-${i}.json`] = '{}'; // 33 total
|
||||
expect(() => importPetZip(root, USER, makeZip(files))).toThrow(/too many files/);
|
||||
});
|
||||
|
||||
it('rejects a single file over 5 MB uncompressed', () => {
|
||||
const zip = makeZip({
|
||||
'pet.json': JSON.stringify({}),
|
||||
'big.png': Buffer.alloc(5 * 1024 * 1024 + 1),
|
||||
});
|
||||
expect(() => importPetZip(root, USER, zip)).toThrow(/file is too large/);
|
||||
});
|
||||
|
||||
it('rejects total uncompressed size over 10 MB', () => {
|
||||
const fourMb = 4 * 1024 * 1024;
|
||||
const zip = makeZip({
|
||||
'pet.json': JSON.stringify({}),
|
||||
'a.png': Buffer.alloc(fourMb),
|
||||
'b.png': Buffer.alloc(fourMb),
|
||||
'c.png': Buffer.alloc(fourMb),
|
||||
});
|
||||
expect(() => importPetZip(root, USER, zip)).toThrow(/uncompressed size is too large/);
|
||||
});
|
||||
|
||||
it('rejects a non-object manifest', () => {
|
||||
const zip = makeZip({ 'pet.json': '[1,2,3]' });
|
||||
expect(() => importPetZip(root, USER, zip)).toThrow(/manifest must be an object/);
|
||||
});
|
||||
|
||||
it('cleans up the staging tmp dir on failure', () => {
|
||||
const zip = makeZip({
|
||||
'pet.json': JSON.stringify({}),
|
||||
'run.sh': 'x',
|
||||
});
|
||||
expect(() => importPetZip(root, USER, zip)).toThrow(PetValidationError);
|
||||
const leftovers = readdirSync(petsDir()).filter(n => n.startsWith('.tmp-'));
|
||||
expect(leftovers).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── deletePet ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('deletePet', () => {
|
||||
it('returns false for an invalid or missing pet', () => {
|
||||
expect(deletePet(root, USER, '../escape')).toBe(false);
|
||||
expect(deletePet(root, USER, 'nope')).toBe(false);
|
||||
});
|
||||
|
||||
it('moves the pet directory to trash and returns true', () => {
|
||||
makePetDir('cat', { name: 'Cat' });
|
||||
expect(deletePet(root, USER, 'cat')).toBe(true);
|
||||
expect(existsSync(join(petsDir(), 'cat'))).toBe(false);
|
||||
const trashed = readdirSync(trashDir());
|
||||
expect(trashed.some(f => f.endsWith('-cat-pet'))).toBe(true);
|
||||
});
|
||||
|
||||
it('clears activePetId when the active pet is deleted', () => {
|
||||
makePetDir('cat', {});
|
||||
writePetSettings(root, USER, { activePetId: 'cat' });
|
||||
deletePet(root, USER, 'cat');
|
||||
expect(readPetSettings(root, USER).activePetId).toBeNull();
|
||||
});
|
||||
|
||||
it('removes workerPets mappings pointing at the deleted pet, keeps others', () => {
|
||||
makePetDir('cat', {});
|
||||
makePetDir('dog', {});
|
||||
writePetSettings(root, USER, { workerPets: { w1: 'cat', w2: 'dog' } });
|
||||
deletePet(root, USER, 'cat');
|
||||
expect(readPetSettings(root, USER).workerPets).toEqual({ w2: 'dog' });
|
||||
});
|
||||
|
||||
it('leaves settings untouched when the pet was not referenced', () => {
|
||||
makePetDir('cat', {});
|
||||
makePetDir('dog', {});
|
||||
writePetSettings(root, USER, { activePetId: 'dog', workerPets: { w1: 'dog' } });
|
||||
deletePet(root, USER, 'cat');
|
||||
const s = readPetSettings(root, USER);
|
||||
expect(s.activePetId).toBe('dog');
|
||||
expect(s.workerPets).toEqual({ w1: 'dog' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolvePetAsset ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('resolvePetAsset', () => {
|
||||
beforeEach(() => {
|
||||
makePetDir('cat', { name: 'Cat' }, {
|
||||
'spritesheet.png': Buffer.from('png'),
|
||||
'preview.webp': Buffer.from('webp'),
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves existing files with the right content type', () => {
|
||||
const png = resolvePetAsset(root, USER, 'cat', 'spritesheet.png');
|
||||
expect(png).not.toBeNull();
|
||||
expect(png!.contentType).toBe('image/png');
|
||||
expect(png!.path).toBe(join(petsDir(), 'cat', 'spritesheet.png'));
|
||||
|
||||
expect(resolvePetAsset(root, USER, 'cat', 'preview.webp')!.contentType).toBe('image/webp');
|
||||
expect(resolvePetAsset(root, USER, 'cat', 'pet.json')!.contentType)
|
||||
.toBe('application/json; charset=utf-8');
|
||||
});
|
||||
|
||||
it('returns null for an invalid pet id', () => {
|
||||
expect(resolvePetAsset(root, USER, 'BAD ID', 'pet.json')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for unsafe file names', () => {
|
||||
expect(resolvePetAsset(root, USER, 'cat', '')).toBeNull();
|
||||
expect(resolvePetAsset(root, USER, 'cat', 'a/b.png')).toBeNull();
|
||||
expect(resolvePetAsset(root, USER, 'cat', 'a\\b.png')).toBeNull();
|
||||
expect(resolvePetAsset(root, USER, 'cat', '.hidden.png')).toBeNull();
|
||||
expect(resolvePetAsset(root, USER, 'cat', '..')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for disallowed extensions', () => {
|
||||
expect(resolvePetAsset(root, USER, 'cat', 'script.js')).toBeNull();
|
||||
expect(resolvePetAsset(root, USER, 'cat', 'noext')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the file does not exist', () => {
|
||||
expect(resolvePetAsset(root, USER, 'cat', 'missing.png')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import type { BrowserSessionRepo } from '../db/browser-session-repo.js';
|
||||
|
||||
vi.mock('./script-runner.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./script-runner.js')>();
|
||||
return { ...actual, runUserScript: vi.fn() };
|
||||
});
|
||||
vi.mock('./session-loader.js', () => ({ loadSessionStateForUser: vi.fn() }));
|
||||
|
||||
import { runUserScript } from './script-runner.js';
|
||||
import { loadSessionStateForUser } from './session-loader.js';
|
||||
import { resolveScriptForKind, resolveAndRunUserScript } from './script-orchestrator.js';
|
||||
|
||||
const USER = 'user-1';
|
||||
let root: string;
|
||||
|
||||
function addMacro(name: string, source = 'export default async () => 1;\n'): string {
|
||||
const dir = join(root, USER, 'browser-macros');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const p = join(dir, name);
|
||||
writeFileSync(p, source);
|
||||
return p;
|
||||
}
|
||||
|
||||
const MACRO_WITH_SESSION = `---
|
||||
description: needs a session
|
||||
session_profile_id: 7
|
||||
---
|
||||
export default async () => 'macro';
|
||||
`;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'script-orch-test-'));
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(runUserScript).mockResolvedValue({ result: 'ran', logs: ['l1'], durationMs: 12 } as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('resolveScriptForKind', () => {
|
||||
it('resolves a browser-macro to its path with the playwright runtime', () => {
|
||||
const p = addMacro('macro.js');
|
||||
const r = resolveScriptForKind(root, USER, 'macro.js', 'browser-macro');
|
||||
expect(r).toEqual({ scriptPath: p, subdir: 'browser-macros', runtime: 'playwright' });
|
||||
});
|
||||
|
||||
it('resolves without an explicit kind', () => {
|
||||
const p = addMacro('macro.js');
|
||||
const r = resolveScriptForKind(root, USER, 'macro.js', undefined);
|
||||
expect(r).toMatchObject({ scriptPath: p, subdir: 'browser-macros' });
|
||||
});
|
||||
|
||||
it('returns an error for a missing macro', () => {
|
||||
const r = resolveScriptForKind(root, USER, 'ghost.js', undefined);
|
||||
expect(r).toEqual({ error: 'browser-macro not found: browser-macros/ghost.js' });
|
||||
});
|
||||
|
||||
it('treats traversal names as not found instead of escaping', () => {
|
||||
const r = resolveScriptForKind(root, USER, '../../etc/passwd', undefined);
|
||||
expect('error' in r).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAndRunUserScript', () => {
|
||||
it('runs a macro and appends .js to the name', async () => {
|
||||
const p = addMacro('task.js');
|
||||
const r = await resolveAndRunUserScript({ rootDir: root, userId: USER, name: 'task', params: { a: 1 } });
|
||||
expect(r).toMatchObject({
|
||||
ok: true, result: 'ran', logs: ['l1'], subdir: 'browser-macros', runtime: 'playwright',
|
||||
});
|
||||
expect(runUserScript).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scriptPath: p, params: { a: 1 }, runtime: 'playwright', timeoutMs: 60_000,
|
||||
}),
|
||||
);
|
||||
expect(loadSessionStateForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns an error result for an unknown macro', async () => {
|
||||
const r = await resolveAndRunUserScript({ rootDir: root, userId: USER, name: 'ghost', params: {} });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toContain('not found');
|
||||
expect(runUserScript).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs a macro without a session profile directly (no storageState)', async () => {
|
||||
addMacro('macro.js');
|
||||
const r = await resolveAndRunUserScript({
|
||||
rootDir: root, userId: USER, name: 'macro', params: {}, headless: false,
|
||||
});
|
||||
expect(r).toMatchObject({ ok: true, subdir: 'browser-macros', runtime: 'playwright' });
|
||||
expect(runUserScript).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ storageState: undefined, headless: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('fails when a macro declares a session but no repo is configured', async () => {
|
||||
addMacro('macro.js', MACRO_WITH_SESSION);
|
||||
const r = await resolveAndRunUserScript({ rootDir: root, userId: USER, name: 'macro', params: {} });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toContain('BrowserSessionRepo not configured');
|
||||
expect(runUserScript).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates session load failures', async () => {
|
||||
addMacro('macro.js', MACRO_WITH_SESSION);
|
||||
vi.mocked(loadSessionStateForUser).mockResolvedValue({
|
||||
ok: false,
|
||||
error: { kind: 'profile_not_found', message: 'profile 7 not found' },
|
||||
} as never);
|
||||
const r = await resolveAndRunUserScript({
|
||||
rootDir: root, userId: USER, name: 'macro', params: {},
|
||||
sessRepo: {} as BrowserSessionRepo, masterKeyPath: '/k',
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toBe('profile 7 not found');
|
||||
});
|
||||
|
||||
it('hydrates storageState and passes it to the runner', async () => {
|
||||
addMacro('macro.js', MACRO_WITH_SESSION);
|
||||
const state = { cookies: [] };
|
||||
vi.mocked(loadSessionStateForUser).mockResolvedValue({ ok: true, storageState: state } as never);
|
||||
const r = await resolveAndRunUserScript({
|
||||
rootDir: root, userId: USER, name: 'macro', params: {},
|
||||
sessRepo: {} as BrowserSessionRepo, masterKeyPath: '/k', timeoutMs: 5000,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
expect(loadSessionStateForUser).toHaveBeenCalledWith(
|
||||
{ sessRepo: {}, masterKeyPath: '/k' }, USER, 7,
|
||||
);
|
||||
expect(runUserScript).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ storageState: state, timeoutMs: 5000 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a frontmatter parse failure', async () => {
|
||||
addMacro('macro.js', '---\nsession_profile_id: -2\n---\nbody');
|
||||
const r = await resolveAndRunUserScript({ rootDir: root, userId: USER, name: 'macro', params: {} });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toContain('failed to parse script frontmatter');
|
||||
});
|
||||
|
||||
it('never throws when the runner fails — returns ok:false with duration', async () => {
|
||||
addMacro('boom.js');
|
||||
vi.mocked(runUserScript).mockRejectedValue(new Error('script exploded'));
|
||||
const r = await resolveAndRunUserScript({ rootDir: root, userId: USER, name: 'boom', params: {} });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toBe('script exploded');
|
||||
expect(typeof r.durationMs).toBe('number');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import type { BrowserSessionRepo } from '../db/browser-session-repo.js';
|
||||
import {
|
||||
initMasterKey,
|
||||
generateUserDek,
|
||||
encryptUserDek,
|
||||
encryptStateBlob,
|
||||
} from '../crypto/sessions.js';
|
||||
import { loadSessionStateForUser } from './session-loader.js';
|
||||
|
||||
const OWNER = 'user-1';
|
||||
const STATE = { cookies: [{ name: 'sid', value: 'abc' }], origins: [] };
|
||||
|
||||
let dir: string;
|
||||
let masterKeyPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'session-loader-test-'));
|
||||
masterKeyPath = join(dir, 'master.key');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Build a stub repo + a valid encrypted blob for OWNER. */
|
||||
function makeFixture(overrides: {
|
||||
profile?: Record<string, unknown> | null;
|
||||
encDek?: Buffer | null;
|
||||
} = {}) {
|
||||
const master = initMasterKey(masterKeyPath);
|
||||
const dek = generateUserDek();
|
||||
const encDek = overrides.encDek !== undefined ? overrides.encDek : encryptUserDek(master, dek);
|
||||
const blob = encryptStateBlob(dek, JSON.stringify(STATE));
|
||||
const profile =
|
||||
overrides.profile !== undefined
|
||||
? overrides.profile
|
||||
: { id: 7, ownerId: OWNER, status: 'active', encryptedStateBlob: blob };
|
||||
|
||||
const sessRepo = {
|
||||
getProfileById: (id: number, ownerId: string) =>
|
||||
profile && id === 7 && ownerId === OWNER ? profile : null,
|
||||
getUserDek: (userId: string) => (userId === OWNER ? encDek : null),
|
||||
} as unknown as BrowserSessionRepo;
|
||||
|
||||
return { sessRepo, blob };
|
||||
}
|
||||
|
||||
describe('loadSessionStateForUser', () => {
|
||||
it('decrypts and parses the storageState for an active profile', async () => {
|
||||
const { sessRepo } = makeFixture();
|
||||
const res = await loadSessionStateForUser({ sessRepo, masterKeyPath }, OWNER, 7);
|
||||
expect(res).toEqual({ ok: true, storageState: STATE });
|
||||
});
|
||||
|
||||
it('reports profile_not_found for an unknown id', async () => {
|
||||
const { sessRepo } = makeFixture();
|
||||
const res = await loadSessionStateForUser({ sessRepo, masterKeyPath }, OWNER, 999);
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) expect(res.error.kind).toBe('profile_not_found');
|
||||
});
|
||||
|
||||
it('reports profile_not_found when the profile belongs to another user', async () => {
|
||||
const { sessRepo } = makeFixture();
|
||||
const res = await loadSessionStateForUser({ sessRepo, masterKeyPath }, 'other-user', 7);
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) expect(res.error.kind).toBe('profile_not_found');
|
||||
});
|
||||
|
||||
it('reports profile_not_active for a non-active status', async () => {
|
||||
const base = makeFixture();
|
||||
const { sessRepo } = makeFixture({
|
||||
profile: { id: 7, ownerId: OWNER, status: 'pending', encryptedStateBlob: base.blob },
|
||||
});
|
||||
const res = await loadSessionStateForUser({ sessRepo, masterKeyPath }, OWNER, 7);
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.error.kind).toBe('profile_not_active');
|
||||
expect(res.error.message).toContain('status=pending');
|
||||
}
|
||||
});
|
||||
|
||||
it('reports profile_not_active when the blob is missing', async () => {
|
||||
const { sessRepo } = makeFixture({
|
||||
profile: { id: 7, ownerId: OWNER, status: 'active', encryptedStateBlob: null },
|
||||
});
|
||||
const res = await loadSessionStateForUser({ sessRepo, masterKeyPath }, OWNER, 7);
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) expect(res.error.kind).toBe('profile_not_active');
|
||||
});
|
||||
|
||||
it('reports dek_not_found when the user has no stored DEK', async () => {
|
||||
const { sessRepo } = makeFixture({ encDek: null });
|
||||
const res = await loadSessionStateForUser({ sessRepo, masterKeyPath }, OWNER, 7);
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) expect(res.error.kind).toBe('dek_not_found');
|
||||
});
|
||||
|
||||
it('reports decrypt_error for a corrupted blob and never throws', async () => {
|
||||
const { sessRepo } = makeFixture({
|
||||
profile: { id: 7, ownerId: OWNER, status: 'active', encryptedStateBlob: Buffer.from('garbage') },
|
||||
});
|
||||
const res = await loadSessionStateForUser({ sessRepo, masterKeyPath }, OWNER, 7);
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) expect(res.error.kind).toBe('decrypt_error');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user