Files
Aether/backend/internal/sharing/store_test.go
T
marcandClaude Opus 5 6fab2388ca security(sharing): Freigabelink prüft die Prüfsumme, Gruppenordner teilbar
Beim Lesen von internal/sharing fiel eine ungleiche Zusage auf: Der
angemeldete Download prüft seit dem 12.09. die SHA-256-Summe beim Ausliefern
und lässt die Verbindung fallen, wenn sie nicht stimmt. Der öffentliche
Freigabelink lief durch ein blankes io.Copy – ungeprüft.

Ausgerechnet der Weg nach draussen. Wer die Datei angemeldet holt, ist der
Eigentümer und merkt, wenn etwas kaputt ist; wer einen Link bekommt, merkt es
nicht. In CLAUDE.md steht das unter „nicht verhandelbar": beim Upload
berechnen, beim Download verifizieren. Die Regel galt an einer von zwei
Stellen. Die Mechanik liegt jetzt in files/verified.go und wird von beiden
gerufen. Nachgewiesen mit gekipptem Bit auf der Platte: Über den Freigabelink
kommt die Datei ebenfalls nicht mehr vollständig an.

Gruppenordner: Create übergab user.ID statt des aufgelösten Prinzipals – eine
Datei im Gruppenordner liess sich nicht verlinken, auch nicht vom Mitglied.
Marcs Entscheidung: Das soll gehen, der Empfänger sieht ohnehin nur die Datei,
nie den Ordner darum. Verlangt wird Schreibrecht: Einen Link zu erzeugen
heisst, die Datei öffentlich zu machen; wer sie nur geliehen lesen darf, soll
sie nicht weiterreichen können (403).

expires_in_hours hatte keine Obergrenze. time.Duration rechnet in
Nanosekunden; ab etwa 2,6 Mio Stunden läuft int64 über, die Dauer wird negativ,
und der Link ist abgelaufen in dem Moment, in dem er entsteht – ohne
Fehlermeldung. Jetzt zehn Jahre, darüber 400.

Dazu X-Content-Type-Options: nosniff, das der angemeldete Weg längst setzt.

Sechs Go-Tests für internal/sharing, unter Mutation nachgewiesen:
Eigentümerprüfung entfernt, Ablaufprüfung entfernt und Token verkürzt machen
jeweils genau den zugehörigen Test rot.

Selbsttests erweitert: sharing 24 → 31 Prüfungen, pruefsumme 14 → 16.

Geprüft: go build/vet sauber, alle Go-Pakete grün, 241 Prüfungen der sieben
Selbsttest-Suiten grün, 48 Vitest-Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 17:57:10 +02:00

252 lines
7.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}