Files
deklarix/internal/web/server_test.go
noroot f5ad08cebd feat: wire persistence into the web layer (check → archive → dossier)
This is the piece that turns a Pre-Publish-Prüfung into an actual
archived, provable record instead of a one-off form response.

extract.Client.Extract now returns Result{Facts, RawJSON} instead of
just Facts — RawJSON is the model's exact, unmodified JSON, which is
what belongs in extraction.payload (the audit trail), not a re-encoded
view through our own Facts struct. extract.ParsePayload reconstructs
Facts from a stored payload later, reusing the same parsing/validation
path Extract uses (including the enum guard), so a previously-saved
extraction can be read back exactly as it would have been the first
time.

internal/dossier.BuildContent no longer requires AssetHash: most checks
right now are caption-only (no image/video upload wired yet), and
inventing a placeholder hash for a nonexistent asset would itself be an
integrity problem in an evidence tool. Content shows "kein Asset
hinterlegt" instead.

internal/web gains a narrow Store interface (mirroring the Extractor
pattern — only the methods these handlers use, not the full
*store.Store) so its test suite stays network/DB-free via an in-memory
fake:

- POST /pruefen persists submission + extraction + findings and marks
  the submission "checked". A needsClarification result persists the
  extraction (there's something worth keeping) but no findings and no
  status change, and the template omits the archive option entirely.
- POST /veroeffentlichen re-derives Facts from the stored payload, hashes
  the canonical submission+facts+findings metadata, gets an RFC-3161
  timestamp, generates the PDF dossier to disk, and persists the
  evidence_package row before marking the submission "published".
- GET /dossier/{id} serves the generated PDF.

Tested end-to-end offline: a fake Timestamper builds a real, structurally
valid self-signed RFC-3161 response so the full check→archive→download
flow runs against an in-memory store, verifying the downloaded bytes are
an actual PDF and the dossier file lands on disk — without hitting a
real database, TSA, or the Claude API.

cmd/deklarix/main.go now wires store.Store, evidence.NewHTTPTimestamper
(TSA_URL, default FreeTSA), and DOSSIER_DIR (default "dossiers") into
web.NewServer alongside the extractor and rule set.

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

468 lines
15 KiB
Go

package web_test
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"fmt"
"math/big"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/digitorus/timestamp"
"github.com/netcell-it/deklarix/internal/extract"
"github.com/netcell-it/deklarix/internal/rules"
"github.com/netcell-it/deklarix/internal/store"
"github.com/netcell-it/deklarix/internal/web"
)
// ─── Fakes ────────────────────────────────────────────────────────────
type fakeExtractor struct {
facts rules.Facts
raw []byte
err error
}
func (f fakeExtractor) Extract(ctx context.Context, in extract.Input) (extract.Result, error) {
if f.err != nil {
return extract.Result{}, f.err
}
raw := f.raw
if raw == nil {
raw = []byte(`{}`)
}
return extract.Result{Facts: f.facts, RawJSON: raw}, nil
}
func (f fakeExtractor) ModelVersion() string { return "fake-model-v0" }
// fakeStore ist eine In-Memory-Implementierung von web.Store, damit die
// Handler-Tests keine echte Postgres-Instanz brauchen.
type fakeStore struct {
mu sync.Mutex
nextID int
submissions map[string]store.Submission
extractions map[string]store.Extraction
findings map[string][]store.Finding
evidencePkgs map[string]store.EvidencePackage
}
func newFakeStore() *fakeStore {
return &fakeStore{
submissions: map[string]store.Submission{},
extractions: map[string]store.Extraction{},
findings: map[string][]store.Finding{},
evidencePkgs: map[string]store.EvidencePackage{},
}
}
func (f *fakeStore) newID() string {
f.nextID++
return fmt.Sprintf("id-%d", f.nextID)
}
func (f *fakeStore) CreateSubmission(ctx context.Context, platform, postType, caption string) (store.Submission, error) {
f.mu.Lock()
defer f.mu.Unlock()
sub := store.Submission{
ID: f.newID(), Platform: platform, PostType: postType, Caption: caption,
Status: "draft", CreatedAt: time.Now(), UpdatedAt: time.Now(),
}
f.submissions[sub.ID] = sub
return sub, nil
}
func (f *fakeStore) GetSubmission(ctx context.Context, id string) (store.Submission, error) {
f.mu.Lock()
defer f.mu.Unlock()
sub, ok := f.submissions[id]
if !ok {
return store.Submission{}, fmt.Errorf("fakeStore: submission %s nicht gefunden", id)
}
return sub, nil
}
func (f *fakeStore) SetSubmissionStatus(ctx context.Context, id, status string) error {
f.mu.Lock()
defer f.mu.Unlock()
sub, ok := f.submissions[id]
if !ok {
return fmt.Errorf("fakeStore: submission %s nicht gefunden", id)
}
sub.Status = status
f.submissions[id] = sub
return nil
}
func (f *fakeStore) CreateExtraction(ctx context.Context, submissionID string, payload []byte, modelVersion, promptVersion string) (store.Extraction, error) {
f.mu.Lock()
defer f.mu.Unlock()
ext := store.Extraction{
ID: f.newID(), SubmissionID: submissionID, Payload: payload,
ModelVersion: modelVersion, PromptVersion: promptVersion, CreatedAt: time.Now(),
}
f.extractions[submissionID] = ext
return ext, nil
}
func (f *fakeStore) GetLatestExtraction(ctx context.Context, submissionID string) (store.Extraction, error) {
f.mu.Lock()
defer f.mu.Unlock()
ext, ok := f.extractions[submissionID]
if !ok {
return store.Extraction{}, fmt.Errorf("fakeStore: keine Extraktion fuer %s", submissionID)
}
return ext, nil
}
func (f *fakeStore) CreateFinding(ctx context.Context, submissionID string, extractionID *string, ruleID string, ruleVersion int, severity, title, fix string, sources []string) (store.Finding, error) {
f.mu.Lock()
defer f.mu.Unlock()
finding := store.Finding{
ID: f.newID(), SubmissionID: submissionID, ExtractionID: extractionID,
RuleID: ruleID, RuleVersion: ruleVersion, Severity: severity,
Title: title, Fix: fix, Sources: sources, CreatedAt: time.Now(),
}
f.findings[submissionID] = append(f.findings[submissionID], finding)
return finding, nil
}
func (f *fakeStore) ListCurrentFindings(ctx context.Context, submissionID string) ([]store.Finding, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.findings[submissionID], nil
}
func (f *fakeStore) CreateEvidencePackage(ctx context.Context, submissionID, dossierPath, sha256Hex string, timestampToken []byte) (store.EvidencePackage, error) {
f.mu.Lock()
defer f.mu.Unlock()
pkg := store.EvidencePackage{
ID: f.newID(), SubmissionID: submissionID, DossierPath: dossierPath,
SHA256: sha256Hex, TimestampToken: timestampToken, CreatedAt: time.Now(),
}
f.evidencePkgs[submissionID] = pkg
return pkg, nil
}
func (f *fakeStore) GetLatestEvidencePackage(ctx context.Context, submissionID string) (store.EvidencePackage, error) {
f.mu.Lock()
defer f.mu.Unlock()
pkg, ok := f.evidencePkgs[submissionID]
if !ok {
return store.EvidencePackage{}, fmt.Errorf("fakeStore: kein evidence package fuer %s", submissionID)
}
return pkg, nil
}
// fakeTimestamper liefert einen offline erzeugten, strukturell gültigen
// (selbstsignierten) RFC-3161-Token — genug, damit evidence.TimestampTime
// ihn parsen kann, ohne eine echte TSA zu brauchen.
type fakeTimestamper struct{}
func (fakeTimestamper) Timestamp(ctx context.Context, hash []byte) ([]byte, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
now := time.Now()
certTemplate := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "deklarix-test-tsa"},
NotBefore: now.Add(-time.Hour),
NotAfter: now.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 {
return nil, err
}
cert, err := x509.ParseCertificate(certDER)
if err != nil {
return nil, err
}
ts := timestamp.Timestamp{
HashAlgorithm: crypto.SHA256,
HashedMessage: hash,
Time: now,
SerialNumber: big.NewInt(1),
Policy: asn1.ObjectIdentifier{1, 2, 3},
}
respDER, err := ts.CreateResponse(cert, key)
if err != nil {
return nil, err
}
parsed, err := timestamp.ParseResponse(respDER)
if err != nil {
return nil, err
}
return parsed.RawToken, nil
}
// ─── Test-Setup ───────────────────────────────────────────────────────
func loadRealRules(t *testing.T) []rules.Rule {
t.Helper()
rs, err := rules.Load(os.DirFS("../../rules"))
if err != nil {
t.Fatalf("rules.Load: %v", err)
}
return rs
}
func newTestServer(t *testing.T, ex web.Extractor) *web.Server {
t.Helper()
s, err := web.NewServer(ex, loadRealRules(t), newFakeStore(), fakeTimestamper{}, t.TempDir())
if err != nil {
t.Fatalf("NewServer: %v", err)
}
return s
}
// ─── Tests ────────────────────────────────────────────────────────────
func TestHandleHealth(t *testing.T) {
s := newTestServer(t, fakeExtractor{})
req := httptest.NewRequest(http.MethodGet, "/health", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if w.Body.String() != `{"ok":true}` {
t.Fatalf("body = %q, want {\"ok\":true}", w.Body.String())
}
}
func TestHandleIndexRendersForm(t *testing.T) {
s := newTestServer(t, fakeExtractor{})
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
for _, want := range []string{`name="platform"`, `name="post_type"`, `name="caption"`, `hx-post="/pruefen"`} {
if !strings.Contains(body, want) {
t.Errorf("index body missing %q\nbody: %s", want, body)
}
}
}
func TestHandleStaticServesHTMX(t *testing.T) {
s := newTestServer(t, fakeExtractor{})
req := httptest.NewRequest(http.MethodGet, "/static/htmx.min.js", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if w.Body.Len() < 1000 {
t.Fatalf("htmx.min.js suspiciously small: %d bytes", w.Body.Len())
}
}
func postForm(t *testing.T, s *web.Server, path string, form url.Values) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
return w
}
func checkForm(extra ...string) url.Values {
v := url.Values{"platform": {"instagram"}, "post_type": {"reel"}, "caption": {"..."}}
for i := 0; i+1 < len(extra); i += 2 {
v.Set(extra[i], extra[i+1])
}
return v
}
func TestHandleCheckRejectsMissingFields(t *testing.T) {
s := newTestServer(t, fakeExtractor{})
w := postForm(t, s, "/pruefen", url.Values{"platform": {"instagram"}})
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 for missing fields", w.Code)
}
}
func TestHandleCheckPropagatesExtractionError(t *testing.T) {
s := newTestServer(t, fakeExtractor{err: fmt.Errorf("simulierter Extraktionsfehler")})
w := postForm(t, s, "/pruefen", checkForm())
if w.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want 502 when extraction fails", w.Code)
}
}
func TestHandleCheckPersistsSubmissionExtractionAndFindings(t *testing.T) {
fs := newFakeStore()
s, err := web.NewServer(fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationPaid,
DisclosurePresent: true, DisclosureWording: "Werbung", DisclosureBeforeCut: false,
}, raw: []byte(`{"gegenleistung":"bezahlt"}`)}, loadRealRules(t), fs, fakeTimestamper{}, t.TempDir())
if err != nil {
t.Fatalf("NewServer: %v", err)
}
w := postForm(t, s, "/pruefen", checkForm())
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "WK-004") {
t.Fatalf("expected WK-004 in result, got: %s", w.Body.String())
}
if len(fs.submissions) != 1 {
t.Fatalf("expected 1 persisted submission, got %d", len(fs.submissions))
}
var subID string
for id, sub := range fs.submissions {
subID = id
if sub.Status != "checked" {
t.Errorf("submission status = %q, want checked", sub.Status)
}
}
if _, ok := fs.extractions[subID]; !ok {
t.Error("expected an extraction to be persisted")
}
if len(fs.findings[subID]) != 1 || fs.findings[subID][0].RuleID != "WK-004" {
t.Errorf("expected exactly one persisted WK-004 finding, got %+v", fs.findings[subID])
}
}
func TestHandleCheckRendersNeedsClarificationAndDoesNotOfferArchive(t *testing.T) {
fs := newFakeStore()
s, err := web.NewServer(fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationUnclear,
}}, loadRealRules(t), fs, fakeTimestamper{}, t.TempDir())
if err != nil {
t.Fatalf("NewServer: %v", err)
}
w := postForm(t, s, "/pruefen", checkForm())
body := w.Body.String()
if strings.Contains(body, "WK-") {
t.Errorf("expected no rule findings for an unclear extraction, got: %s", body)
}
if !strings.Contains(body, "nicht sicher bestimmt") {
t.Errorf("expected a clarification message, got: %s", body)
}
if strings.Contains(body, "veroeffentlichen") || strings.Contains(body, "/veroeffentlichen") {
t.Errorf("expected no archive option when clarification is needed, got: %s", body)
}
for _, sub := range fs.submissions {
if sub.Status == "checked" {
t.Error("submission should not be marked checked when clarification is needed")
}
}
}
func TestFullCheckThenArchiveFlow(t *testing.T) {
fs := newFakeStore()
s, err := web.NewServer(fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationPaid,
DisclosurePresent: true, DisclosureWording: "Werbung", DisclosureBeforeCut: false,
}, raw: []byte(`{"gegenleistung":"bezahlt","kennzeichnung_vorhanden":true,"kennzeichnung_wortlaut":"Werbung","kennzeichnung_vor_kuerzung":false}`)},
loadRealRules(t), fs, fakeTimestamper{}, t.TempDir())
if err != nil {
t.Fatalf("NewServer: %v", err)
}
checkResp := postForm(t, s, "/pruefen", checkForm())
if checkResp.Code != http.StatusOK {
t.Fatalf("check status = %d, body: %s", checkResp.Code, checkResp.Body.String())
}
if !strings.Contains(checkResp.Body.String(), `name="submission_id"`) {
t.Fatalf("expected a hidden submission_id field in the result, got: %s", checkResp.Body.String())
}
var subID string
for id := range fs.submissions {
subID = id
}
archiveResp := postForm(t, s, "/veroeffentlichen", url.Values{"submission_id": {subID}})
if archiveResp.Code != http.StatusOK {
t.Fatalf("archive status = %d, body: %s", archiveResp.Code, archiveResp.Body.String())
}
if !strings.Contains(archiveResp.Body.String(), "/dossier/"+subID) {
t.Fatalf("expected a dossier download link, got: %s", archiveResp.Body.String())
}
pkg, ok := fs.evidencePkgs[subID]
if !ok {
t.Fatal("expected an evidence package to be persisted")
}
if fs.submissions[subID].Status != "published" {
t.Errorf("submission status = %q, want published", fs.submissions[subID].Status)
}
downloadReq := httptest.NewRequest(http.MethodGet, "/dossier/"+subID, nil)
downloadReq.SetPathValue("id", subID)
downloadW := httptest.NewRecorder()
s.ServeHTTP(downloadW, downloadReq)
if downloadW.Code != http.StatusOK {
t.Fatalf("download status = %d", downloadW.Code)
}
if !strings.HasPrefix(downloadW.Body.String(), "%PDF-") {
t.Fatal("downloaded dossier does not start with the PDF header")
}
if _, err := os.Stat(pkg.DossierPath); err != nil {
t.Fatalf("dossier file does not exist on disk: %v", err)
}
}
func TestHandleArchiveRejectsUnknownSubmission(t *testing.T) {
s := newTestServer(t, fakeExtractor{})
w := postForm(t, s, "/veroeffentlichen", url.Values{"submission_id": {"does-not-exist"}})
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for an unknown submission", w.Code)
}
}
func TestHandleCheckCleanCaseHasNoFindingsAndOffersArchive(t *testing.T) {
s := newTestServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}})
w := postForm(t, s, "/pruefen", checkForm())
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
if strings.Contains(body, "WK-") {
t.Errorf("expected no findings for an organic post, got: %s", body)
}
if !strings.Contains(body, "Keine Kennzeichnungsrisiken") {
t.Errorf("expected the no-findings message, got: %s", body)
}
if !strings.Contains(body, "/veroeffentlichen") {
t.Errorf("expected an archive option even with no findings (still a real check), got: %s", body)
}
}