963 lines
31 KiB
Go
963 lines
31 KiB
Go
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: "<script>alert(1)</script>\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(), "<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, "/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 := anmelden(t, h, testAdminToken)
|
|
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, "/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, 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 + 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, "/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, "/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, _, _ := testServer(t)
|
|
h := testHandler(t, srv)
|
|
|
|
cookie := anmelden(t, h, testAdminToken)
|
|
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, samt gültigem CSRF-Token.
|
|
func adminPost(t *testing.T, h http.Handler, pfad string, formular url.Values) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
|
|
cookie := anmelden(t, h, testAdminToken)
|
|
formular.Set(auth.CSRFFieldName, auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectPlatformAdmin))
|
|
|
|
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
|
|
}
|