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).
55 lines
2.7 KiB
TypeScript
55 lines
2.7 KiB
TypeScript
// 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 l’instant (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();
|
||
});
|
||
});
|