atlas-iac/services/hermes/router/session_snapshot_test.go
Hermes Agent d22588dddb fix(hermes): poll the session contract the chat tenants actually serve
Returning to chat.hermes.bstein.dev after a Keycloak logout/login showed
"This session is unavailable to this account. Start a new chat." even
though the session was intact and owned by the same subject.

The banner comes from the continuity fallback the router injects into
every chat page. It polled `/api/sessions/<id>` and
`/api/sessions/<id>/messages` — routes that belong to the Hermes agent
dashboard (added by scripts/patch_web_session_activity.py, applied only
in agent-deployment.yaml). The router proxies browser traffic to the
tenant Hermes WebUI instead, whose only session read is
`GET /api/session?session_id=<id>`; the dashboard paths are unrouted
there, so server.py answered its generic 404 for every poll and the
fallback reported a false ownership failure.

The script runs only on a full document load of `/session/<id>`, which is
exactly what the OIDC round-trip produces when oauth2-proxy returns the
browser to `rd=/session/<id>` — hence the "only after relogin" symptom.

Poll the WebUI contract instead, and let its own answers decide what the
banner claims: 409 `session_profile_mismatch` is the single response that
means the session is outside this account's active scope, 404 now means
the conversation is no longer stored, and 401/403 still re-enter OIDC.
The steady-state poll drops to one request and backs off to 3s/15s now
that it reaches a real endpoint on the tenant Raspberry Pi.

`boundSessionSnapshot` follows the same move: it caps the WebUI envelope
`{"session": {..., "messages": [...]}}`, relaying every other session key
verbatim rather than re-serializing a fixed struct that would silently
drop metadata the banner depends on.

Isolation is unchanged and now covered: the router still resolves the
slot from the salted Keycloak subject, overwrites any client-supplied
X-Hermes-Tenant-Identity, and forwards only the two tenant cookies.

Tests: relogin keeps a stable slot and resolves the durable session; a
second subject replaying the owner's session id, WebUI cookie and a
forged tenant header gets 404 from its own backend and never reaches the
owner's; the legacy dashboard paths are pinned as permanent 404s against
a stub of the deployed WebUI dispatch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 18:14:55 +00:00

167 lines
5.5 KiB
Go

