CLAUDE.md beschreibt die Prüfung seit dem ersten Commit als "Caption, Standbild und Vertragslage rein" — bisher wurde nur die Caption verarbeitet, das asset-Schema aus Migration 0001 blieb ungenutzt. - internal/store/asset.go: CreateAsset/GetLatestAssetForSubmission. Migration 0005 macht asset append-only (Trigger fehlte seit 0001, weil bis jetzt nichts hineinschrieb) — ein hochgeladenes Beweisstück wird nicht nachträglich ausgetauscht, aus demselben Grund wie bei extraction/finding/evidence_package. - handleCheck liest ein optionales "standbild"-Formularfeld (Bild- Upload, max. 8 MiB, Content-Type muss image/* sein), validiert es VOR dem Anlegen der Submission (ein ungültiger Upload hinterlässt so keine leere Beitrags-Zeile), speichert es danach unter ASSET_DIR und legt die Asset-Zeile an. - handleArchive bindet den Asset-Hash (falls vorhanden) in den Metadaten-Hash und ins PDF-Dossier ein (dossier.Data.AssetHash war bereits vorbereitet, wurde aber nie befüllt). - index.html: Formular auf multipart/form-data umgestellt (hx-encoding + enctype), neues optionales Dateifeld. handleCheck bleibt abwärtskompatibel zu urlencoded-Requests (ParseMultipartForm liefert ErrNotMultipart, das wird wie "kein Bild hochgeladen" behandelt, nicht wie ein Fehler). - ASSET_DIR neue Konfigurationsvariable (Default "assets", wie DOSSIER_DIR relativ zu WorkingDirectory=/var/lib/deklarix — kein postinst-Healing nötig, anders als bei RULES_DIR, dessen Default nicht zum installierten Pfad passt). Volle Testsuite inkl. echter Postgres-Tests grün; End-to-End gegen einen laufenden Server verifiziert (Upload, Hash in DB, Hash im erzeugten PDF via pdftotext, Ablehnung bei falschem Dateityp).
1030 lines
32 KiB
Go
1030 lines
32 KiB
Go
package web_test
|
|
|
|
import (
|
|
"context"
|
|
"crypto"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/asn1"
|
|
"errors"
|
|
"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/auth"
|
|
"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"
|
|
)
|
|
|
|
const testSessionCookie = "deklarix_session"
|
|
|
|
// ─── 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
|
|
accounts map[string]store.Account
|
|
users map[string]store.User
|
|
usersByEmail map[string]string // email -> user id
|
|
sessions map[string]store.Session
|
|
submissions map[string]store.Submission
|
|
extractions map[string]store.Extraction
|
|
findings map[string][]store.Finding
|
|
evidencePkgs map[string]store.EvidencePackage
|
|
participants map[string]store.Participant
|
|
auditLog []store.AuditEntry
|
|
assets map[string]store.Asset // submissionID -> zuletzt hochgeladenes Asset
|
|
}
|
|
|
|
func newFakeStore() *fakeStore {
|
|
return &fakeStore{
|
|
accounts: map[string]store.Account{},
|
|
users: map[string]store.User{},
|
|
usersByEmail: map[string]string{},
|
|
sessions: map[string]store.Session{},
|
|
submissions: map[string]store.Submission{},
|
|
extractions: map[string]store.Extraction{},
|
|
findings: map[string][]store.Finding{},
|
|
evidencePkgs: map[string]store.EvidencePackage{},
|
|
participants: map[string]store.Participant{},
|
|
assets: map[string]store.Asset{},
|
|
}
|
|
}
|
|
|
|
func (f *fakeStore) newID() string {
|
|
f.nextID++
|
|
return fmt.Sprintf("id-%d", f.nextID)
|
|
}
|
|
|
|
func (f *fakeStore) CreateAccount(ctx context.Context, name string) (store.Account, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
acc := store.Account{ID: f.newID(), Name: name, CreatedAt: time.Now()}
|
|
f.accounts[acc.ID] = acc
|
|
return acc, nil
|
|
}
|
|
|
|
func (f *fakeStore) GetAccount(ctx context.Context, id string) (store.Account, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
acc, ok := f.accounts[id]
|
|
if !ok {
|
|
return store.Account{}, store.ErrNotFound
|
|
}
|
|
return acc, nil
|
|
}
|
|
|
|
func (f *fakeStore) ListAccounts(ctx context.Context) ([]store.Account, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var out []store.Account
|
|
for _, acc := range f.accounts {
|
|
out = append(out, acc)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeStore) ListVerifiedKanzleien(ctx context.Context) ([]store.Account, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var out []store.Account
|
|
for _, acc := range f.accounts {
|
|
if !acc.Verified {
|
|
continue
|
|
}
|
|
hasKanzlei := false
|
|
for _, u := range f.users {
|
|
if u.AccountID == acc.ID && u.Role == "kanzlei" {
|
|
hasKanzlei = true
|
|
break
|
|
}
|
|
}
|
|
if hasKanzlei {
|
|
out = append(out, acc)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeStore) SetAccountVerified(ctx context.Context, id string, verified bool) (store.Account, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
acc, ok := f.accounts[id]
|
|
if !ok {
|
|
return store.Account{}, store.ErrNotFound
|
|
}
|
|
acc.Verified = verified
|
|
f.accounts[id] = acc
|
|
return acc, nil
|
|
}
|
|
|
|
func (f *fakeStore) ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var out []store.User
|
|
for _, u := range f.users {
|
|
if u.AccountID == accountID {
|
|
out = append(out, u)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeStore) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
e := store.AuditEntry{
|
|
ID: f.newID(), ActorUserID: actorUserID, Action: action, TargetType: targetType,
|
|
TargetID: targetID, Details: details, CreatedAt: time.Now(),
|
|
}
|
|
f.auditLog = append(f.auditLog, e)
|
|
return e, nil
|
|
}
|
|
|
|
func (f *fakeStore) ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
// Neueste zuerst, wie die echte Store-Implementierung (ORDER BY
|
|
// created_at DESC) — hier reicht eine Umkehrung der Einfuegereihenfolge.
|
|
out := make([]store.AuditEntry, len(f.auditLog))
|
|
for i, e := range f.auditLog {
|
|
out[len(f.auditLog)-1-i] = e
|
|
}
|
|
if len(out) > limit {
|
|
out = out[:limit]
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeStore) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if _, exists := f.usersByEmail[email]; exists {
|
|
return store.User{}, fmt.Errorf("fakeStore: email %s bereits vergeben", email)
|
|
}
|
|
u := store.User{
|
|
ID: f.newID(), AccountID: accountID, Email: email, PasswordHash: passwordHash,
|
|
Role: role, CreatedAt: time.Now(),
|
|
}
|
|
f.users[u.ID] = u
|
|
f.usersByEmail[email] = u.ID
|
|
return u, nil
|
|
}
|
|
|
|
func (f *fakeStore) GetUserByEmail(ctx context.Context, email string) (store.User, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
id, ok := f.usersByEmail[email]
|
|
if !ok {
|
|
return store.User{}, store.ErrNotFound
|
|
}
|
|
return f.users[id], nil
|
|
}
|
|
|
|
func (f *fakeStore) GetUser(ctx context.Context, id string) (store.User, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
u, ok := f.users[id]
|
|
if !ok {
|
|
return store.User{}, store.ErrNotFound
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
func (f *fakeStore) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
sess := store.Session{Token: token, UserID: userID, ExpiresAt: expiresAt, CreatedAt: time.Now()}
|
|
f.sessions[token] = sess
|
|
return sess, nil
|
|
}
|
|
|
|
func (f *fakeStore) GetSession(ctx context.Context, token string) (store.Session, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
sess, ok := f.sessions[token]
|
|
if !ok {
|
|
return store.Session{}, store.ErrNotFound
|
|
}
|
|
return sess, nil
|
|
}
|
|
|
|
func (f *fakeStore) DeleteSession(ctx context.Context, token string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
delete(f.sessions, token)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) CreateSubmission(ctx context.Context, accountID, platform, postType, caption string) (store.Submission, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
sub := store.Submission{
|
|
ID: f.newID(), AccountID: accountID, 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{}, store.ErrNotFound
|
|
}
|
|
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
|
|
}
|
|
|
|
func (f *fakeStore) CreateAsset(ctx context.Context, submissionID, kind, path, sha256Hex string) (store.Asset, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
a := store.Asset{
|
|
ID: f.newID(), SubmissionID: submissionID, Kind: kind, Path: path, SHA256: sha256Hex, CreatedAt: time.Now(),
|
|
}
|
|
f.assets[submissionID] = a
|
|
return a, nil
|
|
}
|
|
|
|
func (f *fakeStore) GetLatestAssetForSubmission(ctx context.Context, submissionID string) (store.Asset, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
a, ok := f.assets[submissionID]
|
|
if !ok {
|
|
return store.Asset{}, store.ErrNotFound
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
func (f *fakeStore) ListSubmissionsForAccount(ctx context.Context, accountID string) ([]store.SubmissionSummary, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var out []store.SubmissionSummary
|
|
for _, sub := range f.submissions {
|
|
if sub.AccountID != accountID {
|
|
continue
|
|
}
|
|
sum := store.SubmissionSummary{Submission: sub}
|
|
rank := 0
|
|
for _, finding := range f.findings[sub.ID] {
|
|
sum.FindingCount++
|
|
r := severityRank(finding.Severity)
|
|
if r > rank {
|
|
rank = r
|
|
sum.HighestSeverity = finding.Severity
|
|
}
|
|
}
|
|
out = append(out, sum)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func severityRank(severity string) int {
|
|
switch severity {
|
|
case "hoch":
|
|
return 3
|
|
case "mittel":
|
|
return 2
|
|
case "niedrig":
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func (f *fakeStore) CreateParticipant(ctx context.Context, submissionID, role, name string, vorgegeben, freigegeben bool) (store.Participant, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
p := store.Participant{
|
|
ID: f.newID(), SubmissionID: submissionID, Role: role, Name: name,
|
|
Vorgegeben: vorgegeben, Freigegeben: freigegeben, CreatedAt: time.Now(),
|
|
}
|
|
if freigegeben {
|
|
now := time.Now()
|
|
p.ApprovedAt = &now
|
|
}
|
|
f.participants[p.ID] = p
|
|
return p, nil
|
|
}
|
|
|
|
func (f *fakeStore) ListParticipants(ctx context.Context, submissionID string) ([]store.Participant, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var out []store.Participant
|
|
for _, p := range f.participants {
|
|
if p.SubmissionID == submissionID {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeStore) GetParticipant(ctx context.Context, id string) (store.Participant, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
p, ok := f.participants[id]
|
|
if !ok {
|
|
return store.Participant{}, store.ErrNotFound
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
func (f *fakeStore) UpdateParticipant(ctx context.Context, id string, vorgegeben, freigegeben bool) (store.Participant, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
p, ok := f.participants[id]
|
|
if !ok {
|
|
return store.Participant{}, store.ErrNotFound
|
|
}
|
|
p.Vorgegeben = vorgegeben
|
|
p.Freigegeben = freigegeben
|
|
if freigegeben && p.ApprovedAt == nil {
|
|
now := time.Now()
|
|
p.ApprovedAt = &now
|
|
}
|
|
f.participants[id] = p
|
|
return p, nil
|
|
}
|
|
|
|
func (f *fakeStore) DeleteParticipant(ctx context.Context, id string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if _, ok := f.participants[id]; !ok {
|
|
return store.ErrNotFound
|
|
}
|
|
delete(f.participants, id)
|
|
return 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 newServer(t *testing.T, ex web.Extractor, fs *fakeStore) *web.Server {
|
|
t.Helper()
|
|
s, err := web.NewServer(ex, loadRealRules(t), fs, fakeTimestamper{}, t.TempDir(), t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("NewServer: %v", err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// seedAccount legt direkt im fakeStore (ohne HTTP) einen Account, einen
|
|
// Nutzer und eine gültige Sitzung an und liefert das Session-Cookie.
|
|
func seedAccount(t *testing.T, fs *fakeStore, accountName, email string) *http.Cookie {
|
|
t.Helper()
|
|
return seedAccountWithRole(t, fs, accountName, email, "creator")
|
|
}
|
|
|
|
func seedAccountWithRole(t *testing.T, fs *fakeStore, accountName, email, role string) *http.Cookie {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
acc, err := fs.CreateAccount(ctx, accountName)
|
|
if err != nil {
|
|
t.Fatalf("CreateAccount: %v", err)
|
|
}
|
|
hash, err := auth.HashPassword("test-passwort-123")
|
|
if err != nil {
|
|
t.Fatalf("HashPassword: %v", err)
|
|
}
|
|
user, err := fs.CreateUser(ctx, acc.ID, email, hash, role)
|
|
if err != nil {
|
|
t.Fatalf("CreateUser: %v", err)
|
|
}
|
|
token, err := auth.NewSessionToken()
|
|
if err != nil {
|
|
t.Fatalf("NewSessionToken: %v", err)
|
|
}
|
|
if _, err := fs.CreateSession(ctx, token, user.ID, time.Now().Add(time.Hour)); err != nil {
|
|
t.Fatalf("CreateSession: %v", err)
|
|
}
|
|
return &http.Cookie{Name: testSessionCookie, Value: token}
|
|
}
|
|
|
|
// newAuthedTestServer ist der Standardfall für Tests, die sich nicht für
|
|
// Auth selbst interessieren: ein Server plus ein fertig angemeldeter Mandant.
|
|
func newAuthedTestServer(t *testing.T, ex web.Extractor) (*web.Server, *fakeStore, *http.Cookie) {
|
|
t.Helper()
|
|
fs := newFakeStore()
|
|
s := newServer(t, ex, fs)
|
|
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
|
|
return s, fs, cookie
|
|
}
|
|
|
|
func postForm(t *testing.T, s *web.Server, cookie *http.Cookie, 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")
|
|
if cookie != nil {
|
|
req.AddCookie(cookie)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
s.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
func getWithCookie(t *testing.T, s *web.Server, cookie *http.Cookie, path string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
if cookie != nil {
|
|
req.AddCookie(cookie)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
s.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
func checkForm(extra ...string) url.Values {
|
|
v := url.Values{
|
|
"platform": {"instagram"}, "post_type": {"reel"},
|
|
"consideration": {"bezahlt"}, "caption": {"Werbung: ..."},
|
|
}
|
|
for i := 0; i+1 < len(extra); i += 2 {
|
|
v.Set(extra[i], extra[i+1])
|
|
}
|
|
return v
|
|
}
|
|
|
|
// ─── Tests: Health, statische Assets (kein Auth nötig) ────────────────
|
|
|
|
func TestHandleHealth(t *testing.T) {
|
|
s, _, _ := newAuthedTestServer(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 TestHandleStaticServesHTMX(t *testing.T) {
|
|
s, _, _ := newAuthedTestServer(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())
|
|
}
|
|
}
|
|
|
|
// ─── Tests: Auth (Registrierung, Login, Logout, Zugriffsschutz) ───────
|
|
|
|
func TestRegisterThenLoginThenAccessProtectedPage(t *testing.T) {
|
|
fs := newFakeStore()
|
|
s := newServer(t, fakeExtractor{}, fs)
|
|
|
|
regResp := postForm(t, s, nil, "/register", url.Values{
|
|
"account_name": {"Meine Agentur"}, "email": {"neu@example.com"},
|
|
"password": {"ein-sicheres-passwort"}, "role": {"agentur"},
|
|
})
|
|
if regResp.Code != http.StatusSeeOther {
|
|
t.Fatalf("register status = %d, want 303, body: %s", regResp.Code, regResp.Body.String())
|
|
}
|
|
cookies := regResp.Result().Cookies()
|
|
if len(cookies) == 0 {
|
|
t.Fatal("expected a session cookie to be set after registration")
|
|
}
|
|
|
|
// Mit dem gesetzten Cookie muss die geschützte Startseite erreichbar sein.
|
|
indexResp := getWithCookie(t, s, cookies[0], "/")
|
|
if indexResp.Code != http.StatusOK {
|
|
t.Fatalf("index status with fresh session = %d, want 200", indexResp.Code)
|
|
}
|
|
}
|
|
|
|
func TestRegisterRejectsDuplicateEmail(t *testing.T) {
|
|
fs := newFakeStore()
|
|
s := newServer(t, fakeExtractor{}, fs)
|
|
form := url.Values{
|
|
"account_name": {"A"}, "email": {"doppelt@example.com"},
|
|
"password": {"ein-sicheres-passwort"}, "role": {"creator"},
|
|
}
|
|
if resp := postForm(t, s, nil, "/register", form); resp.Code != http.StatusSeeOther {
|
|
t.Fatalf("first register status = %d, want 303", resp.Code)
|
|
}
|
|
resp := postForm(t, s, nil, "/register", form)
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("second register status = %d, want 200 (re-rendered form with error)", resp.Code)
|
|
}
|
|
if !strings.Contains(resp.Body.String(), "schon vergeben") {
|
|
t.Errorf("expected a duplicate-email error, got: %s", resp.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestLoginWithCorrectPassword(t *testing.T) {
|
|
fs := newFakeStore()
|
|
s := newServer(t, fakeExtractor{}, fs)
|
|
hash, err := auth.HashPassword("richtiges-passwort")
|
|
if err != nil {
|
|
t.Fatalf("HashPassword: %v", err)
|
|
}
|
|
acc, err := fs.CreateAccount(context.Background(), "Bestehender Mandant")
|
|
if err != nil {
|
|
t.Fatalf("CreateAccount: %v", err)
|
|
}
|
|
if _, err := fs.CreateUser(context.Background(), acc.ID, "bestehend@example.com", hash, "marke"); err != nil {
|
|
t.Fatalf("CreateUser: %v", err)
|
|
}
|
|
|
|
resp := postForm(t, s, nil, "/login", url.Values{"email": {"bestehend@example.com"}, "password": {"richtiges-passwort"}})
|
|
if resp.Code != http.StatusSeeOther {
|
|
t.Fatalf("login status = %d, want 303, body: %s", resp.Code, resp.Body.String())
|
|
}
|
|
if len(resp.Result().Cookies()) == 0 {
|
|
t.Fatal("expected a session cookie after successful login")
|
|
}
|
|
}
|
|
|
|
func TestLoginRedirectsAdminToAdminDashboard(t *testing.T) {
|
|
fs := newFakeStore()
|
|
s := newServer(t, fakeExtractor{}, fs)
|
|
hash, err := auth.HashPassword("admin-passwort")
|
|
if err != nil {
|
|
t.Fatalf("HashPassword: %v", err)
|
|
}
|
|
acc, err := fs.CreateAccount(context.Background(), "Deklarix Admin")
|
|
if err != nil {
|
|
t.Fatalf("CreateAccount: %v", err)
|
|
}
|
|
if _, err := fs.CreateUser(context.Background(), acc.ID, "admin@example.com", hash, "admin"); err != nil {
|
|
t.Fatalf("CreateUser: %v", err)
|
|
}
|
|
|
|
resp := postForm(t, s, nil, "/login", url.Values{"email": {"admin@example.com"}, "password": {"admin-passwort"}})
|
|
if resp.Code != http.StatusSeeOther {
|
|
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
|
|
}
|
|
if loc := resp.Header().Get("Location"); loc != "/admin" {
|
|
t.Fatalf("Location = %q, want /admin for an admin login", loc)
|
|
}
|
|
}
|
|
|
|
func TestLoginRejectsWrongPassword(t *testing.T) {
|
|
fs := newFakeStore()
|
|
s := newServer(t, fakeExtractor{}, fs)
|
|
hash, err := auth.HashPassword("richtiges-passwort")
|
|
if err != nil {
|
|
t.Fatalf("HashPassword: %v", err)
|
|
}
|
|
acc, _ := fs.CreateAccount(context.Background(), "X")
|
|
if _, err := fs.CreateUser(context.Background(), acc.ID, "x@example.com", hash, "marke"); err != nil {
|
|
t.Fatalf("CreateUser: %v", err)
|
|
}
|
|
|
|
resp := postForm(t, s, nil, "/login", url.Values{"email": {"x@example.com"}, "password": {"falsch"}})
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (re-rendered login form)", resp.Code)
|
|
}
|
|
if len(resp.Result().Cookies()) != 0 {
|
|
t.Fatal("expected no session cookie for a failed login")
|
|
}
|
|
}
|
|
|
|
func TestLoginRejectsUnknownEmailWithSameMessageAsWrongPassword(t *testing.T) {
|
|
fs := newFakeStore()
|
|
s := newServer(t, fakeExtractor{}, fs)
|
|
|
|
resp := postForm(t, s, nil, "/login", url.Values{"email": {"gibtsnicht@example.com"}, "password": {"irgendwas"}})
|
|
if !strings.Contains(resp.Body.String(), "E-Mail oder Passwort falsch") {
|
|
t.Errorf("expected the generic invalid-credentials message, got: %s", resp.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestIndexRedirectsToLoginWithoutSession(t *testing.T) {
|
|
s, _, _ := newAuthedTestServer(t, fakeExtractor{})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
w := httptest.NewRecorder()
|
|
s.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusSeeOther {
|
|
t.Fatalf("status = %d, want 303 redirect to /login", w.Code)
|
|
}
|
|
if loc := w.Header().Get("Location"); loc != "/login" {
|
|
t.Fatalf("Location = %q, want /login", loc)
|
|
}
|
|
}
|
|
|
|
func TestCheckRejectsRequestsWithoutSession(t *testing.T) {
|
|
s, _, _ := newAuthedTestServer(t, fakeExtractor{})
|
|
|
|
w := postForm(t, s, nil, "/pruefen", checkForm())
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want 401 without a session", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestLogoutClearsSession(t *testing.T) {
|
|
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{})
|
|
|
|
if resp := getWithCookie(t, s, cookie, "/"); resp.Code != http.StatusOK {
|
|
t.Fatalf("index before logout = %d, want 200", resp.Code)
|
|
}
|
|
|
|
logoutResp := postForm(t, s, cookie, "/logout", url.Values{})
|
|
if logoutResp.Code != http.StatusSeeOther {
|
|
t.Fatalf("logout status = %d, want 303", logoutResp.Code)
|
|
}
|
|
if _, err := fs.GetSession(context.Background(), cookie.Value); !errors.Is(err, store.ErrNotFound) {
|
|
t.Fatal("expected the session to be deleted from the store after logout")
|
|
}
|
|
|
|
if resp := getWithCookie(t, s, cookie, "/"); resp.Code != http.StatusSeeOther {
|
|
t.Fatalf("index after logout = %d, want 303 redirect (session no longer valid)", resp.Code)
|
|
}
|
|
}
|
|
|
|
// ─── Tests: Pre-Publish-Prüfung (angemeldet) ──────────────────────────
|
|
|
|
func TestHandleIndexRendersForm(t *testing.T) {
|
|
s, _, cookie := newAuthedTestServer(t, fakeExtractor{})
|
|
|
|
resp := getWithCookie(t, s, cookie, "/")
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", resp.Code)
|
|
}
|
|
body := resp.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 TestHandleCheckRejectsMissingFields(t *testing.T) {
|
|
s, _, cookie := newAuthedTestServer(t, fakeExtractor{})
|
|
|
|
w := postForm(t, s, cookie, "/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, _, cookie := newAuthedTestServer(t, fakeExtractor{err: fmt.Errorf("simulierter Extraktionsfehler")})
|
|
|
|
w := postForm(t, s, cookie, "/pruefen", checkForm())
|
|
if w.Code != http.StatusBadGateway {
|
|
t.Fatalf("status = %d, want 502 when extraction fails", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleCheckPersistsSubmissionExtractionAndFindings(t *testing.T) {
|
|
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
|
|
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationPaid,
|
|
DisclosurePresent: true, DisclosureWording: "Werbung", DisclosureBeforeCut: false,
|
|
}, raw: []byte(`{"gegenleistung":"bezahlt"}`)})
|
|
|
|
w := postForm(t, s, cookie, "/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 sub.AccountID == "" {
|
|
t.Error("expected the submission to carry the current account's ID")
|
|
}
|
|
}
|
|
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) {
|
|
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
|
|
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationUnclear,
|
|
}})
|
|
|
|
w := postForm(t, s, cookie, "/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") {
|
|
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 TestHandleCheckCleanCaseHasNoFindingsAndOffersArchive(t *testing.T) {
|
|
s, _, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
|
|
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
|
|
}})
|
|
|
|
w := postForm(t, s, cookie, "/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)
|
|
}
|
|
}
|
|
|
|
// ─── Tests: Archivierung + Mandantentrennung ──────────────────────────
|
|
|
|
func TestFullCheckThenArchiveFlow(t *testing.T) {
|
|
s, fs, cookie := newAuthedTestServer(t, 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}`)})
|
|
|
|
checkResp := postForm(t, s, cookie, "/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, cookie, "/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.AddCookie(cookie)
|
|
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, _, cookie := newAuthedTestServer(t, fakeExtractor{})
|
|
|
|
w := postForm(t, s, cookie, "/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 TestTenantIsolationArchiveAndDownload(t *testing.T) {
|
|
fs := newFakeStore()
|
|
s := newServer(t, fakeExtractor{facts: rules.Facts{
|
|
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
|
|
}, raw: []byte(`{"gegenleistung":"keine","kennzeichnung_vorhanden":false,"kennzeichnung_wortlaut":"","kennzeichnung_vor_kuerzung":false}`)}, fs)
|
|
|
|
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
|
|
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
|
|
|
|
checkResp := postForm(t, s, cookieA, "/pruefen", checkForm())
|
|
if checkResp.Code != http.StatusOK {
|
|
t.Fatalf("check status = %d", checkResp.Code)
|
|
}
|
|
var subID string
|
|
for id, sub := range fs.submissions {
|
|
if sub.AccountID != "" {
|
|
subID = id
|
|
}
|
|
}
|
|
if subID == "" {
|
|
t.Fatal("expected a submission to have been created for Mandant A")
|
|
}
|
|
|
|
// Mandant B darf As Beitrag weder archivieren noch dessen Dossier
|
|
// abrufen — beides muss wie "nicht gefunden" aussehen, nicht wie ein
|
|
// expliziter Zugriffsfehler.
|
|
archiveResp := postForm(t, s, cookieB, "/veroeffentlichen", url.Values{"submission_id": {subID}})
|
|
if archiveResp.Code != http.StatusNotFound {
|
|
t.Fatalf("cross-tenant archive status = %d, want 404", archiveResp.Code)
|
|
}
|
|
|
|
// Damit A tatsächlich etwas zum Herunterladen hat: A archiviert selbst.
|
|
ownArchiveResp := postForm(t, s, cookieA, "/veroeffentlichen", url.Values{"submission_id": {subID}})
|
|
if ownArchiveResp.Code != http.StatusOK {
|
|
t.Fatalf("own archive status = %d, body: %s", ownArchiveResp.Code, ownArchiveResp.Body.String())
|
|
}
|
|
|
|
downloadReq := httptest.NewRequest(http.MethodGet, "/dossier/"+subID, nil)
|
|
downloadReq.AddCookie(cookieB)
|
|
downloadReq.SetPathValue("id", subID)
|
|
downloadW := httptest.NewRecorder()
|
|
s.ServeHTTP(downloadW, downloadReq)
|
|
if downloadW.Code != http.StatusNotFound {
|
|
t.Fatalf("cross-tenant download status = %d, want 404", downloadW.Code)
|
|
}
|
|
}
|