syncova-backup/apps/api/internal/httpapi/browse_handler_test.go
Jerrit Fritzsche 20b0919676
Some checks failed
CI / Backend (Go) (push) Failing after 31s
CI / Frontend (React/TypeScript) (push) Successful in 45s
CI / Sicherheitsprüfungen (push) Successful in 27s
Datei- und Ordnerwiederherstellung, Ordnerbaum, Geist Mono im Paket
**Warum keine Wiederherstellung funktionierte.** Der Dienst laeuft mit
`ProtectSystem=strict` und `ReadWritePaths` nur auf Repository und
Sicherungsordner — alles andere ist fuer ihn schreibgeschuetzt. Jedes Ziel
ausserhalb endete mit "mkdir: permission denied", und zwar **nach** der
Vorabpruefung. `/tmp` scheiterte anders: Mit `PrivateTmp=yes` hat der Dienst ein
eigenes /tmp, und was dort landet, sieht man von aussen gar nicht.

`setup.sh` legt jetzt `/srv/syncova-restore` an und traegt es in
`ReadWritePaths` ein; `--wiederherstellungsziel` ergaenzt weitere. Der Ort liegt
unter /srv und nicht unter /var/lib — Letzteres steht auf der Sperrliste des
Zielschutzes. Beide Regeln zugleich zu erfuellen laesst genau /srv uebrig; das
ist mir erst aufgefallen, nachdem ich die Flaeche zunaechst falsch gelegt hatte
und der eigene Zielschutz sie ablehnte.

**Zwei neue Endpunkte** (Vertrag entsprechend erweitert):

- `GET /filesystem/browse` — Verzeichnisse mit der Angabe, ob der **Dienst**
  dort schreiben darf. **Gemessen** durch eine Probedatei, nicht aus den
  Rechtebits geraten: Unter ProtectSystem=strict sagen die Bits nichts ueber
  das aus, was der Namensraum zulaesst. Gesperrte Orte werden gezeigt, nicht
  versteckt — sonst bliebe offen, warum ein Pfad fehlt.
- `GET /backups/{id}/contents` — das Manifest als Ebene eines Baums. Der Baum
  entsteht aus den **Pfaden**, nicht aus Verzeichniseintraegen: Ein Manifest
  kann eine Datei enthalten, deren Elternverzeichnis nicht als eigener Eintrag
  vorliegt, und wer nur `directory`-Eintraege auflistet, verliert ganze
  Teilbaeume. Durch Mutation bestaetigt.

**Auswahl statt Textfeld.** Der Assistent hat jetzt einen Ordnerbaum fuer das
Ziel und einen Browser fuer den Backup-Inhalt. Ordner **und** einzelne Dateien
lassen sich waehlen; beides geht als `path_prefix` in die Anfrage, weil der
Server auf Gleichheit oder Praefix mit Verzeichnisgrenze vergleicht. Bewusste
Grenze: eine Auswahl je Lauf — eine Liste kennt die API nicht, und mehrere
Laeufe vorzutaeuschen ergaebe mehrere Ausgaenge, die niemand mehr erklaeren
kann.

**Integritaetslauf.** "can't access property toLocaleString, chunks_checked is
undefined" — die Ergebnisse liegen unter `details`, und die Felder heissen
`missing_chunks`/`corrupted_chunks`, nicht umgekehrt. Betrifft alle vier
Pruefendpunkte; sie tragen dieselbe Huelle. Derselbe Fehler wie bei
/retention-policies: die Antwortform angenommen statt geprueft.

**Geist Mono liegt jetzt im Paket** (drei Schnitte, 128 KB, OFL-Lizenz dabei).
Ausgeliefert vom eigenen Ursprung — das verlangt die CSP, und ein
Backup-Server, dessen Oberflaeche von einem CDN abhaengt, waere auch ohne CSP
falsch. `font-display: swap`, damit der Text sofort steht.

Nachgewiesen gegen Debian 12: Vollwiederherstellung (5 Dateien), nur ein Ordner
(2 Dateien), nur eine Datei (1 Datei) — alle drei bitgenau. Der Server nennt
/srv/syncova-restore als beschreibbar und /etc, /usr, /var als gesperrt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:52:37 +02:00

91 lines
3.4 KiB
Go

