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