package rescan import ( "context" "os" "path/filepath" "testing" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "aether/internal/files" "aether/internal/storage" ) // Der Abgleich ist das einzige Stück Aether, das **hart** löscht: // `DELETE FROM files`, kein Papierkorb. Entfernt wird, was beim Durchgang über // die Platte nicht gesehen wurde. // // Der Ernstfall ist nicht ausgedacht: `LocalFS.Walk` gibt für ein nicht // vorhandenes Verzeichnis `nil` zurück – kein Fehler, keine Einträge. Genau // der Fingerabdruck eines Bind-Mounts, der nach einem Neustart nicht hochkam. // Ohne Bremse gälte dann jede Datei als verwaist, und ein einziger Aufruf mit // `?anwenden=true` löschte den ganzen Bestand aus der Datenbank. // // Der GC hat diese Bremse seit dem 12.09.2026. Hier fehlte sie – an der // gefährlicheren von beiden Stellen. func TestNotbremseBeiLeeremSpeicher(t *testing.T) { // Nichts gesehen, obwohl die Datenbank Dateien kennt: nicht verhandelbar. grund, uebersteuerbar := pruefeBremse(0, 1200, 1200, false) if grund == "" { t.Fatal("leerer Durchgang bei gefuellter Datenbank muss bremsen") } if uebersteuerbar { t.Fatal("die Notbremse darf sich nicht bestaetigen lassen") } // Auch mit ausdruecklicher Bestaetigung nicht. if grund, _ := pruefeBremse(0, 1200, 1200, true); grund == "" { t.Fatal("auch mit Bestaetigung muss die Notbremse halten") } } func TestLeereInstallationDarfDurch(t *testing.T) { // Nichts auf der Platte, nichts in der Datenbank – da gibt es nichts zu // verwechseln. Würde hier gebremst, liesse sich eine frische Anlage nie // abgleichen. if grund, _ := pruefeBremse(0, 0, 0, false); grund != "" { t.Fatalf("leere Installation darf nicht bremsen: %s", grund) } } func TestMengenbremseLaesstSichBestaetigen(t *testing.T) { // Mehr als die Hälfte auf einmal: erst nachfragen. grund, uebersteuerbar := pruefeBremse(500, 1000, 501, false) if grund == "" { t.Fatal("mehr als die Haelfte muss nachfragen") } if !uebersteuerbar { t.Fatal("die Mengenbremse muss sich bestaetigen lassen") } if grund, _ := pruefeBremse(500, 1000, 501, true); grund != "" { t.Fatalf("mit Bestaetigung muss es durchgehen: %s", grund) } } func TestGewoehnlichesAufraeumenGehtDurch(t *testing.T) { // Der Normalfall: ein paar Leichen unter vielen. Bremste es hier, wäre die // Funktion unbrauchbar – und jemand baute die Bremse wieder aus. for _, f := range []struct { gescannt, lebende, fallen int }{ {1000, 1000, 1}, {1000, 1000, 17}, {1000, 1000, 500}, // genau die Hälfte: noch erlaubt {1, 1, 0}, } { if grund, _ := pruefeBremse(f.gescannt, f.lebende, f.fallen, false); grund != "" { t.Fatalf("%d von %d duerfen fallen, wurde gebremst: %s", f.fallen, f.lebende, grund) } } } // Und jetzt der Ernstfall am echten Gerät: Dateien in der Datenbank, ein // Datenordner, den es nicht gibt. Nach dem Anwenden muss **jede** Zeile noch // da sein. // // Läuft nur mit TEST_DSN: ./scripts/db-tests.sh ./internal/rescan/ func TestAnwendenLoeschtNichtsWennDerOrdnerFehlt(t *testing.T) { dsn := os.Getenv("TEST_DSN") if dsn == "" { t.Skip("TEST_DSN nicht gesetzt") } ctx := context.Background() pool, err := pgxpool.New(ctx, dsn) if err != nil { t.Fatalf("verbinden: %v", err) } defer pool.Close() var nutzer uuid.UUID if err := pool.QueryRow(ctx, `INSERT INTO users (username, display_name, password, is_admin) VALUES ($1, $1, 'x', false) RETURNING id`, "rescan-"+uuid.NewString()[:8], ).Scan(&nutzer); err != nil { t.Fatalf("konto: %v", err) } defer pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, nutzer) for i := 0; i < 5; i++ { if _, err := pool.Exec(ctx, `INSERT INTO files (owner_id, folder_id, name, mime_type, size, checksum, storage_key) VALUES ($1, NULL, $2, 'application/pdf', 3, 'x', $3)`, nutzer, "Rechnung.pdf", "users/wer/home/"+uuid.NewString(), ); err != nil { t.Fatalf("datei: %v", err) } } // Ein Wurzelverzeichnis, das es gibt – aber ohne "users/" darin. Genau das // sieht der Server, wenn der Bind-Mount nicht hochkam. wurzel := t.TempDir() st, err := storage.NewLocalFS(wurzel, filepath.Join(wurzel, "tmp")) if err != nil { t.Fatalf("speicher: %v", err) } scanner := New(pool, files.NewStore(pool), st) bericht, err := scanner.Apply(ctx, false) if err != nil { t.Fatalf("anwenden: %v", err) } if !bericht.Aborted { t.Fatal("der Lauf haette abbrechen muessen") } if bericht.Removed != 0 { t.Fatalf("es durfte nichts entfernt werden, entfernt wurden %d", bericht.Removed) } if bericht.Overridable { t.Fatal("die Notbremse darf sich nicht bestaetigen lassen") } var uebrig int if err := pool.QueryRow(ctx, `SELECT count(*) FROM files WHERE owner_id = $1`, nutzer).Scan(&uebrig); err != nil { t.Fatalf("zaehlen: %v", err) } if uebrig != 5 { t.Fatalf("alle fuenf Zeilen muessen stehen bleiben, es sind %d", uebrig) } // Gegenprobe mit Bestaetigung: Auch dann bleibt alles stehen. if _, err := scanner.Apply(ctx, true); err != nil { t.Fatalf("anwenden mit Bestaetigung: %v", err) } if err := pool.QueryRow(ctx, `SELECT count(*) FROM files WHERE owner_id = $1`, nutzer).Scan(&uebrig); err != nil { t.Fatalf("zaehlen: %v", err) } if uebrig != 5 { t.Fatalf("auch mit Bestaetigung muessen alle fuenf stehen, es sind %d", uebrig) } }