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

@@ -9,6 +9,7 @@ import (
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"errors"
"fmt"
"math/big"
"net/http"
@@ -22,12 +23,15 @@ import (
"github.com/digitorus/timestamp"
"github.com/netcell-it/deklarix/internal/auth"
"github.com/netcell-it/deklarix/internal/extract"
"github.com/netcell-it/deklarix/internal/rules"
"github.com/netcell-it/deklarix/internal/store"
"github.com/netcell-it/deklarix/internal/web"
)
const testSessionCookie = "deklarix_session"
// ─── Fakes ────────────────────────────────────────────────────────────
type fakeExtractor struct {
@@ -54,6 +58,10 @@ func (f fakeExtractor) ModelVersion() string { return "fake-model-v0" }
type fakeStore struct {
mu sync.Mutex
nextID int
accounts map[string]store.Account
users map[string]store.User
usersByEmail map[string]string // email -> user id
sessions map[string]store.Session
submissions map[string]store.Submission
extractions map[string]store.Extraction
findings map[string][]store.Finding
@@ -62,6 +70,10 @@ type fakeStore struct {
func newFakeStore() *fakeStore {
return &fakeStore{
accounts: map[string]store.Account{},
users: map[string]store.User{},
usersByEmail: map[string]string{},
sessions: map[string]store.Session{},
submissions: map[string]store.Submission{},
extractions: map[string]store.Extraction{},
findings: map[string][]store.Finding{},
@@ -74,11 +86,79 @@ func (f *fakeStore) newID() string {
return fmt.Sprintf("id-%d", f.nextID)
}
func (f *fakeStore) CreateSubmission(ctx context.Context, platform, postType, caption string) (store.Submission, error) {
func (f *fakeStore) CreateAccount(ctx context.Context, name string) (store.Account, error) {
f.mu.Lock()
defer f.mu.Unlock()
acc := store.Account{ID: f.newID(), Name: name, CreatedAt: time.Now()}
f.accounts[acc.ID] = acc
return acc, nil
}
func (f *fakeStore) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error) {
f.mu.Lock()
defer f.mu.Unlock()
if _, exists := f.usersByEmail[email]; exists {
return store.User{}, fmt.Errorf("fakeStore: email %s bereits vergeben", email)
}
u := store.User{
ID: f.newID(), AccountID: accountID, Email: email, PasswordHash: passwordHash,
Role: role, CreatedAt: time.Now(),
}
f.users[u.ID] = u
f.usersByEmail[email] = u.ID
return u, nil
}
func (f *fakeStore) GetUserByEmail(ctx context.Context, email string) (store.User, error) {
f.mu.Lock()
defer f.mu.Unlock()
id, ok := f.usersByEmail[email]
if !ok {
return store.User{}, store.ErrNotFound
}
return f.users[id], nil
}
func (f *fakeStore) GetUser(ctx context.Context, id string) (store.User, error) {
f.mu.Lock()
defer f.mu.Unlock()
u, ok := f.users[id]
if !ok {
return store.User{}, store.ErrNotFound
}
return u, nil
}
func (f *fakeStore) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error) {
f.mu.Lock()
defer f.mu.Unlock()
sess := store.Session{Token: token, UserID: userID, ExpiresAt: expiresAt, CreatedAt: time.Now()}
f.sessions[token] = sess
return sess, nil
}
func (f *fakeStore) GetSession(ctx context.Context, token string) (store.Session, error) {
f.mu.Lock()
defer f.mu.Unlock()
sess, ok := f.sessions[token]
if !ok {
return store.Session{}, store.ErrNotFound
}
return sess, nil
}
func (f *fakeStore) DeleteSession(ctx context.Context, token string) error {
f.mu.Lock()
defer f.mu.Unlock()
delete(f.sessions, token)
return nil
}
func (f *fakeStore) CreateSubmission(ctx context.Context, accountID, platform, postType, caption string) (store.Submission, error) {
f.mu.Lock()
defer f.mu.Unlock()
sub := store.Submission{
ID: f.newID(), Platform: platform, PostType: postType, Caption: caption,
ID: f.newID(), AccountID: accountID, Platform: platform, PostType: postType, Caption: caption,
Status: "draft", CreatedAt: time.Now(), UpdatedAt: time.Now(),
}
f.submissions[sub.ID] = sub
@@ -90,7 +170,7 @@ func (f *fakeStore) GetSubmission(ctx context.Context, id string) (store.Submiss
defer f.mu.Unlock()
sub, ok := f.submissions[id]
if !ok {
return store.Submission{}, fmt.Errorf("fakeStore: submission %s nicht gefunden", id)
return store.Submission{}, store.ErrNotFound
}
return sub, nil
}
@@ -223,69 +303,70 @@ func loadRealRules(t *testing.T) []rules.Rule {
return rs
}
func newTestServer(t *testing.T, ex web.Extractor) *web.Server {
func newServer(t *testing.T, ex web.Extractor, fs *fakeStore) *web.Server {
t.Helper()
s, err := web.NewServer(ex, loadRealRules(t), newFakeStore(), fakeTimestamper{}, t.TempDir())
s, err := web.NewServer(ex, loadRealRules(t), fs, fakeTimestamper{}, t.TempDir())
if err != nil {
t.Fatalf("NewServer: %v", err)
}
return s
}
// ─── Tests ────────────────────────────────────────────────────────────
func TestHandleHealth(t *testing.T) {
s := newTestServer(t, fakeExtractor{})
req := httptest.NewRequest(http.MethodGet, "/health", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
// seedAccount legt direkt im fakeStore (ohne HTTP) einen Account, einen
// Nutzer und eine gültige Sitzung an und liefert das Session-Cookie.
func seedAccount(t *testing.T, fs *fakeStore, accountName, email string) *http.Cookie {
t.Helper()
ctx := context.Background()
acc, err := fs.CreateAccount(ctx, accountName)
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if w.Body.String() != `{"ok":true}` {
t.Fatalf("body = %q, want {\"ok\":true}", w.Body.String())
hash, err := auth.HashPassword("test-passwort-123")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
user, err := fs.CreateUser(ctx, acc.ID, email, hash, "creator")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
token, err := auth.NewSessionToken()
if err != nil {
t.Fatalf("NewSessionToken: %v", err)
}
if _, err := fs.CreateSession(ctx, token, user.ID, time.Now().Add(time.Hour)); err != nil {
t.Fatalf("CreateSession: %v", err)
}
return &http.Cookie{Name: testSessionCookie, Value: token}
}
func TestHandleIndexRendersForm(t *testing.T) {
s := newTestServer(t, fakeExtractor{})
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
for _, want := range []string{`name="platform"`, `name="post_type"`, `name="caption"`, `hx-post="/pruefen"`} {
if !strings.Contains(body, want) {
t.Errorf("index body missing %q\nbody: %s", want, body)
}
}
// newAuthedTestServer ist der Standardfall für Tests, die sich nicht für
// Auth selbst interessieren: ein Server plus ein fertig angemeldeter Mandant.
func newAuthedTestServer(t *testing.T, ex web.Extractor) (*web.Server, *fakeStore, *http.Cookie) {
t.Helper()
fs := newFakeStore()
s := newServer(t, ex, fs)
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
return s, fs, cookie
}
func TestHandleStaticServesHTMX(t *testing.T) {
s := newTestServer(t, fakeExtractor{})
req := httptest.NewRequest(http.MethodGet, "/static/htmx.min.js", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if w.Body.Len() < 1000 {
t.Fatalf("htmx.min.js suspiciously small: %d bytes", w.Body.Len())
}
}
func postForm(t *testing.T, s *web.Server, path string, form url.Values) *httptest.ResponseRecorder {
func postForm(t *testing.T, s *web.Server, cookie *http.Cookie, path string, form url.Values) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if cookie != nil {
req.AddCookie(cookie)
}
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
return w
}
func getWithCookie(t *testing.T, s *web.Server, cookie *http.Cookie, path string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
if cookie != nil {
req.AddCookie(cookie)
}
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
return w
@@ -299,35 +380,223 @@ func checkForm(extra ...string) url.Values {
return v
}
func TestHandleCheckRejectsMissingFields(t *testing.T) {
s := newTestServer(t, fakeExtractor{})
// ─── Tests: Health, statische Assets (kein Auth nötig) ────────────────
w := postForm(t, s, "/pruefen", url.Values{"platform": {"instagram"}})
func TestHandleHealth(t *testing.T) {
s, _, _ := newAuthedTestServer(t, fakeExtractor{})
req := httptest.NewRequest(http.MethodGet, "/health", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if w.Body.String() != `{"ok":true}` {
t.Fatalf("body = %q, want {\"ok\":true}", w.Body.String())
}
}
func TestHandleStaticServesHTMX(t *testing.T) {
s, _, _ := newAuthedTestServer(t, fakeExtractor{})
req := httptest.NewRequest(http.MethodGet, "/static/htmx.min.js", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if w.Body.Len() < 1000 {
t.Fatalf("htmx.min.js suspiciously small: %d bytes", w.Body.Len())
}
}
// ─── Tests: Auth (Registrierung, Login, Logout, Zugriffsschutz) ───────
func TestRegisterThenLoginThenAccessProtectedPage(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{}, fs)
regResp := postForm(t, s, nil, "/register", url.Values{
"account_name": {"Meine Agentur"}, "email": {"neu@example.com"},
"password": {"ein-sicheres-passwort"}, "role": {"agentur"},
})
if regResp.Code != http.StatusSeeOther {
t.Fatalf("register status = %d, want 303, body: %s", regResp.Code, regResp.Body.String())
}
cookies := regResp.Result().Cookies()
if len(cookies) == 0 {
t.Fatal("expected a session cookie to be set after registration")
}
// Mit dem gesetzten Cookie muss die geschützte Startseite erreichbar sein.
indexResp := getWithCookie(t, s, cookies[0], "/")
if indexResp.Code != http.StatusOK {
t.Fatalf("index status with fresh session = %d, want 200", indexResp.Code)
}
}
func TestRegisterRejectsDuplicateEmail(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{}, fs)
form := url.Values{
"account_name": {"A"}, "email": {"doppelt@example.com"},
"password": {"ein-sicheres-passwort"}, "role": {"creator"},
}
if resp := postForm(t, s, nil, "/register", form); resp.Code != http.StatusSeeOther {
t.Fatalf("first register status = %d, want 303", resp.Code)
}
resp := postForm(t, s, nil, "/register", form)
if resp.Code != http.StatusOK {
t.Fatalf("second register status = %d, want 200 (re-rendered form with error)", resp.Code)
}
if !strings.Contains(resp.Body.String(), "schon vergeben") {
t.Errorf("expected a duplicate-email error, got: %s", resp.Body.String())
}
}
func TestLoginWithCorrectPassword(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{}, fs)
hash, err := auth.HashPassword("richtiges-passwort")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
acc, err := fs.CreateAccount(context.Background(), "Bestehender Mandant")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if _, err := fs.CreateUser(context.Background(), acc.ID, "bestehend@example.com", hash, "marke"); err != nil {
t.Fatalf("CreateUser: %v", err)
}
resp := postForm(t, s, nil, "/login", url.Values{"email": {"bestehend@example.com"}, "password": {"richtiges-passwort"}})
if resp.Code != http.StatusSeeOther {
t.Fatalf("login status = %d, want 303, body: %s", resp.Code, resp.Body.String())
}
if len(resp.Result().Cookies()) == 0 {
t.Fatal("expected a session cookie after successful login")
}
}
func TestLoginRejectsWrongPassword(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{}, fs)
hash, err := auth.HashPassword("richtiges-passwort")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
acc, _ := fs.CreateAccount(context.Background(), "X")
if _, err := fs.CreateUser(context.Background(), acc.ID, "x@example.com", hash, "marke"); err != nil {
t.Fatalf("CreateUser: %v", err)
}
resp := postForm(t, s, nil, "/login", url.Values{"email": {"x@example.com"}, "password": {"falsch"}})
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (re-rendered login form)", resp.Code)
}
if len(resp.Result().Cookies()) != 0 {
t.Fatal("expected no session cookie for a failed login")
}
}
func TestLoginRejectsUnknownEmailWithSameMessageAsWrongPassword(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{}, fs)
resp := postForm(t, s, nil, "/login", url.Values{"email": {"gibtsnicht@example.com"}, "password": {"irgendwas"}})
if !strings.Contains(resp.Body.String(), "E-Mail oder Passwort falsch") {
t.Errorf("expected the generic invalid-credentials message, got: %s", resp.Body.String())
}
}
func TestIndexRedirectsToLoginWithoutSession(t *testing.T) {
s, _, _ := newAuthedTestServer(t, fakeExtractor{})
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303 redirect to /login", w.Code)
}
if loc := w.Header().Get("Location"); loc != "/login" {
t.Fatalf("Location = %q, want /login", loc)
}
}
func TestCheckRejectsRequestsWithoutSession(t *testing.T) {
s, _, _ := newAuthedTestServer(t, fakeExtractor{})
w := postForm(t, s, nil, "/pruefen", checkForm())
if w.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401 without a session", w.Code)
}
}
func TestLogoutClearsSession(t *testing.T) {
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{})
if resp := getWithCookie(t, s, cookie, "/"); resp.Code != http.StatusOK {
t.Fatalf("index before logout = %d, want 200", resp.Code)
}
logoutResp := postForm(t, s, cookie, "/logout", url.Values{})
if logoutResp.Code != http.StatusSeeOther {
t.Fatalf("logout status = %d, want 303", logoutResp.Code)
}
if _, err := fs.GetSession(context.Background(), cookie.Value); !errors.Is(err, store.ErrNotFound) {
t.Fatal("expected the session to be deleted from the store after logout")
}
if resp := getWithCookie(t, s, cookie, "/"); resp.Code != http.StatusSeeOther {
t.Fatalf("index after logout = %d, want 303 redirect (session no longer valid)", resp.Code)
}
}
// ─── Tests: Pre-Publish-Prüfung (angemeldet) ──────────────────────────
func TestHandleIndexRendersForm(t *testing.T) {
s, _, cookie := newAuthedTestServer(t, fakeExtractor{})
resp := getWithCookie(t, s, cookie, "/")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.Code)
}
body := resp.Body.String()
for _, want := range []string{`name="platform"`, `name="post_type"`, `name="caption"`, `hx-post="/pruefen"`} {
if !strings.Contains(body, want) {
t.Errorf("index body missing %q\nbody: %s", want, body)
}
}
}
func TestHandleCheckRejectsMissingFields(t *testing.T) {
s, _, cookie := newAuthedTestServer(t, fakeExtractor{})
w := postForm(t, s, cookie, "/pruefen", url.Values{"platform": {"instagram"}})
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 for missing fields", w.Code)
}
}
func TestHandleCheckPropagatesExtractionError(t *testing.T) {
s := newTestServer(t, fakeExtractor{err: fmt.Errorf("simulierter Extraktionsfehler")})
s, _, cookie := newAuthedTestServer(t, fakeExtractor{err: fmt.Errorf("simulierter Extraktionsfehler")})
w := postForm(t, s, "/pruefen", checkForm())
w := postForm(t, s, cookie, "/pruefen", checkForm())
if w.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want 502 when extraction fails", w.Code)
}
}
func TestHandleCheckPersistsSubmissionExtractionAndFindings(t *testing.T) {
fs := newFakeStore()
s, err := web.NewServer(fakeExtractor{facts: rules.Facts{
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationPaid,
DisclosurePresent: true, DisclosureWording: "Werbung", DisclosureBeforeCut: false,
}, raw: []byte(`{"gegenleistung":"bezahlt"}`)}, loadRealRules(t), fs, fakeTimestamper{}, t.TempDir())
if err != nil {
t.Fatalf("NewServer: %v", err)
}
}, raw: []byte(`{"gegenleistung":"bezahlt"}`)})
w := postForm(t, s, "/pruefen", checkForm())
w := postForm(t, s, cookie, "/pruefen", checkForm())
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", w.Code, w.Body.String())
}
@@ -344,6 +613,9 @@ func TestHandleCheckPersistsSubmissionExtractionAndFindings(t *testing.T) {
if sub.Status != "checked" {
t.Errorf("submission status = %q, want checked", sub.Status)
}
if sub.AccountID == "" {
t.Error("expected the submission to carry the current account's ID")
}
}
if _, ok := fs.extractions[subID]; !ok {
t.Error("expected an extraction to be persisted")
@@ -354,15 +626,11 @@ func TestHandleCheckPersistsSubmissionExtractionAndFindings(t *testing.T) {
}
func TestHandleCheckRendersNeedsClarificationAndDoesNotOfferArchive(t *testing.T) {
fs := newFakeStore()
s, err := web.NewServer(fakeExtractor{facts: rules.Facts{
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationUnclear,
}}, loadRealRules(t), fs, fakeTimestamper{}, t.TempDir())
if err != nil {
t.Fatalf("NewServer: %v", err)
}
}})
w := postForm(t, s, "/pruefen", checkForm())
w := postForm(t, s, cookie, "/pruefen", checkForm())
body := w.Body.String()
if strings.Contains(body, "WK-") {
t.Errorf("expected no rule findings for an unclear extraction, got: %s", body)
@@ -370,7 +638,7 @@ func TestHandleCheckRendersNeedsClarificationAndDoesNotOfferArchive(t *testing.T
if !strings.Contains(body, "nicht sicher bestimmt") {
t.Errorf("expected a clarification message, got: %s", body)
}
if strings.Contains(body, "veroeffentlichen") || strings.Contains(body, "/veroeffentlichen") {
if strings.Contains(body, "/veroeffentlichen") {
t.Errorf("expected no archive option when clarification is needed, got: %s", body)
}
for _, sub := range fs.submissions {
@@ -380,18 +648,36 @@ func TestHandleCheckRendersNeedsClarificationAndDoesNotOfferArchive(t *testing.T
}
}
func TestHandleCheckCleanCaseHasNoFindingsAndOffersArchive(t *testing.T) {
s, _, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}})
w := postForm(t, s, cookie, "/pruefen", checkForm())
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
if strings.Contains(body, "WK-") {
t.Errorf("expected no findings for an organic post, got: %s", body)
}
if !strings.Contains(body, "Keine Kennzeichnungsrisiken") {
t.Errorf("expected the no-findings message, got: %s", body)
}
if !strings.Contains(body, "/veroeffentlichen") {
t.Errorf("expected an archive option even with no findings (still a real check), got: %s", body)
}
}
// ─── Tests: Archivierung + Mandantentrennung ──────────────────────────
func TestFullCheckThenArchiveFlow(t *testing.T) {
fs := newFakeStore()
s, err := web.NewServer(fakeExtractor{facts: rules.Facts{
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationPaid,
DisclosurePresent: true, DisclosureWording: "Werbung", DisclosureBeforeCut: false,
}, raw: []byte(`{"gegenleistung":"bezahlt","kennzeichnung_vorhanden":true,"kennzeichnung_wortlaut":"Werbung","kennzeichnung_vor_kuerzung":false}`)},
loadRealRules(t), fs, fakeTimestamper{}, t.TempDir())
if err != nil {
t.Fatalf("NewServer: %v", err)
}
}, raw: []byte(`{"gegenleistung":"bezahlt","kennzeichnung_vorhanden":true,"kennzeichnung_wortlaut":"Werbung","kennzeichnung_vor_kuerzung":false}`)})
checkResp := postForm(t, s, "/pruefen", checkForm())
checkResp := postForm(t, s, cookie, "/pruefen", checkForm())
if checkResp.Code != http.StatusOK {
t.Fatalf("check status = %d, body: %s", checkResp.Code, checkResp.Body.String())
}
@@ -404,7 +690,7 @@ func TestFullCheckThenArchiveFlow(t *testing.T) {
subID = id
}
archiveResp := postForm(t, s, "/veroeffentlichen", url.Values{"submission_id": {subID}})
archiveResp := postForm(t, s, cookie, "/veroeffentlichen", url.Values{"submission_id": {subID}})
if archiveResp.Code != http.StatusOK {
t.Fatalf("archive status = %d, body: %s", archiveResp.Code, archiveResp.Body.String())
}
@@ -421,6 +707,7 @@ func TestFullCheckThenArchiveFlow(t *testing.T) {
}
downloadReq := httptest.NewRequest(http.MethodGet, "/dossier/"+subID, nil)
downloadReq.AddCookie(cookie)
downloadReq.SetPathValue("id", subID)
downloadW := httptest.NewRecorder()
s.ServeHTTP(downloadW, downloadReq)
@@ -437,31 +724,57 @@ func TestFullCheckThenArchiveFlow(t *testing.T) {
}
func TestHandleArchiveRejectsUnknownSubmission(t *testing.T) {
s := newTestServer(t, fakeExtractor{})
s, _, cookie := newAuthedTestServer(t, fakeExtractor{})
w := postForm(t, s, "/veroeffentlichen", url.Values{"submission_id": {"does-not-exist"}})
w := postForm(t, s, cookie, "/veroeffentlichen", url.Values{"submission_id": {"does-not-exist"}})
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for an unknown submission", w.Code)
}
}
func TestHandleCheckCleanCaseHasNoFindingsAndOffersArchive(t *testing.T) {
s := newTestServer(t, fakeExtractor{facts: rules.Facts{
func TestTenantIsolationArchiveAndDownload(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}})
}, raw: []byte(`{"gegenleistung":"keine","kennzeichnung_vorhanden":false,"kennzeichnung_wortlaut":"","kennzeichnung_vor_kuerzung":false}`)}, fs)
w := postForm(t, s, "/pruefen", checkForm())
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
checkResp := postForm(t, s, cookieA, "/pruefen", checkForm())
if checkResp.Code != http.StatusOK {
t.Fatalf("check status = %d", checkResp.Code)
}
body := w.Body.String()
if strings.Contains(body, "WK-") {
t.Errorf("expected no findings for an organic post, got: %s", body)
var subID string
for id, sub := range fs.submissions {
if sub.AccountID != "" {
subID = id
}
}
if !strings.Contains(body, "Keine Kennzeichnungsrisiken") {
t.Errorf("expected the no-findings message, got: %s", body)
if subID == "" {
t.Fatal("expected a submission to have been created for Mandant A")
}
if !strings.Contains(body, "/veroeffentlichen") {
t.Errorf("expected an archive option even with no findings (still a real check), got: %s", body)
// Mandant B darf As Beitrag weder archivieren noch dessen Dossier
// abrufen — beides muss wie "nicht gefunden" aussehen, nicht wie ein
// expliziter Zugriffsfehler.
archiveResp := postForm(t, s, cookieB, "/veroeffentlichen", url.Values{"submission_id": {subID}})
if archiveResp.Code != http.StatusNotFound {
t.Fatalf("cross-tenant archive status = %d, want 404", archiveResp.Code)
}
// Damit A tatsächlich etwas zum Herunterladen hat: A archiviert selbst.
ownArchiveResp := postForm(t, s, cookieA, "/veroeffentlichen", url.Values{"submission_id": {subID}})
if ownArchiveResp.Code != http.StatusOK {
t.Fatalf("own archive status = %d, body: %s", ownArchiveResp.Code, ownArchiveResp.Body.String())
}
downloadReq := httptest.NewRequest(http.MethodGet, "/dossier/"+subID, nil)
downloadReq.AddCookie(cookieB)
downloadReq.SetPathValue("id", subID)
downloadW := httptest.NewRecorder()
s.ServeHTTP(downloadW, downloadReq)
if downloadW.Code != http.StatusNotFound {
t.Fatalf("cross-tenant download status = %d, want 404", downloadW.Code)
}
}