feat(p12a): services git distants (PAT/app_password) + clone HTTPS

Modèle de données :
- migration #11 git_credentials (secrets chiffrés SecretBox : secret_encrypted ; colonnes ssh/oauth posées pour P12b/P12c) ; #12 repos ALTER remote_url/git_service/credential_id (pas de FK)
- types partagés api.ts (GitCredentialSummary sans secret + hasSecret/secretLast4, CRUD, RemoteRepoSummary, Clone*) ; protocole additif : topic 'clones' + message clone_update (CloneOperation)

Backend :
- core/git-credentials.ts (GitCredentialsManager(db, box)) : CRUD chiffré, test() (GET /user), getSecret()/authContext() internes, NULLification de repos.credential_id à la suppression
- core/git-clients/ (github/gitlab/gitea) via fetch, sans dépendance : verify()+listRepos() paginés, erreurs typées AUTH_FAILED/RATE_LIMITED/UNREACHABLE, SSRF base_url http(s)
- core/git-auth.ts : withGitAuth (GIT_ASKPASS éphémère 0o700, secret par env, GIT_TERMINAL_PROMPT=0, jamais dans l'URL/.git/config, nettoyage finally)
- core/git.ts cloneRepo (spawn git clone --progress, parse progression, timeout)
- core/clone-manager.ts (EventEmitter) : clone async, dest confiné sous scanRoots + non existant, auto-enregistrement via addRepo + métadonnées de provenance, nettoyage du clone partiel, events topic 'clones'
- routes/git-connections.ts (CRUD + /test + /:id/repos + POST /repos/clone 202 + GET /repos/clone/:id) ; app.ts câble box→GitCredentialsManager + CloneManager→gateway ; gateway relaie 'clones'

Frontend :
- ws-client subscribeClones ; stores git-connections + clone (suivi WS)
- components/settings/GitConnectionsSection (liste + formulaire pat/app_password, secret jamais ré-affiché) inséré dans SettingsView ; CloneRepoModal (connexion → repos distants paginés → dest scanRoots[0] → barre de progression WS → redirection) ; bouton « Cloner » dans DashboardView ; i18n EN+FR

Tests : git-credentials (round-trip SecretBox, résumé sans secret, NULLification) ; acceptance-p12.mjs (clone bare local file:// → clone_update done + repo enregistré + secret ABSENT de l'API, de la DB et du .git/config)

Sous-phases restantes : P12b (SSH), P12c (OAuth device flow).
This commit is contained in:
2026-06-27 14:27:18 +02:00
parent e8d10b7ec0
commit 08695a707d
22 changed files with 1583 additions and 10 deletions

View File

@@ -0,0 +1,54 @@
// Credentials git (P12) : round-trip SecretBox, résumé SANS secret, NULLification à la suppression.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { openDb, type Db } from '../src/db/index.js';
import { SecretBox } from '../src/core/secret-box.js';
import { GitCredentialsManager } from '../src/core/git-credentials.js';
let db: Db;
let mgr: GitCredentialsManager;
const SECRET = 'pat-super-secret-1234';
beforeEach(() => {
db = openDb(':memory:');
mgr = new GitCredentialsManager(db, new SecretBox(Buffer.alloc(32, 7)));
});
afterEach(() => db.close());
describe('GitCredentialsManager', () => {
it('crée un credential : secret chiffré en DB, résumé sans secret', () => {
const cred = mgr.create({ label: 'gh', service: 'github', authType: 'pat', secret: SECRET });
expect(cred.hasSecret).toBe(true);
expect(cred.secretLast4).toBe('1234');
// le résumé sérialisé ne contient JAMAIS le secret en clair.
expect(JSON.stringify(cred)).not.toContain(SECRET);
// en DB : valeur chiffrée (préfixe v1:), jamais le clair.
const row = db.prepare('SELECT secret_encrypted FROM git_credentials WHERE id = ?').get(cred.id) as { secret_encrypted: string };
expect(row.secret_encrypted.startsWith('v1:')).toBe(true);
expect(row.secret_encrypted).not.toContain(SECRET);
});
it('getSecret déchiffre (round-trip)', () => {
const cred = mgr.create({ label: 'gl', service: 'gitlab', authType: 'pat', secret: SECRET });
expect(mgr.getSecret(cred.id)).toBe(SECRET);
});
it('exige un base_url pour Gitea', () => {
expect(() => mgr.create({ label: 'x', service: 'gitea', authType: 'pat', secret: SECRET })).toThrow();
expect(mgr.create({ label: 'x', service: 'gitea', authType: 'pat', baseUrl: 'https://git.example.com', secret: SECRET }).baseUrl).toBe('https://git.example.com');
});
it('refuse ssh_key/oauth pour linstant (P12a)', () => {
expect(() => mgr.create({ label: 'x', service: 'github', authType: 'ssh_key', secret: SECRET })).toThrow();
expect(() => mgr.create({ label: 'x', service: 'github', authType: 'oauth', secret: SECRET })).toThrow();
});
it('remove() NULLifie repos.credential_id (pas de FK)', () => {
const cred = mgr.create({ label: 'gh', service: 'github', authType: 'pat', secret: SECRET });
db.prepare(
"INSERT INTO repos (id, path, label, post_create_hooks, pre_trust, created_at, hidden, credential_id) VALUES ('r1', '/tmp/r1', 'r1', '[]', 0, '2020-01-01T00:00:00Z', 0, ?)",
).run(cred.id);
expect(mgr.remove(cred.id)).toBe(true);
const repo = db.prepare("SELECT credential_id FROM repos WHERE id = 'r1'").get() as { credential_id: string | null };
expect(repo.credential_id).toBeNull();
});
});