munin/internal/web/web_test.go

2663 lines
95 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package web
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"log"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"munin/internal/auth"
"munin/internal/export"
"munin/internal/store"
"munin/internal/workspace"
)
const testSecret = "ein-test-secret-das-lang-genug-ist-fuer-tests"
const testAdminToken = "test-admin-token-lang-genug"
// testServer baut einen vollständigen Server auf einer frischen Datenbank.
func testServer(t *testing.T) (*Server, *store.Store, store.Project) {
t.Helper()
tempDir := t.TempDir()
control, err := store.OpenControl(filepath.Join(tempDir, "control.db"))
if err != nil {
t.Fatalf("Kontroll-Datenbank: %v", err)
}
t.Cleanup(func() { control.Close() })
workspacesDir := filepath.Join(tempDir, "workspaces")
if err := os.MkdirAll(workspacesDir, 0750); err != nil {
t.Fatal(err)
}
workspaces := workspace.NewManager(workspacesDir)
t.Cleanup(func() { workspaces.Close() })
ctx := context.Background()
ws, err := control.EnsureDefaultWorkspace(ctx, "Test")
if err != nil {
t.Fatal(err)
}
w, err := workspaces.Get(ws.ID)
if err != nil {
t.Fatalf("Workspace: %v", err)
}
db := w.Store
projekt, err := db.CreateProject(ctx, ws.ID, "testprojekt", "Testprojekt", "")
if err != nil {
t.Fatal(err)
}
// os.DirFS("../..") zeigt vom Package-Verzeichnis auf die Projektwurzel,
// wo templates/ liegt.
srv, err := NewServer(Config{
Workspaces: workspaces,
Control: control,
Signer: auth.NewSigner([]byte(testSecret)),
Cookies: auth.NewCookiePolicy("false"),
Limiter: auth.NewRateLimiter(),
Templates: templatesForTest(t),
AdminToken: testAdminToken,
WriteBudget: auth.NewWriteBudget(1000, time.Hour),
})
if err != nil {
t.Fatalf("Server: %v", err)
}
return srv, db, projekt
}
// templatesForTest liefert das Template-Verzeichnis der Projektwurzel.
func templatesForTest(t *testing.T) fs.FS {
t.Helper()
return os.DirFS("../..")
}
// testHandler baut den vollständigen Router — so wie main.go es tut.
func testHandler(t *testing.T, srv *Server) http.Handler {
t.Helper()
mux := http.NewServeMux()
srv.Routes(mux, os.DirFS("../../static"))
return mux
}
// erzeugeKey legt einen API-Key an und gibt den Klartext zurück.
//
// Trägt den Key auch im Routing-Index der Kontroll-Datenbank ein — ohne den
// würden Login und APIAuth ihn nicht mehr finden (siehe ControlStore.IndexKey
// und die entsprechenden Aufrufe in handlers_admin.go).
func erzeugeKey(t *testing.T, srv *Server, db *store.Store, projectID, name string, rolle auth.Role) string {
t.Helper()
klartext, err := auth.GenerateKey()
if err != nil {
t.Fatal(err)
}
hash := auth.HashKey(klartext)
if _, err := db.CreateAPIKey(context.Background(), projectID, name, hash, string(rolle)); err != nil {
t.Fatal(err)
}
projekt, err := db.ProjectByID(context.Background(), projectID)
if err != nil {
t.Fatal(err)
}
if err := srv.control.IndexKey(context.Background(), hash, projekt.WorkspaceID); err != nil {
t.Fatal(err)
}
return klartext
}
// plattformSubjekt gibt das Session-Subject des Plattform-Admins zurück, so wie
// der Testserver es nach der Anmeldung mit testAdminToken vergibt — gebraucht,
// um passende CSRF-Tokens zu berechnen.
func plattformSubjekt(workspaceID string) string {
signer := auth.NewSigner([]byte(testSecret))
return auth.PlatformSubject(signer.Fingerprint(auth.HashKey(testAdminToken)), workspaceID)
}
// waehleWorkspace wählt für eine Plattform-Admin-Sitzung einen Workspace und
// gibt den neuen Session-Cookie zurück — der Schritt, den handleWorkspaceList
// in der Oberfläche anbietet (siehe handlers_workspaces.go).
func waehleWorkspace(t *testing.T, h http.Handler, cookie *http.Cookie, workspaceID string) *http.Cookie {
t.Helper()
token := auth.NewSigner([]byte(testSecret)).CSRFToken(plattformSubjekt(""))
formular := url.Values{"id": {workspaceID}, auth.CSRFFieldName: {token}}
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/select", strings.NewReader(formular.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
for _, c := range w.Result().Cookies() {
if c.Name == auth.SessionCookieName && c.Value != "" {
return c
}
}
t.Fatalf("Workspace-Auswahl lieferte keinen Cookie (Status %d)", w.Code)
return nil
}
// anmeldenAlsPlattformAdmin meldet den Plattform-Admin an UND wählt gleich den
// gegebenen Workspace — die meisten Tests brauchen genau das, nicht den
// Zwischenschritt über die Auswahlseite.
func anmeldenAlsPlattformAdmin(t *testing.T, h http.Handler, workspaceID string) *http.Cookie {
t.Helper()
cookie := anmelden(t, h, testAdminToken)
return waehleWorkspace(t, h, cookie, workspaceID)
}
// anmelden führt eine Anmeldung durch und gibt den Session-Cookie zurück.
func anmelden(t *testing.T, h http.Handler, key string) *http.Cookie {
t.Helper()
formular := url.Values{"key": {key}}
r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(formular.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
for _, c := range w.Result().Cookies() {
if c.Name == auth.SessionCookieName && c.Value != "" {
return c
}
}
t.Fatalf("Anmeldung lieferte keinen Cookie (Status %d)", w.Code)
return nil
}
// ── Anmeldung ────────────────────────────────────────────────────────────────
func TestLoginRequiredForEverything(t *testing.T) {
srv, _, _ := testServer(t)
h := testHandler(t, srv)
geschuetzt := []string{"/", "/admin/projects", "/p/testprojekt/", "/p/testprojekt/keys"}
for _, pfad := range geschuetzt {
t.Run(pfad, func(t *testing.T) {
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, pfad, nil))
if w.Code != http.StatusSeeOther {
t.Errorf("Status = %d, erwartet %d (Weiterleitung zur Anmeldung)", w.Code, http.StatusSeeOther)
}
})
}
}
func TestLoginWithProjectKey(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
key := erzeugeKey(t, srv, db, projekt.ID, "test", auth.RoleWrite)
cookie := anmelden(t, h, key)
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Errorf("Status = %d, erwartet 200", w.Code)
}
}
func TestLoginRejectsBadKey(t *testing.T) {
srv, _, _ := testServer(t)
h := testHandler(t, srv)
formular := url.Values{"key": {"wk_" + strings.Repeat("0", 64)}}
r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(formular.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("Status = %d, erwartet %d", w.Code, http.StatusUnauthorized)
}
for _, c := range w.Result().Cookies() {
if c.Name == auth.SessionCookieName && c.Value != "" {
t.Error("trotz falschem Key wurde eine Sitzung ausgestellt")
}
}
}
// TestRevokedKeyEndsSession prüft, dass ein zurückgezogener Key sofort wirkt —
// die Sitzung darf ihn nicht überleben.
func TestRevokedKeyEndsSession(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
key := erzeugeKey(t, srv, db, projekt.ID, "test", auth.RoleWrite)
cookie := anmelden(t, h, key)
keys, _ := db.ListAPIKeys(ctx, projekt.ID)
if err := db.RevokeAPIKey(ctx, keys[0].ID); err != nil {
t.Fatal(err)
}
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusSeeOther {
t.Errorf("Status = %d, erwartet Weiterleitung zur Anmeldung — die Sitzung galt weiter", w.Code)
}
}
// ── CSRF ─────────────────────────────────────────────────────────────────────
// TestCSRFBlocksForgedPost bildet den Angriff nach: Eine fremde Seite schickt ein
// POST, der Browser hängt den Session-Cookie automatisch an — nur das Token kann
// sie nicht liefern.
func TestCSRFBlocksForgedPost(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
key := erzeugeKey(t, srv, db, projekt.ID, "test", auth.RoleAdmin)
cookie := anmelden(t, h, key)
formular := url.Values{"name": {"geklaut"}, "role": {"admin"}}
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/keys/create", strings.NewReader(formular.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusForbidden {
t.Errorf("Status = %d, erwartet %d", w.Code, http.StatusForbidden)
}
keys, _ := db.ListAPIKeys(context.Background(), projekt.ID)
if len(keys) != 1 {
t.Errorf("%d Keys — es wurde trotz fehlendem Token einer angelegt", len(keys))
}
}
func TestCSRFAllowsRealForm(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
key := erzeugeKey(t, srv, db, projekt.ID, "test", auth.RoleAdmin)
cookie := anmelden(t, h, key)
// Das Token so berechnen, wie es das Template tut.
keys, _ := db.ListAPIKeys(context.Background(), projekt.ID)
subject := auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keys[0].ID
token := auth.NewSigner([]byte(testSecret)).CSRFToken(subject)
formular := url.Values{"name": {"zweiter"}, "role": {"read"}, auth.CSRFFieldName: {token}}
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/keys/create", strings.NewReader(formular.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("Status = %d, erwartet 200", w.Code)
}
neue, _ := db.ListAPIKeys(context.Background(), projekt.ID)
if len(neue) != 2 {
t.Errorf("%d Keys, erwartet 2", len(neue))
}
// Der Klartext des neuen Keys muss genau einmal in der Antwort stehen.
if !strings.Contains(w.Body.String(), auth.KeyPrefix) {
t.Error("der neue Key wurde nicht angezeigt")
}
// Die Konfigurationsblöcke tragen die echte Adresse, keinen Platzhalter
// (AUSBAUSTUFEN.md 5.2). httptest setzt als Host "example.com".
if strings.Contains(w.Body.String(), "DEIN-SERVER") {
t.Error("die Zugangsseite zeigt noch den Platzhalter DEIN-SERVER")
}
if !strings.Contains(w.Body.String(), "https://example.com/mcp") {
t.Error("die Zugangsseite zeigt die Server-Adresse nicht")
}
}
// ── Abschottung ──────────────────────────────────────────────────────────────
// TestProjectIsolation ist der wichtigste Test des Web-Teils: Ein Key kommt
// ausschließlich an sein eigenes Projekt.
func TestProjectIsolation(t *testing.T) {
srv, db, projektA := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
projektB, err := db.CreateProject(ctx, projektA.WorkspaceID, "fremd", "Fremdes Projekt", "")
if err != nil {
t.Fatal(err)
}
// Ein Eintrag, den Projekt A auf keinen Fall sehen darf.
if _, err := db.PutEntry(ctx, store.EntryWrite{
ProjectID: projektB.ID, Slug: "geheim", Title: "Geheim",
Content: "Vertrauliches aus Projekt B", Source: store.SourceHuman,
}); err != nil {
t.Fatal(err)
}
keyA := erzeugeKey(t, srv, db, projektA.ID, "a", auth.RoleAdmin)
cookie := anmelden(t, h, keyA)
// Jede Route des fremden Projekts muss als "nicht vorhanden" antworten —
// nicht als "verboten". Sonst ließe sich über die Statuscodes herausfinden,
// welche Projekte es sonst noch gibt.
fremdeRouten := []string{
"/p/fremd/",
"/p/fremd/e/geheim",
"/p/fremd/keys",
"/p/fremd/audit",
}
for _, pfad := range fremdeRouten {
t.Run(pfad, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, pfad, nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusNotFound {
t.Errorf("Status = %d, erwartet 404", w.Code)
}
if strings.Contains(w.Body.String(), "Vertrauliches") {
t.Error("Inhalt des fremden Projekts war sichtbar")
}
})
}
}
// TestWorkspaceIsolation ist der wichtigste Test dieser Ausbaustufe: Zwei
// gleichzeitig offene Workspace-Dateien im selben Prozess dürfen sich
// strukturell nicht sehen — auch nicht über einen fremden, aber gültigen Key.
func TestWorkspaceIsolation(t *testing.T) {
srv, _, projektA := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
// Ein zweiter Workspace, komplett unabhängig vom ersten — eigene Datei,
// eigenes Projekt, eigener Key.
wsB, err := srv.control.CreateWorkspace(ctx, "Andere Abteilung")
if err != nil {
t.Fatal(err)
}
workspaceB, err := srv.workspaces.Get(wsB.ID)
if err != nil {
t.Fatal(err)
}
projektB, err := workspaceB.Store.CreateProject(ctx, wsB.ID, "fremd", "Fremdes Projekt", "")
if err != nil {
t.Fatal(err)
}
if _, err := workspaceB.Store.PutEntry(ctx, store.EntryWrite{
ProjectID: projektB.ID, Slug: "geheim", Title: "Geheim",
Content: "Vertrauliches aus Workspace B", Source: store.SourceHuman,
}); err != nil {
t.Fatal(err)
}
keyB := erzeugeKey(t, srv, workspaceB.Store, projektB.ID, "b", auth.RoleAdmin)
// Mit dem Key aus Workspace B angemeldet: Projekt A (Workspace A) darf
// unter keinen Umständen erreichbar sein, obwohl der Prozess beide
// Dateien gleichzeitig offen hält.
cookie := anmelden(t, h, keyB)
r := httptest.NewRequest(http.MethodGet, "/p/"+projektA.Slug+"/", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusNotFound {
t.Errorf("Status = %d, erwartet 404 — Workspace B kam an ein Projekt aus Workspace A", w.Code)
}
}
// TestWorkspaceCreateSelectsItAndRedirects prüft den Weg aus der Oberfläche:
// Ein neuer Workspace wird angelegt, die Sitzung wechselt automatisch dorthin,
// und der Admin landet direkt bei der Projektverwaltung — ohne den Umweg über
// die Auswahlseite.
func TestWorkspaceCreateSelectsItAndRedirects(t *testing.T) {
srv, _, projekt := testServer(t)
h := testHandler(t, srv)
cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID)
token := auth.NewSigner([]byte(testSecret)).
CSRFToken(plattformSubjekt(projekt.WorkspaceID))
formular := url.Values{"name": {"Marketing"}, auth.CSRFFieldName: {token}}
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/create", strings.NewReader(formular.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/admin/projects" {
t.Fatalf("Status = %d, Location = %q — erwartet Weiterleitung zu /admin/projects",
w.Code, w.Header().Get("Location"))
}
workspaces, err := srv.control.ListWorkspaces(context.Background())
if err != nil {
t.Fatal(err)
}
var gefunden bool
for _, ws := range workspaces {
if ws.Name == "Marketing" {
gefunden = true
}
}
if !gefunden {
t.Error("neuer Workspace \"Marketing\" steht nicht in der Kontroll-Datenbank")
}
// Die neue Sitzung muss den neuen Workspace tragen — ein Aufruf von
// /admin/projects mit dem zurückgegebenen Cookie darf nicht mehr zur
// Auswahlseite zurückschicken.
var neuerCookie *http.Cookie
for _, c := range w.Result().Cookies() {
if c.Name == auth.SessionCookieName {
neuerCookie = c
}
}
if neuerCookie == nil {
t.Fatal("keine neue Sitzung nach dem Anlegen ausgestellt")
}
r2 := httptest.NewRequest(http.MethodGet, "/admin/projects", nil)
r2.AddCookie(neuerCookie)
w2 := httptest.NewRecorder()
h.ServeHTTP(w2, r2)
if w2.Code != http.StatusOK {
t.Errorf("Status = %d, erwartet 200 — die neue Sitzung landete nicht im neuen Workspace", w2.Code)
}
}
// TestWorkspaceListShowsProjectCount prüft, dass die Übersicht die
// tatsächliche Projektzahl je Workspace zeigt, nicht nur Name und Datum.
func TestWorkspaceListShowsProjectCount(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
if _, err := db.CreateProject(context.Background(), projekt.WorkspaceID, "zweites", "Zweites", ""); err != nil {
t.Fatal(err)
}
cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID)
r := httptest.NewRequest(http.MethodGet, "/admin/workspaces", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("Status = %d, erwartet 200", w.Code)
}
if !strings.Contains(w.Body.String(), "<td class=\"zahl\">2</td>") {
t.Error("Projektzahl \"2\" steht nicht in der Übersicht")
}
}
// TestWorkspaceRename prüft das Umbenennen über /admin/workspaces/{id}/edit.
func TestWorkspaceRename(t *testing.T) {
srv, _, projekt := testServer(t)
h := testHandler(t, srv)
cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID)
token := auth.NewSigner([]byte(testSecret)).
CSRFToken(plattformSubjekt(projekt.WorkspaceID))
formular := url.Values{"name": {"Umbenannt"}, auth.CSRFFieldName: {token}}
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/"+projekt.WorkspaceID+"/edit",
strings.NewReader(formular.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusSeeOther {
t.Fatalf("Status = %d, erwartet %d", w.Code, http.StatusSeeOther)
}
ws, err := srv.control.WorkspaceByID(context.Background(), projekt.WorkspaceID)
if err != nil {
t.Fatal(err)
}
if ws.Name != "Umbenannt" {
t.Errorf("Name = %q, erwartet \"Umbenannt\"", ws.Name)
}
}
// TestWorkspaceDeleteRequiresConfirmation prüft die Bestätigungsabfrage — ein
// Klick allein darf nicht reichen, dahinter verschwindet alles im Workspace.
func TestWorkspaceDeleteRequiresConfirmation(t *testing.T) {
srv, _, projekt := testServer(t)
h := testHandler(t, srv)
cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID)
token := auth.NewSigner([]byte(testSecret)).
CSRFToken(plattformSubjekt(projekt.WorkspaceID))
formular := url.Values{"id": {projekt.WorkspaceID}, "confirm": {"falsch"}, auth.CSRFFieldName: {token}}
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/delete", strings.NewReader(formular.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("Status = %d, erwartet %d", w.Code, http.StatusBadRequest)
}
if _, err := srv.control.WorkspaceByID(context.Background(), projekt.WorkspaceID); err != nil {
t.Error("Workspace wurde trotz falscher Bestätigung gelöscht")
}
}
// TestWorkspaceDeleteRemovesEverythingAndResetsSession ist der wichtigste
// Test hier: Löschen entfernt Kontroll-Eintrag UND Datei, und eine Sitzung im
// gelöschten Workspace wird auf "kein Workspace gewählt" zurückgesetzt statt
// den Admin einfach auszusperren.
func TestWorkspaceDeleteRemovesEverythingAndResetsSession(t *testing.T) {
srv, _, projekt := testServer(t)
h := testHandler(t, srv)
ws, err := srv.control.WorkspaceByID(context.Background(), projekt.WorkspaceID)
if err != nil {
t.Fatal(err)
}
cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID)
token := auth.NewSigner([]byte(testSecret)).
CSRFToken(plattformSubjekt(projekt.WorkspaceID))
formular := url.Values{"id": {projekt.WorkspaceID}, "confirm": {ws.Name}, auth.CSRFFieldName: {token}}
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/delete", strings.NewReader(formular.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusSeeOther {
t.Fatalf("Status = %d, erwartet %d", w.Code, http.StatusSeeOther)
}
if _, err := srv.control.WorkspaceByID(context.Background(), projekt.WorkspaceID); !errors.Is(err, store.ErrNotFound) {
t.Errorf("Workspace steht nach dem Löschen noch in der Kontroll-Datenbank (err=%v)", err)
}
// Die neue Sitzung ist die des Admins ohne Workspace — /admin/projects
// muss jetzt zur Auswahl zurückschicken statt eine tote Sitzung zu zeigen.
var neuerCookie *http.Cookie
for _, c := range w.Result().Cookies() {
if c.Name == auth.SessionCookieName {
neuerCookie = c
}
}
if neuerCookie == nil {
t.Fatal("keine neue Sitzung nach dem Löschen ausgestellt")
}
r2 := httptest.NewRequest(http.MethodGet, "/admin/projects", nil)
r2.AddCookie(neuerCookie)
w2 := httptest.NewRecorder()
h.ServeHTTP(w2, r2)
if w2.Code != http.StatusSeeOther || w2.Header().Get("Location") != "/admin/workspaces" {
t.Errorf("Status = %d, Location = %q — erwartet Weiterleitung zur Workspace-Auswahl",
w2.Code, w2.Header().Get("Location"))
}
}
// TestPlatformAdminOnly prüft, dass die Projektverwaltung dem Plattform-Admin
// vorbehalten bleibt.
func TestPlatformAdminOnly(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
key := erzeugeKey(t, srv, db, projekt.ID, "test", auth.RoleAdmin)
cookie := anmelden(t, h, key)
r := httptest.NewRequest(http.MethodGet, "/admin/projects", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusForbidden {
t.Errorf("Status = %d, erwartet %d — ein Projekt-Admin kam an die Projektverwaltung", w.Code, http.StatusForbidden)
}
}
func TestPlatformAdminCanAccessAnyProject(t *testing.T) {
srv, _, projekt := testServer(t)
h := testHandler(t, srv)
cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID)
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Errorf("Status = %d, erwartet 200", w.Code)
}
}
// statusMitCookie ruft eine Seite mit dem gegebenen Cookie ab und gibt den
// Status zurück.
func statusMitCookie(h http.Handler, pfad string, cookie *http.Cookie) int {
r := httptest.NewRequest(http.MethodGet, pfad, nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w.Code
}
// TestNeuesAdminTokenBeendetAdminSitzungen stellt den Ernstfall nach: Das
// ADMIN_TOKEN ist geleakt und wird ersetzt, der Server neu gestartet. Wer sich
// mit dem alten Token angemeldet hat, muss sofort draußen sein — Teams mit
// ihren Keys dürfen davon nichts merken.
func TestNeuesAdminTokenBeendetAdminSitzungen(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
ohneWorkspace := anmelden(t, h, testAdminToken)
mitWorkspace := waehleWorkspace(t, h, ohneWorkspace, projekt.WorkspaceID)
team := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "team", auth.RoleAdmin))
if code := statusMitCookie(h, "/p/testprojekt/", mitWorkspace); code != http.StatusOK {
t.Fatalf("vor dem Wechsel: Status %d, erwartet 200", code)
}
// Neustart mit neuem ADMIN_TOKEN, aber demselben SESSION_SECRET und
// denselben Daten.
const neuesToken = "ein-ganz-neues-admin-token"
neu, err := NewServer(Config{
Workspaces: srv.workspaces,
Control: srv.control,
Signer: srv.signer,
Cookies: srv.cookies,
Limiter: auth.NewRateLimiter(),
Templates: templatesForTest(t),
AdminToken: neuesToken,
WriteBudget: srv.writeBudget,
})
if err != nil {
t.Fatal(err)
}
hNeu := testHandler(t, neu)
for name, cookie := range map[string]*http.Cookie{
"ohne Workspace": ohneWorkspace,
"mit Workspace": mitWorkspace,
} {
code := statusMitCookie(hNeu, "/admin/workspaces", cookie)
if code != http.StatusSeeOther {
t.Errorf("alte Admin-Sitzung %s: Status %d, erwartet Weiterleitung zur Anmeldung", name, code)
}
}
if code := statusMitCookie(hNeu, "/p/testprojekt/", team); code != http.StatusOK {
t.Errorf("Sitzung eines Teams: Status %d, erwartet 200 — ein neues ADMIN_TOKEN darf Teams nicht abmelden", code)
}
frisch := anmelden(t, hNeu, neuesToken)
if code := statusMitCookie(hNeu, "/admin/workspaces", frisch); code != http.StatusOK {
t.Errorf("Anmeldung mit dem neuen Token: Status %d, erwartet 200", code)
}
}
// TestAlteAdminSitzungOhneFingerabdruckGiltNicht: Cookies aus der Zeit vor dem
// Fingerabdruck ("platform", "platform:<id>") sind korrekt signiert, dürfen
// aber nicht mehr gelten — sonst bliebe genau die Lücke offen, die der
// Fingerabdruck schließt.
func TestAlteAdminSitzungOhneFingerabdruckGiltNicht(t *testing.T) {
srv, _, projekt := testServer(t)
h := testHandler(t, srv)
signer := auth.NewSigner([]byte(testSecret))
for _, subject := range []string{"platform", "platform:" + projekt.WorkspaceID} {
cookie := &http.Cookie{Name: auth.SessionCookieName, Value: signer.SignSession(subject)}
if code := statusMitCookie(h, "/admin/workspaces", cookie); code != http.StatusSeeOther {
t.Errorf("%q: Status %d, erwartet Weiterleitung zur Anmeldung", subject, code)
}
}
}
// ── Rollen ───────────────────────────────────────────────────────────────────
// TestReadRoleCannotWrite prüft, dass ein Nur-Lese-Zugang keine Einträge ändern kann.
func TestReadRoleCannotWrite(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
key := erzeugeKey(t, srv, db, projekt.ID, "nur-lesen", auth.RoleRead)
cookie := anmelden(t, h, key)
schreibrouten := []struct{ methode, pfad string }{
{http.MethodGet, "/p/testprojekt/new"},
{http.MethodPost, "/p/testprojekt/save"},
}
for _, tc := range schreibrouten {
t.Run(tc.pfad, func(t *testing.T) {
r := httptest.NewRequest(tc.methode, tc.pfad, strings.NewReader(""))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code == http.StatusOK {
t.Errorf("Status = %d — der Nur-Lese-Zugang kam durch", w.Code)
}
})
}
}
// TestWriteRoleCannotManageKeys prüft die Abgrenzung zwischen write und admin.
func TestWriteRoleCannotManageKeys(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
key := erzeugeKey(t, srv, db, projekt.ID, "schreiber", auth.RoleWrite)
cookie := anmelden(t, h, key)
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/keys", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusForbidden {
t.Errorf("Status = %d, erwartet %d", w.Code, http.StatusForbidden)
}
}
// TestKeyListFreshKeyNotUnused prüft, dass die Zugänge-Seite rendert und ein
// frisch angelegter, noch nie benutzter Key nicht als „ungenutzt“ erscheint.
// Die Grenzfälle der Regel selbst prüft TestAPIKeyUnused im Store.
func TestKeyListFreshKeyNotUnused(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
admin := erzeugeKey(t, srv, db, projekt.ID, "verwalterin", auth.RoleAdmin)
erzeugeKey(t, srv, db, projekt.ID, "neue-pipeline", auth.RoleRead)
cookie := anmelden(t, h, admin)
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/keys", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("Status = %d, erwartet %d", w.Code, http.StatusOK)
}
body := w.Body.String()
if !strings.Contains(body, "neue-pipeline") {
t.Error("der neue Key fehlt in der Liste")
}
if strings.Contains(body, "marke warnung") || strings.Contains(body, "seit über 90 Tagen nicht benutzt") {
t.Error("ein frisch angelegter Key wurde als ungenutzt markiert")
}
}
// ── Sicherheits-Header und Limits ────────────────────────────────────────────
func TestSecurityHeaders(t *testing.T) {
h := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), false)
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil))
erwartet := map[string]string{
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "same-origin",
}
for name, wert := range erwartet {
if got := w.Header().Get(name); got != wert {
t.Errorf("%s = %q, erwartet %q", name, got, wert)
}
}
csp := w.Header().Get("Content-Security-Policy")
for _, teil := range []string{"default-src 'self'", "frame-ancestors 'none'", "object-src 'none'"} {
if !strings.Contains(csp, teil) {
t.Errorf("CSP enthält %q nicht", teil)
}
}
// Ohne den Schalter darf HSTS nicht gesetzt sein — sonst sperrt man sich auf
// einer Adresse ohne Zertifikat selbst aus.
if w.Header().Get("Strict-Transport-Security") != "" {
t.Error("HSTS wurde gesetzt, obwohl abgeschaltet")
}
}
func TestLimitBody(t *testing.T) {
var leseFehler error
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
buf := make([]byte, 512)
for {
if _, err := r.Body.Read(buf); err != nil {
if err.Error() != "EOF" {
leseFehler = err
}
return
}
}
})
h := LimitBody(inner, 100)
h.ServeHTTP(httptest.NewRecorder(),
httptest.NewRequest(http.MethodPost, "/", strings.NewReader(strings.Repeat("x", 5000))))
if leseFehler == nil {
t.Error("ein zu großer Body wurde ohne Fehler durchgelassen")
}
}
// ── API-Zugang ───────────────────────────────────────────────────────────────
func TestAPIAuthRejectsBadKey(t *testing.T) {
srv, _, _ := testServer(t)
var erreicht bool
h := srv.APIAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { erreicht = true }))
faelle := map[string]string{
"ohne Header": "",
"falsches Schema": "Basic abc",
"unbekannter Key": "Bearer wk_" + strings.Repeat("0", 64),
"kaputte Form": "Bearer nonsens",
}
for name, header := range faelle {
t.Run(name, func(t *testing.T) {
erreicht = false
r := httptest.NewRequest(http.MethodPost, "/mcp", nil)
if header != "" {
r.Header.Set("Authorization", header)
}
r.RemoteAddr = "203.0.113.1:1234"
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if erreicht {
t.Error("der Request kam durch")
}
if w.Code != http.StatusUnauthorized && w.Code != http.StatusTooManyRequests {
t.Errorf("Status = %d, erwartet 401", w.Code)
}
})
}
}
func TestAPIAuthAcceptsValidKey(t *testing.T) {
srv, db, projekt := testServer(t)
key := erzeugeKey(t, srv, db, projekt.ID, "agent", auth.RoleWrite)
var gesehen store.Project
h := srv.APIAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gesehen, _ = ProjectFrom(r)
}))
r := httptest.NewRequest(http.MethodPost, "/mcp", nil)
r.Header.Set("Authorization", "Bearer "+key)
h.ServeHTTP(httptest.NewRecorder(), r)
if gesehen.ID != projekt.ID {
t.Error("das Projekt wurde nicht im Context abgelegt")
}
}
// TestAPIAuthValidKeyBypassesRateLimit prüft, dass die IP-Sperre nur
// Fehlversuche trifft — sonst würde ein falsch konfigurierter Client alle
// anderen hinter derselben Adresse mitsperren.
func TestAPIAuthValidKeyBypassesRateLimit(t *testing.T) {
srv, db, projekt := testServer(t)
key := erzeugeKey(t, srv, db, projekt.ID, "agent", auth.RoleWrite)
var erreicht bool
h := srv.APIAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { erreicht = true }))
// Erst die Adresse in die Sperre laufen lassen …
for i := 0; i < 15; i++ {
r := httptest.NewRequest(http.MethodPost, "/mcp", nil)
r.Header.Set("Authorization", "Bearer wk_"+strings.Repeat("0", 64))
r.RemoteAddr = "203.0.113.2:1234"
h.ServeHTTP(httptest.NewRecorder(), r)
}
// … und dann mit dem richtigen Key von derselben Adresse kommen.
r := httptest.NewRequest(http.MethodPost, "/mcp", nil)
r.Header.Set("Authorization", "Bearer "+key)
r.RemoteAddr = "203.0.113.2:1234"
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if !erreicht {
t.Errorf("gültiger Key wurde wegen der IP-Sperre abgewiesen (Status %d)", w.Code)
}
}
// ── Einträge über die Weboberfläche ──────────────────────────────────────────
func TestEntryCreateAndView(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
key := erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite)
cookie := anmelden(t, h, key)
keys, _ := db.ListAPIKeys(context.Background(), projekt.ID)
token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keys[0].ID)
formular := url.Values{
"title": {"Deployment-Prozess"},
"content": {"Wir deployen über [[server-zugang]]."},
"tags": {"ops, deployment"},
auth.CSRFFieldName: {token},
}
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/save", strings.NewReader(formular.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusSeeOther {
t.Fatalf("Status = %d, erwartet Weiterleitung (%s)", w.Code, w.Body.String())
}
// Der Bezeichner wurde aus dem Titel abgeleitet.
e, err := db.EntryBySlug(context.Background(), projekt.ID, "deployment-prozess")
if err != nil {
t.Fatalf("Eintrag nicht gefunden: %v", err)
}
if e.Source != store.SourceHuman {
t.Errorf("Source = %q, erwartet %q", e.Source, store.SourceHuman)
}
if e.Author != "mensch" {
t.Errorf("Author = %q, erwartet den Key-Namen", e.Author)
}
if len(e.Tags) != 2 {
t.Errorf("Tags = %v, erwartet zwei", e.Tags)
}
// Das Protokoll vermerkt, dass der Eintrag neu angelegt wurde.
ereignisse, err := db.ListAudit(context.Background(), store.AuditFilter{ProjectID: projekt.ID})
if err != nil {
t.Fatal(err)
}
var gefunden bool
for _, ev := range ereignisse {
if ev.Action == store.ActionEntryWrite && ev.EntrySlug == "deployment-prozess" {
gefunden = true
if ev.Detail != store.DetailEntryCreated {
t.Errorf("Detail = %q, erwartet %q", ev.Detail, store.DetailEntryCreated)
}
}
}
if !gefunden {
t.Error("Schreibvorgang fehlt im Protokoll")
}
// Die Detailansicht muss den Verweis als Link darstellen.
rv := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/deployment-prozess", nil)
rv.AddCookie(cookie)
wv := httptest.NewRecorder()
h.ServeHTTP(wv, rv)
if wv.Code != http.StatusOK {
t.Fatalf("Detailansicht: Status %d", wv.Code)
}
if !strings.Contains(wv.Body.String(), "/p/testprojekt/e/server-zugang") {
t.Error("der [[…]]-Verweis wurde nicht in einen Link umgewandelt")
}
}
// TestEntryBulkTags prüft die Sammelbearbeitung: zwei ausgewählte Einträge
// bekommen ein Schlagwort dazu und eines weg, ein dritter, nicht ausgewählter
// bleibt unangetastet.
func TestEntryBulkTags(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
a, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "a", Title: "A",
Tags: []string{"alt"}, Source: store.SourceHuman})
b, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "b", Title: "B",
Tags: []string{"alt"}, Source: store.SourceHuman})
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "c", Title: "C",
Tags: []string{"alt"}, Source: store.SourceHuman})
key := erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite)
cookie := anmelden(t, h, key)
keys, _ := db.ListAPIKeys(ctx, projekt.ID)
token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keys[0].ID)
formular := url.Values{
"ids": {a.ID, b.ID},
"add_tags": {"neu"},
"remove_tags": {"alt"},
auth.CSRFFieldName: {token},
}
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/entries/bulk-tags", strings.NewReader(formular.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusSeeOther {
t.Fatalf("Status = %d, erwartet Weiterleitung (%s)", w.Code, w.Body.String())
}
neuA, _ := db.EntryBySlug(ctx, projekt.ID, "a")
if len(neuA.Tags) != 1 || neuA.Tags[0] != "neu" {
t.Errorf("Tags von A = %v, erwartet [neu]", neuA.Tags)
}
unveraendertC, _ := db.EntryBySlug(ctx, projekt.ID, "c")
if len(unveraendertC.Tags) != 1 || unveraendertC.Tags[0] != "alt" {
t.Errorf("Tags von C = %v — C war nicht ausgewählt", unveraendertC.Tags)
}
}
// sammelLoeschen schickt die Sammelauswahl der Eintragsliste an bulk-delete.
// rolle bestimmt den Key, mit dem angemeldet wird; extra ergänzt das Formular
// (etwa um tag oder q).
func sammelLoeschen(t *testing.T, srv *Server, db *store.Store, projekt store.Project,
rolle auth.Role, ids []string, extra url.Values) *httptest.ResponseRecorder {
t.Helper()
h := testHandler(t, srv)
key := erzeugeKey(t, srv, db, projekt.ID, "mensch-"+string(rolle), rolle)
cookie := anmelden(t, h, key)
keys, _ := db.ListAPIKeys(context.Background(), projekt.ID)
var keyID string
for _, k := range keys {
if k.Name == "mensch-"+string(rolle) {
keyID = k.ID
}
}
token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keyID)
formular := url.Values{"ids": ids, auth.CSRFFieldName: {token}}
for k, v := range extra {
formular[k] = v
}
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/entries/bulk-delete", strings.NewReader(formular.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
// TestEntryBulkDelete: Die ausgewählten Einträge liegen danach im Papierkorb,
// je Eintrag steht eine Zeile im Protokoll, der Rest bleibt — und die
// Weiterleitung führt zurück in dieselbe gefilterte Liste.
func TestEntryBulkDelete(t *testing.T) {
srv, db, projekt := testServer(t)
ctx := context.Background()
a, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "a", Title: "A", Source: store.SourceHuman})
b, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "b", Title: "B", Source: store.SourceHuman})
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "c", Title: "C", Source: store.SourceHuman})
w := sammelLoeschen(t, srv, db, projekt, auth.RoleWrite, []string{a.ID, b.ID}, url.Values{"tag": {"ops"}})
if w.Code != http.StatusSeeOther {
t.Fatalf("Status = %d, erwartet Weiterleitung (%s)", w.Code, w.Body.String())
}
if ziel := w.Header().Get("Location"); !strings.Contains(ziel, "tag=ops") || !strings.Contains(ziel, "hinweis=") {
t.Errorf("Weiterleitung nach %q — Filter oder Hinweis fehlt", ziel)
}
papierkorb, _ := db.ListTrash(ctx, projekt.ID, "")
if len(papierkorb) != 2 {
t.Errorf("%d Einträge im Papierkorb, erwartet 2", len(papierkorb))
}
if _, err := db.EntryBySlug(ctx, projekt.ID, "c"); err != nil {
t.Errorf("C war nicht ausgewählt und ist trotzdem weg: %v", err)
}
ereignisse, _ := db.ListAudit(ctx, store.AuditFilter{ProjectID: projekt.ID})
loeschungen := 0
for _, e := range ereignisse {
if e.Action == store.ActionEntryDelete {
loeschungen++
}
}
if loeschungen != 2 {
t.Errorf("%d Löschungen im Protokoll, erwartet eine je Eintrag (2)", loeschungen)
}
}
// TestEntryBulkDeleteNurMitSchreibrecht: Mit Leserecht wird nichts gelöscht.
func TestEntryBulkDeleteNurMitSchreibrecht(t *testing.T) {
srv, db, projekt := testServer(t)
ctx := context.Background()
a, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "a", Title: "A", Source: store.SourceHuman})
w := sammelLoeschen(t, srv, db, projekt, auth.RoleRead, []string{a.ID}, nil)
if w.Code == http.StatusSeeOther {
t.Errorf("Leserecht wurde weitergeleitet wie nach Erfolg (Status %d)", w.Code)
}
if _, err := db.EntryBySlug(ctx, projekt.ID, "a"); err != nil {
t.Errorf("mit Leserecht wurde gelöscht: %v", err)
}
}
// TestEintragslisteZeigtSammelloeschen: Der Knopf steht mit Rückfrage in der
// Liste — und zwar hinter „Anwenden", damit Enter in einem Schlagwortfeld nie
// löscht.
func TestEintragslisteZeigtSammelloeschen(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
db.PutEntry(context.Background(), store.EntryWrite{ProjectID: projekt.ID, Slug: "a", Title: "A", Source: store.SourceHuman})
schreiber := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite))
body := seiteHolen(h, schreiber, "/p/testprojekt/").Body.String()
anwenden := strings.Index(body, ">Anwenden<")
loeschen := strings.Index(body, `formaction="/p/testprojekt/entries/bulk-delete"`)
if anwenden < 0 || loeschen < 0 {
t.Fatal("Sammelleiste ohne Anwenden oder ohne Löschknopf")
}
if loeschen < anwenden {
t.Error("der Löschknopf steht vor „Anwenden“ — Enter in einem Textfeld würde löschen")
}
if !strings.Contains(body, "30 Tage lang wiederherstellen") {
t.Error("die Rückfrage nennt die Frist des Papierkorbs nicht")
}
leser := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead))
if strings.Contains(seiteHolen(h, leser, "/p/testprojekt/").Body.String(), "bulk-delete") {
t.Error("die Leserolle sieht den Löschknopf")
}
}
// TestEntryVersionsAndDiff prüft den Verlauf und den Vergleich zweier
// Fassungen — inklusive des Falls "Vergleich gegen die aktuelle Fassung",
// wenn es keine spätere abgelöste Fassung mehr gibt.
func TestEntryVersionsAndDiff(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "T",
Content: "Zeile 1\nZeile 2", Source: store.SourceHuman, Author: "tom"})
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "T",
Content: "Zeile 1\nGeändert", Source: store.SourceHuman, Author: "tom"})
key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead)
cookie := anmelden(t, h, key)
// Verlaufsseite: Fassung 1 muss als vergleichbar auftauchen.
rv := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/e/versions", nil)
rv.AddCookie(cookie)
wv := httptest.NewRecorder()
h.ServeHTTP(wv, rv)
if wv.Code != http.StatusOK {
t.Fatalf("Verlauf: Status %d", wv.Code)
}
if !strings.Contains(wv.Body.String(), "/p/testprojekt/e/e/versions/1") {
t.Error("Verlauf verlinkt nicht auf den Vergleich für Fassung 1")
}
// Vergleich Fassung 1 gegen die aktuelle (Fassung 2) — es gibt keine
// weitere abgelöste Fassung dazwischen.
rd := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/e/versions/1", nil)
rd.AddCookie(cookie)
wd := httptest.NewRecorder()
h.ServeHTTP(wd, rd)
if wd.Code != http.StatusOK {
t.Fatalf("Vergleich: Status %d", wd.Code)
}
body := wd.Body.String()
if !strings.Contains(body, "Zeile 2") || !strings.Contains(body, "Geändert") {
t.Error("der Vergleich zeigt weder die entfernte noch die neue Zeile")
}
if !strings.Contains(body, "aktuell") {
t.Error("der Vergleich markiert die aktuelle Fassung nicht als solche")
}
// Eine nicht existierende Fassungsnummer muss 404 liefern, nicht
// abstürzen.
rf := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/e/versions/99", nil)
rf.AddCookie(cookie)
wf := httptest.NewRecorder()
h.ServeHTTP(wf, rf)
if wf.Code != http.StatusNotFound {
t.Errorf("unbekannte Fassung: Status %d, erwartet 404", wf.Code)
}
}
// wiederherstellenAnfrage baut den POST auf den Wiederherstellen-Knopf —
// so, wie ihn das Formular in entry-diff.html abschickt.
func wiederherstellenAnfrage(t *testing.T, db *store.Store, projekt store.Project, cookie *http.Cookie, fassung, aktuell string) *http.Request {
t.Helper()
keys, _ := db.ListAPIKeys(context.Background(), projekt.ID)
token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keys[0].ID)
formular := url.Values{"current_version": {aktuell}, auth.CSRFFieldName: {token}}
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/e/e/versions/"+fassung+"/restore",
strings.NewReader(formular.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
return r
}
// TestEntryRestore: Eine alte Fassung zurückholen legt eine NEUE Fassung mit
// dem alten Inhalt an — der bisherige Stand wandert in den Verlauf, und das
// Protokoll vermerkt, woher der Inhalt kam.
func TestEntryRestore(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "Alt",
Content: "guter Text", Tags: []string{"a"}, Source: store.SourceHuman, Author: "tom"})
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "Kaputt",
Content: "von einem Agenten zerstört", Tags: []string{"b"}, Source: store.SourceAgent, Author: "agent"})
key := erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite)
cookie := anmelden(t, h, key)
// Die Vergleichsseite zeigt den Knopf für Schreibberechtigte.
rd := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/e/versions/1", nil)
rd.AddCookie(cookie)
wd := httptest.NewRecorder()
h.ServeHTTP(wd, rd)
if !strings.Contains(wd.Body.String(), "/p/testprojekt/e/e/versions/1/restore") {
t.Error("die Vergleichsseite bietet keine Wiederherstellung an")
}
w := httptest.NewRecorder()
h.ServeHTTP(w, wiederherstellenAnfrage(t, db, projekt, cookie, "1", "2"))
if w.Code != http.StatusSeeOther {
t.Fatalf("Status = %d, erwartet 303 (%s)", w.Code, w.Body.String())
}
e, err := db.EntryBySlug(ctx, projekt.ID, "e")
if err != nil {
t.Fatal(err)
}
if e.Version != 3 {
t.Errorf("Fassung %d, erwartet 3 — Wiederherstellen muss eine neue Fassung anlegen", e.Version)
}
if e.Title != "Alt" || e.Content != "guter Text" {
t.Errorf("Inhalt nicht wiederhergestellt: %q / %q", e.Title, e.Content)
}
if len(e.Tags) != 1 || e.Tags[0] != "a" {
t.Errorf("Schlagworte = %v, erwartet [a]", e.Tags)
}
// Der verdrängte Stand ist nicht verloren, sondern jetzt Fassung 2 im Verlauf.
zwei, err := db.EntryVersionByNumber(ctx, e.ID, 2)
if err != nil || zwei.Content != "von einem Agenten zerstört" {
t.Errorf("verdrängte Fassung fehlt im Verlauf: %v / %q", err, zwei.Content)
}
ereignisse, _ := db.ListAudit(ctx, store.AuditFilter{ProjectID: projekt.ID})
gefunden := false
for _, ev := range ereignisse {
if ev.Action == store.ActionEntryWrite && ev.Detail == store.EntryRestoreDetail(1) {
gefunden = true
}
}
if !gefunden {
t.Error("das Protokoll vermerkt die Wiederherstellung nicht")
}
}
// TestEntryRestoreConflict: Hat sich der Eintrag seit dem Anzeigen geändert,
// wird nichts überschrieben.
func TestEntryRestoreConflict(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
for _, inhalt := range []string{"eins", "zwei", "drei"} {
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "T",
Content: inhalt, Source: store.SourceHuman})
}
key := erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite)
cookie := anmelden(t, h, key)
// Die Seite wurde bei Fassung 2 angezeigt, inzwischen gilt Fassung 3.
w := httptest.NewRecorder()
h.ServeHTTP(w, wiederherstellenAnfrage(t, db, projekt, cookie, "1", "2"))
if w.Code != http.StatusConflict {
t.Fatalf("Status = %d, erwartet 409", w.Code)
}
e, _ := db.EntryBySlug(ctx, projekt.ID, "e")
if e.Version != 3 || e.Content != "drei" {
t.Errorf("Eintrag wurde trotz Konflikt verändert: Fassung %d, %q", e.Version, e.Content)
}
// Ohne Fassungsangabe wird ebenfalls abgelehnt, statt blind zu schreiben.
w2 := httptest.NewRecorder()
h.ServeHTTP(w2, wiederherstellenAnfrage(t, db, projekt, cookie, "1", ""))
if w2.Code != http.StatusBadRequest {
t.Errorf("ohne current_version: Status %d, erwartet 400", w2.Code)
}
}
// TestEntryRestoreNeedsWriteRole: Lesende sehen keinen Knopf und kommen auch
// direkt nicht durch.
func TestEntryRestoreNeedsWriteRole(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "T", Content: "eins", Source: store.SourceHuman})
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "T", Content: "zwei", Source: store.SourceHuman})
key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead)
cookie := anmelden(t, h, key)
rd := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/e/versions/1", nil)
rd.AddCookie(cookie)
wd := httptest.NewRecorder()
h.ServeHTTP(wd, rd)
if strings.Contains(wd.Body.String(), "/restore") {
t.Error("Leserolle bekommt den Wiederherstellen-Knopf angezeigt")
}
w := httptest.NewRecorder()
h.ServeHTTP(w, wiederherstellenAnfrage(t, db, projekt, cookie, "1", "2"))
if w.Code != http.StatusForbidden {
t.Errorf("Status = %d, erwartet 403", w.Code)
}
e, _ := db.EntryBySlug(ctx, projekt.ID, "e")
if e.Content != "zwei" {
t.Error("Leserolle konnte eine Fassung wiederherstellen")
}
}
// TestEntryPreviewRendersMarkdown prüft den Vorschau-Endpunkt: gleicher
// Renderer wie die Detailansicht, per POST ohne dass etwas gespeichert wird.
func TestEntryPreviewRendersMarkdown(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
key := erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite)
cookie := anmelden(t, h, key)
keys, _ := db.ListAPIKeys(context.Background(), projekt.ID)
token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keys[0].ID)
formular := url.Values{
"content": {"# Überschrift\n\nEin Verweis auf [[anderer-eintrag]]."},
auth.CSRFFieldName: {token},
}
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/preview", strings.NewReader(formular.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("Status = %d, erwartet 200 (%s)", w.Code, w.Body.String())
}
body := w.Body.String()
if !strings.Contains(body, "<h1>Überschrift</h1>") {
t.Errorf("Vorschau enthält keine gerenderte Überschrift: %s", body)
}
if !strings.Contains(body, "/p/testprojekt/e/anderer-eintrag") {
t.Error("Vorschau löst den [[…]]-Verweis nicht auf")
}
// Es wurde nichts angelegt — die Vorschau speichert nicht.
anzahl, _ := db.CountEntries(context.Background(), projekt.ID)
if anzahl != 0 {
t.Errorf("%d Einträge nach reiner Vorschau, erwartet 0", anzahl)
}
}
// TestEntryPreviewRequiresWriteRole: ein Nur-Lese-Zugang darf die Vorschau
// nicht aufrufen — konsistent mit dem Bearbeitungsformular selbst.
func TestEntryPreviewRequiresWriteRole(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead)
cookie := anmelden(t, h, key)
keys, _ := db.ListAPIKeys(context.Background(), projekt.ID)
token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keys[0].ID)
formular := url.Values{"content": {"Text"}, auth.CSRFFieldName: {token}}
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/preview", strings.NewReader(formular.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusForbidden {
t.Errorf("Status = %d, erwartet %d", w.Code, http.StatusForbidden)
}
}
// TestEntrySuggest prüft die Autovervollständigung: Suche mit Begriff liefert
// den passenden Eintrag, ohne Begriff die zuletzt geänderten.
func TestEntrySuggest(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "deployment-prozess",
Title: "Deployment-Prozess", Content: "Wie wir deployen", Source: store.SourceHuman})
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "urlaubsantrag",
Title: "Urlaubsantrag", Content: "Formular für Urlaub", Source: store.SourceHuman})
key := erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite)
cookie := anmelden(t, h, key)
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/entries/suggest?q=deploy", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("Status = %d, erwartet 200 (%s)", w.Code, w.Body.String())
}
var treffer []suggestItem
if err := json.Unmarshal(w.Body.Bytes(), &treffer); err != nil {
t.Fatalf("Antwort ist kein gültiges JSON: %v (%s)", err, w.Body.String())
}
if len(treffer) != 1 || treffer[0].Slug != "deployment-prozess" {
t.Errorf("Treffer = %+v, erwartet genau \"deployment-prozess\"", treffer)
}
// Ohne Suchbegriff: beide Einträge kommen zurück, keine leere Antwort.
r2 := httptest.NewRequest(http.MethodGet, "/p/testprojekt/entries/suggest", nil)
r2.AddCookie(cookie)
w2 := httptest.NewRecorder()
h.ServeHTTP(w2, r2)
var alle []suggestItem
if err := json.Unmarshal(w2.Body.Bytes(), &alle); err != nil {
t.Fatalf("Antwort ist kein gültiges JSON: %v", err)
}
if len(alle) != 2 {
t.Errorf("%d Vorschläge ohne Suchbegriff, erwartet 2", len(alle))
}
}
// TestEntryGraphData prüft den JSON-Endpunkt der Graph-Ansicht: vorhandene
// Einträge als Knoten, ein Verweis auf einen fehlenden Bezeichner erzeugt
// trotzdem einen (nicht existierenden) Knoten.
func TestEntryGraphData(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "a", Title: "A",
Content: "verweist auf [[b]] und [[fehlt]]", Source: store.SourceHuman})
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "b", Title: "B",
Content: "kein Verweis", Source: store.SourceHuman})
key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead)
cookie := anmelden(t, h, key)
// Nur Lesen reicht — die Graph-Ansicht selbst hängt an imProjekt, nicht
// an mitSchreibrecht.
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/graph/data", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("Status = %d, erwartet 200 (%s)", w.Code, w.Body.String())
}
var daten graphResponse
if err := json.Unmarshal(w.Body.Bytes(), &daten); err != nil {
t.Fatalf("Antwort ist kein gültiges JSON: %v (%s)", err, w.Body.String())
}
if len(daten.Nodes) != 3 {
t.Fatalf("%d Knoten, erwartet 3 (a, b, fehlt)", len(daten.Nodes))
}
if len(daten.Edges) != 2 {
t.Errorf("%d Kanten, erwartet 2", len(daten.Edges))
}
nachSlug := make(map[string]graphNode, len(daten.Nodes))
for _, n := range daten.Nodes {
nachSlug[n.Slug] = n
}
if !nachSlug["a"].Exists {
t.Error("Knoten \"a\" sollte Exists = true tragen")
}
if nachSlug["fehlt"].Exists {
t.Error("Knoten \"fehlt\" sollte Exists = false tragen — es gibt keinen Eintrag dazu")
}
if daten.Truncated {
t.Error("Truncated = true bei nur drei Knoten")
}
}
// TestEntryGraphPageRenders prüft, dass die Graph-Seite selbst ausliefert
// und das Skript samt Endpunkt-Adresse einbindet.
func TestEntryGraphPageRenders(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead)
cookie := anmelden(t, h, key)
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/graph", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("Status = %d, erwartet 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, `data-graph="/p/testprojekt/graph/data"`) {
t.Error("die Seite bindet den Graph-Endpunkt nicht ein")
}
if !strings.Contains(body, "/static/graph.js") {
t.Error("die Seite lädt graph.js nicht")
}
}
// TestEntryGraphDataTruncatesLargeProjects prüft die Obergrenze
// (graphNodeLimit): Mehr Einträge als das Limit führen zu Truncated = true
// und genau graphNodeLimit Knoten in der Antwort, nicht mehr.
func TestEntryGraphDataTruncatesLargeProjects(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
for i := 0; i < graphNodeLimit+5; i++ {
slug := fmt.Sprintf("eintrag-%03d", i)
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: slug, Title: slug,
Content: "kein Verweis", Source: store.SourceHuman})
}
key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead)
cookie := anmelden(t, h, key)
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/graph/data", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
var daten graphResponse
if err := json.Unmarshal(w.Body.Bytes(), &daten); err != nil {
t.Fatalf("Antwort ist kein gültiges JSON: %v", err)
}
if !daten.Truncated {
t.Error("Truncated = false, obwohl mehr Einträge als graphNodeLimit existieren")
}
if len(daten.Nodes) != graphNodeLimit {
t.Errorf("%d Knoten in der Antwort, erwartet genau graphNodeLimit (%d)", len(daten.Nodes), graphNodeLimit)
}
if daten.Total != graphNodeLimit+5 {
t.Errorf("Total = %d, erwartet %d", daten.Total, graphNodeLimit+5)
}
}
// TestEntryContentIsEscaped ist ein Sicherheitstest: Eingeschleustes HTML darf
// nicht ausgeführt werden.
func TestEntryContentIsEscaped(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
if _, err := db.PutEntry(ctx, store.EntryWrite{
ProjectID: projekt.ID, Slug: "boese", Title: "Test",
Content: "<script>alert(1)</script>\n\nText", Source: store.SourceHuman,
}); err != nil {
t.Fatal(err)
}
key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead)
cookie := anmelden(t, h, key)
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/boese", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if strings.Contains(w.Body.String(), "<script>alert(1)</script>") {
t.Error("eingeschleustes <script> wurde unverändert ausgeliefert")
}
}
// ── Zugriffsprotokoll ────────────────────────────────────────────────────────
// TestLogRequestsSchreibtZeile prüft, dass jede Anfrage protokolliert wird.
func TestLogRequestsSchreibtZeile(t *testing.T) {
var puffer bytes.Buffer
log.SetOutput(&puffer)
log.SetFlags(0)
defer func() {
log.SetOutput(os.Stderr)
log.SetFlags(log.LstdFlags)
}()
h := LogRequests(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot)
}))
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/p/test/save", nil))
zeile := puffer.String()
for _, teil := range []string{"418", "POST", "/p/test/save"} {
if !strings.Contains(zeile, teil) {
t.Errorf("%q fehlt in der Protokollzeile: %s", teil, zeile)
}
}
}
// TestLogRequestsSchweigtBeiRauschen prüft, dass statische Dateien und der
// Healthcheck nicht protokolliert werden — der Healthcheck läuft alle 30
// Sekunden und würde das Log fluten.
func TestLogRequestsSchweigtBeiRauschen(t *testing.T) {
var puffer bytes.Buffer
log.SetOutput(&puffer)
defer log.SetOutput(os.Stderr)
h := LogRequests(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
for _, pfad := range []string{"/healthz", "/static/style.css"} {
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, pfad, nil))
}
if puffer.Len() > 0 {
t.Errorf("Rauschen wurde protokolliert: %s", puffer.String())
}
}
// TestLogRequestsProtokolliertKeineSuchbegriffe ist ein Datenschutztest:
// Die Query-Zeichenkette enthält Suchbegriffe, und die sind Inhalt, kein
// Betriebsdatum.
func TestLogRequestsProtokolliertKeineSuchbegriffe(t *testing.T) {
var puffer bytes.Buffer
log.SetOutput(&puffer)
defer log.SetOutput(os.Stderr)
h := LogRequests(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
h.ServeHTTP(httptest.NewRecorder(),
httptest.NewRequest(http.MethodGet, "/p/test/?q=gehaltsverhandlung", nil))
if strings.Contains(puffer.String(), "gehaltsverhandlung") {
t.Errorf("der Suchbegriff steht im Protokoll: %s", puffer.String())
}
}
// TestLogRequestsNenntDenZugang prüft, dass der verwendete Zugang mit
// protokolliert wird — sonst sieht man nicht, welcher Agent Last erzeugt.
func TestLogRequestsNenntDenZugang(t *testing.T) {
var puffer bytes.Buffer
log.SetOutput(&puffer)
defer log.SetOutput(os.Stderr)
// Reihenfolge wie im Betrieb: Der Protokollierer liegt AUSSEN, die Anmeldung
// geschieht weiter innen. Der Name muss trotzdem in der Zeile landen.
innen := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
withSession(r.Context(), Session{
Subject: "key:1",
Key: store.APIKey{Name: "claude-desktop"},
})
})
LogRequests(innen).ServeHTTP(httptest.NewRecorder(),
httptest.NewRequest(http.MethodGet, "/p/test/", nil))
if !strings.Contains(puffer.String(), "claude-desktop") {
t.Errorf("der Zugang fehlt im Protokoll: %s", puffer.String())
}
}
// TestLogRequestsOhneAnmeldung prüft die Gegenrichtung: Eine abgewiesene
// Anfrage wird protokolliert, nur eben ohne Namen.
func TestLogRequestsOhneAnmeldung(t *testing.T) {
var puffer bytes.Buffer
log.SetOutput(&puffer)
defer log.SetOutput(os.Stderr)
h := LogRequests(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/mcp", nil))
zeile := puffer.String()
if !strings.Contains(zeile, "401") {
t.Errorf("abgewiesene Anfrage wurde nicht protokolliert: %s", zeile)
}
if !strings.Contains(zeile, "—") {
t.Errorf("fehlender Zugang wird nicht als \"—\" dargestellt: %s", zeile)
}
}
// TestStatusSchreiberReichtFlusherDurch prüft, dass der Mantel um den
// ResponseWriter dessen Fähigkeiten nicht verdeckt.
//
// Der MCP-Transport streamt Antworten und braucht dafür http.Flusher. Ginge der
// beim Einwickeln verloren, blieben MCP-Antworten im Puffer hängen — ein Fehler,
// den man erst im Betrieb bemerkt.
func TestStatusSchreiberReichtFlusherDurch(t *testing.T) {
var gefunden bool
h := LogRequests(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, gefunden = w.(http.Flusher)
}))
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/mcp", nil))
if !gefunden {
t.Error("http.Flusher ist durch den Protokoll-Mantel verlorengegangen")
}
}
// ── Schreibbudget ────────────────────────────────────────────────────────────
// TestPlatformAdminEditsProject prüft den Weg durch die Verwaltung: Der
// Plattform-Admin ändert Name, Beschreibung und Obergrenze eines Projekts, und
// alles drei steht danach wirklich in der Datenbank.
func TestPlatformAdminEditsProject(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
w := adminPost(t, h, projekt.WorkspaceID, "/admin/projects/testprojekt/edit", url.Values{
"name": {"Neuer Name"},
"description": {"Neue Beschreibung"},
"write_limit_hourly": {"250"},
})
if w.Code != http.StatusSeeOther {
t.Fatalf("Status = %d, erwartet %d — Antwort: %s", w.Code, http.StatusSeeOther, w.Body.String())
}
gelesen, err := db.ProjectByID(context.Background(), projekt.ID)
if err != nil {
t.Fatal(err)
}
if gelesen.Name != "Neuer Name" {
t.Errorf("Name = %q, erwartet \"Neuer Name\"", gelesen.Name)
}
if gelesen.Description != "Neue Beschreibung" {
t.Errorf("Description = %q, erwartet \"Neue Beschreibung\"", gelesen.Description)
}
if gelesen.WriteLimitHourly != 250 {
t.Errorf("WriteLimitHourly = %d, erwartet 250", gelesen.WriteLimitHourly)
}
}
// TestProjectEditFormShowsCurrentValues ist der Grund, warum die Bearbeitung
// eine eigene Seite je Projekt ist: Die Felder müssen den gespeicherten Stand
// zeigen. Sonst überschreibt ein Speichern ohne Änderung die Werte mit den
// Vorgaben des Formulars.
func TestProjectEditFormShowsCurrentValues(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
if err := db.UpdateProject(context.Background(), projekt.ID, store.ProjectUpdate{
Name: "Testprojekt", Description: "Eine Beschreibung", WriteLimitHourly: 250,
}); err != nil {
t.Fatal(err)
}
cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID)
r := httptest.NewRequest(http.MethodGet, "/admin/projects/testprojekt/edit", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("Status = %d, erwartet 200", w.Code)
}
koerper := w.Body.String()
for _, erwartet := range []string{`value="Eine Beschreibung"`, `value="250"`} {
if !strings.Contains(koerper, erwartet) {
t.Errorf("das Formular enthält %s nicht", erwartet)
}
}
}
// TestProjectEditKeepsUnchangedValues deckt die Falle des früheren
// Auswahlfeld-Formulars ab: Ein Speichern, das die Obergrenze nicht anfasst,
// darf sie nicht auf 0 zurücksetzen.
func TestProjectEditKeepsUnchangedValues(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
if err := db.UpdateProject(ctx, projekt.ID, store.ProjectUpdate{
Name: "Testprojekt", Description: "", WriteLimitHourly: 250,
}); err != nil {
t.Fatal(err)
}
// Nur den Namen ändern — die Obergrenze kommt so zurück, wie das Formular
// sie ausgeliefert hat.
adminPost(t, h, projekt.WorkspaceID, "/admin/projects/testprojekt/edit", url.Values{
"name": {"Anderer Name"},
"description": {""},
"write_limit_hourly": {"250"},
})
gelesen, _ := db.ProjectByID(ctx, projekt.ID)
if gelesen.WriteLimitHourly != 250 {
t.Errorf("WriteLimitHourly = %d — die Obergrenze ging beim Speichern verloren", gelesen.WriteLimitHourly)
}
}
// TestProjectAdminCannotEditProject ist der eigentliche Grund, warum die Route
// beim Plattform-Admin liegt: Ein Projekt-Admin könnte sich sonst selbst mehr
// Budget geben — dann wäre die Grenze keine.
func TestProjectAdminCannotEditProject(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
key := erzeugeKey(t, srv, db, projekt.ID, "test", auth.RoleAdmin)
cookie := anmelden(t, h, key)
keys, _ := db.ListAPIKeys(context.Background(), projekt.ID)
token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keys[0].ID)
formular := url.Values{
"name": {"Gekapert"},
"write_limit_hourly": {"999999"},
auth.CSRFFieldName: {token},
}
r := httptest.NewRequest(http.MethodPost, "/admin/projects/testprojekt/edit", strings.NewReader(formular.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusForbidden {
t.Errorf("Status = %d, erwartet %d — ein Projekt-Admin durfte sein Projekt bearbeiten", w.Code, http.StatusForbidden)
}
gelesen, _ := db.ProjectByID(context.Background(), projekt.ID)
if gelesen.WriteLimitHourly != 0 {
t.Errorf("WriteLimitHourly = %d — die Obergrenze wurde trotzdem geändert", gelesen.WriteLimitHourly)
}
}
// TestProjectEditRejectsGarbage prüft, dass eine unbrauchbare Eingabe eine
// Fehlermeldung ergibt und nicht etwa eine 0 (also "Standard") schreibt.
func TestProjectEditRejectsGarbage(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
if err := db.UpdateProject(ctx, projekt.ID, store.ProjectUpdate{
Name: "Testprojekt", WriteLimitHourly: 42,
}); err != nil {
t.Fatal(err)
}
w := adminPost(t, h, projekt.WorkspaceID, "/admin/projects/testprojekt/edit", url.Values{
"name": {"Testprojekt"},
"write_limit_hourly": {"viele"},
})
if w.Code != http.StatusBadRequest {
t.Errorf("Status = %d, erwartet %d", w.Code, http.StatusBadRequest)
}
gelesen, _ := db.ProjectByID(ctx, projekt.ID)
if gelesen.WriteLimitHourly != 42 {
t.Errorf("WriteLimitHourly = %d — die alte Obergrenze wurde überschrieben", gelesen.WriteLimitHourly)
}
}
// TestProjectEditRejectsEmptyName: Ein Projekt ohne Namen wäre in der Übersicht
// eine leere Zeile.
func TestProjectEditRejectsEmptyName(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
w := adminPost(t, h, projekt.WorkspaceID, "/admin/projects/testprojekt/edit", url.Values{
"name": {" "},
"write_limit_hourly": {"0"},
})
if w.Code != http.StatusBadRequest {
t.Errorf("Status = %d, erwartet %d", w.Code, http.StatusBadRequest)
}
gelesen, _ := db.ProjectByID(context.Background(), projekt.ID)
if gelesen.Name != projekt.Name {
t.Errorf("Name = %q — der leere Name wurde gespeichert", gelesen.Name)
}
}
// TestProjectEditUnknownSlug: ein Bezeichner, den es nicht gibt.
func TestProjectEditUnknownSlug(t *testing.T) {
srv, _, projekt := testServer(t)
h := testHandler(t, srv)
cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID)
r := httptest.NewRequest(http.MethodGet, "/admin/projects/gibtesnicht/edit", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusNotFound {
t.Errorf("Status = %d, erwartet 404", w.Code)
}
}
// adminPost schickt ein Formular als Plattform-Admin im gegebenen Workspace,
// samt gültigem CSRF-Token.
func adminPost(t *testing.T, h http.Handler, workspaceID, pfad string, formular url.Values) *httptest.ResponseRecorder {
t.Helper()
cookie := anmeldenAlsPlattformAdmin(t, h, workspaceID)
formular.Set(auth.CSRFFieldName,
auth.NewSigner([]byte(testSecret)).CSRFToken(plattformSubjekt(workspaceID)))
r := httptest.NewRequest(http.MethodPost, pfad, strings.NewReader(formular.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
// ── Hilfe ────────────────────────────────────────────────────────────────────
func TestHilfeBrauchtAnmeldung(t *testing.T) {
srv, _, _ := testServer(t)
h := testHandler(t, srv)
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/hilfe", nil))
if w.Code != http.StatusSeeOther {
t.Errorf("Status = %d, erwartet %d (Weiterleitung zur Anmeldung)", w.Code, http.StatusSeeOther)
}
}
// TestHilfeFuerLeseRolle prüft, dass auch die schwächste Rolle die Hilfe
// sieht, und dass die Platzhalter durch Server-Adresse und Projekt ersetzt
// werden — die Beispiele sollen direkt kopierbar sein.
func TestHilfeFuerLeseRolle(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead)
cookie := anmelden(t, h, key)
r := httptest.NewRequest(http.MethodGet, "/hilfe/mcp-einrichten", nil)
r.Host = "munin.example.org"
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("Status = %d, erwartet 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "https://munin.example.org/mcp") {
t.Error("Server-Adresse wurde nicht eingesetzt")
}
if !strings.Contains(body, "munin-testprojekt") {
t.Error("Projekt-Bezeichner wurde nicht eingesetzt")
}
if strings.Contains(body, "{{SERVER}}") || strings.Contains(body, "{{PROJEKT}}") {
t.Error("Platzhalter stehen noch im Ergebnis")
}
// Die Seitenleiste des eigenen Projekts soll auch auf der Hilfe stehen.
if !strings.Contains(body, `href="/p/testprojekt/"`) {
t.Error("Seitenleiste zeigt das eigene Projekt nicht")
}
}
func TestHilfeOhneSeiteZeigtStart(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead))
r := httptest.NewRequest(http.MethodGet, "/hilfe", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("Status = %d, erwartet 200", w.Code)
}
if !strings.Contains(w.Body.String(), "Willkommen") {
t.Error("/hilfe zeigt nicht die Startseite")
}
}
func TestHilfeUnbekannteSeite(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead))
r := httptest.NewRequest(http.MethodGet, "/hilfe/gibt-es-nicht", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusNotFound {
t.Errorf("Status = %d, erwartet 404", w.Code)
}
}
// TestHilfeFuerAdminOhneWorkspace: Ein frisch angemeldeter Plattform-Admin hat
// noch keinen Workspace gewählt. Die Hilfe muss trotzdem erreichbar sein.
func TestHilfeFuerAdminOhneWorkspace(t *testing.T) {
srv, _, _ := testServer(t)
h := testHandler(t, srv)
cookie := anmelden(t, h, testAdminToken)
r := httptest.NewRequest(http.MethodGet, "/hilfe/plattform-admin", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Errorf("Status = %d, erwartet 200", w.Code)
}
}
// hilfeZielMuster findet in den Templates jedes Ziel innerhalb der Hilfe:
// sowohl {{template "hilfelink" "seite#abschnitt"}} als auch einfache Links
// href="/hilfe/seite". Die runden Klammern sind die "Fanggruppen" — daraus
// liest der Test Seite und (optional) Abschnitt.
var hilfeZielMuster = regexp.MustCompile(`(?:template "hilfelink" "|href="/hilfe/)([a-z0-9-]+)(?:#([a-z0-9-]+))?"`)
// TestHilfelinksZeigenAufVorhandeneAbschnitte prüft jedes Hilfe-Ziel in allen
// Templates gegen die echten Hilfeseiten (AUSBAUSTUFEN.md 5.4).
//
// Die Abschnitts-ids entstehen aus den Überschriften der Hilfeseiten. Benennt
// jemand eine Überschrift um, zeigt der "?"-Link ins Leere — ohne diesen Test
// würde das niemand bemerken, denn der Browser springt dann einfach an den
// Seitenanfang, ganz ohne Fehlermeldung.
func TestHilfelinksZeigenAufVorhandeneAbschnitte(t *testing.T) {
srv, _, _ := testServer(t)
h := testHandler(t, srv)
// Als Plattform-Admin, damit auch die Admin-Hilfeseite erreichbar ist.
cookie := anmelden(t, h, testAdminToken)
dateien, err := filepath.Glob("../../templates/*.html")
if err != nil || len(dateien) == 0 {
t.Fatalf("keine Templates gefunden: %v", err)
}
// Jede Hilfeseite nur einmal abrufen, auch wenn sie mehrfach verlinkt ist.
seiten := map[string]string{}
gefunden := 0
for _, datei := range dateien {
inhalt, err := os.ReadFile(datei)
if err != nil {
t.Fatal(err)
}
for _, treffer := range hilfeZielMuster.FindAllStringSubmatch(string(inhalt), -1) {
// treffer[0] ist der ganze Fund, treffer[1] die Seite,
// treffer[2] der Abschnitt (leer, wenn es keinen gibt).
seite, abschnitt := treffer[1], treffer[2]
gefunden++
html, schonGeholt := seiten[seite]
if !schonGeholt {
r := httptest.NewRequest(http.MethodGet, "/hilfe/"+seite, nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Errorf("%s: Hilfeseite %q antwortet mit %d", filepath.Base(datei), seite, w.Code)
continue
}
html = w.Body.String()
seiten[seite] = html
}
if abschnitt != "" && !strings.Contains(html, `id="`+abschnitt+`"`) {
t.Errorf("%s: Abschnitt %q gibt es auf /hilfe/%s nicht", filepath.Base(datei), abschnitt, seite)
}
}
}
// Findet das Muster gar nichts, prüft der Test auch nichts — dann ist eher
// das Muster kaputt als die Templates.
if gefunden < 5 {
t.Errorf("nur %d Hilfe-Ziele in den Templates gefunden — stimmt das Suchmuster noch?", gefunden)
}
}
// TestHilfelinkImHTML: Das Partial erzeugt einen echten Link mit Abschnitt —
// html/template darf das "#" dabei nicht kaputt-maskieren.
func TestHilfelinkImHTML(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite))
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/new", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if !strings.Contains(w.Body.String(), `href="/hilfe/weboberflaeche#markdown-im-inhalt"`) {
t.Error("der Editor verlinkt nicht auf den Markdown-Abschnitt der Hilfe")
}
}
func TestServerURL(t *testing.T) {
faelle := []struct {
host, erwartet string
}{
{"munin.intern", "https://munin.intern"},
{"localhost:8080", "http://localhost:8080"},
{"127.0.0.1:8080", "http://127.0.0.1:8080"},
{"[::1]:8080", "http://[::1]:8080"},
{"böse<script>", "https://DEIN-SERVER"},
{"", "https://DEIN-SERVER"},
}
for _, f := range faelle {
r := httptest.NewRequest(http.MethodGet, "/hilfe", nil)
r.Host = f.host
if got := serverURL(r); got != f.erwartet {
t.Errorf("serverURL(%q) = %q, erwartet %q", f.host, got, f.erwartet)
}
}
}
// TestHilfeAdminSeiteNurFuerAdmins: Mit Projekt-Key ist die Admin-Seite weder
// verlinkt noch direkt aufrufbar; der Plattform-Admin sieht sie in der Leiste.
func TestHilfeAdminSeiteNurFuerAdmins(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
holen := func(cookie *http.Cookie, pfad string) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodGet, pfad, nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
team := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "chefin", auth.RoleAdmin))
if w := holen(team, "/hilfe/plattform-admin"); w.Code != http.StatusNotFound {
t.Errorf("Projekt-Key: Status = %d, erwartet 404", w.Code)
}
if strings.Contains(holen(team, "/hilfe").Body.String(), `href="/hilfe/plattform-admin"`) {
t.Error("Projekt-Key sieht den Link auf die Admin-Seite")
}
admin := anmelden(t, h, testAdminToken)
if !strings.Contains(holen(admin, "/hilfe").Body.String(), `href="/hilfe/plattform-admin"`) {
t.Error("Plattform-Admin sieht den Link auf die Admin-Seite nicht")
}
}
// TestExportKnopfImZweitenWorkspace: Der Knopf "Jetzt exportieren" muss den
// gerade gewählten Workspace exportieren — auch wenn es nicht der erste ist,
// und auch wenn dort ein Projekt denselben Bezeichner trägt wie im ersten.
// Vorher war der Exporter fest an die Datei des ersten Workspace gebunden und
// meldete hier Erfolg, ohne eine Datei zu schreiben.
func TestExportKnopfImZweitenWorkspace(t *testing.T) {
srv, _, projektA := testServer(t)
ziel := t.TempDir()
srv.exporter = export.New(ziel)
h := testHandler(t, srv)
ctx := context.Background()
wsB, err := srv.control.CreateWorkspace(ctx, "Vertrieb")
if err != nil {
t.Fatal(err)
}
offenB, err := srv.workspaces.Get(wsB.ID)
if err != nil {
t.Fatal(err)
}
// Absichtlich derselbe Bezeichner wie das Projekt im ersten Workspace.
projektB, err := offenB.Store.CreateProject(ctx, wsB.ID, projektA.Slug, "Vertriebsprojekt", "")
if err != nil {
t.Fatal(err)
}
if _, err := offenB.Store.PutEntry(ctx, store.EntryWrite{
ProjectID: projektB.ID, Slug: "angebote", Title: "Angebote",
Content: "Wissen aus dem Vertrieb", Source: store.SourceHuman,
}); err != nil {
t.Fatal(err)
}
cookie := anmeldenAlsPlattformAdmin(t, h, wsB.ID)
token := auth.NewSigner([]byte(testSecret)).CSRFToken(plattformSubjekt(wsB.ID))
formular := url.Values{auth.CSRFFieldName: {token}}
r := httptest.NewRequest(http.MethodPost, "/admin/export", strings.NewReader(formular.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusSeeOther {
t.Fatalf("Status = %d, erwartet 303", w.Code)
}
inhalt, err := os.ReadFile(filepath.Join(ziel, wsB.ID, projektB.Slug, "angebote.md"))
if err != nil {
t.Fatalf("Eintrag aus dem zweiten Workspace wurde nicht exportiert: %v", err)
}
if !strings.Contains(string(inhalt), "Wissen aus dem Vertrieb") {
t.Error("exportierte Datei hat den falschen Inhalt")
}
// Der Knopf exportiert nur den gewählten Workspace, nicht den ersten.
if _, err := os.Stat(filepath.Join(ziel, projektA.WorkspaceID)); err == nil {
t.Error("der Knopf hat auch den nicht gewählten Workspace exportiert")
}
}
// ── Papierkorb ───────────────────────────────────────────────────────────────
// formularSenden schickt ein Formular mit gültigem CSRF-Token ab, so wie es
// ein Mensch mit dem ersten Key des Projekts im Browser täte.
func formularSenden(t *testing.T, h http.Handler, db *store.Store, projekt store.Project, cookie *http.Cookie, pfad string, formular url.Values) *httptest.ResponseRecorder {
t.Helper()
keys, _ := db.ListAPIKeys(context.Background(), projekt.ID)
formular.Set(auth.CSRFFieldName,
auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix+projekt.WorkspaceID+":"+keys[0].ID))
r := httptest.NewRequest(http.MethodPost, pfad, strings.NewReader(formular.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
// seiteHolen ruft eine Seite ab.
func seiteHolen(h http.Handler, cookie *http.Cookie, pfad string) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodGet, pfad, nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
// TestPapierkorbAblauf spielt den ganzen Weg durch: löschen, im Papierkorb
// sehen, wiederherstellen, erneut löschen, endgültig löschen.
func TestPapierkorbAblauf(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "Wichtiges Wissen",
Content: "x", Source: store.SourceHuman})
cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite))
// 1. Löschen über die Detailansicht → Papierkorb, mit Hinweis.
w := formularSenden(t, h, db, projekt, cookie, "/p/testprojekt/e/e/delete", url.Values{})
if w.Code != http.StatusSeeOther || !strings.Contains(w.Header().Get("Location"), "hinweis=") {
t.Fatalf("Löschen: Status %d, Ziel %q", w.Code, w.Header().Get("Location"))
}
if seiteHolen(h, cookie, "/p/testprojekt/e/e").Code != http.StatusNotFound {
t.Error("der gelöschte Eintrag ist noch aufrufbar")
}
// 2. Die Papierkorb-Seite zeigt ihn samt Zugang.
seite := seiteHolen(h, cookie, "/p/testprojekt/trash")
if seite.Code != http.StatusOK {
t.Fatalf("Papierkorb: Status %d", seite.Code)
}
if !strings.Contains(seite.Body.String(), "Wichtiges Wissen") || !strings.Contains(seite.Body.String(), "mensch") {
t.Error("der Papierkorb zeigt Eintrag oder löschenden Zugang nicht")
}
// 3. Wiederherstellen.
formularSenden(t, h, db, projekt, cookie, "/p/testprojekt/trash/restore", url.Values{"slugs": {"e"}})
if seiteHolen(h, cookie, "/p/testprojekt/e/e").Code != http.StatusOK {
t.Fatal("der wiederhergestellte Eintrag ist nicht wieder aufrufbar")
}
// 4. Erneut löschen und endgültig entfernen.
formularSenden(t, h, db, projekt, cookie, "/p/testprojekt/e/e/delete", url.Values{})
formularSenden(t, h, db, projekt, cookie, "/p/testprojekt/trash/purge", url.Values{"slugs": {"e"}})
if liste, _ := db.ListTrash(ctx, projekt.ID, ""); len(liste) != 0 {
t.Error("der Eintrag liegt nach endgültigem Löschen noch im Papierkorb")
}
// Jeder Schritt steht im Protokoll.
ereignisse, _ := db.ListAudit(ctx, store.AuditFilter{ProjectID: projekt.ID})
gezaehlt := map[string]int{}
for _, ev := range ereignisse {
gezaehlt[ev.Action]++
}
if gezaehlt[store.ActionEntryDelete] != 2 || gezaehlt[store.ActionEntryRestore] != 1 || gezaehlt[store.ActionEntryPurge] != 1 {
t.Errorf("Protokoll: %v", gezaehlt)
}
}
// TestPapierkorbPurgeNurAusDemPapierkorb: Über das Formular zum endgültigen
// Löschen lässt sich kein Eintrag entfernen, der nicht im Papierkorb liegt.
func TestPapierkorbPurgeNurAusDemPapierkorb(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "lebt", Title: "T", Source: store.SourceHuman})
cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite))
formularSenden(t, h, db, projekt, cookie, "/p/testprojekt/trash/purge", url.Values{"slugs": {"lebt"}})
if _, err := db.EntryBySlug(ctx, projekt.ID, "lebt"); err != nil {
t.Error("ein Eintrag außerhalb des Papierkorbs wurde endgültig gelöscht")
}
ereignisse, _ := db.ListAudit(ctx, store.AuditFilter{ProjectID: projekt.ID})
for _, ev := range ereignisse {
if ev.Action == store.ActionEntryPurge {
t.Error("das Protokoll vermerkt eine endgültige Löschung, die nicht stattfand")
}
}
}
// TestPapierkorbNurMitSchreibrecht: Lesende sehen weder den Link noch die
// Seite — der Papierkorb kann Inhalte enthalten, die gerade deshalb gelöscht
// wurden, weil sie nicht jeder sehen soll.
func TestPapierkorbNurMitSchreibrecht(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "T", Source: store.SourceHuman})
db.MoveEntryToTrash(ctx, projekt.ID, "e", "tom")
cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead))
if w := seiteHolen(h, cookie, "/p/testprojekt/trash"); w.Code != http.StatusForbidden {
t.Errorf("Papierkorb für Leserolle: Status %d, erwartet 403", w.Code)
}
if strings.Contains(seiteHolen(h, cookie, "/p/testprojekt/").Body.String(), "/trash") {
t.Error("die Leserolle sieht den Link zum Papierkorb")
}
if w := formularSenden(t, h, db, projekt, cookie, "/p/testprojekt/trash/restore", url.Values{"slugs": {"e"}}); w.Code != http.StatusForbidden {
t.Errorf("Wiederherstellen mit Leserolle: Status %d, erwartet 403", w.Code)
}
}
// TestPapierkorbFilterNachZugang: Nach einem Zugang gefiltert, lässt sich
// alles, was er gelöscht hat, in einem Zug zurückholen — ohne das, was andere
// gelöscht haben.
func TestPapierkorbFilterNachZugang(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
for _, slug := range []string{"a", "b", "c"} {
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: slug, Title: "Titel " + slug, Source: store.SourceHuman})
}
db.MoveEntryToTrash(ctx, projekt.ID, "a", "agent-1")
db.MoveEntryToTrash(ctx, projekt.ID, "b", "agent-1")
db.MoveEntryToTrash(ctx, projekt.ID, "c", "tom")
cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite))
seite := seiteHolen(h, cookie, "/p/testprojekt/trash?von=agent-1").Body.String()
if strings.Contains(seite, "Titel c") {
t.Error("der Filter zeigt auch, was ein anderer Zugang gelöscht hat")
}
if !strings.Contains(seite, "Alle 2 von") {
t.Error("der Knopf zum Wiederherstellen aller gefilterten Einträge fehlt")
}
// So, wie das Formular "Alle … wiederherstellen" abschickt — mit einem
// doppelten Bezeichner, der nur einmal zählen darf.
formularSenden(t, h, db, projekt, cookie, "/p/testprojekt/trash/restore",
url.Values{"slugs": {"a", "b", "a"}, "von": {"agent-1"}})
if liste, _ := db.ListTrash(ctx, projekt.ID, ""); len(liste) != 1 || liste[0].Slug != "c" {
t.Errorf("im Papierkorb übrig: %v, erwartet nur „c“", liste)
}
ereignisse, _ := db.ListAudit(ctx, store.AuditFilter{ProjectID: projekt.ID})
wiederhergestellt := 0
for _, ev := range ereignisse {
if ev.Action == store.ActionEntryRestore {
wiederhergestellt++
}
}
if wiederhergestellt != 2 {
t.Errorf("%d Wiederherstellungen protokolliert, erwartet 2", wiederhergestellt)
}
}
// TestSpeichernHoltAusDemPapierkorb: Legt ein Mensch einen "neuen" Eintrag mit
// einem Bezeichner an, der im Papierkorb liegt, erfährt er, dass er ihn
// zurückgeholt und überschrieben hat.
func TestSpeichernHoltAusDemPapierkorb(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "Alt", Content: "alt", Source: store.SourceHuman})
db.MoveEntryToTrash(ctx, projekt.ID, "e", "agent-1")
cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite))
w := formularSenden(t, h, db, projekt, cookie, "/p/testprojekt/save",
url.Values{"slug": {"e"}, "title": {"Neu"}, "content": {"neu"}, "version": {"0"}})
if w.Code != http.StatusSeeOther {
t.Fatalf("Speichern: Status %d (%s)", w.Code, w.Body.String())
}
if !strings.Contains(w.Header().Get("Location"), "hinweis=") {
t.Error("kein Hinweis, dass ein Eintrag aus dem Papierkorb überschrieben wurde")
}
// Ein Formular, das noch den Stand vor dem Löschen trug, bekommt dagegen
// einen Konflikt statt die Löschung stillschweigend aufzuheben.
db.MoveEntryToTrash(ctx, projekt.ID, "e", "agent-1")
w = formularSenden(t, h, db, projekt, cookie, "/p/testprojekt/e/e/save",
url.Values{"slug": {"e"}, "title": {"Neu"}, "content": {"noch neuer"}, "version": {"2"}})
if w.Code != http.StatusBadRequest {
t.Errorf("Speichern eines inzwischen gelöschten Eintrags: Status %d, erwartet 400 (zurück ins Formular)", w.Code)
}
}
// ── Nutzungsübersicht ────────────────────────────────────────────────────────
// TestNutzungsseite: Die Seite zeigt Bestand, Schreibvorgänge und Verweise ins
// Leere — auch für die Leserolle, aber ohne den Link zum Anlegen.
func TestNutzungsseite(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "a", Title: "Ohne Schlagwort",
Content: "siehe [[fehlt-noch]]", Source: store.SourceAgent})
db.LogAudit(ctx, store.AuditEvent{ProjectID: projekt.ID, Actor: "agent", Action: store.ActionEntryWrite,
Source: store.SourceMCP, Detail: store.DetailEntryCreated})
db.LogAudit(ctx, store.AuditEvent{ProjectID: projekt.ID, Actor: "agent", Action: store.ActionEntryWrite,
Source: store.SourceMCP, Detail: store.DetailEntryUpdated})
leser := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead))
w := seiteHolen(h, leser, "/p/testprojekt/usage")
if w.Code != http.StatusOK {
t.Fatalf("Status %d: %s", w.Code, w.Body.String())
}
body := w.Body.String()
for _, erwartet := range []string{"Ohne Schlagwort", "fehlt-noch", "50 %"} {
if !strings.Contains(body, erwartet) {
t.Errorf("%q fehlt auf der Nutzungsseite", erwartet)
}
}
if strings.Contains(body, "/new?slug=") {
t.Error("die Leserolle bekommt einen Link zum Anlegen")
}
schreiber := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite))
if !strings.Contains(seiteHolen(h, schreiber, "/p/testprojekt/usage").Body.String(), "/p/testprojekt/new?slug=fehlt-noch") {
t.Error("mit Schreibrecht fehlt der Link zum Anlegen des fehlenden Eintrags")
}
}
// TestNutzungsseiteZeigtSuchen: Suchen ohne Treffer erscheinen mit Anzahl —
// und eine Anfrage mit HTML darin wird als Text ausgegeben, nicht ausgeführt.
func TestNutzungsseiteZeigtSuchen(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
db.LogSearch(ctx, store.SearchLogEntry{ProjectID: projekt.ID, Query: "vpn zugang", Results: 0})
db.LogSearch(ctx, store.SearchLogEntry{ProjectID: projekt.ID, Query: "vpn zugang", Results: 0})
db.LogSearch(ctx, store.SearchLogEntry{ProjectID: projekt.ID, Query: "<script>x</script>", Results: 0})
db.LogSearch(ctx, store.SearchLogEntry{ProjectID: projekt.ID, Query: "deployment",
Synonyms: []string{"release"}, Results: 1})
cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead))
body := seiteHolen(h, cookie, "/p/testprojekt/usage").Body.String()
for _, erwartet := range []string{"vpn zugang", "2×", "75 %", "25 %"} {
if !strings.Contains(body, erwartet) {
t.Errorf("%q fehlt auf der Nutzungsseite", erwartet)
}
}
if strings.Contains(body, "<script>x</script>") {
t.Error("eine Suchanfrage wurde ungeschützt als HTML ausgegeben")
}
if strings.Contains(body, "deployment") {
t.Error("eine Suche mit Treffer steht in der Liste ohne Treffer")
}
}
// TestNutzungsseiteZeigtZurPruefung: Ein kuratierter Eintrag, den ein Agent
// geändert hat, steht mit Begründung und Link zum Verlauf oben auf der Seite.
func TestNutzungsseiteZeigtZurPruefung(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "vpn", Title: "VPN-Zugang",
Content: "neu", Source: store.SourceAgent})
db.LogAudit(ctx, store.AuditEvent{ProjectID: projekt.ID, Actor: "agent-x", Action: store.ActionEntryWrite,
EntrySlug: "vpn", Source: store.SourceMCP, Detail: store.CuratedOverrideDetail("Server umgezogen")})
cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead))
body := seiteHolen(h, cookie, "/p/testprojekt/usage").Body.String()
for _, erwartet := range []string{"Zur Prüfung", "VPN-Zugang", "/p/testprojekt/e/vpn/versions", "Server umgezogen", "agent-x"} {
if !strings.Contains(body, erwartet) {
t.Errorf("%q fehlt auf der Nutzungsseite", erwartet)
}
}
}
// TestNutzungsseiteLeeresProjekt: Ein Projekt ganz ohne Daten darf nicht
// stolpern (es fehlt dann in der map der Kennzahlen).
func TestNutzungsseiteLeeresProjekt(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead))
w := seiteHolen(h, cookie, "/p/testprojekt/usage")
if w.Code != http.StatusOK {
t.Fatalf("Status %d", w.Code)
}
if !strings.Contains(w.Body.String(), "wurde nichts geschrieben") {
t.Error("kein Hinweis für ein Projekt ohne Schreibvorgänge")
}
}
// TestNeuerEintragMitVorbelegtemBezeichner: ?slug= füllt das Feld, ein
// ungültiger Wert wird ignoriert.
func TestNeuerEintragMitVorbelegtemBezeichner(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite))
if !strings.Contains(seiteHolen(h, cookie, "/p/testprojekt/new?slug=fehlt-noch").Body.String(), `value="fehlt-noch"`) {
t.Error("der Bezeichner wurde nicht vorbelegt")
}
if strings.Contains(seiteHolen(h, cookie, "/p/testprojekt/new?slug=B%C3%B6se+Sache").Body.String(), "Böse") {
t.Error("ein ungültiger Bezeichner wurde ins Formular übernommen")
}
}
// TestAdminProjektlisteZeigtNutzung: Die Projektübersicht des Plattform-Admins
// zeigt, welches Projekt gar nicht schreibt.
func TestAdminProjektlisteZeigtNutzung(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID)
w := seiteHolen(h, cookie, "/admin/projects")
if w.Code != http.StatusOK {
t.Fatalf("Status %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "keine") {
t.Error("ein Projekt ohne Schreibvorgänge ist nicht als „keine“ markiert")
}
if !strings.Contains(body, "/p/testprojekt/usage") {
t.Error("die Projektliste verlinkt nicht auf die Nutzungsseite")
}
db.LogAudit(context.Background(), store.AuditEvent{ProjectID: projekt.ID, Actor: "agent",
Action: store.ActionEntryWrite, Source: store.SourceMCP, Detail: store.DetailEntryCreated})
if !strings.Contains(seiteHolen(h, cookie, "/admin/projects").Body.String(), "1 neu") {
t.Error("der Schreibvorgang erscheint nicht in der Projektliste")
}
}