package web import ( "context" "net/http" "net/http/httptest" "net/url" "regexp" "strings" "testing" "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(`\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, _, 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") } }