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>
159 lines
4.7 KiB
Go
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")
|
|
}
|
|
}
|