Files
deklarix/internal/dossier/content_test.go
noroot db373e51ce feat: add PDF dossier generation (internal/dossier)
Content assembly (BuildContent) is separate from PDF drawing (Render),
so the actual business logic — what goes into the evidence dossier, in
what form, with which mandatory fields — is unit-testable without
parsing PDF bytes. BuildContent refuses to produce a dossier missing
its evidentiary fields (timestamp token, asset/metadata hash, platform)
rather than emitting one with silently empty proof sections. Every
dossier carries the "this is not legal advice" disclaimer required by
CLAUDE.md's guardrails.

Uses github.com/go-pdf/fpdf (actively maintained fork of jung-kurt/
gofpdf, no dependencies beyond the Go stdlib) for rendering. Its core
fonts use cp1252 internally, so a small cp1252.map (copied from the
fpdf module, embedded via go:embed) drives UnicodeTranslator — German
umlauts render correctly without needing an external font file at
runtime, keeping Deklarix a single binary. Verified visually with
pdftotext/pdfinfo against a generated sample.

Tests build a real, structurally valid RFC-3161 token offline (a
throwaway self-signed cert + timestamp.Timestamp.CreateResponse), so
BuildContent/Render/Generate are fully tested without hitting a real
TSA — unlike the network-gated integration test in internal/evidence.

Also adds evidence.TimestampTime(), extracted from the parsing logic
already used by the TSA client, since the dossier needs to show the
timestamped time to a human reader.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 14:18:01 +02:00

159 lines
4.7 KiB
Go

package dossier_test
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"math/big"
"testing"
"time"
"github.com/digitorus/timestamp"
"github.com/netcell-it/deklarix/internal/dossier"
"github.com/netcell-it/deklarix/internal/evidence"
"github.com/netcell-it/deklarix/internal/rules"
)
// fakeTimestampToken erzeugt einen strukturell gültigen, selbstsignierten
// RFC-3161-Token für hash — offline, ohne echte TSA. Damit lassen sich
// BuildContent/Render testen, ohne bei jedem Testlauf eine echte
// Time-Stamp Authority anzufragen (die Echtheit/Vertrauenswürdigkeit
// des Zertifikats spielt für diese Tests keine Rolle, nur dass der Token
// strukturell parsbar ist wie ein echter).
func fakeTimestampToken(t *testing.T, hash []byte, at time.Time) []byte {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
certTemplate := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "deklarix-test-tsa"},
NotBefore: at.Add(-time.Hour),
NotAfter: at.Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageTimeStamping},
}
certDER, err := x509.CreateCertificate(rand.Reader, certTemplate, certTemplate, &key.PublicKey, key)
if err != nil {
t.Fatalf("create certificate: %v", err)
}
cert, err := x509.ParseCertificate(certDER)
if err != nil {
t.Fatalf("parse certificate: %v", err)
}
ts := timestamp.Timestamp{
HashAlgorithm: crypto.SHA256,
HashedMessage: hash,
Time: at,
SerialNumber: big.NewInt(1),
Policy: asn1.ObjectIdentifier{1, 2, 3},
}
respDER, err := ts.CreateResponse(cert, key)
if err != nil {
t.Fatalf("create timestamp response: %v", err)
}
parsed, err := timestamp.ParseResponse(respDER)
if err != nil {
t.Fatalf("parse fake timestamp response: %v", err)
}
return parsed.RawToken
}
func validData(t *testing.T) dossier.Data {
t.Helper()
hash := evidence.HashBytes([]byte("caption+bild"))
at := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC)
return dossier.Data{
Submission: dossier.Submission{
Platform: "instagram",
PostType: "reel",
Caption: "Werbung für ein Produkt äöüß",
CreatedAt: at,
},
Facts: rules.Facts{
DisclosurePresent: true,
DisclosureWording: "Werbung",
DisclosureBeforeCut: false,
},
Findings: []rules.Finding{
{RuleID: "WK-004", RuleVersion: 1, Severity: rules.SeverityHigh, Title: "t", Fix: "f", Sources: []string{"§ 5a Abs. 4 UWG"}},
},
Participants: []dossier.Participant{
{Role: "creator", Name: "Max Mustermann", Vorgegeben: false, Freigegeben: true},
},
AssetHash: hash,
MetadataHash: evidence.HashBytes([]byte("metadata")),
TimestampToken: fakeTimestampToken(t, hash, at),
GeneratedAt: at,
}
}
func TestBuildContentRejectsMissingPlatform(t *testing.T) {
data := validData(t)
data.Submission.Platform = ""
if _, err := dossier.BuildContent(data); err == nil {
t.Fatal("expected error for missing platform, got nil")
}
}
func TestBuildContentRejectsMissingTimestampToken(t *testing.T) {
data := validData(t)
data.TimestampToken = nil
if _, err := dossier.BuildContent(data); err == nil {
t.Fatal("expected error for missing timestamp token, got nil")
}
}
func TestBuildContentRejectsMissingHashes(t *testing.T) {
data := validData(t)
data.AssetHash = nil
if _, err := dossier.BuildContent(data); err == nil {
t.Fatal("expected error for missing asset hash, got nil")
}
data = validData(t)
data.MetadataHash = nil
if _, err := dossier.BuildContent(data); err == nil {
t.Fatal("expected error for missing metadata hash, got nil")
}
}
func TestBuildContentSuccess(t *testing.T) {
data := validData(t)
content, err := dossier.BuildContent(data)
if err != nil {
t.Fatalf("BuildContent: %v", err)
}
if content.Platform != "instagram" {
t.Errorf("Platform = %q, want instagram", content.Platform)
}
if len(content.Findings) != 1 || content.Findings[0].RuleID != "WK-004" {
t.Errorf("Findings = %+v, want one WK-004 row", content.Findings)
}
if len(content.Participants) != 1 || content.Participants[0].Name != "Max Mustermann" {
t.Errorf("Participants = %+v, want one Max-Mustermann row", content.Participants)
}
if len(content.AssetHashHex) != 64 {
t.Errorf("AssetHashHex length = %d, want 64 (hex of 32-byte SHA-256)", len(content.AssetHashHex))
}
if !content.TimestampedAt.Equal(data.Submission.CreatedAt) {
t.Errorf("TimestampedAt = %v, want %v", content.TimestampedAt, data.Submission.CreatedAt)
}
if content.Disclaimer == "" {
t.Error("expected a non-empty disclaimer")
}
}