From 6fbf2d702891427cec819d52ef9987d19ddd0943 Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Sat, 17 Oct 2026 12:55:22 +0200 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20feat(Backup):=20rapport=20d'?= =?UTF-8?q?=C3=A9carts=20des=20relev=C3=A9s=20de=20compte=20apr=C3=A8s=20r?= =?UTF-8?q?estauration=20(#362)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/services.php | 1 + src/Backup/Controller/RestoreController.php | 3 +++ src/Backup/Restore/DocumentOrphanReport.php | 22 +++++++++++------ .../Controller/RestoreControllerTest.php | 24 +++++++++++++++++++ .../Restore/DocumentOrphanReportTest.php | 18 ++++++++++++++ 5 files changed, 61 insertions(+), 7 deletions(-) diff --git a/config/services.php b/config/services.php index 483e9fef..adc7d8b5 100644 --- a/config/services.php +++ b/config/services.php @@ -1315,6 +1315,7 @@ new BackupCatalog($backupDir), new RestoreStatus($stateDir, $c->get(ClockInterface::class)), new DocumentOrphanReport($c->get(PDO::class), $config['storage']['group_documents_path']), + new DocumentOrphanReport($c->get(PDO::class), $config['storage']['accounting_statements_path'], DocumentOrphanReport::ACCOUNTING_STATEMENTS), new CurrentPasswordVerifier($c->get(UserRepositoryInterface::class), $c->get(PasswordHasherInterface::class)), $launcher->launch(...), $c->get(ClockInterface::class), diff --git a/src/Backup/Controller/RestoreController.php b/src/Backup/Controller/RestoreController.php index 727436b2..44f1104d 100644 --- a/src/Backup/Controller/RestoreController.php +++ b/src/Backup/Controller/RestoreController.php @@ -39,6 +39,7 @@ public function __construct( private readonly BackupCatalog $catalog, private readonly RestoreStatus $status, private readonly DocumentOrphanReport $documents, + private readonly DocumentOrphanReport $statements, private readonly CurrentPasswordVerifier $passwordVerifier, private readonly \Closure $launch, private readonly ClockInterface $clock, @@ -62,6 +63,8 @@ public function page(Request $request): Response 'running' => $this->status->isRunning(), 'missingFiles' => $this->documents->missingFiles(), 'orphanFiles' => $this->documents->orphanFiles(), + 'statementMissing' => $this->statements->missingFiles(), + 'statementOrphans' => $this->statements->orphanFiles(), 'confirmWord' => self::CONFIRM_WORD, 'restoreEnabled' => $this->safeguard->isEnabled(), 'csrfToken' => $this->csrfTokenManager->getToken(), diff --git a/src/Backup/Restore/DocumentOrphanReport.php b/src/Backup/Restore/DocumentOrphanReport.php index 78201b1e..0d05fa2a 100644 --- a/src/Backup/Restore/DocumentOrphanReport.php +++ b/src/Backup/Restore/DocumentOrphanReport.php @@ -5,19 +5,27 @@ namespace App\Backup\Restore; /** - * #241 : après une restauration, liste les écarts entre group_documents et le dossier de stockage. - * Lecture seule : ne supprime, n'écrit et ne lit jamais le contenu d'un fichier. + * #241 : après une restauration, liste les écarts entre une table de fichiers (documents de groupe, relevés de compte #362) et son dossier de + * stockage. Lecture seule : ne supprime, n'écrit et ne lit jamais le contenu d'un fichier. La table et les colonnes viennent des constantes + * ci-dessous, jamais d'une valeur extérieure. */ final class DocumentOrphanReport { - public function __construct(private readonly \PDO $pdo, private readonly string $storagePath) + /** Documents de groupe : table, colonne du rattachement, clé rendue. */ + public const GROUP_DOCUMENTS = ['group_documents', 'group_id', 'groupId']; + /** Relevés de compte de l'association (#362). */ + public const ACCOUNTING_STATEMENTS = ['accounting_statements', 'fiscal_year_id', 'fiscalYearId']; + + /** @param array{0: string, 1: string, 2: string} $source l'une des constantes GROUP_DOCUMENTS ou ACCOUNTING_STATEMENTS */ + public function __construct(private readonly \PDO $pdo, private readonly string $storagePath, private readonly array $source = self::GROUP_DOCUMENTS) { } - /** @return list lignes dont le fichier est absent */ + /** @return list> lignes dont le fichier est absent : `id`, la clé de rattachement de la source (`groupId`, `fiscalYearId`) et `originalName` */ public function missingFiles(): array { - $statement = $this->pdo->prepare('SELECT id, group_id, original_name, stored_name FROM group_documents ORDER BY id'); + [$table, $ownerColumn, $ownerKey] = $this->source; + $statement = $this->pdo->prepare("SELECT id, {$ownerColumn} AS owner_id, original_name, stored_name FROM {$table} ORDER BY id"); $statement->execute(); $missing = []; @@ -27,7 +35,7 @@ public function missingFiles(): array } $missing[] = [ 'id' => (int) $row['id'], - 'groupId' => (int) $row['group_id'], + $ownerKey => (int) $row['owner_id'], 'originalName' => (string) $row['original_name'], ]; } @@ -42,7 +50,7 @@ public function orphanFiles(): array return []; } - $statement = $this->pdo->prepare('SELECT stored_name FROM group_documents'); + $statement = $this->pdo->prepare("SELECT stored_name FROM {$this->source[0]}"); $statement->execute(); $known = array_flip($statement->fetchAll(\PDO::FETCH_COLUMN)); diff --git a/tests/Backup/Controller/RestoreControllerTest.php b/tests/Backup/Controller/RestoreControllerTest.php index 81d46d0b..035bd2b7 100644 --- a/tests/Backup/Controller/RestoreControllerTest.php +++ b/tests/Backup/Controller/RestoreControllerTest.php @@ -113,6 +113,7 @@ public function selectActiveGroup(int $groupId): void new BackupCatalog($this->dir . '/backups'), new RestoreStatus($this->dir . '/state', new MockClock('2026-10-20 10:00:00')), new DocumentOrphanReport($this->pdo, $this->dir . '/documents'), + new DocumentOrphanReport($this->pdo, $this->dir . '/statements', DocumentOrphanReport::ACCOUNTING_STATEMENTS), new CurrentPasswordVerifier($this->users, new FastPasswordHasher()), $launch ?? function (string $file): void { $this->launched[] = $file; @@ -313,6 +314,29 @@ public function testTheDocumentsWithoutFileAndTheOrphanFilesAreListed(): void self::assertStringNotContainsString('Aucune incohérence', $html); } + #[Test] + public function testTheStatementsWithoutFileAndTheirOrphanFilesAreListedApart(): void + { + $owner = $this->user(self::OWNER); + $this->pdo->prepare("INSERT INTO accounting_fiscal_years (label, starts_on, ends_on, created_by_user_id, created_at) VALUES ('2025-2026', '2025-10-01', '2026-09-30', ?, '2025-10-01 00:00:00')")->execute([$owner->id()]); + $year = (int) $this->pdo->lastInsertId(); + $this->pdo->prepare("INSERT INTO accounting_statements (fiscal_year_id, period_start, period_end, original_name, stored_name, mime, size_bytes, sha256, uploaded_by_user_id, uploaded_at) VALUES (?, '2025-10-01', '2025-10-31', 'octobre.pdf', ?, 'application/pdf', 10, ?, ?, '2025-11-01 00:00:00')")->execute([$year, str_repeat('a', 32), str_repeat('1', 64), $owner->id()]); + mkdir($this->dir . '/statements', 0o700, true); + file_put_contents($this->dir . '/statements/' . str_repeat('c', 32), 'x'); + + try { + $html = $this->page($owner); + } finally { + unlink($this->dir . '/statements/' . str_repeat('c', 32)); + rmdir($this->dir . '/statements'); + } + + self::assertStringContainsString('octobre.pdf', $html); + self::assertStringContainsString('exercice n° ' . $year, $html); + self::assertStringContainsString(str_repeat('c', 32), $html); + self::assertStringNotContainsString('Rien à signaler sur les relevés', $html); + } + #[Test] public function testWithoutBackupsOrInconsistenciesThePageSaysSo(): void { diff --git a/tests/Backup/Restore/DocumentOrphanReportTest.php b/tests/Backup/Restore/DocumentOrphanReportTest.php index 25db7fbe..806ff904 100644 --- a/tests/Backup/Restore/DocumentOrphanReportTest.php +++ b/tests/Backup/Restore/DocumentOrphanReportTest.php @@ -46,6 +46,24 @@ public function testARowWithoutItsFileIsReportedAsMissing(): void self::assertSame([['id' => $document->id(), 'groupId' => $group->id(), 'originalName' => 'fiche.pdf']], $missing); } + #[Test] + public function testAccountingStatementsAreReportedTheSameWay(): void + { + $admin = (new MysqlUserRepository($this->pdo))->save(new User(0, 'admin@rehearsalbox.test', 'hash', 'Admin', UserRole::Admin, true, 0, null)); + $this->pdo->prepare("INSERT INTO accounting_fiscal_years (label, starts_on, ends_on, created_by_user_id, created_at) VALUES ('2025-2026', '2025-10-01', '2026-09-30', ?, '2025-10-01 00:00:00')")->execute([$admin->id()]); + $year = (int) $this->pdo->lastInsertId(); + $insert = $this->pdo->prepare("INSERT INTO accounting_statements (fiscal_year_id, period_start, period_end, original_name, stored_name, mime, size_bytes, sha256, uploaded_by_user_id, uploaded_at) VALUES (?, '2025-10-01', '2025-10-31', ?, ?, 'application/pdf', 10, ?, ?, '2025-11-01 00:00:00')"); + $insert->execute([$year, 'present.pdf', str_repeat('a', 32), str_repeat('1', 64), $admin->id()]); + $insert->execute([$year, 'perdu.pdf', str_repeat('b', 32), str_repeat('2', 64), $admin->id()]); + file_put_contents($this->storagePath . '/' . str_repeat('a', 32), 'x'); + file_put_contents($this->storagePath . '/' . str_repeat('c', 32), 'x'); + $report = new DocumentOrphanReport($this->pdo, $this->storagePath, DocumentOrphanReport::ACCOUNTING_STATEMENTS); + + self::assertSame([['id' => (int) $this->pdo->query("SELECT id FROM accounting_statements WHERE original_name = 'perdu.pdf'")->fetchColumn(), 'fiscalYearId' => $year, 'originalName' => 'perdu.pdf']], $report->missingFiles()); + self::assertSame([str_repeat('c', 32)], $report->orphanFiles()); + self::assertFileExists($this->storagePath . '/' . str_repeat('c', 32), 'lecture seule : rien n\'est supprimé'); + } + #[Test] public function testAFileWithoutRowIsReportedAsOrphan(): void { From 5dbe5880f804ce02b9076bc4502b0e0b3ff5c823 Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Sat, 17 Oct 2026 12:55:22 +0200 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=A8=20feat(Backup):=20relev=C3=A9s=20?= =?UTF-8?q?de=20compte=20dans=20la=20restauration=20en=20ligne=20de=20comm?= =?UTF-8?q?ande=20et=20sur=20la=20page=20(#362)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/restore-db.php | 2 ++ templates/admin/restore/index.php | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/bin/restore-db.php b/bin/restore-db.php index 3ee546ba..3d172439 100644 --- a/bin/restore-db.php +++ b/bin/restore-db.php @@ -78,6 +78,8 @@ } $orphans = new DocumentOrphanReport($pdo, $config['storage']['group_documents_path']); $notes[] = sprintf('Documents de groupes : %d ligne(s) sans fichier, %d fichier(s) sans ligne (aucun supprimé).', count($orphans->missingFiles()), count($orphans->orphanFiles())); + $statements = new DocumentOrphanReport($pdo, $config['storage']['accounting_statements_path'], DocumentOrphanReport::ACCOUNTING_STATEMENTS); + $notes[] = sprintf('Relevés de compte : %d ligne(s) sans fichier, %d fichier(s) sans ligne (aucun supprimé).', count($statements->missingFiles()), count($statements->orphanFiles())); return implode("\n", $notes); }; diff --git a/templates/admin/restore/index.php b/templates/admin/restore/index.php index dd10f3b0..d82eaa21 100644 --- a/templates/admin/restore/index.php +++ b/templates/admin/restore/index.php @@ -9,6 +9,8 @@ * @var bool $running * @var list $missingFiles * @var list $orphanFiles + * @var list $statementMissing + * @var list $statementOrphans * @var string $confirmWord * @var bool $restoreEnabled faux : la restauration par le web est désactivée (config `restore.enabled`, #424) : aucune action proposée * @var \App\Account\Entity\UserRole $currentUserRole @@ -173,6 +175,30 @@ + +
+

