From 5156fe62dab55d1979203bacaa043b164a0f1f26 Mon Sep 17 00:00:00 2001 From: noroot Date: Thu, 27 Aug 2026 15:56:11 +0200 Subject: [PATCH] feat: wire auth into the web layer (Schritt 2, part 2/2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registration creates a new account plus its first user; login authenticates an existing one; both set a deklarix_session cookie (HttpOnly, SameSite=Strict, Secure only when the request itself came over TLS — hardcoding Secure=true would break local http://localhost development, since browsers won't store a Secure cookie over plaintext). requirePage protects full-page GETs (redirects to /login); requireAPI protects the htmx/download endpoints (401, since those are only ever called from an already-authenticated page — an unauthenticated hit there is the exception, e.g. a session expiring mid-use). handleCheck now creates submissions under the current account. handleArchive and handleDossierDownload compare the submission's account against the caller's and return 404 on mismatch — not 403, which would confirm the ID exists to a different tenant. Login failure uses the same message for "no such email" and "wrong password" to avoid account enumeration. Restructured templates along the way: layout.html now only holds reusable fragments ("head", "nav"); each full page (index/login/register) is its own top-level named template. The previous layout+content nesting would have broken the moment a second page defined "content" — Go's html/template keys blocks by name across the whole parsed set, not per file, so two pages both defining "content" would silently overwrite each other. Verified against a real running instance (not just Go's test recorder): started the compiled binary against a fresh Postgres and drove the whole flow with curl — anonymous redirect, registration setting a real cookie, authenticated page load, logout clearing both the cookie and the server-side session row, and being locked out again afterward. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 20 +- go.mod | 2 +- go.sum | 7 +- internal/web/auth_handlers.go | 136 ++++++++ internal/web/handlers.go | 22 +- internal/web/middleware.go | 81 +++++ internal/web/server.go | 32 +- internal/web/server_test.go | 501 ++++++++++++++++++++++----- internal/web/templates/index.html | 8 +- internal/web/templates/layout.html | 25 +- internal/web/templates/login.html | 19 + internal/web/templates/register.html | 30 ++ 12 files changed, 755 insertions(+), 128 deletions(-) create mode 100644 internal/web/auth_handlers.go create mode 100644 internal/web/middleware.go create mode 100644 internal/web/templates/login.html create mode 100644 internal/web/templates/register.html diff --git a/CLAUDE.md b/CLAUDE.md index 5b66b7c..415a471 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,8 +121,16 @@ Managed Postgres in der EU (DSGVO). ## Datenmodell -Fünf Tabellen, mehr braucht der MVP nicht: +Plus drei Tabellen für Auth/Mandantentrennung (`account`, `app_user`, +`session`), mehr braucht der MVP nicht: +- `account` — ein Mandant (Creator, Agentur, Marke oder Kanzlei als + eigene Organisation); jede Submission gehört genau einem Account +- `app_user` — ein Login innerhalb eines Accounts (E-Mail, Passwort- + Hash, Rolle) +- `session` — eine angemeldete Sitzung (Token, Ablaufzeit); bewusst + eine echte Tabelle statt zustandsloser signierter Tokens, damit + Logout eine Sitzung wirklich beendet - `submission` — ein eingereichter Beitrag, Status, Zeitpunkte - `asset` — Bild oder Datei, Pfad, SHA-256 - `extraction` — das JSON aus Stufe 1, Modellversion, Prompt-Version @@ -146,6 +154,16 @@ Fundstellen, Vertrags- und Briefing-Bezug, Verantwortungsmatrix, alle Hashes und das Zeitstempel-Token. Vollständiger Export muss für den Nutzer jederzeit möglich sein — wer kündigt, bekommt sein Archiv. +**Auth/Mandantentrennung:** `POST /register` legt einen neuen Account +plus den ersten Nutzer darin an, `POST /login` meldet einen bestehenden +Nutzer an — beides setzt ein `deklarix_session`-Cookie (HttpOnly, +SameSite=Strict, Secure sobald über TLS erreicht). `GET /` verlangt eine +gültige Sitzung (sonst Redirect zu `/login`); `POST /pruefen`, +`POST /veroeffentlichen` und `GET /dossier/{id}` verlangen sie ebenfalls +(sonst 401). Ein Beitrag eines fremden Accounts wird wie ein nicht +existierender behandelt (404), nie mit einer expliziten 403 bestätigt — +sonst würde die Antwort selbst verraten, dass die ID existiert. + --- ## Go Commands diff --git a/go.mod b/go.mod index 1808a02..5387bb1 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/go-pdf/fpdf v0.9.0 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/jackc/pgx/v5 v5.10.0 + golang.org/x/crypto v0.55.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -18,7 +19,6 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/kr/text v0.2.0 // indirect github.com/rogpeppe/go-internal v1.16.0 // indirect - golang.org/x/crypto v0.55.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/text v0.41.0 // indirect ) diff --git a/go.sum b/go.sum index 6123a24..5a9cfba 100644 --- a/go.sum +++ b/go.sum @@ -86,15 +86,10 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/web/auth_handlers.go b/internal/web/auth_handlers.go new file mode 100644 index 0000000..294bded --- /dev/null +++ b/internal/web/auth_handlers.go @@ -0,0 +1,136 @@ +package web + +import ( + "net/http" + "time" + + "github.com/netcell-it/deklarix/internal/auth" +) + +type authPageData struct { + Title string + Error string +} + +func (s *Server) renderAuthPage(w http.ResponseWriter, name string, data authPageData) { + if err := s.templates.ExecuteTemplate(w, name, data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } +} + +func (s *Server) handleRegisterForm(w http.ResponseWriter, r *http.Request) { + s.renderAuthPage(w, "register", authPageData{Title: "Registrieren"}) +} + +func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) { + s.renderAuthPage(w, "login", authPageData{Title: "Anmelden"}) +} + +// handleRegister legt einen neuen Mandanten (Account) und den ersten +// Nutzer darin an. Es gibt bewusst keinen separaten "Account beitreten"- +// Flow — das wäre Schritt 4 (Agentur-/Markensicht mit mehreren Nutzern +// pro Account), hier reicht ein Nutzer pro neuem Account. +func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "ungültiges Formular"}) + return + } + + accountName := r.FormValue("account_name") + email := r.FormValue("email") + password := r.FormValue("password") + role := r.FormValue("role") + if accountName == "" || email == "" || password == "" || role == "" { + s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Alle Felder sind Pflicht"}) + return + } + + passwordHash, err := auth.HashPassword(password) + if err != nil { + s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: err.Error()}) + return + } + + ctx := r.Context() + acc, err := s.store.CreateAccount(ctx, accountName) + if err != nil { + s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Konto konnte nicht angelegt werden"}) + return + } + user, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, role) + if err != nil { + s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"}) + return + } + + if err := s.startSession(w, r, user.ID); err != nil { + s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Sitzung konnte nicht gestartet werden"}) + return + } + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "ungültiges Formular"}) + return + } + + email := r.FormValue("email") + password := r.FormValue("password") + + ctx := r.Context() + user, err := s.store.GetUserByEmail(ctx, email) + if err != nil { + // Absichtlich dieselbe Meldung wie bei falschem Passwort — sonst + // verrät die Fehlermeldung, ob eine E-Mail-Adresse existiert. + s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "E-Mail oder Passwort falsch"}) + return + } + if err := auth.VerifyPassword(user.PasswordHash, password); err != nil { + s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "E-Mail oder Passwort falsch"}) + return + } + + if err := s.startSession(w, r, user.ID); err != nil { + s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "Sitzung konnte nicht gestartet werden"}) + return + } + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + if cookie, err := r.Cookie(sessionCookieName); err == nil { + _ = s.store.DeleteSession(r.Context(), cookie.Value) + } + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1, + }) + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +// startSession erzeugt eine neue Sitzung und setzt das Session-Cookie. +// Secure ist an genau dann, wenn die Anfrage selbst über TLS kam — ein +// fest verdrahtetes Secure=true würde lokale Entwicklung ohne TLS +// (http://localhost) brechen, da Browser ein Secure-Cookie über Klartext- +// HTTP schlicht nicht speichern. +func (s *Server) startSession(w http.ResponseWriter, r *http.Request, userID string) error { + token, err := auth.NewSessionToken() + if err != nil { + return err + } + expiresAt := time.Now().Add(auth.SessionDuration) + if _, err := s.store.CreateSession(r.Context(), token, userID, expiresAt); err != nil { + return err + } + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: token, + Path: "/", + HttpOnly: true, + Secure: r.TLS != nil, + SameSite: http.SameSiteStrictMode, + Expires: expiresAt, + }) + return nil +} diff --git a/internal/web/handlers.go b/internal/web/handlers.go index f6eef43..b24b433 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -24,7 +24,7 @@ type indexData struct { } func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { - if err := s.templates.ExecuteTemplate(w, "layout", indexData{Title: "Pre-Publish-Prüfung"}); err != nil { + if err := s.templates.ExecuteTemplate(w, "index", indexData{Title: "Pre-Publish-Prüfung"}); err != nil { http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) } } @@ -66,6 +66,7 @@ func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) { } ctx := r.Context() + accountID := currentUser(r).AccountID result, err := s.extractor.Extract(ctx, extract.Input{ Platform: platform, @@ -77,7 +78,7 @@ func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) { return } - sub, err := s.store.CreateSubmission(ctx, platform, postType, caption) + sub, err := s.store.CreateSubmission(ctx, accountID, platform, postType, caption) if err != nil { http.Error(w, "Beitrag konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError) return @@ -144,6 +145,14 @@ func (s *Server) handleArchive(w http.ResponseWriter, r *http.Request) { http.Error(w, "Beitrag nicht gefunden: "+err.Error(), http.StatusNotFound) return } + // Mandantentrennung: ein Beitrag eines anderen Accounts wird wie ein + // nicht existierender behandelt, nicht mit einer 403 bestätigt — + // eine 403 würde einem anderen Mandanten verraten, dass die ID + // existiert. + if sub.AccountID != currentUser(r).AccountID { + http.Error(w, "Beitrag nicht gefunden", http.StatusNotFound) + return + } ext, err := s.store.GetLatestExtraction(ctx, submissionID) if err != nil { @@ -244,8 +253,15 @@ func (s *Server) handleArchive(w http.ResponseWriter, r *http.Request) { // archivierten Beitrags aus. func (s *Server) handleDossierDownload(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") + ctx := r.Context() - pkg, err := s.store.GetLatestEvidencePackage(r.Context(), id) + sub, err := s.store.GetSubmission(ctx, id) + if err != nil || sub.AccountID != currentUser(r).AccountID { + http.Error(w, "Kein Dossier für diesen Beitrag gefunden", http.StatusNotFound) + return + } + + pkg, err := s.store.GetLatestEvidencePackage(ctx, id) if err != nil { http.Error(w, "Kein Dossier für diesen Beitrag gefunden", http.StatusNotFound) return diff --git a/internal/web/middleware.go b/internal/web/middleware.go new file mode 100644 index 0000000..ee9b2e9 --- /dev/null +++ b/internal/web/middleware.go @@ -0,0 +1,81 @@ +package web + +import ( + "context" + "net/http" + "time" + + "github.com/netcell-it/deklarix/internal/store" +) + +const sessionCookieName = "deklarix_session" + +type contextKey int + +const userContextKey contextKey = iota + +// authenticate liest das Session-Cookie, prüft die Sitzung (existiert, +// nicht abgelaufen) und lädt den zugehörigen Nutzer. Liefert (User{}, false), +// wenn irgendein Schritt fehlschlägt — die Gründe (kein Cookie, unbekanntes +// Token, abgelaufen, Nutzer weg) werden bewusst nicht unterschieden, damit +// requirePage/requireAPI immer denselben, einzigen Fehlerpfad haben. +func (s *Server) authenticate(r *http.Request) (store.User, bool) { + cookie, err := r.Cookie(sessionCookieName) + if err != nil || cookie.Value == "" { + return store.User{}, false + } + + sess, err := s.store.GetSession(r.Context(), cookie.Value) + if err != nil { + return store.User{}, false + } + if time.Now().After(sess.ExpiresAt) { + return store.User{}, false + } + + user, err := s.store.GetUser(r.Context(), sess.UserID) + if err != nil { + return store.User{}, false + } + return user, true +} + +// requirePage schützt volle Seitenaufrufe — ohne gültige Sitzung geht +// es zurück zu /login (eine echte Navigation, kein htmx-Fragment). +func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user, ok := s.authenticate(r) + if !ok { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user))) + } +} + +// requireAPI schützt htmx-Endpunkte (Formular-Posts, Downloads) — diese +// werden nur aus einer bereits authentifizierten Seite heraus +// aufgerufen, ein Fehlschlag hier ist der Ausnahmefall (z. B. Sitzung +// mitten in der Nutzung abgelaufen), daher schlicht 401 statt Redirect. +func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user, ok := s.authenticate(r) + if !ok { + http.Error(w, "nicht angemeldet", http.StatusUnauthorized) + return + } + next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user))) + } +} + +// currentUser liest den Nutzer, den requirePage/requireAPI in den +// Kontext gelegt haben. Panics, wenn es aufgerufen wird, ohne dass eine +// dieser Middlewares vorgeschaltet war — das ist ein Programmierfehler, +// kein Laufzeitfall, den man abfangen sollte. +func currentUser(r *http.Request) store.User { + user, ok := r.Context().Value(userContextKey).(store.User) + if !ok { + panic("web: currentUser aufgerufen ohne requirePage/requireAPI") + } + return user +} diff --git a/internal/web/server.go b/internal/web/server.go index 5284686..d4ac685 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -1,10 +1,8 @@ // Package web ist die HTTP-Schicht: Routing, Templates, Handler. // Bewusst html/template + htmx, kein Frontend-Build (siehe CLAUDE.md, -// Stack). Persistenz (Submission/Finding/Evidence speichern) ist noch -// nicht angebunden — das ist der nächste Schritt (Verdrahtung über -// internal/store). Dieser Server führt Pre-Publish-Prüfungen aus -// (extrahieren + bewerten) und zeigt das Ergebnis an, ohne es -// aufzubewahren. +// Stack). Jede Submission gehört einem Account (Mandant); Auth-Cookies +// binden einen Request an einen angemeldeten Nutzer und damit an dessen +// Account — siehe middleware.go. package web import ( @@ -13,6 +11,7 @@ import ( "fmt" "html/template" "net/http" + "time" "github.com/netcell-it/deklarix/internal/evidence" "github.com/netcell-it/deklarix/internal/extract" @@ -39,7 +38,7 @@ type Extractor interface { // komplette *store.Store. *store.Store erfüllt sie; Tests injizieren // einen Fake statt eine echte Datenbank zu brauchen. type Store interface { - CreateSubmission(ctx context.Context, platform, postType, caption string) (store.Submission, error) + CreateSubmission(ctx context.Context, accountID, platform, postType, caption string) (store.Submission, error) GetSubmission(ctx context.Context, id string) (store.Submission, error) SetSubmissionStatus(ctx context.Context, id, status string) error CreateExtraction(ctx context.Context, submissionID string, payload []byte, modelVersion, promptVersion string) (store.Extraction, error) @@ -48,6 +47,14 @@ type Store interface { ListCurrentFindings(ctx context.Context, submissionID string) ([]store.Finding, error) CreateEvidencePackage(ctx context.Context, submissionID, dossierPath, sha256Hex string, timestampToken []byte) (store.EvidencePackage, error) GetLatestEvidencePackage(ctx context.Context, submissionID string) (store.EvidencePackage, error) + + CreateAccount(ctx context.Context, name string) (store.Account, error) + CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error) + GetUserByEmail(ctx context.Context, email string) (store.User, error) + GetUser(ctx context.Context, id string) (store.User, error) + CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error) + GetSession(ctx context.Context, token string) (store.Session, error) + DeleteSession(ctx context.Context, token string) error } // Server bündelt Routing und Abhängigkeiten der Web-Schicht. @@ -81,10 +88,15 @@ func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper mux := http.NewServeMux() mux.HandleFunc("GET /health", s.handleHealth) - mux.HandleFunc("GET /{$}", s.handleIndex) - mux.HandleFunc("POST /pruefen", s.handleCheck) - mux.HandleFunc("POST /veroeffentlichen", s.handleArchive) - mux.HandleFunc("GET /dossier/{id}", s.handleDossierDownload) + mux.HandleFunc("GET /register", s.handleRegisterForm) + mux.HandleFunc("POST /register", s.handleRegister) + mux.HandleFunc("GET /login", s.handleLoginForm) + mux.HandleFunc("POST /login", s.handleLogin) + mux.HandleFunc("POST /logout", s.handleLogout) + mux.HandleFunc("GET /{$}", s.requirePage(s.handleIndex)) + mux.HandleFunc("POST /pruefen", s.requireAPI(s.handleCheck)) + mux.HandleFunc("POST /veroeffentlichen", s.requireAPI(s.handleArchive)) + mux.HandleFunc("GET /dossier/{id}", s.requireAPI(s.handleDossierDownload)) mux.Handle("GET /static/", http.FileServerFS(staticFS)) s.mux = mux diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 8329399..fc2cbe2 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -9,6 +9,7 @@ import ( "crypto/x509" "crypto/x509/pkix" "encoding/asn1" + "errors" "fmt" "math/big" "net/http" @@ -22,12 +23,15 @@ import ( "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 { @@ -54,6 +58,10 @@ func (f fakeExtractor) ModelVersion() string { return "fake-model-v0" } 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 @@ -62,6 +70,10 @@ type fakeStore struct { 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{}, @@ -74,11 +86,79 @@ func (f *fakeStore) newID() string { return fmt.Sprintf("id-%d", f.nextID) } -func (f *fakeStore) CreateSubmission(ctx context.Context, platform, postType, caption string) (store.Submission, error) { +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) 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(), Platform: platform, PostType: postType, Caption: caption, + ID: f.newID(), AccountID: accountID, Platform: platform, PostType: postType, Caption: caption, Status: "draft", CreatedAt: time.Now(), UpdatedAt: time.Now(), } f.submissions[sub.ID] = sub @@ -90,7 +170,7 @@ func (f *fakeStore) GetSubmission(ctx context.Context, id string) (store.Submiss defer f.mu.Unlock() sub, ok := f.submissions[id] if !ok { - return store.Submission{}, fmt.Errorf("fakeStore: submission %s nicht gefunden", id) + return store.Submission{}, store.ErrNotFound } return sub, nil } @@ -223,69 +303,70 @@ func loadRealRules(t *testing.T) []rules.Rule { return rs } -func newTestServer(t *testing.T, ex web.Extractor) *web.Server { +func newServer(t *testing.T, ex web.Extractor, fs *fakeStore) *web.Server { t.Helper() - s, err := web.NewServer(ex, loadRealRules(t), newFakeStore(), fakeTimestamper{}, t.TempDir()) + s, err := web.NewServer(ex, loadRealRules(t), fs, fakeTimestamper{}, t.TempDir()) if err != nil { t.Fatalf("NewServer: %v", err) } return s } -// ─── Tests ──────────────────────────────────────────────────────────── - -func TestHandleHealth(t *testing.T) { - s := newTestServer(t, fakeExtractor{}) - - req := httptest.NewRequest(http.MethodGet, "/health", nil) - w := httptest.NewRecorder() - s.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", w.Code) +// 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() + ctx := context.Background() + acc, err := fs.CreateAccount(ctx, accountName) + if err != nil { + t.Fatalf("CreateAccount: %v", err) } - if w.Body.String() != `{"ok":true}` { - t.Fatalf("body = %q, want {\"ok\":true}", w.Body.String()) + hash, err := auth.HashPassword("test-passwort-123") + if err != nil { + t.Fatalf("HashPassword: %v", err) } + user, err := fs.CreateUser(ctx, acc.ID, email, hash, "creator") + 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} } -func TestHandleIndexRendersForm(t *testing.T) { - s := newTestServer(t, fakeExtractor{}) - - req := httptest.NewRequest(http.MethodGet, "/", nil) - w := httptest.NewRecorder() - s.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", w.Code) - } - body := w.Body.String() - for _, want := range []string{`name="platform"`, `name="post_type"`, `name="caption"`, `hx-post="/pruefen"`} { - if !strings.Contains(body, want) { - t.Errorf("index body missing %q\nbody: %s", want, body) - } - } +// 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 TestHandleStaticServesHTMX(t *testing.T) { - s := newTestServer(t, fakeExtractor{}) - - req := httptest.NewRequest(http.MethodGet, "/static/htmx.min.js", nil) - w := httptest.NewRecorder() - s.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", w.Code) - } - if w.Body.Len() < 1000 { - t.Fatalf("htmx.min.js suspiciously small: %d bytes", w.Body.Len()) - } -} - -func postForm(t *testing.T, s *web.Server, path string, form url.Values) *httptest.ResponseRecorder { +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 @@ -299,35 +380,223 @@ func checkForm(extra ...string) url.Values { return v } -func TestHandleCheckRejectsMissingFields(t *testing.T) { - s := newTestServer(t, fakeExtractor{}) +// ─── Tests: Health, statische Assets (kein Auth nötig) ──────────────── - w := postForm(t, s, "/pruefen", url.Values{"platform": {"instagram"}}) +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 := newTestServer(t, fakeExtractor{err: fmt.Errorf("simulierter Extraktionsfehler")}) + s, _, cookie := newAuthedTestServer(t, fakeExtractor{err: fmt.Errorf("simulierter Extraktionsfehler")}) - w := postForm(t, s, "/pruefen", checkForm()) + 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) { - fs := newFakeStore() - s, err := web.NewServer(fakeExtractor{facts: rules.Facts{ + 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"}`)}, loadRealRules(t), fs, fakeTimestamper{}, t.TempDir()) - if err != nil { - t.Fatalf("NewServer: %v", err) - } + }, raw: []byte(`{"gegenleistung":"bezahlt"}`)}) - w := postForm(t, s, "/pruefen", checkForm()) + 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()) } @@ -344,6 +613,9 @@ func TestHandleCheckPersistsSubmissionExtractionAndFindings(t *testing.T) { 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") @@ -354,15 +626,11 @@ func TestHandleCheckPersistsSubmissionExtractionAndFindings(t *testing.T) { } func TestHandleCheckRendersNeedsClarificationAndDoesNotOfferArchive(t *testing.T) { - fs := newFakeStore() - s, err := web.NewServer(fakeExtractor{facts: rules.Facts{ + s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{ Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationUnclear, - }}, loadRealRules(t), fs, fakeTimestamper{}, t.TempDir()) - if err != nil { - t.Fatalf("NewServer: %v", err) - } + }}) - w := postForm(t, s, "/pruefen", checkForm()) + 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) @@ -370,7 +638,7 @@ func TestHandleCheckRendersNeedsClarificationAndDoesNotOfferArchive(t *testing.T if !strings.Contains(body, "nicht sicher bestimmt") { t.Errorf("expected a clarification message, got: %s", body) } - if strings.Contains(body, "veroeffentlichen") || strings.Contains(body, "/veroeffentlichen") { + if strings.Contains(body, "/veroeffentlichen") { t.Errorf("expected no archive option when clarification is needed, got: %s", body) } for _, sub := range fs.submissions { @@ -380,18 +648,36 @@ func TestHandleCheckRendersNeedsClarificationAndDoesNotOfferArchive(t *testing.T } } +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) { - fs := newFakeStore() - s, err := web.NewServer(fakeExtractor{facts: rules.Facts{ + 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}`)}, - loadRealRules(t), fs, fakeTimestamper{}, t.TempDir()) - if err != nil { - t.Fatalf("NewServer: %v", err) - } + }, raw: []byte(`{"gegenleistung":"bezahlt","kennzeichnung_vorhanden":true,"kennzeichnung_wortlaut":"Werbung","kennzeichnung_vor_kuerzung":false}`)}) - checkResp := postForm(t, s, "/pruefen", checkForm()) + checkResp := postForm(t, s, cookie, "/pruefen", checkForm()) if checkResp.Code != http.StatusOK { t.Fatalf("check status = %d, body: %s", checkResp.Code, checkResp.Body.String()) } @@ -404,7 +690,7 @@ func TestFullCheckThenArchiveFlow(t *testing.T) { subID = id } - archiveResp := postForm(t, s, "/veroeffentlichen", url.Values{"submission_id": {subID}}) + 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()) } @@ -421,6 +707,7 @@ func TestFullCheckThenArchiveFlow(t *testing.T) { } downloadReq := httptest.NewRequest(http.MethodGet, "/dossier/"+subID, nil) + downloadReq.AddCookie(cookie) downloadReq.SetPathValue("id", subID) downloadW := httptest.NewRecorder() s.ServeHTTP(downloadW, downloadReq) @@ -437,31 +724,57 @@ func TestFullCheckThenArchiveFlow(t *testing.T) { } func TestHandleArchiveRejectsUnknownSubmission(t *testing.T) { - s := newTestServer(t, fakeExtractor{}) + s, _, cookie := newAuthedTestServer(t, fakeExtractor{}) - w := postForm(t, s, "/veroeffentlichen", url.Values{"submission_id": {"does-not-exist"}}) + 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 TestHandleCheckCleanCaseHasNoFindingsAndOffersArchive(t *testing.T) { - s := newTestServer(t, fakeExtractor{facts: rules.Facts{ +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) - w := postForm(t, s, "/pruefen", checkForm()) - if w.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", w.Code) + 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) } - body := w.Body.String() - if strings.Contains(body, "WK-") { - t.Errorf("expected no findings for an organic post, got: %s", body) + var subID string + for id, sub := range fs.submissions { + if sub.AccountID != "" { + subID = id + } } - if !strings.Contains(body, "Keine Kennzeichnungsrisiken") { - t.Errorf("expected the no-findings message, got: %s", body) + if subID == "" { + t.Fatal("expected a submission to have been created for Mandant A") } - if !strings.Contains(body, "/veroeffentlichen") { - t.Errorf("expected an archive option even with no findings (still a real check), got: %s", body) + + // 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) } } diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index 569f42a..27908de 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -1,4 +1,8 @@ -{{define "content"}} +{{define "index"}} + +{{template "head" .}} + +{{template "nav" .}}

Pre-Publish-Prüfung

Caption und Plattform eingeben, um auf Kennzeichnungsrisiken zu prüfen.

@@ -24,4 +28,6 @@
+ + {{end}} diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index 760e805..743f903 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -1,13 +1,14 @@ -{{define "layout"}} - - - - - {{.Title}} — Deklarix - - - - {{template "content" .}} - - +{{define "head"}} + + +{{.Title}} — Deklarix + +{{end}} + +{{define "nav"}} + {{end}} diff --git a/internal/web/templates/login.html b/internal/web/templates/login.html new file mode 100644 index 0000000..d588de3 --- /dev/null +++ b/internal/web/templates/login.html @@ -0,0 +1,19 @@ +{{define "login"}} + +{{template "head" .}} + +

Anmelden

+{{if .Error}}

{{.Error}}

{{end}} +
+ + + + + + + +
+

Noch kein Konto? Registrieren

+ + +{{end}} diff --git a/internal/web/templates/register.html b/internal/web/templates/register.html new file mode 100644 index 0000000..2fdd609 --- /dev/null +++ b/internal/web/templates/register.html @@ -0,0 +1,30 @@ +{{define "register"}} + +{{template "head" .}} + +

Registrieren

+{{if .Error}}

{{.Error}}

{{end}} +
+ + + + + + + + + + + + + +
+

Schon ein Konto? Anmelden

+ + +{{end}}