Bislang gab es keine vom Nutzer-Rollenmodell (creator/agentur/marke/
kanzlei) getrennte Betreiber-Rolle — jede Verwaltungsaufgabe (welche
Kanzlei darf im öffentlichen Verzeichnis stehen, wer sind unsere
Accounts) wäre nur per Hand in der Datenbank möglich gewesen. Admin
ist von Anfang an als fünfte app_user-Rolle im Datenmodell verankert,
nicht nachträglich aufgesetzt.
Migration 0004:
- app_user.role erlaubt zusätzlich 'admin' (kein Self-Service-Weg
dorthin — /register bietet die Rolle nicht an, erster Admin wird
einmalig per SQL angelegt, siehe CLAUDE.md).
- account.verified: Freigabe fürs kostenlose Kanzlei-Verzeichnis
(§ 49b Abs. 3 BRAO: reine Auflistung, kein Routing/keine Vermittlung).
- audit_log: append-only-Protokoll jeder Admin-Aktion (gleicher Trigger
wie finding/extraction/evidence_package).
Neue Routen:
- GET /admin, /admin/accounts, /admin/accounts/{id}: Accounts-Übersicht
und -Detail (Logins je Account), requireAdmin (404 statt 403 für
angemeldete Nicht-Admins, wie beim bestehenden Mandanten-404-Muster).
- POST /admin/accounts/{id}/verifizieren: Kanzlei-Freigabe umschalten,
schreibt einen Audit-Log-Eintrag.
- GET /admin/audit-log: Protokoll ansehen.
- GET /kanzleien: öffentliches Verzeichnis (kein Login), zeigt nur
Accounts, die sowohl verified sind als auch einen Nutzer der Rolle
"kanzlei" haben.
Volle Testsuite inkl. echter Postgres-Tests grün; End-to-End manuell
gegen einen laufenden Server verifiziert (Admin-Login, Verify-Toggle,
Erscheinen im öffentlichen Verzeichnis, Audit-Log-Eintrag, 404 für
Nicht-Admin-Zugriff).
984 lines
31 KiB
Go
984 lines
31 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
|
|
}
|
|
|
|
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{},
|
|
}
|
|
}
|
|
|
|
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) 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())
|
|
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 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)
|
|
}
|
|
}
|