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:
noroot
2026-08-27 15:56:11 +02:00
parent 2a16dc2200
commit 5156fe62da
12 changed files with 755 additions and 128 deletions

View File

@@ -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
View File

@@ -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
View File

@@ -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=

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

View File

@@ -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

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

View File

@@ -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

View File

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

View File

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

View File

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

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

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