557 lines
20 KiB
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
|
|
}
|
|
|
|
// 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(`<td class="num">\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, _, 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",
|
|
`<span class="status-kind">Ruhiges Projekt</span>`, // Name über den Befunden
|
|
`<svg class="chart"`,
|
|
} {
|
|
if !strings.Contains(page, want) {
|
|
t.Errorf("%q fehlt im Überblick", want)
|
|
}
|
|
}
|
|
// 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(page, "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(page, "gilt nur noch bis") > 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")
|
|
}
|
|
}
|