package web
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"log"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"munin/internal/auth"
"munin/internal/export"
"munin/internal/store"
"munin/internal/workspace"
)
const testSecret = "ein-test-secret-das-lang-genug-ist-fuer-tests"
const testAdminToken = "test-admin-token-lang-genug"
// testServer baut einen vollständigen Server auf einer frischen Datenbank.
func testServer(t *testing.T) (*Server, *store.Store, store.Project) {
t.Helper()
tempDir := t.TempDir()
control, err := store.OpenControl(filepath.Join(tempDir, "control.db"))
if err != nil {
t.Fatalf("Kontroll-Datenbank: %v", err)
}
t.Cleanup(func() { control.Close() })
workspacesDir := filepath.Join(tempDir, "workspaces")
if err := os.MkdirAll(workspacesDir, 0750); err != nil {
t.Fatal(err)
}
workspaces := workspace.NewManager(store.NewSQLite("", workspacesDir))
t.Cleanup(func() { workspaces.Close() })
ctx := context.Background()
ws, err := control.EnsureDefaultWorkspace(ctx, "Test")
if err != nil {
t.Fatal(err)
}
w, err := workspaces.Get(ws.ID)
if err != nil {
t.Fatalf("Workspace: %v", err)
}
db := w.Store
project, err := db.CreateProject(ctx, ws.ID, "testprojekt", "Testprojekt", "")
if err != nil {
t.Fatal(err)
}
// os.DirFS("../..") zeigt vom Package-Verzeichnis auf die Projektwurzel,
// wo templates/ liegt.
srv, err := NewServer(Config{
Workspaces: workspaces,
Control: control,
Signer: auth.NewSigner([]byte(testSecret)),
Cookies: auth.NewCookiePolicy("false"),
Limiter: auth.NewRateLimiter(),
Templates: templatesForTest(t),
AdminToken: testAdminToken,
WriteBudget: auth.NewWriteBudget(1000, time.Hour),
})
if err != nil {
t.Fatalf("Server: %v", err)
}
return srv, db, project
}
// templatesForTest liefert das Template-Verzeichnis der Projektwurzel.
func templatesForTest(t *testing.T) fs.FS {
t.Helper()
return os.DirFS("../..")
}
// testHandler baut den vollständigen Router — so wie main.go es tut.
func testHandler(t *testing.T, srv *Server) http.Handler {
t.Helper()
mux := http.NewServeMux()
srv.Routes(mux, os.DirFS("../../static"))
return mux
}
// createKey legt einen API-Key an und gibt den Klartext zurück.
//
// Trägt den Key auch im Routing-Index der Kontroll-Datenbank ein — ohne den
// würden Login und APIAuth ihn nicht mehr finden (siehe ControlStore.IndexKey
// und die entsprechenden Aufrufe in handlers_admin.go).
func createKey(t *testing.T, srv *Server, db *store.Store, projectID, name string, role auth.Role) string {
t.Helper()
plaintext, err := auth.GenerateKey()
if err != nil {
t.Fatal(err)
}
hash := auth.HashKey(plaintext)
if _, err := db.CreateAPIKey(context.Background(), projectID, name, hash, string(role)); err != nil {
t.Fatal(err)
}
project, err := db.ProjectByID(context.Background(), projectID)
if err != nil {
t.Fatal(err)
}
if err := srv.control.IndexKey(context.Background(), hash, project.WorkspaceID); err != nil {
t.Fatal(err)
}
return plaintext
}
// platformSubject gibt das Session-Subject des Plattform-Admins zurück, so wie
// der Testserver es nach der Anmeldung mit testAdminToken vergibt — gebraucht,
// um passende CSRF-Tokens zu berechnen.
func platformSubject(workspaceID string) string {
signer := auth.NewSigner([]byte(testSecret))
return auth.PlatformSubject(signer.Fingerprint(auth.HashKey(testAdminToken)), workspaceID)
}
// selectWorkspace wählt für eine Plattform-Admin-Sitzung einen Workspace und
// gibt den neuen Session-Cookie zurück — der Schritt, den handleWorkspaceList
// in der Oberfläche anbietet (siehe handlers_workspaces.go).
func selectWorkspace(t *testing.T, h http.Handler, cookie *http.Cookie, workspaceID string) *http.Cookie {
t.Helper()
token := auth.NewSigner([]byte(testSecret)).CSRFToken(platformSubject(""))
form := url.Values{"id": {workspaceID}, auth.CSRFFieldName: {token}}
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/select", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
for _, c := range w.Result().Cookies() {
if c.Name == auth.SessionCookieName && c.Value != "" {
return c
}
}
t.Fatalf("Workspace-Auswahl lieferte keinen Cookie (Status %d)", w.Code)
return nil
}
// loginAsPlatformAdmin meldet den Plattform-Admin an UND wählt gleich den
// gegebenen Workspace — die meisten Tests brauchen genau das, nicht den
// Zwischenschritt über die Auswahlseite.
func loginAsPlatformAdmin(t *testing.T, h http.Handler, workspaceID string) *http.Cookie {
t.Helper()
cookie := login(t, h, testAdminToken)
return selectWorkspace(t, h, cookie, workspaceID)
}
// login führt eine Anmeldung durch und gibt den Session-Cookie zurück.
func login(t *testing.T, h http.Handler, key string) *http.Cookie {
t.Helper()
form := url.Values{"key": {key}}
r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
for _, c := range w.Result().Cookies() {
if c.Name == auth.SessionCookieName && c.Value != "" {
return c
}
}
t.Fatalf("Anmeldung lieferte keinen Cookie (Status %d)", w.Code)
return nil
}
// ── Anmeldung ────────────────────────────────────────────────────────────────
func TestLoginRequiredForEverything(t *testing.T) {
srv, _, _ := testServer(t)
h := testHandler(t, srv)
protected := []string{"/", "/admin/projects", "/p/testprojekt/", "/p/testprojekt/keys"}
for _, filePath := range protected {
t.Run(filePath, func(t *testing.T) {
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, filePath, nil))
if w.Code != http.StatusSeeOther {
t.Errorf("Status = %d, erwartet %d (Weiterleitung zur Anmeldung)", w.Code, http.StatusSeeOther)
}
})
}
}
func TestLoginWithProjectKey(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
key := createKey(t, srv, db, project.ID, "test", auth.RoleWrite)
cookie := login(t, h, key)
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Errorf("Status = %d, erwartet 200", w.Code)
}
}
func TestLoginRejectsBadKey(t *testing.T) {
srv, _, _ := testServer(t)
h := testHandler(t, srv)
form := url.Values{"key": {"wk_" + strings.Repeat("0", 64)}}
r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("Status = %d, erwartet %d", w.Code, http.StatusUnauthorized)
}
for _, c := range w.Result().Cookies() {
if c.Name == auth.SessionCookieName && c.Value != "" {
t.Error("trotz falschem Key wurde eine Sitzung ausgestellt")
}
}
}
// postLogin schickt das Anmeldeformular ab und gibt die Antwort zurück — ohne
// wie login auf Erfolg zu bestehen.
func postLogin(h http.Handler, key string) *httptest.ResponseRecorder {
form := url.Values{"key": {key}}
r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
// TestLoginLockoutAppliesToCorrectKey prüft, dass eine gesperrte IP auch mit
// dem richtigen Token nicht hereinkommt. Sonst änderte die Sperre nur den
// Statuscode, und Raten ginge in voller Geschwindigkeit weiter.
func TestLoginLockoutAppliesToCorrectKey(t *testing.T) {
srv, _, _ := testServer(t)
h := testHandler(t, srv)
for i := 0; i < 5; i++ {
postLogin(h, "falsch-geraten")
}
w := postLogin(h, testAdminToken)
if w.Code != http.StatusTooManyRequests {
t.Fatalf("Status = %d, erwartet 429 trotz richtigem Token", w.Code)
}
for _, c := range w.Result().Cookies() {
if c.Name == auth.SessionCookieName && c.Value != "" {
t.Error("gesperrte IP hat eine Sitzung bekommen")
}
}
}
// TestValidLoginDoesNotResetLockout: Eine erfolgreiche Anmeldung (etwa mit dem
// eigenen Key) darf die Fehlversuche nicht löschen — sonst ließe sich die
// Sperre damit nach jedem vierten Versuch aushebeln.
func TestValidLoginDoesNotResetLockout(t *testing.T) {
srv, _, _ := testServer(t)
h := testHandler(t, srv)
for i := 0; i < 4; i++ {
postLogin(h, "falsch-geraten")
}
if w := postLogin(h, testAdminToken); w.Code != http.StatusSeeOther {
t.Fatalf("Anmeldung nach 4 Fehlversuchen: Status %d, erwartet 303", w.Code)
}
postLogin(h, "falsch-geraten") // der fünfte zählt weiter, statt von vorn
if w := postLogin(h, testAdminToken); w.Code != http.StatusTooManyRequests {
t.Errorf("Status = %d, erwartet 429 — die erfolgreiche Anmeldung hat den Zähler zurückgesetzt", w.Code)
}
}
// TestHintRequiresSignature prüft, dass nur vom Server signierte Meldungen
// erscheinen. Ein präparierter Link mit eigenem Text zeigt nichts an.
func TestHintRequiresSignature(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
cookie := login(t, h, createKey(t, srv, db, project.ID, "test", auth.RoleWrite))
list := "/p/" + project.Slug + "/"
forged := fetchPage(h, cookie, list+"?hinweis="+url.QueryEscape("Bitte Key an boese@example.org senden")).Body.String()
if strings.Contains(forged, "boese@example.org") {
t.Error("unsignierte Meldung wird angezeigt")
}
// Signatur zu einem anderen Text: ebenso nichts.
other := srv.withHint(list, "Echte Meldung")
tampered := strings.Replace(other, url.QueryEscape("Echte Meldung"), url.QueryEscape("Andere Meldung"), 1)
if strings.Contains(fetchPage(h, cookie, tampered).Body.String(), "Andere Meldung") {
t.Error("Meldung mit fremder Signatur wird angezeigt")
}
if !strings.Contains(fetchPage(h, cookie, other).Body.String(), "Echte Meldung") {
t.Error("signierte Meldung fehlt")
}
}
// TestRevokedKeyEndsSession prüft, dass ein zurückgezogener Key sofort wirkt —
// die Sitzung darf ihn nicht überleben.
func TestRevokedKeyEndsSession(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
key := createKey(t, srv, db, project.ID, "test", auth.RoleWrite)
cookie := login(t, h, key)
keys, _ := db.ListAPIKeys(ctx, project.ID)
if err := db.RevokeAPIKey(ctx, keys[0].ID); err != nil {
t.Fatal(err)
}
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusSeeOther {
t.Errorf("Status = %d, erwartet Weiterleitung zur Anmeldung — die Sitzung galt weiter", w.Code)
}
}
// ── CSRF ─────────────────────────────────────────────────────────────────────
// TestCSRFBlocksForgedPost bildet den Angriff nach: Eine fremde Seite schickt ein
// POST, der Browser hängt den Session-Cookie automatisch an — nur das Token kann
// sie nicht liefern.
func TestCSRFBlocksForgedPost(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
key := createKey(t, srv, db, project.ID, "test", auth.RoleAdmin)
cookie := login(t, h, key)
form := url.Values{"name": {"geklaut"}, "role": {"admin"}}
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/keys/create", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusForbidden {
t.Errorf("Status = %d, erwartet %d", w.Code, http.StatusForbidden)
}
keys, _ := db.ListAPIKeys(context.Background(), project.ID)
if len(keys) != 1 {
t.Errorf("%d Keys — es wurde trotz fehlendem Token einer angelegt", len(keys))
}
}
func TestCSRFAllowsRealForm(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
key := createKey(t, srv, db, project.ID, "test", auth.RoleAdmin)
cookie := login(t, h, key)
// Das Token so berechnen, wie es das Template tut.
keys, _ := db.ListAPIKeys(context.Background(), project.ID)
subject := auth.SubjectKeyPrefix + project.WorkspaceID + ":" + keys[0].ID
token := auth.NewSigner([]byte(testSecret)).CSRFToken(subject)
form := url.Values{"name": {"zweiter"}, "role": {"read"}, auth.CSRFFieldName: {token}}
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/keys/create", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("Status = %d, erwartet 200", w.Code)
}
fresh, _ := db.ListAPIKeys(context.Background(), project.ID)
if len(fresh) != 2 {
t.Errorf("%d Keys, erwartet 2", len(fresh))
}
// Der Klartext des neuen Keys muss genau einmal in der Antwort stehen.
if !strings.Contains(w.Body.String(), auth.KeyPrefix) {
t.Error("der neue Key wurde nicht angezeigt")
}
// Die Konfigurationsblöcke tragen die echte Adresse, keinen Platzhalter
// (AUSBAUSTUFEN.md 5.2). httptest setzt als Host "example.com".
if strings.Contains(w.Body.String(), "DEIN-SERVER") {
t.Error("die Zugangsseite zeigt noch den Platzhalter DEIN-SERVER")
}
if !strings.Contains(w.Body.String(), "https://example.com/mcp") {
t.Error("die Zugangsseite zeigt die Server-Adresse nicht")
}
}
// ── Abschottung ──────────────────────────────────────────────────────────────
// TestProjectIsolation ist der wichtigste Test des Web-Teils: Ein Key kommt
// ausschließlich an sein eigenes Projekt.
func TestProjectIsolation(t *testing.T) {
srv, db, projectA := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
projectB, err := db.CreateProject(ctx, projectA.WorkspaceID, "fremd", "Fremdes Projekt", "")
if err != nil {
t.Fatal(err)
}
// Ein Eintrag, den Projekt A auf keinen Fall sehen darf.
if _, err := db.PutEntry(ctx, store.EntryWrite{
ProjectID: projectB.ID, Slug: "geheim", Title: "Geheim",
Content: "Vertrauliches aus Projekt B", Source: store.SourceHuman,
}); err != nil {
t.Fatal(err)
}
keyA := createKey(t, srv, db, projectA.ID, "a", auth.RoleAdmin)
cookie := login(t, h, keyA)
// Jede Route des fremden Projekts muss als "nicht vorhanden" antworten —
// nicht als "verboten". Sonst ließe sich über die Statuscodes herausfinden,
// welche Projekte es sonst noch gibt.
foreignRoutes := []string{
"/p/fremd/",
"/p/fremd/e/geheim",
"/p/fremd/keys",
"/p/fremd/audit",
}
for _, filePath := range foreignRoutes {
t.Run(filePath, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, filePath, nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusNotFound {
t.Errorf("Status = %d, erwartet 404", w.Code)
}
if strings.Contains(w.Body.String(), "Vertrauliches") {
t.Error("Inhalt des fremden Projekts war sichtbar")
}
})
}
}
// TestWorkspaceIsolation ist der wichtigste Test dieser Ausbaustufe: Zwei
// gleichzeitig offene Workspace-Dateien im selben Prozess dürfen sich
// strukturell nicht sehen — auch nicht über einen fremden, aber gültigen Key.
func TestWorkspaceIsolation(t *testing.T) {
srv, _, projectA := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
// Ein zweiter Workspace, komplett unabhängig vom ersten — eigene Datei,
// eigenes Projekt, eigener Key.
wsB, err := srv.control.CreateWorkspace(ctx, "Andere Abteilung")
if err != nil {
t.Fatal(err)
}
workspaceB, err := srv.workspaces.Get(wsB.ID)
if err != nil {
t.Fatal(err)
}
projectB, err := workspaceB.Store.CreateProject(ctx, wsB.ID, "fremd", "Fremdes Projekt", "")
if err != nil {
t.Fatal(err)
}
if _, err := workspaceB.Store.PutEntry(ctx, store.EntryWrite{
ProjectID: projectB.ID, Slug: "geheim", Title: "Geheim",
Content: "Vertrauliches aus Workspace B", Source: store.SourceHuman,
}); err != nil {
t.Fatal(err)
}
keyB := createKey(t, srv, workspaceB.Store, projectB.ID, "b", auth.RoleAdmin)
// Mit dem Key aus Workspace B angemeldet: Projekt A (Workspace A) darf
// unter keinen Umständen erreichbar sein, obwohl der Prozess beide
// Dateien gleichzeitig offen hält.
cookie := login(t, h, keyB)
r := httptest.NewRequest(http.MethodGet, "/p/"+projectA.Slug+"/", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusNotFound {
t.Errorf("Status = %d, erwartet 404 — Workspace B kam an ein Projekt aus Workspace A", w.Code)
}
}
// TestWorkspaceCreateSelectsItAndRedirects prüft den Weg aus der Oberfläche:
// Ein neuer Workspace wird angelegt, die Sitzung wechselt automatisch dorthin,
// und der Admin landet direkt bei der Projektverwaltung — ohne den Umweg über
// die Auswahlseite.
func TestWorkspaceCreateSelectsItAndRedirects(t *testing.T) {
srv, _, project := testServer(t)
h := testHandler(t, srv)
cookie := loginAsPlatformAdmin(t, h, project.WorkspaceID)
token := auth.NewSigner([]byte(testSecret)).
CSRFToken(platformSubject(project.WorkspaceID))
form := url.Values{"name": {"Marketing"}, auth.CSRFFieldName: {token}}
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/create", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/admin/projects" {
t.Fatalf("Status = %d, Location = %q — erwartet Weiterleitung zu /admin/projects",
w.Code, w.Header().Get("Location"))
}
workspaces, err := srv.control.ListWorkspaces(context.Background())
if err != nil {
t.Fatal(err)
}
var found bool
for _, ws := range workspaces {
if ws.Name == "Marketing" {
found = true
}
}
if !found {
t.Error("neuer Workspace \"Marketing\" steht nicht in der Kontroll-Datenbank")
}
// Die neue Sitzung muss den neuen Workspace tragen — ein Aufruf von
// /admin/projects mit dem zurückgegebenen Cookie darf nicht mehr zur
// Auswahlseite zurückschicken.
var newCookie *http.Cookie
for _, c := range w.Result().Cookies() {
if c.Name == auth.SessionCookieName {
newCookie = c
}
}
if newCookie == nil {
t.Fatal("keine neue Sitzung nach dem Anlegen ausgestellt")
}
r2 := httptest.NewRequest(http.MethodGet, "/admin/projects", nil)
r2.AddCookie(newCookie)
w2 := httptest.NewRecorder()
h.ServeHTTP(w2, r2)
if w2.Code != http.StatusOK {
t.Errorf("Status = %d, erwartet 200 — die neue Sitzung landete nicht im neuen Workspace", w2.Code)
}
}
// TestWorkspaceListShowsProjectCount prüft, dass die Übersicht die
// tatsächliche Projektzahl je Workspace zeigt, nicht nur Name und Datum —
// und unter dem Namen den Speicherort (Datei bzw. Schema).
func TestWorkspaceListShowsProjectCount(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
if _, err := db.CreateProject(context.Background(), project.WorkspaceID, "zweites", "Zweites", ""); err != nil {
t.Fatal(err)
}
cookie := loginAsPlatformAdmin(t, h, project.WorkspaceID)
r := httptest.NewRequest(http.MethodGet, "/admin/workspaces", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("Status = %d, erwartet 200", w.Code)
}
if !strings.Contains(w.Body.String(), "
2 | ") {
t.Error("Projektzahl \"2\" steht nicht in der Übersicht")
}
// Unter dem Namen steht, wo die Daten liegen — hier die SQLite-Datei.
location := "workspaces/" + project.WorkspaceID + ".db"
if !strings.Contains(w.Body.String(), location) {
t.Errorf("Speicherort %q steht nicht in der Übersicht", location)
}
}
// TestWorkspaceRename prüft das Umbenennen über /admin/workspaces/{id}/edit.
func TestWorkspaceRename(t *testing.T) {
srv, _, project := testServer(t)
h := testHandler(t, srv)
cookie := loginAsPlatformAdmin(t, h, project.WorkspaceID)
token := auth.NewSigner([]byte(testSecret)).
CSRFToken(platformSubject(project.WorkspaceID))
form := url.Values{"name": {"Umbenannt"}, auth.CSRFFieldName: {token}}
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/"+project.WorkspaceID+"/edit",
strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusSeeOther {
t.Fatalf("Status = %d, erwartet %d", w.Code, http.StatusSeeOther)
}
ws, err := srv.control.WorkspaceByID(context.Background(), project.WorkspaceID)
if err != nil {
t.Fatal(err)
}
if ws.Name != "Umbenannt" {
t.Errorf("Name = %q, erwartet \"Umbenannt\"", ws.Name)
}
}
// TestWorkspaceDeleteRequiresConfirmation prüft die Bestätigungsabfrage — ein
// Klick allein darf nicht reichen, dahinter verschwindet alles im Workspace.
func TestWorkspaceDeleteRequiresConfirmation(t *testing.T) {
srv, _, project := testServer(t)
h := testHandler(t, srv)
cookie := loginAsPlatformAdmin(t, h, project.WorkspaceID)
token := auth.NewSigner([]byte(testSecret)).
CSRFToken(platformSubject(project.WorkspaceID))
form := url.Values{"id": {project.WorkspaceID}, "confirm": {"falsch"}, auth.CSRFFieldName: {token}}
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/delete", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("Status = %d, erwartet %d", w.Code, http.StatusBadRequest)
}
if _, err := srv.control.WorkspaceByID(context.Background(), project.WorkspaceID); err != nil {
t.Error("Workspace wurde trotz falscher Bestätigung gelöscht")
}
}
// TestWorkspaceDeleteRemovesEverythingAndResetsSession ist der wichtigste
// Test hier: Löschen entfernt Kontroll-Eintrag UND Datei, und eine Sitzung im
// gelöschten Workspace wird auf "kein Workspace gewählt" zurückgesetzt statt
// den Admin einfach auszusperren.
func TestWorkspaceDeleteRemovesEverythingAndResetsSession(t *testing.T) {
srv, _, project := testServer(t)
h := testHandler(t, srv)
ws, err := srv.control.WorkspaceByID(context.Background(), project.WorkspaceID)
if err != nil {
t.Fatal(err)
}
cookie := loginAsPlatformAdmin(t, h, project.WorkspaceID)
token := auth.NewSigner([]byte(testSecret)).
CSRFToken(platformSubject(project.WorkspaceID))
form := url.Values{"id": {project.WorkspaceID}, "confirm": {ws.Name}, auth.CSRFFieldName: {token}}
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/delete", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusSeeOther {
t.Fatalf("Status = %d, erwartet %d", w.Code, http.StatusSeeOther)
}
if _, err := srv.control.WorkspaceByID(context.Background(), project.WorkspaceID); !errors.Is(err, store.ErrNotFound) {
t.Errorf("Workspace steht nach dem Löschen noch in der Kontroll-Datenbank (err=%v)", err)
}
// Die neue Sitzung ist die des Admins ohne Workspace — /admin/projects
// muss jetzt zur Auswahl zurückschicken statt eine tote Sitzung zu zeigen.
var newCookie *http.Cookie
for _, c := range w.Result().Cookies() {
if c.Name == auth.SessionCookieName {
newCookie = c
}
}
if newCookie == nil {
t.Fatal("keine neue Sitzung nach dem Löschen ausgestellt")
}
r2 := httptest.NewRequest(http.MethodGet, "/admin/projects", nil)
r2.AddCookie(newCookie)
w2 := httptest.NewRecorder()
h.ServeHTTP(w2, r2)
if w2.Code != http.StatusSeeOther || w2.Header().Get("Location") != "/admin/workspaces" {
t.Errorf("Status = %d, Location = %q — erwartet Weiterleitung zur Workspace-Auswahl",
w2.Code, w2.Header().Get("Location"))
}
}
// TestPlatformAdminOnly prüft, dass die Projektverwaltung dem Plattform-Admin
// vorbehalten bleibt.
func TestPlatformAdminOnly(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
key := createKey(t, srv, db, project.ID, "test", auth.RoleAdmin)
cookie := login(t, h, key)
r := httptest.NewRequest(http.MethodGet, "/admin/projects", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusForbidden {
t.Errorf("Status = %d, erwartet %d — ein Projekt-Admin kam an die Projektverwaltung", w.Code, http.StatusForbidden)
}
}
func TestPlatformAdminCanAccessAnyProject(t *testing.T) {
srv, _, project := testServer(t)
h := testHandler(t, srv)
cookie := loginAsPlatformAdmin(t, h, project.WorkspaceID)
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Errorf("Status = %d, erwartet 200", w.Code)
}
}
// statusWithCookie ruft eine Seite mit dem gegebenen Cookie ab und gibt den
// Status zurück.
func statusWithCookie(h http.Handler, filePath string, cookie *http.Cookie) int {
r := httptest.NewRequest(http.MethodGet, filePath, nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w.Code
}
// TestNewAdminTokenEndsAdminSessions stellt den Ernstfall nach: Das
// ADMIN_TOKEN ist geleakt und wird ersetzt, der Server neu gestartet. Wer sich
// mit dem alten Token angemeldet hat, muss sofort draußen sein — Teams mit
// ihren Keys dürfen davon nichts merken.
func TestNewAdminTokenEndsAdminSessions(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
withoutWorkspace := login(t, h, testAdminToken)
withWs := selectWorkspace(t, h, withoutWorkspace, project.WorkspaceID)
team := login(t, h, createKey(t, srv, db, project.ID, "team", auth.RoleAdmin))
if code := statusWithCookie(h, "/p/testprojekt/", withWs); code != http.StatusOK {
t.Fatalf("vor dem Wechsel: Status %d, erwartet 200", code)
}
// Neustart mit neuem ADMIN_TOKEN, aber demselben SESSION_SECRET und
// denselben Daten.
const newToken = "ein-ganz-neues-admin-token"
newer, err := NewServer(Config{
Workspaces: srv.workspaces,
Control: srv.control,
Signer: srv.signer,
Cookies: srv.cookies,
Limiter: auth.NewRateLimiter(),
Templates: templatesForTest(t),
AdminToken: newToken,
WriteBudget: srv.writeBudget,
})
if err != nil {
t.Fatal(err)
}
hNew := testHandler(t, newer)
for name, cookie := range map[string]*http.Cookie{
"ohne Workspace": withoutWorkspace,
"mit Workspace": withWs,
} {
code := statusWithCookie(hNew, "/admin/workspaces", cookie)
if code != http.StatusSeeOther {
t.Errorf("alte Admin-Sitzung %s: Status %d, erwartet Weiterleitung zur Anmeldung", name, code)
}
}
if code := statusWithCookie(hNew, "/p/testprojekt/", team); code != http.StatusOK {
t.Errorf("Sitzung eines Teams: Status %d, erwartet 200 — ein neues ADMIN_TOKEN darf Teams nicht abmelden", code)
}
fresh := login(t, hNew, newToken)
if code := statusWithCookie(hNew, "/admin/workspaces", fresh); code != http.StatusOK {
t.Errorf("Anmeldung mit dem neuen Token: Status %d, erwartet 200", code)
}
}
// TestOldAdminSessionWithoutFingerprintIsInvalid: Cookies aus der Zeit vor dem
// Fingerabdruck ("platform", "platform:") sind korrekt signiert, dürfen
// aber nicht mehr gelten — sonst bliebe genau die Lücke offen, die der
// Fingerabdruck schließt.
func TestOldAdminSessionWithoutFingerprintIsInvalid(t *testing.T) {
srv, _, project := testServer(t)
h := testHandler(t, srv)
signer := auth.NewSigner([]byte(testSecret))
for _, subject := range []string{"platform", "platform:" + project.WorkspaceID} {
cookie := &http.Cookie{Name: auth.SessionCookieName, Value: signer.SignSession(subject, time.Now().Add(time.Hour))}
if code := statusWithCookie(h, "/admin/workspaces", cookie); code != http.StatusSeeOther {
t.Errorf("%q: Status %d, erwartet Weiterleitung zur Anmeldung", subject, code)
}
}
}
// TestPlatformAdminSessionIsShort: Die Sitzung des Plattform-Admins gilt 12
// Stunden, und ein Workspace-Wechsel verlängert sie nicht — sonst ließe sich
// ein gestohlener Cookie durch Umschalten ewig am Leben halten.
func TestPlatformAdminSessionIsShort(t *testing.T) {
srv, _, project := testServer(t)
h := testHandler(t, srv)
cookie := login(t, h, testAdminToken)
if left := time.Until(cookie.Expires); left < auth.PlatformAdminSessionTTL-time.Minute || left > auth.PlatformAdminSessionTTL {
t.Fatalf("Admin-Sitzung gilt noch %v, erwartet %v", left.Round(time.Minute), auth.PlatformAdminSessionTTL)
}
switched := selectWorkspace(t, h, cookie, project.WorkspaceID)
if !switched.Expires.Equal(cookie.Expires) {
t.Errorf("Workspace-Wechsel hat den Ablauf verschoben: %v statt %v", switched.Expires, cookie.Expires)
}
}
// ── Rollen ───────────────────────────────────────────────────────────────────
// TestReadRoleCannotWrite prüft, dass ein Nur-Lese-Zugang keine Einträge ändern kann.
func TestReadRoleCannotWrite(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
key := createKey(t, srv, db, project.ID, "nur-lesen", auth.RoleRead)
cookie := login(t, h, key)
writeRoutes := []struct{ method, filePath string }{
{http.MethodGet, "/p/testprojekt/new"},
{http.MethodPost, "/p/testprojekt/save"},
}
for _, tc := range writeRoutes {
t.Run(tc.filePath, func(t *testing.T) {
r := httptest.NewRequest(tc.method, tc.filePath, strings.NewReader(""))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code == http.StatusOK {
t.Errorf("Status = %d — der Nur-Lese-Zugang kam durch", w.Code)
}
})
}
}
// TestWriteRoleCannotManageKeys prüft die Abgrenzung zwischen write und admin.
func TestWriteRoleCannotManageKeys(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
key := createKey(t, srv, db, project.ID, "schreiber", auth.RoleWrite)
cookie := login(t, h, key)
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/keys", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusForbidden {
t.Errorf("Status = %d, erwartet %d", w.Code, http.StatusForbidden)
}
}
// TestKeyListFreshKeyNotUnused prüft, dass die Zugänge-Seite rendert und ein
// frisch angelegter, noch nie benutzter Key nicht als „ungenutzt“ erscheint.
// Die Grenzfälle der Regel selbst prüft TestAPIKeyUnused im Store.
func TestKeyListFreshKeyNotUnused(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
admin := createKey(t, srv, db, project.ID, "verwalterin", auth.RoleAdmin)
createKey(t, srv, db, project.ID, "neue-pipeline", auth.RoleRead)
cookie := login(t, h, admin)
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/keys", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("Status = %d, erwartet %d", w.Code, http.StatusOK)
}
body := w.Body.String()
if !strings.Contains(body, "neue-pipeline") {
t.Error("der neue Key fehlt in der Liste")
}
if strings.Contains(body, "marke warnung") || strings.Contains(body, "seit über 90 Tagen nicht benutzt") {
t.Error("ein frisch angelegter Key wurde als ungenutzt markiert")
}
}
// ── Sicherheits-Header und Limits ────────────────────────────────────────────
func TestSecurityHeaders(t *testing.T) {
h := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), false)
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil))
want := map[string]string{
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "same-origin",
}
for name, value := range want {
if got := w.Header().Get(name); got != value {
t.Errorf("%s = %q, erwartet %q", name, got, value)
}
}
csp := w.Header().Get("Content-Security-Policy")
for _, part := range []string{"default-src 'self'", "frame-ancestors 'none'", "object-src 'none'", "img-src 'self' data:;"} {
if !strings.Contains(csp, part) {
t.Errorf("CSP enthält %q nicht", part)
}
}
// Externe Bilder wären ein Zählpixel für jeden Leser.
if strings.Contains(csp, "https:") {
t.Errorf("CSP erlaubt externe Quellen: %q", csp)
}
// Ohne den Schalter darf HSTS nicht gesetzt sein — sonst sperrt man sich auf
// einer Adresse ohne Zertifikat selbst aus.
if w.Header().Get("Strict-Transport-Security") != "" {
t.Error("HSTS wurde gesetzt, obwohl abgeschaltet")
}
}
func TestLimitBody(t *testing.T) {
var readErr error
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
buf := make([]byte, 512)
for {
if _, err := r.Body.Read(buf); err != nil {
if err.Error() != "EOF" {
readErr = err
}
return
}
}
})
h := LimitBody(inner, 100)
h.ServeHTTP(httptest.NewRecorder(),
httptest.NewRequest(http.MethodPost, "/", strings.NewReader(strings.Repeat("x", 5000))))
if readErr == nil {
t.Error("ein zu großer Body wurde ohne Fehler durchgelassen")
}
}
// ── API-Zugang ───────────────────────────────────────────────────────────────
func TestAPIAuthRejectsBadKey(t *testing.T) {
srv, _, _ := testServer(t)
var reached bool
h := srv.APIAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached = true }))
cases := map[string]string{
"ohne Header": "",
"falsches Schema": "Basic abc",
"unbekannter Key": "Bearer wk_" + strings.Repeat("0", 64),
"kaputte Form": "Bearer nonsens",
}
for name, header := range cases {
t.Run(name, func(t *testing.T) {
reached = false
r := httptest.NewRequest(http.MethodPost, "/mcp", nil)
if header != "" {
r.Header.Set("Authorization", header)
}
r.RemoteAddr = "203.0.113.1:1234"
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if reached {
t.Error("der Request kam durch")
}
if w.Code != http.StatusUnauthorized && w.Code != http.StatusTooManyRequests {
t.Errorf("Status = %d, erwartet 401", w.Code)
}
})
}
}
func TestAPIAuthAcceptsValidKey(t *testing.T) {
srv, db, project := testServer(t)
key := createKey(t, srv, db, project.ID, "agent", auth.RoleWrite)
var seen store.Project
h := srv.APIAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen, _ = ProjectFrom(r)
}))
r := httptest.NewRequest(http.MethodPost, "/mcp", nil)
r.Header.Set("Authorization", "Bearer "+key)
h.ServeHTTP(httptest.NewRecorder(), r)
if seen.ID != project.ID {
t.Error("das Projekt wurde nicht im Context abgelegt")
}
}
// TestAPIAuthValidKeyBypassesRateLimit prüft, dass die IP-Sperre nur
// Fehlversuche trifft — sonst würde ein falsch konfigurierter Client alle
// anderen hinter derselben Adresse mitsperren.
func TestAPIAuthValidKeyBypassesRateLimit(t *testing.T) {
srv, db, project := testServer(t)
key := createKey(t, srv, db, project.ID, "agent", auth.RoleWrite)
var reached bool
h := srv.APIAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached = true }))
// Erst die Adresse in die Sperre laufen lassen …
for i := 0; i < 15; i++ {
r := httptest.NewRequest(http.MethodPost, "/mcp", nil)
r.Header.Set("Authorization", "Bearer wk_"+strings.Repeat("0", 64))
r.RemoteAddr = "203.0.113.2:1234"
h.ServeHTTP(httptest.NewRecorder(), r)
}
// … und dann mit dem richtigen Key von derselben Adresse kommen.
r := httptest.NewRequest(http.MethodPost, "/mcp", nil)
r.Header.Set("Authorization", "Bearer "+key)
r.RemoteAddr = "203.0.113.2:1234"
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if !reached {
t.Errorf("gültiger Key wurde wegen der IP-Sperre abgewiesen (Status %d)", w.Code)
}
}
// ── Einträge über die Weboberfläche ──────────────────────────────────────────
func TestEntryCreateAndView(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
key := createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite)
cookie := login(t, h, key)
keys, _ := db.ListAPIKeys(context.Background(), project.ID)
token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + project.WorkspaceID + ":" + keys[0].ID)
form := url.Values{
"title": {"Deployment-Prozess"},
"content": {"Wir deployen über [[server-zugang]]."},
"tags": {"ops, deployment"},
auth.CSRFFieldName: {token},
}
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/save", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusSeeOther {
t.Fatalf("Status = %d, erwartet Weiterleitung (%s)", w.Code, w.Body.String())
}
// Der Bezeichner wurde aus dem Titel abgeleitet.
e, err := db.EntryBySlug(context.Background(), project.ID, "deployment-prozess")
if err != nil {
t.Fatalf("Eintrag nicht gefunden: %v", err)
}
if e.Source != store.SourceHuman {
t.Errorf("Source = %q, erwartet %q", e.Source, store.SourceHuman)
}
if e.Author != "mensch" {
t.Errorf("Author = %q, erwartet den Key-Namen", e.Author)
}
if len(e.Tags) != 2 {
t.Errorf("Tags = %v, erwartet zwei", e.Tags)
}
// Das Protokoll vermerkt, dass der Eintrag neu angelegt wurde.
events, err := db.ListAudit(context.Background(), store.AuditFilter{ProjectID: project.ID})
if err != nil {
t.Fatal(err)
}
var found bool
for _, ev := range events {
if ev.Action == store.ActionEntryWrite && ev.EntrySlug == "deployment-prozess" {
found = true
if ev.Detail != store.DetailEntryCreated {
t.Errorf("Detail = %q, erwartet %q", ev.Detail, store.DetailEntryCreated)
}
}
}
if !found {
t.Error("Schreibvorgang fehlt im Protokoll")
}
// Die Detailansicht muss den Verweis als Link darstellen.
rv := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/deployment-prozess", nil)
rv.AddCookie(cookie)
wv := httptest.NewRecorder()
h.ServeHTTP(wv, rv)
if wv.Code != http.StatusOK {
t.Fatalf("Detailansicht: Status %d", wv.Code)
}
if !strings.Contains(wv.Body.String(), "/p/testprojekt/e/server-zugang") {
t.Error("der [[…]]-Verweis wurde nicht in einen Link umgewandelt")
}
}
// TestEntryBulkTags prüft die Sammelbearbeitung: zwei ausgewählte Einträge
// bekommen ein Schlagwort dazu und eines weg, ein dritter, nicht ausgewählter
// bleibt unangetastet.
func TestEntryBulkTags(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
a, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "a", Title: "A",
Tags: []string{"alt"}, Source: store.SourceHuman})
b, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "b", Title: "B",
Tags: []string{"alt"}, Source: store.SourceHuman})
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "c", Title: "C",
Tags: []string{"alt"}, Source: store.SourceHuman})
key := createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite)
cookie := login(t, h, key)
keys, _ := db.ListAPIKeys(ctx, project.ID)
token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + project.WorkspaceID + ":" + keys[0].ID)
form := url.Values{
"ids": {a.ID, b.ID},
"add_tags": {"neu"},
"remove_tags": {"alt"},
auth.CSRFFieldName: {token},
}
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/entries/bulk-tags", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusSeeOther {
t.Fatalf("Status = %d, erwartet Weiterleitung (%s)", w.Code, w.Body.String())
}
freshA, _ := db.EntryBySlug(ctx, project.ID, "a")
if len(freshA.Tags) != 1 || freshA.Tags[0] != "neu" {
t.Errorf("Tags von A = %v, erwartet [neu]", freshA.Tags)
}
unchangedC, _ := db.EntryBySlug(ctx, project.ID, "c")
if len(unchangedC.Tags) != 1 || unchangedC.Tags[0] != "alt" {
t.Errorf("Tags von C = %v — C war nicht ausgewählt", unchangedC.Tags)
}
}
// bulkDelete schickt die Sammelauswahl der Eintragsliste an bulk-delete.
// role bestimmt den Key, mit dem angemeldet wird; extra ergänzt das Formular
// (etwa um tag oder q).
func bulkDelete(t *testing.T, srv *Server, db *store.Store, project store.Project,
role auth.Role, ids []string, extra url.Values) *httptest.ResponseRecorder {
t.Helper()
h := testHandler(t, srv)
key := createKey(t, srv, db, project.ID, "mensch-"+string(role), role)
cookie := login(t, h, key)
keys, _ := db.ListAPIKeys(context.Background(), project.ID)
var keyID string
for _, k := range keys {
if k.Name == "mensch-"+string(role) {
keyID = k.ID
}
}
token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + project.WorkspaceID + ":" + keyID)
form := url.Values{"ids": ids, auth.CSRFFieldName: {token}}
for k, v := range extra {
form[k] = v
}
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/entries/bulk-delete", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
// TestEntryBulkDelete: Die ausgewählten Einträge liegen danach im Papierkorb,
// je Eintrag steht eine Zeile im Protokoll, der Rest bleibt — und die
// Weiterleitung führt zurück in dieselbe gefilterte Liste.
func TestEntryBulkDelete(t *testing.T) {
srv, db, project := testServer(t)
ctx := context.Background()
a, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "a", Title: "A", Source: store.SourceHuman})
b, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "b", Title: "B", Source: store.SourceHuman})
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "c", Title: "C", Source: store.SourceHuman})
w := bulkDelete(t, srv, db, project, auth.RoleWrite, []string{a.ID, b.ID}, url.Values{"tag": {"ops"}})
if w.Code != http.StatusSeeOther {
t.Fatalf("Status = %d, erwartet Weiterleitung (%s)", w.Code, w.Body.String())
}
if target := w.Header().Get("Location"); !strings.Contains(target, "tag=ops") || !strings.Contains(target, "hinweis=") {
t.Errorf("Weiterleitung nach %q — Filter oder Hinweis fehlt", target)
}
trash, _ := db.ListTrash(ctx, project.ID, "")
if len(trash) != 2 {
t.Errorf("%d Einträge im Papierkorb, erwartet 2", len(trash))
}
if _, err := db.EntryBySlug(ctx, project.ID, "c"); err != nil {
t.Errorf("C war nicht ausgewählt und ist trotzdem weg: %v", err)
}
events, _ := db.ListAudit(ctx, store.AuditFilter{ProjectID: project.ID})
deletions := 0
for _, e := range events {
if e.Action == store.ActionEntryDelete {
deletions++
}
}
if deletions != 2 {
t.Errorf("%d Löschungen im Protokoll, erwartet eine je Eintrag (2)", deletions)
}
}
// TestEntryBulkDeleteRequiresWriteRole: Mit Leserecht wird nichts gelöscht.
func TestEntryBulkDeleteRequiresWriteRole(t *testing.T) {
srv, db, project := testServer(t)
ctx := context.Background()
a, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "a", Title: "A", Source: store.SourceHuman})
w := bulkDelete(t, srv, db, project, auth.RoleRead, []string{a.ID}, nil)
if w.Code == http.StatusSeeOther {
t.Errorf("Leserecht wurde weitergeleitet wie nach Erfolg (Status %d)", w.Code)
}
if _, err := db.EntryBySlug(ctx, project.ID, "a"); err != nil {
t.Errorf("mit Leserecht wurde gelöscht: %v", err)
}
}
// TestEntryListShowsBulkDelete: Der Knopf steht mit Rückfrage in der
// Liste — und zwar hinter „Anwenden", damit Enter in einem Schlagwortfeld nie
// löscht.
func TestEntryListShowsBulkDelete(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
db.PutEntry(context.Background(), store.EntryWrite{ProjectID: project.ID, Slug: "a", Title: "A", Source: store.SourceHuman})
writer := login(t, h, createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite))
body := fetchPage(h, writer, "/p/testprojekt/").Body.String()
apply := strings.Index(body, ">Anwenden<")
deleteIt := strings.Index(body, `formaction="/p/testprojekt/entries/bulk-delete"`)
if apply < 0 || deleteIt < 0 {
t.Fatal("Sammelleiste ohne Anwenden oder ohne Löschknopf")
}
if deleteIt < apply {
t.Error("der Löschknopf steht vor „Anwenden“ — Enter in einem Textfeld würde löschen")
}
if !strings.Contains(body, "30 Tage lang wiederherstellen") {
t.Error("die Rückfrage nennt die Frist des Papierkorbs nicht")
}
reader := login(t, h, createKey(t, srv, db, project.ID, "leser", auth.RoleRead))
if strings.Contains(fetchPage(h, reader, "/p/testprojekt/").Body.String(), "bulk-delete") {
t.Error("die Leserolle sieht den Löschknopf")
}
}
// TestEntryVersionsAndDiff prüft den Verlauf und den Vergleich zweier
// Fassungen — inklusive des Falls "Vergleich gegen die aktuelle Fassung",
// wenn es keine spätere abgelöste Fassung mehr gibt.
func TestEntryVersionsAndDiff(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "e", Title: "T",
Content: "Zeile 1\nZeile 2", Source: store.SourceHuman, Author: "tom"})
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "e", Title: "T",
Content: "Zeile 1\nGeändert", Source: store.SourceHuman, Author: "tom"})
key := createKey(t, srv, db, project.ID, "leser", auth.RoleRead)
cookie := login(t, h, key)
// Verlaufsseite: Fassung 1 muss als vergleichbar auftauchen.
rv := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/e/versions", nil)
rv.AddCookie(cookie)
wv := httptest.NewRecorder()
h.ServeHTTP(wv, rv)
if wv.Code != http.StatusOK {
t.Fatalf("Verlauf: Status %d", wv.Code)
}
if !strings.Contains(wv.Body.String(), "/p/testprojekt/e/e/versions/1") {
t.Error("Verlauf verlinkt nicht auf den Vergleich für Fassung 1")
}
// Vergleich Fassung 1 gegen die aktuelle (Fassung 2) — es gibt keine
// weitere abgelöste Fassung dazwischen.
rd := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/e/versions/1", nil)
rd.AddCookie(cookie)
wd := httptest.NewRecorder()
h.ServeHTTP(wd, rd)
if wd.Code != http.StatusOK {
t.Fatalf("Vergleich: Status %d", wd.Code)
}
body := wd.Body.String()
if !strings.Contains(body, "Zeile 2") || !strings.Contains(body, "Geändert") {
t.Error("der Vergleich zeigt weder die entfernte noch die neue Zeile")
}
if !strings.Contains(body, "aktuell") {
t.Error("der Vergleich markiert die aktuelle Fassung nicht als solche")
}
// Eine nicht existierende Fassungsnummer muss 404 liefern, nicht
// abstürzen.
rf := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/e/versions/99", nil)
rf.AddCookie(cookie)
wf := httptest.NewRecorder()
h.ServeHTTP(wf, rf)
if wf.Code != http.StatusNotFound {
t.Errorf("unbekannte Fassung: Status %d, erwartet 404", wf.Code)
}
}
// restoreRequest baut den POST auf den Wiederherstellen-Knopf —
// so, wie ihn das Formular in entry-diff.html abschickt.
func restoreRequest(t *testing.T, db *store.Store, project store.Project, cookie *http.Cookie, version, current string) *http.Request {
t.Helper()
keys, _ := db.ListAPIKeys(context.Background(), project.ID)
token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + project.WorkspaceID + ":" + keys[0].ID)
form := url.Values{"current_version": {current}, auth.CSRFFieldName: {token}}
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/e/e/versions/"+version+"/restore",
strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
return r
}
// TestEntryRestore: Eine alte Fassung zurückholen legt eine NEUE Fassung mit
// dem alten Inhalt an — der bisherige Stand wandert in den Verlauf, und das
// Protokoll vermerkt, woher der Inhalt kam.
func TestEntryRestore(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "e", Title: "Alt",
Content: "guter Text", Tags: []string{"a"}, Source: store.SourceHuman, Author: "tom"})
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "e", Title: "Kaputt",
Content: "von einem Agenten zerstört", Tags: []string{"b"}, Source: store.SourceAgent, Author: "agent"})
key := createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite)
cookie := login(t, h, key)
// Die Vergleichsseite zeigt den Knopf für Schreibberechtigte.
rd := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/e/versions/1", nil)
rd.AddCookie(cookie)
wd := httptest.NewRecorder()
h.ServeHTTP(wd, rd)
if !strings.Contains(wd.Body.String(), "/p/testprojekt/e/e/versions/1/restore") {
t.Error("die Vergleichsseite bietet keine Wiederherstellung an")
}
w := httptest.NewRecorder()
h.ServeHTTP(w, restoreRequest(t, db, project, cookie, "1", "2"))
if w.Code != http.StatusSeeOther {
t.Fatalf("Status = %d, erwartet 303 (%s)", w.Code, w.Body.String())
}
e, err := db.EntryBySlug(ctx, project.ID, "e")
if err != nil {
t.Fatal(err)
}
if e.Version != 3 {
t.Errorf("Fassung %d, erwartet 3 — Wiederherstellen muss eine neue Fassung anlegen", e.Version)
}
if e.Title != "Alt" || e.Content != "guter Text" {
t.Errorf("Inhalt nicht wiederhergestellt: %q / %q", e.Title, e.Content)
}
if len(e.Tags) != 1 || e.Tags[0] != "a" {
t.Errorf("Schlagworte = %v, erwartet [a]", e.Tags)
}
// Der verdrängte Stand ist nicht verloren, sondern jetzt Fassung 2 im Verlauf.
two, err := db.EntryVersionByNumber(ctx, e.ID, 2)
if err != nil || two.Content != "von einem Agenten zerstört" {
t.Errorf("verdrängte Fassung fehlt im Verlauf: %v / %q", err, two.Content)
}
events, _ := db.ListAudit(ctx, store.AuditFilter{ProjectID: project.ID})
found := false
for _, ev := range events {
if ev.Action == store.ActionEntryWrite && ev.Detail == store.EntryRestoreDetail(1) {
found = true
}
}
if !found {
t.Error("das Protokoll vermerkt die Wiederherstellung nicht")
}
}
// TestEntryRestoreConflict: Hat sich der Eintrag seit dem Anzeigen geändert,
// wird nichts überschrieben.
func TestEntryRestoreConflict(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
for _, content := range []string{"eins", "zwei", "drei"} {
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "e", Title: "T",
Content: content, Source: store.SourceHuman})
}
key := createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite)
cookie := login(t, h, key)
// Die Seite wurde bei Fassung 2 angezeigt, inzwischen gilt Fassung 3.
w := httptest.NewRecorder()
h.ServeHTTP(w, restoreRequest(t, db, project, cookie, "1", "2"))
if w.Code != http.StatusConflict {
t.Fatalf("Status = %d, erwartet 409", w.Code)
}
e, _ := db.EntryBySlug(ctx, project.ID, "e")
if e.Version != 3 || e.Content != "drei" {
t.Errorf("Eintrag wurde trotz Konflikt verändert: Fassung %d, %q", e.Version, e.Content)
}
// Ohne Fassungsangabe wird ebenfalls abgelehnt, statt blind zu schreiben.
w2 := httptest.NewRecorder()
h.ServeHTTP(w2, restoreRequest(t, db, project, cookie, "1", ""))
if w2.Code != http.StatusBadRequest {
t.Errorf("ohne current_version: Status %d, erwartet 400", w2.Code)
}
}
// TestEntryRestoreNeedsWriteRole: Lesende sehen keinen Knopf und kommen auch
// direkt nicht durch.
func TestEntryRestoreNeedsWriteRole(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "e", Title: "T", Content: "eins", Source: store.SourceHuman})
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "e", Title: "T", Content: "zwei", Source: store.SourceHuman})
key := createKey(t, srv, db, project.ID, "leser", auth.RoleRead)
cookie := login(t, h, key)
rd := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/e/versions/1", nil)
rd.AddCookie(cookie)
wd := httptest.NewRecorder()
h.ServeHTTP(wd, rd)
if strings.Contains(wd.Body.String(), "/restore") {
t.Error("Leserolle bekommt den Wiederherstellen-Knopf angezeigt")
}
w := httptest.NewRecorder()
h.ServeHTTP(w, restoreRequest(t, db, project, cookie, "1", "2"))
if w.Code != http.StatusForbidden {
t.Errorf("Status = %d, erwartet 403", w.Code)
}
e, _ := db.EntryBySlug(ctx, project.ID, "e")
if e.Content != "zwei" {
t.Error("Leserolle konnte eine Fassung wiederherstellen")
}
}
// TestEntryPreviewRendersMarkdown prüft den Vorschau-Endpunkt: gleicher
// Renderer wie die Detailansicht, per POST ohne dass etwas gespeichert wird.
func TestEntryPreviewRendersMarkdown(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
key := createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite)
cookie := login(t, h, key)
keys, _ := db.ListAPIKeys(context.Background(), project.ID)
token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + project.WorkspaceID + ":" + keys[0].ID)
form := url.Values{
"content": {"# Überschrift\n\nEin Verweis auf [[anderer-eintrag]]."},
auth.CSRFFieldName: {token},
}
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/preview", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("Status = %d, erwartet 200 (%s)", w.Code, w.Body.String())
}
body := w.Body.String()
if !strings.Contains(body, "Ü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.
count, _ := db.CountEntries(context.Background(), project.ID)
if count != 0 {
t.Errorf("%d Einträge nach reiner Vorschau, erwartet 0", count)
}
}
// TestEntryPreviewRequiresWriteRole: ein Nur-Lese-Zugang darf die Vorschau
// nicht aufrufen — konsistent mit dem Bearbeitungsformular selbst.
func TestEntryPreviewRequiresWriteRole(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
key := createKey(t, srv, db, project.ID, "leser", auth.RoleRead)
cookie := login(t, h, key)
keys, _ := db.ListAPIKeys(context.Background(), project.ID)
token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + project.WorkspaceID + ":" + keys[0].ID)
form := url.Values{"content": {"Text"}, auth.CSRFFieldName: {token}}
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/preview", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusForbidden {
t.Errorf("Status = %d, erwartet %d", w.Code, http.StatusForbidden)
}
}
// TestEntrySuggest prüft die Autovervollständigung: Suche mit Begriff liefert
// den passenden Eintrag, ohne Begriff die zuletzt geänderten.
func TestEntrySuggest(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "deployment-prozess",
Title: "Deployment-Prozess", Content: "Wie wir deployen", Source: store.SourceHuman})
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "urlaubsantrag",
Title: "Urlaubsantrag", Content: "Formular für Urlaub", Source: store.SourceHuman})
key := createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite)
cookie := login(t, h, key)
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/entries/suggest?q=deploy", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("Status = %d, erwartet 200 (%s)", w.Code, w.Body.String())
}
var hits []suggestItem
if err := json.Unmarshal(w.Body.Bytes(), &hits); err != nil {
t.Fatalf("Antwort ist kein gültiges JSON: %v (%s)", err, w.Body.String())
}
if len(hits) != 1 || hits[0].Slug != "deployment-prozess" {
t.Errorf("Treffer = %+v, erwartet genau \"deployment-prozess\"", hits)
}
// Ohne Suchbegriff: beide Einträge kommen zurück, keine leere Antwort.
r2 := httptest.NewRequest(http.MethodGet, "/p/testprojekt/entries/suggest", nil)
r2.AddCookie(cookie)
w2 := httptest.NewRecorder()
h.ServeHTTP(w2, r2)
var all []suggestItem
if err := json.Unmarshal(w2.Body.Bytes(), &all); err != nil {
t.Fatalf("Antwort ist kein gültiges JSON: %v", err)
}
if len(all) != 2 {
t.Errorf("%d Vorschläge ohne Suchbegriff, erwartet 2", len(all))
}
}
// TestEntryGraphData prüft den JSON-Endpunkt der Graph-Ansicht: vorhandene
// Einträge als Knoten, ein Verweis auf einen fehlenden Bezeichner erzeugt
// trotzdem einen (nicht existierenden) Knoten.
func TestEntryGraphData(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "a", Title: "A",
Content: "verweist auf [[b]] und [[fehlt]]", Source: store.SourceHuman})
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "b", Title: "B",
Content: "kein Verweis", Source: store.SourceHuman})
key := createKey(t, srv, db, project.ID, "leser", auth.RoleRead)
cookie := login(t, h, key)
// Nur Lesen reicht — die Graph-Ansicht selbst hängt an inProject, nicht
// an withWriteAccess.
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/graph/data", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("Status = %d, erwartet 200 (%s)", w.Code, w.Body.String())
}
var data graphResponse
if err := json.Unmarshal(w.Body.Bytes(), &data); err != nil {
t.Fatalf("Antwort ist kein gültiges JSON: %v (%s)", err, w.Body.String())
}
if len(data.Nodes) != 3 {
t.Fatalf("%d Knoten, erwartet 3 (a, b, fehlt)", len(data.Nodes))
}
if len(data.Edges) != 2 {
t.Errorf("%d Kanten, erwartet 2", len(data.Edges))
}
toSlug := make(map[string]graphNode, len(data.Nodes))
for _, n := range data.Nodes {
toSlug[n.Slug] = n
}
if !toSlug["a"].Exists {
t.Error("Knoten \"a\" sollte Exists = true tragen")
}
if toSlug["fehlt"].Exists {
t.Error("Knoten \"fehlt\" sollte Exists = false tragen — es gibt keinen Eintrag dazu")
}
if data.Truncated {
t.Error("Truncated = true bei nur drei Knoten")
}
}
// TestEntryGraphPageRenders prüft, dass die Graph-Seite selbst ausliefert
// und das Skript samt Endpunkt-Adresse einbindet.
func TestEntryGraphPageRenders(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
key := createKey(t, srv, db, project.ID, "leser", auth.RoleRead)
cookie := login(t, h, key)
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/graph", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("Status = %d, erwartet 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, `data-graph="/p/testprojekt/graph/data"`) {
t.Error("die Seite bindet den Graph-Endpunkt nicht ein")
}
if !strings.Contains(body, "/static/graph.js") {
t.Error("die Seite lädt graph.js nicht")
}
}
// TestEntryGraphDataTruncatesLargeProjects prüft die Obergrenze
// (graphNodeLimit): Mehr Einträge als das Limit führen zu Truncated = true
// und genau graphNodeLimit Knoten in der Antwort, nicht mehr.
func TestEntryGraphDataTruncatesLargeProjects(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
for i := 0; i < graphNodeLimit+5; i++ {
slug := fmt.Sprintf("eintrag-%03d", i)
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: slug, Title: slug,
Content: "kein Verweis", Source: store.SourceHuman})
}
key := createKey(t, srv, db, project.ID, "leser", auth.RoleRead)
cookie := login(t, h, key)
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/graph/data", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
var data graphResponse
if err := json.Unmarshal(w.Body.Bytes(), &data); err != nil {
t.Fatalf("Antwort ist kein gültiges JSON: %v", err)
}
if !data.Truncated {
t.Error("Truncated = false, obwohl mehr Einträge als graphNodeLimit existieren")
}
if len(data.Nodes) != graphNodeLimit {
t.Errorf("%d Knoten in der Antwort, erwartet genau graphNodeLimit (%d)", len(data.Nodes), graphNodeLimit)
}
if data.Total != graphNodeLimit+5 {
t.Errorf("Total = %d, erwartet %d", data.Total, graphNodeLimit+5)
}
}
// TestEntryContentIsEscaped ist ein Sicherheitstest: Eingeschleustes HTML darf
// nicht ausgeführt werden.
func TestEntryContentIsEscaped(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
if _, err := db.PutEntry(ctx, store.EntryWrite{
ProjectID: project.ID, Slug: "boese", Title: "Test",
Content: "\n\nText", Source: store.SourceHuman,
}); err != nil {
t.Fatal(err)
}
key := createKey(t, srv, db, project.ID, "leser", auth.RoleRead)
cookie := login(t, h, key)
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/boese", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if strings.Contains(w.Body.String(), "") {
t.Error("eingeschleustes ", Results: 0})
db.LogSearch(ctx, store.SearchLogEntry{ProjectID: project.ID, Query: "deployment",
Synonyms: []string{"release"}, Results: 1})
cookie := login(t, h, createKey(t, srv, db, project.ID, "leser", auth.RoleRead))
body := fetchPage(h, cookie, "/p/testprojekt/usage").Body.String()
for _, want := range []string{"vpn zugang", "2×", "75 %", "25 %"} {
if !strings.Contains(body, want) {
t.Errorf("%q fehlt auf der Nutzungsseite", want)
}
}
if strings.Contains(body, "") {
t.Error("eine Suchanfrage wurde ungeschützt als HTML ausgegeben")
}
if strings.Contains(body, "deployment") {
t.Error("eine Suche mit Treffer steht in der Liste ohne Treffer")
}
}
// TestUsagePageShowsForReview: Ein kuratierter Eintrag, den ein Agent
// geändert hat, steht mit Begründung und Link zum Verlauf oben auf der Seite.
func TestUsagePageShowsForReview(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "vpn", Title: "VPN-Zugang",
Content: "neu", Source: store.SourceAgent})
db.LogAudit(ctx, store.AuditEvent{ProjectID: project.ID, Actor: "agent-x", Action: store.ActionEntryWrite,
EntrySlug: "vpn", Source: store.SourceMCP, Detail: store.CuratedOverrideDetail("Server umgezogen")})
cookie := login(t, h, createKey(t, srv, db, project.ID, "leser", auth.RoleRead))
body := fetchPage(h, cookie, "/p/testprojekt/usage").Body.String()
for _, want := range []string{"Zur Prüfung", "VPN-Zugang", "/p/testprojekt/e/vpn/versions", "Server umgezogen", "agent-x"} {
if !strings.Contains(body, want) {
t.Errorf("%q fehlt auf der Nutzungsseite", want)
}
}
}
// TestUsagePageEmptyProject: Ein Projekt ganz ohne Daten darf nicht
// stolpern (es fehlt dann in der map der Kennzahlen).
func TestUsagePageEmptyProject(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
cookie := login(t, h, createKey(t, srv, db, project.ID, "leser", auth.RoleRead))
w := fetchPage(h, cookie, "/p/testprojekt/usage")
if w.Code != http.StatusOK {
t.Fatalf("Status %d", w.Code)
}
if !strings.Contains(w.Body.String(), "wurde nichts geschrieben") {
t.Error("kein Hinweis für ein Projekt ohne Schreibvorgänge")
}
}
// TestNewEntryWithPrefilledSlug: ?slug= füllt das Feld, ein
// ungültiger Wert wird ignoriert.
func TestNewEntryWithPrefilledSlug(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
cookie := login(t, h, createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite))
if !strings.Contains(fetchPage(h, cookie, "/p/testprojekt/new?slug=fehlt-noch").Body.String(), `value="fehlt-noch"`) {
t.Error("der Bezeichner wurde nicht vorbelegt")
}
if strings.Contains(fetchPage(h, cookie, "/p/testprojekt/new?slug=B%C3%B6se+Sache").Body.String(), "Böse") {
t.Error("ein ungültiger Bezeichner wurde ins Formular übernommen")
}
}
// TestAdminProjectListShowsUsage: Die Projektübersicht des Plattform-Admins
// zeigt, welches Projekt gar nicht schreibt.
func TestAdminProjectListShowsUsage(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
cookie := loginAsPlatformAdmin(t, h, project.WorkspaceID)
w := fetchPage(h, cookie, "/admin/projects")
if w.Code != http.StatusOK {
t.Fatalf("Status %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "keine") {
t.Error("ein Projekt ohne Schreibvorgänge ist nicht als „keine“ markiert")
}
if !strings.Contains(body, "/p/testprojekt/usage") {
t.Error("die Projektliste verlinkt nicht auf die Nutzungsseite")
}
db.LogAudit(context.Background(), store.AuditEvent{ProjectID: project.ID, Actor: "agent",
Action: store.ActionEntryWrite, Source: store.SourceMCP, Detail: store.DetailEntryCreated})
if !strings.Contains(fetchPage(h, cookie, "/admin/projects").Body.String(), "1 neu") {
t.Error("der Schreibvorgang erscheint nicht in der Projektliste")
}
}
// TestLiveAndReady prüft die beiden Prüfungen für Container und
// Load-Balancer: Beide antworten ohne Anmeldung. /ready meldet 503, sobald die
// Datenbank nicht antwortet, /live bleibt dabei ok — sonst würde der
// Container neu gestartet, was gegen eine fehlende Datenbank nicht hilft.
func TestLiveAndReady(t *testing.T) {
srv, _, _ := testServer(t)
h := testHandler(t, srv)
queries := func(filePath string) (int, string) {
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, filePath, nil))
return w.Code, w.Body.String()
}
for _, filePath := range []string{"/live", "/healthz", "/ready"} {
if code, body := queries(filePath); code != http.StatusOK || body != "ok\n" {
t.Errorf("%s = %d %q, erwartet 200 ok", filePath, code, body)
}
}
// Datenbank weg: Die Kontroll-Datenbank wird geschlossen.
srv.control.Close()
code, body := queries("/ready")
if code != http.StatusServiceUnavailable {
t.Errorf("/ready ohne Datenbank = %d, erwartet 503", code)
}
if body != "nicht bereit\n" {
t.Errorf("/ready verrät mehr als nötig: %q", body)
}
if code, _ := queries("/live"); code != http.StatusOK {
t.Errorf("/live ohne Datenbank = %d, erwartet weiter 200", code)
}
}