munin/internal/web/web_test.go
2026-10-19 10:13:56 +02:00

2805 lines
99 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(store.NewSQLite("", 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
project, 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, project
}
// 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
}
// createKey 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 createKey(t *testing.T, srv *Server, db *store.Store, projectID, name string, role auth.Role) string {
t.Helper()
plaintext, err := auth.GenerateKey()
if err != nil {
t.Fatal(err)
}
hash := auth.HashKey(plaintext)
if _, err := db.CreateAPIKey(context.Background(), projectID, name, hash, string(role)); err != nil {
t.Fatal(err)
}
project, err := db.ProjectByID(context.Background(), projectID)
if err != nil {
t.Fatal(err)
}
if err := srv.control.IndexKey(context.Background(), hash, project.WorkspaceID); err != nil {
t.Fatal(err)
}
return plaintext
}
// platformSubject 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 platformSubject(workspaceID string) string {
signer := auth.NewSigner([]byte(testSecret))
return auth.PlatformSubject(signer.Fingerprint(auth.HashKey(testAdminToken)), workspaceID)
}
// selectWorkspace 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 selectWorkspace(t *testing.T, h http.Handler, cookie *http.Cookie, workspaceID string) *http.Cookie {
t.Helper()
token := auth.NewSigner([]byte(testSecret)).CSRFToken(platformSubject(""))
form := url.Values{"id": {workspaceID}, auth.CSRFFieldName: {token}}
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/select", strings.NewReader(form.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
}
// loginAsPlatformAdmin 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 loginAsPlatformAdmin(t *testing.T, h http.Handler, workspaceID string) *http.Cookie {
t.Helper()
cookie := login(t, h, testAdminToken)
return selectWorkspace(t, h, cookie, workspaceID)
}
// login führt eine Anmeldung durch und gibt den Session-Cookie zurück.
func login(t *testing.T, h http.Handler, key string) *http.Cookie {
t.Helper()
form := url.Values{"key": {key}}
r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.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)
protected := []string{"/", "/admin/projects", "/p/testprojekt/", "/p/testprojekt/keys"}
for _, filePath := range protected {
t.Run(filePath, func(t *testing.T) {
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, filePath, 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, project := testServer(t)
h := testHandler(t, srv)
key := createKey(t, srv, db, project.ID, "test", auth.RoleWrite)
cookie := login(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)
form := url.Values{"key": {"wk_" + strings.Repeat("0", 64)}}
r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.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")
}
}
}
// postLogin schickt das Anmeldeformular ab und gibt die Antwort zurück — ohne
// wie login auf Erfolg zu bestehen.
func postLogin(h http.Handler, key string) *httptest.ResponseRecorder {
form := url.Values{"key": {key}}
r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
// TestLoginLockoutAppliesToCorrectKey prüft, dass eine gesperrte IP auch mit
// dem richtigen Token nicht hereinkommt. Sonst änderte die Sperre nur den
// Statuscode, und Raten ginge in voller Geschwindigkeit weiter.
func TestLoginLockoutAppliesToCorrectKey(t *testing.T) {
srv, _, _ := testServer(t)
h := testHandler(t, srv)
for i := 0; i < 5; i++ {
postLogin(h, "falsch-geraten")
}
w := postLogin(h, testAdminToken)
if w.Code != http.StatusTooManyRequests {
t.Fatalf("Status = %d, erwartet 429 trotz richtigem Token", w.Code)
}
for _, c := range w.Result().Cookies() {
if c.Name == auth.SessionCookieName && c.Value != "" {
t.Error("gesperrte IP hat eine Sitzung bekommen")
}
}
}
// TestValidLoginDoesNotResetLockout: Eine erfolgreiche Anmeldung (etwa mit dem
// eigenen Key) darf die Fehlversuche nicht löschen — sonst ließe sich die
// Sperre damit nach jedem vierten Versuch aushebeln.
func TestValidLoginDoesNotResetLockout(t *testing.T) {
srv, _, _ := testServer(t)
h := testHandler(t, srv)
for i := 0; i < 4; i++ {
postLogin(h, "falsch-geraten")
}
if w := postLogin(h, testAdminToken); w.Code != http.StatusSeeOther {
t.Fatalf("Anmeldung nach 4 Fehlversuchen: Status %d, erwartet 303", w.Code)
}
postLogin(h, "falsch-geraten") // der fünfte zählt weiter, statt von vorn
if w := postLogin(h, testAdminToken); w.Code != http.StatusTooManyRequests {
t.Errorf("Status = %d, erwartet 429 — die erfolgreiche Anmeldung hat den Zähler zurückgesetzt", w.Code)
}
}
// TestHintRequiresSignature prüft, dass nur vom Server signierte Meldungen
// erscheinen. Ein präparierter Link mit eigenem Text zeigt nichts an.
func TestHintRequiresSignature(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
cookie := login(t, h, createKey(t, srv, db, project.ID, "test", auth.RoleWrite))
list := "/p/" + project.Slug + "/"
forged := fetchPage(h, cookie, list+"?hinweis="+url.QueryEscape("Bitte Key an boese@example.org senden")).Body.String()
if strings.Contains(forged, "boese@example.org") {
t.Error("unsignierte Meldung wird angezeigt")
}
// Signatur zu einem anderen Text: ebenso nichts.
other := srv.withHint(list, "Echte Meldung")
tampered := strings.Replace(other, url.QueryEscape("Echte Meldung"), url.QueryEscape("Andere Meldung"), 1)
if strings.Contains(fetchPage(h, cookie, tampered).Body.String(), "Andere Meldung") {
t.Error("Meldung mit fremder Signatur wird angezeigt")
}
if !strings.Contains(fetchPage(h, cookie, other).Body.String(), "Echte Meldung") {
t.Error("signierte Meldung fehlt")
}
}
// TestRevokedKeyEndsSession prüft, dass ein zurückgezogener Key sofort wirkt —
// die Sitzung darf ihn nicht überleben.
func TestRevokedKeyEndsSession(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
key := createKey(t, srv, db, project.ID, "test", auth.RoleWrite)
cookie := login(t, h, key)
keys, _ := db.ListAPIKeys(ctx, project.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, project := testServer(t)
h := testHandler(t, srv)
key := createKey(t, srv, db, project.ID, "test", auth.RoleAdmin)
cookie := login(t, h, key)
form := url.Values{"name": {"geklaut"}, "role": {"admin"}}
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/keys/create", strings.NewReader(form.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(), project.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, project := testServer(t)
h := testHandler(t, srv)
key := createKey(t, srv, db, project.ID, "test", auth.RoleAdmin)
cookie := login(t, h, key)
// Das Token so berechnen, wie es das Template tut.
keys, _ := db.ListAPIKeys(context.Background(), project.ID)
subject := auth.SubjectKeyPrefix + project.WorkspaceID + ":" + keys[0].ID
token := auth.NewSigner([]byte(testSecret)).CSRFToken(subject)
form := url.Values{"name": {"zweiter"}, "role": {"read"}, auth.CSRFFieldName: {token}}
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/keys/create", strings.NewReader(form.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)
}
fresh, _ := db.ListAPIKeys(context.Background(), project.ID)
if len(fresh) != 2 {
t.Errorf("%d Keys, erwartet 2", len(fresh))
}
// 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, projectA := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
projectB, err := db.CreateProject(ctx, projectA.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: projectB.ID, Slug: "geheim", Title: "Geheim",
Content: "Vertrauliches aus Projekt B", Source: store.SourceHuman,
}); err != nil {
t.Fatal(err)
}
keyA := createKey(t, srv, db, projectA.ID, "a", auth.RoleAdmin)
cookie := login(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.
foreignRoutes := []string{
"/p/fremd/",
"/p/fremd/e/geheim",
"/p/fremd/keys",
"/p/fremd/audit",
}
for _, filePath := range foreignRoutes {
t.Run(filePath, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, filePath, 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, _, projectA := 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)
}
projectB, 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: projectB.ID, Slug: "geheim", Title: "Geheim",
Content: "Vertrauliches aus Workspace B", Source: store.SourceHuman,
}); err != nil {
t.Fatal(err)
}
keyB := createKey(t, srv, workspaceB.Store, projectB.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 := login(t, h, keyB)
r := httptest.NewRequest(http.MethodGet, "/p/"+projectA.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, _, project := testServer(t)
h := testHandler(t, srv)
cookie := loginAsPlatformAdmin(t, h, project.WorkspaceID)
token := auth.NewSigner([]byte(testSecret)).
CSRFToken(platformSubject(project.WorkspaceID))
form := url.Values{"name": {"Marketing"}, auth.CSRFFieldName: {token}}
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/create", strings.NewReader(form.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 found bool
for _, ws := range workspaces {
if ws.Name == "Marketing" {
found = true
}
}
if !found {
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 newCookie *http.Cookie
for _, c := range w.Result().Cookies() {
if c.Name == auth.SessionCookieName {
newCookie = c
}
}
if newCookie == nil {
t.Fatal("keine neue Sitzung nach dem Anlegen ausgestellt")
}
r2 := httptest.NewRequest(http.MethodGet, "/admin/projects", nil)
r2.AddCookie(newCookie)
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 —
// und unter dem Namen den Speicherort (Datei bzw. Schema).
func TestWorkspaceListShowsProjectCount(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
if _, err := db.CreateProject(context.Background(), project.WorkspaceID, "zweites", "Zweites", ""); err != nil {
t.Fatal(err)
}
cookie := loginAsPlatformAdmin(t, h, project.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=\"num\">2</td>") {
t.Error("Projektzahl \"2\" steht nicht in der Übersicht")
}
// Unter dem Namen steht, wo die Daten liegen — hier die SQLite-Datei.
location := "workspaces/" + project.WorkspaceID + ".db"
if !strings.Contains(w.Body.String(), location) {
t.Errorf("Speicherort %q steht nicht in der Übersicht", location)
}
}
// TestWorkspaceRename prüft das Umbenennen über /admin/workspaces/{id}/edit.
func TestWorkspaceRename(t *testing.T) {
srv, _, project := testServer(t)
h := testHandler(t, srv)
cookie := loginAsPlatformAdmin(t, h, project.WorkspaceID)
token := auth.NewSigner([]byte(testSecret)).
CSRFToken(platformSubject(project.WorkspaceID))
form := url.Values{"name": {"Umbenannt"}, auth.CSRFFieldName: {token}}
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/"+project.WorkspaceID+"/edit",
strings.NewReader(form.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(), project.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, _, project := testServer(t)
h := testHandler(t, srv)
cookie := loginAsPlatformAdmin(t, h, project.WorkspaceID)
token := auth.NewSigner([]byte(testSecret)).
CSRFToken(platformSubject(project.WorkspaceID))
form := url.Values{"id": {project.WorkspaceID}, "confirm": {"falsch"}, auth.CSRFFieldName: {token}}
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/delete", strings.NewReader(form.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(), project.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, _, project := testServer(t)
h := testHandler(t, srv)
ws, err := srv.control.WorkspaceByID(context.Background(), project.WorkspaceID)
if err != nil {
t.Fatal(err)
}
cookie := loginAsPlatformAdmin(t, h, project.WorkspaceID)
token := auth.NewSigner([]byte(testSecret)).
CSRFToken(platformSubject(project.WorkspaceID))
form := url.Values{"id": {project.WorkspaceID}, "confirm": {ws.Name}, auth.CSRFFieldName: {token}}
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/delete", strings.NewReader(form.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(), project.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 newCookie *http.Cookie
for _, c := range w.Result().Cookies() {
if c.Name == auth.SessionCookieName {
newCookie = c
}
}
if newCookie == nil {
t.Fatal("keine neue Sitzung nach dem Löschen ausgestellt")
}
r2 := httptest.NewRequest(http.MethodGet, "/admin/projects", nil)
r2.AddCookie(newCookie)
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, project := testServer(t)
h := testHandler(t, srv)
key := createKey(t, srv, db, project.ID, "test", auth.RoleAdmin)
cookie := login(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, _, project := testServer(t)
h := testHandler(t, srv)
cookie := loginAsPlatformAdmin(t, h, project.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)
}
}
// statusWithCookie ruft eine Seite mit dem gegebenen Cookie ab und gibt den
// Status zurück.
func statusWithCookie(h http.Handler, filePath string, cookie *http.Cookie) int {
r := httptest.NewRequest(http.MethodGet, filePath, nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w.Code
}
// TestNewAdminTokenEndsAdminSessions 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 TestNewAdminTokenEndsAdminSessions(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
withoutWorkspace := login(t, h, testAdminToken)
withWs := selectWorkspace(t, h, withoutWorkspace, project.WorkspaceID)
team := login(t, h, createKey(t, srv, db, project.ID, "team", auth.RoleAdmin))
if code := statusWithCookie(h, "/p/testprojekt/", withWs); 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 newToken = "ein-ganz-neues-admin-token"
newer, err := NewServer(Config{
Workspaces: srv.workspaces,
Control: srv.control,
Signer: srv.signer,
Cookies: srv.cookies,
Limiter: auth.NewRateLimiter(),
Templates: templatesForTest(t),
AdminToken: newToken,
WriteBudget: srv.writeBudget,
})
if err != nil {
t.Fatal(err)
}
hNew := testHandler(t, newer)
for name, cookie := range map[string]*http.Cookie{
"ohne Workspace": withoutWorkspace,
"mit Workspace": withWs,
} {
code := statusWithCookie(hNew, "/admin/workspaces", cookie)
if code != http.StatusSeeOther {
t.Errorf("alte Admin-Sitzung %s: Status %d, erwartet Weiterleitung zur Anmeldung", name, code)
}
}
if code := statusWithCookie(hNew, "/p/testprojekt/", team); code != http.StatusOK {
t.Errorf("Sitzung eines Teams: Status %d, erwartet 200 — ein neues ADMIN_TOKEN darf Teams nicht abmelden", code)
}
fresh := login(t, hNew, newToken)
if code := statusWithCookie(hNew, "/admin/workspaces", fresh); code != http.StatusOK {
t.Errorf("Anmeldung mit dem neuen Token: Status %d, erwartet 200", code)
}
}
// TestOldAdminSessionWithoutFingerprintIsInvalid: 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 TestOldAdminSessionWithoutFingerprintIsInvalid(t *testing.T) {
srv, _, project := testServer(t)
h := testHandler(t, srv)
signer := auth.NewSigner([]byte(testSecret))
for _, subject := range []string{"platform", "platform:" + project.WorkspaceID} {
cookie := &http.Cookie{Name: auth.SessionCookieName, Value: signer.SignSession(subject, time.Now().Add(time.Hour))}
if code := statusWithCookie(h, "/admin/workspaces", cookie); code != http.StatusSeeOther {
t.Errorf("%q: Status %d, erwartet Weiterleitung zur Anmeldung", subject, code)
}
}
}
// TestPlatformAdminSessionIsShort: Die Sitzung des Plattform-Admins gilt 12
// Stunden, und ein Workspace-Wechsel verlängert sie nicht — sonst ließe sich
// ein gestohlener Cookie durch Umschalten ewig am Leben halten.
func TestPlatformAdminSessionIsShort(t *testing.T) {
srv, _, project := testServer(t)
h := testHandler(t, srv)
cookie := login(t, h, testAdminToken)
if left := time.Until(cookie.Expires); left < auth.PlatformAdminSessionTTL-time.Minute || left > auth.PlatformAdminSessionTTL {
t.Fatalf("Admin-Sitzung gilt noch %v, erwartet %v", left.Round(time.Minute), auth.PlatformAdminSessionTTL)
}
switched := selectWorkspace(t, h, cookie, project.WorkspaceID)
if !switched.Expires.Equal(cookie.Expires) {
t.Errorf("Workspace-Wechsel hat den Ablauf verschoben: %v statt %v", switched.Expires, cookie.Expires)
}
}
// ── Rollen ───────────────────────────────────────────────────────────────────
// TestReadRoleCannotWrite prüft, dass ein Nur-Lese-Zugang keine Einträge ändern kann.
func TestReadRoleCannotWrite(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
key := createKey(t, srv, db, project.ID, "nur-lesen", auth.RoleRead)
cookie := login(t, h, key)
writeRoutes := []struct{ method, filePath string }{
{http.MethodGet, "/p/testprojekt/new"},
{http.MethodPost, "/p/testprojekt/save"},
}
for _, tc := range writeRoutes {
t.Run(tc.filePath, func(t *testing.T) {
r := httptest.NewRequest(tc.method, tc.filePath, 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, project := testServer(t)
h := testHandler(t, srv)
key := createKey(t, srv, db, project.ID, "schreiber", auth.RoleWrite)
cookie := login(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, project := testServer(t)
h := testHandler(t, srv)
admin := createKey(t, srv, db, project.ID, "verwalterin", auth.RoleAdmin)
createKey(t, srv, db, project.ID, "neue-pipeline", auth.RoleRead)
cookie := login(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))
want := map[string]string{
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "same-origin",
}
for name, value := range want {
if got := w.Header().Get(name); got != value {
t.Errorf("%s = %q, erwartet %q", name, got, value)
}
}
csp := w.Header().Get("Content-Security-Policy")
for _, part := range []string{"default-src 'self'", "frame-ancestors 'none'", "object-src 'none'", "img-src 'self' data:;"} {
if !strings.Contains(csp, part) {
t.Errorf("CSP enthält %q nicht", part)
}
}
// Externe Bilder wären ein Zählpixel für jeden Leser.
if strings.Contains(csp, "https:") {
t.Errorf("CSP erlaubt externe Quellen: %q", csp)
}
// 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 readErr 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" {
readErr = err
}
return
}
}
})
h := LimitBody(inner, 100)
h.ServeHTTP(httptest.NewRecorder(),
httptest.NewRequest(http.MethodPost, "/", strings.NewReader(strings.Repeat("x", 5000))))
if readErr == nil {
t.Error("ein zu großer Body wurde ohne Fehler durchgelassen")
}
}
// ── API-Zugang ───────────────────────────────────────────────────────────────
func TestAPIAuthRejectsBadKey(t *testing.T) {
srv, _, _ := testServer(t)
var reached bool
h := srv.APIAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached = true }))
cases := 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 cases {
t.Run(name, func(t *testing.T) {
reached = 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 reached {
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, project := testServer(t)
key := createKey(t, srv, db, project.ID, "agent", auth.RoleWrite)
var seen store.Project
h := srv.APIAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen, _ = ProjectFrom(r)
}))
r := httptest.NewRequest(http.MethodPost, "/mcp", nil)
r.Header.Set("Authorization", "Bearer "+key)
h.ServeHTTP(httptest.NewRecorder(), r)
if seen.ID != project.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, project := testServer(t)
key := createKey(t, srv, db, project.ID, "agent", auth.RoleWrite)
var reached bool
h := srv.APIAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached = 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 !reached {
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, project := testServer(t)
h := testHandler(t, srv)
key := createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite)
cookie := login(t, h, key)
keys, _ := db.ListAPIKeys(context.Background(), project.ID)
token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + project.WorkspaceID + ":" + keys[0].ID)
form := 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(form.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(), project.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.
events, err := db.ListAudit(context.Background(), store.AuditFilter{ProjectID: project.ID})
if err != nil {
t.Fatal(err)
}
var found bool
for _, ev := range events {
if ev.Action == store.ActionEntryWrite && ev.EntrySlug == "deployment-prozess" {
found = true
if ev.Detail != store.DetailEntryCreated {
t.Errorf("Detail = %q, erwartet %q", ev.Detail, store.DetailEntryCreated)
}
}
}
if !found {
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, project := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
a, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "a", Title: "A",
Tags: []string{"alt"}, Source: store.SourceHuman})
b, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "b", Title: "B",
Tags: []string{"alt"}, Source: store.SourceHuman})
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "c", Title: "C",
Tags: []string{"alt"}, Source: store.SourceHuman})
key := createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite)
cookie := login(t, h, key)
keys, _ := db.ListAPIKeys(ctx, project.ID)
token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + project.WorkspaceID + ":" + keys[0].ID)
form := 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(form.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())
}
freshA, _ := db.EntryBySlug(ctx, project.ID, "a")
if len(freshA.Tags) != 1 || freshA.Tags[0] != "neu" {
t.Errorf("Tags von A = %v, erwartet [neu]", freshA.Tags)
}
unchangedC, _ := db.EntryBySlug(ctx, project.ID, "c")
if len(unchangedC.Tags) != 1 || unchangedC.Tags[0] != "alt" {
t.Errorf("Tags von C = %v — C war nicht ausgewählt", unchangedC.Tags)
}
}
// bulkDelete schickt die Sammelauswahl der Eintragsliste an bulk-delete.
// role bestimmt den Key, mit dem angemeldet wird; extra ergänzt das Formular
// (etwa um tag oder q).
func bulkDelete(t *testing.T, srv *Server, db *store.Store, project store.Project,
role auth.Role, ids []string, extra url.Values) *httptest.ResponseRecorder {
t.Helper()
h := testHandler(t, srv)
key := createKey(t, srv, db, project.ID, "mensch-"+string(role), role)
cookie := login(t, h, key)
keys, _ := db.ListAPIKeys(context.Background(), project.ID)
var keyID string
for _, k := range keys {
if k.Name == "mensch-"+string(role) {
keyID = k.ID
}
}
token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + project.WorkspaceID + ":" + keyID)
form := url.Values{"ids": ids, auth.CSRFFieldName: {token}}
for k, v := range extra {
form[k] = v
}
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/entries/bulk-delete", strings.NewReader(form.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, project := testServer(t)
ctx := context.Background()
a, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "a", Title: "A", Source: store.SourceHuman})
b, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "b", Title: "B", Source: store.SourceHuman})
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "c", Title: "C", Source: store.SourceHuman})
w := bulkDelete(t, srv, db, project, 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 target := w.Header().Get("Location"); !strings.Contains(target, "tag=ops") || !strings.Contains(target, "hinweis=") {
t.Errorf("Weiterleitung nach %q — Filter oder Hinweis fehlt", target)
}
trash, _ := db.ListTrash(ctx, project.ID, "")
if len(trash) != 2 {
t.Errorf("%d Einträge im Papierkorb, erwartet 2", len(trash))
}
if _, err := db.EntryBySlug(ctx, project.ID, "c"); err != nil {
t.Errorf("C war nicht ausgewählt und ist trotzdem weg: %v", err)
}
events, _ := db.ListAudit(ctx, store.AuditFilter{ProjectID: project.ID})
deletions := 0
for _, e := range events {
if e.Action == store.ActionEntryDelete {
deletions++
}
}
if deletions != 2 {
t.Errorf("%d Löschungen im Protokoll, erwartet eine je Eintrag (2)", deletions)
}
}
// TestEntryBulkDeleteRequiresWriteRole: Mit Leserecht wird nichts gelöscht.
func TestEntryBulkDeleteRequiresWriteRole(t *testing.T) {
srv, db, project := testServer(t)
ctx := context.Background()
a, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "a", Title: "A", Source: store.SourceHuman})
w := bulkDelete(t, srv, db, project, 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, project.ID, "a"); err != nil {
t.Errorf("mit Leserecht wurde gelöscht: %v", err)
}
}
// TestEntryListShowsBulkDelete: Der Knopf steht mit Rückfrage in der
// Liste — und zwar hinter „Anwenden", damit Enter in einem Schlagwortfeld nie
// löscht.
func TestEntryListShowsBulkDelete(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
db.PutEntry(context.Background(), store.EntryWrite{ProjectID: project.ID, Slug: "a", Title: "A", Source: store.SourceHuman})
writer := login(t, h, createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite))
body := fetchPage(h, writer, "/p/testprojekt/").Body.String()
apply := strings.Index(body, ">Anwenden<")
deleteIt := strings.Index(body, `formaction="/p/testprojekt/entries/bulk-delete"`)
if apply < 0 || deleteIt < 0 {
t.Fatal("Sammelleiste ohne Anwenden oder ohne Löschknopf")
}
if deleteIt < apply {
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")
}
reader := login(t, h, createKey(t, srv, db, project.ID, "leser", auth.RoleRead))
if strings.Contains(fetchPage(h, reader, "/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, project := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "e", Title: "T",
Content: "Zeile 1\nZeile 2", Source: store.SourceHuman, Author: "tom"})
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "e", Title: "T",
Content: "Zeile 1\nGeändert", Source: store.SourceHuman, Author: "tom"})
key := createKey(t, srv, db, project.ID, "leser", auth.RoleRead)
cookie := login(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)
}
}
// restoreRequest baut den POST auf den Wiederherstellen-Knopf —
// so, wie ihn das Formular in entry-diff.html abschickt.
func restoreRequest(t *testing.T, db *store.Store, project store.Project, cookie *http.Cookie, version, current string) *http.Request {
t.Helper()
keys, _ := db.ListAPIKeys(context.Background(), project.ID)
token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + project.WorkspaceID + ":" + keys[0].ID)
form := url.Values{"current_version": {current}, auth.CSRFFieldName: {token}}
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/e/e/versions/"+version+"/restore",
strings.NewReader(form.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, project := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "e", Title: "Alt",
Content: "guter Text", Tags: []string{"a"}, Source: store.SourceHuman, Author: "tom"})
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "e", Title: "Kaputt",
Content: "von einem Agenten zerstört", Tags: []string{"b"}, Source: store.SourceAgent, Author: "agent"})
key := createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite)
cookie := login(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, restoreRequest(t, db, project, 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, project.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.
two, err := db.EntryVersionByNumber(ctx, e.ID, 2)
if err != nil || two.Content != "von einem Agenten zerstört" {
t.Errorf("verdrängte Fassung fehlt im Verlauf: %v / %q", err, two.Content)
}
events, _ := db.ListAudit(ctx, store.AuditFilter{ProjectID: project.ID})
found := false
for _, ev := range events {
if ev.Action == store.ActionEntryWrite && ev.Detail == store.EntryRestoreDetail(1) {
found = true
}
}
if !found {
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, project := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
for _, content := range []string{"eins", "zwei", "drei"} {
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "e", Title: "T",
Content: content, Source: store.SourceHuman})
}
key := createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite)
cookie := login(t, h, key)
// Die Seite wurde bei Fassung 2 angezeigt, inzwischen gilt Fassung 3.
w := httptest.NewRecorder()
h.ServeHTTP(w, restoreRequest(t, db, project, cookie, "1", "2"))
if w.Code != http.StatusConflict {
t.Fatalf("Status = %d, erwartet 409", w.Code)
}
e, _ := db.EntryBySlug(ctx, project.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, restoreRequest(t, db, project, 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, project := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "e", Title: "T", Content: "eins", Source: store.SourceHuman})
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "e", Title: "T", Content: "zwei", Source: store.SourceHuman})
key := createKey(t, srv, db, project.ID, "leser", auth.RoleRead)
cookie := login(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, restoreRequest(t, db, project, cookie, "1", "2"))
if w.Code != http.StatusForbidden {
t.Errorf("Status = %d, erwartet 403", w.Code)
}
e, _ := db.EntryBySlug(ctx, project.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, project := testServer(t)
h := testHandler(t, srv)
key := createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite)
cookie := login(t, h, key)
keys, _ := db.ListAPIKeys(context.Background(), project.ID)
token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + project.WorkspaceID + ":" + keys[0].ID)
form := url.Values{
"content": {"# Überschrift\n\nEin Verweis auf [[anderer-eintrag]]."},
auth.CSRFFieldName: {token},
}
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/preview", strings.NewReader(form.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.
count, _ := db.CountEntries(context.Background(), project.ID)
if count != 0 {
t.Errorf("%d Einträge nach reiner Vorschau, erwartet 0", count)
}
}
// TestEntryPreviewRequiresWriteRole: ein Nur-Lese-Zugang darf die Vorschau
// nicht aufrufen — konsistent mit dem Bearbeitungsformular selbst.
func TestEntryPreviewRequiresWriteRole(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
key := createKey(t, srv, db, project.ID, "leser", auth.RoleRead)
cookie := login(t, h, key)
keys, _ := db.ListAPIKeys(context.Background(), project.ID)
token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + project.WorkspaceID + ":" + keys[0].ID)
form := url.Values{"content": {"Text"}, auth.CSRFFieldName: {token}}
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/preview", strings.NewReader(form.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, project := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "deployment-prozess",
Title: "Deployment-Prozess", Content: "Wie wir deployen", Source: store.SourceHuman})
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "urlaubsantrag",
Title: "Urlaubsantrag", Content: "Formular für Urlaub", Source: store.SourceHuman})
key := createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite)
cookie := login(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 hits []suggestItem
if err := json.Unmarshal(w.Body.Bytes(), &hits); err != nil {
t.Fatalf("Antwort ist kein gültiges JSON: %v (%s)", err, w.Body.String())
}
if len(hits) != 1 || hits[0].Slug != "deployment-prozess" {
t.Errorf("Treffer = %+v, erwartet genau \"deployment-prozess\"", hits)
}
// 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 all []suggestItem
if err := json.Unmarshal(w2.Body.Bytes(), &all); err != nil {
t.Fatalf("Antwort ist kein gültiges JSON: %v", err)
}
if len(all) != 2 {
t.Errorf("%d Vorschläge ohne Suchbegriff, erwartet 2", len(all))
}
}
// 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, project := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "a", Title: "A",
Content: "verweist auf [[b]] und [[fehlt]]", Source: store.SourceHuman})
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "b", Title: "B",
Content: "kein Verweis", Source: store.SourceHuman})
key := createKey(t, srv, db, project.ID, "leser", auth.RoleRead)
cookie := login(t, h, key)
// Nur Lesen reicht — die Graph-Ansicht selbst hängt an inProject, nicht
// an withWriteAccess.
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 data graphResponse
if err := json.Unmarshal(w.Body.Bytes(), &data); err != nil {
t.Fatalf("Antwort ist kein gültiges JSON: %v (%s)", err, w.Body.String())
}
if len(data.Nodes) != 3 {
t.Fatalf("%d Knoten, erwartet 3 (a, b, fehlt)", len(data.Nodes))
}
if len(data.Edges) != 2 {
t.Errorf("%d Kanten, erwartet 2", len(data.Edges))
}
toSlug := make(map[string]graphNode, len(data.Nodes))
for _, n := range data.Nodes {
toSlug[n.Slug] = n
}
if !toSlug["a"].Exists {
t.Error("Knoten \"a\" sollte Exists = true tragen")
}
if toSlug["fehlt"].Exists {
t.Error("Knoten \"fehlt\" sollte Exists = false tragen — es gibt keinen Eintrag dazu")
}
if data.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, project := testServer(t)
h := testHandler(t, srv)
key := createKey(t, srv, db, project.ID, "leser", auth.RoleRead)
cookie := login(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, project := 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: project.ID, Slug: slug, Title: slug,
Content: "kein Verweis", Source: store.SourceHuman})
}
key := createKey(t, srv, db, project.ID, "leser", auth.RoleRead)
cookie := login(t, h, key)
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/graph/data", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
var data graphResponse
if err := json.Unmarshal(w.Body.Bytes(), &data); err != nil {
t.Fatalf("Antwort ist kein gültiges JSON: %v", err)
}
if !data.Truncated {
t.Error("Truncated = false, obwohl mehr Einträge als graphNodeLimit existieren")
}
if len(data.Nodes) != graphNodeLimit {
t.Errorf("%d Knoten in der Antwort, erwartet genau graphNodeLimit (%d)", len(data.Nodes), graphNodeLimit)
}
if data.Total != graphNodeLimit+5 {
t.Errorf("Total = %d, erwartet %d", data.Total, graphNodeLimit+5)
}
}
// TestEntryContentIsEscaped ist ein Sicherheitstest: Eingeschleustes HTML darf
// nicht ausgeführt werden.
func TestEntryContentIsEscaped(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
if _, err := db.PutEntry(ctx, store.EntryWrite{
ProjectID: project.ID, Slug: "boese", Title: "Test",
Content: "<script>alert(1)</script>\n\nText", Source: store.SourceHuman,
}); err != nil {
t.Fatal(err)
}
key := createKey(t, srv, db, project.ID, "leser", auth.RoleRead)
cookie := login(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 ────────────────────────────────────────────────────────
// TestLogRequestsWritesLine prüft, dass jede Anfrage protokolliert wird.
func TestLogRequestsWritesLine(t *testing.T) {
var buf bytes.Buffer
log.SetOutput(&buf)
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))
line := buf.String()
for _, part := range []string{"418", "POST", "/p/test/save"} {
if !strings.Contains(line, part) {
t.Errorf("%q fehlt in der Protokollzeile: %s", part, line)
}
}
}
// TestLogRequestsSilentOnNoise 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 TestLogRequestsSilentOnNoise(t *testing.T) {
var buf bytes.Buffer
log.SetOutput(&buf)
defer log.SetOutput(os.Stderr)
h := LogRequests(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
for _, filePath := range []string{"/live", "/ready", "/healthz", "/static/style.css"} {
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, filePath, nil))
}
if buf.Len() > 0 {
t.Errorf("Rauschen wurde protokolliert: %s", buf.String())
}
}
// TestLogRequestsDoesNotLogSearchTerms ist ein Datenschutztest:
// Die Query-Zeichenkette enthält Suchbegriffe, und die sind Inhalt, kein
// Betriebsdatum.
func TestLogRequestsDoesNotLogSearchTerms(t *testing.T) {
var buf bytes.Buffer
log.SetOutput(&buf)
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(buf.String(), "gehaltsverhandlung") {
t.Errorf("der Suchbegriff steht im Protokoll: %s", buf.String())
}
}
// TestLogRequestsNamesTheKey prüft, dass der verwendete Zugang mit
// protokolliert wird — sonst sieht man nicht, welcher Agent Last erzeugt.
func TestLogRequestsNamesTheKey(t *testing.T) {
var buf bytes.Buffer
log.SetOutput(&buf)
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.
inside := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
withSession(r.Context(), Session{
Subject: "key:1",
Key: store.APIKey{Name: "claude-desktop"},
})
})
LogRequests(inside).ServeHTTP(httptest.NewRecorder(),
httptest.NewRequest(http.MethodGet, "/p/test/", nil))
if !strings.Contains(buf.String(), "claude-desktop") {
t.Errorf("der Zugang fehlt im Protokoll: %s", buf.String())
}
}
// TestLogRequestsWithoutLogin prüft die Gegenrichtung: Eine abgewiesene
// Anfrage wird protokolliert, nur eben ohne Namen.
func TestLogRequestsWithoutLogin(t *testing.T) {
var buf bytes.Buffer
log.SetOutput(&buf)
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))
line := buf.String()
if !strings.Contains(line, "401") {
t.Errorf("abgewiesene Anfrage wurde nicht protokolliert: %s", line)
}
if !strings.Contains(line, "—") {
t.Errorf("fehlender Zugang wird nicht als \"—\" dargestellt: %s", line)
}
}
// TestStatusWriterPassesFlusherThrough 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 TestStatusWriterPassesFlusherThrough(t *testing.T) {
var found bool
h := LogRequests(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, found = w.(http.Flusher)
}))
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/mcp", nil))
if !found {
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, project := testServer(t)
h := testHandler(t, srv)
w := adminPost(t, h, project.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())
}
read, err := db.ProjectByID(context.Background(), project.ID)
if err != nil {
t.Fatal(err)
}
if read.Name != "Neuer Name" {
t.Errorf("Name = %q, erwartet \"Neuer Name\"", read.Name)
}
if read.Description != "Neue Beschreibung" {
t.Errorf("Description = %q, erwartet \"Neue Beschreibung\"", read.Description)
}
if read.WriteLimitHourly != 250 {
t.Errorf("WriteLimitHourly = %d, erwartet 250", read.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, project := testServer(t)
h := testHandler(t, srv)
if err := db.UpdateProject(context.Background(), project.ID, store.ProjectUpdate{
Name: "Testprojekt", Description: "Eine Beschreibung", WriteLimitHourly: 250,
}); err != nil {
t.Fatal(err)
}
cookie := loginAsPlatformAdmin(t, h, project.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)
}
body := w.Body.String()
for _, want := range []string{`value="Eine Beschreibung"`, `value="250"`} {
if !strings.Contains(body, want) {
t.Errorf("das Formular enthält %s nicht", want)
}
}
}
// 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, project := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
if err := db.UpdateProject(ctx, project.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, project.WorkspaceID, "/admin/projects/testprojekt/edit", url.Values{
"name": {"Anderer Name"},
"description": {""},
"write_limit_hourly": {"250"},
})
read, _ := db.ProjectByID(ctx, project.ID)
if read.WriteLimitHourly != 250 {
t.Errorf("WriteLimitHourly = %d — die Obergrenze ging beim Speichern verloren", read.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, project := testServer(t)
h := testHandler(t, srv)
key := createKey(t, srv, db, project.ID, "test", auth.RoleAdmin)
cookie := login(t, h, key)
keys, _ := db.ListAPIKeys(context.Background(), project.ID)
token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + project.WorkspaceID + ":" + keys[0].ID)
form := url.Values{
"name": {"Gekapert"},
"write_limit_hourly": {"999999"},
auth.CSRFFieldName: {token},
}
r := httptest.NewRequest(http.MethodPost, "/admin/projects/testprojekt/edit", strings.NewReader(form.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)
}
read, _ := db.ProjectByID(context.Background(), project.ID)
if read.WriteLimitHourly != 0 {
t.Errorf("WriteLimitHourly = %d — die Obergrenze wurde trotzdem geändert", read.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, project := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
if err := db.UpdateProject(ctx, project.ID, store.ProjectUpdate{
Name: "Testprojekt", WriteLimitHourly: 42,
}); err != nil {
t.Fatal(err)
}
w := adminPost(t, h, project.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)
}
read, _ := db.ProjectByID(ctx, project.ID)
if read.WriteLimitHourly != 42 {
t.Errorf("WriteLimitHourly = %d — die alte Obergrenze wurde überschrieben", read.WriteLimitHourly)
}
}
// TestProjectEditRejectsEmptyName: Ein Projekt ohne Namen wäre in der Übersicht
// eine leere Zeile.
func TestProjectEditRejectsEmptyName(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
w := adminPost(t, h, project.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)
}
read, _ := db.ProjectByID(context.Background(), project.ID)
if read.Name != project.Name {
t.Errorf("Name = %q — der leere Name wurde gespeichert", read.Name)
}
}
// TestProjectEditUnknownSlug: ein Bezeichner, den es nicht gibt.
func TestProjectEditUnknownSlug(t *testing.T) {
srv, _, project := testServer(t)
h := testHandler(t, srv)
cookie := loginAsPlatformAdmin(t, h, project.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, filePath string, form url.Values) *httptest.ResponseRecorder {
t.Helper()
cookie := loginAsPlatformAdmin(t, h, workspaceID)
form.Set(auth.CSRFFieldName,
auth.NewSigner([]byte(testSecret)).CSRFToken(platformSubject(workspaceID)))
r := httptest.NewRequest(http.MethodPost, filePath, strings.NewReader(form.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 TestHelpRequiresLogin(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)
}
}
// TestHelpForReadRole 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 TestHelpForReadRole(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
key := createKey(t, srv, db, project.ID, "leser", auth.RoleRead)
cookie := login(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 TestHelpWithoutPageShowsStart(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
cookie := login(t, h, createKey(t, srv, db, project.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 TestHelpUnknownPage(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
cookie := login(t, h, createKey(t, srv, db, project.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)
}
}
// TestHelpForAdminWithoutWorkspace: Ein frisch angemeldeter Plattform-Admin hat
// noch keinen Workspace gewählt. Die Hilfe muss trotzdem erreichbar sein.
func TestHelpForAdminWithoutWorkspace(t *testing.T) {
srv, _, _ := testServer(t)
h := testHandler(t, srv)
cookie := login(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)
}
}
// helpTargetPattern findet in den Templates jedes Ziel innerhalb der Hilfe:
// sowohl {{template "helplink" "seite#abschnitt"}} als auch einfache Links
// href="/hilfe/seite". Die runden Klammern sind die "Fanggruppen" — daraus
// liest der Test Seite und (optional) Abschnitt.
var helpTargetPattern = regexp.MustCompile(`(?:template "helplink" "|href="/hilfe/)([a-z0-9-]+)(?:#([a-z0-9-]+))?"`)
// TestHelpLinksPointToExistingSections 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 TestHelpLinksPointToExistingSections(t *testing.T) {
srv, _, _ := testServer(t)
h := testHandler(t, srv)
// Als Plattform-Admin, damit auch die Admin-Hilfeseite erreichbar ist.
cookie := login(t, h, testAdminToken)
files, err := filepath.Glob("../../templates/*.html")
if err != nil || len(files) == 0 {
t.Fatalf("keine Templates gefunden: %v", err)
}
// Jede Hilfeseite nur einmal abrufen, auch wenn sie mehrfach verlinkt ist.
pages := map[string]string{}
found := 0
for _, file := range files {
content, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
for _, hits := range helpTargetPattern.FindAllStringSubmatch(string(content), -1) {
// hits[0] ist der ganze Fund, treffer[1] die Seite,
// hits[2] der Abschnitt (leer, wenn es keinen gibt).
page, section := hits[1], hits[2]
found++
html, alreadyFetched := pages[page]
if !alreadyFetched {
r := httptest.NewRequest(http.MethodGet, "/hilfe/"+page, 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(file), page, w.Code)
continue
}
html = w.Body.String()
pages[page] = html
}
if section != "" && !strings.Contains(html, `id="`+section+`"`) {
t.Errorf("%s: Abschnitt %q gibt es auf /hilfe/%s nicht", filepath.Base(file), section, page)
}
}
}
// Findet das Muster gar nichts, prüft der Test auch nichts — dann ist eher
// das Muster kaputt als die Templates.
if found < 5 {
t.Errorf("nur %d Hilfe-Ziele in den Templates gefunden — stimmt das Suchmuster noch?", found)
}
}
// TestHelpLinkInHTML: Das Partial erzeugt einen echten Link mit Abschnitt —
// html/template darf das "#" dabei nicht kaputt-maskieren.
func TestHelpLinkInHTML(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
cookie := login(t, h, createKey(t, srv, db, project.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) {
cases := []struct {
host, want 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 cases {
r := httptest.NewRequest(http.MethodGet, "/hilfe", nil)
r.Host = f.host
if got := serverURL(r); got != f.want {
t.Errorf("serverURL(%q) = %q, erwartet %q", f.host, got, f.want)
}
}
}
// TestHelpAdminPageOnlyForAdmins: Mit Projekt-Key ist die Admin-Seite weder
// verlinkt noch direkt aufrufbar; der Plattform-Admin sieht sie in der Leiste.
func TestHelpAdminPageOnlyForAdmins(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
fetch := func(cookie *http.Cookie, filePath string) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodGet, filePath, nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
team := login(t, h, createKey(t, srv, db, project.ID, "chefin", auth.RoleAdmin))
if w := fetch(team, "/hilfe/plattform-admin"); w.Code != http.StatusNotFound {
t.Errorf("Projekt-Key: Status = %d, erwartet 404", w.Code)
}
if strings.Contains(fetch(team, "/hilfe").Body.String(), `href="/hilfe/plattform-admin"`) {
t.Error("Projekt-Key sieht den Link auf die Admin-Seite")
}
admin := login(t, h, testAdminToken)
if !strings.Contains(fetch(admin, "/hilfe").Body.String(), `href="/hilfe/plattform-admin"`) {
t.Error("Plattform-Admin sieht den Link auf die Admin-Seite nicht")
}
}
// TestExportButtonInSecondWorkspace: 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 TestExportButtonInSecondWorkspace(t *testing.T) {
srv, _, projectA := testServer(t)
target := t.TempDir()
srv.exporter = export.New(target)
h := testHandler(t, srv)
ctx := context.Background()
wsB, err := srv.control.CreateWorkspace(ctx, "Vertrieb")
if err != nil {
t.Fatal(err)
}
openedB, err := srv.workspaces.Get(wsB.ID)
if err != nil {
t.Fatal(err)
}
// Absichtlich derselbe Bezeichner wie das Projekt im ersten Workspace.
projectB, err := openedB.Store.CreateProject(ctx, wsB.ID, projectA.Slug, "Vertriebsprojekt", "")
if err != nil {
t.Fatal(err)
}
if _, err := openedB.Store.PutEntry(ctx, store.EntryWrite{
ProjectID: projectB.ID, Slug: "angebote", Title: "Angebote",
Content: "Wissen aus dem Vertrieb", Source: store.SourceHuman,
}); err != nil {
t.Fatal(err)
}
cookie := loginAsPlatformAdmin(t, h, wsB.ID)
token := auth.NewSigner([]byte(testSecret)).CSRFToken(platformSubject(wsB.ID))
form := url.Values{auth.CSRFFieldName: {token}}
r := httptest.NewRequest(http.MethodPost, "/admin/export", strings.NewReader(form.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)
}
content, err := os.ReadFile(filepath.Join(target, wsB.ID, projectB.Slug, "angebote.md"))
if err != nil {
t.Fatalf("Eintrag aus dem zweiten Workspace wurde nicht exportiert: %v", err)
}
if !strings.Contains(string(content), "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(target, projectA.WorkspaceID)); err == nil {
t.Error("der Knopf hat auch den nicht gewählten Workspace exportiert")
}
}
// ── Papierkorb ───────────────────────────────────────────────────────────────
// submitForm 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 submitForm(t *testing.T, h http.Handler, db *store.Store, project store.Project, cookie *http.Cookie, filePath string, form url.Values) *httptest.ResponseRecorder {
t.Helper()
keys, _ := db.ListAPIKeys(context.Background(), project.ID)
form.Set(auth.CSRFFieldName,
auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix+project.WorkspaceID+":"+keys[0].ID))
r := httptest.NewRequest(http.MethodPost, filePath, strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
// fetchPage ruft eine Seite ab.
func fetchPage(h http.Handler, cookie *http.Cookie, filePath string) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodGet, filePath, nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
// TestTrashExpiry spielt den ganzen Weg durch: löschen, im Papierkorb
// sehen, wiederherstellen, erneut löschen, endgültig löschen.
func TestTrashExpiry(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "e", Title: "Wichtiges Wissen",
Content: "x", Source: store.SourceHuman})
cookie := login(t, h, createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite))
// 1. Löschen über die Detailansicht → Papierkorb, mit Hinweis.
w := submitForm(t, h, db, project, 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 fetchPage(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.
page := fetchPage(h, cookie, "/p/testprojekt/trash")
if page.Code != http.StatusOK {
t.Fatalf("Papierkorb: Status %d", page.Code)
}
if !strings.Contains(page.Body.String(), "Wichtiges Wissen") || !strings.Contains(page.Body.String(), "mensch") {
t.Error("der Papierkorb zeigt Eintrag oder löschenden Zugang nicht")
}
// 3. Wiederherstellen.
submitForm(t, h, db, project, cookie, "/p/testprojekt/trash/restore", url.Values{"slugs": {"e"}})
if fetchPage(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.
submitForm(t, h, db, project, cookie, "/p/testprojekt/e/e/delete", url.Values{})
submitForm(t, h, db, project, cookie, "/p/testprojekt/trash/purge", url.Values{"slugs": {"e"}})
if list, _ := db.ListTrash(ctx, project.ID, ""); len(list) != 0 {
t.Error("der Eintrag liegt nach endgültigem Löschen noch im Papierkorb")
}
// Jeder Schritt steht im Protokoll.
events, _ := db.ListAudit(ctx, store.AuditFilter{ProjectID: project.ID})
counted := map[string]int{}
for _, ev := range events {
counted[ev.Action]++
}
if counted[store.ActionEntryDelete] != 2 || counted[store.ActionEntryRestore] != 1 || counted[store.ActionEntryPurge] != 1 {
t.Errorf("Protokoll: %v", counted)
}
}
// TestTrashPurgeOnlyFromTrash: Über das Formular zum endgültigen
// Löschen lässt sich kein Eintrag entfernen, der nicht im Papierkorb liegt.
func TestTrashPurgeOnlyFromTrash(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "lebt", Title: "T", Source: store.SourceHuman})
cookie := login(t, h, createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite))
submitForm(t, h, db, project, cookie, "/p/testprojekt/trash/purge", url.Values{"slugs": {"lebt"}})
if _, err := db.EntryBySlug(ctx, project.ID, "lebt"); err != nil {
t.Error("ein Eintrag außerhalb des Papierkorbs wurde endgültig gelöscht")
}
events, _ := db.ListAudit(ctx, store.AuditFilter{ProjectID: project.ID})
for _, ev := range events {
if ev.Action == store.ActionEntryPurge {
t.Error("das Protokoll vermerkt eine endgültige Löschung, die nicht stattfand")
}
}
}
// TestTrashRequiresWriteRole: 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 TestTrashRequiresWriteRole(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "e", Title: "T", Source: store.SourceHuman})
db.MoveEntryToTrash(ctx, project.ID, "e", "tom")
cookie := login(t, h, createKey(t, srv, db, project.ID, "leser", auth.RoleRead))
if w := fetchPage(h, cookie, "/p/testprojekt/trash"); w.Code != http.StatusForbidden {
t.Errorf("Papierkorb für Leserolle: Status %d, erwartet 403", w.Code)
}
if strings.Contains(fetchPage(h, cookie, "/p/testprojekt/").Body.String(), "/trash") {
t.Error("die Leserolle sieht den Link zum Papierkorb")
}
if w := submitForm(t, h, db, project, cookie, "/p/testprojekt/trash/restore", url.Values{"slugs": {"e"}}); w.Code != http.StatusForbidden {
t.Errorf("Wiederherstellen mit Leserolle: Status %d, erwartet 403", w.Code)
}
}
// TestTrashFilterByKey: 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 TestTrashFilterByKey(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
for _, slug := range []string{"a", "b", "c"} {
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: slug, Title: "Titel " + slug, Source: store.SourceHuman})
}
db.MoveEntryToTrash(ctx, project.ID, "a", "agent-1")
db.MoveEntryToTrash(ctx, project.ID, "b", "agent-1")
db.MoveEntryToTrash(ctx, project.ID, "c", "tom")
cookie := login(t, h, createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite))
page := fetchPage(h, cookie, "/p/testprojekt/trash?von=agent-1").Body.String()
if strings.Contains(page, "Titel c") {
t.Error("der Filter zeigt auch, was ein anderer Zugang gelöscht hat")
}
if !strings.Contains(page, "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.
submitForm(t, h, db, project, cookie, "/p/testprojekt/trash/restore",
url.Values{"slugs": {"a", "b", "a"}, "von": {"agent-1"}})
if list, _ := db.ListTrash(ctx, project.ID, ""); len(list) != 1 || list[0].Slug != "c" {
t.Errorf("im Papierkorb übrig: %v, erwartet nur „c“", list)
}
events, _ := db.ListAudit(ctx, store.AuditFilter{ProjectID: project.ID})
restored := 0
for _, ev := range events {
if ev.Action == store.ActionEntryRestore {
restored++
}
}
if restored != 2 {
t.Errorf("%d Wiederherstellungen protokolliert, erwartet 2", restored)
}
}
// TestSaveRestoresFromTrash: 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 TestSaveRestoresFromTrash(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "e", Title: "Alt", Content: "alt", Source: store.SourceHuman})
db.MoveEntryToTrash(ctx, project.ID, "e", "agent-1")
cookie := login(t, h, createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite))
w := submitForm(t, h, db, project, 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, project.ID, "e", "agent-1")
w = submitForm(t, h, db, project, 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 ────────────────────────────────────────────────────────
// TestUsagePage: Die Seite zeigt Bestand, Schreibvorgänge und Verweise ins
// Leere — auch für die Leserolle, aber ohne den Link zum Anlegen.
func TestUsagePage(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "a", Title: "Ohne Schlagwort",
Content: "siehe [[fehlt-noch]]", Source: store.SourceAgent})
db.LogAudit(ctx, store.AuditEvent{ProjectID: project.ID, Actor: "agent", Action: store.ActionEntryWrite,
Source: store.SourceMCP, Detail: store.DetailEntryCreated})
db.LogAudit(ctx, store.AuditEvent{ProjectID: project.ID, Actor: "agent", Action: store.ActionEntryWrite,
Source: store.SourceMCP, Detail: store.DetailEntryUpdated})
reader := login(t, h, createKey(t, srv, db, project.ID, "leser", auth.RoleRead))
w := fetchPage(h, reader, "/p/testprojekt/usage")
if w.Code != http.StatusOK {
t.Fatalf("Status %d: %s", w.Code, w.Body.String())
}
body := w.Body.String()
for _, want := range []string{"Ohne Schlagwort", "fehlt-noch", "50 %"} {
if !strings.Contains(body, want) {
t.Errorf("%q fehlt auf der Nutzungsseite", want)
}
}
if strings.Contains(body, "/new?slug=") {
t.Error("die Leserolle bekommt einen Link zum Anlegen")
}
writer := login(t, h, createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite))
if !strings.Contains(fetchPage(h, writer, "/p/testprojekt/usage").Body.String(), "/p/testprojekt/new?slug=fehlt-noch") {
t.Error("mit Schreibrecht fehlt der Link zum Anlegen des fehlenden Eintrags")
}
}
// TestUsagePageShowsSearches: Suchen ohne Treffer erscheinen mit Anzahl —
// und eine Anfrage mit HTML darin wird als Text ausgegeben, nicht ausgeführt.
func TestUsagePageShowsSearches(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
db.LogSearch(ctx, store.SearchLogEntry{ProjectID: project.ID, Query: "vpn zugang", Results: 0})
db.LogSearch(ctx, store.SearchLogEntry{ProjectID: project.ID, Query: "vpn zugang", Results: 0})
db.LogSearch(ctx, store.SearchLogEntry{ProjectID: project.ID, Query: "<script>x</script>", Results: 0})
db.LogSearch(ctx, store.SearchLogEntry{ProjectID: project.ID, Query: "deployment",
Synonyms: []string{"release"}, Results: 1})
cookie := login(t, h, createKey(t, srv, db, project.ID, "leser", auth.RoleRead))
body := fetchPage(h, cookie, "/p/testprojekt/usage").Body.String()
for _, want := range []string{"vpn zugang", "2×", "75 %", "25 %"} {
if !strings.Contains(body, want) {
t.Errorf("%q fehlt auf der Nutzungsseite", want)
}
}
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")
}
}
// TestUsagePageShowsForReview: Ein kuratierter Eintrag, den ein Agent
// geändert hat, steht mit Begründung und Link zum Verlauf oben auf der Seite.
func TestUsagePageShowsForReview(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "vpn", Title: "VPN-Zugang",
Content: "neu", Source: store.SourceAgent})
db.LogAudit(ctx, store.AuditEvent{ProjectID: project.ID, Actor: "agent-x", Action: store.ActionEntryWrite,
EntrySlug: "vpn", Source: store.SourceMCP, Detail: store.CuratedOverrideDetail("Server umgezogen")})
cookie := login(t, h, createKey(t, srv, db, project.ID, "leser", auth.RoleRead))
body := fetchPage(h, cookie, "/p/testprojekt/usage").Body.String()
for _, want := range []string{"Zur Prüfung", "VPN-Zugang", "/p/testprojekt/e/vpn/versions", "Server umgezogen", "agent-x"} {
if !strings.Contains(body, want) {
t.Errorf("%q fehlt auf der Nutzungsseite", want)
}
}
}
// TestUsagePageEmptyProject: Ein Projekt ganz ohne Daten darf nicht
// stolpern (es fehlt dann in der map der Kennzahlen).
func TestUsagePageEmptyProject(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
cookie := login(t, h, createKey(t, srv, db, project.ID, "leser", auth.RoleRead))
w := fetchPage(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")
}
}
// TestNewEntryWithPrefilledSlug: ?slug= füllt das Feld, ein
// ungültiger Wert wird ignoriert.
func TestNewEntryWithPrefilledSlug(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
cookie := login(t, h, createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite))
if !strings.Contains(fetchPage(h, cookie, "/p/testprojekt/new?slug=fehlt-noch").Body.String(), `value="fehlt-noch"`) {
t.Error("der Bezeichner wurde nicht vorbelegt")
}
if strings.Contains(fetchPage(h, cookie, "/p/testprojekt/new?slug=B%C3%B6se+Sache").Body.String(), "Böse") {
t.Error("ein ungültiger Bezeichner wurde ins Formular übernommen")
}
}
// TestAdminProjectListShowsUsage: Die Projektübersicht des Plattform-Admins
// zeigt, welches Projekt gar nicht schreibt.
func TestAdminProjectListShowsUsage(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
cookie := loginAsPlatformAdmin(t, h, project.WorkspaceID)
w := fetchPage(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: project.ID, Actor: "agent",
Action: store.ActionEntryWrite, Source: store.SourceMCP, Detail: store.DetailEntryCreated})
if !strings.Contains(fetchPage(h, cookie, "/admin/projects").Body.String(), "1 neu") {
t.Error("der Schreibvorgang erscheint nicht in der Projektliste")
}
}
// TestLiveAndReady prüft die beiden Prüfungen für Container und
// Load-Balancer: Beide antworten ohne Anmeldung. /ready meldet 503, sobald die
// Datenbank nicht antwortet, /live bleibt dabei ok — sonst würde der
// Container neu gestartet, was gegen eine fehlende Datenbank nicht hilft.
func TestLiveAndReady(t *testing.T) {
srv, _, _ := testServer(t)
h := testHandler(t, srv)
queries := func(filePath string) (int, string) {
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, filePath, nil))
return w.Code, w.Body.String()
}
for _, filePath := range []string{"/live", "/healthz", "/ready"} {
if code, body := queries(filePath); code != http.StatusOK || body != "ok\n" {
t.Errorf("%s = %d %q, erwartet 200 ok", filePath, code, body)
}
}
// Datenbank weg: Die Kontroll-Datenbank wird geschlossen.
srv.control.Close()
code, body := queries("/ready")
if code != http.StatusServiceUnavailable {
t.Errorf("/ready ohne Datenbank = %d, erwartet 503", code)
}
if body != "nicht bereit\n" {
t.Errorf("/ready verrät mehr als nötig: %q", body)
}
if code, _ := queries("/live"); code != http.StatusOK {
t.Errorf("/live ohne Datenbank = %d, erwartet weiter 200", code)
}
}