Relevés de compte après restauration

+ +

Rien à signaler sur les relevés.

+ + +

Lignes sans fichier

+
    + +
  • (exercice n° )
  • + +
+ + +

Fichiers sans ligne

+
    + +
  • + +
+ + +
From ca615797c85c46b9f59ffebea6d692340cb76ddb Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Sat, 17 Oct 2026 12:55:22 +0200 Subject: [PATCH 3/4] =?UTF-8?q?=E2=9C=A8=20feat(Deploy):=20copie=20hors=20?= =?UTF-8?q?serveur=20des=20fichiers=20de=20production=20et=20dossier=20des?= =?UTF-8?q?=20relev=C3=A9s=20cr=C3=A9=C3=A9=20en=200700=20(#362)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/deploy.sh | 2 +- bin/fetch-files.sh | 79 ++++++++++ tests/Deploy/Storage/FetchFilesScriptTest.php | 137 ++++++++++++++++++ 3 files changed, 217 insertions(+), 1 deletion(-) create mode 100755 bin/fetch-files.sh create mode 100644 tests/Deploy/Storage/FetchFilesScriptTest.php diff --git a/bin/deploy.sh b/bin/deploy.sh index 7e9b2205..14bb889c 100755 --- a/bin/deploy.sh +++ b/bin/deploy.sh @@ -73,7 +73,7 @@ trap 'deploy_log_finish $?' EXIT # --- 2. Envoi du code (fichiers suivis par git uniquement) ---------------- progress_step "Envoi de la release ${release}" -rb_ssh "mkdir -p \"\$HOME/$BASE/releases\" \"\$HOME/$BASE/shared/storage/group-documents\" \"\$HOME/$BASE/shared/well-known\" \"\$HOME/$BASE/backups\" && chmod 700 \"\$HOME/$BASE/shared\" \"\$HOME/$BASE/backups\" && mkdir \"\$HOME/$BASE/releases/$release\"" +rb_ssh "mkdir -p \"\$HOME/$BASE/releases\" \"\$HOME/$BASE/shared/storage/group-documents\" \"\$HOME/$BASE/shared/storage/accounting-statements\" \"\$HOME/$BASE/shared/well-known\" \"\$HOME/$BASE/backups\" && chmod 700 \"\$HOME/$BASE/shared\" \"\$HOME/$BASE/backups\" && mkdir \"\$HOME/$BASE/releases/$release\"" # Jamais le seed (script destructif, comptes au mot de passe connu) : il n'a rien à faire en production. git archive --format=tar HEAD -- bin config database public src templates composer.json composer.lock ':(exclude)database/seed.php' \ | rb_ssh "tar -x -C \"\$HOME/$BASE/releases/$release\"" diff --git a/bin/fetch-files.sh b/bin/fetch-files.sh new file mode 100755 index 00000000..4c836421 --- /dev/null +++ b/bin/fetch-files.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Copie HORS SERVEUR d'un dossier de fichiers de la production (#362), lancée depuis le poste de dev : les relevés de compte de l'association +# (`accounting-statements`) ou les documents de groupes (`group-documents`) ne sont PAS dans un dump de la base. +# Archive tar.gz envoyée en flux par SSH (aucune dépendance côté serveur), vérifiée avant d'être rangée, jamais dans le dépôt. +# Les relevés contiennent des IBAN et des noms de tiers : le dossier local est en 0700 ; le ranger sur un disque chiffré. +# +# Usage : bin/fetch-files.sh [accounting-statements|group-documents] (défaut : accounting-statements) +# Variables : RB_SSH_CONFIG (obligatoire), RB_SSH_HOST (rehearsalbox), RB_REMOTE_BASE (rehearsalbox), +# RB_BACKUP_LOCAL_DIR ($HOME/rehearsalbox-backups, jamais dans le dépôt), RB_BACKUP_LOCAL_KEEP (30 archives par dossier) +set -euo pipefail +umask 077 + +SSH_CONFIG="${RB_SSH_CONFIG:?RB_SSH_CONFIG requis}" +SSH_HOST="${RB_SSH_HOST:-rehearsalbox}" +BASE="${RB_REMOTE_BASE:-rehearsalbox}" +LOCAL_DIR="${RB_BACKUP_LOCAL_DIR:-$HOME/rehearsalbox-backups}" +KEEP="${RB_BACKUP_LOCAL_KEEP:-30}" + +# Liste fermée : jamais un nom venu de l'extérieur dans la commande distante +FOLDER="${1:-accounting-statements}" +case "$FOLDER" in + accounting-statements|group-documents) ;; + *) echo "Usage : $0 [accounting-statements|group-documents]" >&2; exit 1 ;; +esac + +case "$KEEP" in + ''|*[!0-9]*|0) echo "RB_BACKUP_LOCAL_KEEP doit être un entier supérieur ou égal à 1 (sinon l'archive qu'on vient de récupérer serait supprimée)." >&2; exit 1 ;; +esac + +REPO="$(cd "$(dirname "$0")/.." && pwd -P)" +LOCAL_ABS="$(realpath -m -- "$LOCAL_DIR")" +case "$LOCAL_ABS/" in + "$REPO/"*) + echo "Refusé : le dossier de sauvegarde ($LOCAL_ABS) est dans le dépôt." >&2 + echo "Choisir un dossier hors du dépôt via RB_BACKUP_LOCAL_DIR." >&2 + exit 1 + ;; +esac + +mkdir -p "$LOCAL_DIR" +chmod 700 "$LOCAL_DIR" + +DEST="$LOCAL_DIR/$FOLDER-$(date -u +%Y%m%d%H%M%S).tgz" +PARTIAL="$DEST.partial" + +# Le dossier peut ne pas exister encore (aucun relevé déposé) : ce n'est pas une erreur. Une panne de connexion, elle, en est une. +PRESENT="$(ssh -F "$SSH_CONFIG" -o BatchMode=yes "$SSH_HOST" "if [ -d \"\$HOME/$BASE/shared/storage/$FOLDER\" ]; then echo present; else echo absent; fi")" \ + || { echo "Connexion SSH impossible vers $SSH_HOST." >&2; exit 1; } +if [ "$PRESENT" != "present" ]; then + echo "Rien à copier : $FOLDER n'existe pas encore sur le serveur." + exit 0 +fi + +if ! ssh -F "$SSH_CONFIG" -o BatchMode=yes "$SSH_HOST" "tar -C \"\$HOME/$BASE/shared/storage\" -czf - \"$FOLDER\"" > "$PARTIAL"; then + rm -f "$PARTIAL" + echo "Échec de la copie de $FOLDER." >&2 + exit 1 +fi + +if ! tar -tzf "$PARTIAL" > /dev/null; then + rm -f "$PARTIAL" + echo "Archive corrompue : copie supprimée." >&2 + exit 1 +fi + +chmod 600 "$PARTIAL" +mv "$PARTIAL" "$DEST" + +# Rotation locale : on ne garde que les RB_BACKUP_LOCAL_KEEP plus récentes de ce dossier +i=0 +while IFS= read -r f; do + i=$((i + 1)) + if [ "$i" -gt "$KEEP" ]; then + rm -f -- "$f" + fi +done < <(ls -1 "$LOCAL_DIR/$FOLDER"-*.tgz 2>/dev/null | sort -r || true) + +echo "Fichiers récupérés : $(basename "$DEST") ($(du -h "$DEST" | cut -f1), $(tar -tzf "$DEST" | grep -vc '/$' || true) fichier(s))" +echo "Dossier local : $LOCAL_DIR (à ranger sur un disque chiffré : IBAN et noms de tiers)." diff --git a/tests/Deploy/Storage/FetchFilesScriptTest.php b/tests/Deploy/Storage/FetchFilesScriptTest.php new file mode 100644 index 00000000..e9e6809a --- /dev/null +++ b/tests/Deploy/Storage/FetchFilesScriptTest.php @@ -0,0 +1,137 @@ +root = sys_get_temp_dir() . '/rb-fetchfiles-' . bin2hex(random_bytes(4)); + mkdir($this->root . '/bin', 0o700, true); + mkdir($this->root . '/home/rehearsalbox/shared/storage/accounting-statements', 0o700, true); + file_put_contents($this->root . '/home/rehearsalbox/shared/storage/accounting-statements/aaaa', 'releve'); + file_put_contents($this->root . '/bin/ssh', "#!/bin/bash\nwhile [ \"\$1\" != \"rehearsalbox\" ]; do shift; done\nshift\nHOME=\"{$this->root}/home\" exec bash -c \"\$1\"\n"); + chmod($this->root . '/bin/ssh', 0o700); + } + + protected function tearDown(): void + { + exec('rm -rf ' . escapeshellarg($this->root)); + } + + /** @return array{0: int, 1: string} */ + private function fetch(string $args = '', string $keep = '30', ?string $dir = null): array + { + $dir ??= $this->root . '/out'; + $command = sprintf( + 'PATH=%s:$PATH RB_SSH_CONFIG=/dev/null RB_BACKUP_LOCAL_DIR=%s RB_BACKUP_LOCAL_KEEP=%s bash %s %s 2>&1', + escapeshellarg($this->root . '/bin'), + escapeshellarg($dir), + escapeshellarg($keep), + escapeshellarg(__DIR__ . '/../../../bin/fetch-files.sh'), + $args, + ); + exec($command, $lines, $code); + + return [$code, implode("\n", $lines)]; + } + + /** @return list */ + private function archives(): array + { + return glob($this->root . '/out/*.tgz') ?: []; + } + + #[Test] + public function testTheArchiveIsStoredPrivatelyAndContainsTheFiles(): void + { + [$code, $output] = $this->fetch(); + + self::assertSame(0, $code, $output); + $archives = $this->archives(); + self::assertCount(1, $archives); + self::assertSame('0600', substr(sprintf('%o', fileperms($archives[0])), -4)); + self::assertSame('0700', substr(sprintf('%o', fileperms($this->root . '/out')), -4)); + self::assertStringContainsString('accounting-statements/aaaa', (string) shell_exec('tar -tzf ' . escapeshellarg($archives[0]))); + self::assertSame([], glob($this->root . '/out/*.partial') ?: []); + } + + #[Test] + public function testOnlyTheTwoKnownFoldersAreAccepted(): void + { + foreach (['../x', "'; rm -rf /'", 'config', 'accounting-statements/..', ''] as $bad) { + if ($bad === '') { + continue; + } + [$code] = $this->fetch(escapeshellarg($bad)); + self::assertSame(1, $code, $bad); + } + self::assertSame([], $this->archives()); + [$code] = $this->fetch('group-documents'); + self::assertSame(0, $code, 'dossier absent du serveur : rien à copier, pas une erreur'); + self::assertSame([], $this->archives()); + } + + #[Test] + public function testALocalFolderInsideTheRepositoryIsRefused(): void + { + $inside = realpath(__DIR__ . '/../../..') . '/backups-interdits'; + + [$code, $output] = $this->fetch('', '30', $inside); + + self::assertSame(1, $code); + self::assertStringContainsString('Refusé', $output); + self::assertDirectoryDoesNotExist($inside); + } + + #[Test] + public function testOnlyTheMostRecentArchivesAreKept(): void + { + mkdir($this->root . '/out', 0o700); + foreach (['20250101000000', '20250102000000', '20250103000000'] as $stamp) { + exec('tar -C ' . escapeshellarg($this->root . '/home/rehearsalbox/shared/storage') . ' -czf ' . escapeshellarg($this->root . "/out/accounting-statements-{$stamp}.tgz") . ' accounting-statements'); + } + + [$code, $output] = $this->fetch('', '2'); + + self::assertSame(0, $code, $output); + self::assertCount(2, $this->archives()); + self::assertFileDoesNotExist($this->root . '/out/accounting-statements-20250101000000.tgz'); + } + + #[Test] + public function testAFailingConnectionLeavesNothingBehind(): void + { + file_put_contents($this->root . '/bin/ssh', "#!/bin/bash\nexit 255\n"); + + [$code] = $this->fetch(); + + self::assertNotSame(0, $code); + self::assertSame([], $this->archives()); + self::assertSame([], glob($this->root . '/out/*.partial') ?: []); + } + + #[Test] + public function testTheScriptNeverTouchesTheDatabaseOrPrintsSecrets(): void + { + $script = (string) file_get_contents(__DIR__ . '/../../../bin/fetch-files.sh'); + + self::assertStringContainsString('set -euo pipefail', $script); + self::assertStringContainsString('umask 077', $script); + self::assertFalse(stripos($script, 'mysql')); + self::assertFalse(stripos($script, 'mariadb')); + self::assertFalse(stripos($script, 'password')); + } +} From ea7be0c8deff7c933aa4b18d0a33364bd1e7cc17 Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Sat, 17 Oct 2026 12:55:22 +0200 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=93=96=20docs(Accounting):=20sauvegar?= =?UTF-8?q?de=20et=20restauration=20des=20relev=C3=A9s=20(#362)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/architecture.md | 2 +- .claude/deploiement.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.claude/architecture.md b/.claude/architecture.md index cef4d967..0d690758 100644 --- a/.claude/architecture.md +++ b/.claude/architecture.md @@ -471,7 +471,7 @@ Migration 042 (`accounting_statements`), `Statement`, `StatementRepositoryInterf - **Immuable** : ni modification ni suppression ; un relevé déposé par erreur est **remplacé** (l'ancien reste, marqué « remplacé ») tant que l'exercice est ouvert ; un exercice clos fige ses relevés. Plafonds par exercice : 60 relevés, 200 Mo, 10 Mo par fichier ; dépôt sous le verrou de la ligne d'exercice, fichier retiré si la transaction échoue. - **Écart** : solde de la banque selon le livre à la fin de la période (solde d'ouverture de l'exercice + écritures hors espèces jusqu'à `period_end`) comparé au solde saisi du relevé (facultatif). Limite connue : le solde d'ouverture de l'exercice est compté en entier côté banque, la caisse en espèces n'ayant pas de solde propre. - **Téléchargement** : toujours en pièce jointe, `nosniff`, CSP `sandbox`, `Cache-Control: private, no-store`, flux ; fichier absent : 404. -- **À faire (livraison 10)** : inclure `accounting-statements` dans les sauvegardes et le rapport d'orphelins (`DocumentOrphanReport`). +- **Sauvegarde et restauration** : `DocumentOrphanReport` couvre aussi les relevés (page `/admin/restore` et `bin/restore-db.php`), `bin/fetch-files.sh` copie le dossier hors serveur (les fichiers ne sont pas dans un dump), `deploy.sh` crée le dossier en 0700. ## Comptabilité : écritures récurrentes (#362, livraison 5) diff --git a/.claude/deploiement.md b/.claude/deploiement.md index bda53d85..a206b860 100644 --- a/.claude/deploiement.md +++ b/.claude/deploiement.md @@ -179,6 +179,7 @@ Deux voies pour la restauration COMPLÈTE (A) : la **page d'administration** ou - **Migrations** : rejouées automatiquement si le dump est d'avant (la table `migrations_log` revient avec le dump). - **Messages chiffrés** : « Messages : N valeur(s) se déchiffrent » = rien à faire (dump d'après le chiffrement). Si « MESSAGES ILLISIBLES » : dump **d'avant** — le mode strict refuse le clair. Enchaîne : `php bin/message-keys.php transition` → `php bin/encrypt-messages.php --dry-run` → `php bin/encrypt-messages.php` → `php bin/message-keys.php check` → `php bin/message-keys.php strict`. Dump chiffré avec une **ancienne clé** (après rotation) : elle doit être dans le fichier de clés (elles y restent) ; `check` le prouve. Rien n'est chiffré automatiquement. - **Documents de groupes** : les fichiers (`shared/storage`) ne sont PAS dans un dump. La page et la commande listent (lecture seule, rien n'est supprimé) les lignes sans fichier et les fichiers sans ligne ; à traiter à la main. + - **Relevés de compte de l'association** (#362) : même principe, section « Relevés de compte après restauration » (lignes sans fichier, fichiers sans ligne). Les fichiers de `shared/storage/accounting-statements` ne sont pas dans un dump : **les copier hors serveur avec `bin/fetch-files.sh`** (`accounting-statements` par défaut, `group-documents` en argument), après chaque lot de relevés déposés ; archive tar.gz vérifiée, rangée en 0600 dans `RB_BACKUP_LOCAL_DIR` (hors dépôt), à garder sur un disque chiffré (IBAN et noms de tiers). Restauration d'un fichier : extraire l'archive dans `shared/storage` puis relire le rapport d'écarts. 4. **Vérifier le site** : se connecter, ouvrir une conversation, envoyer un message ; `php bin/send-reminders.php` ne doit rien envoyer d'absurde (les relances reposent sur des dates : vérifier qu'aucune rafale ne part). OPcache : aucune purge nécessaire (le code n'a pas changé). 5. **Si ça tourne mal** : si l'import échoue après le vidage, le message nomme le dump de sécurité (`pre-…-avant-restauration`) : le restaurer de la même façon. Si le processus a été interrompu (page : « restauration interrompue »), relancer avec la sauvegarde voulue ou avec le dump de sécurité.