munin/internal/web/workspace_admin_test.go

557 lines
20 KiB
Go

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
}
// legeWorkspaceAdminAn 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 legeWorkspaceAdminAn(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
}
// anmeldenAlsWorkspaceAdmin legt einen Workspace-Admin an, meldet ihn an und
// berechnet sein CSRF-Token.
func anmeldenAlsWorkspaceAdmin(t *testing.T, srv *Server, h http.Handler, workspaceID string) workspaceAdmin {
t.Helper()
token, admin := legeWorkspaceAdminAn(t, srv, workspaceID, "Erika")
subject := auth.SubjectWorkspaceAdminPrefix + workspaceID + ":" + admin.ID
return workspaceAdmin{
token: token,
cookie: anmelden(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, pfad string) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodGet, pfad, 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, pfad string, formular url.Values) *httptest.ResponseRecorder {
if formular == nil {
formular = url.Values{}
}
formular.Set(auth.CSRFFieldName, a.csrf)
r := httptest.NewRequest(http.MethodPost, pfad, strings.NewReader(formular.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, _, projekt := testServer(t)
h := testHandler(t, srv)
admin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.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(), projekt.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, projekt := testServer(t)
h := testHandler(t, srv)
admin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.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())
}
neu, err := db.ProjectBySlug(ctx, projekt.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, neu.ID); p.Name != "Umbenannt" {
t.Errorf("Name = %q, erwartet \"Umbenannt\"", p.Name)
}
// Löschen
w = admin.post(h, "/admin/projects/delete", url.Values{"id": {neu.ID}, "confirm": {"neu"}})
if w.Code != http.StatusSeeOther {
t.Fatalf("Löschen: Status %d, erwartet 303", w.Code)
}
if _, err := db.ProjectByID(ctx, neu.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".
ereignisse, err := db.ListAudit(ctx, store.AuditFilter{})
if err != nil {
t.Fatal(err)
}
gefunden := false
for _, e := range ereignisse {
if e.Action == store.ActionProjectCreated {
gefunden = true
if e.Actor != "workspace-admin:Erika" {
t.Errorf("Actor = %q, erwartet \"workspace-admin:Erika\"", e.Actor)
}
}
}
if !gefunden {
t.Error("das Anlegen des Projekts fehlt im Protokoll")
}
}
func TestWorkspaceAdminManagesKeysAndReads(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
admin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID)
for _, pfad := range []string{"/p/testprojekt/", "/p/testprojekt/keys", "/p/testprojekt/audit", "/admin/audit"} {
if w := admin.get(h, pfad); w.Code != http.StatusOK {
t.Errorf("GET %s: Status %d, erwartet 200", pfad, 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(), projekt.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, _, projekt := testServer(t)
h := testHandler(t, srv)
admin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID)
id := projekt.WorkspaceID
gets := []string{
"/admin/workspaces",
"/admin/workspaces/" + id + "/edit",
}
for _, pfad := range gets {
if w := admin.get(h, pfad); w.Code != http.StatusForbidden {
t.Errorf("GET %s: Status %d, erwartet 403", pfad, 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 pfad, formular := range posts {
if w := admin.post(h, pfad, formular); w.Code != http.StatusForbidden {
t.Errorf("POST %s: Status %d, erwartet 403", pfad, 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, _, projekt := testServer(t)
h := testHandler(t, srv)
admin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID)
for _, pfad := range []string{"/p/testprojekt/new", "/p/testprojekt/import", "/p/testprojekt/trash"} {
if w := admin.get(h, pfad); w.Code != http.StatusForbidden {
t.Errorf("GET %s: Status %d, erwartet 403", pfad, 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, projekt := testServer(t)
h := testHandler(t, srv)
admin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID)
formularSeite := admin.get(h, "/admin/projects/testprojekt/edit")
if formularSeite.Code != http.StatusOK {
t.Fatalf("Bearbeitungsseite: Status %d, erwartet 200", formularSeite.Code)
}
if strings.Contains(formularSeite.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())
}
gelesen, err := db.ProjectByID(context.Background(), projekt.ID)
if err != nil {
t.Fatal(err)
}
if gelesen.WriteLimitHourly != projekt.WriteLimitHourly {
t.Errorf("WriteLimitHourly = %d, erwartet unverändert %d", gelesen.WriteLimitHourly, projekt.WriteLimitHourly)
}
if gelesen.Name != "Neuer Name" {
t.Errorf("Name = %q — die übrigen Felder sollen trotzdem gespeichert werden", gelesen.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, _, projekt := 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)
}
projektB, err := workspaceB.Store.CreateProject(ctx, wsB.ID, "fremd", "Fremdes Projekt", "")
if err != nil {
t.Fatal(err)
}
admin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID)
for _, pfad := range []string{"/p/fremd/", "/p/fremd/keys", "/admin/projects/fremd/edit"} {
if w := admin.get(h, pfad); w.Code != http.StatusNotFound {
t.Errorf("GET %s: Status %d, erwartet 404", pfad, w.Code)
}
}
if w := admin.post(h, "/admin/projects/delete", url.Values{"id": {projektB.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, projektB.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, _, projekt := testServer(t)
token, _ := legeWorkspaceAdminAn(t, srv, projekt.WorkspaceID, "Erika")
var erreicht bool
h := srv.APIAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { erreicht = true }))
r := httptest.NewRequest(http.MethodPost, "/mcp", nil)
r.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if erreicht {
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, _, projekt := testServer(t)
h := testHandler(t, srv)
pfad := "/admin/workspaces/" + projekt.WorkspaceID + "/admins/"
w := adminPost(t, h, projekt.WorkspaceID, pfad+"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 := anmelden(t, h, token)
admins, err := srv.control.ListWorkspaceAdmins(context.Background(), projekt.WorkspaceID)
if err != nil || len(admins) != 1 {
t.Fatalf("erwartet genau einen Workspace-Admin, gefunden %d (%v)", len(admins), err)
}
w = adminPost(t, h, projekt.WorkspaceID, pfad+"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.
formular := url.Values{"key": {token}}
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("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, _, projekt := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
legeWorkspaceAdminAn(t, srv, projekt.WorkspaceID, "Erika")
legeWorkspaceAdminAn(t, srv, projekt.WorkspaceID, "Max")
_, weg := legeWorkspaceAdminAn(t, srv, projekt.WorkspaceID, "Ehemalig")
if err := srv.control.RevokeWorkspaceAdmin(ctx, projekt.WorkspaceID, weg.ID); 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)
}
body := w.Body.String()
if !regexp.MustCompile(`<td class="zahl">\s*2\s*</td>`).MatchString(body) {
t.Error("die Übersicht zeigt nicht 2 aktive Workspace-Admins")
}
if strings.Contains(body, "ungenutzt</a>") {
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, _, projekt := testServer(t)
h := testHandler(t, srv)
wsB, err := srv.control.CreateWorkspace(context.Background(), "Andere Abteilung")
if err != nil {
t.Fatal(err)
}
_, fremd := legeWorkspaceAdminAn(t, srv, wsB.ID, "Fremd")
w := adminPost(t, h, projekt.WorkspaceID,
"/admin/workspaces/"+projekt.WorkspaceID+"/admins/revoke", url.Values{"id": {fremd.ID}})
if w.Code != http.StatusNotFound {
t.Errorf("Status = %d, erwartet 404", w.Code)
}
gelesen, err := srv.control.WorkspaceAdminByID(context.Background(), fremd.ID)
if err != nil {
t.Fatal(err)
}
if gelesen.Revoked() {
t.Error("der Zugang aus Workspace B wurde zurückgezogen")
}
}
// TestWorkspaceAdminUeberblick: 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 TestWorkspaceAdminUeberblick(t *testing.T) {
srv, db, projekt := 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, projekt.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: projekt.ID, Slug: "notiz", Title: "Notiz", Content: "x", Source: store.SourceAgent,
}); err != nil {
t.Fatal(err)
}
db.LogAudit(ctx, store.AuditEvent{ProjectID: projekt.ID, Actor: "agent", Action: store.ActionEntryWrite, Source: store.SourceMCP})
// Projekt 2: nur ein Lese-Key, nie benutzt.
ruhig, err := db.CreateProject(ctx, projekt.WorkspaceID, "ruhig", "Ruhiges Projekt", "")
if err != nil {
t.Fatal(err)
}
if _, err := db.CreateAPIKey(ctx, ruhig.ID, "leser", "hash-leser", string(auth.RoleRead)); err != nil {
t.Fatal(err)
}
// Projekt 3: gar kein Key.
if _, err := db.CreateProject(ctx, projekt.WorkspaceID, "leer", "Leeres Projekt", ""); err != nil {
t.Fatal(err)
}
admin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID)
w := admin.get(h, "/admin/overview")
if w.Code != http.StatusOK {
t.Fatalf("/admin/overview = %d", w.Code)
}
seite := w.Body.String()
for _, erwartet := range []string{
"Der Key „praktikum“ gilt nur noch bis",
"/p/" + projekt.Slug + "/keys",
"Kein gültiger Key",
"Kein Key mit der Rolle admin",
"In 30 Tagen weder geschrieben noch gesucht",
"/p/ruhig/usage",
`<span class="zustand-art">Ruhiges Projekt</span>`, // Name über den Befunden
`<svg class="diagramm"`,
} {
if !strings.Contains(seite, erwartet) {
t.Errorf("%q fehlt im Überblick", erwartet)
}
}
// Das Projekt ohne Key ist schon rot gewarnt; „ohne Aktivität“ wäre
// dort nur Wiederholung. Genau einmal heißt: nur für "ruhig".
if n := strings.Count(seite, "weder geschrieben noch gesucht"); n != 1 {
t.Errorf("„ohne Aktivität“ %d-mal, erwartet einmal (nur das Projekt mit Key)", n)
}
// Warnungen stehen vor Hinweisen.
if strings.Index(seite, "gilt nur noch bis") > strings.Index(seite, "Kein Key mit der Rolle admin") {
t.Error("Warnung steht hinter einem Hinweis")
}
// Technisches gehört auf die Systemseite, nicht hierher.
for _, fehlt := range []string{"max_connections", "WAL", "Kontroll-Datenbank"} {
if strings.Contains(seite, fehlt) {
t.Errorf("%q steht im Überblick des Workspace-Admins", fehlt)
}
}
// Ein Projektzugang kommt nicht dran, auch nicht mit Rolle admin.
key := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "projekt-admin", auth.RoleAdmin))
if code := seiteHolen(h, key, "/admin/overview").Code; code == http.StatusOK {
t.Error("Projekt-Admin sieht den Überblick des Workspace")
}
}