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