munin/internal/web/web_test.go

2493 lines
88 KiB
Go

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