package sharing import ( "context" "errors" "os" "testing" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) // Ein Freigabelink ist der einzige Weg, auf dem eine Datei dieses Haus ohne // Anmeldung verlässt. Wer das Token hat, ist drin – also entscheidet sich hier // alles an zwei Fragen: Gilt der Link noch, und gehört er dem, der ihn // anfasst? // // Beides steckt nicht in einer if-Zeile, sondern in WHERE-Klauseln und einem // Zeitvergleich. Ein Fehler darin fällt beim Lesen nicht auf: Die Abfrage // läuft, sie liefert nur zu viel. // // Läuft nur mit TEST_DSN. Einfachster Weg: ./scripts/db-tests.sh ./internal/sharing/ func testStore(t *testing.T) (*Store, *pgxpool.Pool, context.Context) { t.Helper() 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) } t.Cleanup(pool.Close) return NewStore(pool), pool, ctx } func konto(t *testing.T, pool *pgxpool.Pool, ctx context.Context, name string) uuid.UUID { t.Helper() var id uuid.UUID err := pool.QueryRow(ctx, `INSERT INTO users (username, display_name, password, is_admin) VALUES ($1, $1, 'x', false) RETURNING id`, name+"-"+uuid.NewString()[:8], ).Scan(&id) if err != nil { t.Fatalf("konto %s: %v", name, err) } t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, id) }) return id } func datei(t *testing.T, pool *pgxpool.Pool, ctx context.Context, owner uuid.UUID, name string) uuid.UUID { t.Helper() var id uuid.UUID err := pool.QueryRow(ctx, `INSERT INTO files (owner_id, folder_id, name, mime_type, size, checksum, storage_key) VALUES ($1, NULL, $2, 'application/pdf', 1, 'x', $3) RETURNING id`, owner, name, "k/"+uuid.NewString(), ).Scan(&id) if err != nil { t.Fatalf("datei %s: %v", name, err) } return id } func TestAbgelaufenerLinkGiltNichtMehr(t *testing.T) { s, pool, ctx := testStore(t) anna := konto(t, pool, ctx, "anna") f := datei(t, pool, ctx, anna, "Rechnung.pdf") gestern := time.Now().Add(-1 * time.Hour) abgelaufen, err := s.Create(ctx, f, anna, "", &gestern) if err != nil { t.Fatalf("anlegen: %v", err) } if _, _, err := s.GetByToken(ctx, abgelaufen.Token); !errors.Is(err, ErrExpired) { t.Fatalf("abgelaufener Link muss ErrExpired liefern, kam: %v", err) } // Gegenprobe – sonst prüfte der Test nur, dass irgendetwas fehlschlägt. morgen := time.Now().Add(1 * time.Hour) gueltig, err := s.Create(ctx, f, anna, "", &morgen) if err != nil { t.Fatalf("anlegen: %v", err) } if _, _, err := s.GetByToken(ctx, gueltig.Token); err != nil { t.Fatalf("gültiger Link muss durchgehen, kam: %v", err) } // Ohne Ablauf gilt er unbegrenzt. ewig, err := s.Create(ctx, f, anna, "", nil) if err != nil { t.Fatalf("anlegen: %v", err) } if _, _, err := s.GetByToken(ctx, ewig.Token); err != nil { t.Fatalf("Link ohne Ablauf muss gelten, kam: %v", err) } } func TestFremderLinkLaesstSichNichtLoeschen(t *testing.T) { s, pool, ctx := testStore(t) anna := konto(t, pool, ctx, "anna") bert := konto(t, pool, ctx, "bert") f := datei(t, pool, ctx, anna, "Gehaltsabrechnung.pdf") link, err := s.Create(ctx, f, anna, "", nil) if err != nil { t.Fatalf("anlegen: %v", err) } // Bert kennt die ID – etwa, weil sie einmal irgendwo auftauchte. if err := s.Delete(ctx, link.ID, bert); !errors.Is(err, ErrNotFound) { t.Fatalf("Bert darf Annas Link nicht löschen, kam: %v", err) } // Und er ist wirklich noch da. if _, _, err := s.GetByToken(ctx, link.Token); err != nil { t.Fatalf("Link muss nach dem Fehlversuch noch gelten, kam: %v", err) } // Anna darf. if err := s.Delete(ctx, link.ID, anna); err != nil { t.Fatalf("Anna muss ihren Link löschen dürfen: %v", err) } if _, _, err := s.GetByToken(ctx, link.Token); !errors.Is(err, ErrNotFound) { t.Fatalf("gelöschter Link muss ErrNotFound liefern, kam: %v", err) } } func TestListeZeigtNurEigeneLinks(t *testing.T) { s, pool, ctx := testStore(t) anna := konto(t, pool, ctx, "anna") bert := konto(t, pool, ctx, "bert") fa := datei(t, pool, ctx, anna, "Annas.pdf") fb := datei(t, pool, ctx, bert, "Berts.pdf") if _, err := s.Create(ctx, fa, anna, "", nil); err != nil { t.Fatalf("anlegen: %v", err) } if _, err := s.Create(ctx, fb, bert, "", nil); err != nil { t.Fatalf("anlegen: %v", err) } annas, err := s.ListByOwner(ctx, anna) if err != nil { t.Fatalf("liste: %v", err) } if len(annas) != 1 { t.Fatalf("Anna soll genau einen Link sehen, sah %d", len(annas)) } if annas[0].FileID != fa { t.Fatalf("Anna sieht einen fremden Link: %v", annas[0].FileID) } } func TestPasswortVerlaesstDenSpeicherNichtVersehentlich(t *testing.T) { s, pool, ctx := testStore(t) anna := konto(t, pool, ctx, "anna") f := datei(t, pool, ctx, anna, "Vertrag.pdf") mitPw, err := s.Create(ctx, f, anna, "argon2-hash-platzhalter", nil) if err != nil { t.Fatalf("anlegen: %v", err) } // Das Anlegen gibt den Hash **nicht** zurück, nur die Tatsache. if !mitPw.HasPassword { t.Fatal("HasPassword muss true sein") } // Beim Abruf über das Token kommt er heraus – dort wird er gebraucht, // um das eingegebene Passwort zu prüfen. Aber nur dort. link, hash, err := s.GetByToken(ctx, mitPw.Token) if err != nil { t.Fatalf("abrufen: %v", err) } if hash != "argon2-hash-platzhalter" { t.Fatalf("Hash muss beim Tokenabruf vorliegen, kam %q", hash) } if !link.HasPassword { t.Fatal("HasPassword muss auch beim Abruf true sein") } // Die Liste für die Oberfläche kennt ihn nicht. liste, err := s.ListByOwner(ctx, anna) if err != nil { t.Fatalf("liste: %v", err) } if len(liste) != 1 || !liste[0].HasPassword { t.Fatalf("Liste soll den Link mit HasPassword zeigen, kam %+v", liste) } // Ohne Passwort bleibt es false – sonst prüfte das oben nichts. ohnePw, err := s.Create(ctx, f, anna, "", nil) if err != nil { t.Fatalf("anlegen: %v", err) } if ohnePw.HasPassword { t.Fatal("HasPassword muss ohne Passwort false sein") } if _, hash, _ := s.GetByToken(ctx, ohnePw.Token); hash != "" { t.Fatalf("ohne Passwort darf kein Hash kommen, kam %q", hash) } } func TestTokenIstNichtZuErraten(t *testing.T) { s, pool, ctx := testStore(t) anna := konto(t, pool, ctx, "anna") f := datei(t, pool, ctx, anna, "Rechnung.pdf") gesehen := map[string]bool{} for i := 0; i < 20; i++ { l, err := s.Create(ctx, f, anna, "", nil) if err != nil { t.Fatalf("anlegen: %v", err) } // 24 Zufallsbytes, base64url ohne Auffüllen = 32 Zeichen. Wird das // jemals kürzer, ist das Token ratbar – und der Link ist das einzige // Geheimnis, das ihn schützt. if len(l.Token) != 32 { t.Fatalf("Token soll 32 Zeichen haben, hat %d: %q", len(l.Token), l.Token) } if gesehen[l.Token] { t.Fatalf("Token doppelt vergeben: %q", l.Token) } gesehen[l.Token] = true } } func TestZaehlerZaehlt(t *testing.T) { s, pool, ctx := testStore(t) anna := konto(t, pool, ctx, "anna") f := datei(t, pool, ctx, anna, "Rechnung.pdf") link, err := s.Create(ctx, f, anna, "", nil) if err != nil { t.Fatalf("anlegen: %v", err) } if link.DownloadCount != 0 { t.Fatalf("frischer Link soll bei 0 stehen, stand bei %d", link.DownloadCount) } s.IncrementDownloads(ctx, link.Token) s.IncrementDownloads(ctx, link.Token) nach, _, err := s.GetByToken(ctx, link.Token) if err != nil { t.Fatalf("abrufen: %v", err) } if nach.DownloadCount != 2 { t.Fatalf("Zähler soll 2 sein, ist %d", nach.DownloadCount) } }