package web import ( "bytes" "context" "io/fs" "log" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "testing" "time" "munin/internal/auth" "munin/internal/search" "munin/internal/store" ) 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() db, err := store.Open(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatalf("Datenbank: %v", err) } t.Cleanup(func() { db.Close() }) ctx := context.Background() ws, err := db.EnsureWorkspace(ctx, "Test") if err != nil { t.Fatal(err) } 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{ Store: db, Search: search.NewRegistry(db), Signer: auth.NewSigner([]byte(testSecret)), Cookies: auth.NewCookiePolicy("false"), Limiter: auth.NewRateLimiter(), Templates: templatesForTest(t), WorkspaceID: ws.ID, 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. func erzeugeKey(t *testing.T, db *store.Store, projectID, name string, rolle auth.Role) string { t.Helper() klartext, err := auth.GenerateKey() if err != nil { t.Fatal(err) } if _, err := db.CreateAPIKey(context.Background(), projectID, name, auth.HashKey(klartext), string(rolle)); err != nil { t.Fatal(err) } return klartext } // 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, 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, 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, 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, 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 + 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") } } // ── 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() ws, _ := db.EnsureWorkspace(ctx, "Test") projektB, err := db.CreateProject(ctx, ws.ID, "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, 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") } }) } } // 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, 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, _, _ := testServer(t) h := testHandler(t, srv) cookie := anmelden(t, h, testAdminToken) 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, 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, 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) } } // ── 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, 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, 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, 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 + 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) } // 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") } } // TestEntryContentIsEscaped ist ein Sicherheitstest: Eingeschleustes HTML darf // nicht ausgeführt werden. func TestEntryContentIsEscaped(t *testing.T) { srv, db, projekt := testServer(t) h := testHandler(t, srv) ctx := context.Background() if _, err := db.PutEntry(ctx, store.EntryWrite{ ProjectID: projekt.ID, Slug: "boese", Title: "Test", Content: "\n\nText", Source: store.SourceHuman, }); err != nil { t.Fatal(err) } key := erzeugeKey(t, db, projekt.ID, "leser", auth.RoleRead) cookie := anmelden(t, h, key) r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/boese", nil) r.AddCookie(cookie) w := httptest.NewRecorder() h.ServeHTTP(w, r) if strings.Contains(w.Body.String(), "") { t.Error("eingeschleustes