561 lines
20 KiB
Go
561 lines
20 KiB
Go
package mcpserver
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
|
|
"munin/internal/auth"
|
|
"munin/internal/search"
|
|
"munin/internal/store"
|
|
"munin/internal/web"
|
|
)
|
|
|
|
// aufbau legt Datenbank, Projekt und Handler an.
|
|
//
|
|
// Das Schreibbudget ist bewusst großzügig: Die meisten Tests haben mit der
|
|
// Begrenzung nichts zu tun, sollen aber trotzdem durch denselben Code laufen wie
|
|
// der Betrieb. Wer die Grenze prüfen will, nimmt aufbauMitBudget.
|
|
func aufbau(t *testing.T) (*Handler, *store.Store, store.Project) {
|
|
t.Helper()
|
|
return aufbauMitBudget(t, 1000)
|
|
}
|
|
|
|
// aufbauMitBudget wie aufbau, aber mit einer wählbaren Obergrenze je Stunde.
|
|
func aufbauMitBudget(t *testing.T, limit int) (*Handler, *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, "test", "Testprojekt", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
budget := auth.NewWriteBudget(limit, time.Hour)
|
|
return New(db, search.NewRegistry(db), budget), db, projekt
|
|
}
|
|
|
|
// ruf ruft ein MCP-Werkzeug auf.
|
|
//
|
|
// Statt einen HTTP-Server zu starten, verbindet sich der Client über ein
|
|
// In-Memory-Transportpaar direkt mit dem Server — dieselbe Logik, ohne Netzwerk.
|
|
// Die Sitzung (Projekt und Rolle) wird im Context mitgegeben, so wie es sonst
|
|
// web.APIAuth tut.
|
|
func ruf(t *testing.T, h *Handler, projekt store.Project, rolle auth.Role, werkzeug string, args map[string]any) *mcp.CallToolResult {
|
|
t.Helper()
|
|
|
|
sess := web.Session{
|
|
Subject: "key:test",
|
|
Key: store.APIKey{ID: "test-key", Name: "test-agent", ProjectID: projekt.ID, Role: string(rolle)},
|
|
Project: projekt,
|
|
Role: rolle,
|
|
}
|
|
ctx := web.WithSessionForTest(context.Background(), sess)
|
|
|
|
clientTransport, serverTransport := mcp.NewInMemoryTransports()
|
|
|
|
serverSession, err := h.serverFor(projekt).Connect(ctx, serverTransport, nil)
|
|
if err != nil {
|
|
t.Fatalf("Serververbindung: %v", err)
|
|
}
|
|
defer serverSession.Close()
|
|
|
|
client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1"}, nil)
|
|
clientSession, err := client.Connect(ctx, clientTransport, nil)
|
|
if err != nil {
|
|
t.Fatalf("Clientverbindung: %v", err)
|
|
}
|
|
defer clientSession.Close()
|
|
|
|
ergebnis, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: werkzeug, Arguments: args})
|
|
if err != nil {
|
|
t.Fatalf("Aufruf %q: %v", werkzeug, err)
|
|
}
|
|
return ergebnis
|
|
}
|
|
|
|
// text fasst die Textbausteine eines Ergebnisses zusammen.
|
|
func text(r *mcp.CallToolResult) string {
|
|
var b strings.Builder
|
|
for _, c := range r.Content {
|
|
if t, ok := c.(*mcp.TextContent); ok {
|
|
b.WriteString(t.Text)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// ── Werkzeugbestand ──────────────────────────────────────────────────────────
|
|
|
|
// TestToolsAndInstructions prüft, dass alle Werkzeuge da sind und die
|
|
// Anleitung mitgeliefert wird. Die Anleitung ist das wirksamste Stellrad für die
|
|
// Qualität der Antworten — sie darf nicht versehentlich wegfallen.
|
|
func TestToolsAndInstructions(t *testing.T) {
|
|
h, _, projekt := aufbau(t)
|
|
|
|
ctx := context.Background()
|
|
clientTransport, serverTransport := mcp.NewInMemoryTransports()
|
|
|
|
serverSession, err := h.serverFor(projekt).Connect(ctx, serverTransport, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer serverSession.Close()
|
|
|
|
client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1"}, nil)
|
|
clientSession, err := client.Connect(ctx, clientTransport, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer clientSession.Close()
|
|
|
|
if instr := clientSession.InitializeResult().Instructions; !strings.Contains(instr, "recall") {
|
|
t.Error("die Anleitung fehlt oder erklärt recall nicht")
|
|
}
|
|
|
|
liste, err := clientSession.ListTools(ctx, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
vorhanden := make(map[string]bool)
|
|
for _, tool := range liste.Tools {
|
|
vorhanden[tool.Name] = true
|
|
}
|
|
|
|
for _, name := range []string{"recall", "get", "list", "remember", "forget"} {
|
|
if !vorhanden[name] {
|
|
t.Errorf("Werkzeug %q fehlt", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Schreiben und Dublettenvermeidung ────────────────────────────────────────
|
|
|
|
// TestRememberDeduplicatesBySlug prüft den Kern des Gedächtnisses: Zweimal
|
|
// dasselbe Thema unter demselben Bezeichner ergibt einen Eintrag, nicht zwei.
|
|
func TestRememberDeduplicatesBySlug(t *testing.T) {
|
|
h, db, projekt := aufbau(t)
|
|
|
|
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
|
"slug": "deployment", "title": "Deployment", "content": "Erste Erkenntnis",
|
|
})
|
|
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
|
"slug": "deployment", "title": "Deployment", "content": "Zweite, bessere Erkenntnis",
|
|
})
|
|
|
|
anzahl, err := db.CountEntries(context.Background(), projekt.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if anzahl != 1 {
|
|
t.Errorf("%d Einträge, erwartet 1 — es wurde eine Dublette angelegt", anzahl)
|
|
}
|
|
|
|
e, _ := db.EntryBySlug(context.Background(), projekt.ID, "deployment")
|
|
if e.Content != "Zweite, bessere Erkenntnis" {
|
|
t.Error("der Eintrag wurde nicht aktualisiert")
|
|
}
|
|
if e.Version != 2 {
|
|
t.Errorf("Version = %d, erwartet 2", e.Version)
|
|
}
|
|
}
|
|
|
|
// TestRememberRecordsProvenance prüft, dass nachvollziehbar bleibt, wer geschrieben hat.
|
|
func TestRememberRecordsProvenance(t *testing.T) {
|
|
h, db, projekt := aufbau(t)
|
|
|
|
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
|
"slug": "notiz", "title": "Notiz", "content": "Inhalt",
|
|
})
|
|
|
|
e, _ := db.EntryBySlug(context.Background(), projekt.ID, "notiz")
|
|
if e.Source != store.SourceAgent {
|
|
t.Errorf("Source = %q, erwartet %q", e.Source, store.SourceAgent)
|
|
}
|
|
if e.Author != "test-agent" {
|
|
t.Errorf("Author = %q, erwartet den Namen des Zugangs", e.Author)
|
|
}
|
|
}
|
|
|
|
// TestRememberDerivesSlug prüft, dass ein fehlender Bezeichner aus dem Titel
|
|
// abgeleitet wird — das Modell soll nicht an Formalien scheitern.
|
|
func TestRememberDerivesSlug(t *testing.T) {
|
|
h, db, projekt := aufbau(t)
|
|
|
|
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
|
"title": "Der Deployment-Prozess", "content": "Inhalt",
|
|
})
|
|
|
|
if _, err := db.EntryBySlug(context.Background(), projekt.ID, "der-deployment-prozess"); err != nil {
|
|
t.Errorf("kein Eintrag unter dem abgeleiteten Bezeichner: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestRememberDetectsConflict prüft das optimistische Sperren.
|
|
func TestRememberDetectsConflict(t *testing.T) {
|
|
h, _, projekt := aufbau(t)
|
|
|
|
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
|
"slug": "geteilt", "title": "T", "content": "Fassung 1",
|
|
})
|
|
// Agent A schreibt und erhöht damit auf Fassung 2.
|
|
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
|
"slug": "geteilt", "title": "T", "content": "von A",
|
|
})
|
|
|
|
// Agent B hatte noch Fassung 1 gelesen.
|
|
ergebnis := ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
|
"slug": "geteilt", "title": "T", "content": "von B", "expected_version": 1,
|
|
})
|
|
|
|
if !ergebnis.IsError {
|
|
t.Fatal("der Konflikt wurde nicht erkannt — B hat A stillschweigend überschrieben")
|
|
}
|
|
// Die Fehlermeldung muss dem Modell sagen, was zu tun ist.
|
|
if !strings.Contains(text(ergebnis), "get") {
|
|
t.Errorf("die Fehlermeldung erklärt das weitere Vorgehen nicht: %s", text(ergebnis))
|
|
}
|
|
}
|
|
|
|
// ── Rollen ───────────────────────────────────────────────────────────────────
|
|
|
|
// TestReadRoleCannotWrite prüft, dass ein Nur-Lese-Zugang nichts verändern kann.
|
|
func TestReadRoleCannotWrite(t *testing.T) {
|
|
h, db, projekt := aufbau(t)
|
|
|
|
// Jedes Werkzeug bekommt nur die Parameter, die es kennt — sonst weist schon
|
|
// die Schemaprüfung des SDK den Aufruf ab und die Rollenprüfung käme nie dran.
|
|
faelle := map[string]map[string]any{
|
|
"remember": {"slug": "x", "title": "T", "content": "C"},
|
|
"forget": {"slug": "x"},
|
|
}
|
|
|
|
for werkzeug, args := range faelle {
|
|
t.Run(werkzeug, func(t *testing.T) {
|
|
ergebnis := ruf(t, h, projekt, auth.RoleRead, werkzeug, args)
|
|
if !ergebnis.IsError {
|
|
t.Error("der Nur-Lese-Zugang durfte schreiben")
|
|
}
|
|
if !strings.Contains(text(ergebnis), "Leserecht") {
|
|
t.Errorf("die Fehlermeldung nennt den Grund nicht: %s", text(ergebnis))
|
|
}
|
|
})
|
|
}
|
|
|
|
if n, _ := db.CountEntries(context.Background(), projekt.ID); n != 0 {
|
|
t.Errorf("%d Einträge angelegt, erwartet 0", n)
|
|
}
|
|
}
|
|
|
|
func TestReadRoleCanRead(t *testing.T) {
|
|
h, _, projekt := aufbau(t)
|
|
|
|
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
|
"slug": "notiz", "title": "Notiz", "content": "Inhalt",
|
|
})
|
|
|
|
for _, werkzeug := range []string{"recall", "list"} {
|
|
args := map[string]any{}
|
|
if werkzeug == "recall" {
|
|
args["query"] = "Notiz"
|
|
}
|
|
if ergebnis := ruf(t, h, projekt, auth.RoleRead, werkzeug, args); ergebnis.IsError {
|
|
t.Errorf("%s wurde für den Lese-Zugang abgelehnt: %s", werkzeug, text(ergebnis))
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Suchen und Lesen ─────────────────────────────────────────────────────────
|
|
|
|
// TestRecallFindsByPrefix prüft die Präfix-Rückfallebene: "deploy" muss
|
|
// "Deployment-Prozess" finden. Ohne sie liefert die Suche ein leeres Ergebnis,
|
|
// und das Modell erfährt nie, dass es den Eintrag gäbe.
|
|
func TestRecallFindsByPrefix(t *testing.T) {
|
|
h, _, projekt := aufbau(t)
|
|
|
|
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
|
"slug": "deployment-prozess", "title": "Deployment-Prozess",
|
|
"content": "Wir deployen über GitHub Actions.",
|
|
})
|
|
|
|
ergebnis := ruf(t, h, projekt, auth.RoleRead, "recall", map[string]any{"query": "deploy"})
|
|
if ergebnis.IsError {
|
|
t.Fatalf("recall schlug fehl: %s", text(ergebnis))
|
|
}
|
|
if !strings.Contains(text(ergebnis), "deployment-prozess") {
|
|
t.Errorf("die Präfix-Suche fand den Eintrag nicht: %s", text(ergebnis))
|
|
}
|
|
}
|
|
|
|
// TestRecallEmptyGivesHint prüft, dass ein leeres Ergebnis dem Modell sagt, was
|
|
// als Nächstes zu tun ist.
|
|
func TestRecallEmptyGivesHint(t *testing.T) {
|
|
h, _, projekt := aufbau(t)
|
|
|
|
ergebnis := ruf(t, h, projekt, auth.RoleRead, "recall", map[string]any{"query": "gibtesnicht"})
|
|
if !strings.Contains(text(ergebnis), "remember") {
|
|
t.Errorf("kein Hinweis auf das weitere Vorgehen: %s", text(ergebnis))
|
|
}
|
|
}
|
|
|
|
func TestRecallFiltersByTag(t *testing.T) {
|
|
h, _, projekt := aufbau(t)
|
|
|
|
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
|
"slug": "a", "title": "Deployment A", "content": "deployment", "tags": []any{"ops"},
|
|
})
|
|
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
|
"slug": "b", "title": "Deployment B", "content": "deployment", "tags": []any{"docs"},
|
|
})
|
|
|
|
ergebnis := ruf(t, h, projekt, auth.RoleRead, "recall",
|
|
map[string]any{"query": "deployment", "tags": []any{"ops"}})
|
|
|
|
if strings.Contains(text(ergebnis), `"slug": "b"`) || strings.Contains(text(ergebnis), `"slug":"b"`) {
|
|
t.Errorf("die Schlagwortfilterung griff nicht: %s", text(ergebnis))
|
|
}
|
|
}
|
|
|
|
// TestGetReturnsBacklinks prüft die Verweise in beide Richtungen — sie ersetzen
|
|
// die frühere Ordnerhierarchie als Struktur zwischen Einträgen.
|
|
func TestGetReturnsBacklinks(t *testing.T) {
|
|
h, _, projekt := aufbau(t)
|
|
|
|
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
|
"slug": "ziel", "title": "Ziel", "content": "Inhalt",
|
|
})
|
|
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
|
"slug": "quelle", "title": "Quelle", "content": "siehe [[ziel]]",
|
|
})
|
|
|
|
ergebnis := ruf(t, h, projekt, auth.RoleRead, "get", map[string]any{"slug": "ziel"})
|
|
if !strings.Contains(text(ergebnis), "quelle") {
|
|
t.Errorf("der Rückverweis fehlt: %s", text(ergebnis))
|
|
}
|
|
|
|
andersherum := ruf(t, h, projekt, auth.RoleRead, "get", map[string]any{"slug": "quelle"})
|
|
if !strings.Contains(text(andersherum), "ziel") {
|
|
t.Error("der ausgehende Verweis fehlt")
|
|
}
|
|
}
|
|
|
|
func TestGetUnknownSlugExplains(t *testing.T) {
|
|
h, _, projekt := aufbau(t)
|
|
|
|
ergebnis := ruf(t, h, projekt, auth.RoleRead, "get", map[string]any{"slug": "gibtesnicht"})
|
|
if !ergebnis.IsError {
|
|
t.Fatal("ein unbekannter Bezeichner wurde nicht als Fehler gemeldet")
|
|
}
|
|
// Die Meldung soll dem Modell den nächsten Schritt nennen.
|
|
if !strings.Contains(text(ergebnis), "recall") {
|
|
t.Errorf("die Meldung hilft nicht weiter: %s", text(ergebnis))
|
|
}
|
|
}
|
|
|
|
// ── Abschottung ──────────────────────────────────────────────────────────────
|
|
|
|
// TestProjectIsolation ist der wichtigste Test: Die Werkzeuge eines Projekts
|
|
// dürfen unter keinen Umständen an die Daten eines anderen kommen.
|
|
func TestProjectIsolation(t *testing.T) {
|
|
h, db, projektA := aufbau(t)
|
|
ctx := context.Background()
|
|
|
|
ws, _ := db.EnsureWorkspace(ctx, "Test")
|
|
projektB, err := db.CreateProject(ctx, ws.ID, "fremd", "Fremdes Projekt", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Wissen in Projekt B ablegen.
|
|
ruf(t, h, projektB, auth.RoleWrite, "remember", map[string]any{
|
|
"slug": "geheim", "title": "Geheim", "content": "Vertrauliches aus Projekt B",
|
|
})
|
|
|
|
// Mit den Werkzeugen von Projekt A danach suchen.
|
|
suche := ruf(t, h, projektA, auth.RoleRead, "recall", map[string]any{"query": "Vertrauliches"})
|
|
if strings.Contains(text(suche), "Vertrauliches") {
|
|
t.Error("recall in Projekt A fand Inhalte aus Projekt B")
|
|
}
|
|
|
|
lesen := ruf(t, h, projektA, auth.RoleRead, "get", map[string]any{"slug": "geheim"})
|
|
if !lesen.IsError {
|
|
t.Error("get in Projekt A lieferte einen Eintrag aus Projekt B")
|
|
}
|
|
|
|
liste := ruf(t, h, projektA, auth.RoleRead, "list", map[string]any{})
|
|
if strings.Contains(text(liste), "geheim") {
|
|
t.Error("list in Projekt A zeigte einen Eintrag aus Projekt B")
|
|
}
|
|
|
|
// Und der Gegentest: Löschen darf ebenfalls nicht durchschlagen.
|
|
ruf(t, h, projektA, auth.RoleWrite, "forget", map[string]any{"slug": "geheim"})
|
|
if n, _ := db.CountEntries(ctx, projektB.ID); n != 1 {
|
|
t.Error("forget in Projekt A hat einen Eintrag in Projekt B gelöscht")
|
|
}
|
|
}
|
|
|
|
// ── Grenzen ──────────────────────────────────────────────────────────────────
|
|
|
|
// TestRecallLimitIsCapped prüft, dass ein Client nicht beliebig viele Treffer
|
|
// anfordern kann — eine riesige Antwort sprengt das Kontextfenster des Modells.
|
|
func TestRecallLimitIsCapped(t *testing.T) {
|
|
h, _, projekt := aufbau(t)
|
|
|
|
for i := 0; i < 60; i++ {
|
|
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
|
"slug": "eintrag-" + string(rune('a'+i%26)) + string(rune('a'+i/26)),
|
|
"title": "Eintrag",
|
|
"content": "gemeinsames Stichwort",
|
|
})
|
|
}
|
|
|
|
ergebnis := ruf(t, h, projekt, auth.RoleRead, "recall",
|
|
map[string]any{"query": "gemeinsames", "limit": 9999})
|
|
|
|
// Mehr als recallMaxLimit Treffer dürfen nicht zurückkommen.
|
|
if anzahl := strings.Count(text(ergebnis), `"slug"`); anzahl > recallMaxLimit {
|
|
t.Errorf("%d Treffer zurückgegeben, Obergrenze ist %d", anzahl, recallMaxLimit)
|
|
}
|
|
}
|
|
|
|
func TestRememberRejectsEmptyContent(t *testing.T) {
|
|
h, _, projekt := aufbau(t)
|
|
|
|
ergebnis := ruf(t, h, projekt, auth.RoleWrite, "remember",
|
|
map[string]any{"slug": "leer", "title": "T", "content": ""})
|
|
|
|
if !ergebnis.IsError {
|
|
t.Error("ein leerer Inhalt wurde angenommen")
|
|
}
|
|
}
|
|
|
|
// ── Schreibbudget ────────────────────────────────────────────────────────────
|
|
|
|
// TestWriteBudgetStopsRunawayAgent prüft den eigentlichen Zweck der Begrenzung:
|
|
// Ein Agent in einer Schleife wird gestoppt, statt bis zum Plattenende zu schreiben.
|
|
func TestWriteBudgetStopsRunawayAgent(t *testing.T) {
|
|
const grenze = 3
|
|
h, db, projekt := aufbauMitBudget(t, grenze)
|
|
|
|
for i := 0; i < grenze; i++ {
|
|
ergebnis := ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
|
"slug": fmt.Sprintf("eintrag-%d", i), "title": "Eintrag", "content": "Inhalt",
|
|
})
|
|
if ergebnis.IsError {
|
|
t.Fatalf("Aufruf %d von %d abgelehnt, obwohl im Budget: %s", i+1, grenze, text(ergebnis))
|
|
}
|
|
}
|
|
|
|
ergebnis := ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
|
"slug": "eintrag-zuviel", "title": "Zu viel", "content": "Inhalt",
|
|
})
|
|
if !ergebnis.IsError {
|
|
t.Fatal("Aufruf über der Grenze wurde angenommen")
|
|
}
|
|
|
|
// Die Meldung geht an ein Sprachmodell. Sie muss sagen, was los ist UND was
|
|
// zu tun ist — sonst versucht es das Modell sofort wieder.
|
|
meldung := text(ergebnis)
|
|
for _, erwartet := range []string{"Schreibbudget", "NICHT sofort erneut"} {
|
|
if !strings.Contains(meldung, erwartet) {
|
|
t.Errorf("Meldung enthält %q nicht: %s", erwartet, meldung)
|
|
}
|
|
}
|
|
|
|
// Und der abgelehnte Aufruf darf nichts geschrieben haben.
|
|
if _, err := db.EntryBySlug(context.Background(), projekt.ID, "eintrag-zuviel"); err == nil {
|
|
t.Error("der abgelehnte Aufruf hat den Eintrag trotzdem angelegt")
|
|
}
|
|
}
|
|
|
|
// TestWriteBudgetLeavesReadingAlone stellt sicher, dass nur das Schreiben
|
|
// gedeckelt ist. Ein erschöpftes Budget darf das Gedächtnis nicht unlesbar machen —
|
|
// sonst wäre der Agent nicht gebremst, sondern blind.
|
|
func TestWriteBudgetLeavesReadingAlone(t *testing.T) {
|
|
h, _, projekt := aufbauMitBudget(t, 1)
|
|
|
|
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
|
"slug": "vorhanden", "title": "Vorhanden", "content": "Inhalt",
|
|
})
|
|
|
|
// Budget ist jetzt aufgebraucht.
|
|
if ergebnis := ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
|
"slug": "noch-einer", "title": "Noch einer", "content": "Inhalt",
|
|
}); !ergebnis.IsError {
|
|
t.Fatal("Vorbedingung verfehlt: das Budget ist noch nicht erschöpft")
|
|
}
|
|
|
|
for _, werkzeug := range []string{"recall", "get", "list"} {
|
|
args := map[string]any{}
|
|
switch werkzeug {
|
|
case "recall":
|
|
args["query"] = "Inhalt"
|
|
case "get":
|
|
args["slug"] = "vorhanden"
|
|
}
|
|
|
|
if ergebnis := ruf(t, h, projekt, auth.RoleWrite, werkzeug, args); ergebnis.IsError {
|
|
t.Errorf("%q wurde trotz erschöpftem Schreibbudget abgelehnt: %s", werkzeug, text(ergebnis))
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestWriteBudgetIsPerProject prüft die Mandantentrennung: Ein Projekt, das sein
|
|
// Budget verbraucht, darf kein anderes mitbremsen.
|
|
func TestWriteBudgetIsPerProject(t *testing.T) {
|
|
h, db, projekt := aufbauMitBudget(t, 1)
|
|
|
|
ctx := context.Background()
|
|
zweites, err := db.CreateProject(ctx, projekt.WorkspaceID, "zweites", "Zweites Projekt", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{"slug": "a", "title": "A", "content": "Inhalt"})
|
|
if ergebnis := ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
|
"slug": "b", "title": "B", "content": "Inhalt",
|
|
}); !ergebnis.IsError {
|
|
t.Fatal("Vorbedingung verfehlt: das erste Projekt ist noch nicht am Limit")
|
|
}
|
|
|
|
if ergebnis := ruf(t, h, zweites, auth.RoleWrite, "remember", map[string]any{
|
|
"slug": "c", "title": "C", "content": "Inhalt",
|
|
}); ergebnis.IsError {
|
|
t.Errorf("das zweite Projekt wurde mitgesperrt: %s", text(ergebnis))
|
|
}
|
|
}
|
|
|
|
// TestWriteBudgetUsesProjectLimit prüft, dass die eigene Obergrenze eines
|
|
// Projekts den Standard schlägt — und zwar die aus der Sitzung, damit eine
|
|
// Änderung in der Verwaltung sofort wirkt.
|
|
func TestWriteBudgetUsesProjectLimit(t *testing.T) {
|
|
h, _, projekt := aufbauMitBudget(t, 100)
|
|
|
|
// Dasselbe Projekt, aber mit einer eigenen, engeren Grenze — so, wie es
|
|
// web.APIAuth nach einer Änderung in der Verwaltung aus der Datenbank läse.
|
|
projekt.WriteLimitHourly = 1
|
|
|
|
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{"slug": "a", "title": "A", "content": "Inhalt"})
|
|
|
|
if ergebnis := ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
|
"slug": "b", "title": "B", "content": "Inhalt",
|
|
}); !ergebnis.IsError {
|
|
t.Error("die eigene Obergrenze des Projekts wurde nicht angewandt")
|
|
}
|
|
}
|