Files
paliad/internal/services/paliadin_test.go
m 8d714dd95e fix(t-paliad-146): gate Paliadin to owner email in code, drop PALIADIN_ENABLED
m's call (2026-05-07 21:52): "remove the export variable, that is bad
form. It should be connected only to my account."

The PALIADIN_ENABLED env var was a deploy-time toggle: easy to
mis-flip, splits prod/dev behaviour, and reads as "could be turned on
for anyone." Replaced with a per-request gate in code:

  services.PaliadinOwnerEmail = "matthias.siebels@hoganlovells.com"

handlers/paliadin.go now gates every entry point through
requirePaliadinOwner, which looks up paliad.users.email by the caller's
UUID and returns 404 (not 403 — pretend the route doesn't exist) for
anyone else.

Routes register unconditionally; the gate is in the code, not the
deploy. main.go wires PaliadinService whenever DATABASE_URL is set and
logs the owner identity at boot. CLAUDE.md drops the PALIADIN_ENABLED
row and gains an explanatory note about the in-code gate.

Sidebar entries (Paliadin under Übersicht; Paliadin Monitor under
Admin) now render with display:none, revealed by sidebar.ts after
/api/me confirms the caller's email matches PALIADIN_OWNER_EMAIL —
same fail-closed pattern the Admin group already uses.

Side-effect for ops: paliad.de production now serves the routes too,
but only to m, and only successfully if the host has tmux + claude
in PATH (which Dokploy doesn't). m hitting /paliadin from prod gets a
"tmux unavailable" — clear failure mode, not a security concern.

One new test (TestPaliadinOwnerEmail_IsLowercaseStable) keeps the
constant aligned with migration 023's seed so a future rename of m's
account doesn't silently strand the gate. All existing tests pass.
2026-05-07 21:57:20 +02:00

166 lines
4.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package services
import (
"strings"
"testing"
)
// Tests for the PoC paliadin trailer parser. The parser is load-bearing:
// it's how the dashboard learns which tools Claude used, how many rows
// each returned, and how Claude classified the question. Wrong parsing
// = silently broken monitoring.
func TestSplitTrailer_HappyPath(t *testing.T) {
body := strings.TrimSpace(`
Diese Woche stehen 3 Fristen an:
- 16.05. Klageerwiderung [#deadline-OPEN:c47bd2-1]
- 17.05. Replik [#deadline-OPEN:e92a01-3]
- 20.05. Wiedereinsetzung [#deadline-OPEN:f31b09-7]
---
[paliadin-meta]
used_tools: search_my_deadlines, lookup_court
rows_seen: 3, 1
classifier_tag: data
[/paliadin-meta]
`)
clean, meta := splitTrailer(body)
if strings.Contains(clean, "[paliadin-meta]") {
t.Fatalf("trailer not stripped from body:\n%s", clean)
}
if !strings.HasPrefix(clean, "Diese Woche") {
t.Errorf("body lost prefix: %q", clean[:50])
}
wantTools := []string{"search_my_deadlines", "lookup_court"}
if len(meta.UsedTools) != len(wantTools) {
t.Fatalf("UsedTools len = %d; want %d", len(meta.UsedTools), len(wantTools))
}
for i, want := range wantTools {
if meta.UsedTools[i] != want {
t.Errorf("UsedTools[%d] = %q; want %q", i, meta.UsedTools[i], want)
}
}
wantRows := []int{3, 1}
if len(meta.RowsSeen) != len(wantRows) {
t.Fatalf("RowsSeen len = %d; want %d", len(meta.RowsSeen), len(wantRows))
}
for i, want := range wantRows {
if meta.RowsSeen[i] != want {
t.Errorf("RowsSeen[%d] = %d; want %d", i, meta.RowsSeen[i], want)
}
}
if meta.ClassifierTag != "data" {
t.Errorf("ClassifierTag = %q; want %q", meta.ClassifierTag, "data")
}
}
func TestSplitTrailer_NoTrailer(t *testing.T) {
body := "Just a response, no trailer."
clean, meta := splitTrailer(body)
if clean != body {
t.Errorf("body changed: %q vs %q", clean, body)
}
if len(meta.UsedTools) != 0 || len(meta.RowsSeen) != 0 || meta.ClassifierTag != "" {
t.Errorf("meta should be zero: %+v", meta)
}
}
func TestSplitTrailer_EmptyToolsList(t *testing.T) {
body := strings.TrimSpace(`
Klageerwiderung ist die Erwiderung auf die Klage.
---
[paliadin-meta]
used_tools:
rows_seen:
classifier_tag: concept
[/paliadin-meta]
`)
clean, meta := splitTrailer(body)
if strings.Contains(clean, "[paliadin-meta]") {
t.Errorf("trailer not stripped")
}
if len(meta.UsedTools) != 0 {
t.Errorf("UsedTools should be empty: %v", meta.UsedTools)
}
if meta.ClassifierTag != "concept" {
t.Errorf("ClassifierTag = %q; want concept", meta.ClassifierTag)
}
}
func TestCountChips(t *testing.T) {
cases := []struct {
body string
want int
}{
{"plain text", 0},
{"see [#deadline-OPEN:abc-123]", 1},
{"two [#deadline-OPEN:abc] and [#projekt-OPEN:slug]", 2},
{"chip nav [chip:nav:/projects/123]", 1},
{"chip filter [chip:filter:status=pending]", 1},
{"mixed [#frist-OPEN:x] and [chip:nav:/y]", 2},
// Hallucinated / malformed markers don't count.
{"[#deadline-OPEN:]", 0},
{"[#deadline-OPEN]", 0},
{"[chip:invalid]", 0},
}
for _, c := range cases {
got := countChips(c.body)
if got != c.want {
t.Errorf("countChips(%q) = %d; want %d", c.body, got, c.want)
}
}
}
func TestApproxTokenCount(t *testing.T) {
cases := []struct {
s string
want int
}{
{"", 0},
{"hello", 1}, // 1 word × 1.3 → 1
{"hello world", 2}, // 2 × 1.3 = 2.6 → 2
{"one two three four five six seven", 9}, // 7 × 1.3 = 9.1 → 9
}
for _, c := range cases {
got := approxTokenCount(c.s)
if got != c.want {
t.Errorf("approxTokenCount(%q) = %d; want %d", c.s, got, c.want)
}
}
}
func TestSanitiseForTmux(t *testing.T) {
in := "first line\nsecond line\rthird"
got := sanitiseForTmux(in)
if strings.ContainsAny(got, "\n\r") {
t.Errorf("sanitiseForTmux did not strip newlines: %q", got)
}
}
func TestPaliadinOwnerEmail_IsLowercaseStable(t *testing.T) {
// Sanity check: the constant matches the email seeded in
// migration 023 verbatim. If it ever drifts, the gate would
// reject m on a fresh DB without anyone noticing.
want := "matthias.siebels@hoganlovells.com"
if PaliadinOwnerEmail != want {
t.Fatalf("PaliadinOwnerEmail = %q; want %q (matches migration 023 seed)",
PaliadinOwnerEmail, want)
}
// Lowercase invariant — the gate uses strings.EqualFold but we
// store + compare lowercase consistently anyway.
if strings.ToLower(PaliadinOwnerEmail) != PaliadinOwnerEmail {
t.Errorf("PaliadinOwnerEmail must be lowercase: %q", PaliadinOwnerEmail)
}
}
func TestSanitiseForTmux_TruncatesLong(t *testing.T) {
long := strings.Repeat("x", 10_000)
got := sanitiseForTmux(long)
if !strings.HasSuffix(got, "[…truncated]") {
t.Errorf("expected truncation marker, got tail: %q", got[len(got)-20:])
}
}