Aktive Keys, die seit über 90 Tagen nicht benutzt wurden (nie benutzt: gemessen ab Anlegen), tragen unter Zugänge die Marke „ungenutzt“; über der Tabelle steht ein Hinweis mit der Anzahl. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2404 lines
84 KiB
Go
2404 lines
84 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
|
|
}
|
|
|
|
// waehleWorkspace wählt für eine Plattform-Admin-Sitzung einen Workspace und
|
|
// gibt den neuen Session-Cookie zurück — der Schritt, den handleWorkspaceList
|
|
// in der Oberfläche anbietet (siehe handlers_workspaces.go).
|
|
func waehleWorkspace(t *testing.T, h http.Handler, cookie *http.Cookie, workspaceID string) *http.Cookie {
|
|
t.Helper()
|
|
|
|
token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectPlatformAdmin)
|
|
formular := url.Values{"id": {workspaceID}, auth.CSRFFieldName: {token}}
|
|
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/select", strings.NewReader(formular.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
r.AddCookie(cookie)
|
|
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
|
|
for _, c := range w.Result().Cookies() {
|
|
if c.Name == auth.SessionCookieName && c.Value != "" {
|
|
return c
|
|
}
|
|
}
|
|
t.Fatalf("Workspace-Auswahl lieferte keinen Cookie (Status %d)", w.Code)
|
|
return nil
|
|
}
|
|
|
|
// anmeldenAlsPlattformAdmin meldet den Plattform-Admin an UND wählt gleich den
|
|
// gegebenen Workspace — die meisten Tests brauchen genau das, nicht den
|
|
// Zwischenschritt über die Auswahlseite.
|
|
func anmeldenAlsPlattformAdmin(t *testing.T, h http.Handler, workspaceID string) *http.Cookie {
|
|
t.Helper()
|
|
cookie := anmelden(t, h, testAdminToken)
|
|
return waehleWorkspace(t, h, cookie, workspaceID)
|
|
}
|
|
|
|
// anmelden führt eine Anmeldung durch und gibt den Session-Cookie zurück.
|
|
func anmelden(t *testing.T, h http.Handler, key string) *http.Cookie {
|
|
t.Helper()
|
|
|
|
formular := url.Values{"key": {key}}
|
|
r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(formular.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
|
|
for _, c := range w.Result().Cookies() {
|
|
if c.Name == auth.SessionCookieName && c.Value != "" {
|
|
return c
|
|
}
|
|
}
|
|
t.Fatalf("Anmeldung lieferte keinen Cookie (Status %d)", w.Code)
|
|
return nil
|
|
}
|
|
|
|
// ── Anmeldung ────────────────────────────────────────────────────────────────
|
|
|
|
func TestLoginRequiredForEverything(t *testing.T) {
|
|
srv, _, _ := testServer(t)
|
|
h := testHandler(t, srv)
|
|
|
|
geschuetzt := []string{"/", "/admin/projects", "/p/testprojekt/", "/p/testprojekt/keys"}
|
|
|
|
for _, pfad := range geschuetzt {
|
|
t.Run(pfad, func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, pfad, nil))
|
|
|
|
if w.Code != http.StatusSeeOther {
|
|
t.Errorf("Status = %d, erwartet %d (Weiterleitung zur Anmeldung)", w.Code, http.StatusSeeOther)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoginWithProjectKey(t *testing.T) {
|
|
srv, db, projekt := testServer(t)
|
|
h := testHandler(t, srv)
|
|
|
|
key := erzeugeKey(t, srv, db, projekt.ID, "test", auth.RoleWrite)
|
|
cookie := anmelden(t, h, key)
|
|
|
|
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/", nil)
|
|
r.AddCookie(cookie)
|
|
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("Status = %d, erwartet 200", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestLoginRejectsBadKey(t *testing.T) {
|
|
srv, _, _ := testServer(t)
|
|
h := testHandler(t, srv)
|
|
|
|
formular := url.Values{"key": {"wk_" + strings.Repeat("0", 64)}}
|
|
r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(formular.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("Status = %d, erwartet %d", w.Code, http.StatusUnauthorized)
|
|
}
|
|
for _, c := range w.Result().Cookies() {
|
|
if c.Name == auth.SessionCookieName && c.Value != "" {
|
|
t.Error("trotz falschem Key wurde eine Sitzung ausgestellt")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRevokedKeyEndsSession prüft, dass ein zurückgezogener Key sofort wirkt —
|
|
// die Sitzung darf ihn nicht überleben.
|
|
func TestRevokedKeyEndsSession(t *testing.T) {
|
|
srv, db, projekt := testServer(t)
|
|
h := testHandler(t, srv)
|
|
ctx := context.Background()
|
|
|
|
key := erzeugeKey(t, srv, db, projekt.ID, "test", auth.RoleWrite)
|
|
cookie := anmelden(t, h, key)
|
|
|
|
keys, _ := db.ListAPIKeys(ctx, projekt.ID)
|
|
if err := db.RevokeAPIKey(ctx, keys[0].ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/", nil)
|
|
r.AddCookie(cookie)
|
|
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusSeeOther {
|
|
t.Errorf("Status = %d, erwartet Weiterleitung zur Anmeldung — die Sitzung galt weiter", w.Code)
|
|
}
|
|
}
|
|
|
|
// ── CSRF ─────────────────────────────────────────────────────────────────────
|
|
|
|
// TestCSRFBlocksForgedPost bildet den Angriff nach: Eine fremde Seite schickt ein
|
|
// POST, der Browser hängt den Session-Cookie automatisch an — nur das Token kann
|
|
// sie nicht liefern.
|
|
func TestCSRFBlocksForgedPost(t *testing.T) {
|
|
srv, db, projekt := testServer(t)
|
|
h := testHandler(t, srv)
|
|
|
|
key := erzeugeKey(t, srv, db, projekt.ID, "test", auth.RoleAdmin)
|
|
cookie := anmelden(t, h, key)
|
|
|
|
formular := url.Values{"name": {"geklaut"}, "role": {"admin"}}
|
|
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/keys/create", strings.NewReader(formular.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
r.AddCookie(cookie)
|
|
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Errorf("Status = %d, erwartet %d", w.Code, http.StatusForbidden)
|
|
}
|
|
|
|
keys, _ := db.ListAPIKeys(context.Background(), projekt.ID)
|
|
if len(keys) != 1 {
|
|
t.Errorf("%d Keys — es wurde trotz fehlendem Token einer angelegt", len(keys))
|
|
}
|
|
}
|
|
|
|
func TestCSRFAllowsRealForm(t *testing.T) {
|
|
srv, db, projekt := testServer(t)
|
|
h := testHandler(t, srv)
|
|
|
|
key := erzeugeKey(t, srv, db, projekt.ID, "test", auth.RoleAdmin)
|
|
cookie := anmelden(t, h, key)
|
|
|
|
// Das Token so berechnen, wie es das Template tut.
|
|
keys, _ := db.ListAPIKeys(context.Background(), projekt.ID)
|
|
subject := auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keys[0].ID
|
|
token := auth.NewSigner([]byte(testSecret)).CSRFToken(subject)
|
|
|
|
formular := url.Values{"name": {"zweiter"}, "role": {"read"}, auth.CSRFFieldName: {token}}
|
|
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/keys/create", strings.NewReader(formular.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
r.AddCookie(cookie)
|
|
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("Status = %d, erwartet 200", w.Code)
|
|
}
|
|
|
|
neue, _ := db.ListAPIKeys(context.Background(), projekt.ID)
|
|
if len(neue) != 2 {
|
|
t.Errorf("%d Keys, erwartet 2", len(neue))
|
|
}
|
|
// Der Klartext des neuen Keys muss genau einmal in der Antwort stehen.
|
|
if !strings.Contains(w.Body.String(), auth.KeyPrefix) {
|
|
t.Error("der neue Key wurde nicht angezeigt")
|
|
}
|
|
|
|
// Die Konfigurationsblöcke tragen die echte Adresse, keinen Platzhalter
|
|
// (AUSBAUSTUFEN.md 5.2). httptest setzt als Host "example.com".
|
|
if strings.Contains(w.Body.String(), "DEIN-SERVER") {
|
|
t.Error("die Zugangsseite zeigt noch den Platzhalter DEIN-SERVER")
|
|
}
|
|
if !strings.Contains(w.Body.String(), "https://example.com/mcp") {
|
|
t.Error("die Zugangsseite zeigt die Server-Adresse nicht")
|
|
}
|
|
}
|
|
|
|
// ── Abschottung ──────────────────────────────────────────────────────────────
|
|
|
|
// TestProjectIsolation ist der wichtigste Test des Web-Teils: Ein Key kommt
|
|
// ausschließlich an sein eigenes Projekt.
|
|
func TestProjectIsolation(t *testing.T) {
|
|
srv, db, projektA := testServer(t)
|
|
h := testHandler(t, srv)
|
|
ctx := context.Background()
|
|
|
|
projektB, err := db.CreateProject(ctx, projektA.WorkspaceID, "fremd", "Fremdes Projekt", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Ein Eintrag, den Projekt A auf keinen Fall sehen darf.
|
|
if _, err := db.PutEntry(ctx, store.EntryWrite{
|
|
ProjectID: projektB.ID, Slug: "geheim", Title: "Geheim",
|
|
Content: "Vertrauliches aus Projekt B", Source: store.SourceHuman,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
keyA := erzeugeKey(t, srv, db, projektA.ID, "a", auth.RoleAdmin)
|
|
cookie := anmelden(t, h, keyA)
|
|
|
|
// Jede Route des fremden Projekts muss als "nicht vorhanden" antworten —
|
|
// nicht als "verboten". Sonst ließe sich über die Statuscodes herausfinden,
|
|
// welche Projekte es sonst noch gibt.
|
|
fremdeRouten := []string{
|
|
"/p/fremd/",
|
|
"/p/fremd/e/geheim",
|
|
"/p/fremd/keys",
|
|
"/p/fremd/audit",
|
|
}
|
|
|
|
for _, pfad := range fremdeRouten {
|
|
t.Run(pfad, func(t *testing.T) {
|
|
r := httptest.NewRequest(http.MethodGet, pfad, nil)
|
|
r.AddCookie(cookie)
|
|
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("Status = %d, erwartet 404", w.Code)
|
|
}
|
|
if strings.Contains(w.Body.String(), "Vertrauliches") {
|
|
t.Error("Inhalt des fremden Projekts war sichtbar")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestWorkspaceIsolation ist der wichtigste Test dieser Ausbaustufe: Zwei
|
|
// gleichzeitig offene Workspace-Dateien im selben Prozess dürfen sich
|
|
// strukturell nicht sehen — auch nicht über einen fremden, aber gültigen Key.
|
|
func TestWorkspaceIsolation(t *testing.T) {
|
|
srv, _, projektA := testServer(t)
|
|
h := testHandler(t, srv)
|
|
ctx := context.Background()
|
|
|
|
// Ein zweiter Workspace, komplett unabhängig vom ersten — eigene Datei,
|
|
// eigenes Projekt, eigener Key.
|
|
wsB, err := srv.control.CreateWorkspace(ctx, "Andere Abteilung")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
workspaceB, err := srv.workspaces.Get(wsB.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
projektB, err := workspaceB.Store.CreateProject(ctx, wsB.ID, "fremd", "Fremdes Projekt", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := workspaceB.Store.PutEntry(ctx, store.EntryWrite{
|
|
ProjectID: projektB.ID, Slug: "geheim", Title: "Geheim",
|
|
Content: "Vertrauliches aus Workspace B", Source: store.SourceHuman,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
keyB := erzeugeKey(t, srv, workspaceB.Store, projektB.ID, "b", auth.RoleAdmin)
|
|
|
|
// Mit dem Key aus Workspace B angemeldet: Projekt A (Workspace A) darf
|
|
// unter keinen Umständen erreichbar sein, obwohl der Prozess beide
|
|
// Dateien gleichzeitig offen hält.
|
|
cookie := anmelden(t, h, keyB)
|
|
|
|
r := httptest.NewRequest(http.MethodGet, "/p/"+projektA.Slug+"/", nil)
|
|
r.AddCookie(cookie)
|
|
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("Status = %d, erwartet 404 — Workspace B kam an ein Projekt aus Workspace A", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestWorkspaceCreateSelectsItAndRedirects prüft den Weg aus der Oberfläche:
|
|
// Ein neuer Workspace wird angelegt, die Sitzung wechselt automatisch dorthin,
|
|
// und der Admin landet direkt bei der Projektverwaltung — ohne den Umweg über
|
|
// die Auswahlseite.
|
|
func TestWorkspaceCreateSelectsItAndRedirects(t *testing.T) {
|
|
srv, _, projekt := testServer(t)
|
|
h := testHandler(t, srv)
|
|
|
|
cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID)
|
|
token := auth.NewSigner([]byte(testSecret)).
|
|
CSRFToken(auth.SubjectPlatformAdmin + ":" + projekt.WorkspaceID)
|
|
|
|
formular := url.Values{"name": {"Marketing"}, auth.CSRFFieldName: {token}}
|
|
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/create", strings.NewReader(formular.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
r.AddCookie(cookie)
|
|
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/admin/projects" {
|
|
t.Fatalf("Status = %d, Location = %q — erwartet Weiterleitung zu /admin/projects",
|
|
w.Code, w.Header().Get("Location"))
|
|
}
|
|
|
|
workspaces, err := srv.control.ListWorkspaces(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var gefunden bool
|
|
for _, ws := range workspaces {
|
|
if ws.Name == "Marketing" {
|
|
gefunden = true
|
|
}
|
|
}
|
|
if !gefunden {
|
|
t.Error("neuer Workspace \"Marketing\" steht nicht in der Kontroll-Datenbank")
|
|
}
|
|
|
|
// Die neue Sitzung muss den neuen Workspace tragen — ein Aufruf von
|
|
// /admin/projects mit dem zurückgegebenen Cookie darf nicht mehr zur
|
|
// Auswahlseite zurückschicken.
|
|
var neuerCookie *http.Cookie
|
|
for _, c := range w.Result().Cookies() {
|
|
if c.Name == auth.SessionCookieName {
|
|
neuerCookie = c
|
|
}
|
|
}
|
|
if neuerCookie == nil {
|
|
t.Fatal("keine neue Sitzung nach dem Anlegen ausgestellt")
|
|
}
|
|
|
|
r2 := httptest.NewRequest(http.MethodGet, "/admin/projects", nil)
|
|
r2.AddCookie(neuerCookie)
|
|
w2 := httptest.NewRecorder()
|
|
h.ServeHTTP(w2, r2)
|
|
|
|
if w2.Code != http.StatusOK {
|
|
t.Errorf("Status = %d, erwartet 200 — die neue Sitzung landete nicht im neuen Workspace", w2.Code)
|
|
}
|
|
}
|
|
|
|
// TestWorkspaceListShowsProjectCount prüft, dass die Übersicht die
|
|
// tatsächliche Projektzahl je Workspace zeigt, nicht nur Name und Datum.
|
|
func TestWorkspaceListShowsProjectCount(t *testing.T) {
|
|
srv, db, projekt := testServer(t)
|
|
h := testHandler(t, srv)
|
|
|
|
if _, err := db.CreateProject(context.Background(), projekt.WorkspaceID, "zweites", "Zweites", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID)
|
|
r := httptest.NewRequest(http.MethodGet, "/admin/workspaces", nil)
|
|
r.AddCookie(cookie)
|
|
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("Status = %d, erwartet 200", w.Code)
|
|
}
|
|
if !strings.Contains(w.Body.String(), "<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(auth.SubjectPlatformAdmin + ":" + projekt.WorkspaceID)
|
|
|
|
formular := url.Values{"name": {"Umbenannt"}, auth.CSRFFieldName: {token}}
|
|
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/"+projekt.WorkspaceID+"/edit",
|
|
strings.NewReader(formular.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
r.AddCookie(cookie)
|
|
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusSeeOther {
|
|
t.Fatalf("Status = %d, erwartet %d", w.Code, http.StatusSeeOther)
|
|
}
|
|
|
|
ws, err := srv.control.WorkspaceByID(context.Background(), projekt.WorkspaceID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ws.Name != "Umbenannt" {
|
|
t.Errorf("Name = %q, erwartet \"Umbenannt\"", ws.Name)
|
|
}
|
|
}
|
|
|
|
// TestWorkspaceDeleteRequiresConfirmation prüft die Bestätigungsabfrage — ein
|
|
// Klick allein darf nicht reichen, dahinter verschwindet alles im Workspace.
|
|
func TestWorkspaceDeleteRequiresConfirmation(t *testing.T) {
|
|
srv, _, projekt := testServer(t)
|
|
h := testHandler(t, srv)
|
|
|
|
cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID)
|
|
token := auth.NewSigner([]byte(testSecret)).
|
|
CSRFToken(auth.SubjectPlatformAdmin + ":" + projekt.WorkspaceID)
|
|
|
|
formular := url.Values{"id": {projekt.WorkspaceID}, "confirm": {"falsch"}, auth.CSRFFieldName: {token}}
|
|
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/delete", strings.NewReader(formular.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
r.AddCookie(cookie)
|
|
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("Status = %d, erwartet %d", w.Code, http.StatusBadRequest)
|
|
}
|
|
if _, err := srv.control.WorkspaceByID(context.Background(), projekt.WorkspaceID); err != nil {
|
|
t.Error("Workspace wurde trotz falscher Bestätigung gelöscht")
|
|
}
|
|
}
|
|
|
|
// TestWorkspaceDeleteRemovesEverythingAndResetsSession ist der wichtigste
|
|
// Test hier: Löschen entfernt Kontroll-Eintrag UND Datei, und eine Sitzung im
|
|
// gelöschten Workspace wird auf "kein Workspace gewählt" zurückgesetzt statt
|
|
// den Admin einfach auszusperren.
|
|
func TestWorkspaceDeleteRemovesEverythingAndResetsSession(t *testing.T) {
|
|
srv, _, projekt := testServer(t)
|
|
h := testHandler(t, srv)
|
|
ws, err := srv.control.WorkspaceByID(context.Background(), projekt.WorkspaceID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID)
|
|
token := auth.NewSigner([]byte(testSecret)).
|
|
CSRFToken(auth.SubjectPlatformAdmin + ":" + projekt.WorkspaceID)
|
|
|
|
formular := url.Values{"id": {projekt.WorkspaceID}, "confirm": {ws.Name}, auth.CSRFFieldName: {token}}
|
|
r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/delete", strings.NewReader(formular.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
r.AddCookie(cookie)
|
|
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusSeeOther {
|
|
t.Fatalf("Status = %d, erwartet %d", w.Code, http.StatusSeeOther)
|
|
}
|
|
|
|
if _, err := srv.control.WorkspaceByID(context.Background(), projekt.WorkspaceID); !errors.Is(err, store.ErrNotFound) {
|
|
t.Errorf("Workspace steht nach dem Löschen noch in der Kontroll-Datenbank (err=%v)", err)
|
|
}
|
|
|
|
// Die neue Sitzung ist die des Admins ohne Workspace — /admin/projects
|
|
// muss jetzt zur Auswahl zurückschicken statt eine tote Sitzung zu zeigen.
|
|
var neuerCookie *http.Cookie
|
|
for _, c := range w.Result().Cookies() {
|
|
if c.Name == auth.SessionCookieName {
|
|
neuerCookie = c
|
|
}
|
|
}
|
|
if neuerCookie == nil {
|
|
t.Fatal("keine neue Sitzung nach dem Löschen ausgestellt")
|
|
}
|
|
|
|
r2 := httptest.NewRequest(http.MethodGet, "/admin/projects", nil)
|
|
r2.AddCookie(neuerCookie)
|
|
w2 := httptest.NewRecorder()
|
|
h.ServeHTTP(w2, r2)
|
|
|
|
if w2.Code != http.StatusSeeOther || w2.Header().Get("Location") != "/admin/workspaces" {
|
|
t.Errorf("Status = %d, Location = %q — erwartet Weiterleitung zur Workspace-Auswahl",
|
|
w2.Code, w2.Header().Get("Location"))
|
|
}
|
|
}
|
|
|
|
// TestPlatformAdminOnly prüft, dass die Projektverwaltung dem Plattform-Admin
|
|
// vorbehalten bleibt.
|
|
func TestPlatformAdminOnly(t *testing.T) {
|
|
srv, db, projekt := testServer(t)
|
|
h := testHandler(t, srv)
|
|
|
|
key := erzeugeKey(t, srv, db, projekt.ID, "test", auth.RoleAdmin)
|
|
cookie := anmelden(t, h, key)
|
|
|
|
r := httptest.NewRequest(http.MethodGet, "/admin/projects", nil)
|
|
r.AddCookie(cookie)
|
|
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Errorf("Status = %d, erwartet %d — ein Projekt-Admin kam an die Projektverwaltung", w.Code, http.StatusForbidden)
|
|
}
|
|
}
|
|
|
|
func TestPlatformAdminCanAccessAnyProject(t *testing.T) {
|
|
srv, _, projekt := testServer(t)
|
|
h := testHandler(t, srv)
|
|
|
|
cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID)
|
|
|
|
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/", nil)
|
|
r.AddCookie(cookie)
|
|
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("Status = %d, erwartet 200", w.Code)
|
|
}
|
|
}
|
|
|
|
// ── Rollen ───────────────────────────────────────────────────────────────────
|
|
|
|
// TestReadRoleCannotWrite prüft, dass ein Nur-Lese-Zugang keine Einträge ändern kann.
|
|
func TestReadRoleCannotWrite(t *testing.T) {
|
|
srv, db, projekt := testServer(t)
|
|
h := testHandler(t, srv)
|
|
|
|
key := erzeugeKey(t, srv, db, projekt.ID, "nur-lesen", auth.RoleRead)
|
|
cookie := anmelden(t, h, key)
|
|
|
|
schreibrouten := []struct{ methode, pfad string }{
|
|
{http.MethodGet, "/p/testprojekt/new"},
|
|
{http.MethodPost, "/p/testprojekt/save"},
|
|
}
|
|
|
|
for _, tc := range schreibrouten {
|
|
t.Run(tc.pfad, func(t *testing.T) {
|
|
r := httptest.NewRequest(tc.methode, tc.pfad, strings.NewReader(""))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
r.AddCookie(cookie)
|
|
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
|
|
if w.Code == http.StatusOK {
|
|
t.Errorf("Status = %d — der Nur-Lese-Zugang kam durch", w.Code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestWriteRoleCannotManageKeys prüft die Abgrenzung zwischen write und admin.
|
|
func TestWriteRoleCannotManageKeys(t *testing.T) {
|
|
srv, db, projekt := testServer(t)
|
|
h := testHandler(t, srv)
|
|
|
|
key := erzeugeKey(t, srv, db, projekt.ID, "schreiber", auth.RoleWrite)
|
|
cookie := anmelden(t, h, key)
|
|
|
|
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/keys", nil)
|
|
r.AddCookie(cookie)
|
|
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Errorf("Status = %d, erwartet %d", w.Code, http.StatusForbidden)
|
|
}
|
|
}
|
|
|
|
// TestKeyListFreshKeyNotUnused prüft, dass die Zugänge-Seite rendert und ein
|
|
// frisch angelegter, noch nie benutzter Key nicht als „ungenutzt“ erscheint.
|
|
// Die Grenzfälle der Regel selbst prüft TestAPIKeyUnused im Store.
|
|
func TestKeyListFreshKeyNotUnused(t *testing.T) {
|
|
srv, db, projekt := testServer(t)
|
|
h := testHandler(t, srv)
|
|
|
|
admin := erzeugeKey(t, srv, db, projekt.ID, "verwalterin", auth.RoleAdmin)
|
|
erzeugeKey(t, srv, db, projekt.ID, "neue-pipeline", auth.RoleRead)
|
|
cookie := anmelden(t, h, admin)
|
|
|
|
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/keys", nil)
|
|
r.AddCookie(cookie)
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("Status = %d, erwartet %d", w.Code, http.StatusOK)
|
|
}
|
|
body := w.Body.String()
|
|
if !strings.Contains(body, "neue-pipeline") {
|
|
t.Error("der neue Key fehlt in der Liste")
|
|
}
|
|
if strings.Contains(body, "marke warnung") || strings.Contains(body, "seit über 90 Tagen nicht benutzt") {
|
|
t.Error("ein frisch angelegter Key wurde als ungenutzt markiert")
|
|
}
|
|
}
|
|
|
|
// ── Sicherheits-Header und Limits ────────────────────────────────────────────
|
|
|
|
func TestSecurityHeaders(t *testing.T) {
|
|
h := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), false)
|
|
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil))
|
|
|
|
erwartet := map[string]string{
|
|
"X-Content-Type-Options": "nosniff",
|
|
"X-Frame-Options": "DENY",
|
|
"Referrer-Policy": "same-origin",
|
|
}
|
|
for name, wert := range erwartet {
|
|
if got := w.Header().Get(name); got != wert {
|
|
t.Errorf("%s = %q, erwartet %q", name, got, wert)
|
|
}
|
|
}
|
|
|
|
csp := w.Header().Get("Content-Security-Policy")
|
|
for _, teil := range []string{"default-src 'self'", "frame-ancestors 'none'", "object-src 'none'"} {
|
|
if !strings.Contains(csp, teil) {
|
|
t.Errorf("CSP enthält %q nicht", teil)
|
|
}
|
|
}
|
|
|
|
// Ohne den Schalter darf HSTS nicht gesetzt sein — sonst sperrt man sich auf
|
|
// einer Adresse ohne Zertifikat selbst aus.
|
|
if w.Header().Get("Strict-Transport-Security") != "" {
|
|
t.Error("HSTS wurde gesetzt, obwohl abgeschaltet")
|
|
}
|
|
}
|
|
|
|
func TestLimitBody(t *testing.T) {
|
|
var leseFehler error
|
|
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
buf := make([]byte, 512)
|
|
for {
|
|
if _, err := r.Body.Read(buf); err != nil {
|
|
if err.Error() != "EOF" {
|
|
leseFehler = err
|
|
}
|
|
return
|
|
}
|
|
}
|
|
})
|
|
|
|
h := LimitBody(inner, 100)
|
|
h.ServeHTTP(httptest.NewRecorder(),
|
|
httptest.NewRequest(http.MethodPost, "/", strings.NewReader(strings.Repeat("x", 5000))))
|
|
|
|
if leseFehler == nil {
|
|
t.Error("ein zu großer Body wurde ohne Fehler durchgelassen")
|
|
}
|
|
}
|
|
|
|
// ── API-Zugang ───────────────────────────────────────────────────────────────
|
|
|
|
func TestAPIAuthRejectsBadKey(t *testing.T) {
|
|
srv, _, _ := testServer(t)
|
|
|
|
var erreicht bool
|
|
h := srv.APIAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { erreicht = true }))
|
|
|
|
faelle := map[string]string{
|
|
"ohne Header": "",
|
|
"falsches Schema": "Basic abc",
|
|
"unbekannter Key": "Bearer wk_" + strings.Repeat("0", 64),
|
|
"kaputte Form": "Bearer nonsens",
|
|
}
|
|
|
|
for name, header := range faelle {
|
|
t.Run(name, func(t *testing.T) {
|
|
erreicht = false
|
|
r := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
|
if header != "" {
|
|
r.Header.Set("Authorization", header)
|
|
}
|
|
r.RemoteAddr = "203.0.113.1:1234"
|
|
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
|
|
if erreicht {
|
|
t.Error("der Request kam durch")
|
|
}
|
|
if w.Code != http.StatusUnauthorized && w.Code != http.StatusTooManyRequests {
|
|
t.Errorf("Status = %d, erwartet 401", w.Code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAPIAuthAcceptsValidKey(t *testing.T) {
|
|
srv, db, projekt := testServer(t)
|
|
key := erzeugeKey(t, srv, db, projekt.ID, "agent", auth.RoleWrite)
|
|
|
|
var gesehen store.Project
|
|
h := srv.APIAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gesehen, _ = ProjectFrom(r)
|
|
}))
|
|
|
|
r := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
|
r.Header.Set("Authorization", "Bearer "+key)
|
|
|
|
h.ServeHTTP(httptest.NewRecorder(), r)
|
|
|
|
if gesehen.ID != projekt.ID {
|
|
t.Error("das Projekt wurde nicht im Context abgelegt")
|
|
}
|
|
}
|
|
|
|
// TestAPIAuthValidKeyBypassesRateLimit prüft, dass die IP-Sperre nur
|
|
// Fehlversuche trifft — sonst würde ein falsch konfigurierter Client alle
|
|
// anderen hinter derselben Adresse mitsperren.
|
|
func TestAPIAuthValidKeyBypassesRateLimit(t *testing.T) {
|
|
srv, db, projekt := testServer(t)
|
|
key := erzeugeKey(t, srv, db, projekt.ID, "agent", auth.RoleWrite)
|
|
|
|
var erreicht bool
|
|
h := srv.APIAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { erreicht = true }))
|
|
|
|
// Erst die Adresse in die Sperre laufen lassen …
|
|
for i := 0; i < 15; i++ {
|
|
r := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
|
r.Header.Set("Authorization", "Bearer wk_"+strings.Repeat("0", 64))
|
|
r.RemoteAddr = "203.0.113.2:1234"
|
|
h.ServeHTTP(httptest.NewRecorder(), r)
|
|
}
|
|
|
|
// … und dann mit dem richtigen Key von derselben Adresse kommen.
|
|
r := httptest.NewRequest(http.MethodPost, "/mcp", nil)
|
|
r.Header.Set("Authorization", "Bearer "+key)
|
|
r.RemoteAddr = "203.0.113.2:1234"
|
|
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
|
|
if !erreicht {
|
|
t.Errorf("gültiger Key wurde wegen der IP-Sperre abgewiesen (Status %d)", w.Code)
|
|
}
|
|
}
|
|
|
|
// ── Einträge über die Weboberfläche ──────────────────────────────────────────
|
|
|
|
func TestEntryCreateAndView(t *testing.T) {
|
|
srv, db, projekt := testServer(t)
|
|
h := testHandler(t, srv)
|
|
|
|
key := erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite)
|
|
cookie := anmelden(t, h, key)
|
|
|
|
keys, _ := db.ListAPIKeys(context.Background(), projekt.ID)
|
|
token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keys[0].ID)
|
|
|
|
formular := url.Values{
|
|
"title": {"Deployment-Prozess"},
|
|
"content": {"Wir deployen über [[server-zugang]]."},
|
|
"tags": {"ops, deployment"},
|
|
auth.CSRFFieldName: {token},
|
|
}
|
|
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/save", strings.NewReader(formular.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
r.AddCookie(cookie)
|
|
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusSeeOther {
|
|
t.Fatalf("Status = %d, erwartet Weiterleitung (%s)", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Der Bezeichner wurde aus dem Titel abgeleitet.
|
|
e, err := db.EntryBySlug(context.Background(), projekt.ID, "deployment-prozess")
|
|
if err != nil {
|
|
t.Fatalf("Eintrag nicht gefunden: %v", err)
|
|
}
|
|
if e.Source != store.SourceHuman {
|
|
t.Errorf("Source = %q, erwartet %q", e.Source, store.SourceHuman)
|
|
}
|
|
if e.Author != "mensch" {
|
|
t.Errorf("Author = %q, erwartet den Key-Namen", e.Author)
|
|
}
|
|
if len(e.Tags) != 2 {
|
|
t.Errorf("Tags = %v, erwartet zwei", e.Tags)
|
|
}
|
|
|
|
// Das Protokoll vermerkt, dass der Eintrag neu angelegt wurde.
|
|
ereignisse, err := db.ListAudit(context.Background(), store.AuditFilter{ProjectID: projekt.ID})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var gefunden bool
|
|
for _, ev := range ereignisse {
|
|
if ev.Action == store.ActionEntryWrite && ev.EntrySlug == "deployment-prozess" {
|
|
gefunden = true
|
|
if ev.Detail != store.DetailEntryCreated {
|
|
t.Errorf("Detail = %q, erwartet %q", ev.Detail, store.DetailEntryCreated)
|
|
}
|
|
}
|
|
}
|
|
if !gefunden {
|
|
t.Error("Schreibvorgang fehlt im Protokoll")
|
|
}
|
|
|
|
// Die Detailansicht muss den Verweis als Link darstellen.
|
|
rv := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/deployment-prozess", nil)
|
|
rv.AddCookie(cookie)
|
|
|
|
wv := httptest.NewRecorder()
|
|
h.ServeHTTP(wv, rv)
|
|
|
|
if wv.Code != http.StatusOK {
|
|
t.Fatalf("Detailansicht: Status %d", wv.Code)
|
|
}
|
|
if !strings.Contains(wv.Body.String(), "/p/testprojekt/e/server-zugang") {
|
|
t.Error("der [[…]]-Verweis wurde nicht in einen Link umgewandelt")
|
|
}
|
|
}
|
|
|
|
// TestEntryBulkTags prüft die Sammelbearbeitung: zwei ausgewählte Einträge
|
|
// bekommen ein Schlagwort dazu und eines weg, ein dritter, nicht ausgewählter
|
|
// bleibt unangetastet.
|
|
func TestEntryBulkTags(t *testing.T) {
|
|
srv, db, projekt := testServer(t)
|
|
h := testHandler(t, srv)
|
|
ctx := context.Background()
|
|
|
|
a, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "a", Title: "A",
|
|
Tags: []string{"alt"}, Source: store.SourceHuman})
|
|
b, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "b", Title: "B",
|
|
Tags: []string{"alt"}, Source: store.SourceHuman})
|
|
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "c", Title: "C",
|
|
Tags: []string{"alt"}, Source: store.SourceHuman})
|
|
|
|
key := erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite)
|
|
cookie := anmelden(t, h, key)
|
|
|
|
keys, _ := db.ListAPIKeys(ctx, projekt.ID)
|
|
token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keys[0].ID)
|
|
|
|
formular := url.Values{
|
|
"ids": {a.ID, b.ID},
|
|
"add_tags": {"neu"},
|
|
"remove_tags": {"alt"},
|
|
auth.CSRFFieldName: {token},
|
|
}
|
|
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/entries/bulk-tags", strings.NewReader(formular.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
r.AddCookie(cookie)
|
|
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusSeeOther {
|
|
t.Fatalf("Status = %d, erwartet Weiterleitung (%s)", w.Code, w.Body.String())
|
|
}
|
|
|
|
neuA, _ := db.EntryBySlug(ctx, projekt.ID, "a")
|
|
if len(neuA.Tags) != 1 || neuA.Tags[0] != "neu" {
|
|
t.Errorf("Tags von A = %v, erwartet [neu]", neuA.Tags)
|
|
}
|
|
|
|
unveraendertC, _ := db.EntryBySlug(ctx, projekt.ID, "c")
|
|
if len(unveraendertC.Tags) != 1 || unveraendertC.Tags[0] != "alt" {
|
|
t.Errorf("Tags von C = %v — C war nicht ausgewählt", unveraendertC.Tags)
|
|
}
|
|
}
|
|
|
|
// TestEntryVersionsAndDiff prüft den Verlauf und den Vergleich zweier
|
|
// Fassungen — inklusive des Falls "Vergleich gegen die aktuelle Fassung",
|
|
// wenn es keine spätere abgelöste Fassung mehr gibt.
|
|
func TestEntryVersionsAndDiff(t *testing.T) {
|
|
srv, db, projekt := testServer(t)
|
|
h := testHandler(t, srv)
|
|
ctx := context.Background()
|
|
|
|
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "T",
|
|
Content: "Zeile 1\nZeile 2", Source: store.SourceHuman, Author: "tom"})
|
|
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "T",
|
|
Content: "Zeile 1\nGeändert", Source: store.SourceHuman, Author: "tom"})
|
|
|
|
key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead)
|
|
cookie := anmelden(t, h, key)
|
|
|
|
// Verlaufsseite: Fassung 1 muss als vergleichbar auftauchen.
|
|
rv := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/e/versions", nil)
|
|
rv.AddCookie(cookie)
|
|
wv := httptest.NewRecorder()
|
|
h.ServeHTTP(wv, rv)
|
|
|
|
if wv.Code != http.StatusOK {
|
|
t.Fatalf("Verlauf: Status %d", wv.Code)
|
|
}
|
|
if !strings.Contains(wv.Body.String(), "/p/testprojekt/e/e/versions/1") {
|
|
t.Error("Verlauf verlinkt nicht auf den Vergleich für Fassung 1")
|
|
}
|
|
|
|
// Vergleich Fassung 1 gegen die aktuelle (Fassung 2) — es gibt keine
|
|
// weitere abgelöste Fassung dazwischen.
|
|
rd := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/e/versions/1", nil)
|
|
rd.AddCookie(cookie)
|
|
wd := httptest.NewRecorder()
|
|
h.ServeHTTP(wd, rd)
|
|
|
|
if wd.Code != http.StatusOK {
|
|
t.Fatalf("Vergleich: Status %d", wd.Code)
|
|
}
|
|
body := wd.Body.String()
|
|
if !strings.Contains(body, "Zeile 2") || !strings.Contains(body, "Geändert") {
|
|
t.Error("der Vergleich zeigt weder die entfernte noch die neue Zeile")
|
|
}
|
|
if !strings.Contains(body, "aktuell") {
|
|
t.Error("der Vergleich markiert die aktuelle Fassung nicht als solche")
|
|
}
|
|
|
|
// Eine nicht existierende Fassungsnummer muss 404 liefern, nicht
|
|
// abstürzen.
|
|
rf := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/e/versions/99", nil)
|
|
rf.AddCookie(cookie)
|
|
wf := httptest.NewRecorder()
|
|
h.ServeHTTP(wf, rf)
|
|
if wf.Code != http.StatusNotFound {
|
|
t.Errorf("unbekannte Fassung: Status %d, erwartet 404", wf.Code)
|
|
}
|
|
}
|
|
|
|
// wiederherstellenAnfrage baut den POST auf den Wiederherstellen-Knopf —
|
|
// so, wie ihn das Formular in entry-diff.html abschickt.
|
|
func wiederherstellenAnfrage(t *testing.T, db *store.Store, projekt store.Project, cookie *http.Cookie, fassung, aktuell string) *http.Request {
|
|
t.Helper()
|
|
keys, _ := db.ListAPIKeys(context.Background(), projekt.ID)
|
|
token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keys[0].ID)
|
|
|
|
formular := url.Values{"current_version": {aktuell}, auth.CSRFFieldName: {token}}
|
|
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/e/e/versions/"+fassung+"/restore",
|
|
strings.NewReader(formular.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
r.AddCookie(cookie)
|
|
return r
|
|
}
|
|
|
|
// TestEntryRestore: Eine alte Fassung zurückholen legt eine NEUE Fassung mit
|
|
// dem alten Inhalt an — der bisherige Stand wandert in den Verlauf, und das
|
|
// Protokoll vermerkt, woher der Inhalt kam.
|
|
func TestEntryRestore(t *testing.T) {
|
|
srv, db, projekt := testServer(t)
|
|
h := testHandler(t, srv)
|
|
ctx := context.Background()
|
|
|
|
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "Alt",
|
|
Content: "guter Text", Tags: []string{"a"}, Source: store.SourceHuman, Author: "tom"})
|
|
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "Kaputt",
|
|
Content: "von einem Agenten zerstört", Tags: []string{"b"}, Source: store.SourceAgent, Author: "agent"})
|
|
|
|
key := erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite)
|
|
cookie := anmelden(t, h, key)
|
|
|
|
// Die Vergleichsseite zeigt den Knopf für Schreibberechtigte.
|
|
rd := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/e/versions/1", nil)
|
|
rd.AddCookie(cookie)
|
|
wd := httptest.NewRecorder()
|
|
h.ServeHTTP(wd, rd)
|
|
if !strings.Contains(wd.Body.String(), "/p/testprojekt/e/e/versions/1/restore") {
|
|
t.Error("die Vergleichsseite bietet keine Wiederherstellung an")
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, wiederherstellenAnfrage(t, db, projekt, cookie, "1", "2"))
|
|
if w.Code != http.StatusSeeOther {
|
|
t.Fatalf("Status = %d, erwartet 303 (%s)", w.Code, w.Body.String())
|
|
}
|
|
|
|
e, err := db.EntryBySlug(ctx, projekt.ID, "e")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if e.Version != 3 {
|
|
t.Errorf("Fassung %d, erwartet 3 — Wiederherstellen muss eine neue Fassung anlegen", e.Version)
|
|
}
|
|
if e.Title != "Alt" || e.Content != "guter Text" {
|
|
t.Errorf("Inhalt nicht wiederhergestellt: %q / %q", e.Title, e.Content)
|
|
}
|
|
if len(e.Tags) != 1 || e.Tags[0] != "a" {
|
|
t.Errorf("Schlagworte = %v, erwartet [a]", e.Tags)
|
|
}
|
|
|
|
// Der verdrängte Stand ist nicht verloren, sondern jetzt Fassung 2 im Verlauf.
|
|
zwei, err := db.EntryVersionByNumber(ctx, e.ID, 2)
|
|
if err != nil || zwei.Content != "von einem Agenten zerstört" {
|
|
t.Errorf("verdrängte Fassung fehlt im Verlauf: %v / %q", err, zwei.Content)
|
|
}
|
|
|
|
ereignisse, _ := db.ListAudit(ctx, store.AuditFilter{ProjectID: projekt.ID})
|
|
gefunden := false
|
|
for _, ev := range ereignisse {
|
|
if ev.Action == store.ActionEntryWrite && ev.Detail == store.EntryRestoreDetail(1) {
|
|
gefunden = true
|
|
}
|
|
}
|
|
if !gefunden {
|
|
t.Error("das Protokoll vermerkt die Wiederherstellung nicht")
|
|
}
|
|
}
|
|
|
|
// TestEntryRestoreConflict: Hat sich der Eintrag seit dem Anzeigen geändert,
|
|
// wird nichts überschrieben.
|
|
func TestEntryRestoreConflict(t *testing.T) {
|
|
srv, db, projekt := testServer(t)
|
|
h := testHandler(t, srv)
|
|
ctx := context.Background()
|
|
|
|
for _, inhalt := range []string{"eins", "zwei", "drei"} {
|
|
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "T",
|
|
Content: inhalt, Source: store.SourceHuman})
|
|
}
|
|
|
|
key := erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite)
|
|
cookie := anmelden(t, h, key)
|
|
|
|
// Die Seite wurde bei Fassung 2 angezeigt, inzwischen gilt Fassung 3.
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, wiederherstellenAnfrage(t, db, projekt, cookie, "1", "2"))
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("Status = %d, erwartet 409", w.Code)
|
|
}
|
|
|
|
e, _ := db.EntryBySlug(ctx, projekt.ID, "e")
|
|
if e.Version != 3 || e.Content != "drei" {
|
|
t.Errorf("Eintrag wurde trotz Konflikt verändert: Fassung %d, %q", e.Version, e.Content)
|
|
}
|
|
|
|
// Ohne Fassungsangabe wird ebenfalls abgelehnt, statt blind zu schreiben.
|
|
w2 := httptest.NewRecorder()
|
|
h.ServeHTTP(w2, wiederherstellenAnfrage(t, db, projekt, cookie, "1", ""))
|
|
if w2.Code != http.StatusBadRequest {
|
|
t.Errorf("ohne current_version: Status %d, erwartet 400", w2.Code)
|
|
}
|
|
}
|
|
|
|
// TestEntryRestoreNeedsWriteRole: Lesende sehen keinen Knopf und kommen auch
|
|
// direkt nicht durch.
|
|
func TestEntryRestoreNeedsWriteRole(t *testing.T) {
|
|
srv, db, projekt := testServer(t)
|
|
h := testHandler(t, srv)
|
|
ctx := context.Background()
|
|
|
|
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "T", Content: "eins", Source: store.SourceHuman})
|
|
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "T", Content: "zwei", Source: store.SourceHuman})
|
|
|
|
key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead)
|
|
cookie := anmelden(t, h, key)
|
|
|
|
rd := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/e/versions/1", nil)
|
|
rd.AddCookie(cookie)
|
|
wd := httptest.NewRecorder()
|
|
h.ServeHTTP(wd, rd)
|
|
if strings.Contains(wd.Body.String(), "/restore") {
|
|
t.Error("Leserolle bekommt den Wiederherstellen-Knopf angezeigt")
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, wiederherstellenAnfrage(t, db, projekt, cookie, "1", "2"))
|
|
if w.Code != http.StatusForbidden {
|
|
t.Errorf("Status = %d, erwartet 403", w.Code)
|
|
}
|
|
e, _ := db.EntryBySlug(ctx, projekt.ID, "e")
|
|
if e.Content != "zwei" {
|
|
t.Error("Leserolle konnte eine Fassung wiederherstellen")
|
|
}
|
|
}
|
|
|
|
// TestEntryPreviewRendersMarkdown prüft den Vorschau-Endpunkt: gleicher
|
|
// Renderer wie die Detailansicht, per POST ohne dass etwas gespeichert wird.
|
|
func TestEntryPreviewRendersMarkdown(t *testing.T) {
|
|
srv, db, projekt := testServer(t)
|
|
h := testHandler(t, srv)
|
|
|
|
key := erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite)
|
|
cookie := anmelden(t, h, key)
|
|
|
|
keys, _ := db.ListAPIKeys(context.Background(), projekt.ID)
|
|
token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keys[0].ID)
|
|
|
|
formular := url.Values{
|
|
"content": {"# Überschrift\n\nEin Verweis auf [[anderer-eintrag]]."},
|
|
auth.CSRFFieldName: {token},
|
|
}
|
|
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/preview", strings.NewReader(formular.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
r.AddCookie(cookie)
|
|
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("Status = %d, erwartet 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
body := w.Body.String()
|
|
if !strings.Contains(body, "<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(auth.SubjectPlatformAdmin+":"+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(auth.SubjectPlatformAdmin + ":" + wsB.ID)
|
|
formular := url.Values{auth.CSRFFieldName: {token}}
|
|
r := httptest.NewRequest(http.MethodPost, "/admin/export", strings.NewReader(formular.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
r.AddCookie(cookie)
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
|
|
if w.Code != http.StatusSeeOther {
|
|
t.Fatalf("Status = %d, erwartet 303", w.Code)
|
|
}
|
|
|
|
inhalt, err := os.ReadFile(filepath.Join(ziel, wsB.ID, projektB.Slug, "angebote.md"))
|
|
if err != nil {
|
|
t.Fatalf("Eintrag aus dem zweiten Workspace wurde nicht exportiert: %v", err)
|
|
}
|
|
if !strings.Contains(string(inhalt), "Wissen aus dem Vertrieb") {
|
|
t.Error("exportierte Datei hat den falschen Inhalt")
|
|
}
|
|
// Der Knopf exportiert nur den gewählten Workspace, nicht den ersten.
|
|
if _, err := os.Stat(filepath.Join(ziel, projektA.WorkspaceID)); err == nil {
|
|
t.Error("der Knopf hat auch den nicht gewählten Workspace exportiert")
|
|
}
|
|
}
|
|
|
|
// ── Papierkorb ───────────────────────────────────────────────────────────────
|
|
|
|
// formularSenden schickt ein Formular mit gültigem CSRF-Token ab, so wie es
|
|
// ein Mensch mit dem ersten Key des Projekts im Browser täte.
|
|
func formularSenden(t *testing.T, h http.Handler, db *store.Store, projekt store.Project, cookie *http.Cookie, pfad string, formular url.Values) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
keys, _ := db.ListAPIKeys(context.Background(), projekt.ID)
|
|
formular.Set(auth.CSRFFieldName,
|
|
auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix+projekt.WorkspaceID+":"+keys[0].ID))
|
|
|
|
r := httptest.NewRequest(http.MethodPost, pfad, strings.NewReader(formular.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
r.AddCookie(cookie)
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
return w
|
|
}
|
|
|
|
// seiteHolen ruft eine Seite ab.
|
|
func seiteHolen(h http.Handler, cookie *http.Cookie, pfad string) *httptest.ResponseRecorder {
|
|
r := httptest.NewRequest(http.MethodGet, pfad, nil)
|
|
r.AddCookie(cookie)
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
return w
|
|
}
|
|
|
|
// TestPapierkorbAblauf spielt den ganzen Weg durch: löschen, im Papierkorb
|
|
// sehen, wiederherstellen, erneut löschen, endgültig löschen.
|
|
func TestPapierkorbAblauf(t *testing.T) {
|
|
srv, db, projekt := testServer(t)
|
|
h := testHandler(t, srv)
|
|
ctx := context.Background()
|
|
|
|
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "Wichtiges Wissen",
|
|
Content: "x", Source: store.SourceHuman})
|
|
|
|
cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite))
|
|
|
|
// 1. Löschen über die Detailansicht → Papierkorb, mit Hinweis.
|
|
w := formularSenden(t, h, db, projekt, cookie, "/p/testprojekt/e/e/delete", url.Values{})
|
|
if w.Code != http.StatusSeeOther || !strings.Contains(w.Header().Get("Location"), "hinweis=") {
|
|
t.Fatalf("Löschen: Status %d, Ziel %q", w.Code, w.Header().Get("Location"))
|
|
}
|
|
if seiteHolen(h, cookie, "/p/testprojekt/e/e").Code != http.StatusNotFound {
|
|
t.Error("der gelöschte Eintrag ist noch aufrufbar")
|
|
}
|
|
|
|
// 2. Die Papierkorb-Seite zeigt ihn samt Zugang.
|
|
seite := seiteHolen(h, cookie, "/p/testprojekt/trash")
|
|
if seite.Code != http.StatusOK {
|
|
t.Fatalf("Papierkorb: Status %d", seite.Code)
|
|
}
|
|
if !strings.Contains(seite.Body.String(), "Wichtiges Wissen") || !strings.Contains(seite.Body.String(), "mensch") {
|
|
t.Error("der Papierkorb zeigt Eintrag oder löschenden Zugang nicht")
|
|
}
|
|
|
|
// 3. Wiederherstellen.
|
|
formularSenden(t, h, db, projekt, cookie, "/p/testprojekt/trash/restore", url.Values{"slugs": {"e"}})
|
|
if seiteHolen(h, cookie, "/p/testprojekt/e/e").Code != http.StatusOK {
|
|
t.Fatal("der wiederhergestellte Eintrag ist nicht wieder aufrufbar")
|
|
}
|
|
|
|
// 4. Erneut löschen und endgültig entfernen.
|
|
formularSenden(t, h, db, projekt, cookie, "/p/testprojekt/e/e/delete", url.Values{})
|
|
formularSenden(t, h, db, projekt, cookie, "/p/testprojekt/trash/purge", url.Values{"slugs": {"e"}})
|
|
if liste, _ := db.ListTrash(ctx, projekt.ID, ""); len(liste) != 0 {
|
|
t.Error("der Eintrag liegt nach endgültigem Löschen noch im Papierkorb")
|
|
}
|
|
|
|
// Jeder Schritt steht im Protokoll.
|
|
ereignisse, _ := db.ListAudit(ctx, store.AuditFilter{ProjectID: projekt.ID})
|
|
gezaehlt := map[string]int{}
|
|
for _, ev := range ereignisse {
|
|
gezaehlt[ev.Action]++
|
|
}
|
|
if gezaehlt[store.ActionEntryDelete] != 2 || gezaehlt[store.ActionEntryRestore] != 1 || gezaehlt[store.ActionEntryPurge] != 1 {
|
|
t.Errorf("Protokoll: %v", gezaehlt)
|
|
}
|
|
}
|
|
|
|
// TestPapierkorbPurgeNurAusDemPapierkorb: Über das Formular zum endgültigen
|
|
// Löschen lässt sich kein Eintrag entfernen, der nicht im Papierkorb liegt.
|
|
func TestPapierkorbPurgeNurAusDemPapierkorb(t *testing.T) {
|
|
srv, db, projekt := testServer(t)
|
|
h := testHandler(t, srv)
|
|
ctx := context.Background()
|
|
|
|
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "lebt", Title: "T", Source: store.SourceHuman})
|
|
cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite))
|
|
|
|
formularSenden(t, h, db, projekt, cookie, "/p/testprojekt/trash/purge", url.Values{"slugs": {"lebt"}})
|
|
|
|
if _, err := db.EntryBySlug(ctx, projekt.ID, "lebt"); err != nil {
|
|
t.Error("ein Eintrag außerhalb des Papierkorbs wurde endgültig gelöscht")
|
|
}
|
|
ereignisse, _ := db.ListAudit(ctx, store.AuditFilter{ProjectID: projekt.ID})
|
|
for _, ev := range ereignisse {
|
|
if ev.Action == store.ActionEntryPurge {
|
|
t.Error("das Protokoll vermerkt eine endgültige Löschung, die nicht stattfand")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPapierkorbNurMitSchreibrecht: Lesende sehen weder den Link noch die
|
|
// Seite — der Papierkorb kann Inhalte enthalten, die gerade deshalb gelöscht
|
|
// wurden, weil sie nicht jeder sehen soll.
|
|
func TestPapierkorbNurMitSchreibrecht(t *testing.T) {
|
|
srv, db, projekt := testServer(t)
|
|
h := testHandler(t, srv)
|
|
ctx := context.Background()
|
|
|
|
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "T", Source: store.SourceHuman})
|
|
db.MoveEntryToTrash(ctx, projekt.ID, "e", "tom")
|
|
|
|
cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead))
|
|
|
|
if w := seiteHolen(h, cookie, "/p/testprojekt/trash"); w.Code != http.StatusForbidden {
|
|
t.Errorf("Papierkorb für Leserolle: Status %d, erwartet 403", w.Code)
|
|
}
|
|
if strings.Contains(seiteHolen(h, cookie, "/p/testprojekt/").Body.String(), "/trash") {
|
|
t.Error("die Leserolle sieht den Link zum Papierkorb")
|
|
}
|
|
if w := formularSenden(t, h, db, projekt, cookie, "/p/testprojekt/trash/restore", url.Values{"slugs": {"e"}}); w.Code != http.StatusForbidden {
|
|
t.Errorf("Wiederherstellen mit Leserolle: Status %d, erwartet 403", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestPapierkorbFilterNachZugang: Nach einem Zugang gefiltert, lässt sich
|
|
// alles, was er gelöscht hat, in einem Zug zurückholen — ohne das, was andere
|
|
// gelöscht haben.
|
|
func TestPapierkorbFilterNachZugang(t *testing.T) {
|
|
srv, db, projekt := testServer(t)
|
|
h := testHandler(t, srv)
|
|
ctx := context.Background()
|
|
|
|
for _, slug := range []string{"a", "b", "c"} {
|
|
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: slug, Title: "Titel " + slug, Source: store.SourceHuman})
|
|
}
|
|
db.MoveEntryToTrash(ctx, projekt.ID, "a", "agent-1")
|
|
db.MoveEntryToTrash(ctx, projekt.ID, "b", "agent-1")
|
|
db.MoveEntryToTrash(ctx, projekt.ID, "c", "tom")
|
|
|
|
cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite))
|
|
|
|
seite := seiteHolen(h, cookie, "/p/testprojekt/trash?von=agent-1").Body.String()
|
|
if strings.Contains(seite, "Titel c") {
|
|
t.Error("der Filter zeigt auch, was ein anderer Zugang gelöscht hat")
|
|
}
|
|
if !strings.Contains(seite, "Alle 2 von") {
|
|
t.Error("der Knopf zum Wiederherstellen aller gefilterten Einträge fehlt")
|
|
}
|
|
|
|
// So, wie das Formular "Alle … wiederherstellen" abschickt — mit einem
|
|
// doppelten Bezeichner, der nur einmal zählen darf.
|
|
formularSenden(t, h, db, projekt, cookie, "/p/testprojekt/trash/restore",
|
|
url.Values{"slugs": {"a", "b", "a"}, "von": {"agent-1"}})
|
|
|
|
if liste, _ := db.ListTrash(ctx, projekt.ID, ""); len(liste) != 1 || liste[0].Slug != "c" {
|
|
t.Errorf("im Papierkorb übrig: %v, erwartet nur „c“", liste)
|
|
}
|
|
ereignisse, _ := db.ListAudit(ctx, store.AuditFilter{ProjectID: projekt.ID})
|
|
wiederhergestellt := 0
|
|
for _, ev := range ereignisse {
|
|
if ev.Action == store.ActionEntryRestore {
|
|
wiederhergestellt++
|
|
}
|
|
}
|
|
if wiederhergestellt != 2 {
|
|
t.Errorf("%d Wiederherstellungen protokolliert, erwartet 2", wiederhergestellt)
|
|
}
|
|
}
|
|
|
|
// TestSpeichernHoltAusDemPapierkorb: Legt ein Mensch einen "neuen" Eintrag mit
|
|
// einem Bezeichner an, der im Papierkorb liegt, erfährt er, dass er ihn
|
|
// zurückgeholt und überschrieben hat.
|
|
func TestSpeichernHoltAusDemPapierkorb(t *testing.T) {
|
|
srv, db, projekt := testServer(t)
|
|
h := testHandler(t, srv)
|
|
ctx := context.Background()
|
|
|
|
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "Alt", Content: "alt", Source: store.SourceHuman})
|
|
db.MoveEntryToTrash(ctx, projekt.ID, "e", "agent-1")
|
|
|
|
cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite))
|
|
|
|
w := formularSenden(t, h, db, projekt, cookie, "/p/testprojekt/save",
|
|
url.Values{"slug": {"e"}, "title": {"Neu"}, "content": {"neu"}, "version": {"0"}})
|
|
if w.Code != http.StatusSeeOther {
|
|
t.Fatalf("Speichern: Status %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
if !strings.Contains(w.Header().Get("Location"), "hinweis=") {
|
|
t.Error("kein Hinweis, dass ein Eintrag aus dem Papierkorb überschrieben wurde")
|
|
}
|
|
|
|
// Ein Formular, das noch den Stand vor dem Löschen trug, bekommt dagegen
|
|
// einen Konflikt statt die Löschung stillschweigend aufzuheben.
|
|
db.MoveEntryToTrash(ctx, projekt.ID, "e", "agent-1")
|
|
w = formularSenden(t, h, db, projekt, cookie, "/p/testprojekt/e/e/save",
|
|
url.Values{"slug": {"e"}, "title": {"Neu"}, "content": {"noch neuer"}, "version": {"2"}})
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("Speichern eines inzwischen gelöschten Eintrags: Status %d, erwartet 400 (zurück ins Formular)", w.Code)
|
|
}
|
|
}
|
|
|
|
// ── Nutzungsübersicht ────────────────────────────────────────────────────────
|
|
|
|
// TestNutzungsseite: Die Seite zeigt Bestand, Schreibvorgänge und Verweise ins
|
|
// Leere — auch für die Leserolle, aber ohne den Link zum Anlegen.
|
|
func TestNutzungsseite(t *testing.T) {
|
|
srv, db, projekt := testServer(t)
|
|
h := testHandler(t, srv)
|
|
ctx := context.Background()
|
|
|
|
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "a", Title: "Ohne Schlagwort",
|
|
Content: "siehe [[fehlt-noch]]", Source: store.SourceAgent})
|
|
db.LogAudit(ctx, store.AuditEvent{ProjectID: projekt.ID, Actor: "agent", Action: store.ActionEntryWrite,
|
|
Source: store.SourceMCP, Detail: store.DetailEntryCreated})
|
|
db.LogAudit(ctx, store.AuditEvent{ProjectID: projekt.ID, Actor: "agent", Action: store.ActionEntryWrite,
|
|
Source: store.SourceMCP, Detail: store.DetailEntryUpdated})
|
|
|
|
leser := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead))
|
|
w := seiteHolen(h, leser, "/p/testprojekt/usage")
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("Status %d: %s", w.Code, w.Body.String())
|
|
}
|
|
body := w.Body.String()
|
|
for _, erwartet := range []string{"Ohne Schlagwort", "fehlt-noch", "50 %"} {
|
|
if !strings.Contains(body, erwartet) {
|
|
t.Errorf("%q fehlt auf der Nutzungsseite", erwartet)
|
|
}
|
|
}
|
|
if strings.Contains(body, "/new?slug=") {
|
|
t.Error("die Leserolle bekommt einen Link zum Anlegen")
|
|
}
|
|
|
|
schreiber := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite))
|
|
if !strings.Contains(seiteHolen(h, schreiber, "/p/testprojekt/usage").Body.String(), "/p/testprojekt/new?slug=fehlt-noch") {
|
|
t.Error("mit Schreibrecht fehlt der Link zum Anlegen des fehlenden Eintrags")
|
|
}
|
|
}
|
|
|
|
// TestNutzungsseiteLeeresProjekt: Ein Projekt ganz ohne Daten darf nicht
|
|
// stolpern (es fehlt dann in der map der Kennzahlen).
|
|
func TestNutzungsseiteLeeresProjekt(t *testing.T) {
|
|
srv, db, projekt := testServer(t)
|
|
h := testHandler(t, srv)
|
|
|
|
cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead))
|
|
w := seiteHolen(h, cookie, "/p/testprojekt/usage")
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("Status %d", w.Code)
|
|
}
|
|
if !strings.Contains(w.Body.String(), "wurde nichts geschrieben") {
|
|
t.Error("kein Hinweis für ein Projekt ohne Schreibvorgänge")
|
|
}
|
|
}
|
|
|
|
// TestNeuerEintragMitVorbelegtemBezeichner: ?slug= füllt das Feld, ein
|
|
// ungültiger Wert wird ignoriert.
|
|
func TestNeuerEintragMitVorbelegtemBezeichner(t *testing.T) {
|
|
srv, db, projekt := testServer(t)
|
|
h := testHandler(t, srv)
|
|
cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite))
|
|
|
|
if !strings.Contains(seiteHolen(h, cookie, "/p/testprojekt/new?slug=fehlt-noch").Body.String(), `value="fehlt-noch"`) {
|
|
t.Error("der Bezeichner wurde nicht vorbelegt")
|
|
}
|
|
if strings.Contains(seiteHolen(h, cookie, "/p/testprojekt/new?slug=B%C3%B6se+Sache").Body.String(), "Böse") {
|
|
t.Error("ein ungültiger Bezeichner wurde ins Formular übernommen")
|
|
}
|
|
}
|
|
|
|
// TestAdminProjektlisteZeigtNutzung: Die Projektübersicht des Plattform-Admins
|
|
// zeigt, welches Projekt gar nicht schreibt.
|
|
func TestAdminProjektlisteZeigtNutzung(t *testing.T) {
|
|
srv, db, projekt := testServer(t)
|
|
h := testHandler(t, srv)
|
|
|
|
cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID)
|
|
w := seiteHolen(h, cookie, "/admin/projects")
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("Status %d", w.Code)
|
|
}
|
|
body := w.Body.String()
|
|
if !strings.Contains(body, "keine") {
|
|
t.Error("ein Projekt ohne Schreibvorgänge ist nicht als „keine“ markiert")
|
|
}
|
|
if !strings.Contains(body, "/p/testprojekt/usage") {
|
|
t.Error("die Projektliste verlinkt nicht auf die Nutzungsseite")
|
|
}
|
|
|
|
db.LogAudit(context.Background(), store.AuditEvent{ProjectID: projekt.ID, Actor: "agent",
|
|
Action: store.ActionEntryWrite, Source: store.SourceMCP, Detail: store.DetailEntryCreated})
|
|
if !strings.Contains(seiteHolen(h, cookie, "/admin/projects").Body.String(), "1 neu") {
|
|
t.Error("der Schreibvorgang erscheint nicht in der Projektliste")
|
|
}
|
|
}
|