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) } }