Files
webmail/app/api/admin/plugins/__tests__/route.test.ts
T
Linus Rath e57b7a2c18 fix: check plugin and theme packages the same way on every install path
The Settings upload, the admin upload, the marketplace install and the
marketplace preview each parsed the ZIP and manifest with their own copy
of the code, and the copies had drifted. A `permissions` string skipped
validation and was then matched by substring, a small ZIP could inflate
to hundreds of megabytes on the server, the Settings upload dropped
`locales` and took origin lists unchecked, and settings schemas were
never checked at all.

All of them now go through `lib/extension-package.ts`:
- every file is inflated through a byte cap, with a limit on entries
- one manifest check: types, permissions, entrypoint, version,
  minAppVersion, settingsSchema, configSchema, locales and origin lists
- only checked fields are stored
2026-10-14 01:43:43 +02:00

103 lines
3.6 KiB
TypeScript

/**
* @vitest-environment node
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
import JSZip from 'jszip';
vi.mock('@/lib/admin/session', () => ({
requireAdminAuth: vi.fn(async () => ({ session: { sub: 'admin' } })),
getClientIP: () => '127.0.0.1',
}));
vi.mock('@/lib/admin/audit', () => ({ auditLog: vi.fn(async () => {}) }));
let policy: Record<string, unknown>;
vi.mock('@/lib/admin/config-manager', () => ({
configManager: {
ensureLoaded: vi.fn(async () => {}),
get: vi.fn(),
getPolicy: vi.fn(() => policy),
setPolicy: vi.fn(async (p: Record<string, unknown>) => { policy = p; }),
},
}));
const registry = new Map<string, Record<string, unknown>>();
const removedConfig: string[] = [];
vi.mock('@/lib/admin/plugin-registry', () => ({
savePlugin: vi.fn(async (p: Record<string, unknown>) => { registry.set(p.id as string, p); }),
getPlugin: vi.fn(async (id: string) => registry.get(id) ?? null),
getPluginRegistry: vi.fn(async () => ({ plugins: [...registry.values()] })),
deletePlugin: vi.fn(async (id: string) => registry.delete(id)),
}));
vi.mock('@/lib/admin/plugin-config', () => ({
deletePluginConfig: vi.fn(async (id: string) => { removedConfig.push(id); }),
}));
vi.mock('@/lib/admin/plugin-dev', () => ({ listDevPlugins: vi.fn(async () => []) }));
vi.mock('@/lib/admin/csp-frame-origins', async (orig) => ({
...(await orig<typeof import('@/lib/admin/csp-frame-origins')>()),
invalidateFrameOriginsCache: vi.fn(),
}));
import { POST } from '../route';
const MANIFEST = {
id: 'demo-plugin', name: 'Demo', version: '1.0.0', author: 'me', description: 'd',
type: 'hook', permissions: ['email:read'], entrypoint: 'index.js',
};
async function upload(files: Record<string, string | Uint8Array>) {
const zip = new JSZip();
for (const [n, d] of Object.entries(files)) zip.file(n, d);
const bytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' });
const form = new FormData();
form.append('file', new File([bytes as BlobPart], 'p.zip', { type: 'application/zip' }));
return POST(new NextRequest('http://localhost/api/admin/plugins', { method: 'POST', body: form }));
}
beforeEach(() => {
registry.clear();
removedConfig.length = 0;
policy = { forceEnabledPlugins: [] };
});
describe('POST /api/admin/plugins', () => {
it('installs a valid plugin with its locales', async () => {
const res = await upload({
'manifest.json': JSON.stringify({ ...MANIFEST, locales: { de: { hi: 'Hallo' } } }),
'index.js': 'module.exports = {}',
});
expect(res.status).toBe(200);
expect(registry.get('demo-plugin')?.locales).toEqual({ de: { hi: 'Hallo' } });
});
it('refuses permissions given as a string', async () => {
const res = await upload({
'manifest.json': JSON.stringify({ ...MANIFEST, permissions: 'crypto:full email:send', tier: 'privileged' }),
'index.js': '',
});
expect(res.status).toBe(400);
expect(registry.size).toBe(0);
});
it('refuses a ZIP bomb instead of inflating it', async () => {
const res = await upload({
'manifest.json': JSON.stringify(MANIFEST),
'index.js': new Uint8Array(30 * 1024 * 1024),
});
expect(res.status).toBe(400);
expect((await res.json()).error).toMatch(/once unpacked/);
});
it('refuses an invalid settings schema', async () => {
const res = await upload({
'manifest.json': JSON.stringify({ ...MANIFEST, settingsSchema: { m: { type: 'select', label: 'M', default: 'a', options: 'a,b' } } }),
'index.js': '',
});
expect(res.status).toBe(400);
});
});