// vsix-fetch.test.mjs — Unit tests for URL detection + body capping.

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
  detectUrlType,
  fetchJetBrainsPlugin,
  fetchPluginFromUrl,
  __testing,
} from '../../scanners/lib/vsix-fetch.mjs';

const { isAllowedHost, readBodyCapped, MAX_VSIX_BYTES, JETBRAINS_ALLOWED_HOSTS } = __testing;

describe('detectUrlType', () => {
  it('detects VS Code Marketplace URL', () => {
    const out = detectUrlType('https://marketplace.visualstudio.com/items?itemName=ms-python.python');
    assert.equal(out.type, 'marketplace');
    assert.equal(out.publisher, 'ms-python');
    assert.equal(out.name, 'python');
  });

  it('returns unknown for marketplace URL without itemName', () => {
    const out = detectUrlType('https://marketplace.visualstudio.com/items');
    assert.equal(out.type, 'unknown');
  });

  it('returns unknown for marketplace itemName without dot', () => {
    const out = detectUrlType('https://marketplace.visualstudio.com/items?itemName=foobar');
    assert.equal(out.type, 'unknown');
  });

  it('detects OpenVSX URL with version', () => {
    const out = detectUrlType('https://open-vsx.org/extension/anthropic/claude-code/1.2.3');
    assert.equal(out.type, 'openvsx');
    assert.equal(out.publisher, 'anthropic');
    assert.equal(out.name, 'claude-code');
    assert.equal(out.version, '1.2.3');
  });

  it('detects OpenVSX URL without version', () => {
    const out = detectUrlType('https://open-vsx.org/extension/anthropic/claude-code');
    assert.equal(out.type, 'openvsx');
    assert.equal(out.publisher, 'anthropic');
    assert.equal(out.name, 'claude-code');
    assert.equal(out.version, null);
  });

  it('detects direct .vsix download', () => {
    const out = detectUrlType('https://example.com/path/extension.vsix');
    assert.equal(out.type, 'vsix');
  });

  it('detects GitHub URL as github (unsupported)', () => {
    const out = detectUrlType('https://github.com/anthropic/claude-code');
    assert.equal(out.type, 'github');
  });

  it('rejects plain HTTP', () => {
    const out = detectUrlType('http://marketplace.visualstudio.com/items?itemName=ms-python.python');
    assert.equal(out.type, 'unknown');
  });

  it('returns unknown for malformed URL', () => {
    const out = detectUrlType('not a url');
    assert.equal(out.type, 'unknown');
  });

  it('returns unknown for unrelated HTTPS URL', () => {
    const out = detectUrlType('https://example.com/somefile.zip');
    assert.equal(out.type, 'unknown');
  });
});

describe('isAllowedHost', () => {
  it('allows marketplace gallery cdn for marketplace fetches', () => {
    assert.equal(isAllowedHost('foo.gallerycdn.vsassets.io', 'marketplace'), true);
    assert.equal(isAllowedHost('marketplace.visualstudio.com', 'marketplace'), true);
  });

  it('rejects unrelated host for marketplace fetches', () => {
    assert.equal(isAllowedHost('evil.example.com', 'marketplace'), false);
  });

  it('allows openvsx blob storage', () => {
    assert.equal(isAllowedHost('open-vsx.org', 'openvsx'), true);
    assert.equal(isAllowedHost('openvsxorg.blob.core.windows.net', 'openvsx'), true);
  });

  it('rejects unrelated host for openvsx fetches', () => {
    assert.equal(isAllowedHost('evil.example.com', 'openvsx'), false);
  });
});

