munin/internal/entry/entry_test.go
2026-10-19 10:13:56 +02:00

237 lines
8.3 KiB
Go

package entry
import (
"strings"
"testing"
)
// TestRenderHTMLEscapesRawHTML ist ein Sicherheitstest.
//
// Das gerenderte Ergebnis wird in der Weboberfläche ohne weiteres Escaping
// eingesetzt. Der Schutz davor, dass jemand über einen Eintrag ein <script>
// einschleust, hängt damit allein daran, dass goldmark kein Roh-HTML durchreicht.
// Wird diese Einstellung je gelockert, schlägt dieser Test an.
func TestRenderHTMLEscapesRawHTML(t *testing.T) {
malicious := []string{
"<script>alert(1)</script>",
"<img src=x onerror=alert(1)>",
"<iframe src=\"https://fremd.example\"></iframe>",
}
for _, input := range malicious {
html, err := RenderHTML(input, "/p/test/e/")
if err != nil {
t.Fatalf("RenderHTML(%q): %v", input, err)
}
if strings.Contains(html, "<script") || strings.Contains(html, "onerror") || strings.Contains(html, "<iframe") {
t.Errorf("Roh-HTML kam durch: %q → %q", input, html)
}
}
}
// TestRenderHTMLResolvesLinks prüft, dass [[bezeichner]] zu einem Link wird.
func TestRenderHTMLResolvesLinks(t *testing.T) {
html, err := RenderHTML("Siehe [[server-zugang]] für Details.", "/p/test/e/")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(html, `href="/p/test/e/server-zugang"`) {
t.Errorf("der Verweis wurde nicht aufgelöst: %s", html)
}
}
// TestRenderHTMLKeepsMarkdown prüft, dass gewöhnliches Markdown funktioniert.
func TestRenderHTMLKeepsMarkdown(t *testing.T) {
html, err := RenderHTML("# Überschrift\n\n- Punkt eins\n- Punkt zwei\n\n`code`", "/p/test/e/")
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"<h1", "<ul>", "<li>", "<code>"} {
if !strings.Contains(html, want) {
t.Errorf("%s fehlt im Ergebnis: %s", want, html)
}
}
}
func TestRenderPlainStripsLinkSyntax(t *testing.T) {
got := RenderPlain("Siehe [[server-zugang]] für Details.")
if strings.Contains(got, "[[") {
t.Errorf("die Verweis-Klammern blieben stehen: %q", got)
}
if !strings.Contains(got, "server-zugang") {
t.Errorf("der Bezeichner ging verloren: %q", got)
}
}
// ── Textausschnitte ──────────────────────────────────────────────────────────
func TestSnippetAroundMatch(t *testing.T) {
text := strings.Repeat("Vorlauf. ", 40) + "GESUCHT " + strings.Repeat("Nachlauf. ", 40)
got := Snippet(text, "GESUCHT")
if !strings.Contains(got, "GESUCHT") {
t.Error("der Treffer fehlt im Ausschnitt")
}
// Der Ausschnitt soll kurz sein — er dient dem Einordnen, nicht dem Lesen.
if len(got) > 400 {
t.Errorf("Ausschnitt ist %d Zeichen lang, das ist zu viel", len(got))
}
// Vorne und hinten wurde gekürzt, das muss sichtbar sein.
if !strings.HasPrefix(got, "…") || !strings.HasSuffix(got, "…") {
t.Errorf("die Auslassungszeichen fehlen: %q", got)
}
}
func TestSnippetWithoutMatch(t *testing.T) {
// Kein wörtlicher Treffer — etwa weil der Fund über die Präfix-Suche kam.
got := Snippet("Ein kurzer Text ohne den Suchbegriff.", "deploy")
if got == "" {
t.Error("ohne Treffer kam gar kein Ausschnitt zurück")
}
if !strings.Contains(got, "kurzer Text") {
t.Errorf("es wurde nicht der Textanfang genommen: %q", got)
}
}
// TestSnippetForWholeQuestion prüft den Normalfall bei Agenten: Die Anfrage ist
// eine ganze Frage, die so nie im Text steht. Der Ausschnitt muss trotzdem an
// der Fundstelle ansetzen, nicht am Textanfang.
func TestSnippetForWholeQuestion(t *testing.T) {
text := strings.Repeat("Vorlauf. ", 40) + "Der Zertifikatswechsel ist im März. " + strings.Repeat("Nachlauf. ", 40)
got := Snippet(text, "wie läuft der Zertifikatswechsel")
if !strings.Contains(got, "Zertifikatswechsel") {
t.Errorf("der Ausschnitt setzt nicht an der Fundstelle an: %q", got)
}
}
// TestSnippetLongestWordFirst prüft die Wahl zwischen mehreren Wörtern:
// Das längere, aussagekräftigere gewinnt, auch wenn das kürzere früher steht.
func TestSnippetLongestWordFirst(t *testing.T) {
text := "Dienst. " + strings.Repeat("Füllwort. ", 60) + "Kubernetes-Cluster hier."
got := Snippet(text, "dienst kubernetes")
if !strings.Contains(got, "Kubernetes") {
t.Errorf("es wurde nicht das längste Wort gewählt: %q", got)
}
}
func TestHitWord(t *testing.T) {
text := "Wir deployen über GitHub Actions."
cases := map[string]string{
"GitHub Actions": "GitHub Actions", // ganze Anfrage kommt vor
"wie deployen wir": "deployen",
"wie wir": "", // nur kurze Wörter
"gibtesnicht": "",
"": "",
"actions, github?": "actions", // gleich lang: Reihenfolge der Anfrage
}
for query, want := range cases {
if got := hitWord(text, query); got != want {
t.Errorf("trefferwort(%q) = %q, erwartet %q", query, got, want)
}
}
}
// TestHighlightSnippetSingleWord prüft, dass bei einer ganzen Frage das Wort
// hervorgehoben wird, an dem sich der Ausschnitt ausrichtet.
func TestHighlightSnippetSingleWord(t *testing.T) {
got := HighlightSnippet("Wir deployen über GitHub Actions.", "wie deployen wir")
if !strings.Contains(got, "<mark>deployen</mark>") {
t.Errorf("das Fundwort wurde nicht hervorgehoben: %q", got)
}
}
func TestSnippetShortText(t *testing.T) {
got := Snippet("Kurz.", "Kurz")
if got != "Kurz." {
t.Errorf("Snippet = %q, erwartet %q", got, "Kurz.")
}
}
// TestHighlightSnippetEscapesFirst ist ein Sicherheitstest: Der Inhalt muss
// escaped werden, BEVOR die Hervorhebung eingesetzt wird. Andersherum könnte ein
// Eintrag eigenes HTML in die Trefferliste einschleusen.
func TestHighlightSnippetEscapesFirst(t *testing.T) {
got := HighlightSnippet("Ein <script>alert(1)</script> im Text", "script")
if strings.Contains(got, "<script>") {
t.Errorf("unescaptes HTML im Ausschnitt: %q", got)
}
// Die spitzen Klammern des Inhalts müssen escaped sein. Geprüft wird auf
// "&lt;" statt auf "&lt;script&gt;", weil die Hervorhebung mitten im
// escapten Text steht: "&lt;<mark>script</mark>&gt;".
if !strings.Contains(got, "&lt;") || !strings.Contains(got, "&gt;") {
t.Errorf("der Inhalt wurde nicht escaped: %q", got)
}
// Die Hervorhebung selbst muss echtes HTML sein.
if !strings.Contains(got, "<mark>") {
t.Errorf("der Treffer wurde nicht hervorgehoben: %q", got)
}
}
func TestHighlightSnippetWithoutQuery(t *testing.T) {
got := HighlightSnippet("Text mit <Klammern>", "")
if strings.Contains(got, "<mark>") {
t.Error("ohne Suchbegriff wurde etwas hervorgehoben")
}
if strings.Contains(got, "<Klammern>") {
t.Errorf("der Inhalt wurde nicht escaped: %q", got)
}
}
// ── Titel raten ──────────────────────────────────────────────────────────────
func TestTitleFromContent(t *testing.T) {
cases := map[string]string{
"# Deployment\n\nText": "Deployment",
"## Unterüberschrift\nX": "Unterüberschrift",
"\n\nErste echte Zeile\nX": "Erste echte Zeile",
"": "Ohne Titel",
" \n \n": "Ohne Titel",
}
for input, want := range cases {
if got := TitleFromContent(input); got != want {
t.Errorf("TitleFromContent(%q) = %q, erwartet %q", input, got, want)
}
}
}
func TestTitleFromContentIsShortened(t *testing.T) {
long := strings.Repeat("Wort ", 100)
got := TitleFromContent(long)
if len(got) > 130 {
t.Errorf("der Titel ist %d Zeichen lang — er wurde nicht gekürzt", len(got))
}
}
// ── Auch gesucht als ─────────────────────────────────────────────────────────
func TestAlsoSearchedAs(t *testing.T) {
cases := map[string]string{
"Text.\n\nAuch gesucht als: release, rollout": "release, rollout",
"Text.\n*Auch gesucht als:* release": "release",
"- **Auch gesucht als:** release, auslieferung": "release, auslieferung",
"auch gesucht als: klein geschrieben": "klein geschrieben",
"Auch gesucht als: alt\nText\nAuch gesucht als: neu": "neu",
"Ein Text ohne die Zeile.": "",
"Wir haben auch gesucht als Team.": "", // nicht am Zeilenanfang
}
for content, want := range cases {
if got := AlsoSearchedAs(content); got != want {
t.Errorf("AuchGesuchtAls(%q) = %q, erwartet %q", content, got, want)
}
}
}