Files
arboretum/packages/server/test/git-credentials.test.ts
Johan LEROY 08695a707d 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).
2026-06-27 14:27:18 +02:00

55 lines
2.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();
});
});