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