Files
paliad/cmd/server/main_paliadin_backend_test.go
mAi edc81bbbc2 feat(t-paliad-194): AichatPaliadinService + PALIADIN_BACKEND=aichat env gate (m/paliad#38 Phase B)
Adds the Phase B paliad-side migration: a thin HTTP client of the
centralized aichat backend shipped in m/mAi#207 Phase A (darwin's
mai/darwin/issue-207-aichat branch). Implements the same services.Paliadin
interface as LocalPaliadinService / RemotePaliadinService — handler
plumbing is unchanged, the cutover is a single env-var flip.

internal/services/aichat_paliadin.go (~530 LoC):
  - POST /chat/turn + POST /chat/reset + GET /chat/health via the aichat
    JSON envelope (mirrors m/mAi internal/aichat/api/types.go verbatim;
    no module import to keep paliad self-contained).
  - Per-turn HS256 JWT mint (uses paliadin_jwt.go from the prior commit)
    when SUPABASE_JWT_SECRET is configured. Aichat owns file write +
    cleanup; we just sign and ship.
  - Service-wide health-gate cache (10 s success window, no failure
    cache — failures re-probe so recovery surfaces immediately).
  - Per-user-window primer cache. Pulls up to MaxPrimerTurns prior
    exchanges from paliad.paliadin_turns and ships them in TurnRequest.
    Primer so a pane respawn (pane_spawned=true in response) doesn't
    strand the user with a cold claude. Cleared on ResetSession +
    pane_spawned response.
  - Username from email_localpart per m's §13 Q2 pick (sanitized inside
    aichat). Nil-DB fallback: "user-<uuid8>".
  - Maps aichat's typed wire errors (auth_failed, persona_unknown,
    mriver_unreachable, bootstrap_failed, timeout, shim_error) onto
    paliad's existing audit-row codes — preserves the German i18n table
    in paliadin.ts unchanged (no new strings needed per design §11).

cmd/server/main.go:
  - PALIADIN_BACKEND env: "aichat" → AichatPaliadinService, anything
    else → existing remote/local/disabled tree. Default = legacy, so
    every existing deploy is byte-identical until flipped.
  - buildAichatPaliadinConfig validates AICHAT_URL + AICHAT_TOKEN at
    boot; AICHAT_PERSONA defaults to "paliadin". JWT secret threaded
    in so per-user RLS is on by default.

Tests cover constructor defaults, health-gate caching + retry +
expiry, ResetSession wiring, error-envelope decoding + classifier,
HTTP-layer auth/JSON wiring via a roundTripper, JWT mint integration,
TurnContext → meta packing, and the env-gate helper. go test ./...
green. NOT self-merged — head owns the merge per task instructions.
2026-05-15 03:03:34 +02:00

87 lines
2.8 KiB
Go

package main
import (
"strings"
"testing"
)
// TestBuildAichatPaliadinConfig pins the env-driven wiring used by the
// PALIADIN_BACKEND=aichat path in main(). It guards three things:
//
// 1. Required vars (AICHAT_URL, AICHAT_TOKEN) must be set — otherwise
// boot fails fast with a clear error message.
// 2. AICHAT_PERSONA defaults to "paliadin" so a misconfigured deploy
// doesn't silently route to a different persona.
// 3. The JWT secret threads through so per-turn JWT mint is on by
// default (folded-in t-paliad-156 work).
//
// We can't unit-test the switch{} block in main() directly without
// invoking the rest of boot, so this test exercises the helper that
// branch calls — the same surface a Phase B regression would hit.
func TestBuildAichatPaliadinConfig(t *testing.T) {
t.Run("rejects empty URL", func(t *testing.T) {
t.Setenv("AICHAT_URL", "")
t.Setenv("AICHAT_TOKEN", "tok")
_, err := buildAichatPaliadinConfig("secret")
if err == nil || !strings.Contains(err.Error(), "AICHAT_URL") {
t.Errorf("err = %v; want AICHAT_URL complaint", err)
}
})
t.Run("rejects empty token", func(t *testing.T) {
t.Setenv("AICHAT_URL", "http://aichat.test")
t.Setenv("AICHAT_TOKEN", "")
_, err := buildAichatPaliadinConfig("secret")
if err == nil || !strings.Contains(err.Error(), "AICHAT_TOKEN") {
t.Errorf("err = %v; want AICHAT_TOKEN complaint", err)
}
})
t.Run("defaults persona to paliadin", func(t *testing.T) {
t.Setenv("AICHAT_URL", "http://aichat.test/")
t.Setenv("AICHAT_TOKEN", "tok")
t.Setenv("AICHAT_PERSONA", "")
cfg, err := buildAichatPaliadinConfig("secret")
if err != nil {
t.Fatalf("err: %v", err)
}
if cfg.Persona != "paliadin" {
t.Errorf("persona = %q; want paliadin", cfg.Persona)
}
if cfg.BaseURL != "http://aichat.test" {
t.Errorf("base url trailing slash leaked: %q", cfg.BaseURL)
}
if string(cfg.JWTSecret) != "secret" {
t.Errorf("JWT secret not threaded; got %q", string(cfg.JWTSecret))
}
if cfg.BearerToken != "tok" {
t.Errorf("BearerToken = %q; want tok", cfg.BearerToken)
}
})
t.Run("honours AICHAT_PERSONA override", func(t *testing.T) {
t.Setenv("AICHAT_URL", "http://aichat.test")
t.Setenv("AICHAT_TOKEN", "tok")
t.Setenv("AICHAT_PERSONA", "custom-paliadin")
cfg, err := buildAichatPaliadinConfig("secret")
if err != nil {
t.Fatalf("err: %v", err)
}
if cfg.Persona != "custom-paliadin" {
t.Errorf("persona = %q; want custom-paliadin", cfg.Persona)
}
})
}
func TestRLSModeLabel(t *testing.T) {
if got := rlsModeLabel(nil); got != "service-role" {
t.Errorf("nil → %q; want service-role", got)
}
if got := rlsModeLabel([]byte{}); got != "service-role" {
t.Errorf("empty → %q; want service-role", got)
}
if got := rlsModeLabel([]byte("x")); got != "per-user" {
t.Errorf("non-empty → %q; want per-user", got)
}
}