qwen_2.5_model / src /modules /plugins /plugin-installer.spec.ts
Muhammad Noman
Deploy OpenWA to Hugging Face Spaces
46252cd
Raw
History Blame Contribute Delete
11.3 kB
import AdmZip from 'adm-zip';
import { BadRequestException } from '@nestjs/common';
import { parsePluginPackage } from './plugin-installer';
const validManifest = { id: 'my-plg', name: 'My Plugin', version: '1.0.0', type: 'extension', main: 'index.js' };
function zipOf(files: Record<string, string>): Buffer {
const z = new AdmZip();
for (const [name, content] of Object.entries(files)) z.addFile(name, Buffer.from(content));
return z.toBuffer();
}
/**
* Build a valid plugin zip, then corrupt one entry's compressed bytes so `getData()` throws a
* decompression/CRC error (BAD_CRC / zlib invalid-code) — simulating a truncated or damaged archive.
*/
function zipWithCorruptEntry(target: string): Buffer {
const z = new AdmZip();
z.addFile('manifest.json', Buffer.from(JSON.stringify(validManifest)));
z.addFile('index.js', Buffer.from('module.exports=class{}'));
z.addFile(target, Buffer.from('A'.repeat(100)));
const buf = Buffer.from(z.toBuffer()); // writable copy
const locsig = Buffer.from([0x50, 0x4b, 0x03, 0x04]); // PK\x03\x04 local file header signature
let off = buf.indexOf(locsig);
while (off !== -1) {
const nameLen = buf.readUInt16LE(off + 26);
const extraLen = buf.readUInt16LE(off + 28);
const name = buf.slice(off + 30, off + 30 + nameLen).toString();
const dataStart = off + 30 + nameLen + extraLen;
if (name === target) {
buf[dataStart] ^= 0xff; // flip the first compressed byte → bad CRC / invalid deflate stream
break;
}
off = buf.indexOf(locsig, off + 1);
}
return buf;
}
/**
* Build a plugin zip whose `target` entry declares uncompressed size = 0 in BOTH the central
* directory and the local header, while the entry still carries real (deflated) content. adm-zip
* only sets zlib `maxOutputLength` when the declared size is > 0, so without a bound this entry
* inflates with no cap — the zip-bomb residual gap (a lying-header entry).
*/
function zipWithLyingZeroSizeEntries(targets: Record<string, string>): Buffer {
const z = new AdmZip();
z.addFile('manifest.json', Buffer.from(JSON.stringify(validManifest)));
z.addFile('index.js', Buffer.from('module.exports=class{}'));
for (const [name, content] of Object.entries(targets)) z.addFile(name, Buffer.from(content));
const buf = Buffer.from(z.toBuffer()); // writable copy
const censig = Buffer.from([0x50, 0x4b, 0x01, 0x02]); // PK\x01\x02 central directory header signature
let off = buf.indexOf(censig);
while (off !== -1) {
const nameLen = buf.readUInt16LE(off + 28);
const name = buf.slice(off + 46, off + 46 + nameLen).toString();
if (name in targets) {
const locoff = buf.readUInt32LE(off + 42); // CENOFF — local header offset
buf.writeUInt32LE(0, off + 24); // CENLEN = 0 (declared uncompressed size, central)
buf.writeUInt32LE(0, locoff + 22); // LOCLEN = 0 (declared uncompressed size, local)
}
off = buf.indexOf(censig, off + 1);
}
return buf;
}
function zipWithLyingZeroSizeEntry(target: string, content: string): Buffer {
return zipWithLyingZeroSizeEntries({ [target]: content });
}
describe('parsePluginPackage', () => {
it('parses a flat package (manifest + files at the root)', () => {
const out = parsePluginPackage(
zipOf({ 'manifest.json': JSON.stringify(validManifest), 'index.js': 'module.exports=class{}' }),
);
expect(out.manifest.id).toBe('my-plg');
expect(out.entries.map(e => e.relPath).sort()).toEqual(['index.js', 'manifest.json']);
});
it('strips a single wrapping folder (a zipped plugin directory)', () => {
const out = parsePluginPackage(
zipOf({ 'my-plg/manifest.json': JSON.stringify(validManifest), 'my-plg/index.js': 'x' }),
);
expect(out.entries.map(e => e.relPath).sort()).toEqual(['index.js', 'manifest.json']);
});
it('ignores cruft outside the package root (e.g. __MACOSX)', () => {
const out = parsePluginPackage(
zipOf({ 'my-plg/manifest.json': JSON.stringify(validManifest), 'my-plg/index.js': 'x', '__MACOSX/._x': 'junk' }),
);
expect(out.entries.map(e => e.relPath).sort()).toEqual(['index.js', 'manifest.json']);
});
it('rejects an archive with no manifest.json', () => {
expect(() => parsePluginPackage(zipOf({ 'index.js': 'x' }))).toThrow(/no manifest/i);
});
it('rejects a manifest missing a required field', () => {
const bad = { ...validManifest, main: undefined };
expect(() => parsePluginPackage(zipOf({ 'manifest.json': JSON.stringify(bad), 'index.js': 'x' }))).toThrow(
/required field: main/i,
);
});
it('rejects a non-string required field (numeric main) with a clean 400, not a TypeError/500', () => {
// A non-string `main` is truthy, so a bare falsy check would pass it through and then crash
// path.posix.normalize with an uncaught TypeError (HTTP 500). It must be rejected as a 400.
const bad = { ...validManifest, main: 123 };
expect(() => parsePluginPackage(zipOf({ 'manifest.json': JSON.stringify(bad), 'index.js': 'x' }))).toThrow(
/invalid required field/i,
);
});
it('rejects a non-object manifest (null / array / scalar) with a 400, not a TypeError/500', () => {
// JSON.parse("null") is null (no throw); accessing manifest['id'] on it then throws an uncaught
// TypeError (HTTP 500). null, an array, and a bare scalar must all be rejected as a clean 400.
for (const body of ['null', '[]', '"x"', '5', 'true']) {
expect(() => parsePluginPackage(zipOf({ 'manifest.json': body, 'index.js': 'x' }))).toThrow(
/must be a JSON object/i,
);
}
});
it('rejects an unsafe plugin id', () => {
const bad = { ...validManifest, id: '../evil' };
expect(() => parsePluginPackage(zipOf({ 'manifest.json': JSON.stringify(bad), 'index.js': 'x' }))).toThrow(
/invalid plugin id/i,
);
});
it('rejects an id reserved by a built-in', () => {
const bad = { ...validManifest, id: 'baileys' };
expect(() => parsePluginPackage(zipOf({ 'manifest.json': JSON.stringify(bad), 'index.js': 'x' }))).toThrow(
/reserved/i,
);
});
it('rejects a case-variant of a reserved id (the id check is case-insensitive)', () => {
// SAFE_ID accepts mixed case, so a case-variant must still hit the reservation — else `Auto-Reply`
// installs as a distinct plugin that shadows the reserved `auto-reply`.
const bad = { ...validManifest, id: 'Auto-Reply' };
expect(() => parsePluginPackage(zipOf({ 'manifest.json': JSON.stringify(bad), 'index.js': 'x' }))).toThrow(
/reserved/i,
);
});
it('rejects an engine-type package (engines are built-in, not user-installable)', () => {
const bad = { ...validManifest, id: 'my-engine', type: 'engine' };
expect(() => parsePluginPackage(zipOf({ 'manifest.json': JSON.stringify(bad), 'index.js': 'x' }))).toThrow(
/extension/i,
);
});
it('rejects an unknown plugin type', () => {
const bad = { ...validManifest, type: 'wormhole' };
expect(() => parsePluginPackage(zipOf({ 'manifest.json': JSON.stringify(bad), 'index.js': 'x' }))).toThrow(/type/i);
});
it('rejects a zip-slip path escaping the package root', () => {
// adm-zip sanitizes names on add, so forge the malicious entry name directly to simulate a
// hand-crafted archive.
const z = new AdmZip();
z.addFile('manifest.json', Buffer.from(JSON.stringify(validManifest)));
z.addFile('index.js', Buffer.from('x'));
z.addFile('evil.js', Buffer.from('pwned'));
z.getEntries().find(e => e.entryName === 'evil.js')!.entryName = '../evil.js';
expect(() => parsePluginPackage(z.toBuffer())).toThrow(/unsafe path/i);
});
it('rejects a package missing its declared main file', () => {
const buf = zipOf({ 'manifest.json': JSON.stringify(validManifest), 'other.js': 'x' });
expect(() => parsePluginPackage(buf)).toThrow(/missing its main file/i);
});
it('rejects too many files', () => {
const files: Record<string, string> = { 'manifest.json': JSON.stringify(validManifest), 'index.js': 'x' };
for (let i = 0; i < 5; i++) files[`f${i}.txt`] = 'x';
expect(() => parsePluginPackage(zipOf(files), { maxEntries: 3, maxTotalBytes: 1e9 })).toThrow(/too many/i);
});
it('rejects an archive that exceeds the size limit (before decompressing)', () => {
const buf = zipOf({ 'manifest.json': JSON.stringify(validManifest), 'index.js': 'a'.repeat(100) });
expect(() => parsePluginPackage(buf, { maxEntries: 100, maxTotalBytes: 10 })).toThrow(/size limit/i);
});
it('rejects a corrupt entry with a clean 400, not an uncaught decompression error / 500', () => {
// A truncated/damaged entry throws BAD_CRC / a zlib error from getData(); without the try/catch
// that escapes as an HTTP 500. It must surface as a BadRequestException (clean 400).
const buf = zipWithCorruptEntry('data.bin');
expect(() => parsePluginPackage(buf)).toThrow(BadRequestException);
expect(() => parsePluginPackage(buf)).toThrow(/corrupt or too large/i);
});
it('rejects a lying size=0 entry whose actual content exceeds the cap (no unbounded inflation)', () => {
// adm-zip skips zlib `maxOutputLength` when declared size is 0, so this entry would inflate with
// NO cap. The declared aggregate (0 + tiny manifest + index) passes the pre-check; only the
// actual-bytes bound stops the lying entry. Actual content (500B) exceeds the per-entry cap (200B)
// → ERR_BUFFER_TOO_LARGE → BadRequestException.
const buf = zipWithLyingZeroSizeEntry('big.bin', 'A'.repeat(500));
expect(() => parsePluginPackage(buf, { maxEntries: 100, maxTotalBytes: 200 })).toThrow(BadRequestException);
expect(() => parsePluginPackage(buf, { maxEntries: 100, maxTotalBytes: 200 })).toThrow(/corrupt or too large/i);
});
it('rejects many lying size=0 entries whose aggregate exceeds the cap (multi-entry zip bomb)', () => {
// Each lying entry is individually under the per-entry cap (150B < 300B), and the declared sum
// is tiny (lying sizes are 0 + manifest + index), so both the declared pre-check and the per-entry
// bound pass. Without the running actual-bytes counter, these accumulate unbounded in `entries`
// before the function returns → OOM. The aggregate bound aborts as soon as the running total
// crosses the cap.
const buf = zipWithLyingZeroSizeEntries({
'big1.bin': 'A'.repeat(150),
'big2.bin': 'A'.repeat(150),
'big3.bin': 'A'.repeat(150),
});
expect(() => parsePluginPackage(buf, { maxEntries: 100, maxTotalBytes: 300 })).toThrow(BadRequestException);
expect(() => parsePluginPackage(buf, { maxEntries: 100, maxTotalBytes: 300 })).toThrow(/too large/i);
});
it('happy path unchanged: a normal small plugin installs identically (no regression)', () => {
const buf = zipOf({
'manifest.json': JSON.stringify(validManifest),
'index.js': 'module.exports=class{}',
'extra.txt': 'some extra bytes',
});
const out = parsePluginPackage(buf);
expect(out.manifest.id).toBe('my-plg');
expect(out.entries.map(e => e.relPath).sort()).toEqual(['extra.txt', 'index.js', 'manifest.json']);
expect(out.entries.find(e => e.relPath === 'extra.txt')?.data.toString()).toBe('some extra bytes');
});
});