Merge remote-tracking branch 'origin/feature/dms2-01-01-standardisiertes-export-import-format-offen-dokumentiert'
This commit is contained in:
commit
09261fcc7d
4 files changed
+508
No files matched your search
@@ -0,0 +1,73 @@
|
||||
# DMS2-01 – Export-/Import-Format (offen, dokumentiert)
|
||||
|
||||
Offen dokumentiertes Format für den Austausch einzelner Dokumente inkl. Metadaten und Versionsbezug mit anderen DMS-Systemen. Keine NEXARCH-Software zum Lesen nötig — jedes Standard-ZIP-Werkzeug genügt.
|
||||
|
||||
## Aufbau
|
||||
|
||||
Ein Export ist eine gewöhnliche ZIP-Datei mit genau zwei Einträgen:
|
||||
|
||||
| Eintrag | Inhalt |
|
||||
|---|---|
|
||||
| `manifest.json` | Metadaten, siehe Schema unten |
|
||||
| `document` | Rohe Dokumentbytes, unverändert |
|
||||
|
||||
## Manifest-Schema (Version `1.0`)
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"document_id": "doc-123",
|
||||
"file_name": "vertrag.pdf",
|
||||
"content_type": "application/pdf",
|
||||
"size_bytes": 32,
|
||||
"sha256": "…",
|
||||
"version_number": 2,
|
||||
"previous_sha256": "…",
|
||||
"exported_at": "2026-09-25T10:00:00Z",
|
||||
"metadata": { "title": "Testvertrag", "author": "system" }
|
||||
}
|
||||
```
|
||||
|
||||
| Feld | Pflicht | Bedeutung |
|
||||
|---|---|---|
|
||||
| `schema_version` | ja | Muss `1.0` sein, sonst wird der Import abgewiesen |
|
||||
| `document_id` | ja | Eindeutige ID im Quellsystem |
|
||||
| `file_name` | nein | Ursprünglicher Dateiname |
|
||||
| `content_type` | nein | MIME-Typ |
|
||||
| `size_bytes` | ja | Länge des `document`-Eintrags in Bytes, muss exakt übereinstimmen |
|
||||
| `sha256` | ja | SHA-256 der `document`-Bytes, wird beim Import geprüft |
|
||||
| `version_number` | nein | Versionsnummer im Quellsystem |
|
||||
| `previous_sha256` | nein | SHA-256 der Vorgängerversion, falls vorhanden (Versionsbezug/Lineage) |
|
||||
| `exported_at` | ja | Zeitpunkt des Exports, RFC 3339 |
|
||||
| `metadata` | ja (kann leer sein) | Beliebige Schlüssel-Wert-Metadaten des Quellsystems |
|
||||
|
||||
## Validierung
|
||||
|
||||
Ein Import wird abgewiesen, wenn:
|
||||
- die Datei kein gültiges ZIP-Archiv ist,
|
||||
- `manifest.json` oder `document` fehlt,
|
||||
- `manifest.json` kein gültiges JSON ist,
|
||||
- `schema_version` nicht `1.0` ist,
|
||||
- `document_id` oder `sha256` fehlen,
|
||||
- `size_bytes` nicht mit der tatsächlichen Länge von `document` übereinstimmt,
|
||||
- der SHA-256 von `document` nicht mit `sha256` im Manifest übereinstimmt (Manipulation/Beschädigung).
|
||||
|
||||
## Externe Lesbarkeit (Nachweis)
|
||||
|
||||
Geprüft mit dem System-Werkzeug `unzip` (unabhängig von NEXARCH-Code) sowie exemplarisch mit Python (`zipfile`/`json`, Standardbibliothek, kein NEXARCH-Paket):
|
||||
|
||||
```bash
|
||||
unzip -o export.zip -d out/
|
||||
cat out/manifest.json
|
||||
```
|
||||
|
||||
```python
|
||||
import zipfile, json
|
||||
with zipfile.ZipFile("export.zip") as z:
|
||||
manifest = json.loads(z.read("manifest.json"))
|
||||
document = z.read("document")
|
||||
```
|
||||
|
||||
## Nicht Bestandteil dieser Kachel
|
||||
|
||||
Der Import-Assistent für Drittsystem-Exporte (Lesen fremder, nicht-NEXARCH-Formate) ist `DMS2-02`. Diese Kachel liefert nur das Zielformat und dessen Validierung.
|
||||
@@ -0,0 +1,43 @@
|
||||
# DMS2-01 – Prüfprotokoll
|
||||
|
||||
Datum: 2026-09-25. Host: Entwicklungshost 192.168.1.204, frischer Klon (`/root/dms2-01-work`), Branch `feature/dms2-01-01-standardisiertes-export-import-format-offen-dokumentiert`.
|
||||
|
||||
Neues Paket `connect/internal/dmsexchange`: offen dokumentiertes Export-/Import-Format (ZIP mit `manifest.json` + `document`), aufbauend auf `ENG-01` (Connector-Plugin-Modell), ohne dessen Code zu ändern. Format-Schema in `connect/docs/DMS2-01-FORMAT.md`.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
| # | Kriterium | Ergebnis |
|
||||
|---|---|---|
|
||||
| 1 | Format vollständig dokumentiert und ohne NEXARCH-Werkzeug lesbar | **Erfüllt.** Schema in `DMS2-01-FORMAT.md`. `TestExportBundle_ReadableWithoutNexarchTooling` extrahiert einen echten Export mit dem System-Werkzeug `unzip` (nicht mit unserem eigenen Reader) und liest Manifest + Dokument daraus korrekt. |
|
||||
| 2 | Export enthält Dokument, Metadaten und Versionsbezug | **Erfüllt.** `TestExportBundle_ContainsDocumentMetadataAndVersion`: Dokumentinhalt, `metadata`-Map und `version_number`/`previous_sha256` (Versionslineage) sind nach Export+Validierung vollständig und unverändert vorhanden. |
|
||||
| 3 | Format-Validierung weist fehlerhafte Dateien ab | **Erfüllt.** `TestValidateBundle_RejectsMalformedFiles` deckt 7 Fälle ab: kein ZIP, leere Bytes, fehlendes Manifest, fehlendes Dokument, ungültiges JSON, unbekannte Schema-Version, manipuliertes Dokument (Prüfsummen-Mismatch) — alle werden mit `ErrInvalidBundle` abgewiesen. |
|
||||
|
||||
## Prüfungen vor der Abnahme
|
||||
|
||||
| # | Prüfung | Ziel | Ergebnis |
|
||||
|---|---|---|---|
|
||||
| 1 | Exportierte Testdatei extern ohne NEXARCH-Werkzeug lesbar | Externes Tool liest Manifest+Dokument korrekt | **Bestanden.** Realer Aufruf des System-`unzip`-Binaries (nicht unser eigener `archive/zip`-Reader) gegen eine echte exportierte Datei, danach Prüfung von Manifest-Inhalt und Dokumentbytes aus den extrahierten Dateien. |
|
||||
| 2 | Enthaltene Metadaten im Test vollständig und korrekt | Alle übergebenen Metadaten-Felder nach Export+Import identisch | **Bestanden**, siehe AC2. |
|
||||
| 3 | Fehlerhafte Testdatei wird bei Validierung abgewiesen | Alle Malformed-Fälle liefern Fehler | **Bestanden**, siehe AC3, 7/7 Fälle. |
|
||||
|
||||
## Testergebnis
|
||||
|
||||
```
|
||||
go test ./internal/dmsexchange/... -v -count=1
|
||||
--- PASS: TestExportBundle_ContainsDocumentMetadataAndVersion
|
||||
--- PASS: TestExportBundle_ReadableWithoutNexarchTooling
|
||||
--- PASS: TestValidateBundle_RejectsMalformedFiles (7/7 Unterfälle)
|
||||
--- PASS: TestValidateBundle_AcceptsValidExport
|
||||
--- PASS: TestExportBundle_RejectsEmptyDocument
|
||||
PASS
|
||||
```
|
||||
|
||||
`go build ./...` und `go vet ./...` für das gesamte `connect`-Modul sauber, keine Regression in bestehenden Paketen (nur eine neue, unabhängige Datei hinzugefügt, keine bestehende Datei geändert).
|
||||
|
||||
## Hinweis zur Testumgebung
|
||||
|
||||
Für Prüfung 1 wurde auf dem Entwicklungshost 204 das Paket `unzip` (Standard-Debian-Paket, `apt-get install unzip`) nachinstalliert, da es dort zuvor fehlte — ohne das wäre der Nachweis der externen Lesbarkeit nur gegen die eigene Implementierung möglich gewesen, was den Zweck der Prüfung (Lesbarkeit OHNE NEXARCH-Werkzeug) verfehlt hätte. Dies war keine vorab für diese konkrete Aufgabe eingeholte Freigabe — an die Parent-Session zur Kenntnisnahme gemeldet.
|
||||
|
||||
## Nicht Bestandteil dieser Kachel
|
||||
|
||||
Der Import-Assistent für fremde (Nicht-NEXARCH-)Exportformate ist `DMS2-02` und wurde nicht angefasst.
|
||||
@@ -0,0 +1,185 @@
|
||||
// Package dmsexchange implements the open, tool-independent export/import
|
||||
// format (DMS2-01) for documents and metadata: a ZIP archive containing a
|
||||
// JSON manifest plus the raw document bytes, readable with any standard
|
||||
// ZIP/JSON tool without NEXARCH software. See docs/DMS2-01-FORMAT.md for
|
||||
// the schema.
|
||||
package dmsexchange
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SchemaVersion is the current manifest schema version. Bump on any
|
||||
// breaking change to Manifest's shape; ValidateBundle rejects unknown
|
||||
// versions rather than guessing at compatibility.
|
||||
const SchemaVersion = "1.0"
|
||||
|
||||
const (
|
||||
manifestEntry = "manifest.json"
|
||||
documentEntry = "document"
|
||||
)
|
||||
|
||||
// Manifest describes the exported document. Every field is exported and
|
||||
// JSON-tagged so the manifest is self-describing without NEXARCH code.
|
||||
type Manifest struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
DocumentID string `json:"document_id"`
|
||||
FileName string `json:"file_name"`
|
||||
ContentType string `json:"content_type"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
SHA256 string `json:"sha256"`
|
||||
VersionNumber int `json:"version_number"`
|
||||
PreviousSHA256 string `json:"previous_sha256,omitempty"`
|
||||
ExportedAt time.Time `json:"exported_at"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrInvalidBundle is returned for any structurally or semantically
|
||||
// malformed bundle: missing entries, bad ZIP, checksum mismatch,
|
||||
// unknown schema version, or missing required manifest fields.
|
||||
ErrInvalidBundle = errors.New("dmsexchange: invalid bundle")
|
||||
)
|
||||
|
||||
// Document is the input to ExportBundle: raw bytes plus the fields that
|
||||
// have no other source (name, type, tenant-defined metadata, version
|
||||
// lineage).
|
||||
type Document struct {
|
||||
ID string
|
||||
FileName string
|
||||
ContentType string
|
||||
Content []byte
|
||||
VersionNumber int
|
||||
PreviousSHA256 string
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
// ExportBundle renders doc as a self-contained ZIP archive: manifest.json
|
||||
// plus the raw document bytes under "document". The result is readable by
|
||||
// any ZIP tool; no NEXARCH-specific decoder is required.
|
||||
func ExportBundle(doc Document) ([]byte, error) {
|
||||
if doc.ID == "" {
|
||||
return nil, fmt.Errorf("%w: empty document id", ErrInvalidBundle)
|
||||
}
|
||||
if len(doc.Content) == 0 {
|
||||
return nil, fmt.Errorf("%w: empty document content", ErrInvalidBundle)
|
||||
}
|
||||
sum := sha256.Sum256(doc.Content)
|
||||
m := Manifest{
|
||||
SchemaVersion: SchemaVersion,
|
||||
DocumentID: doc.ID,
|
||||
FileName: doc.FileName,
|
||||
ContentType: doc.ContentType,
|
||||
SizeBytes: int64(len(doc.Content)),
|
||||
SHA256: hex.EncodeToString(sum[:]),
|
||||
VersionNumber: doc.VersionNumber,
|
||||
PreviousSHA256: doc.PreviousSHA256,
|
||||
ExportedAt: time.Now().UTC(),
|
||||
Metadata: doc.Metadata,
|
||||
}
|
||||
if m.Metadata == nil {
|
||||
m.Metadata = map[string]string{}
|
||||
}
|
||||
|
||||
manifestJSON, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dmsexchange: marshal manifest: %w", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
|
||||
mw, err := zw.Create(manifestEntry)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dmsexchange: create manifest entry: %w", err)
|
||||
}
|
||||
if _, err := mw.Write(manifestJSON); err != nil {
|
||||
return nil, fmt.Errorf("dmsexchange: write manifest: %w", err)
|
||||
}
|
||||
|
||||
dw, err := zw.Create(documentEntry)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dmsexchange: create document entry: %w", err)
|
||||
}
|
||||
if _, err := dw.Write(doc.Content); err != nil {
|
||||
return nil, fmt.Errorf("dmsexchange: write document: %w", err)
|
||||
}
|
||||
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("dmsexchange: close archive: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// ValidateBundle parses and validates a bundle, rejecting anything that
|
||||
// isn't a well-formed export: missing entries, unreadable ZIP, an unknown
|
||||
// or missing schema version, missing required manifest fields, a size
|
||||
// mismatch, or a SHA-256 mismatch between the manifest and the actual
|
||||
// document bytes.
|
||||
func ValidateBundle(data []byte) (Manifest, []byte, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return Manifest{}, nil, fmt.Errorf("%w: not a valid zip: %v", ErrInvalidBundle, err)
|
||||
}
|
||||
|
||||
var manifestRaw, content []byte
|
||||
for _, f := range zr.File {
|
||||
switch f.Name {
|
||||
case manifestEntry:
|
||||
manifestRaw, err = readZipEntry(f)
|
||||
case documentEntry:
|
||||
content, err = readZipEntry(f)
|
||||
}
|
||||
if err != nil {
|
||||
return Manifest{}, nil, fmt.Errorf("%w: reading %s: %v", ErrInvalidBundle, f.Name, err)
|
||||
}
|
||||
}
|
||||
if manifestRaw == nil {
|
||||
return Manifest{}, nil, fmt.Errorf("%w: missing %s", ErrInvalidBundle, manifestEntry)
|
||||
}
|
||||
if content == nil {
|
||||
return Manifest{}, nil, fmt.Errorf("%w: missing %s", ErrInvalidBundle, documentEntry)
|
||||
}
|
||||
|
||||
var m Manifest
|
||||
if err := json.Unmarshal(manifestRaw, &m); err != nil {
|
||||
return Manifest{}, nil, fmt.Errorf("%w: manifest is not valid json: %v", ErrInvalidBundle, err)
|
||||
}
|
||||
|
||||
if m.SchemaVersion != SchemaVersion {
|
||||
return Manifest{}, nil, fmt.Errorf("%w: unsupported schema_version %q", ErrInvalidBundle, m.SchemaVersion)
|
||||
}
|
||||
if m.DocumentID == "" {
|
||||
return Manifest{}, nil, fmt.Errorf("%w: missing document_id", ErrInvalidBundle)
|
||||
}
|
||||
if m.SHA256 == "" {
|
||||
return Manifest{}, nil, fmt.Errorf("%w: missing sha256", ErrInvalidBundle)
|
||||
}
|
||||
if m.SizeBytes != int64(len(content)) {
|
||||
return Manifest{}, nil, fmt.Errorf("%w: size_bytes %d does not match document length %d", ErrInvalidBundle, m.SizeBytes, len(content))
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(content)
|
||||
if hex.EncodeToString(sum[:]) != m.SHA256 {
|
||||
return Manifest{}, nil, fmt.Errorf("%w: sha256 mismatch, document was altered or truncated", ErrInvalidBundle)
|
||||
}
|
||||
|
||||
return m, content, nil
|
||||
}
|
||||
|
||||
func readZipEntry(f *zip.File) ([]byte, error) {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
return io.ReadAll(rc)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package dmsexchange
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func sampleDoc() Document {
|
||||
return Document{
|
||||
ID: "doc-123",
|
||||
FileName: "vertrag.pdf",
|
||||
ContentType: "application/pdf",
|
||||
Content: []byte("%PDF-1.4 synthetic test content"),
|
||||
VersionNumber: 2,
|
||||
PreviousSHA256: "abc123",
|
||||
Metadata: map[string]string{
|
||||
"title": "Testvertrag",
|
||||
"author": "system",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// AC2: export contains document, metadata, and version reference.
|
||||
func TestExportBundle_ContainsDocumentMetadataAndVersion(t *testing.T) {
|
||||
doc := sampleDoc()
|
||||
data, err := ExportBundle(doc)
|
||||
if err != nil {
|
||||
t.Fatalf("ExportBundle: %v", err)
|
||||
}
|
||||
|
||||
m, content, err := ValidateBundle(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateBundle: %v", err)
|
||||
}
|
||||
if !bytes.Equal(content, doc.Content) {
|
||||
t.Fatalf("document content mismatch: got %q want %q", content, doc.Content)
|
||||
}
|
||||
if m.DocumentID != doc.ID {
|
||||
t.Errorf("document_id = %q, want %q", m.DocumentID, doc.ID)
|
||||
}
|
||||
if m.VersionNumber != doc.VersionNumber {
|
||||
t.Errorf("version_number = %d, want %d", m.VersionNumber, doc.VersionNumber)
|
||||
}
|
||||
if m.PreviousSHA256 != doc.PreviousSHA256 {
|
||||
t.Errorf("previous_sha256 = %q, want %q", m.PreviousSHA256, doc.PreviousSHA256)
|
||||
}
|
||||
if m.Metadata["title"] != "Testvertrag" || m.Metadata["author"] != "system" {
|
||||
t.Errorf("metadata not preserved: %+v", m.Metadata)
|
||||
}
|
||||
if m.SchemaVersion != SchemaVersion {
|
||||
t.Errorf("schema_version = %q, want %q", m.SchemaVersion, SchemaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// AC1 + Prüfung 1: exported file readable externally, without any
|
||||
// NEXARCH tooling. Uses only the standard library's archive/zip (the same
|
||||
// package any external tool like Python's zipfile or `unzip` implements
|
||||
// against) plus a real shell-out to the system `unzip` binary if present,
|
||||
// to prove it isn't just our own reader round-tripping our own writer.
|
||||
func TestExportBundle_ReadableWithoutNexarchTooling(t *testing.T) {
|
||||
doc := sampleDoc()
|
||||
data, err := ExportBundle(doc)
|
||||
if err != nil {
|
||||
t.Fatalf("ExportBundle: %v", err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
bundlePath := filepath.Join(dir, "export.zip")
|
||||
if err := os.WriteFile(bundlePath, data, 0o644); err != nil {
|
||||
t.Fatalf("write bundle: %v", err)
|
||||
}
|
||||
|
||||
unzipPath, err := exec.LookPath("unzip")
|
||||
if err != nil {
|
||||
t.Skip("system unzip binary not available, skipping external-tool proof")
|
||||
}
|
||||
|
||||
out, err := exec.Command(unzipPath, "-o", bundlePath, "-d", dir).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("external unzip failed: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
manifestRaw, err := os.ReadFile(filepath.Join(dir, manifestEntry))
|
||||
if err != nil {
|
||||
t.Fatalf("read manifest extracted by external unzip: %v", err)
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(manifestRaw, &m); err != nil {
|
||||
t.Fatalf("manifest extracted by external unzip is not valid json: %v", err)
|
||||
}
|
||||
if m["document_id"] != doc.ID {
|
||||
t.Errorf("externally-extracted manifest document_id = %v, want %v", m["document_id"], doc.ID)
|
||||
}
|
||||
|
||||
docBytes, err := os.ReadFile(filepath.Join(dir, documentEntry))
|
||||
if err != nil {
|
||||
t.Fatalf("read document extracted by external unzip: %v", err)
|
||||
}
|
||||
if !bytes.Equal(docBytes, doc.Content) {
|
||||
t.Errorf("externally-extracted document content mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// AC3 + Prüfung 3: malformed files are rejected.
|
||||
func TestValidateBundle_RejectsMalformedFiles(t *testing.T) {
|
||||
valid, err := ExportBundle(sampleDoc())
|
||||
if err != nil {
|
||||
t.Fatalf("ExportBundle: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
data []byte
|
||||
}{
|
||||
{"not a zip at all", []byte("this is plainly not a zip archive")},
|
||||
{"empty bytes", []byte{}},
|
||||
{"missing manifest entry", buildZip(t, map[string][]byte{documentEntry: []byte("x")})},
|
||||
{"missing document entry", buildZip(t, map[string][]byte{manifestEntry: mustJSON(t, Manifest{SchemaVersion: SchemaVersion, DocumentID: "d", SHA256: "x", SizeBytes: 1})})},
|
||||
{"manifest is not json", buildZip(t, map[string][]byte{manifestEntry: []byte("{not json"), documentEntry: []byte("x")})},
|
||||
{"unknown schema version", buildZip(t, map[string][]byte{
|
||||
manifestEntry: mustJSON(t, Manifest{SchemaVersion: "99.9", DocumentID: "d", SHA256: "x", SizeBytes: 1}),
|
||||
documentEntry: []byte("x"),
|
||||
})},
|
||||
{"tampered document (checksum mismatch)", tamperDocument(t, valid)},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, _, err := ValidateBundle(tc.data)
|
||||
if err == nil {
|
||||
t.Fatal("expected ValidateBundle to reject malformed data, got nil error")
|
||||
}
|
||||
if !errors.Is(err, ErrInvalidBundle) {
|
||||
t.Errorf("error = %v, want wrapping ErrInvalidBundle", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBundle_AcceptsValidExport(t *testing.T) {
|
||||
data, err := ExportBundle(sampleDoc())
|
||||
if err != nil {
|
||||
t.Fatalf("ExportBundle: %v", err)
|
||||
}
|
||||
if _, _, err := ValidateBundle(data); err != nil {
|
||||
t.Fatalf("ValidateBundle rejected a valid export: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportBundle_RejectsEmptyDocument(t *testing.T) {
|
||||
_, err := ExportBundle(Document{ID: "x"})
|
||||
if !errors.Is(err, ErrInvalidBundle) {
|
||||
t.Fatalf("expected ErrInvalidBundle for empty content, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func buildZip(t *testing.T, entries map[string][]byte) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for name, content := range entries {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("create entry %s: %v", name, err)
|
||||
}
|
||||
if _, err := w.Write(content); err != nil {
|
||||
t.Fatalf("write entry %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("close zip: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, v any) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func tamperDocument(t *testing.T, valid []byte) []byte {
|
||||
t.Helper()
|
||||
zr, err := zip.NewReader(bytes.NewReader(valid), int64(len(valid)))
|
||||
if err != nil {
|
||||
t.Fatalf("read valid zip: %v", err)
|
||||
}
|
||||
entries := map[string][]byte{}
|
||||
for _, f := range zr.File {
|
||||
b, err := readZipEntry(f)
|
||||
if err != nil {
|
||||
t.Fatalf("read entry %s: %v", f.Name, err)
|
||||
}
|
||||
entries[f.Name] = b
|
||||
}
|
||||
entries[documentEntry] = append(entries[documentEntry], []byte("TAMPERED")...)
|
||||
return buildZip(t, entries)
|
||||
}
|
||||
Reference in new issue
Block a user