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