package main
import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"testing"
"testing/iotest"
)
// webuiSession mirrors the fields of the tenant WebUI `GET /api/session`
// payload that the continuity fallback reads.
type webuiSession struct {
SessionID string `json:"session_id"`
Messages []json.RawMessage `json:"messages"`
MessageCount int `json:"message_count"`
Title string `json:"title,omitempty"`
ReadOnly bool `json:"read_only,omitempty"`
}
type webuiSessionEnvelope struct {
Session webuiSession `json:"session"`
}
func snapshotResponse(target, body string) *http.Response {
request, _ := http.NewRequest(http.MethodGet, target, nil)
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
Request: request,
}
}
func decodeSnapshot(t *testing.T, response *http.Response) webuiSession {
t.Helper()
var envelope webuiSessionEnvelope
if err := json.NewDecoder(response.Body).Decode(&envelope); err != nil {
t.Fatal(err)
}
return envelope.Session
}
func TestBoundSessionSnapshotKeepsOnlyRecentMessages(t *testing.T) {
messages := make([]map[string]int, 30)
for index := range messages {
messages[index] = map[string]int{"index": index}
}
body, _ := json.Marshal(map[string]any{"session": map[string]any{
"session_id": "resolved", "messages": messages, "message_count": 30,
}})
response := snapshotResponse(
"http://tenant/api/session?session_id=root&messages=1&msg_limit=24&hermes_fallback=1",
string(body),
)
response.Header.Set("ETag", "stale")
if err := boundSessionSnapshot(response); err != nil {
t.Fatal(err)
}
bounded := decodeSnapshot(t, response)
if len(bounded.Messages) != sessionSnapshotItems || bounded.MessageCount != 30 {
t.Fatalf("snapshot was not bounded: %#v", bounded)
}
var first map[string]int
if err := json.Unmarshal(bounded.Messages[0], &first); err != nil || first["index"] != 6 {
t.Fatalf("snapshot did not retain the recent tail: %#v, %v", first, err)
}
if response.Header.Get("Cache-Control") != "no-store" || response.Header.Get("ETag") != "" {
t.Fatal("bounded snapshot retained cache metadata")
}
}
func TestBoundSessionSnapshotPreservesLargerReportedTotal(t *testing.T) {
response := snapshotResponse(
"http://tenant/api/session?session_id=leaf&hermes_fallback=1",
`{"session":{"session_id":"leaf","messages":[],"message_count":100}}`,
)
if err := boundSessionSnapshot(response); err != nil {
t.Fatal(err)
}
bounded := decodeSnapshot(t, response)
if bounded.SessionID != "leaf" || bounded.MessageCount != 100 {
t.Fatalf("reported total was lost: %#v", bounded)
}
}
// The poller and the WebUI both evolve; bounding the message tail must never
// strip the surrounding session metadata that decides what the banner says.
func TestBoundSessionSnapshotRelaysUnknownSessionMetadata(t *testing.T) {
response := snapshotResponse(
"http://tenant/api/session?session_id=root&hermes_fallback=1",
`{"session":{"session_id":"root","messages":[{"role":"user"}],`+
`"is_streaming":true,"active_stream_id":"stream-1","read_only":false,`+
`"future_field":{"kept":true}},"other_envelope_key":7}`,
)
if err := boundSessionSnapshot(response); err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(response.Body)
if err != nil {
t.Fatal(err)
}
for _, expected := range []string{
`"is_streaming":true`, `"active_stream_id":"stream-1"`,
`"future_field":{"kept":true}`, `"other_envelope_key":7`,
} {
if !strings.Contains(string(body), expected) {
t.Fatalf("bounded snapshot dropped %s: %s", expected, body)
}
}
if length := response.Header.Get("Content-Length"); length != "" &&
length != strings.TrimSpace(length) {
t.Fatal("content length was not rewritten")
}
}
func TestBoundSessionSnapshotFailsClosedOnMalformedOrOversizedBody(t *testing.T) {
for name, body := range map[string]string{
"malformed": `{`,
"missing": `{"error":"Session not found"}`,
"nonObject": `{"session":42}`,
"badMessageList": `{"session":{"messages":"all of them"}}`,
"oversized": strings.Repeat("x", sessionSnapshotBytes+1),
} {
t.Run(name, func(t *testing.T) {
response := snapshotResponse(
"http://tenant/api/session?session_id=root&hermes_fallback=1",
body,
)
if err := boundSessionSnapshot(response); err == nil {
t.Fatal("unsafe snapshot was accepted")
}
})
}
response := snapshotResponse(
"http://tenant/api/session?session_id=root&hermes_fallback=1", `{}`,
)
response.Body = io.NopCloser(iotest.ErrReader(errors.New("read failed")))
if err := boundSessionSnapshot(response); err == nil {
t.Fatal("snapshot body read failure was ignored")
}
}
func TestBoundSessionSnapshotLeavesNativeAndErrorResponsesUntouched(t *testing.T) {
for _, response := range []*http.Response{
{StatusCode: http.StatusOK},
// The WebUI's own session reads carry no fallback marker.
snapshotResponse("http://tenant/api/session?session_id=root", `{}`),
snapshotResponse("http://tenant/api/sessions", `{}`),
// The dashboard-only route is not this backend's contract.
snapshotResponse("http://tenant/api/sessions/root/messages?hermes_fallback=1", `{}`),
} {
if err := boundSessionSnapshot(response); err != nil {
t.Fatal(err)
}
}
errorResponse := snapshotResponse(
"http://tenant/api/session?session_id=root&hermes_fallback=1", `{}`,
)
errorResponse.StatusCode = http.StatusNotFound
if err := boundSessionSnapshot(errorResponse); err != nil {
t.Fatal(err)
}
}