feat: wire auth into the web layer (Schritt 2, part 2/2)
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 <noreply@anthropic.com>
This commit is contained in:
20
CLAUDE.md
20
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
|
||||
|
||||
2
go.mod
2
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
|
||||
)
|
||||
|
||||
7
go.sum
7
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=
|
||||
|
||||
136
internal/web/auth_handlers.go
Normal file
136
internal/web/auth_handlers.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
81
internal/web/middleware.go
Normal file
81
internal/web/middleware.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 TestFullCheckThenArchiveFlow(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s, err := web.NewServer(fakeExtractor{facts: rules.Facts{
|
||||
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationPaid,
|
||||
DisclosurePresent: true, DisclosureWording: "Werbung", DisclosureBeforeCut: false,
|
||||
}, raw: []byte(`{"gegenleistung":"bezahlt","kennzeichnung_vorhanden":true,"kennzeichnung_wortlaut":"Werbung","kennzeichnung_vor_kuerzung":false}`)},
|
||||
loadRealRules(t), fs, fakeTimestamper{}, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
checkResp := postForm(t, s, "/pruefen", checkForm())
|
||||
// ─── 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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{{define "content"}}
|
||||
{{define "index"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .}}
|
||||
<h1>Pre-Publish-Prüfung</h1>
|
||||
<p>Caption und Plattform eingeben, um auf Kennzeichnungsrisiken zu prüfen.</p>
|
||||
|
||||
@@ -24,4 +28,6 @@
|
||||
</form>
|
||||
|
||||
<div id="ergebnis"></div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
{{define "layout"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
{{define "head"}}
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} — Deklarix</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
{{template "content" .}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
{{define "nav"}}
|
||||
<nav>
|
||||
<form method="post" action="/logout" style="display:inline">
|
||||
<button type="submit">Abmelden</button>
|
||||
</form>
|
||||
</nav>
|
||||
{{end}}
|
||||
|
||||
19
internal/web/templates/login.html
Normal file
19
internal/web/templates/login.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{{define "login"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
<h1>Anmelden</h1>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/login">
|
||||
<label for="email">E-Mail</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
|
||||
<label for="password">Passwort</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
|
||||
<button type="submit">Anmelden</button>
|
||||
</form>
|
||||
<p><a href="/register">Noch kein Konto? Registrieren</a></p>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
30
internal/web/templates/register.html
Normal file
30
internal/web/templates/register.html
Normal file
@@ -0,0 +1,30 @@
|
||||
{{define "register"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
<h1>Registrieren</h1>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/register">
|
||||
<label for="account_name">Name (Creator, Agentur, Marke oder Kanzlei)</label>
|
||||
<input type="text" id="account_name" name="account_name" required>
|
||||
|
||||
<label for="role">Rolle</label>
|
||||
<select id="role" name="role" required>
|
||||
<option value="creator">Creator</option>
|
||||
<option value="agentur">Agentur</option>
|
||||
<option value="marke">Marke</option>
|
||||
<option value="kanzlei">Kanzlei</option>
|
||||
</select>
|
||||
|
||||
<label for="email">E-Mail</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
|
||||
<label for="password">Passwort (mind. 8 Zeichen)</label>
|
||||
<input type="password" id="password" name="password" minlength="8" required>
|
||||
|
||||
<button type="submit">Konto anlegen</button>
|
||||
</form>
|
||||
<p><a href="/login">Schon ein Konto? Anmelden</a></p>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user