describe('readBodyCapped', () => {
  function makeStreamResponse(chunks) {
    const stream = new ReadableStream({
      start(controller) {
        for (const chunk of chunks) controller.enqueue(chunk);
        controller.close();
      },
    });
    return new Response(stream);
  }

  it('reads small body fully and computes SHA-256', async () => {
    const data = new TextEncoder().encode('hello world');
    const res = makeStreamResponse([data]);
    const ctrl = new AbortController();
    const out = await readBodyCapped(res, ctrl);
    assert.equal(out.size, 11);
    assert.equal(out.buffer.toString('utf8'), 'hello world');
    // sha256("hello world")
    assert.equal(out.sha256, 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9');
  });

  it('aborts when body exceeds MAX_VSIX_BYTES', async () => {
    // Stream a small chunk repeated such that total > cap.
    const chunkSize = 1024 * 1024;
    const chunk = new Uint8Array(chunkSize);
    const totalChunks = Math.ceil(MAX_VSIX_BYTES / chunkSize) + 2; // overshoot
    const stream = new ReadableStream({
      async start(controller) {
        for (let i = 0; i < totalChunks; i++) controller.enqueue(chunk);
        controller.close();
      },
    });
    const res = new Response(stream);
    const ctrl = new AbortController();
    await assert.rejects(() => readBodyCapped(res, ctrl), /exceeds maximum size/);
  });
});

// ---------------------------------------------------------------------------
// JetBrains Marketplace — URL detection + host whitelist + fetch happy path
// ---------------------------------------------------------------------------

describe('detectUrlType — JetBrains Marketplace', () => {
  it('detects /plugin/<numericId>-<slug>', () => {
    const out = detectUrlType('https://plugins.jetbrains.com/plugin/7973-intellivue');
    assert.equal(out.type, 'jetbrains');
    assert.equal(out.numericId, '7973');
    assert.equal(out.xmlId, null);
  });

  it('detects /plugin/<numericId> without slug', () => {
    const out = detectUrlType('https://plugins.jetbrains.com/plugin/7973');
    assert.equal(out.type, 'jetbrains');
    assert.equal(out.numericId, '7973');
  });

  it('detects /plugin/download?pluginId=<xmlId>', () => {
    const out = detectUrlType('https://plugins.jetbrains.com/plugin/download?pluginId=com.example.plugin');
    assert.equal(out.type, 'jetbrains');
    assert.equal(out.xmlId, 'com.example.plugin');
    assert.equal(out.numericId, null);
  });

  it('detects /plugin/download?pluginId=<xmlId>&version=<v>', () => {
    const out = detectUrlType('https://plugins.jetbrains.com/plugin/download?pluginId=com.example&version=1.2.3');
    assert.equal(out.type, 'jetbrains');
    assert.equal(out.xmlId, 'com.example');
    assert.equal(out.version, '1.2.3');
  });

  it('rejects /plugin/download without pluginId', () => {
    const out = detectUrlType('https://plugins.jetbrains.com/plugin/download');
    assert.equal(out.type, 'unknown');
  });

  it('rejects malformed xmlId (special characters)', () => {
    const out = detectUrlType('https://plugins.jetbrains.com/plugin/download?pluginId=evil%3B%20ls');
    assert.equal(out.type, 'unknown');
  });

  it('rejects non-numeric numericId', () => {
    const out = detectUrlType('https://plugins.jetbrains.com/plugin/abc-intellivue');
    assert.equal(out.type, 'unknown');
  });
});

describe('isAllowedHost — JetBrains host whitelist', () => {
  it('accepts plugins.jetbrains.com', () => {
    assert.equal(isAllowedHost('plugins.jetbrains.com', 'jetbrains'), true);
  });

  it('accepts downloads.marketplace.jetbrains.com', () => {
    assert.equal(isAllowedHost('downloads.marketplace.jetbrains.com', 'jetbrains'), true);
  });

  it('accepts cache-redirector.jetbrains.com', () => {
    assert.equal(isAllowedHost('cache-redirector.jetbrains.com', 'jetbrains'), true);
  });

  it('rejects evil.jetbrains.com (subdomain takeover defense)', () => {
    assert.equal(isAllowedHost('evil.jetbrains.com', 'jetbrains'), false);
  });

  it('rejects unrelated host attacker.example.com', () => {
    assert.equal(isAllowedHost('attacker.example.com', 'jetbrains'), false);
  });

  it('rejects typosquat jetbrains.com.evil.com', () => {
    assert.equal(isAllowedHost('jetbrains.com.evil.com', 'jetbrains'), false);
  });

  it('JETBRAINS_ALLOWED_HOSTS has exactly 3 entries', () => {
    assert.equal(JETBRAINS_ALLOWED_HOSTS.size, 3);
  });
});

describe('fetchJetBrainsPlugin — happy path with mocked fetch', () => {
  it('fetches by xmlId directly (no metadata lookup needed)', async () => {
    const origFetch = globalThis.fetch;
    const fakeVsix = Buffer.from('PK\x03\x04fake-jar-bytes-for-test');
    try {
      globalThis.fetch = async (url) => {
        assert.match(String(url), /plugin\/download\?pluginId=com\.example/);
        return new Response(fakeVsix, {
          status: 200,
          headers: { 'content-type': 'application/zip' },
        });
      };
      const out = await fetchJetBrainsPlugin({ xmlId: 'com.example' });
      assert.equal(out.source.type, 'jetbrains');
      assert.equal(out.source.xmlId, 'com.example');
      assert.equal(out.size, fakeVsix.length);
      assert.ok(out.sha256);
      assert.ok(out.sha256.length === 64);
    } finally {
      globalThis.fetch = origFetch;
    }
  });

  it('resolves numericId → xmlId via metadata lookup, then downloads', async () => {
    const origFetch = globalThis.fetch;
    const calls = [];
    const fakeVsix = Buffer.from('PK\x03\x04xx');
    try {
      globalThis.fetch = async (url) => {
        calls.push(String(url));
        if (String(url).includes('/api/plugins/7973')) {
          return new Response(JSON.stringify({ xmlId: 'com.example.intellivue' }), {
            status: 200, headers: { 'content-type': 'application/json' },
          });
        }
        return new Response(fakeVsix, { status: 200 });
      };
      const out = await fetchJetBrainsPlugin({ numericId: '7973' });
      assert.equal(out.source.xmlId, 'com.example.intellivue');
      assert.equal(calls.length, 2);
      assert.match(calls[0], /\/api\/plugins\/7973$/);
      assert.match(calls[1], /plugin\/download\?pluginId=com\.example\.intellivue/);
    } finally {
      globalThis.fetch = origFetch;
    }
  });

  it('rejects invalid numericId', async () => {
    await assert.rejects(
      () => fetchJetBrainsPlugin({ numericId: 'abc' }),
      /invalid numericId/,
    );
  });

  it('rejects missing both xmlId and numericId', async () => {
    await assert.rejects(
      () => fetchJetBrainsPlugin({}),
      /need xmlId or numericId/,
    );
  });

  it('rejects suspicious xmlId (shell-metachar)', async () => {
    await assert.rejects(
      () => fetchJetBrainsPlugin({ xmlId: 'evil;rm -rf' }),
      /suspicious xmlId/,
    );
  });
});

describe('fetchPluginFromUrl — routes JetBrains vs VSIX', () => {
  it('dispatches JetBrains URLs to fetchJetBrainsPlugin', async () => {
    const origFetch = globalThis.fetch;
    try {
      globalThis.fetch = async () => new Response(Buffer.from('x'), { status: 200 });
      const out = await fetchPluginFromUrl('https://plugins.jetbrains.com/plugin/download?pluginId=com.example');
      assert.equal(out.source.type, 'jetbrains');
    } finally {
      globalThis.fetch = origFetch;
    }
  });

  it('dispatches OpenVSX URLs through the VSIX path (no regression)', async () => {
    const origFetch = globalThis.fetch;
    try {
      // Minimal OpenVSX happy-path: version in URL → single download call.
      globalThis.fetch = async () => new Response(Buffer.from('x'), { status: 200 });
      const out = await fetchPluginFromUrl('https://open-vsx.org/extension/redhat/java/1.29.0');
      assert.equal(out.source.type, 'openvsx');
    } finally {
      globalThis.fetch = origFetch;
    }
  });
});
