package store import ( "context" "errors" "fmt" "path/filepath" "strings" "sync" "sync/atomic" "testing" "time" ) // testStore öffnet eine frische Datenbank in einem Testverzeichnis. // // Bewusst eine echte Datei statt ":memory:" — so wird auch geprüft, dass // Migrationen, WAL-Modus und Fremdschlüssel wirklich funktionieren. t.TempDir // räumt hinterher auf. // // Mit MUNIN_TEST_PG läuft der Test stattdessen gegen PostgreSQL, in einem // eigenen Schema (siehe postgres_test.go). func testStore(t *testing.T) *Store { t.Helper() if pgTestAktiv() { return testPostgresStore(t) } s, err := Open(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatalf("Datenbank konnte nicht geöffnet werden: %v", err) } t.Cleanup(func() { s.Close() }) return s } // testWorkspace liefert eine Workspace-ID für Tests. // // Eine Workspace-Datei kennt keine "workspaces"-Tabelle mehr (die lebt in der // zentralen Kontroll-Datenbank, siehe control.go) — für Tests reicht deshalb // eine beliebige ID, ohne dass eine echte ControlStore-Instanz nötig wäre. func testWorkspace() Workspace { return Workspace{ID: NewID(), Name: "Test"} } // testProject legt Workspace und Projekt an — die Grundlage fast aller Tests. func testProject(t *testing.T, s *Store) Project { t.Helper() ctx := context.Background() ws := testWorkspace() p, err := s.CreateProject(ctx, ws.ID, "test", "Testprojekt", "") if err != nil { t.Fatalf("Projekt: %v", err) } return p } // ── Migrationen ────────────────────────────────────────────────────────────── // TestMigrationsAreIdempotent prüft, dass ein zweiter Start dieselbe Datenbank // nicht kaputt macht — der Normalfall bei jedem Neustart des Servers. func TestMigrationsAreIdempotent(t *testing.T) { pfad := filepath.Join(t.TempDir(), "test.db") s1, err := Open(pfad) if err != nil { t.Fatalf("erster Start: %v", err) } p := testProject(t, s1) s1.Close() s2, err := Open(pfad) if err != nil { t.Fatalf("zweiter Start: %v", err) } defer s2.Close() // Die Daten aus dem ersten Start müssen noch da sein. if _, err := s2.ProjectByID(context.Background(), p.ID); err != nil { t.Errorf("Projekt nach Neustart nicht mehr auffindbar: %v", err) } } // TestMigrationBestandsdaten prüft, dass eine mit Fassung 001 angelegte // Datenbank sauber auf 002 gehoben wird — der Fall bei jedem Update. func TestMigrationBestandsdaten(t *testing.T) { pfad := filepath.Join(t.TempDir(), "alt.db") s, err := Open(pfad) if err != nil { t.Fatal(err) } ctx := context.Background() ws := testWorkspace() p, _ := s.CreateProject(ctx, ws.ID, "alt", "Alt", "") s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "ziel", Title: "Ziel", Content: "x", Tags: []string{"ops"}, Source: SourceHuman}) s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "quelle", Title: "Quelle", Content: "siehe [[ziel]]", Tags: []string{"ops", "docs"}, Source: SourceHuman}) s.Close() // Erneut öffnen — hier läuft die Migration bei Bestandsdaten. s2, err := Open(pfad) if err != nil { t.Fatalf("Migration fehlgeschlagen: %v", err) } defer s2.Close() // project_id muss in beiden Nebentabellen nachgetragen worden sein. var leereTags, leereLinks int s2.lesen.QueryRowContext(ctx, `SELECT COUNT(*) FROM entry_tags WHERE project_id = ''`).Scan(&leereTags) s2.lesen.QueryRowContext(ctx, `SELECT COUNT(*) FROM entry_links WHERE project_id = ''`).Scan(&leereLinks) if leereTags != 0 || leereLinks != 0 { t.Errorf("nicht nachgetragen: %d Schlagworte, %d Verweise ohne Projekt", leereTags, leereLinks) } // Und die Abfragen liefern weiterhin das Richtige. tags, err := s2.ListTags(ctx, p.ID) if err != nil || len(tags) != 2 { t.Errorf("ListTags nach Migration: %v (%d Schlagworte, erwartet 2)", err, len(tags)) } links, err := s2.Backlinks(ctx, p.ID, "ziel") if err != nil || len(links) != 1 { t.Errorf("Backlinks nach Migration: %v (%d Verweise, erwartet 1)", err, len(links)) } } // TestGleichzeitigeSchreibvorgaenge ist der Regressionstest zu den zwei // Verbindungspools (siehe Store in store.go). // // Mit einem gemeinsamen Pool für Lesen und Schreiben scheiterten hier 296 von // 320 Schreibvorgängen mit "database is locked" — mehrere Verbindungen begannen // gleichzeitig eine Schreibtransaktion und liefen mitten drin auf die Sperre, // wo busy_timeout nicht mehr greift. func TestGleichzeitigeSchreibvorgaenge(t *testing.T) { s := testStore(t) p := testProject(t, s) ctx := context.Background() const agenten, jeAgent = 8, 40 var wg sync.WaitGroup var mu sync.Mutex var fehler []error for a := 0; a < agenten; a++ { wg.Add(1) go func(a int) { defer wg.Done() for i := 0; i < jeAgent; i++ { _, err := s.PutEntry(ctx, EntryWrite{ ProjectID: p.ID, Slug: fmt.Sprintf("agent%d-%03d", a, i), Title: "Eintrag", Content: "Inhalt", Source: SourceAgent, Author: fmt.Sprintf("agent-%d", a), }) if err != nil { mu.Lock() fehler = append(fehler, err) mu.Unlock() } } }(a) } wg.Wait() if len(fehler) > 0 { t.Errorf("%d von %d Schreibvorgängen fehlgeschlagen, erster: %v", len(fehler), agenten*jeAgent, fehler[0]) } n, err := s.CountEntries(ctx, p.ID) if err != nil { t.Fatal(err) } if n != agenten*jeAgent { t.Errorf("%d Einträge angelegt, erwartet %d", n, agenten*jeAgent) } } // TestGleichzeitigesLesenUndSchreiben prüft, dass Lesezugriffe nicht hinter // Schreibvorgängen warten müssen — der Grund für den getrennten Lesepool. func TestGleichzeitigesLesenUndSchreiben(t *testing.T) { s := testStore(t) p := testProject(t, s) ctx := context.Background() for i := 0; i < 50; i++ { s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: fmt.Sprintf("e-%03d", i), Title: "T", Content: "Inhalt", Source: SourceHuman}) } var wg sync.WaitGroup var leseFehler, schreibFehler int64 // Schreiber und Leser gleichzeitig laufen lassen. for i := 0; i < 4; i++ { wg.Add(2) go func(i int) { defer wg.Done() for j := 0; j < 25; j++ { if _, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: fmt.Sprintf("neu-%d-%03d", i, j), Title: "T", Content: "Inhalt", Source: SourceAgent}); err != nil { atomic.AddInt64(&schreibFehler, 1) } } }(i) go func() { defer wg.Done() for j := 0; j < 100; j++ { if _, err := s.ListEntries(ctx, p.ID, EntryFilter{Limit: 20}); err != nil { atomic.AddInt64(&leseFehler, 1) } } }() } wg.Wait() if leseFehler > 0 || schreibFehler > 0 { t.Errorf("%d Lesefehler, %d Schreibfehler bei gleichzeitigem Zugriff", leseFehler, schreibFehler) } } // ── Workspace und Projekte ─────────────────────────────────────────────────── // // Die Stabilität von EnsureDefaultWorkspace wird in control_test.go geprüft — // die Funktion gehört zur zentralen Kontroll-Datenbank, nicht zu dieser Datei. func TestCreateProjectRejectsBadSlug(t *testing.T) { s := testStore(t) ctx := context.Background() ws := testWorkspace() for _, slug := range []string{"", "Groß", "mit leer", "../raus", "a/b", strings.Repeat("x", 65)} { if _, err := s.CreateProject(ctx, ws.ID, slug, "X", ""); err == nil { t.Errorf("ungültiger Bezeichner %q wurde akzeptiert", slug) } } } func TestCreateProjectRejectsDuplicateSlug(t *testing.T) { s := testStore(t) ctx := context.Background() ws := testWorkspace() if _, err := s.CreateProject(ctx, ws.ID, "doppelt", "Erst", ""); err != nil { t.Fatal(err) } _, err := s.CreateProject(ctx, ws.ID, "doppelt", "Zweit", "") if !errors.Is(err, ErrDuplicate) { t.Errorf("Fehler = %v, erwartet ErrDuplicate", err) } } // TestProjectWriteLimitDefaultsToZero prüft die Vorgabe aus Migration 003. // 0 heißt nicht "gesperrt", sondern "keine eigene Angabe" — nur so bekommen // bestehende Projekte nach der Migration den Standard aus der Umgebung. func TestProjectWriteLimitDefaultsToZero(t *testing.T) { s := testStore(t) p := testProject(t, s) if p.WriteLimitHourly != 0 { t.Errorf("WriteLimitHourly = %d bei einem neuen Projekt, erwartet 0", p.WriteLimitHourly) } } func TestUpdateProject(t *testing.T) { s := testStore(t) p := testProject(t, s) ctx := context.Background() if err := s.UpdateProject(ctx, p.ID, ProjectUpdate{ Name: "Neuer Name", Description: "Neue Beschreibung", WriteLimitHourly: 250, }); err != nil { t.Fatalf("Projekt ändern: %v", err) } gelesen, err := s.ProjectByID(ctx, p.ID) if err != nil { t.Fatal(err) } if gelesen.Name != "Neuer Name" || gelesen.Description != "Neue Beschreibung" { t.Errorf("Name/Beschreibung = %q/%q", gelesen.Name, gelesen.Description) } if gelesen.WriteLimitHourly != 250 { t.Errorf("WriteLimitHourly = %d, erwartet 250", gelesen.WriteLimitHourly) } // Der Bezeichner bleibt fest — er steckt in URLs und MCP-Konfigurationen. if gelesen.Slug != p.Slug { t.Errorf("Slug = %q, erwartet %q — der Bezeichner darf sich nicht ändern", gelesen.Slug, p.Slug) } // 0 nimmt die eigene Obergrenze zurück. if err := s.UpdateProject(ctx, p.ID, ProjectUpdate{Name: "Neuer Name"}); err != nil { t.Fatal(err) } gelesen, _ = s.ProjectByID(ctx, p.ID) if gelesen.WriteLimitHourly != 0 { t.Errorf("WriteLimitHourly = %d nach dem Zurücksetzen, erwartet 0", gelesen.WriteLimitHourly) } } // TestUpdateProjectRejectsNegativeLimit: Eine negative Zahl würde im // Schreibbudget als "abgeschaltet" gelesen — also das Gegenteil einer Grenze. func TestUpdateProjectRejectsNegativeLimit(t *testing.T) { s := testStore(t) p := testProject(t, s) err := s.UpdateProject(context.Background(), p.ID, ProjectUpdate{Name: "Test", WriteLimitHourly: -1}) if err == nil { t.Error("eine negative Obergrenze wurde angenommen") } } func TestUpdateProjectRejectsEmptyName(t *testing.T) { s := testStore(t) p := testProject(t, s) if err := s.UpdateProject(context.Background(), p.ID, ProjectUpdate{Name: " "}); err == nil { t.Error("ein leerer Name wurde angenommen") } } func TestUpdateProjectUnknownID(t *testing.T) { s := testStore(t) err := s.UpdateProject(context.Background(), "gibtesnicht", ProjectUpdate{Name: "Test"}) if !errors.Is(err, ErrNotFound) { t.Errorf("Fehler = %v, erwartet ErrNotFound", err) } } func TestProjectNotFound(t *testing.T) { s := testStore(t) if _, err := s.ProjectByID(context.Background(), "gibtsnicht"); !errors.Is(err, ErrNotFound) { t.Errorf("Fehler = %v, erwartet ErrNotFound", err) } } // TestDeleteProjectCascades prüft, dass mit dem Projekt auch Keys und Einträge // verschwinden. Das funktioniert nur, wenn foreign_keys eingeschaltet ist — // dieser Test schlägt fehl, falls das PRAGMA je verloren geht. func TestDeleteProjectCascades(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) if _, err := s.CreateAPIKey(ctx, p.ID, "test", "hash1", "write"); err != nil { t.Fatal(err) } if _, err := s.PutEntry(ctx, EntryWrite{ ProjectID: p.ID, Slug: "eintrag", Title: "T", Content: "C", Source: SourceHuman, }); err != nil { t.Fatal(err) } if err := s.DeleteProject(ctx, p.ID); err != nil { t.Fatal(err) } if _, err := s.ResolveKeyHash(ctx, "hash1"); !errors.Is(err, ErrNotFound) { t.Error("der Key des gelöschten Projekts existiert noch") } if n, _ := s.CountEntries(ctx, p.ID); n != 0 { t.Errorf("%d Einträge des gelöschten Projekts sind übrig", n) } } // ── API-Keys ───────────────────────────────────────────────────────────────── func TestResolveKeyHash(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) k, err := s.CreateAPIKey(ctx, p.ID, "tom-cline", "hash-abc", "write") if err != nil { t.Fatal(err) } auf, err := s.ResolveKeyHash(ctx, "hash-abc") if err != nil { t.Fatalf("gültiger Key wurde nicht aufgelöst: %v", err) } if auf.Key.ID != k.ID { t.Error("falscher Key aufgelöst") } // Das Projekt kommt in derselben Abfrage mit — sonst bräuchte jeder Request // einen zweiten Datenbankzugriff. if auf.Project.ID != p.ID { t.Error("das Projekt wurde nicht mitgeliefert") } } // TestRevokedKeyIsGone prüft, dass ein zurückgezogener Key sofort nicht mehr // gilt — das ist der Ersatz für den früheren Fingerabdruck-Mechanismus. func TestRevokedKeyIsGone(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) k, _ := s.CreateAPIKey(ctx, p.ID, "alt", "hash-alt", "write") if _, err := s.ResolveKeyHash(ctx, "hash-alt"); err != nil { t.Fatalf("Key galt von Anfang an nicht: %v", err) } if err := s.RevokeAPIKey(ctx, k.ID); err != nil { t.Fatal(err) } if _, err := s.ResolveKeyHash(ctx, "hash-alt"); !errors.Is(err, ErrNotFound) { t.Error("zurückgezogener Key gilt weiterhin") } // Der Datensatz bleibt aber erhalten — sonst verlöre das Audit-Log die Spur. if _, err := s.APIKeyByID(ctx, k.ID); err != nil { t.Errorf("der Key-Datensatz wurde gelöscht statt markiert: %v", err) } } // TestAPIKeyUnused prüft, wann ein Key als „ungenutzt“ gilt (AUSBAUSTUFEN.md 4.7). func TestAPIKeyUnused(t *testing.T) { jetzt := time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC) tage := func(n int) time.Time { return jetzt.Add(-time.Duration(n) * 24 * time.Hour) } faelle := []struct { name string key APIKey erwartet bool }{ {"gestern benutzt", APIKey{CreatedAt: tage(400), LastUsedAt: tage(1)}, false}, {"vor 91 Tagen benutzt", APIKey{CreatedAt: tage(400), LastUsedAt: tage(91)}, true}, {"genau 90 Tage", APIKey{CreatedAt: tage(400), LastUsedAt: tage(90)}, false}, {"nie benutzt, frisch angelegt", APIKey{CreatedAt: tage(3)}, false}, {"nie benutzt, lange angelegt", APIKey{CreatedAt: tage(120)}, true}, {"zurückgezogen", APIKey{CreatedAt: tage(400), LastUsedAt: tage(200), RevokedAt: tage(100)}, false}, } for _, f := range faelle { t.Run(f.name, func(t *testing.T) { if got := f.key.Unused(jetzt); got != f.erwartet { t.Errorf("Unused = %v, erwartet %v", got, f.erwartet) } }) } } // TestWorkspaceAdminUnused: Workspace-Admins folgen derselben Regel wie Keys. func TestWorkspaceAdminUnused(t *testing.T) { jetzt := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC) tage := func(n int) time.Time { return jetzt.Add(-time.Duration(n) * 24 * time.Hour) } faelle := []struct { name string admin WorkspaceAdmin erwartet bool }{ {"gestern benutzt", WorkspaceAdmin{CreatedAt: tage(400), LastUsedAt: tage(1)}, false}, {"vor 91 Tagen benutzt", WorkspaceAdmin{CreatedAt: tage(400), LastUsedAt: tage(91)}, true}, {"nie benutzt, frisch angelegt", WorkspaceAdmin{CreatedAt: tage(3)}, false}, {"nie benutzt, lange angelegt", WorkspaceAdmin{CreatedAt: tage(120)}, true}, {"zurückgezogen", WorkspaceAdmin{CreatedAt: tage(400), LastUsedAt: tage(200), RevokedAt: tage(100)}, false}, } for _, f := range faelle { t.Run(f.name, func(t *testing.T) { if got := f.admin.Unused(jetzt); got != f.erwartet { t.Errorf("Unused = %v, erwartet %v", got, f.erwartet) } }) } } func TestCreateAPIKeyRequiresName(t *testing.T) { s := testStore(t) p := testProject(t, s) if _, err := s.CreateAPIKey(context.Background(), p.ID, " ", "hash", "read"); err == nil { t.Error("ein Key ohne Namen wurde angelegt") } } // ── Einträge ───────────────────────────────────────────────────────────────── func TestPutEntryCreatesAndUpdates(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) erst, err := s.PutEntry(ctx, EntryWrite{ ProjectID: p.ID, Slug: "deployment", Title: "Deployment", Content: "Erste Fassung", Tags: []string{"Ops", "ops", " Deploy "}, Source: SourceAgent, Author: "agent-1", }) if err != nil { t.Fatal(err) } if erst.Version != 1 { t.Errorf("Version = %d, erwartet 1", erst.Version) } // Schlagworte werden vereinheitlicht: klein, ohne Dubletten, sortiert. if len(erst.Tags) != 2 || erst.Tags[0] != "deploy" || erst.Tags[1] != "ops" { t.Errorf("Tags = %v, erwartet [deploy ops]", erst.Tags) } // Derselbe Slug aktualisiert statt anzulegen — das ist der Kern der // Dublettenvermeidung. zweit, err := s.PutEntry(ctx, EntryWrite{ ProjectID: p.ID, Slug: "deployment", Title: "Deployment", Content: "Zweite Fassung", Source: SourceAgent, Author: "agent-2", }) if err != nil { t.Fatal(err) } if zweit.Version != 2 { t.Errorf("Version = %d, erwartet 2", zweit.Version) } if zweit.ID != erst.ID { t.Error("es wurde ein zweiter Eintrag angelegt statt zu aktualisieren") } if n, _ := s.CountEntries(ctx, p.ID); n != 1 { t.Errorf("%d Einträge, erwartet 1", n) } // Die Schlagworte des ersten Schreibvorgangs sind weg, weil der zweite keine // mitgebracht hat — der Eintrag ist die Wahrheit, nicht die Summe der Änderungen. if len(zweit.Tags) != 0 { t.Errorf("Tags = %v, erwartet leer", zweit.Tags) } } // TestPutEntryOptimisticLocking prüft den Schutz vor stillem Überschreiben. func TestPutEntryOptimisticLocking(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) basis := EntryWrite{ProjectID: p.ID, Slug: "geteilt", Title: "T", Source: SourceAgent} e, err := s.PutEntry(ctx, basis) if err != nil { t.Fatal(err) } // Agent A schreibt auf Basis von Version 1 — geht durch. a := basis a.Content = "von A" a.ExpectedVersion = e.Version if _, err := s.PutEntry(ctx, a); err != nil { t.Fatalf("erster Schreibvorgang scheiterte: %v", err) } // Agent B hatte ebenfalls Version 1 gelesen — muss jetzt einen Konflikt bekommen. b := basis b.Content = "von B" b.ExpectedVersion = e.Version if _, err := s.PutEntry(ctx, b); !errors.Is(err, ErrConflict) { t.Errorf("Fehler = %v, erwartet ErrConflict — B hat A stillschweigend überschrieben", err) } } // TestPutEntryExpectedVersionOnMissingEntry: Wer einen Stand fortschreiben will, // den es nicht mehr gibt, bekommt einen Konflikt statt eines neuen Eintrags. func TestPutEntryExpectedVersionOnMissingEntry(t *testing.T) { s := testStore(t) p := testProject(t, s) _, err := s.PutEntry(context.Background(), EntryWrite{ ProjectID: p.ID, Slug: "geloescht", Title: "T", Source: SourceAgent, ExpectedVersion: 3, }) if !errors.Is(err, ErrConflict) { t.Errorf("Fehler = %v, erwartet ErrConflict", err) } } func TestPutEntryValidation(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) cases := []struct { name string w EntryWrite }{ {"leerer Slug", EntryWrite{ProjectID: p.ID, Title: "T", Source: SourceHuman}}, {"Slug mit Pfad", EntryWrite{ProjectID: p.ID, Slug: "../raus", Title: "T", Source: SourceHuman}}, {"Slug groß", EntryWrite{ProjectID: p.ID, Slug: "Groß", Title: "T", Source: SourceHuman}}, {"kein Titel", EntryWrite{ProjectID: p.ID, Slug: "ok", Source: SourceHuman}}, {"unbekannte Herkunft", EntryWrite{ProjectID: p.ID, Slug: "ok", Title: "T", Source: "irgendwas"}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if _, err := s.PutEntry(ctx, tc.w); err == nil { t.Error("ungültiger Schreibvorgang wurde akzeptiert") } }) } } // TestEntriesAreProjectScoped ist der Isolationstest: derselbe Slug in zwei // Projekten sind zwei verschiedene Einträge, und keiner sieht den anderen. func TestEntriesAreProjectScoped(t *testing.T) { s := testStore(t) ctx := context.Background() ws := testWorkspace() a, _ := s.CreateProject(ctx, ws.ID, "projekt-a", "A", "") b, _ := s.CreateProject(ctx, ws.ID, "projekt-b", "B", "") if _, err := s.PutEntry(ctx, EntryWrite{ ProjectID: a.ID, Slug: "geheim", Title: "A-Wissen", Content: "nur für A", Source: SourceHuman, }); err != nil { t.Fatal(err) } if _, err := s.EntryBySlug(ctx, b.ID, "geheim"); !errors.Is(err, ErrNotFound) { t.Error("Projekt B sieht einen Eintrag von Projekt A") } // Derselbe Slug muss in B trotzdem frei sein. if _, err := s.PutEntry(ctx, EntryWrite{ ProjectID: b.ID, Slug: "geheim", Title: "B-Wissen", Content: "nur für B", Source: SourceHuman, }); err != nil { t.Errorf("derselbe Slug ließ sich in Projekt B nicht anlegen: %v", err) } } func TestListEntriesFilterByTag(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "a", Title: "A", Tags: []string{"ops"}, Source: SourceHuman}) s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "b", Title: "B", Tags: []string{"docs"}, Source: SourceHuman}) s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "c", Title: "C", Tags: []string{"ops", "docs"}, Source: SourceHuman}) gefiltert, err := s.ListEntries(ctx, p.ID, EntryFilter{Tag: "ops"}) if err != nil { t.Fatal(err) } if len(gefiltert) != 2 { t.Errorf("%d Treffer, erwartet 2", len(gefiltert)) } // Groß-/Kleinschreibung darf keine Rolle spielen. grossGeschrieben, _ := s.ListEntries(ctx, p.ID, EntryFilter{Tag: "OPS"}) if len(grossGeschrieben) != 2 { t.Errorf("%d Treffer bei Großschreibung, erwartet 2", len(grossGeschrieben)) } } func TestListTagsCounts(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "a", Title: "A", Tags: []string{"ops"}, Source: SourceHuman}) s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "b", Title: "B", Tags: []string{"ops", "docs"}, Source: SourceHuman}) tags, err := s.ListTags(ctx, p.ID) if err != nil { t.Fatal(err) } if len(tags) != 2 { t.Fatalf("%d Schlagworte, erwartet 2", len(tags)) } // Häufigste zuerst. if tags[0].Tag != "ops" || tags[0].Count != 2 { t.Errorf("erstes Schlagwort = %+v, erwartet {ops 2}", tags[0]) } } func TestMoveEntryToTrash(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "T", Source: SourceHuman}) if err := s.MoveEntryToTrash(ctx, p.ID, "weg", "tom"); err != nil { t.Fatal(err) } if _, err := s.EntryBySlug(ctx, p.ID, "weg"); !errors.Is(err, ErrNotFound) { t.Error("der Eintrag ist nach dem Löschen noch sichtbar") } if err := s.MoveEntryToTrash(ctx, p.ID, "weg", "tom"); !errors.Is(err, ErrNotFound) { t.Error("zweimaliges Löschen meldete keinen Fehler") } } // ── Verweise ───────────────────────────────────────────────────────────────── func TestExtractLinks(t *testing.T) { cases := map[string][]string{ "": nil, "ohne Verweise": nil, "siehe [[deployment]]": {"deployment"}, "[[b]] und [[a]] und nochmal [[a]]": {"a", "b"}, "[[Großbuchstaben]] zählen nicht": nil, "[[mit leerzeichen]] auch nicht": nil, "[[a-b_c1]] ist erlaubt": {"a-b_c1"}, "einfache [klammern] sind kein Verweis": nil, // In Code zählt nichts (siehe verweiseAusserhalbVonCode). "Verweis: `[[beispiel]]`, echt: [[echt]]": {"echt"}, "doppelte Backticks: ``a `[[x]]` b`` und [[y]]": {"y"}, "```\n[[im-block]]\n```\n[[danach]]": {"danach"}, "~~~go\n[[tilde]]\n~~~\n": nil, " ```\n[[eingerueckter-zaun]]\n ```": nil, "````\n```\n[[innen]]\n````\n[[aussen]]": {"aussen"}, "```\n[[offen-bis-zum-ende]]": nil, "ein ` allein, dann [[zaehlt]]": {"zaehlt"}, " [[eingerueckt-vier]] zählt (wie Listenzeilen)": {"eingerueckt-vier"}, } for input, erwartet := range cases { got := ExtractLinks(input) if len(got) != len(erwartet) { t.Errorf("ExtractLinks(%q) = %v, erwartet %v", input, got, erwartet) continue } for i := range got { if got[i] != erwartet[i] { t.Errorf("ExtractLinks(%q) = %v, erwartet %v", input, got, erwartet) break } } } } // TestReplaceLinksLaesstCodeStehen prüft das Gegenstück beim Anzeigen: Ein // Verweis in Code bleibt Text, ein echter wird ersetzt. func TestReplaceLinksLaesstCodeStehen(t *testing.T) { ein := "So verweist man: `[[bezeichner]]`. Siehe [[deployment]].\n```\n[[auch-nicht]]\n```\n" soll := "So verweist man: `[[bezeichner]]`. Siehe .\n```\n[[auch-nicht]]\n```\n" got := ReplaceLinks(ein, func(slug string) string { return "<" + slug + ">" }) if got != soll { t.Errorf("\nist: %q\nsoll: %q", got, soll) } } // TestBacklinks prüft die Gegenrichtung der Verweise — sie ersetzt die frühere // Ordnerhierarchie als Struktur zwischen Einträgen. func TestBacklinks(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "ziel", Title: "Ziel", Source: SourceHuman}) s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "quelle-a", Title: "Quelle A", Content: "siehe [[ziel]]", Source: SourceHuman}) s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "quelle-b", Title: "Quelle B", Content: "auch [[ziel]]", Source: SourceHuman}) s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "ohne", Title: "Ohne", Source: SourceHuman}) links, err := s.Backlinks(ctx, p.ID, "ziel") if err != nil { t.Fatal(err) } if len(links) != 2 { t.Fatalf("%d Rückverweise, erwartet 2", len(links)) } // Verweise verschwinden, wenn sie aus dem Inhalt entfernt werden. s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "quelle-a", Title: "Quelle A", Content: "ohne Verweis", Source: SourceHuman}) links, _ = s.Backlinks(ctx, p.ID, "ziel") if len(links) != 1 { t.Errorf("%d Rückverweise nach dem Entfernen, erwartet 1", len(links)) } } // ── Audit ──────────────────────────────────────────────────────────────────── func TestAuditRoundTrip(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) s.LogAudit(ctx, AuditEvent{ProjectID: p.ID, Actor: "tom", Action: ActionEntryWrite, EntrySlug: "a", Source: SourceWeb, IP: "10.0.0.1"}) s.LogAudit(ctx, AuditEvent{ProjectID: p.ID, Actor: "agent", Action: ActionEntryDelete, EntrySlug: "b", Source: SourceMCP}) ereignisse, err := s.ListAudit(ctx, AuditFilter{ProjectID: p.ID}) if err != nil { t.Fatal(err) } if len(ereignisse) != 2 { t.Fatalf("%d Ereignisse, erwartet 2", len(ereignisse)) } // Neueste zuerst. if ereignisse[0].Action != ActionEntryDelete { t.Errorf("erstes Ereignis = %q, erwartet %q", ereignisse[0].Action, ActionEntryDelete) } if ereignisse[0].Timestamp.IsZero() { t.Error("Zeitstempel wurde nicht gesetzt") } } // TestPruneAudit prüft die Aufbewahrungsfrist — ohne sie wächst das Protokoll // unbegrenzt, und personenbezogene Daten dürften gar nicht so lange liegen. func TestPruneAudit(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) s.LogAudit(ctx, AuditEvent{ProjectID: p.ID, Actor: "x", Action: "test", Source: SourceSystem}) // Grenze in der Zukunft — alles muss weg. zukunft := time.Now().UTC().Add(time.Hour).Format(timeFormat) n, err := s.PruneAudit(ctx, zukunft) if err != nil { t.Fatal(err) } if n != 1 { t.Errorf("%d Zeilen gelöscht, erwartet 1", n) } rest, _ := s.ListAudit(ctx, AuditFilter{}) if len(rest) != 0 { t.Errorf("%d Ereignisse übrig", len(rest)) } } // ── Hilfsfunktionen ────────────────────────────────────────────────────────── func TestSlugify(t *testing.T) { cases := map[string]string{ "Deployment Prozess": "deployment-prozess", "Größe & Übersicht": "groesse-uebersicht", " mehrere Wörter ": "mehrere-woerter", "MIT-Bindestrich": "mit-bindestrich", "!!!": "", "straße": "strasse", } for input, erwartet := range cases { if got := Slugify(input); got != erwartet { t.Errorf("Slugify(%q) = %q, erwartet %q", input, got, erwartet) } } } func TestNewIDIsSortableAndUnique(t *testing.T) { gesehen := make(map[string]bool) var vorher string for i := 0; i < 200; i++ { id := NewID() if gesehen[id] { t.Fatalf("doppelte ID: %s", id) } gesehen[id] = true // Der Zeitanteil steht vorne, IDs sind also grob chronologisch sortierbar. if vorher != "" && id < vorher { zeitVorher, _, _ := strings.Cut(vorher, "-") zeitJetzt, _, _ := strings.Cut(id, "-") if zeitJetzt < zeitVorher { t.Errorf("IDs sind nicht chronologisch: %s vor %s", vorher, id) } } vorher = id } } func TestNormalizeTags(t *testing.T) { got := NormalizeTags([]string{" Ops ", "ops", "DOCS", "", " "}) if len(got) != 2 || got[0] != "docs" || got[1] != "ops" { t.Errorf("NormalizeTags = %v, erwartet [docs ops]", got) } } func TestBulkUpdateTags(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) a, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "a", Title: "A", Tags: []string{"alt"}, Source: SourceHuman}) b, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "b", Title: "B", Tags: []string{"alt", "bleibt"}, Source: SourceHuman}) // Dritter Eintrag bleibt unangetastet — zur Probe, dass BulkUpdateTags nur // trifft, wer ausgewählt wurde. c, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "c", Title: "C", Tags: []string{"alt"}, Source: SourceHuman}) geaendert, err := s.BulkUpdateTags(ctx, p.ID, []string{a.ID, b.ID}, []string{"neu"}, []string{"alt"}) if err != nil { t.Fatal(err) } if len(geaendert) != 2 { t.Fatalf("%d geänderte Einträge zurückgegeben, erwartet 2", len(geaendert)) } neuA, _ := s.EntryBySlug(ctx, p.ID, "a") if !stringSlicesEqual(neuA.Tags, []string{"neu"}) { t.Errorf("Tags von A = %v, erwartet [neu]", neuA.Tags) } if neuA.Version != 2 { t.Errorf("Version von A = %d, erwartet 2 (Sammelbearbeitung zählt als Änderung)", neuA.Version) } neuB, _ := s.EntryBySlug(ctx, p.ID, "b") if !stringSlicesEqual(neuB.Tags, []string{"bleibt", "neu"}) { t.Errorf("Tags von B = %v, erwartet [bleibt neu]", neuB.Tags) } unveraendertC, _ := s.EntryBySlug(ctx, p.ID, "c") if !stringSlicesEqual(unveraendertC.Tags, []string{"alt"}) { t.Errorf("Tags von C = %v — C war nicht ausgewählt und hätte sich nicht ändern dürfen", unveraendertC.Tags) } if unveraendertC.Version != c.Version { t.Error("Version von C wurde erhöht, obwohl C nicht ausgewählt war") } } func TestBulkUpdateTagsIgnoresForeignProjectIDs(t *testing.T) { s := testStore(t) ctx := context.Background() ws := testWorkspace() a, _ := s.CreateProject(ctx, ws.ID, "a", "A", "") b, _ := s.CreateProject(ctx, ws.ID, "b", "B", "") fremd, _ := s.PutEntry(ctx, EntryWrite{ProjectID: b.ID, Slug: "fremd", Title: "Fremd", Tags: []string{"x"}, Source: SourceHuman}) // Aufruf mit dem Projekt A, aber einer ID aus Projekt B — darf nichts // bewirken, sonst könnte ein untergeschobenes Formularfeld projektübergreifend // Einträge verändern. geaendert, err := s.BulkUpdateTags(ctx, a.ID, []string{fremd.ID}, []string{"neu"}, nil) if err != nil { t.Fatal(err) } if len(geaendert) != 0 { t.Errorf("%d Einträge geändert, erwartet 0 — die ID gehörte zu einem fremden Projekt", len(geaendert)) } unveraendert, _ := s.EntryBySlug(ctx, b.ID, "fremd") if !stringSlicesEqual(unveraendert.Tags, []string{"x"}) { t.Errorf("Tags = %v — der fremde Eintrag wurde trotzdem verändert", unveraendert.Tags) } } // ── Änderungshistorie ──────────────────────────────────────────────────────── func TestPutEntryRecordsHistory(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) erste, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Erste Fassung", Content: "Inhalt 1", Tags: []string{"a"}, Source: SourceHuman, Author: "tom"}) if err != nil { t.Fatal(err) } // Beim erstmaligen Anlegen gibt es noch nichts zu sichern. historie, err := s.EntryVersions(ctx, erste.ID) if err != nil { t.Fatal(err) } if len(historie) != 0 { t.Errorf("%d Fassungen nach dem Anlegen, erwartet 0", len(historie)) } if _, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Zweite Fassung", Content: "Inhalt 2", Tags: []string{"b"}, Source: SourceHuman, Author: "tom"}); err != nil { t.Fatal(err) } if _, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Dritte Fassung", Content: "Inhalt 3", Tags: []string{"c"}, Source: SourceHuman, Author: "tom"}); err != nil { t.Fatal(err) } historie, err = s.EntryVersions(ctx, erste.ID) if err != nil { t.Fatal(err) } if len(historie) != 2 { t.Fatalf("%d Fassungen in der Historie, erwartet 2", len(historie)) } // Neueste zuerst. if historie[0].Version != 2 || historie[0].Title != "Zweite Fassung" { t.Errorf("historie[0] = Version %d %q, erwartet Version 2 \"Zweite Fassung\"", historie[0].Version, historie[0].Title) } if historie[1].Version != 1 || historie[1].Title != "Erste Fassung" { t.Errorf("historie[1] = Version %d %q, erwartet Version 1 \"Erste Fassung\"", historie[1].Version, historie[1].Title) } if historie[1].Content != "Inhalt 1" || !stringSlicesEqual(historie[1].Tags, []string{"a"}) { t.Errorf("historie[1] Inhalt/Tags = %q/%v, erwartet \"Inhalt 1\"/[a]", historie[1].Content, historie[1].Tags) } fassung1, err := s.EntryVersionByNumber(ctx, erste.ID, 1) if err != nil { t.Fatal(err) } if fassung1.Content != "Inhalt 1" { t.Errorf("EntryVersionByNumber(1).Content = %q, erwartet \"Inhalt 1\"", fassung1.Content) } if _, err := s.EntryVersionByNumber(ctx, erste.ID, 99); !errors.Is(err, ErrNotFound) { t.Errorf("unbekannte Fassung: err = %v, erwartet ErrNotFound", err) } } func TestBulkUpdateTagsRecordsHistory(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) e, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "T", Content: "Inhalt", Tags: []string{"alt"}, Source: SourceHuman}) if _, err := s.BulkUpdateTags(ctx, p.ID, []string{e.ID}, []string{"neu"}, []string{"alt"}); err != nil { t.Fatal(err) } historie, err := s.EntryVersions(ctx, e.ID) if err != nil { t.Fatal(err) } if len(historie) != 1 { t.Fatalf("%d Fassungen, erwartet 1", len(historie)) } if !stringSlicesEqual(historie[0].Tags, []string{"alt"}) { t.Errorf("gesicherte Tags = %v, erwartet [alt] (der Stand vor der Sammelbearbeitung)", historie[0].Tags) } } // TestTrashKeepsHistoryPurgeRemovesIt: Der Papierkorb behält den Verlauf, // erst endgültiges Löschen entfernt ihn (CASCADE). func TestTrashKeepsHistoryPurgeRemovesIt(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) e, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "T", Content: "1", Source: SourceHuman}) s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "T", Content: "2", Source: SourceHuman}) if err := s.MoveEntryToTrash(ctx, p.ID, "e", "tom"); err != nil { t.Fatal(err) } if historie, _ := s.EntryVersions(ctx, e.ID); len(historie) != 1 { t.Errorf("%d Fassungen im Papierkorb, erwartet 1 — der Verlauf muss erhalten bleiben", len(historie)) } if n, err := s.PurgeFromTrash(ctx, p.ID, []string{"e"}); err != nil || n != 1 { t.Fatalf("PurgeFromTrash: %d, %v", n, err) } historie, err := s.EntryVersions(ctx, e.ID) if err != nil { t.Fatal(err) } if len(historie) != 0 { t.Errorf("%d Fassungen nach dem Löschen des Eintrags, erwartet 0 (CASCADE)", len(historie)) } } // ── Graph ──────────────────────────────────────────────────────────────────── func TestLinkGraph(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "a", Title: "A", Content: "verweist auf [[b]] und [[fehlt]]", Source: SourceHuman}) s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "b", Title: "B", Content: "verweist zurück auf [[a]]", Source: SourceHuman}) // Eintrag ohne jeden Verweis — muss trotzdem als Knoten auftauchen. s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "c", Title: "C", Content: "allein", Source: SourceHuman}) knoten, kanten, err := s.LinkGraph(ctx, p.ID) if err != nil { t.Fatal(err) } if len(knoten) != 4 { t.Fatalf("%d Knoten, erwartet 4 (a, b, c, fehlt)", len(knoten)) } nachSlug := make(map[string]GraphNode, len(knoten)) for _, k := range knoten { nachSlug[k.Slug] = k } if !nachSlug["a"].Exists || !nachSlug["b"].Exists || !nachSlug["c"].Exists { t.Error("vorhandene Einträge sollten Exists = true tragen") } fehlt, ok := nachSlug["fehlt"] if !ok { t.Fatal("Verweis auf einen nicht existierenden Bezeichner erzeugte keinen Knoten") } if fehlt.Exists { t.Error("Exists = true für einen Bezeichner ohne Eintrag") } if len(kanten) != 3 { t.Fatalf("%d Kanten, erwartet 3 (a→b, a→fehlt, b→a)", len(kanten)) } } func TestLinkGraphIsProjectScoped(t *testing.T) { s := testStore(t) ctx := context.Background() ws := testWorkspace() a, _ := s.CreateProject(ctx, ws.ID, "a", "A", "") b, _ := s.CreateProject(ctx, ws.ID, "b", "B", "") s.PutEntry(ctx, EntryWrite{ProjectID: a.ID, Slug: "geheim", Title: "Geheim", Content: "nur in A", Source: SourceHuman}) s.PutEntry(ctx, EntryWrite{ProjectID: b.ID, Slug: "anderes", Title: "Anderes", Content: "nur in B", Source: SourceHuman}) knoten, _, err := s.LinkGraph(ctx, a.ID) if err != nil { t.Fatal(err) } if len(knoten) != 1 || knoten[0].Slug != "geheim" { t.Errorf("Knoten = %v — Projekt A sieht Einträge aus Projekt B", knoten) } } // TestVeralteteEintraegeFilternUndZaehlen: ListEntries mit ChangedBefore und // CountStaleEntries finden genau die Einträge, die seit über einem Jahr // unverändert sind — und keine im Papierkorb. func TestVeralteteEintraegeFilternUndZaehlen(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) jetzt := time.Now() alt := jetzt.Add(-400 * 24 * time.Hour) for _, w := range []EntryWrite{ {Slug: "alt", Title: "Alt", CreatedAt: alt, UpdatedAt: alt}, {Slug: "alt-geloescht", Title: "Alt, gelöscht", CreatedAt: alt, UpdatedAt: alt}, {Slug: "frisch", Title: "Frisch"}, } { w.ProjectID, w.Content, w.Source = p.ID, "x", SourceHuman if _, err := s.PutEntry(ctx, w); err != nil { t.Fatal(err) } } if err := s.MoveEntryToTrash(ctx, p.ID, "alt-geloescht", "test"); err != nil { t.Fatal(err) } liste, err := s.ListEntries(ctx, p.ID, EntryFilter{ChangedBefore: StaleCutoff(jetzt)}) if err != nil { t.Fatal(err) } if len(liste) != 1 || liste[0].Slug != "alt" { t.Errorf("gefiltert: %d Einträge, erwartet nur „alt“", len(liste)) } anzahl, err := s.CountStaleEntries(ctx, p.ID, jetzt) if err != nil || anzahl != 1 { t.Errorf("CountStaleEntries = %d, %v; erwartet 1", anzahl, err) } } // TestAuditFilter: ListAudit schränkt nach Vorgang, Zugang und Zeitraum ein, // AuditActors nennt die Zugänge. func TestAuditFilter(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) jetzt := time.Now().UTC() for _, z := range []struct { alter time.Duration wer, vorgang string }{ {48 * time.Hour, "anna", ActionEntryWrite}, {24 * time.Hour, "bernd", ActionEntryDelete}, {1 * time.Hour, "anna", ActionEntryDelete}, } { if _, err := s.schreiben.ExecContext(ctx, `INSERT INTO audit_events (id, ts, project_id, api_key_id, actor, action, entry_slug, source, ip, detail) VALUES (?, ?, ?, '', ?, ?, '', 'web', '', '')`, NewID(), jetzt.Add(-z.alter).Format(time.RFC3339Nano), p.ID, z.wer, z.vorgang); err != nil { t.Fatal(err) } } zaehle := func(f AuditFilter) int { t.Helper() f.ProjectID = p.ID liste, err := s.ListAudit(ctx, f) if err != nil { t.Fatal(err) } return len(liste) } if n := zaehle(AuditFilter{Action: ActionEntryDelete}); n != 2 { t.Errorf("Vorgang: %d, erwartet 2", n) } if n := zaehle(AuditFilter{Actor: "anna"}); n != 2 { t.Errorf("Zugang: %d, erwartet 2", n) } if n := zaehle(AuditFilter{Actor: "anna", Action: ActionEntryDelete}); n != 1 { t.Errorf("Zugang und Vorgang: %d, erwartet 1", n) } if n := zaehle(AuditFilter{Since: jetzt.Add(-30 * time.Hour), Until: jetzt.Add(-2 * time.Hour)}); n != 1 { t.Errorf("Zeitraum: %d, erwartet 1 (bernd)", n) } zugaenge, err := s.AuditActors(ctx, p.ID) if err != nil || strings.Join(zugaenge, ",") != "anna,bernd" { t.Errorf("AuditActors = %v, %v; erwartet anna,bernd", zugaenge, err) } } // TestKeyAblauf: Ein abgelaufener Key gilt wie ein zurückgezogener — er lässt // sich nicht mehr auflösen. Ein Ablauf in der Vergangenheit wird gar nicht // erst angenommen. func TestKeyAblauf(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) jetzt := time.Now() if _, err := s.CreateAPIKeyWithExpiry(ctx, p.ID, "zu-spaet", "h0", "write", jetzt.Add(-time.Hour)); err == nil { t.Error("Key mit Ablauf in der Vergangenheit wurde angelegt") } k, err := s.CreateAPIKeyWithExpiry(ctx, p.ID, "auf-zeit", "h1", "write", jetzt.Add(time.Hour)) if err != nil { t.Fatal(err) } if _, err := s.ResolveKeyHash(ctx, "h1"); err != nil { t.Errorf("gültiger Key lässt sich nicht auflösen: %v", err) } gelesen, _ := s.APIKeyByID(ctx, k.ID) if gelesen.ExpiresAt.IsZero() || !gelesen.ExpiresSoon(jetzt) || gelesen.Expired(jetzt) { t.Errorf("Ablauf nicht gespeichert oder falsch bewertet: %+v", gelesen) } // Ablauf in die Vergangenheit setzen — wie ein Key, dessen Tag vorbei ist. if _, err := s.schreiben.ExecContext(ctx, `UPDATE api_keys SET expires_at = ? WHERE id = ?`, jetzt.Add(-time.Minute).UTC().Format(time.RFC3339Nano), k.ID); err != nil { t.Fatal(err) } if _, err := s.ResolveKeyHash(ctx, "h1"); !errors.Is(err, ErrNotFound) { t.Errorf("abgelaufener Key: err = %v, erwartet ErrNotFound", err) } gelesen, _ = s.APIKeyByID(ctx, k.ID) if gelesen.Valid(jetzt) || gelesen.Unused(jetzt.Add(200*24*time.Hour)) { t.Error("abgelaufener Key gilt als gültig oder als ungenutzt") } // Ohne Ablauf: wie bisher. if _, err := s.CreateAPIKey(ctx, p.ID, "unbegrenzt", "h2", "read"); err != nil { t.Fatal(err) } if _, err := s.ResolveKeyHash(ctx, "h2"); err != nil { t.Errorf("Key ohne Ablauf: %v", err) } }