Files
patrick 89de794356
CI / Backend (go vet, go test -cover) (push) Has been cancelled
CI / Frontend (ESLint, tsc, next build) (push) Has been cancelled
FDN-02/FDN-03/FDN-07/FDN-08: Migrations-Rollback, Objekt-Storage-Interface, go.sum-Fix, Observability
- FDN-02: Rollback-fähige Down-Migrationen (024-026), archivdms seed dev CLI
- FDN-03: internal/objectstore Interface + lokaler WORM-Treiber, signierte Download-URLs
- FDN-07: go.mod/go.sum vervollständigt (fehlender go-ldap/v3-Eintrag), CI-Pipeline (.gitea/workflows/ci.yml, bereits in FDN-01 committet) damit lauffähig
- FDN-08: Request-ID-Middleware, /metrics-Endpoint, Panic-Recovery, Login/Logout/Me technisches Logging inkl. Access-Log je Anfrage
2026-08-11 22:27:52 +02:00

205 lines
6.2 KiB
Go

package objectstore
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"archivdms/config"
)
func newTestStore(t *testing.T) *LocalStore {
t.Helper()
l, err := NewLocalStore(config.StorageConfig{BasePath: t.TempDir()}, "test-master-secret", "https://dms.example.test")
if err != nil {
t.Fatalf("NewLocalStore: %v", err)
}
return l
}
// stage writes a scratch file and returns its path plus content hash.
func stage(t *testing.T, content string) (string, string) {
t.Helper()
p := filepath.Join(t.TempDir(), "scratch.pdf")
if err := os.WriteFile(p, []byte(content), 0o640); err != nil {
t.Fatalf("write scratch: %v", err)
}
sum := sha256.Sum256([]byte(content))
return p, hex.EncodeToString(sum[:])
}
// AK 1 + Prüfung 1: Round-Trip Archive -> Open, WORM path scheme and 0440.
func TestArchiveOpenRoundTrip(t *testing.T) {
l := newTestStore(t)
ctx := context.Background()
src, hash := stage(t, "hello worm")
at := time.Date(2026, 3, 7, 10, 0, 0, 0, time.UTC)
dst, err := l.Archive(ctx, 42, src, ".pdf", hash, at)
if err != nil {
t.Fatalf("Archive: %v", err)
}
want := filepath.Join(l.cfg.StorePath(), "42", "2026", "03", hash+".pdf")
if dst != want {
t.Fatalf("path scheme changed: got %q want %q", dst, want)
}
fi, err := os.Stat(dst)
if err != nil {
t.Fatalf("stat archived: %v", err)
}
if fi.Mode().Perm() != 0o440 {
t.Fatalf("WORM permissions: got %o want 0440", fi.Mode().Perm())
}
if _, err := os.Stat(src); !os.IsNotExist(err) {
t.Fatalf("scratch file not consumed")
}
f, err := l.Open(ctx, 42, dst)
if err != nil {
t.Fatalf("Open: %v", err)
}
defer f.Close()
got, _ := io.ReadAll(f)
if string(got) != "hello worm" {
t.Fatalf("round-trip content mismatch: %q", got)
}
// Duplicate archival of the same content is rejected.
src2, _ := stage(t, "hello worm")
if _, err := l.Archive(ctx, 42, src2, ".pdf", hash, at); !errors.Is(err, ErrObjectExists) {
t.Fatalf("duplicate: got %v want ErrObjectExists", err)
}
}
// AK 1: cross-tenant access is refused even with a valid path.
func TestOpenForeignTenantRejected(t *testing.T) {
l := newTestStore(t)
ctx := context.Background()
src, hash := stage(t, "tenant one")
dst, err := l.Archive(ctx, 1, src, ".pdf", hash, time.Now())
if err != nil {
t.Fatalf("Archive: %v", err)
}
if _, err := l.Open(ctx, 2, dst); !errors.Is(err, ErrOutsideTenant) {
t.Fatalf("foreign tenant: got %v want ErrOutsideTenant", err)
}
if _, err := l.Open(ctx, 1, filepath.Join(filepath.Dir(dst), "..", "..", "..", "2", "x.pdf")); !errors.Is(err, ErrOutsideTenant) {
t.Fatalf("traversal: got %v want ErrOutsideTenant", err)
}
}
// Prüfung 3: missing object yields a clear, typed error.
func TestMissingObjectErrors(t *testing.T) {
l := newTestStore(t)
ctx := context.Background()
missing := filepath.Join(l.cfg.StorePath(), "7", "2026", "01", "deadbeef.pdf")
if _, err := l.Open(ctx, 7, missing); !errors.Is(err, ErrObjectNotFound) {
t.Fatalf("Open: got %v want ErrObjectNotFound", err)
}
if _, err := l.Stat(ctx, 7, missing); !errors.Is(err, ErrObjectNotFound) {
t.Fatalf("Stat: got %v want ErrObjectNotFound", err)
}
if err := l.Delete(ctx, 7, missing); !errors.Is(err, ErrObjectNotFound) {
t.Fatalf("Delete: got %v want ErrObjectNotFound", err)
}
if _, err := l.Open(ctx, 7, ""); !errors.Is(err, ErrObjectNotFound) {
t.Fatalf("empty path: got %v want ErrObjectNotFound", err)
}
}
func TestDeleteRemovesObject(t *testing.T) {
l := newTestStore(t)
ctx := context.Background()
src, hash := stage(t, "to be deleted")
dst, err := l.Archive(ctx, 5, src, ".pdf", hash, time.Now())
if err != nil {
t.Fatalf("Archive: %v", err)
}
if err := l.Delete(ctx, 5, dst); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := os.Stat(dst); !os.IsNotExist(err) {
t.Fatalf("object still present after delete")
}
}
// AK 2 + Prüfung 2: signed URLs verify while valid and are refused afterwards.
func TestSignedURLLifecycle(t *testing.T) {
l := newTestStore(t)
link, err := l.SignedURL(3, 99, time.Minute)
if err != nil {
t.Fatalf("SignedURL: %v", err)
}
if !strings.HasPrefix(link, "https://dms.example.test"+SignedPath+"?") {
t.Fatalf("unexpected link: %s", link)
}
u, err := url.Parse(link)
if err != nil {
t.Fatalf("parse link: %v", err)
}
ref, err := l.VerifySignedURL(u.Query(), time.Now())
if err != nil {
t.Fatalf("verify: %v", err)
}
if ref.TenantID != 3 || ref.DocumentID != 99 {
t.Fatalf("payload mismatch: %+v", ref)
}
// Expired.
if _, err := l.VerifySignedURL(u.Query(), time.Now().Add(2*time.Minute)); !errors.Is(err, ErrSignatureExpired) {
t.Fatalf("expired: got %v want ErrSignatureExpired", err)
}
// Tampered document id.
q := u.Query()
q.Set("d", "100")
if _, err := l.VerifySignedURL(q, time.Now()); !errors.Is(err, ErrSignatureInvalid) {
t.Fatalf("tampered: got %v want ErrSignatureInvalid", err)
}
// Foreign key material.
other, err := NewLocalStore(config.StorageConfig{BasePath: t.TempDir()}, "different-secret", "")
if err != nil {
t.Fatalf("NewLocalStore: %v", err)
}
if _, err := other.VerifySignedURL(u.Query(), time.Now()); !errors.Is(err, ErrSignatureInvalid) {
t.Fatalf("foreign key: got %v want ErrSignatureInvalid", err)
}
// Missing parameters.
if _, err := l.VerifySignedURL(url.Values{}, time.Now()); !errors.Is(err, ErrSignatureInvalid) {
t.Fatalf("empty query: got %v want ErrSignatureInvalid", err)
}
}
// AK 2: ttl <= 0 falls back to the configured default validity.
func TestSignedURLDefaultTTL(t *testing.T) {
l, err := NewLocalStore(config.StorageConfig{BasePath: t.TempDir(), SignedURLTTLMinutes: 5}, "s", "")
if err != nil {
t.Fatalf("NewLocalStore: %v", err)
}
link, err := l.SignedURL(1, 1, 0)
if err != nil {
t.Fatalf("SignedURL: %v", err)
}
u, _ := url.Parse(link)
if strings.HasPrefix(link, "http") {
t.Fatalf("empty baseURL must yield a relative link: %s", link)
}
if _, err := l.VerifySignedURL(u.Query(), time.Now().Add(4*time.Minute)); err != nil {
t.Fatalf("within default ttl: %v", err)
}
if _, err := l.VerifySignedURL(u.Query(), time.Now().Add(6*time.Minute)); !errors.Is(err, ErrSignatureExpired) {
t.Fatalf("past default ttl: got %v want ErrSignatureExpired", err)
}
}