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
}
// 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(auth.SubjectPlatformAdmin)
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(auth.SubjectPlatformAdmin + ":" + 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(), "
2 | ") {
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(auth.SubjectPlatformAdmin + ":" + 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(auth.SubjectPlatformAdmin + ":" + 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(auth.SubjectPlatformAdmin + ":" + 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)
}
}
// ── 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, "Überschrift
") {
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: "\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(), "") {
t.Error("eingeschleustes