internal/rescan 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, oder eines Tippfehlers in DATA_PATH. Dann gilt jede Datei als verwaist, und ein einziger Aufruf mit ?anwenden=true löscht den gesamten Bestand aus der Datenbank. Die Bytes lägen noch da, aber niemand wüsste mehr, dass es sie gibt – und der GC räumte sie 24 Stunden später ab. Der GC hat diese Bremse seit dem 12.09. Beide gleichen Platte und Datenbank ab, nur aus entgegengesetzter Richtung, und beide haben denselben Fehlerfall: Eine Seite antwortet fälschlich mit „leer". Der GC löscht dann Blobs – die Datenbank weiss hinterher noch, was fehlt. Hier fällt die Zeile selbst. Die gefährlichere Seite war die ungesicherte. Zwei Bremsen: Die Notbremse (nichts gefunden, Datenbank kennt Dateien) lässt sich nicht übersteuern. Die Mengenbremse (mehr als die Hälfte) verlangt &bestaetigt=true. Die leere Installation ist ausgenommen. Dazu umgebaut: erst sammeln, dann entscheiden, dann löschen. Vorher wurde mitten in der Schleife gelöscht; die Gesamtzahl kennt man dort erst, wenn die Hälfte schon weg ist. Der Bericht nennt den Grund und ob er bestätigbar ist – die Oberfläche soll keinen Knopf anbieten, der nichts bewirkt. Ein abgebrochener Lauf steht rot in der Karte und im Audit-Log; gerade der, denn er ist der Hinweis darauf, dass mit dem Datenordner etwas nicht stimmt. Fünf Tests, unter Mutation nachgewiesen. Der Ernstfall läuft am echten Gerät: fünf Zeilen in der Datenbank, ein Wurzelverzeichnis ohne users/ – nach Apply stehen alle fünf noch, auch mit Bestätigung. audit und thumbnails bekommen bewusst keine Go-Tests; die Begründung steht in BEFUNDE.md statt als ewig offener Punkt. Nebenbefund behoben: kalender-teilen-selbsttest.py leckte je Lauf ein App-Passwort des Besitzers. Nach achtzehn Läufen war maxAppPasswords = 20 erreicht, und zwei Suiten scheiterten mit 409, ohne dass am Code etwas kaputt war. Geprüft: go build/vet sauber, alle Go-Pakete grün, 243 Prüfungen der sieben Selbsttest-Suiten grün, 48 Vitest-Tests, tsc sauber. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
165 lines
5.2 KiB
Go
165 lines
5.2 KiB
Go
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)
|
||
}
|
||
}
|