266 lines
9.9 KiB
JavaScript
266 lines
9.9 KiB
JavaScript
// jetbrains-fetch.test.mjs — Integration tests for `/security ide-scan <url>`
|
|
// with a JetBrains Marketplace URL. Mocks `globalThis.fetch` so we never hit
|
|
// real plugins.jetbrains.com endpoints. `useSandbox: false` is required because
|
|
// mocks do not cross process boundaries — this mirrors the VSIX test strategy.
|
|
//
|
|
// Covers:
|
|
// 1. Spawned worker emits well-formed JSON when fed a bogus URL (sub-process
|
|
// path — no mock, we just assert the IPC contract holds).
|
|
// 2. End-to-end `scan()` on a `/plugin/<numericId>-<slug>` URL resolves
|
|
// numericId → xmlId via metadata, then downloads + extracts.
|
|
// 3. End-to-end `scan()` on a `/plugin/download?pluginId=<xmlId>` URL
|
|
// skips the metadata round-trip and downloads directly.
|
|
// 4. Network failure / malformed archive bubble up as warnings.
|
|
// 5. URL kind discriminator (`meta.source.kind === 'jetbrains'`) distinguishes
|
|
// JetBrains plugins from VS Code extensions in the envelope.
|
|
//
|
|
// See: plan step 12 (`ultraplan-2026-04-17-jetbrains-ide-scan.md`).
|
|
|
|
import { describe, it, before, after } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { spawn } from 'node:child_process';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { resetCounter } from '../../scanners/lib/output.mjs';
|
|
import { scan } from '../../scanners/ide-extension-scanner.mjs';
|
|
import { createZip } from '../helpers/zip-writer.mjs';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const JB_WORKER_PATH = join(
|
|
__dirname,
|
|
'..',
|
|
'..',
|
|
'scanners',
|
|
'lib',
|
|
'jetbrains-fetch-worker.mjs',
|
|
);
|
|
|
|
const realFetch = globalThis.fetch;
|
|
|
|
function mockBufferResponse(buffer, { status = 200 } = {}) {
|
|
const stream = new ReadableStream({
|
|
start(controller) { controller.enqueue(buffer); controller.close(); },
|
|
});
|
|
return new Response(stream, {
|
|
status,
|
|
headers: { 'content-type': 'application/zip' },
|
|
});
|
|
}
|
|
|
|
function jsonResponse(obj, { status = 200 } = {}) {
|
|
return new Response(JSON.stringify(obj), {
|
|
status,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
function installFetchRouter(routes) {
|
|
globalThis.fetch = async (url) => {
|
|
const handler = routes(String(url));
|
|
if (!handler) throw new Error(`unrouted fetch: ${url}`);
|
|
return handler;
|
|
};
|
|
}
|
|
|
|
// Build a synthetic JetBrains plugin archive with the layout
|
|
// <plugin-name>/lib/<plugin>.jar → containing META-INF/plugin.xml.
|
|
// The outer archive is what plugins.jetbrains.com ships; the inner jar is what
|
|
// parseIntelliJPlugin walks for the manifest.
|
|
function buildBenignJetBrainsArchive() {
|
|
const pluginXml = `<?xml version="1.0"?>
|
|
<idea-plugin>
|
|
<id>com.example.benign</id>
|
|
<name>Benign</name>
|
|
<version>1.0.0</version>
|
|
<vendor>Example</vendor>
|
|
</idea-plugin>`;
|
|
const innerJar = createZip([
|
|
{ name: 'META-INF/plugin.xml', data: pluginXml },
|
|
{ name: 'META-INF/MANIFEST.MF', data: 'Manifest-Version: 1.0\n' },
|
|
]);
|
|
return createZip([
|
|
{ name: 'com.example.benign/lib/main.jar', data: innerJar },
|
|
]);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 1. Worker IPC contract
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('jetbrains-fetch-worker — IPC contract', () => {
|
|
it('emits ok:false JSON on missing args and exits 1', async () => {
|
|
const child = spawn('node', [JB_WORKER_PATH], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
let out = '';
|
|
child.stdout.on('data', (c) => { out += c.toString('utf8'); });
|
|
const code = await new Promise((resolve) => child.on('close', resolve));
|
|
assert.equal(code, 1);
|
|
const parsed = JSON.parse(out.trim());
|
|
assert.equal(parsed.ok, false);
|
|
assert.match(parsed.error, /missing --url or --tmpdir/);
|
|
});
|
|
|
|
it('emits ok:false JSON when given a non-JetBrains URL', async () => {
|
|
// Reject non-JetBrains URLs at the worker level — defense-in-depth in case
|
|
// orchestrator routes a wrong URL to the JB worker.
|
|
const child = spawn(
|
|
'node',
|
|
[JB_WORKER_PATH, '--url', 'https://example.com/x.vsix', '--tmpdir', '/tmp'],
|
|
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
|
);
|
|
let out = '';
|
|
child.stdout.on('data', (c) => { out += c.toString('utf8'); });
|
|
const code = await new Promise((resolve) => child.on('close', resolve));
|
|
assert.equal(code, 1);
|
|
const parsed = JSON.parse(out.trim());
|
|
assert.equal(parsed.ok, false);
|
|
assert.match(parsed.error, /expected JetBrains URL/);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 2. End-to-end scan() with mocked fetch
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('ide-extension-scanner — JetBrains URL mode', () => {
|
|
before(() => resetCounter());
|
|
after(() => { globalThis.fetch = realFetch; });
|
|
|
|
it('resolves numericId → xmlId via metadata, then downloads + scans', async () => {
|
|
const archive = buildBenignJetBrainsArchive();
|
|
const calls = [];
|
|
installFetchRouter((url) => {
|
|
calls.push(url);
|
|
if (/\/api\/plugins\/7973$/.test(url)) {
|
|
return jsonResponse({ xmlId: 'com.example.benign' });
|
|
}
|
|
if (/\/plugin\/download\?pluginId=com\.example\.benign/.test(url)) {
|
|
return mockBufferResponse(archive);
|
|
}
|
|
return null;
|
|
});
|
|
|
|
const env = await scan(
|
|
'https://plugins.jetbrains.com/plugin/7973-benign',
|
|
{ useSandbox: false },
|
|
);
|
|
|
|
// Two fetches: metadata + download.
|
|
assert.equal(calls.length, 2, `calls: ${calls.join(', ')}`);
|
|
assert.match(calls[0], /\/api\/plugins\/7973/);
|
|
assert.match(calls[1], /\/plugin\/download\?pluginId=com\.example\.benign/);
|
|
|
|
// Envelope shape.
|
|
assert.ok(env.meta.source, 'expected meta.source to be set');
|
|
assert.equal(env.meta.source.type, 'url');
|
|
assert.equal(env.meta.source.kind, 'jetbrains');
|
|
assert.equal(env.meta.source.xmlId, 'com.example.benign');
|
|
assert.equal(env.meta.source.numericId, '7973');
|
|
assert.match(env.meta.source.sha256, /^[a-f0-9]{64}$/);
|
|
assert.equal(env.meta.source.sandbox, 'in-process');
|
|
assert.equal(env.meta.target, 'https://plugins.jetbrains.com/plugin/7973-benign');
|
|
|
|
// Scanner parsed the inner plugin.xml and produced exactly one JB extension.
|
|
assert.equal(env.extensions.length, 1);
|
|
assert.equal(env.extensions[0].type, 'jetbrains');
|
|
assert.equal(env.extensions[0].id, 'com.example.benign');
|
|
assert.equal(env.extensions[0].version, '1.0.0');
|
|
});
|
|
|
|
it('downloads by xmlId directly (no metadata round-trip)', async () => {
|
|
const archive = buildBenignJetBrainsArchive();
|
|
let metaCalled = false;
|
|
let downloadCalled = false;
|
|
installFetchRouter((url) => {
|
|
if (/\/api\/plugins\//.test(url)) {
|
|
metaCalled = true;
|
|
return jsonResponse({ xmlId: 'should.not.be.used' });
|
|
}
|
|
if (/\/plugin\/download\?pluginId=com\.example\.benign/.test(url)) {
|
|
downloadCalled = true;
|
|
return mockBufferResponse(archive);
|
|
}
|
|
return null;
|
|
});
|
|
|
|
const env = await scan(
|
|
'https://plugins.jetbrains.com/plugin/download?pluginId=com.example.benign',
|
|
{ useSandbox: false },
|
|
);
|
|
|
|
assert.equal(metaCalled, false, 'metadata should not be fetched when xmlId is explicit');
|
|
assert.equal(downloadCalled, true);
|
|
assert.equal(env.meta.source.kind, 'jetbrains');
|
|
assert.equal(env.meta.source.xmlId, 'com.example.benign');
|
|
assert.equal(env.extensions.length, 1);
|
|
assert.equal(env.extensions[0].type, 'jetbrains');
|
|
});
|
|
|
|
it('passes version query through unchanged', async () => {
|
|
const archive = buildBenignJetBrainsArchive();
|
|
const calls = [];
|
|
installFetchRouter((url) => {
|
|
calls.push(url);
|
|
if (/\/plugin\/download\?pluginId=com\.example\.benign/.test(url)) {
|
|
return mockBufferResponse(archive);
|
|
}
|
|
return null;
|
|
});
|
|
|
|
await scan(
|
|
'https://plugins.jetbrains.com/plugin/download?pluginId=com.example.benign&version=2.3.4',
|
|
{ useSandbox: false },
|
|
);
|
|
|
|
assert.equal(calls.length, 1);
|
|
assert.match(calls[0], /version=2\.3\.4/);
|
|
});
|
|
|
|
it('reports fetch network failure as a warning, no extensions scanned', async () => {
|
|
installFetchRouter(() => { throw new Error('ECONNREFUSED'); });
|
|
const env = await scan(
|
|
'https://plugins.jetbrains.com/plugin/download?pluginId=com.example.benign',
|
|
{ useSandbox: false },
|
|
);
|
|
assert.equal(env.extensions.length, 0);
|
|
assert.ok(
|
|
env.meta.warnings.some((w) => /URL fetch\/extract failed/.test(w)),
|
|
`warnings: ${env.meta.warnings.join(' | ')}`,
|
|
);
|
|
});
|
|
|
|
it('reports malformed archive as a warning, no extensions scanned', async () => {
|
|
installFetchRouter((url) => {
|
|
if (/\/plugin\/download/.test(url)) {
|
|
return mockBufferResponse(Buffer.from('not a zip at all'));
|
|
}
|
|
return null;
|
|
});
|
|
const env = await scan(
|
|
'https://plugins.jetbrains.com/plugin/download?pluginId=com.example.benign',
|
|
{ useSandbox: false },
|
|
);
|
|
assert.equal(env.extensions.length, 0);
|
|
assert.ok(
|
|
env.meta.warnings.some((w) => /malformed plugin archive|URL fetch\/extract failed/.test(w)),
|
|
`warnings: ${env.meta.warnings.join(' | ')}`,
|
|
);
|
|
});
|
|
|
|
it('cannot reach JetBrains path via the VS Code-only toggle', async () => {
|
|
// Sanity: --vscode-only should not short-circuit URL fetches, but also
|
|
// shouldn't gate on extension type (URL scan fetches regardless of toggle).
|
|
const archive = buildBenignJetBrainsArchive();
|
|
installFetchRouter((url) => {
|
|
if (/\/plugin\/download/.test(url)) return mockBufferResponse(archive);
|
|
return null;
|
|
});
|
|
const env = await scan(
|
|
'https://plugins.jetbrains.com/plugin/download?pluginId=com.example.benign',
|
|
{ useSandbox: false, vscodeOnly: true },
|
|
);
|
|
// The URL was explicitly JB; we still scan it.
|
|
assert.equal(env.meta.source.kind, 'jetbrains');
|
|
assert.equal(env.extensions.length, 1);
|
|
assert.equal(env.extensions[0].type, 'jetbrains');
|
|
});
|
|
});
|