Upload via GUI (7543 Dateien)
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
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');
|
||||
|
||||
require('../src/git/gitBinary.js').useBundledGit();
|
||||
const { registerFeatureIpc } = require('../src/features/featuresIpc.js');
|
||||
|
||||
// Drei Repos; das ZIP von "langsam" kommt in kleinen Stücken über ~3 s, damit ein Abbruch mitten hineinfällt
|
||||
function fakeGitea() {
|
||||
const server = http.createServer((req, res) => {
|
||||
const u = new URL(req.url, 'http://x');
|
||||
const send = (code, obj) => { res.statusCode = code; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(obj)); };
|
||||
if (u.pathname === '/api/v1/user/repos') {
|
||||
if (u.searchParams.get('page') !== '1') return send(200, []);
|
||||
const port = server.address().port;
|
||||
return send(200, ['eins', 'langsam', 'drei'].map(name => ({
|
||||
name, owner: { login: 'M_Viper' }, default_branch: 'main', empty: false,
|
||||
clone_url: `http://127.0.0.1:${port}/M_Viper/${name}.git`
|
||||
})));
|
||||
}
|
||||
const m = u.pathname.match(/^\/api\/v1\/repos\/M_Viper\/(\w+)\/archive\/main\.zip$/);
|
||||
if (m) {
|
||||
const total = 64 * 1024;
|
||||
res.writeHead(200, { 'Content-Type': 'application/zip', 'Content-Length': String(total) });
|
||||
if (m[1] !== 'langsam') return res.end(Buffer.alloc(total, 1));
|
||||
let sent = 0;
|
||||
const t = setInterval(() => {
|
||||
if (res.destroyed) return clearInterval(t);
|
||||
res.write(Buffer.alloc(2048, 1));
|
||||
sent += 2048;
|
||||
if (sent >= total) { clearInterval(t); res.end(); }
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
send(404, { message: 'not found' });
|
||||
});
|
||||
return new Promise(r => server.listen(0, '127.0.0.1', () => r({ server, url: `http://127.0.0.1:${server.address().port}` })));
|
||||
}
|
||||
|
||||
function setup(url) {
|
||||
const handlers = {};
|
||||
const events = [];
|
||||
registerFeatureIpc({
|
||||
ipcMain: { handle: (name, fn) => { handlers[name] = fn; } },
|
||||
app: { getPath: () => fs.mkdtempSync(path.join(os.tmpdir(), 'gm-ud-')) },
|
||||
readCredentials: () => ({ giteaURL: url, giteaToken: 't' }),
|
||||
buildItemsFromPaths: async () => ({ items: [], skipped: [], ignored: [] }),
|
||||
clearGitTransportBlock: () => false,
|
||||
broadcast: (channel, p) => { if (channel === 'repo-backup-progress') events.push(p); }
|
||||
});
|
||||
return { call: (name, data) => handlers[name]({ sender: { send() {} } }, data), events };
|
||||
}
|
||||
|
||||
test('Backup meldet Fortschritt: Phasen, aktuelles Repo, Bytes und Ergebnis je Repo', async () => {
|
||||
const g = await fakeGitea();
|
||||
try {
|
||||
const { call, events } = setup(g.url);
|
||||
const target = fs.mkdtempSync(path.join(os.tmpdir(), 'gm-bk-'));
|
||||
await call('repo-backup-set-config', { targetDir: target });
|
||||
const r = await call('repo-backup-run-now', {});
|
||||
assert.strictEqual(r.summary.ok, 3, JSON.stringify(r));
|
||||
|
||||
const phases = [...new Set(events.map(e => e.phase))];
|
||||
assert.deepStrictEqual(phases, ['start', 'list', 'running', 'done']);
|
||||
assert.deepStrictEqual(events.find(e => e.phase === 'running').repos, ['M_Viper/eins', 'M_Viper/langsam', 'M_Viper/drei']);
|
||||
assert.ok(events.some(e => e.repo === 'M_Viper/langsam' && e.bytes > 0 && e.bytesTotal === 64 * 1024), 'Download-Fortschritt fehlt');
|
||||
const last = events[events.length - 1];
|
||||
assert.strictEqual(last.results.length, 3);
|
||||
assert.ok(last.results.every(x => x.ok && x.method === 'ZIP'));
|
||||
assert.ok(!events.some(e => 'abort' in e), 'interne Felder dürfen nicht nach außen gehen');
|
||||
|
||||
// Während kein Backup läuft, gibt es auch keinen Zwischenstand
|
||||
const state = await call('repo-backup-get-config', {});
|
||||
assert.strictEqual(state.running, false);
|
||||
assert.strictEqual(state.progress, null);
|
||||
} finally {
|
||||
g.server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Abbrechen beendet den laufenden ZIP-Download sofort und überspringt den Rest', async () => {
|
||||
const g = await fakeGitea();
|
||||
try {
|
||||
const { call, events } = setup(g.url);
|
||||
const target = fs.mkdtempSync(path.join(os.tmpdir(), 'gm-bk-'));
|
||||
await call('repo-backup-set-config', { targetDir: target });
|
||||
const started = Date.now();
|
||||
const running = call('repo-backup-run-now', {});
|
||||
|
||||
// warten, bis der langsame Download läuft
|
||||
while (!events.some(e => e.repo === 'M_Viper/langsam' && e.bytes > 0)) {
|
||||
if (Date.now() - started > 10000) throw new Error('Download startete nicht');
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
}
|
||||
const state = await call('repo-backup-get-config', {});
|
||||
assert.strictEqual(state.running, true);
|
||||
assert.strictEqual(state.progress.repo, 'M_Viper/langsam');
|
||||
|
||||
assert.strictEqual((await call('repo-backup-cancel', {})).ok, true);
|
||||
const r = await running;
|
||||
assert.ok(Date.now() - started < 2500, 'Abbruch muss den Download sofort beenden');
|
||||
assert.strictEqual(r.ok, false);
|
||||
assert.strictEqual(r.summary.cancelled, true);
|
||||
assert.strictEqual(r.summary.ok, 1);
|
||||
assert.strictEqual(r.summary.skipped, 2);
|
||||
// keine halbe Datei zurücklassen
|
||||
const archiv = path.join(target, 'M_Viper', 'langsam-archiv');
|
||||
assert.deepStrictEqual(fs.existsSync(archiv) ? fs.readdirSync(archiv) : [], []);
|
||||
assert.strictEqual((await call('repo-backup-cancel', {})).ok, false);
|
||||
} finally {
|
||||
g.server.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const http = require('node:http');
|
||||
const { deleteGiteaPathsBatched } = require('../src/git/apiHandler.js');
|
||||
|
||||
// Minimaler Gitea-Nachbau: Repo-Info, Trees-API (seitenweise) und Mehrfach-Commit-Endpunkt.
|
||||
function fakeGitea(paths, { perPage = 1000, failPath = null } = {}) {
|
||||
const files = new Map(paths.map((p, i) => [p, `sha${i}`]));
|
||||
const commits = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
const u = new URL(req.url, 'http://x');
|
||||
const send = (code, obj) => { res.statusCode = code; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(obj)); };
|
||||
if (req.method === 'GET' && u.pathname === '/api/v1/repos/o/r') return send(200, { default_branch: 'main' });
|
||||
if (req.method === 'GET' && u.pathname === '/api/v1/repos/o/r/git/trees/main') {
|
||||
const page = Number(u.searchParams.get('page') || 1);
|
||||
const all = [...files.keys()].map(p => ({ path: p, type: 'blob', sha: files.get(p) }));
|
||||
const slice = all.slice((page - 1) * perPage, page * perPage);
|
||||
return send(200, { tree: slice, truncated: page * perPage < all.length, total_count: all.length, page });
|
||||
}
|
||||
if (req.method === 'POST' && u.pathname === '/api/v1/repos/o/r/contents') {
|
||||
let body = '';
|
||||
req.on('data', c => body += c);
|
||||
req.on('end', () => {
|
||||
const { files: ops } = JSON.parse(body);
|
||||
if (failPath && ops.some(o => o.path === failPath)) return send(422, { message: 'kaputt' });
|
||||
for (const o of ops) {
|
||||
assert.strictEqual(o.operation, 'delete');
|
||||
assert.strictEqual(o.sha, files.get(o.path));
|
||||
files.delete(o.path);
|
||||
}
|
||||
commits.push(ops.length);
|
||||
send(201, {});
|
||||
});
|
||||
return;
|
||||
}
|
||||
send(404, { message: 'object does not exist' });
|
||||
});
|
||||
return new Promise(r => server.listen(0, '127.0.0.1', () => r({ server, files, commits, url: `http://127.0.0.1:${server.address().port}` })));
|
||||
}
|
||||
|
||||
test('Ordner wird paketweise gelöscht, Nachbarn bleiben', async () => {
|
||||
const paths = [...Array.from({ length: 2500 }, (_, i) => `mobile/build/f${i}.dex`), 'mobile/build.gradle.kts', 'mobile/src/A.kt'];
|
||||
const g = await fakeGitea(paths, { perPage: 1000 });
|
||||
try {
|
||||
const r = await deleteGiteaPathsBatched({ token: 't', url: g.url, owner: 'o', repo: 'r', prefix: 'mobile/build' });
|
||||
assert.strictEqual(r.deleted, 2500);
|
||||
assert.deepStrictEqual(g.commits, Array(25).fill(100));
|
||||
assert.deepStrictEqual([...g.files.keys()].sort(), ['mobile/build.gradle.kts', 'mobile/src/A.kt']);
|
||||
} finally { g.server.close(); }
|
||||
});
|
||||
|
||||
test('eine fehlerhafte Datei blockiert nicht das ganze Paket', async () => {
|
||||
const paths = Array.from({ length: 10 }, (_, i) => `x/f${i}`);
|
||||
const g = await fakeGitea(paths, { failPath: 'x/f3' });
|
||||
try {
|
||||
const r = await deleteGiteaPathsBatched({ token: 't', url: g.url, owner: 'o', repo: 'r', paths });
|
||||
assert.strictEqual(r.deleted, 9);
|
||||
assert.deepStrictEqual(r.failed.map(f => f.path), ['x/f3']);
|
||||
} finally { g.server.close(); }
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const http = require('node:http');
|
||||
const { bareHostname } = require('../src/utils/helpers.js');
|
||||
const { checkGiteaConnection } = require('../src/git/apiHandler.js');
|
||||
|
||||
function listen(host) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ version: '1.22.0' }));
|
||||
});
|
||||
server.once('error', reject);
|
||||
server.listen(0, host, () => resolve(server));
|
||||
});
|
||||
}
|
||||
|
||||
test('bareHostname entfernt Klammern nur bei IPv6-Literalen', () => {
|
||||
assert.strictEqual(bareHostname(new URL('http://[2001:db8::1]:3000').hostname), '2001:db8::1');
|
||||
assert.strictEqual(bareHostname('[::1]'), '::1');
|
||||
assert.strictEqual(bareHostname('127.0.0.1'), '127.0.0.1');
|
||||
assert.strictEqual(bareHostname('git.example.com'), 'git.example.com');
|
||||
assert.strictEqual(bareHostname(undefined), '');
|
||||
});
|
||||
|
||||
test('Gitea-Verbindung über IPv4', async () => {
|
||||
const server = await listen('127.0.0.1');
|
||||
try {
|
||||
const r = await checkGiteaConnection({ url: `http://127.0.0.1:${server.address().port}` });
|
||||
assert.strictEqual(r.ok, true);
|
||||
} finally { server.close(); }
|
||||
});
|
||||
|
||||
test('Gitea-Verbindung über IPv6', async (t) => {
|
||||
let server;
|
||||
try { server = await listen('::1'); } catch { return t.skip('IPv6-Loopback nicht verfügbar'); }
|
||||
try {
|
||||
const r = await checkGiteaConnection({ url: `http://[::1]:${server.address().port}` });
|
||||
assert.strictEqual(r.ok, true);
|
||||
} finally { server.close(); }
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const { createPrefs, DEFAULTS } = require('../src/features/appPrefs.js');
|
||||
const { useBundledGit, applyGitIdentity, commitIdentity } = require('../src/git/gitBinary.js');
|
||||
const { createRepoBackup, excludeMatcher } = require('../src/backup/GiteaRepoBackup.js');
|
||||
|
||||
const tmp = (p) => fs.mkdtempSync(path.join(os.tmpdir(), p));
|
||||
|
||||
test('Einstellungen: Standardwerte, Bereinigung und ungültige E-Mail', () => {
|
||||
const file = path.join(tmp('gm-prefs-'), 'app-settings.json');
|
||||
const prefs = createPrefs(file);
|
||||
assert.deepStrictEqual(prefs.get(), DEFAULTS);
|
||||
|
||||
const r = prefs.set({ zoom: 400, theme: 'pink', closeAction: 'tray', gitName: 'Max <x>\n', unbekannt: 1, uploadSecretScan: 0 });
|
||||
assert.strictEqual(r.prefs.zoom, 150);
|
||||
assert.strictEqual(r.prefs.theme, 'dark');
|
||||
assert.strictEqual(r.prefs.closeAction, 'tray');
|
||||
assert.strictEqual(r.prefs.gitName, 'Max x');
|
||||
assert.strictEqual(r.prefs.uploadSecretScan, false);
|
||||
assert.ok(!('unbekannt' in r.prefs));
|
||||
|
||||
const bad = prefs.set({ gitEmail: 'keine-mail' });
|
||||
assert.deepStrictEqual(bad.rejected, ['gitEmail']);
|
||||
assert.strictEqual(bad.prefs.gitEmail, '');
|
||||
|
||||
// nach dem Neuladen noch da
|
||||
assert.strictEqual(createPrefs(file).get().closeAction, 'tray');
|
||||
});
|
||||
|
||||
test('Git-Identität greift nur als Rückfallwert und verschwindet beim Leeren', () => {
|
||||
useBundledGit();
|
||||
const userData = tmp('gm-ud-');
|
||||
const home = tmp('gm-home-');
|
||||
const env = { ...process.env, HOME: home, USERPROFILE: home };
|
||||
const ident = () => spawnSync('git', ['var', 'GIT_AUTHOR_IDENT'], { encoding: 'utf8', env: { ...env, GIT_CONFIG_SYSTEM: process.env.GIT_CONFIG_SYSTEM || '' } }).stdout;
|
||||
|
||||
assert.strictEqual(applyGitIdentity({ name: 'Test Name', email: 'test@example.com' }, userData), true);
|
||||
assert.match(ident(), /^Test Name <test@example\.com>/);
|
||||
|
||||
// Eine globale Einstellung hat Vorrang
|
||||
fs.writeFileSync(path.join(home, '.gitconfig'), '[user]\n\tname = Global\n\temail = global@example.com\n');
|
||||
assert.match(ident(), /^Global <global@example\.com>/);
|
||||
|
||||
applyGitIdentity({ name: '', email: '' }, userData);
|
||||
assert.strictEqual(process.env.GIT_CONFIG_SYSTEM, undefined);
|
||||
assert.ok(!fs.existsSync(path.join(userData, 'git-system.gitconfig')));
|
||||
assert.deepStrictEqual(commitIdentity({ gitName: '', gitEmail: '' }), { name: 'Git Manager GUI', email: 'gui@gitmanager.local' });
|
||||
});
|
||||
|
||||
test('Backup: Ausschlussmuster und Zeitpunkt für automatische Backups', () => {
|
||||
const m = excludeMatcher('test-*\nM_Viper/Alt?\n# Kommentar\nmy.repo');
|
||||
assert.strictEqual(m('M_Viper/test-1'), true);
|
||||
assert.strictEqual(m('M_Viper/Alt1'), true);
|
||||
assert.strictEqual(m('M_Viper/Alt12'), false);
|
||||
assert.strictEqual(m('x/my.repo'), true);
|
||||
assert.strictEqual(m('x/myXrepo'), false);
|
||||
|
||||
const configPath = path.join(tmp('gm-bk-'), 'repo-backup.json');
|
||||
const backup = createRepoBackup({ configPath, getContext() {}, giteaCall() {}, runGit() {}, isPackFailure() {}, redact: (s) => s });
|
||||
backup.setConfig({ targetDir: 'X:/irgendwo', interval: 'daily', time: '03:00' });
|
||||
const at = (s) => new Date(s);
|
||||
// noch nie gelaufen: vor 03:00 nicht, danach schon
|
||||
assert.strictEqual(backup.isDue(at('2026-10-22T02:59:00')), true, 'Slot von gestern 03:00 ist offen');
|
||||
fs.writeFileSync(configPath, JSON.stringify({ ...JSON.parse(fs.readFileSync(configPath, 'utf8')), lastRun: '2026-10-22T03:05:00' }));
|
||||
assert.strictEqual(backup.isDue(at('2026-10-22T20:00:00')), false, 'heute schon gelaufen');
|
||||
assert.strictEqual(backup.isDue(at('2026-10-23T02:59:00')), false, 'vor 03:00 noch nicht');
|
||||
assert.strictEqual(backup.isDue(at('2026-10-23T03:00:00')), true, 'ab 03:00 wieder fällig');
|
||||
|
||||
backup.setConfig({ time: '25:99' });
|
||||
assert.strictEqual(backup.getState().config.time, '');
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
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');
|
||||
const archiver = require('archiver');
|
||||
|
||||
require('../src/git/gitBinary.js').useBundledGit();
|
||||
const { registerFeatureIpc } = require('../src/features/featuresIpc.js');
|
||||
|
||||
const git = (cwd, ...a) => spawnSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', ...a], { cwd, encoding: 'utf8' });
|
||||
const tmp = (p) => fs.mkdtempSync(path.join(os.tmpdir(), p));
|
||||
|
||||
function makeZip(file, entries) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const out = fs.createWriteStream(file);
|
||||
const zip = archiver('zip');
|
||||
out.on('close', resolve);
|
||||
zip.on('error', reject);
|
||||
zip.pipe(out);
|
||||
for (const [name, content] of entries) zip.append(content, { name });
|
||||
zip.finalize();
|
||||
});
|
||||
}
|
||||
|
||||
// Backup-Ordner wie von GiteaRepoBackup angelegt: Spiegel + ZIP-Stände
|
||||
async function makeBackupDir() {
|
||||
const work = tmp('gm-src-');
|
||||
git(work, 'init', '-q', '-b', 'main');
|
||||
fs.writeFileSync(path.join(work, 'a.txt'), 'hallo');
|
||||
git(work, 'add', '.'); git(work, 'commit', '-qm', 'eins');
|
||||
git(work, 'branch', 'feature'); git(work, 'tag', 'v1.0');
|
||||
const dir = tmp('gm-bk-');
|
||||
fs.mkdirSync(path.join(dir, 'M_Viper'));
|
||||
git(dir, 'clone', '-q', '--mirror', work, path.join(dir, 'M_Viper', 'deskhub.git'));
|
||||
const zipDir = path.join(dir, 'M_Viper', 'deskhub-archiv');
|
||||
fs.mkdirSync(zipDir);
|
||||
await makeZip(path.join(zipDir, 'deskhub-2026-10-01_10-00.zip'), [['deskhub/a.txt', 'alt'], ['deskhub/src/b.kt', 'fun b() {}']]);
|
||||
await makeZip(path.join(zipDir, 'deskhub-2026-10-20_10-00.zip'), [['deskhub/a.txt', 'neu'], ['deskhub/src/b.kt', 'fun b() {}']]);
|
||||
fs.utimesSync(path.join(zipDir, 'deskhub-2026-10-01_10-00.zip'), new Date('2026-10-01'), new Date('2026-10-01'));
|
||||
fs.writeFileSync(path.join(dir, 'backup-protokoll.json'), '{}');
|
||||
return { dir, work };
|
||||
}
|
||||
|
||||
// Gitea-Nachbau: neue Repos zeigen auf echte lokale Bare-Repos, damit der Push wirklich läuft
|
||||
function fakeGitea() {
|
||||
const created = [];
|
||||
const patches = [];
|
||||
const repoRoot = tmp('gm-remote-');
|
||||
const server = http.createServer((req, res) => {
|
||||
const u = new URL(req.url, 'http://x');
|
||||
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', () => {
|
||||
if (u.pathname === '/api/v1/user') return send(200, { login: 'M_Viper' });
|
||||
if (u.pathname === '/api/v1/user/repos' && req.method === 'POST') {
|
||||
const b = JSON.parse(body);
|
||||
if (b.name === 'deskhub') return send(409, { message: 'repository already exists' });
|
||||
const bare = path.join(repoRoot, `${b.name}.git`);
|
||||
spawnSync('git', ['init', '-q', '--bare', '-b', 'main', bare]);
|
||||
created.push(b);
|
||||
return send(201, { name: b.name, full_name: `M_Viper/${b.name}`, owner: { login: 'M_Viper' }, clone_url: bare, html_url: `http://gitea/M_Viper/${b.name}`, default_branch: 'main' });
|
||||
}
|
||||
if (u.pathname.startsWith('/api/v1/repos/M_Viper/') && req.method === 'PATCH') { patches.push(JSON.parse(body)); return send(200, {}); }
|
||||
send(404, { message: 'not found' });
|
||||
});
|
||||
});
|
||||
return new Promise(r => server.listen(0, '127.0.0.1', () => r({ server, created, patches, repoRoot, url: `http://127.0.0.1:${server.address().port}` })));
|
||||
}
|
||||
|
||||
function setup(url) {
|
||||
const handlers = {};
|
||||
const userData = tmp('gm-ud-');
|
||||
registerFeatureIpc({
|
||||
ipcMain: { handle: (name, fn) => { handlers[name] = fn; } },
|
||||
app: { getPath: () => userData },
|
||||
readCredentials: () => ({ giteaURL: url, giteaToken: 'geheim123' }),
|
||||
buildItemsFromPaths: async () => ({ items: [], skipped: [], ignored: [] }),
|
||||
clearGitTransportBlock: () => false
|
||||
});
|
||||
return (name, data) => handlers[name]({ sender: { send() {} } }, data);
|
||||
}
|
||||
|
||||
test('Sicherungen werden gefunden: Spiegel und ZIP-Stände, neuester zuerst', async () => {
|
||||
const { dir } = await makeBackupDir();
|
||||
const call = setup('http://127.0.0.1:1');
|
||||
const r = await call('repo-backup-list', { dir });
|
||||
assert.strictEqual(r.ok, true, r.error);
|
||||
assert.strictEqual(r.backups.length, 1);
|
||||
const b = r.backups[0];
|
||||
assert.deepStrictEqual([b.owner, b.repo], ['M_Viper', 'deskhub']);
|
||||
assert.ok(b.mirror);
|
||||
assert.deepStrictEqual(b.zips.map(z => z.file), ['deskhub-2026-10-20_10-00.zip', 'deskhub-2026-10-01_10-00.zip']);
|
||||
});
|
||||
|
||||
test('Lokal: Spiegel wird zur Arbeitskopie mit allen Branches, ZIP ohne Oberordner entpackt', async () => {
|
||||
const { dir } = await makeBackupDir();
|
||||
const call = setup('http://127.0.0.1:1');
|
||||
const { backups: [b] } = await call('repo-backup-list', { dir });
|
||||
|
||||
const dest1 = tmp('gm-dst-');
|
||||
const m = await call('repo-restore-local', { kind: 'mirror', source: b.mirror.path, destParent: dest1, repo: 'deskhub' });
|
||||
assert.strictEqual(m.ok, true, m.error);
|
||||
assert.strictEqual(fs.readFileSync(path.join(dest1, 'deskhub', 'a.txt'), 'utf8'), 'hallo');
|
||||
assert.match(git(path.join(dest1, 'deskhub'), 'branch', '-r').stdout, /feature/);
|
||||
|
||||
// Zweiter Versuch in denselben Ordner darf nichts überschreiben
|
||||
const again = await call('repo-restore-local', { kind: 'mirror', source: b.mirror.path, destParent: dest1, repo: 'deskhub' });
|
||||
assert.strictEqual(again.ok, false);
|
||||
assert.match(again.error, /existiert bereits/);
|
||||
|
||||
const dest2 = tmp('gm-dst-');
|
||||
const z = await call('repo-restore-local', { kind: 'zip', source: b.zips[0].path, destParent: dest2, repo: 'deskhub' });
|
||||
assert.strictEqual(z.ok, true, z.error);
|
||||
assert.strictEqual(z.files, 2);
|
||||
assert.strictEqual(fs.readFileSync(path.join(dest2, 'deskhub', 'a.txt'), 'utf8'), 'neu');
|
||||
assert.ok(fs.existsSync(path.join(dest2, 'deskhub', 'src', 'b.kt')));
|
||||
});
|
||||
|
||||
// ZIP von Hand bauen: archiver entschärft "../" schon beim Erzeugen, ein echter Angreifer nicht.
|
||||
function rawZip(file, entries) {
|
||||
const zlib = require('node:zlib');
|
||||
const locals = [];
|
||||
const centrals = [];
|
||||
let offset = 0;
|
||||
for (const [name, content] of entries) {
|
||||
const n = Buffer.from(name);
|
||||
const data = Buffer.from(content);
|
||||
const crc = zlib.crc32(data);
|
||||
const lh = Buffer.alloc(30);
|
||||
lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt32LE(crc, 14);
|
||||
lh.writeUInt32LE(data.length, 18); lh.writeUInt32LE(data.length, 22); lh.writeUInt16LE(n.length, 26);
|
||||
const ch = Buffer.alloc(46);
|
||||
ch.writeUInt32LE(0x02014b50, 0); ch.writeUInt16LE(20, 4); ch.writeUInt16LE(20, 6); ch.writeUInt32LE(crc, 16);
|
||||
ch.writeUInt32LE(data.length, 20); ch.writeUInt32LE(data.length, 24); ch.writeUInt16LE(n.length, 28); ch.writeUInt32LE(offset, 42);
|
||||
locals.push(lh, n, data);
|
||||
centrals.push(ch, n);
|
||||
offset += lh.length + n.length + data.length;
|
||||
}
|
||||
const cd = Buffer.concat(centrals);
|
||||
const end = Buffer.alloc(22);
|
||||
end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(entries.length, 8); end.writeUInt16LE(entries.length, 10);
|
||||
end.writeUInt32LE(cd.length, 12); end.writeUInt32LE(offset, 16);
|
||||
fs.writeFileSync(file, Buffer.concat([...locals, cd, end]));
|
||||
}
|
||||
|
||||
test('Archive mit Pfaden außerhalb des Ziels werden abgewiesen', async () => {
|
||||
const d = tmp('gm-evil-');
|
||||
const zip = path.join(d, 'evil.zip');
|
||||
rawZip(zip, [['deskhub/ok.txt', 'ok'], ['deskhub/../../boese.txt', 'x']]);
|
||||
const unzipper = require('unzipper');
|
||||
const names = (await unzipper.Open.file(zip)).files.map(f => f.path);
|
||||
assert.ok(names.includes('deskhub/../../boese.txt'), 'Testarchiv muss den gefährlichen Pfad wirklich enthalten');
|
||||
const call = setup('http://127.0.0.1:1');
|
||||
const destParent = tmp('gm-dst-');
|
||||
const r = await call('repo-restore-local', { kind: 'zip', source: zip, destParent, repo: 'x' });
|
||||
assert.strictEqual(r.ok, false);
|
||||
assert.match(r.error, /Unsicherer Pfad/);
|
||||
assert.ok(!fs.existsSync(path.join(destParent, 'x', 'ok.txt')), 'auch harmlose Dateien dürfen dann nicht entpackt werden');
|
||||
assert.ok(!fs.existsSync(path.join(destParent, 'boese.txt')));
|
||||
});
|
||||
|
||||
test('Gitea: Spiegel und ZIP als neues Repo, Namenskonflikt und ZIP-Überschreiben werden abgefangen', async () => {
|
||||
const { dir } = await makeBackupDir();
|
||||
const g = await fakeGitea();
|
||||
try {
|
||||
const call = setup(g.url);
|
||||
const { backups: [b] } = await call('repo-backup-list', { dir });
|
||||
|
||||
const m = await call('repo-restore-gitea', { kind: 'mirror', source: b.mirror.path, owner: 'M_Viper', repo: 'deskhub', mode: 'new', newName: 'deskhub-wieder', isPrivate: true });
|
||||
assert.strictEqual(m.ok, true, m.error);
|
||||
const bare = path.join(g.repoRoot, 'deskhub-wieder.git');
|
||||
const refs = git(bare, 'for-each-ref', '--format=%(refname)').stdout;
|
||||
assert.match(refs, /refs\/heads\/main/);
|
||||
assert.match(refs, /refs\/heads\/feature/);
|
||||
assert.match(refs, /refs\/tags\/v1\.0/);
|
||||
assert.strictEqual(g.created[0].private, true);
|
||||
|
||||
const z = await call('repo-restore-gitea', { kind: 'zip', source: b.zips[0].path, owner: 'M_Viper', repo: 'deskhub', mode: 'new', newName: 'deskhub-zip' });
|
||||
assert.strictEqual(z.ok, true, z.error);
|
||||
assert.strictEqual(z.method, 'Git');
|
||||
const files = git(path.join(g.repoRoot, 'deskhub-zip.git'), 'ls-tree', '-r', '--name-only', 'main').stdout.trim().split('\n');
|
||||
assert.deepStrictEqual(files.sort(), ['a.txt', 'src/b.kt']);
|
||||
|
||||
const conflict = await call('repo-restore-gitea', { kind: 'mirror', source: b.mirror.path, owner: 'M_Viper', repo: 'deskhub', mode: 'new', newName: 'deskhub' });
|
||||
assert.strictEqual(conflict.ok, false);
|
||||
assert.match(conflict.error, /existiert bereits/);
|
||||
|
||||
const zipOverwrite = await call('repo-restore-gitea', { kind: 'zip', source: b.zips[0].path, owner: 'M_Viper', repo: 'deskhub', mode: 'overwrite' });
|
||||
assert.strictEqual(zipOverwrite.ok, false);
|
||||
assert.match(zipOverwrite.error, /nur mit einem Git-Spiegel/);
|
||||
|
||||
const badName = await call('repo-restore-gitea', { kind: 'zip', source: b.zips[0].path, owner: 'M_Viper', repo: 'deskhub', mode: 'new', newName: 'mit leerzeichen' });
|
||||
assert.strictEqual(badName.ok, false);
|
||||
} finally {
|
||||
g.server.close();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user