Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA. Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb, Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming, Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office (Collabora/OnlyOffice). Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort) sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
224 lines
5.4 KiB
Go
224 lines
5.4 KiB
Go
package storage
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"io"
|
||
"os"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
func newTestFS(t *testing.T) *LocalFS {
|
||
t.Helper()
|
||
root := t.TempDir()
|
||
tmp := t.TempDir()
|
||
fs, err := NewLocalFS(root, tmp)
|
||
if err != nil {
|
||
t.Fatalf("NewLocalFS: %v", err)
|
||
}
|
||
return fs
|
||
}
|
||
|
||
func TestPutAndGet(t *testing.T) {
|
||
fs := newTestFS(t)
|
||
ctx := context.Background()
|
||
content := []byte("hello aether")
|
||
|
||
checksum, size, err := fs.Put(ctx, "testfile", bytes.NewReader(content))
|
||
if err != nil {
|
||
t.Fatalf("Put: %v", err)
|
||
}
|
||
if size != int64(len(content)) {
|
||
t.Errorf("size: got %d want %d", size, len(content))
|
||
}
|
||
|
||
// verify checksum
|
||
h := sha256.Sum256(content)
|
||
expected := hex.EncodeToString(h[:])
|
||
if checksum != expected {
|
||
t.Errorf("checksum: got %s want %s", checksum, expected)
|
||
}
|
||
|
||
// get and read back
|
||
rc, err := fs.Get(ctx, "testfile")
|
||
if err != nil {
|
||
t.Fatalf("Get: %v", err)
|
||
}
|
||
defer rc.Close()
|
||
got, err := io.ReadAll(rc)
|
||
if err != nil {
|
||
t.Fatalf("ReadAll: %v", err)
|
||
}
|
||
if !bytes.Equal(got, content) {
|
||
t.Errorf("content mismatch")
|
||
}
|
||
}
|
||
|
||
func TestChecksumVerification(t *testing.T) {
|
||
fs := newTestFS(t)
|
||
ctx := context.Background()
|
||
content := []byte("important data")
|
||
|
||
storedChecksum, _, err := fs.Put(ctx, "file", bytes.NewReader(content))
|
||
if err != nil {
|
||
t.Fatalf("Put: %v", err)
|
||
}
|
||
|
||
info, err := fs.Stat(ctx, "file")
|
||
if err != nil {
|
||
t.Fatalf("Stat: %v", err)
|
||
}
|
||
if info.Checksum != storedChecksum {
|
||
t.Errorf("checksum mismatch after stat: got %s want %s", info.Checksum, storedChecksum)
|
||
}
|
||
}
|
||
|
||
func TestPathTraversal(t *testing.T) {
|
||
fs := newTestFS(t)
|
||
ctx := context.Background()
|
||
|
||
cases := []string{
|
||
"../escape",
|
||
"../../etc/passwd",
|
||
"/absolute/path",
|
||
"a/../../b",
|
||
"foo/../../../bar",
|
||
}
|
||
for _, key := range cases {
|
||
_, _, err := fs.Put(ctx, key, strings.NewReader("data"))
|
||
if err == nil {
|
||
t.Errorf("expected error for traversal key %q but got none", key)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestAtomicWrite_NoPartialFile(t *testing.T) {
|
||
fs := newTestFS(t)
|
||
ctx := context.Background()
|
||
|
||
// Simulate an interrupted reader (fails mid-stream).
|
||
bad := &failReader{data: []byte("partial"), failAt: 3}
|
||
_, _, err := fs.Put(ctx, "partial", bad)
|
||
if err == nil {
|
||
t.Fatal("expected error from failing reader")
|
||
}
|
||
|
||
// The destination file must NOT exist.
|
||
dest := fs.root + "/partial"
|
||
if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) {
|
||
t.Error("partial file must not exist after failed write")
|
||
}
|
||
}
|
||
|
||
func TestDelete(t *testing.T) {
|
||
fs := newTestFS(t)
|
||
ctx := context.Background()
|
||
|
||
if _, _, err := fs.Put(ctx, "todelete", strings.NewReader("bye")); err != nil {
|
||
t.Fatalf("Put: %v", err)
|
||
}
|
||
if err := fs.Delete(ctx, "todelete"); err != nil {
|
||
t.Fatalf("Delete: %v", err)
|
||
}
|
||
if _, err := fs.Get(ctx, "todelete"); err == nil {
|
||
t.Error("expected error getting deleted file")
|
||
}
|
||
// Double-delete should be a no-op.
|
||
if err := fs.Delete(ctx, "todelete"); err != nil {
|
||
t.Errorf("second Delete should be no-op, got: %v", err)
|
||
}
|
||
}
|
||
|
||
// failReader returns data normally up to failAt bytes, then errors.
|
||
type failReader struct {
|
||
data []byte
|
||
pos int
|
||
failAt int
|
||
}
|
||
|
||
func (r *failReader) Read(p []byte) (int, error) {
|
||
if r.pos >= r.failAt {
|
||
return 0, io.ErrUnexpectedEOF
|
||
}
|
||
n := copy(p, r.data[r.pos:min(r.failAt, len(r.data))])
|
||
r.pos += n
|
||
return n, nil
|
||
}
|
||
|
||
func min(a, b int) int {
|
||
if a < b {
|
||
return a
|
||
}
|
||
return b
|
||
}
|
||
|
||
// Walk ist die Grundlage der Blob-Bereinigung: Übersieht es Dateien, bleiben
|
||
// Waisen liegen; meldet es Schlüssel in einer anderen Form als die Datenbank
|
||
// sie speichert, hält der GC gesunde Blobs für verwaist und löscht sie.
|
||
func TestWalkReportsEveryBlobWithDBStyleKeys(t *testing.T) {
|
||
fs := newTestFS(t)
|
||
ctx := context.Background()
|
||
|
||
want := map[string]bool{
|
||
"users/alice/home/notiz.txt": true,
|
||
"users/alice/home/Unterordner/bild.png": true,
|
||
"users/alice/.versions/abc-123": true,
|
||
"users/alice/.trash/deadbeef_alt.txt": true,
|
||
}
|
||
for key := range want {
|
||
if _, _, err := fs.Put(ctx, key, strings.NewReader("inhalt")); err != nil {
|
||
t.Fatalf("Put(%q): %v", key, err)
|
||
}
|
||
}
|
||
// Außerhalb des Präfixes – darf nicht mitkommen.
|
||
if _, _, err := fs.Put(ctx, "thumbs/xyz.jpg", strings.NewReader("x")); err != nil {
|
||
t.Fatalf("Put thumb: %v", err)
|
||
}
|
||
|
||
got := map[string]bool{}
|
||
if err := fs.Walk(ctx, "users", func(key string, info FileInfo) error {
|
||
got[key] = true
|
||
if info.Size != int64(len("inhalt")) {
|
||
t.Errorf("Walk reported size %d for %q, want %d", info.Size, key, len("inhalt"))
|
||
}
|
||
if info.ModTime.IsZero() {
|
||
t.Errorf("Walk reported zero ModTime for %q – the GC grace period relies on it", key)
|
||
}
|
||
return nil
|
||
}); err != nil {
|
||
t.Fatalf("Walk: %v", err)
|
||
}
|
||
|
||
for key := range want {
|
||
if !got[key] {
|
||
t.Errorf("Walk missed %q – the GC would never reclaim it", key)
|
||
}
|
||
}
|
||
if got["thumbs/xyz.jpg"] {
|
||
t.Error("Walk crossed the prefix boundary")
|
||
}
|
||
if len(got) != len(want) {
|
||
t.Errorf("Walk returned %d keys, want %d: %v", len(got), len(want), got)
|
||
}
|
||
}
|
||
|
||
// Ein fehlender Präfix ist kein Fehler – sonst bräche der GC auf einer frischen
|
||
// Installation ab, bevor er irgendetwas tut.
|
||
func TestWalkMissingPrefixIsNotAnError(t *testing.T) {
|
||
fs := newTestFS(t)
|
||
|
||
called := false
|
||
if err := fs.Walk(context.Background(), "gibtesnicht", func(string, FileInfo) error {
|
||
called = true
|
||
return nil
|
||
}); err != nil {
|
||
t.Fatalf("Walk on missing prefix: %v", err)
|
||
}
|
||
if called {
|
||
t.Error("callback ran although the prefix does not exist")
|
||
}
|
||
}
|