package web import ( "context" "net/http" "net/http/httptest" "net/url" "regexp" "strings" "testing" "time" "munin/internal/auth" "munin/internal/store" ) // Tests für den Workspace-Admin (AUSBAUSTUFEN.md 4.8). // // Der Schwerpunkt liegt auf dem, was er NICHT darf: Die Rolle ist nur dann // ungefährlich, wenn sie an keiner Stelle über ihren Workspace, über die // Weboberfläche oder über die Projekt- und Key-Verwaltung hinausreicht. // workspaceAdmin bündelt, was ein Test über einen angemeldeten // Workspace-Admin wissen muss. type workspaceAdmin struct { token string cookie *http.Cookie csrf string } // createWorkspaceAdmin erzeugt einen Workspace-Admin direkt in der // Kontroll-Datenbank und gibt das Token im Klartext zurück — wie es der // Plattform-Admin auf der Bearbeitungsseite des Workspace bekäme. func createWorkspaceAdmin(t *testing.T, srv *Server, workspaceID, name string) (string, store.WorkspaceAdmin) { t.Helper() token, err := auth.GenerateWorkspaceAdminToken() if err != nil { t.Fatal(err) } admin, err := srv.control.CreateWorkspaceAdmin(context.Background(), workspaceID, name, auth.HashKey(token)) if err != nil { t.Fatal(err) } return token, admin } // loginAsWorkspaceAdmin legt einen Workspace-Admin an, meldet ihn an und // berechnet sein CSRF-Token. func loginAsWorkspaceAdmin(t *testing.T, srv *Server, h http.Handler, workspaceID string) workspaceAdmin { t.Helper() token, admin := createWorkspaceAdmin(t, srv, workspaceID, "Erika") subject := auth.SubjectWorkspaceAdminPrefix + workspaceID + ":" + admin.ID return workspaceAdmin{ token: token, cookie: login(t, h, token), csrf: auth.NewSigner([]byte(testSecret)).CSRFToken(subject), } } // get ruft eine Seite mit der Sitzung des Workspace-Admins auf. func (a workspaceAdmin) get(h http.Handler, filePath string) *httptest.ResponseRecorder { r := httptest.NewRequest(http.MethodGet, filePath, nil) r.AddCookie(a.cookie) w := httptest.NewRecorder() h.ServeHTTP(w, r) return w } // post schickt ein Formular mit gültigem CSRF-Token — abgewiesen werden darf // es also nur an der Rechteprüfung, nicht am Token. func (a workspaceAdmin) post(h http.Handler, filePath string, form url.Values) *httptest.ResponseRecorder { if form == nil { form = url.Values{} } form.Set(auth.CSRFFieldName, a.csrf) r := httptest.NewRequest(http.MethodPost, filePath, strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.AddCookie(a.cookie) w := httptest.NewRecorder() h.ServeHTTP(w, r) return w } // ── Was er darf ────────────────────────────────────────────────────────────── func TestWorkspaceAdminLandsOnProjects(t *testing.T) { srv, _, project := testServer(t) h := testHandler(t, srv) admin := loginAsWorkspaceAdmin(t, srv, h, project.WorkspaceID) w := admin.get(h, "/") if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/admin/projects" { t.Fatalf("Startseite: Status %d, Ziel %q — erwartet Weiterleitung auf /admin/projects", w.Code, w.Header().Get("Location")) } w = admin.get(h, "/admin/projects") if w.Code != http.StatusOK { t.Fatalf("Projektliste: Status %d, erwartet 200", w.Code) } if !strings.Contains(w.Body.String(), project.Name) { t.Error("die Projektliste zeigt das Projekt des Workspace nicht") } if strings.Contains(w.Body.String(), `action="/admin/export"`) { t.Error("die Projektliste bietet dem Workspace-Admin den Export an") } } func TestWorkspaceAdminManagesProjects(t *testing.T) { srv, db, project := testServer(t) h := testHandler(t, srv) admin := loginAsWorkspaceAdmin(t, srv, h, project.WorkspaceID) ctx := context.Background() // Anlegen w := admin.post(h, "/admin/projects/create", url.Values{"name": {"Neues Team"}, "slug": {"neu"}}) if w.Code != http.StatusSeeOther { t.Fatalf("Anlegen: Status %d, erwartet 303 — %s", w.Code, w.Body.String()) } newer, err := db.ProjectBySlug(ctx, project.WorkspaceID, "neu") if err != nil { t.Fatalf("das angelegte Projekt fehlt: %v", err) } // Umbenennen w = admin.post(h, "/admin/projects/neu/edit", url.Values{"name": {"Umbenannt"}, "description": {""}}) if w.Code != http.StatusSeeOther { t.Fatalf("Bearbeiten: Status %d, erwartet 303 — %s", w.Code, w.Body.String()) } if p, _ := db.ProjectByID(ctx, newer.ID); p.Name != "Umbenannt" { t.Errorf("Name = %q, erwartet \"Umbenannt\"", p.Name) } // Löschen w = admin.post(h, "/admin/projects/delete", url.Values{"id": {newer.ID}, "confirm": {"neu"}}) if w.Code != http.StatusSeeOther { t.Fatalf("Löschen: Status %d, erwartet 303", w.Code) } if _, err := db.ProjectByID(ctx, newer.ID); err == nil { t.Error("das Projekt ist nach dem Löschen noch da") } // Im Protokoll steht der Workspace-Admin mit Namen, nicht "platform-admin". events, err := db.ListAudit(ctx, store.AuditFilter{}) if err != nil { t.Fatal(err) } found := false for _, e := range events { if e.Action == store.ActionProjectCreated { found = true if e.Actor != "workspace-admin:Erika" { t.Errorf("Actor = %q, erwartet \"workspace-admin:Erika\"", e.Actor) } } } if !found { t.Error("das Anlegen des Projekts fehlt im Protokoll") } } func TestWorkspaceAdminManagesKeysAndReads(t *testing.T) { srv, db, project := testServer(t) h := testHandler(t, srv) admin := loginAsWorkspaceAdmin(t, srv, h, project.WorkspaceID) for _, filePath := range []string{"/p/testprojekt/", "/p/testprojekt/keys", "/p/testprojekt/audit", "/admin/audit"} { if w := admin.get(h, filePath); w.Code != http.StatusOK { t.Errorf("GET %s: Status %d, erwartet 200", filePath, w.Code) } } w := admin.post(h, "/p/testprojekt/keys/create", url.Values{"name": {"team-lead"}, "role": {"admin"}}) if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), auth.KeyPrefix) { t.Fatalf("Key anlegen: Status %d, oder kein Key in der Antwort", w.Code) } keys, err := db.ListAPIKeys(context.Background(), project.ID) if err != nil || len(keys) != 1 { t.Fatalf("erwartet genau einen Key, gefunden %d (%v)", len(keys), err) } w = admin.post(h, "/p/testprojekt/keys/revoke", url.Values{"id": {keys[0].ID}}) if w.Code != http.StatusOK { t.Fatalf("Key zurückziehen: Status %d, erwartet 200", w.Code) } if k, _ := db.APIKeyByID(context.Background(), keys[0].ID); !k.Revoked() { t.Error("der Key ist nach dem Zurückziehen noch aktiv") } } // ── Was er nicht darf ──────────────────────────────────────────────────────── // TestWorkspaceAdminCannotReachPlatformRoutes geht jede Route durch, die dem // Plattform-Admin vorbehalten bleibt. func TestWorkspaceAdminCannotReachPlatformRoutes(t *testing.T) { srv, _, project := testServer(t) h := testHandler(t, srv) admin := loginAsWorkspaceAdmin(t, srv, h, project.WorkspaceID) id := project.WorkspaceID gets := []string{ "/admin/workspaces", "/admin/workspaces/" + id + "/edit", } for _, filePath := range gets { if w := admin.get(h, filePath); w.Code != http.StatusForbidden { t.Errorf("GET %s: Status %d, erwartet 403", filePath, w.Code) } } posts := map[string]url.Values{ "/admin/workspaces/create": {"name": {"Eigener"}}, "/admin/workspaces/select": {"id": {id}}, "/admin/workspaces/delete": {"id": {id}, "confirm": {"Test"}}, "/admin/workspaces/" + id + "/edit": {"name": {"Umbenannt"}}, "/admin/workspaces/" + id + "/admins/create": {"name": {"Zweiter"}}, "/admin/workspaces/" + id + "/admins/revoke": {"id": {"egal"}}, "/admin/export": nil, } for filePath, form := range posts { if w := admin.post(h, filePath, form); w.Code != http.StatusForbidden { t.Errorf("POST %s: Status %d, erwartet 403", filePath, w.Code) } } // Kein zweiter Workspace-Admin durch die Hintertür. admins, err := srv.control.ListWorkspaceAdmins(context.Background(), id) if err != nil { t.Fatal(err) } if len(admins) != 1 { t.Errorf("%d Workspace-Admins, erwartet 1", len(admins)) } } // TestWorkspaceAdminCannotWriteEntries: Lesen ja, Schreiben nein — auch nicht // über Import, Papierkorb oder Wiederherstellen. func TestWorkspaceAdminCannotWriteEntries(t *testing.T) { srv, _, project := testServer(t) h := testHandler(t, srv) admin := loginAsWorkspaceAdmin(t, srv, h, project.WorkspaceID) for _, filePath := range []string{"/p/testprojekt/new", "/p/testprojekt/import", "/p/testprojekt/trash"} { if w := admin.get(h, filePath); w.Code != http.StatusForbidden { t.Errorf("GET %s: Status %d, erwartet 403", filePath, w.Code) } } w := admin.post(h, "/p/testprojekt/save", url.Values{ "slug": {"neu"}, "title": {"Neu"}, "content": {"Text"}, }) if w.Code != http.StatusForbidden { t.Errorf("Eintrag speichern: Status %d, erwartet 403", w.Code) } // Die Seitenleiste bietet weder Import noch Papierkorb an. body := admin.get(h, "/p/testprojekt/").Body.String() for _, link := range []string{`href="/p/testprojekt/import"`, `href="/p/testprojekt/trash"`, `href="/p/testprojekt/new"`} { if strings.Contains(body, link) { t.Errorf("die Seite verlinkt %s, obwohl der Workspace-Admin dort abgewiesen wird", link) } } } // TestWorkspaceAdminCannotChangeWriteBudget: Das Feld wird nicht angeboten, // und ein von Hand nachgebautes Formular ändert trotzdem nichts. func TestWorkspaceAdminCannotChangeWriteBudget(t *testing.T) { srv, db, project := testServer(t) h := testHandler(t, srv) admin := loginAsWorkspaceAdmin(t, srv, h, project.WorkspaceID) formPage := admin.get(h, "/admin/projects/testprojekt/edit") if formPage.Code != http.StatusOK { t.Fatalf("Bearbeitungsseite: Status %d, erwartet 200", formPage.Code) } if strings.Contains(formPage.Body.String(), `name="write_limit_hourly"`) { t.Error("die Bearbeitungsseite bietet dem Workspace-Admin das Schreibbudget an") } w := admin.post(h, "/admin/projects/testprojekt/edit", url.Values{ "name": {"Neuer Name"}, "description": {""}, "write_limit_hourly": {"99999"}, }) if w.Code != http.StatusSeeOther { t.Fatalf("Speichern: Status %d, erwartet 303 — %s", w.Code, w.Body.String()) } read, err := db.ProjectByID(context.Background(), project.ID) if err != nil { t.Fatal(err) } if read.WriteLimitHourly != project.WriteLimitHourly { t.Errorf("WriteLimitHourly = %d, erwartet unverändert %d", read.WriteLimitHourly, project.WriteLimitHourly) } if read.Name != "Neuer Name" { t.Errorf("Name = %q — die übrigen Felder sollen trotzdem gespeichert werden", read.Name) } } // TestWorkspaceAdminStaysInOwnWorkspace: Projekte eines anderen Workspace // sind für ihn nicht vorhanden — weder unter /p/ noch in der Verwaltung. func TestWorkspaceAdminStaysInOwnWorkspace(t *testing.T) { srv, _, project := testServer(t) h := testHandler(t, srv) ctx := context.Background() 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) } projectB, err := workspaceB.Store.CreateProject(ctx, wsB.ID, "fremd", "Fremdes Projekt", "") if err != nil { t.Fatal(err) } admin := loginAsWorkspaceAdmin(t, srv, h, project.WorkspaceID) for _, filePath := range []string{"/p/fremd/", "/p/fremd/keys", "/admin/projects/fremd/edit"} { if w := admin.get(h, filePath); w.Code != http.StatusNotFound { t.Errorf("GET %s: Status %d, erwartet 404", filePath, w.Code) } } if w := admin.post(h, "/admin/projects/delete", url.Values{"id": {projectB.ID}, "confirm": {"fremd"}}); w.Code != http.StatusNotFound { t.Errorf("fremdes Projekt löschen: Status %d, erwartet 404", w.Code) } if _, err := workspaceB.Store.ProjectByID(ctx, projectB.ID); err != nil { t.Error("das Projekt aus Workspace B wurde gelöscht") } if strings.Contains(admin.get(h, "/admin/projects").Body.String(), "Fremdes Projekt") { t.Error("die Projektliste zeigt ein Projekt aus einem anderen Workspace") } } // TestWorkspaceAdminTokenRejectedByAPI: Das Token taugt nur für die // Weboberfläche, nicht für MCP. func TestWorkspaceAdminTokenRejectedByAPI(t *testing.T) { srv, _, project := testServer(t) token, _ := createWorkspaceAdmin(t, srv, project.WorkspaceID, "Erika") var reached bool h := srv.APIAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached = true })) r := httptest.NewRequest(http.MethodPost, "/mcp", nil) r.Header.Set("Authorization", "Bearer "+token) w := httptest.NewRecorder() h.ServeHTTP(w, r) if reached { t.Fatal("ein Workspace-Admin-Token kam an der MCP-Schnittstelle durch") } if w.Code != http.StatusUnauthorized { t.Errorf("Status = %d, erwartet 401", w.Code) } } // ── Anlegen und Zurückziehen durch den Plattform-Admin ────────────────────── func TestPlatformAdminIssuesAndRevokesWorkspaceAdmin(t *testing.T) { srv, _, project := testServer(t) h := testHandler(t, srv) filePath := "/admin/workspaces/" + project.WorkspaceID + "/admins/" w := adminPost(t, h, project.WorkspaceID, filePath+"create", url.Values{"name": {"Erika"}}) if w.Code != http.StatusOK { t.Fatalf("Anlegen: Status %d, erwartet 200 — %s", w.Code, w.Body.String()) } // Das Token steht genau einmal in der Antwort — herausfischen und damit anmelden. body := w.Body.String() start := strings.Index(body, auth.WorkspaceAdminPrefix) if start < 0 { t.Fatal("die Antwort enthält kein Token") } token := body[start : start+len(auth.WorkspaceAdminPrefix)+64] if !auth.LooksLikeWorkspaceAdminToken(token) { t.Fatalf("%q sieht nicht wie ein Workspace-Admin-Token aus", token) } cookie := login(t, h, token) admins, err := srv.control.ListWorkspaceAdmins(context.Background(), project.WorkspaceID) if err != nil || len(admins) != 1 { t.Fatalf("erwartet genau einen Workspace-Admin, gefunden %d (%v)", len(admins), err) } w = adminPost(t, h, project.WorkspaceID, filePath+"revoke", url.Values{"id": {admins[0].ID}}) if w.Code != http.StatusOK { t.Fatalf("Zurückziehen: Status %d, erwartet 200", w.Code) } // Die laufende Sitzung endet sofort. r := httptest.NewRequest(http.MethodGet, "/admin/projects", nil) r.AddCookie(cookie) w = httptest.NewRecorder() h.ServeHTTP(w, r) if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/login" { t.Errorf("nach dem Zurückziehen: Status %d, Ziel %q — erwartet Weiterleitung zur Anmeldung", w.Code, w.Header().Get("Location")) } // Und eine neue Anmeldung klappt nicht mehr. form := url.Values{"key": {token}} r = httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.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("Anmeldung mit zurückgezogenem Token: Status %d, erwartet 401", w.Code) } } // TestWorkspaceListCountsActiveAdmins: Die Übersicht zählt nur aktive // Workspace-Admins, und ein frisch angelegter gilt nicht als „ungenutzt“. func TestWorkspaceListCountsActiveAdmins(t *testing.T) { srv, _, project := testServer(t) h := testHandler(t, srv) ctx := context.Background() createWorkspaceAdmin(t, srv, project.WorkspaceID, "Erika") createWorkspaceAdmin(t, srv, project.WorkspaceID, "Max") _, gone := createWorkspaceAdmin(t, srv, project.WorkspaceID, "Ehemalig") if err := srv.control.RevokeWorkspaceAdmin(ctx, project.WorkspaceID, gone.ID); err != nil { t.Fatal(err) } cookie := loginAsPlatformAdmin(t, h, project.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) } body := w.Body.String() if !regexp.MustCompile(`\s*2\s*`).MatchString(body) { t.Error("die Übersicht zeigt nicht 2 aktive Workspace-Admins") } if strings.Contains(body, "ungenutzt") { t.Error("frisch angelegte Workspace-Admins sind als ungenutzt markiert") } } // TestWorkspaceAdminRevokeRejectsForeignID:Über die Bearbeitungsseite von // Workspace A lässt sich kein Zugang aus Workspace B zurückziehen. func TestWorkspaceAdminRevokeRejectsForeignID(t *testing.T) { srv, _, project := testServer(t) h := testHandler(t, srv) wsB, err := srv.control.CreateWorkspace(context.Background(), "Andere Abteilung") if err != nil { t.Fatal(err) } _, foreign := createWorkspaceAdmin(t, srv, wsB.ID, "Fremd") w := adminPost(t, h, project.WorkspaceID, "/admin/workspaces/"+project.WorkspaceID+"/admins/revoke", url.Values{"id": {foreign.ID}}) if w.Code != http.StatusNotFound { t.Errorf("Status = %d, erwartet 404", w.Code) } read, err := srv.control.WorkspaceAdminByID(context.Background(), foreign.ID) if err != nil { t.Fatal(err) } if read.Revoked() { t.Error("der Zugang aus Workspace B wurde zurückgezogen") } } // TestWorkspaceAdminOverview: Der Überblick (AUSBAUSTUFEN.md 5.17) nennt je // Projekt, was zu tun ist — mit Link dorthin —, zeigt die Projekttabelle und // die Aktivität. Ein Projektzugang kommt nicht dran. func TestWorkspaceAdminOverview(t *testing.T) { srv, db, project := testServer(t) h := testHandler(t, srv) ctx := context.Background() // Projekt 1: ein admin-Key, der in fünf Tagen abläuft, und ein Eintrag. if _, err := db.CreateAPIKeyWithExpiry(ctx, project.ID, "praktikum", "hash-praktikum", string(auth.RoleAdmin), time.Now().Add(5*24*time.Hour)); err != nil { t.Fatal(err) } if _, err := db.PutEntry(ctx, store.EntryWrite{ ProjectID: project.ID, Slug: "notiz", Title: "Notiz", Content: "x", Source: store.SourceAgent, }); err != nil { t.Fatal(err) } db.LogAudit(ctx, store.AuditEvent{ProjectID: project.ID, Actor: "agent", Action: store.ActionEntryWrite, Source: store.SourceMCP}) // Projekt 2: nur ein Lese-Key, nie benutzt. idle, err := db.CreateProject(ctx, project.WorkspaceID, "ruhig", "Ruhiges Projekt", "") if err != nil { t.Fatal(err) } if _, err := db.CreateAPIKey(ctx, idle.ID, "leser", "hash-leser", string(auth.RoleRead)); err != nil { t.Fatal(err) } // Projekt 3: gar kein Key. if _, err := db.CreateProject(ctx, project.WorkspaceID, "leer", "Leeres Projekt", ""); err != nil { t.Fatal(err) } admin := loginAsWorkspaceAdmin(t, srv, h, project.WorkspaceID) w := admin.get(h, "/admin/overview") if w.Code != http.StatusOK { t.Fatalf("/admin/overview = %d", w.Code) } page := w.Body.String() for _, want := range []string{ "Der Key „praktikum“ gilt nur noch bis", "/p/" + project.Slug + "/keys", "Kein gültiger Key", "Kein Key mit der Rolle admin", "In 30 Tagen weder geschrieben noch gesucht", "/p/ruhig/usage", `Ruhiges Projekt`, // Name über den Befunden ` strings.Index(page, "Kein Key mit der Rolle admin") { t.Error("Warnung steht hinter einem Hinweis") } // Technisches gehört auf die Systemseite, nicht hierher. for _, missing := range []string{"max_connections", "WAL", "Kontroll-Datenbank"} { if strings.Contains(page, missing) { t.Errorf("%q steht im Überblick des Workspace-Admins", missing) } } // Ein Projektzugang kommt nicht dran, auch nicht mit Rolle admin. key := login(t, h, createKey(t, srv, db, project.ID, "projekt-admin", auth.RoleAdmin)) if code := fetchPage(h, key, "/admin/overview").Code; code == http.StatusOK { t.Error("Projekt-Admin sieht den Überblick des Workspace") } }