const test = require('node:test'); const assert = require('node:assert'); const http = require('node:http'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const { spawnSync } = require('node:child_process'); require('../src/git/gitBinary.js').useBundledGit(); const { registerFeatureIpc, parsePorcelainZ } = require('../src/features/featuresIpc.js'); const { scanForSecrets, checkName } = require('../src/features/secretScan.js'); // Minimaler Gitea-Nachbau für Diagnose, PRs/Issues und Backup function fakeGitea() { const calls = []; const comments = []; const server = http.createServer((req, res) => { const u = new URL(req.url, 'http://x'); calls.push(`${req.method} ${u.pathname}`); const send = (code, obj) => { res.statusCode = code; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(obj)); }; let body = ''; req.on('data', c => body += c); req.on('end', () => { const p = u.pathname; if (p === '/api/v1/version') return send(200, { version: '1.24.0' }); if (p === '/api/v1/user') return send(200, { login: 'M_Viper' }); if (p === '/api/v1/user/repos') { if (u.searchParams.get('page') && u.searchParams.get('page') !== '1') return send(200, []); return send(200, [{ name: 'deskhub', owner: { login: 'M_Viper' }, default_branch: 'main', empty: false, clone_url: `http://127.0.0.1:${server.address().port}/M_Viper/deskhub.git` }]); } if (p === '/api/v1/repos/M_Viper/deskhub/pulls') return send(200, [{ number: 3, title: 'Fix', state: 'open', user: { login: 'a' }, labels: [], head: { label: 'fix' }, base: { label: 'main' }, mergeable: true }]); if (p === '/api/v1/repos/M_Viper/deskhub/issues' && req.method === 'GET') return send(200, [{ number: 5, title: 'Bug', state: 'open', labels: [{ name: 'bug', color: 'ff0000' }] }]); if (p === '/api/v1/repos/M_Viper/deskhub/issues' && req.method === 'POST') return send(201, { number: 6, ...JSON.parse(body), state: 'open', labels: [] }); if (p === '/api/v1/repos/M_Viper/deskhub/issues/5/comments' && req.method === 'POST') { comments.push(JSON.parse(body).body); return send(201, {}); } if (p === '/api/v1/repos/M_Viper/deskhub/issues/5/comments') return send(200, comments.map((b, i) => ({ id: i, body: b, user: { login: 'M_Viper' } }))); if (p === '/api/v1/repos/M_Viper/deskhub/issues/5') return send(200, { number: 5, title: 'Bug', state: 'open', labels: [] }); if (p === '/api/v1/repos/M_Viper/deskhub/pulls/3/merge') return send(200, {}); if (p === '/api/v1/repos/M_Viper/deskhub/archive/main.zip') { res.statusCode = 200; res.setHeader('Content-Type', 'application/zip'); return res.end(Buffer.from('PK\u0003\u0004fake')); } send(404, { message: 'not found' }); }); }); return new Promise(r => server.listen(0, '127.0.0.1', () => r({ server, calls, comments, url: `http://127.0.0.1:${server.address().port}` }))); } function setup(url) { const handlers = {}; const userData = fs.mkdtempSync(path.join(os.tmpdir(), 'gm-ud-')); registerFeatureIpc({ ipcMain: { handle: (name, fn) => { handlers[name] = fn; } }, app: { getPath: () => userData }, readCredentials: () => ({ giteaURL: url, giteaToken: 'geheim123' }), buildItemsFromPaths: async (paths) => ({ items: paths.map(p => ({ localFile: p, targetPath: path.basename(p) })), skipped: [], ignored: [] }), clearGitTransportBlock: () => false }); const call = (name, data) => handlers[name]({ sender: { send() {} } }, data); return { call, userData }; } test('Geheimnisse werden an Name und Inhalt erkannt, Vorlagen nicht', async () => { assert.ok(checkName('google-services.json')); assert.ok(checkName('.env')); assert.ok(checkName('release.keystore')); assert.strictEqual(checkName('.env.example'), null); assert.strictEqual(checkName('README.md'), null); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gm-sec-')); const bot = path.join(dir, 'bot.js'); fs.writeFileSync(bot, 'const TOKEN = "123456789:AA' + 'b'.repeat(33) + '";'); const clean = path.join(dir, 'app.js'); fs.writeFileSync(clean, 'console.log("hallo")'); const found = await scanForSecrets([{ localFile: bot, size: fs.statSync(bot).size }, { localFile: clean, size: fs.statSync(clean).size }]); assert.match(found.get(bot), /Telegram/); assert.strictEqual(found.has(clean), false); fs.rmSync(dir, { recursive: true, force: true }); }); test('git status -z wird korrekt gelesen (inkl. Umbenennung)', () => { const files = parsePorcelainZ(' M src/a.js\0?? neu.txt\0R neu/name.js\0alt/name.js\0D weg.txt\0'); assert.deepStrictEqual(files.map(f => [f.code, f.path, f.from]), [['M', 'src/a.js', null], ['?', 'neu.txt', null], ['R', 'neu/name.js', 'alt/name.js'], ['D', 'weg.txt', null]]); }); test('Lokale Änderungen: Status, Diff und Commit nur der ausgewählten Dateien', async () => { const { call } = setup('http://127.0.0.1:1'); const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gm-local-')); const git = (...a) => spawnSync('git', ['-C', repo, '-c', 'user.name=t', '-c', 'user.email=t@t', ...a], { encoding: 'utf8' }); git('init', '-q'); fs.writeFileSync(path.join(repo, 'a.txt'), 'eins\n'); git('add', '.'); git('commit', '-qm', 'start'); fs.writeFileSync(path.join(repo, 'a.txt'), 'zwei\n'); fs.writeFileSync(path.join(repo, 'b.txt'), 'neu\n'); const st = await call('local-git', { action: 'status', folder: repo }); assert.strictEqual(st.ok, true); assert.deepStrictEqual(st.files.map(f => f.path).sort(), ['a.txt', 'b.txt']); const diff = await call('local-git', { action: 'diff', folder: repo, file: 'a.txt', code: 'M' }); assert.match(diff.diff, /-eins/); assert.match(diff.diff, /\+zwei/); const c = await call('local-git', { action: 'commit', folder: repo, files: ['a.txt'], message: 'nur a' }); assert.strictEqual(c.ok, true, c.error); const after = await call('local-git', { action: 'status', folder: repo }); assert.deepStrictEqual(after.files.map(f => f.path), ['b.txt']); const notRepo = await call('local-git', { action: 'status', folder: os.tmpdir() + path.sep + 'gm-gibtsnicht-' + Date.now() }); assert.strictEqual(notRepo.ok, false); fs.rmSync(repo, { recursive: true, force: true }); }); test('Upload-Vorschau markiert Geheimnisse', async () => { const { call } = setup('http://127.0.0.1:1'); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gm-prev-')); const a = path.join(dir, 'main.kt'); fs.writeFileSync(a, 'fun main() {}'); const g = path.join(dir, 'google-services.json'); fs.writeFileSync(g, '{}'); const r = await call('preview-upload-paths', { paths: [a, g], destPath: '' }); assert.strictEqual(r.ok, true); assert.strictEqual(r.secretCount, 1); assert.ok(r.files.find(f => f.localFile === g).secret); fs.rmSync(dir, { recursive: true, force: true }); }); test('Diagnose, PRs/Issues und Backup gegen einen nachgebauten Server', async () => { const g = await fakeGitea(); const { call } = setup(g.url); try { const d = await call('run-server-diagnosis', {}); const byLabel = Object.fromEntries(d.checks.map(c => [c.label, c.status])); assert.strictEqual(byLabel['Gitea-API'], 'ok'); assert.strictEqual(byLabel['Token'], 'ok'); assert.strictEqual(byLabel['Erreichbar über IPv4'], 'ok'); assert.ok(!JSON.stringify(d).includes('geheim123'), 'Token darf nicht in der Diagnose stehen'); const pulls = await call('gitea-collab', { action: 'listPulls', owner: 'M_Viper', repo: 'deskhub' }); assert.strictEqual(pulls.items[0].head, 'fix'); const issues = await call('gitea-collab', { action: 'listIssues', owner: 'M_Viper', repo: 'deskhub' }); assert.strictEqual(issues.items[0].labels[0].name, 'bug'); assert.strictEqual((await call('gitea-collab', { action: 'comment', owner: 'M_Viper', repo: 'deskhub', index: 5, body: 'Danke' })).ok, true); const detail = await call('gitea-collab', { action: 'get', owner: 'M_Viper', repo: 'deskhub', index: 5 }); assert.strictEqual(detail.comments[0].body, 'Danke'); assert.strictEqual((await call('gitea-collab', { action: 'merge', owner: 'M_Viper', repo: 'deskhub', index: 3, style: 'squash' })).ok, true); assert.strictEqual((await call('gitea-collab', { action: 'createIssue', owner: 'M_Viper', repo: 'deskhub', title: 'Neu' })).item.number, 6); const target = fs.mkdtempSync(path.join(os.tmpdir(), 'gm-bk-')); await call('repo-backup-set-config', { targetDir: target, interval: 'daily', keep: 2 }); for (let i = 0; i < 3; i++) { const r = await call('repo-backup-run-now', {}); assert.strictEqual(r.summary.ok, 1, JSON.stringify(r)); await new Promise(res => setTimeout(res, 61000 / 60)); // eindeutige Zeitstempel } const zips = fs.readdirSync(path.join(target, 'M_Viper', 'deskhub-archiv')); assert.ok(zips.length <= 3 && zips.length >= 1); const state = await call('repo-backup-get-config', {}); assert.strictEqual(state.config.lastResult.total, 1); fs.rmSync(target, { recursive: true, force: true }); } finally { g.server.close(); } });