package httpapi
import (
"testing"
"time"
"github.com/syncova/syncova/packages/repository"
)
// TestCollectLevelBuildsTreeWithoutDirectoryEntries haelt fest, dass der Baum
// aus den Pfaden entsteht und nicht aus Verzeichniseintraegen.
//
// Ein Manifest kann eine Datei enthalten, deren Elternverzeichnis nicht als
// eigener Eintrag vorliegt — etwa bei einer Quelle, die nur Dateien meldet. Wer
// nur die Eintraege vom Typ "directory" auflistet, verliert dann ganze
// Teilbaeume, und die Datei ist ueber die Oberflaeche nicht mehr erreichbar.
func TestCollectLevelBuildsTreeWithoutDirectoryEntries(testInstance *testing.T) {
manifestEntries := []repository.ManifestEntry{
// Kein Eintrag fuer "berichte" selbst.
{Path: "berichte/2026/jahr.pdf", EntryType: "file", SizeBytes: 900},
{Path: "berichte/2025/jahr.pdf", EntryType: "file", SizeBytes: 100},
{Path: "notiz.txt", EntryType: "file", SizeBytes: 6},
}
rootLevel := collectLevel(manifestEntries, "")
if len(rootLevel) != 2 {
testInstance.Fatalf("erwartet 2 Eintraege auf der Wurzel, erhalten %d", len(rootLevel))
}
// Verzeichnisse stehen vorn.
if !rootLevel[0].IsDirectory || rootLevel[0].Name != "berichte" {
testInstance.Errorf("das Verzeichnis berichte fehlt oder steht nicht vorn: %+v", rootLevel[0])
}
// Die Kennzahlen summieren den ganzen Teilbaum: Ein Betreiber will vor dem
// Zurueckholen wissen, wie viel an einem Ordner haengt.
if rootLevel[0].ChildCount != 2 || rootLevel[0].TotalBytes != 1000 {
testInstance.Errorf("Kennzahlen des Ordners falsch: %d Objekte, %d Byte",
rootLevel[0].ChildCount, rootLevel[0].TotalBytes)
}
// Und eine Ebene tiefer erscheinen die Jahresordner.
deeperLevel := collectLevel(manifestEntries, "berichte")
if len(deeperLevel) != 2 {
testInstance.Fatalf("erwartet 2 Jahresordner, erhalten %d", len(deeperLevel))
}
}
// TestCollectLevelRespectsDirectoryBoundary haelt die Verzeichnisgrenze fest.
//
// "dokumente" darf nicht auch "dokumentation" treffen — sonst holte eine
// Wiederherstellung Daten zurueck, die niemand ausgewaehlt hat.
func TestCollectLevelRespectsDirectoryBoundary(testInstance *testing.T) {
manifestEntries := []repository.ManifestEntry{
{Path: "dokumente/a.txt", EntryType: "file", SizeBytes: 1},
{Path: "dokumentation/b.txt", EntryType: "file", SizeBytes: 1},
}
level := collectLevel(manifestEntries, "dokumente")
if len(level) != 1 || level[0].Name != "a.txt" {
testInstance.Errorf("die Verzeichnisgrenze wird nicht beachtet: %+v", level)
}
}
// TestCollectLevelKeepsFileMetadata prueft die Angaben je Datei.
func TestCollectLevelKeepsFileMetadata(testInstance *testing.T) {
modificationTime := time.Date(2026, 8, 18, 10, 0, 0, 0, time.UTC)
level := collectLevel([]repository.ManifestEntry{
{Path: "notiz.txt", EntryType: "file", SizeBytes: 42, Mode: "0644", ModifiedAt: modificationTime},
}, "")
if len(level) != 1 {
testInstance.Fatalf("erwartet einen Eintrag, erhalten %d", len(level))
}
// Der Pfad ist genau der Wert, den eine Wiederherstellung als
// `path_prefix` erwartet — eine Abweichung faellt sonst erst beim
// Zurueckschreiben auf.
if level[0].Path != "notiz.txt" || level[0].SizeBytes != 42 || level[0].Mode != "0644" {
testInstance.Errorf("Angaben der Datei unvollstaendig: %+v", level[0])
}
if level[0].ModifiedAt != "2026-08-18T10:00:00Z" {
testInstance.Errorf("Zeitstempel falsch: %q", level[0].ModifiedAt)
}
}