/ src / download / index.test.ts
index.test.ts
  1  import * as fs from 'node:fs';
  2  import * as http from 'node:http';
  3  import * as os from 'node:os';
  4  import * as path from 'node:path';
  5  import { afterEach, describe, expect, it, vi } from 'vitest';
  6  import { formatCookieHeader, httpDownload, resolveRedirectUrl } from './index.js';
  7  
  8  const servers: http.Server[] = [];
  9  const tempDirs: string[] = [];
 10  
 11  afterEach(async () => {
 12    vi.unstubAllEnvs();
 13    await Promise.all(servers.map((server) => new Promise<void>((resolve, reject) => {
 14      server.close((err) => (err ? reject(err) : resolve()));
 15    })));
 16    servers.length = 0;
 17    for (const dir of tempDirs) {
 18      try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
 19    }
 20    tempDirs.length = 0;
 21  });
 22  
 23  async function startServer(handler: http.RequestListener, hostname = '127.0.0.1'): Promise<string> {
 24    const server = http.createServer(handler);
 25    servers.push(server);
 26    await new Promise<void>((resolve) => server.listen(0, hostname, resolve));
 27    const address = server.address();
 28    if (!address || typeof address === 'string') {
 29      throw new Error('Failed to start test server');
 30    }
 31    return `http://${hostname}:${address.port}`;
 32  }
 33  
 34  // Windows Defender can briefly lock newly-written .tmp files, causing EPERM.
 35  // Retry once to handle this flakiness.
 36  describe('download helpers', { retry: process.platform === 'win32' ? 2 : 0 }, () => {
 37    it('resolves relative redirects against the original URL', () => {
 38      expect(resolveRedirectUrl('https://example.com/a/file', '/cdn/file.bin')).toBe('https://example.com/cdn/file.bin');
 39      expect(resolveRedirectUrl('https://example.com/a/file', '../next')).toBe('https://example.com/next');
 40    });
 41  
 42    it('formats browser cookies into a Cookie header', () => {
 43      expect(formatCookieHeader([
 44        { name: 'sid', value: 'abc', domain: 'example.com' },
 45        { name: 'ct0', value: 'def', domain: 'example.com' },
 46      ])).toBe('sid=abc; ct0=def');
 47    });
 48  
 49    it('fails after exceeding the redirect limit', async () => {
 50      const baseUrl = await startServer((_req, res) => {
 51        res.statusCode = 302;
 52        res.setHeader('Location', '/loop');
 53        res.end();
 54      });
 55  
 56      const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-dl-'));
 57      tempDirs.push(tempDir);
 58      const destPath = path.join(tempDir, 'file.txt');
 59      const result = await httpDownload(`${baseUrl}/loop`, destPath, { maxRedirects: 2 });
 60  
 61      expect(result).toEqual({
 62        success: false,
 63        size: 0,
 64        error: 'Too many redirects (> 2)',
 65      });
 66      expect(fs.existsSync(destPath)).toBe(false);
 67    });
 68  
 69    it('does not forward cookies across cross-domain redirects', async () => {
 70      let forwardedCookie: string | undefined;
 71      const targetUrl = await startServer((req, res) => {
 72        forwardedCookie = req.headers.cookie;
 73        res.statusCode = 200;
 74        res.end('ok');
 75      }, 'localhost');
 76  
 77      const redirectUrl = await startServer((_req, res) => {
 78        res.statusCode = 302;
 79        res.setHeader('Location', targetUrl);
 80        res.end();
 81      });
 82  
 83      const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-dl-'));
 84      tempDirs.push(tempDir);
 85      const destPath = path.join(tempDir, 'redirect.txt');
 86      const result = await httpDownload(`${redirectUrl}/start`, destPath, { cookies: 'sid=abc' });
 87  
 88      expect(result).toEqual({ success: true, size: 2 });
 89      expect(forwardedCookie).toBeUndefined();
 90      expect(fs.readFileSync(destPath, 'utf8')).toBe('ok');
 91    });
 92  
 93    it('does not forward cookie headers across cross-domain redirects', async () => {
 94      let forwardedCookie: string | undefined;
 95      const targetUrl = await startServer((req, res) => {
 96        forwardedCookie = req.headers.cookie;
 97        res.statusCode = 200;
 98        res.end('ok');
 99      }, 'localhost');
100  
101      const redirectUrl = await startServer((_req, res) => {
102        res.statusCode = 302;
103        res.setHeader('Location', targetUrl);
104        res.end();
105      });
106  
107      const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-dl-'));
108      tempDirs.push(tempDir);
109      const destPath = path.join(tempDir, 'redirect-header.txt');
110      const result = await httpDownload(`${redirectUrl}/start`, destPath, {
111        headers: { Cookie: 'sid=header-cookie' },
112      });
113  
114      expect(result).toEqual({ success: true, size: 2 });
115      expect(forwardedCookie).toBeUndefined();
116      expect(fs.readFileSync(destPath, 'utf8')).toBe('ok');
117    });
118  
119    it('bypasses proxy settings for loopback downloads', async () => {
120      vi.stubEnv('HTTP_PROXY', 'http://127.0.0.1:9');
121  
122      const baseUrl = await startServer((_req, res) => {
123        res.statusCode = 200;
124        res.end('ok');
125      });
126  
127      const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-dl-'));
128      tempDirs.push(tempDir);
129      const destPath = path.join(tempDir, 'loopback.txt');
130      const result = await httpDownload(`${baseUrl}/ok`, destPath);
131  
132      expect(result).toEqual({ success: true, size: 2 });
133      expect(fs.readFileSync(destPath, 'utf8')).toBe('ok');
134    });
135  });