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.
166 lines
4.7 KiB
Go
166 lines
4.7 KiB
Go
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:])
|
||
}
|
||
}
|