|
| 1 | +#!/usr/bin/env npx tsx |
| 2 | +// test-web.ts — web 扫描服务端到端集成测试(启真实 http 服务,打全部端点) |
| 3 | + |
| 4 | +import { spawn } from 'child_process' |
| 5 | +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs' |
| 6 | +import { join } from 'path' |
| 7 | +import { tmpdir } from 'os' |
| 8 | + |
| 9 | +let passed = 0, failed = 0 |
| 10 | +function test(name: string, cond: boolean, detail?: string) { |
| 11 | + if (cond) { passed++; console.log(` ✅ ${name}`) } |
| 12 | + else { failed++; console.log(` ❌ ${name}${detail ? ' — ' + detail : ''}`) } |
| 13 | +} |
| 14 | + |
| 15 | +async function waitUp(url: string, ms = 8000): Promise<boolean> { |
| 16 | + const t0 = Date.now() |
| 17 | + while (Date.now() - t0 < ms) { |
| 18 | + try { const r = await fetch(url); if (r.ok) return true } catch { /* retry */ } |
| 19 | + await new Promise(r => setTimeout(r, 200)) |
| 20 | + } |
| 21 | + return false |
| 22 | +} |
| 23 | + |
| 24 | +function startServer(args: string[]): Promise<any> { |
| 25 | + const child = spawn('node', ['dist/cli.js', 'web', ...args], { stdio: 'ignore' }) |
| 26 | + return Promise.resolve(child) |
| 27 | +} |
| 28 | + |
| 29 | +async function main() { |
| 30 | + console.log('\n========== ShellWard Web 服务集成测试 ==========\n') |
| 31 | + |
| 32 | + // ---- 本地模式 ---- |
| 33 | + console.log('--- 本地模式 (web --local) ---') |
| 34 | + const localPort = 8211 |
| 35 | + const localSrv = await startServer(['--local', '--port', String(localPort)]) |
| 36 | + const base = `http://localhost:${localPort}` |
| 37 | + const up = await waitUp(base + '/') |
| 38 | + test('服务启动并响应', up) |
| 39 | + if (up) { |
| 40 | + const home = await (await fetch(base + '/')).text() |
| 41 | + test('首页含「选择本地项目文件夹」', home.includes('选择本地项目文件夹')) |
| 42 | + test('首页含上传脚本 /scan-files', home.includes('scan-files')) |
| 43 | + test('首页含 URL 入口', home.includes('公开仓库地址')) |
| 44 | + |
| 45 | + // 模拟浏览器上传(客户端读文件夹后发的 JSON) |
| 46 | + const payload = { |
| 47 | + root: 'myproj', |
| 48 | + files: [ |
| 49 | + { path: 'myproj/package.json', content: '{"dependencies":{"openai":"^4"}}' }, |
| 50 | + { path: 'myproj/app.ts', content: 'const k="sk-RZ9mKp2QwLs7Yv3Nd8Tb1Hc4Xj6Pq"\nconst phone="13912345678"' }, |
| 51 | + ], |
| 52 | + } |
| 53 | + const upl = await fetch(base + '/scan-files', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) |
| 54 | + test('上传扫描返回 200', upl.status === 200, `status=${upl.status}`) |
| 55 | + const uhtml = await upl.text() |
| 56 | + test('上传报告是完整 HTML', uhtml.startsWith('<!DOCTYPE html>')) |
| 57 | + test('上传报告含 openai 依赖发现', uhtml.includes('openai')) |
| 58 | + test('上传报告含密钥发现', uhtml.includes('硬编码') || uhtml.includes('密钥')) |
| 59 | + test('上传报告含手机号发现', uhtml.includes('手机号')) |
| 60 | + |
| 61 | + // 空上传 |
| 62 | + const empty = await fetch(base + '/scan-files', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ files: [] }) }) |
| 63 | + test('空上传返回 400', empty.status === 400, `status=${empty.status}`) |
| 64 | + |
| 65 | + // 路径穿越防护 |
| 66 | + const eviltmp = mkdtempSync(join(tmpdir(), 'sw-evil-')) |
| 67 | + const evil = await fetch(base + '/scan-files', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ root: 'x', files: [{ path: '../../../../' + eviltmp.replace(/^\//, '') + '/PWNED', content: 'x' }] }) }) |
| 68 | + await evil.text() |
| 69 | + let pwned = false |
| 70 | + try { require('fs').statSync(join(eviltmp, 'PWNED')); pwned = true } catch { /* good */ } |
| 71 | + test('路径穿越被挡(未写出目标文件)', !pwned) |
| 72 | + rmSync(eviltmp, { recursive: true, force: true }) |
| 73 | + |
| 74 | + // 本地路径扫描 |
| 75 | + const proj = mkdtempSync(join(tmpdir(), 'sw-proj-')) |
| 76 | + writeFileSync(join(proj, 'package.json'), '{"dependencies":{"@anthropic-ai/sdk":"^0.2"}}') |
| 77 | + const pathScan = await fetch(base + '/scan?path=' + encodeURIComponent(proj)) |
| 78 | + test('本地路径扫描返回 200', pathScan.status === 200, `status=${pathScan.status}`) |
| 79 | + test('本地路径扫描含 Anthropic 发现', (await pathScan.text()).includes('Anthropic')) |
| 80 | + rmSync(proj, { recursive: true, force: true }) |
| 81 | + |
| 82 | + // 非法仓库 URL |
| 83 | + const bad = await fetch(base + '/scan?repo=' + encodeURIComponent('http://evil.com/a/b')) |
| 84 | + test('非法 URL 返回 400', bad.status === 400, `status=${bad.status}`) |
| 85 | + } |
| 86 | + try { localSrv.kill() } catch {} |
| 87 | + |
| 88 | + // ---- 公网模式 ---- |
| 89 | + console.log('\n--- 公网模式 (web) ---') |
| 90 | + const pubPort = 8212 |
| 91 | + const pubSrv = await startServer([String(pubPort)]) |
| 92 | + const pbase = `http://localhost:${pubPort}` |
| 93 | + const pup = await waitUp(pbase + '/') |
| 94 | + test('公网服务启动', pup) |
| 95 | + if (pup) { |
| 96 | + const phome = await (await fetch(pbase + '/')).text() |
| 97 | + test('公网首页不含本地上传(只 URL)', !phome.includes('选择本地项目文件夹') && phome.includes('公开仓库地址')) |
| 98 | + // 公网模式禁止本地路径扫描 |
| 99 | + const blocked = await fetch(pbase + '/scan?path=/etc') |
| 100 | + test('公网模式拒绝本地路径扫描 (403)', blocked.status === 403, `status=${blocked.status}`) |
| 101 | + // 公网模式拒绝上传 |
| 102 | + const noupload = await fetch(pbase + '/scan-files', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"files":[]}' }) |
| 103 | + test('公网模式拒绝上传 (403)', noupload.status === 403, `status=${noupload.status}`) |
| 104 | + } |
| 105 | + try { pubSrv.kill() } catch {} |
| 106 | + |
| 107 | + console.log(`\n========== Web 测试: ${passed} 通过, ${failed} 失败 ==========\n`) |
| 108 | + if (failed > 0) process.exit(1) |
| 109 | +} |
| 110 | + |
| 111 | +main().catch(e => { console.error('测试崩溃:', e); process.exit(1) }) |
0 commit comments