From e108b3e06984223f9489e5dcf1f22ed60ae8adb7 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sat, 25 Apr 2026 23:34:21 +0300 Subject: [PATCH] feat(session): list and detail JSON API with filters --- internal/domain/session/handler.go | 208 +++++++++++- internal/domain/session/handler_test.go | 349 +++++++++++++++++++++ internal/domain/session/repository.go | 289 +++++++++++++++++ internal/domain/session/repository_test.go | 344 ++++++++++++++++++++ 4 files changed, 1179 insertions(+), 11 deletions(-) create mode 100644 internal/domain/session/handler_test.go create mode 100644 internal/domain/session/repository.go create mode 100644 internal/domain/session/repository_test.go diff --git a/internal/domain/session/handler.go b/internal/domain/session/handler.go index 0015c2a2d659c1e0e972b6e8556ee100e84b2467..899600c3b6fc25e6aa21164ab46ec3839587e223 100644 --- a/internal/domain/session/handler.go +++ b/internal/domain/session/handler.go @@ -1,22 +1,208 @@ -// Phase-5 stub. Replaced by real implementation in Phase 8. -// -// Mount is a no-op so the /api/v1 group registers no session routes yet; -// Server.Init still wires it in so the full router topology compiles. package session import ( "context" + "log/slog" + "net/http" + "strconv" + "strings" "github.com/go-chi/chi/v5" + "go.bigb.es/auxilia/culpa" + "go.bigb.es/auxilia/scribe" + + "sourcecraft.dev/bigbes/lethe/internal/pkg/apierror" + "sourcecraft.dev/bigbes/lethe/internal/pkg/httputil" + "sourcecraft.dev/bigbes/lethe/internal/server/auth" +) + +// Pagination knobs locked by the spec. The Handler clamps client-supplied +// values into [0, defaultLimit] / [0, maxLimit] before reaching Repository. +const ( + defaultLimit = 50 + maxLimit = 200 ) -// Handler is a no-op steward service. Phase 8 supplies the real session -// read API. -type Handler struct{} +// allOwnersSentinel is the literal value of `?owner=` that an admin uses to +// scope a list across every owner. Any other non-empty value is interpreted +// as a SpecificOwner. +const allOwnersSentinel = "*" -// Init satisfies the steward Service contract. +// Handler is the steward-managed HTTP boundary for the sessions read API. +// Repo is the injected SQL steward; the Handler holds no other state. +type Handler struct { + Repo *Repository `inject:""` +} + +// Init satisfies the steward Initer contract. The Handler is stateless +// beyond its injected dependencies. func (h *Handler) Init(_ context.Context) error { return nil } -// Mount registers no routes. Phase 8 replaces this with the sessions list / -// detail endpoints rooted at the /api/v1 group passed in by Server.Init. -func (h *Handler) Mount(_ chi.Router) {} +// Mount registers the two read routes under r. Server.Init mounts this +// inside the /api/v1 group, so the effective paths are +// `/api/v1/sessions` and `/api/v1/sessions/{tool}/{host}/{session_id}`. +func (h *Handler) Mount(r chi.Router) { + r.Get("/sessions", h.List) + r.Get("/sessions/{tool}/{host}/{session_id}", h.Get) +} + +// listResponse is the JSON body returned by List. The Limit/Offset echo back +// the (possibly clamped) effective values so clients can detect that their +// supplied limit was capped. +type listResponse struct { + Sessions []Session `json:"sessions"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +// List handles GET /sessions. It resolves the owner scope (admin gating on +// `?owner=`), parses optional filters, clamps pagination, and writes a +// listResponse. Errors surface through apierror.Render as RFC 7807. +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + scope, err := h.resolveScope(r) + if err != nil { + apierror.Render(w, r, err) + return + } + + q := r.URL.Query() + filter := ListFilter{Owner: scope} + + if v := q.Get("tool"); v != "" { + filter.Tool = &v + } + if v := q.Get("host"); v != "" { + filter.Host = &v + } + if v := q.Get("since"); v != "" { + n, perr := strconv.ParseInt(v, 10, 64) + if perr != nil { + apierror.Render(w, r, culpa.WithCode( + culpa.WithPublic(culpa.Wrap(perr, "parse since"), "since must be an integer (unix epoch seconds)"), + "INVALID", + )) + return + } + filter.Since = &n + } + if v := q.Get("until"); v != "" { + n, perr := strconv.ParseInt(v, 10, 64) + if perr != nil { + apierror.Render(w, r, culpa.WithCode( + culpa.WithPublic(culpa.Wrap(perr, "parse until"), "until must be an integer (unix epoch seconds)"), + "INVALID", + )) + return + } + filter.Until = &n + } + if filter.Since != nil && filter.Until != nil && *filter.Since > *filter.Until { + apierror.Render(w, r, culpa.WithCode( + culpa.WithPublic(culpa.New("since > until"), "since must be <= until"), + "INVALID", + )) + return + } + + filter.Limit = clampLimit(q.Get("limit")) + filter.Offset = clampOffset(q.Get("offset")) + + rows, err := h.Repo.List(r.Context(), filter) + if err != nil { + apierror.Render(w, r, err) + return + } + + if writeErr := httputil.WriteJSON(w, http.StatusOK, listResponse{ + Sessions: rows, + Limit: filter.Limit, + Offset: filter.Offset, + }); writeErr != nil { + slog.Default().ErrorContext(r.Context(), "write sessions response", scribe.Err(writeErr)) + } +} + +// Get handles GET /sessions/{tool}/{host}/{session_id}. The chi router +// guarantees non-empty captures, but we defend in depth (a misconfigured +// mount would otherwise produce a SQL query with empty keys). +func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { + scope, err := h.resolveScope(r) + if err != nil { + apierror.Render(w, r, err) + return + } + + tool := chi.URLParam(r, "tool") + host := chi.URLParam(r, "host") + sessionID := chi.URLParam(r, "session_id") + if tool == "" || host == "" || sessionID == "" { + apierror.Render(w, r, culpa.WithCode( + culpa.WithPublic(culpa.New("tool, host, and session_id are required"), "tool, host, and session_id are required"), + "INVALID", + )) + return + } + + out, err := h.Repo.Get(r.Context(), scope, tool, host, sessionID) + if err != nil { + apierror.Render(w, r, err) + return + } + if writeErr := httputil.WriteJSON(w, http.StatusOK, out); writeErr != nil { + slog.Default().ErrorContext(r.Context(), "write session response", scribe.Err(writeErr)) + } +} + +// resolveScope reads the authenticated identity off the context and the +// optional `?owner=` query parameter, then returns the appropriate +// OwnerScope. Non-admin requests with `?owner=` set (any value, including +// the requester's own user) are 403 — the parameter is admin-only and must +// not be ignored silently for non-admins. +func (h *Handler) resolveScope(r *http.Request) (OwnerScope, error) { + id := auth.MustIdentity(r.Context()) + param := r.URL.Query().Get("owner") + if param == "" { + return OwnerScope{User: id.User}, nil + } + if !id.IsAdmin { + return OwnerScope{}, culpa.WithCode( + culpa.WithPublic(culpa.New("?owner= is admin-only"), "?owner= is admin-only"), + "FORBIDDEN", + ) + } + if param == allOwnersSentinel { + return OwnerScope{User: id.User, AllOwners: true}, nil + } + owner := strings.ToLower(param) + return OwnerScope{User: id.User, SpecificOwner: &owner}, nil +} + +// clampLimit returns the effective limit: defaultLimit when missing, +// non-numeric, or negative; capped at maxLimit when the parsed value +// exceeds it. +func clampLimit(raw string) int { + if raw == "" { + return defaultLimit + } + n, err := strconv.Atoi(raw) + if err != nil || n < 0 { + return defaultLimit + } + if n > maxLimit { + return maxLimit + } + return n +} + +// clampOffset returns the effective offset: 0 when missing, non-numeric, +// or negative. +func clampOffset(raw string) int { + if raw == "" { + return 0 + } + n, err := strconv.Atoi(raw) + if err != nil || n < 0 { + return 0 + } + return n +} diff --git a/internal/domain/session/handler_test.go b/internal/domain/session/handler_test.go new file mode 100644 index 0000000000000000000000000000000000000000..ec1524f8b26af13508a930d839fe5222e14b0b56 --- /dev/null +++ b/internal/domain/session/handler_test.go @@ -0,0 +1,349 @@ +package session_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "sourcecraft.dev/bigbes/lethe/internal/domain/session" + "sourcecraft.dev/bigbes/lethe/internal/server/auth" +) + +// fakeAuthMiddleware injects a fixed Identity onto the request context so +// the handler can call auth.MustIdentity without a real Authenticator. +func fakeAuthMiddleware(id auth.Identity) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := auth.WithIdentity(r.Context(), id) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// newHandler wires a Repository against a fresh in-memory database and +// returns the Handler. The DB is exposed so each test can seed rows. +func newHandler(t *testing.T) (*session.Handler, *session.Repository) { + t.Helper() + repo, _ := newRepo(t) + h := &session.Handler{Repo: repo} + if err := h.Init(context.Background()); err != nil { + t.Fatalf("handler.Init: %v", err) + } + return h, repo +} + +// mountWithIdentity builds a chi router with the fake auth middleware +// (injecting id) and the session handler mounted under /api/v1. +func mountWithIdentity(h *session.Handler, id auth.Identity) http.Handler { + r := chi.NewRouter() + r.Route("/api/v1", func(r chi.Router) { + r.Use(fakeAuthMiddleware(id)) + h.Mount(r) + }) + return r +} + +// listBody is the decoded JSON of GET /sessions; matches the handler's +// listResponse but lives here so tests don't reach into unexported names. +type listBody struct { + Sessions []session.Session `json:"sessions"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +// problemBody captures only the fields tests assert on for RFC 7807 docs. +type problemBody struct { + Status int `json:"status"` + Code string `json:"code"` +} + +func doList(t *testing.T, router http.Handler, query string) (*httptest.ResponseRecorder, listBody) { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/api/v1/sessions"+query, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + var body listBody + if rec.Code == http.StatusOK { + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal list body: %v (body=%s)", err, rec.Body.String()) + } + } + return rec, body +} + +func doGet(t *testing.T, router http.Handler, tool, host, sid string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/api/v1/sessions/"+tool+"/"+host+"/"+sid, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec +} + +func TestHandler_List_PaginationDefaults(t *testing.T) { + h, repo := newHandler(t) + seedSession(t, repo.Database.DB, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010) + router := mountWithIdentity(h, auth.Identity{User: "alice"}) + + rec, body := doList(t, router, "") + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if body.Limit != 50 || body.Offset != 0 { + t.Fatalf("defaults: limit=%d offset=%d; want 50/0", body.Limit, body.Offset) + } +} + +func TestHandler_List_PaginationCaps(t *testing.T) { + h, _ := newHandler(t) + router := mountWithIdentity(h, auth.Identity{User: "alice"}) + + rec, body := doList(t, router, "?limit=999") + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if body.Limit != 200 { + t.Fatalf("expected limit capped to 200; got %d", body.Limit) + } +} + +func TestHandler_List_NegativeClamped(t *testing.T) { + h, _ := newHandler(t) + router := mountWithIdentity(h, auth.Identity{User: "alice"}) + + rec, body := doList(t, router, "?limit=-3&offset=-7") + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if body.Limit != 50 { + t.Fatalf("expected negative limit clamped to default 50; got %d", body.Limit) + } + if body.Offset != 0 { + t.Fatalf("expected negative offset clamped to 0; got %d", body.Offset) + } +} + +func TestHandler_List_SinceGreaterThanUntilReturns400(t *testing.T) { + h, _ := newHandler(t) + router := mountWithIdentity(h, auth.Identity{User: "alice"}) + + rec, _ := doList(t, router, "?since=200&until=100") + if rec.Code != http.StatusBadRequest { + t.Fatalf("status=%d; want 400; body=%s", rec.Code, rec.Body.String()) + } + var p problemBody + _ = json.Unmarshal(rec.Body.Bytes(), &p) + if p.Code != "INVALID" { + t.Fatalf("expected code INVALID; got %q (body=%s)", p.Code, rec.Body.String()) + } +} + +func TestHandler_List_BadSinceReturns400(t *testing.T) { + h, _ := newHandler(t) + router := mountWithIdentity(h, auth.Identity{User: "alice"}) + + rec, _ := doList(t, router, "?since=not-a-number") + if rec.Code != http.StatusBadRequest { + t.Fatalf("status=%d; want 400; body=%s", rec.Code, rec.Body.String()) + } + var p problemBody + _ = json.Unmarshal(rec.Body.Bytes(), &p) + if p.Code != "INVALID" { + t.Fatalf("expected INVALID; got %q", p.Code) + } +} + +func TestHandler_List_PerUserIsolation(t *testing.T) { + h, repo := newHandler(t) + db := repo.Database.DB + seedSession(t, db, "alice", "cc", "phoebe", "sA", 1700000000, 1700000010) + seedSession(t, db, "bob", "cc", "phoebe", "sB", 1700000100, 1700000110) + seedSession(t, db, "bob", "cc", "phoebe", "sB2", 1700000200, 1700000210) + + router := mountWithIdentity(h, auth.Identity{User: "alice"}) + // No filter — should still only see alice's row. + rec, body := doList(t, router, "") + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if len(body.Sessions) != 1 || body.Sessions[0].Owner != "alice" { + t.Fatalf("alice should see only her row; got %#v", body.Sessions) + } + + // Filter by tool — bob's matching rows still excluded. + rec, body = doList(t, router, "?tool=cc") + if rec.Code != http.StatusOK || len(body.Sessions) != 1 || body.Sessions[0].Owner != "alice" { + t.Fatalf("alice + tool filter should yield 1 alice row; got code=%d %#v", rec.Code, body.Sessions) + } +} + +func TestHandler_List_NonAdminOwnerParamReturns403(t *testing.T) { + h, repo := newHandler(t) + seedSession(t, repo.Database.DB, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010) + router := mountWithIdentity(h, auth.Identity{User: "alice", IsAdmin: false}) + + for _, q := range []string{"?owner=alice", "?owner=bob", "?owner=*"} { + rec, _ := doList(t, router, q) + if rec.Code != http.StatusForbidden { + t.Fatalf("query %q: status=%d; want 403; body=%s", q, rec.Code, rec.Body.String()) + } + var p problemBody + _ = json.Unmarshal(rec.Body.Bytes(), &p) + if p.Code != "FORBIDDEN" { + t.Fatalf("query %q: code=%q; want FORBIDDEN", q, p.Code) + } + } +} + +func TestHandler_List_AdminOwnerParamHonored(t *testing.T) { + h, repo := newHandler(t) + db := repo.Database.DB + seedSession(t, db, "alice", "cc", "phoebe", "sA", 1700000000, 1700000010) + seedSession(t, db, "bob", "cc", "phoebe", "sB", 1700000100, 1700000110) + router := mountWithIdentity(h, auth.Identity{User: "admin", IsAdmin: true}) + + rec, body := doList(t, router, "?owner=bob") + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if len(body.Sessions) != 1 || body.Sessions[0].Owner != "bob" { + t.Fatalf("expected bob's row only; got %#v", body.Sessions) + } +} + +func TestHandler_List_AdminOwnerStarReturnsAllOwners(t *testing.T) { + h, repo := newHandler(t) + db := repo.Database.DB + seedSession(t, db, "alice", "cc", "phoebe", "sA", 1700000000, 1700000010) + seedSession(t, db, "bob", "cc", "phoebe", "sB", 1700000100, 1700000110) + router := mountWithIdentity(h, auth.Identity{User: "admin", IsAdmin: true}) + + rec, body := doList(t, router, "?owner=*") + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if len(body.Sessions) != 2 { + t.Fatalf("expected 2 rows; got %d (%#v)", len(body.Sessions), body.Sessions) + } +} + +func TestHandler_List_AdminWithoutOwnerParam_ShowsOnlyOwnRows(t *testing.T) { + h, repo := newHandler(t) + db := repo.Database.DB + seedSession(t, db, "admin", "cc", "phoebe", "sAdm", 1700000000, 1700000010) + seedSession(t, db, "bob", "cc", "phoebe", "sB", 1700000100, 1700000110) + router := mountWithIdentity(h, auth.Identity{User: "admin", IsAdmin: true}) + + rec, body := doList(t, router, "") + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if len(body.Sessions) != 1 || body.Sessions[0].Owner != "admin" { + t.Fatalf("admin without ?owner= should see only own rows; got %#v", body.Sessions) + } +} + +func TestHandler_Get_OtherOwnersSession_ReturnsNotFound404(t *testing.T) { + h, repo := newHandler(t) + seedSession(t, repo.Database.DB, "bob", "cc", "phoebe", "sB", 1700000000, 1700000010) + router := mountWithIdentity(h, auth.Identity{User: "alice"}) + + rec := doGet(t, router, "cc", "phoebe", "sB") + if rec.Code != http.StatusNotFound { + t.Fatalf("status=%d; want 404; body=%s", rec.Code, rec.Body.String()) + } + var p problemBody + _ = json.Unmarshal(rec.Body.Bytes(), &p) + if p.Code != "NOT_FOUND" { + t.Fatalf("expected NOT_FOUND; got %q", p.Code) + } +} + +func TestHandler_Get_OwnSession_Returns200(t *testing.T) { + h, repo := newHandler(t) + db := repo.Database.DB + seedSession(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010) + seedTurn(t, db, "alice", "cc", "phoebe", "s1", "tA", 1, 1700000005, "user", "hi") + router := mountWithIdentity(h, auth.Identity{User: "alice"}) + + rec := doGet(t, router, "cc", "phoebe", "s1") + if rec.Code != http.StatusOK { + t.Fatalf("status=%d; want 200; body=%s", rec.Code, rec.Body.String()) + } + var got session.SessionWithTurns + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.SessionID != "s1" || got.Owner != "alice" { + t.Fatalf("unexpected session: %#v", got.Session) + } + if len(got.Turns) != 1 || got.Turns[0].TurnID != "tA" { + t.Fatalf("unexpected turns: %#v", got.Turns) + } +} + +func TestHandler_Get_AdminCanGetAnyOwner(t *testing.T) { + h, repo := newHandler(t) + db := repo.Database.DB + seedSession(t, db, "bob", "cc", "phoebe", "sB", 1700000000, 1700000010) + router := mountWithIdentity(h, auth.Identity{User: "admin", IsAdmin: true}) + + // With ?owner=bob admins fetch any user's session. + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/sessions/cc/phoebe/sB?owner=bob", nil) + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d; body=%s", rec.Code, rec.Body.String()) + } + var got session.SessionWithTurns + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.Owner != "bob" { + t.Fatalf("expected bob's session; got %#v", got.Session) + } + + // And with ?owner=* the admin can also reach across owners. + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/api/v1/sessions/cc/phoebe/sB?owner=*", nil) + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("?owner=*: status=%d; body=%s", rec.Code, rec.Body.String()) + } +} + +func TestHandler_Mount_RegistersExpectedRoutes(t *testing.T) { + h, _ := newHandler(t) + router := chi.NewRouter() + router.Route("/api/v1", func(r chi.Router) { + r.Use(fakeAuthMiddleware(auth.Identity{User: "alice"})) + h.Mount(r) + }) + + wantPatterns := map[string]bool{ + "/api/v1/sessions": false, + "/api/v1/sessions/{tool}/{host}/{session_id}": false, + } + walkErr := chi.Walk(router, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { + if method != http.MethodGet { + return nil + } + for k := range wantPatterns { + if route == k { + wantPatterns[k] = true + } + } + return nil + }) + if walkErr != nil { + t.Fatalf("walk: %v", walkErr) + } + for pat, found := range wantPatterns { + if !found { + t.Errorf("expected GET %s registered; not found", pat) + } + } +} diff --git a/internal/domain/session/repository.go b/internal/domain/session/repository.go new file mode 100644 index 0000000000000000000000000000000000000000..ff103a655dd00ffe1edf36352e9209423d1fa338 --- /dev/null +++ b/internal/domain/session/repository.go @@ -0,0 +1,289 @@ +// Package session implements the read-only sessions API: list with filters +// and detail-with-turns. The package layers as Repository (raw SQL) and +// Handler (HTTP boundary). Both are steward services; the Repository owns +// the SQL composition and the per-owner isolation invariant, while the +// Handler resolves the owner scope from the authenticated identity and +// translates errors into RFC 7807 problems. +// +// The owner-scope resolution rules (locked): +// - Default scope is `WHERE owner = `. Non-admins always read +// their own data only. +// - Admins may pass `?owner=` to scope to that user, or `?owner=*` to +// read across all owners (no WHERE clause on owner). +// - Non-admin requests that include `?owner=` (any value, including their +// own user) are 403 — the parameter is admin-only. +// - A Get for another owner's session returns 404 (not 403). Returning a +// distinct status would leak existence across tenants. +package session + +import ( + "context" + "database/sql" + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + "strings" + + "go.bigb.es/auxilia/culpa" + + "sourcecraft.dev/bigbes/lethe/internal/platform/database" +) + +// JSONText is a thin wrapper around the raw JSON bytes stored in TEXT columns +// (sessions.metadata, turns.tool_calls, turns.metadata). The SQLite driver +// does not know how to scan NULL into json.RawMessage directly, and the +// stored values may legitimately be NULL — Scan handles that by leaving the +// underlying slice nil. JSON marshal omits the field entirely (`,omitempty`) +// when the slice is empty, so a NULL column becomes an absent JSON key. +type JSONText []byte + +// Scan implements sql.Scanner so NULL TEXT columns become a nil []byte. +// Non-NULL values are copied (the driver's buffer is not safe to retain). +func (j *JSONText) Scan(src any) error { + if src == nil { + *j = nil + return nil + } + switch v := src.(type) { + case []byte: + buf := make([]byte, len(v)) + copy(buf, v) + *j = buf + return nil + case string: + *j = []byte(v) + return nil + default: + return fmt.Errorf("session: cannot scan %T into JSONText", src) + } +} + +// Value implements driver.Valuer; included for completeness so the type can +// flow back through any future writes (Phase 8 itself is read-only). +func (j JSONText) Value() (driver.Value, error) { + if len(j) == 0 { + return nil, nil + } + return string(j), nil +} + +// MarshalJSON returns the stored bytes verbatim (or `null` if empty) so the +// JSON output preserves whatever the collector originally produced. +func (j JSONText) MarshalJSON() ([]byte, error) { + if len(j) == 0 { + return []byte("null"), nil + } + // Validate that the stored bytes are syntactically valid JSON before + // emitting them — guards against a corrupt row turning a 200 into an + // HTTP-level encoding panic. + if !json.Valid(j) { + return nil, fmt.Errorf("session: stored JSON is invalid") + } + return []byte(j), nil +} + +// UnmarshalJSON stores the raw bytes verbatim, mirroring json.RawMessage. +func (j *JSONText) UnmarshalJSON(b []byte) error { + if j == nil { + return errors.New("session.JSONText: UnmarshalJSON on nil pointer") + } + *j = append((*j)[0:0], b...) + return nil +} + +// Session is the row shape returned by List and embedded in SessionWithTurns. +// JSON tags mirror the wire vocabulary used by the collector and clients. +type Session struct { + Owner string `db:"owner" json:"owner"` + Tool string `db:"tool" json:"tool"` + Host string `db:"host" json:"host"` + SessionID string `db:"session_id" json:"session_id"` + StartedAt int64 `db:"started_at" json:"started_at"` + EndedAt int64 `db:"ended_at" json:"ended_at"` + WorkingDir *string `db:"working_dir" json:"working_dir,omitempty"` + SourceFile string `db:"source_file" json:"source_file"` + Metadata JSONText `db:"metadata" json:"metadata,omitempty"` +} + +// Turn is the row shape returned inside SessionWithTurns. Optional columns +// (model, tokens_in/out, cost_usd, tool_calls, metadata) are nullable in the +// schema and are exposed as pointers / RawMessage so callers can distinguish +// "absent" from "zero". +type Turn struct { + Owner string `db:"owner" json:"owner"` + Tool string `db:"tool" json:"tool"` + Host string `db:"host" json:"host"` + SessionID string `db:"session_id" json:"session_id"` + TurnID string `db:"turn_id" json:"turn_id"` + Seq int64 `db:"seq" json:"seq"` + Role string `db:"role" json:"role"` + Timestamp int64 `db:"timestamp" json:"timestamp"` + Content string `db:"content" json:"content"` + Model *string `db:"model" json:"model,omitempty"` + TokensIn *int64 `db:"tokens_in" json:"tokens_in,omitempty"` + TokensOut *int64 `db:"tokens_out" json:"tokens_out,omitempty"` + CostUSD *float64 `db:"cost_usd" json:"cost_usd,omitempty"` + ToolCalls JSONText `db:"tool_calls" json:"tool_calls,omitempty"` + Metadata JSONText `db:"metadata" json:"metadata,omitempty"` +} + +// SessionWithTurns is the response shape for Get. Session is embedded so the +// JSON output flattens the session columns at the top level alongside the +// "turns" array. +type SessionWithTurns struct { + Session + Turns []Turn `json:"turns"` +} + +// OwnerScope is resolved by the Handler from the authenticated identity and +// the optional `?owner=` query parameter. It is the only knob the Repository +// has for tightening or widening the owner WHERE clause. +// +// Exactly one of the three states is meaningful per call: +// - AllOwners=true → no WHERE clause on owner (admin + ?owner=*) +// - SpecificOwner != nil → WHERE owner = *SpecificOwner (admin + ?owner=u) +// - otherwise → WHERE owner = User (default; non-admins always) +type OwnerScope struct { + User string + AllOwners bool + SpecificOwner *string +} + +// ListFilter aggregates every option List supports. The Handler clamps Limit +// and Offset before constructing this struct; the Repository assumes both are +// already in the safe range. +type ListFilter struct { + Owner OwnerScope + Tool *string + Host *string + Since *int64 + Until *int64 + Limit int + Offset int +} + +// Repository is the SQL steward for the sessions read API. It is stateless +// beyond its injected dependencies; Init is empty. +type Repository struct { + Database *database.Database `inject:""` +} + +// Init satisfies the steward Initer contract. Nothing to set up — the +// underlying *sqlx.DB is owned by the Database steward. +func (r *Repository) Init(_ context.Context) error { return nil } + +// sessionSelectColumns is the canonical column list for SELECTs against +// `sessions`. Centralized so the List and Get queries stay in lock-step with +// the Session struct's `db` tags. +const sessionSelectColumns = `owner, tool, host, session_id, started_at, ended_at, working_dir, source_file, metadata` + +// turnSelectColumns mirrors sessionSelectColumns for the `turns` table. +const turnSelectColumns = `owner, tool, host, session_id, turn_id, seq, role, timestamp, content, model, tokens_in, tokens_out, cost_usd, tool_calls, metadata` + +// List runs the dynamic-WHERE list query. The owner clause is built first +// (per OwnerScope), then optional filters are appended in fixed order; only +// values reach the driver via "?" placeholders — column names and the +// AND-skeleton are constructed from string literals never derived from input. +// +// Ordering is `started_at DESC, session_id DESC`: the secondary key keeps +// pagination deterministic when two sessions share a started_at. +// +// An empty result set returns a non-nil zero-length slice. Callers that +// JSON-encode the slice get `[]` rather than `null`. +func (r *Repository) List(ctx context.Context, f ListFilter) ([]Session, error) { + var ( + sb strings.Builder + args []any + ) + sb.WriteString("SELECT ") + sb.WriteString(sessionSelectColumns) + sb.WriteString(" FROM sessions") + + clauses := make([]string, 0, 5) + switch { + case f.Owner.AllOwners: + // no owner clause + case f.Owner.SpecificOwner != nil: + clauses = append(clauses, "owner = ?") + args = append(args, *f.Owner.SpecificOwner) + default: + clauses = append(clauses, "owner = ?") + args = append(args, f.Owner.User) + } + if f.Tool != nil { + clauses = append(clauses, "tool = ?") + args = append(args, *f.Tool) + } + if f.Host != nil { + clauses = append(clauses, "host = ?") + args = append(args, *f.Host) + } + if f.Since != nil { + clauses = append(clauses, "started_at >= ?") + args = append(args, *f.Since) + } + if f.Until != nil { + clauses = append(clauses, "started_at <= ?") + args = append(args, *f.Until) + } + if len(clauses) > 0 { + sb.WriteString(" WHERE ") + sb.WriteString(strings.Join(clauses, " AND ")) + } + sb.WriteString(" ORDER BY started_at DESC, session_id DESC LIMIT ? OFFSET ?") + args = append(args, f.Limit, f.Offset) + + out := make([]Session, 0) + if err := r.Database.DB.SelectContext(ctx, &out, sb.String(), args...); err != nil { + return nil, culpa.WithCode(culpa.Wrap(err, "list sessions"), "DB_QUERY") + } + return out, nil +} + +// Get returns the named session and its turns in seq order. The owner clause +// is built from scope identically to List: AllOwners means no clause, +// SpecificOwner pins the row, otherwise the current user is the only allowed +// owner. A miss for any of those reasons returns NOT_FOUND — never 403 — +// because differentiating "wrong owner" from "no such session" would leak +// existence across tenants. +// +// The turns query uses the resolved session's owner (read off the loaded +// row), keeping the result set internally consistent even under AllOwners. +func (r *Repository) Get(ctx context.Context, scope OwnerScope, tool, host, sessionID string) (*SessionWithTurns, error) { + var ( + sb strings.Builder + args []any + ) + sb.WriteString("SELECT ") + sb.WriteString(sessionSelectColumns) + sb.WriteString(" FROM sessions WHERE ") + + switch { + case scope.AllOwners: + // no owner clause + case scope.SpecificOwner != nil: + sb.WriteString("owner = ? AND ") + args = append(args, *scope.SpecificOwner) + default: + sb.WriteString("owner = ? AND ") + args = append(args, scope.User) + } + sb.WriteString("tool = ? AND host = ? AND session_id = ?") + args = append(args, tool, host, sessionID) + + var s Session + if err := r.Database.DB.GetContext(ctx, &s, sb.String(), args...); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, culpa.WithCode(culpa.New("session not found"), "NOT_FOUND") + } + return nil, culpa.WithCode(culpa.Wrap(err, "get session"), "DB_QUERY") + } + + turns := make([]Turn, 0) + const turnsQuery = "SELECT " + turnSelectColumns + " FROM turns WHERE owner = ? AND tool = ? AND host = ? AND session_id = ? ORDER BY seq ASC" + if err := r.Database.DB.SelectContext(ctx, &turns, turnsQuery, s.Owner, s.Tool, s.Host, s.SessionID); err != nil { + return nil, culpa.WithCode(culpa.Wrap(err, "list session turns"), "DB_QUERY") + } + return &SessionWithTurns{Session: s, Turns: turns}, nil +} diff --git a/internal/domain/session/repository_test.go b/internal/domain/session/repository_test.go new file mode 100644 index 0000000000000000000000000000000000000000..9775aa6a6b32ea19952ca0c3145a9f74dd06f4b4 --- /dev/null +++ b/internal/domain/session/repository_test.go @@ -0,0 +1,344 @@ +package session_test + +import ( + "context" + "testing" + "time" + + "github.com/jmoiron/sqlx" + "go.bigb.es/auxilia/culpa" + _ "modernc.org/sqlite" + + "sourcecraft.dev/bigbes/lethe/internal/config" + "sourcecraft.dev/bigbes/lethe/internal/domain/session" + "sourcecraft.dev/bigbes/lethe/internal/platform/database" +) + +// newTestDatabase builds a Database steward against :memory: (one DB per +// test, isolated). Cleanup runs Destroy. +func newTestDatabase(t *testing.T) *database.Database { + t.Helper() + d := &database.Database{ + Cfg: config.DatabaseConfig{ + Path: ":memory:", + BusyTimeout: 5 * time.Second, + }, + } + if err := d.Init(context.Background()); err != nil { + t.Fatalf("database.Init: %v", err) + } + t.Cleanup(func() { _ = d.Destroy(context.Background()) }) + return d +} + +// newRepo wires a Repository against a fresh in-memory database. +func newRepo(t *testing.T) (*session.Repository, *sqlx.DB) { + t.Helper() + d := newTestDatabase(t) + repo := &session.Repository{Database: d} + if err := repo.Init(context.Background()); err != nil { + t.Fatalf("repo.Init: %v", err) + } + return repo, d.DB +} + +// seedSession inserts a session row directly via SQL. The tests deliberately +// do not depend on internal/domain/ingest/ — the read-side package must be +// testable in isolation. +func seedSession(t *testing.T, db *sqlx.DB, owner, tool, host, sid string, startedAt, endedAt int64) { + t.Helper() + _, err := db.Exec(` + INSERT INTO sessions (owner, tool, host, session_id, started_at, ended_at, working_dir, source_file, metadata) + VALUES (?, ?, ?, ?, ?, ?, NULL, ?, NULL)`, + owner, tool, host, sid, startedAt, endedAt, "/tmp/x.jsonl", + ) + if err != nil { + t.Fatalf("seed session %s/%s/%s/%s: %v", owner, tool, host, sid, err) + } +} + +// seedTurn inserts a turn row directly via SQL. Optional columns are NULL. +func seedTurn(t *testing.T, db *sqlx.DB, owner, tool, host, sid, tid string, seq, ts int64, role, content string) { + t.Helper() + _, err := db.Exec(` + INSERT INTO turns (owner, tool, host, session_id, turn_id, seq, role, timestamp, content, + model, tokens_in, tokens_out, cost_usd, tool_calls, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, NULL)`, + owner, tool, host, sid, tid, seq, role, ts, content, + ) + if err != nil { + t.Fatalf("seed turn %s/%s: %v", sid, tid, err) + } +} + +func ptrString(v string) *string { return &v } + +func TestList_FilterByTool(t *testing.T) { + repo, db := newRepo(t) + seedSession(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010) + seedSession(t, db, "alice", "gemini", "phoebe", "s2", 1700000020, 1700000030) + + tool := "cc" + got, err := repo.List(context.Background(), session.ListFilter{ + Owner: session.OwnerScope{User: "alice"}, + Tool: &tool, + Limit: 50, + }) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(got) != 1 || got[0].SessionID != "s1" { + t.Fatalf("expected exactly s1; got %#v", got) + } +} + +func TestList_FilterByHost(t *testing.T) { + repo, db := newRepo(t) + seedSession(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010) + seedSession(t, db, "alice", "cc", "rhea", "s2", 1700000020, 1700000030) + + host := "rhea" + got, err := repo.List(context.Background(), session.ListFilter{ + Owner: session.OwnerScope{User: "alice"}, + Host: &host, + Limit: 50, + }) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(got) != 1 || got[0].SessionID != "s2" { + t.Fatalf("expected exactly s2; got %#v", got) + } +} + +func TestList_FilterByTimeRange(t *testing.T) { + repo, db := newRepo(t) + seedSession(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010) + seedSession(t, db, "alice", "cc", "phoebe", "s2", 1700000100, 1700000110) + seedSession(t, db, "alice", "cc", "phoebe", "s3", 1700000200, 1700000210) + + since := int64(1700000050) + until := int64(1700000150) + got, err := repo.List(context.Background(), session.ListFilter{ + Owner: session.OwnerScope{User: "alice"}, + Since: &since, + Until: &until, + Limit: 50, + }) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(got) != 1 || got[0].SessionID != "s2" { + t.Fatalf("expected exactly s2 in range; got %#v", got) + } +} + +func TestList_FilterCombined(t *testing.T) { + repo, db := newRepo(t) + seedSession(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010) + seedSession(t, db, "alice", "cc", "rhea", "s2", 1700000050, 1700000060) + seedSession(t, db, "alice", "gemini", "phoebe", "s3", 1700000070, 1700000080) + + tool := "cc" + host := "phoebe" + since := int64(1699999999) + got, err := repo.List(context.Background(), session.ListFilter{ + Owner: session.OwnerScope{User: "alice"}, + Tool: &tool, + Host: &host, + Since: &since, + Limit: 50, + }) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(got) != 1 || got[0].SessionID != "s1" { + t.Fatalf("expected only s1; got %#v", got) + } +} + +func TestList_OrderingByStartedAtDesc(t *testing.T) { + repo, db := newRepo(t) + seedSession(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010) + seedSession(t, db, "alice", "cc", "phoebe", "s2", 1700000200, 1700000210) + seedSession(t, db, "alice", "cc", "phoebe", "s3", 1700000100, 1700000110) + + got, err := repo.List(context.Background(), session.ListFilter{ + Owner: session.OwnerScope{User: "alice"}, + Limit: 50, + }) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(got) != 3 { + t.Fatalf("want 3 rows, got %d", len(got)) + } + if got[0].SessionID != "s2" || got[1].SessionID != "s3" || got[2].SessionID != "s1" { + t.Fatalf("ordering wrong: %s, %s, %s", got[0].SessionID, got[1].SessionID, got[2].SessionID) + } +} + +func TestList_PaginationLimitOffset(t *testing.T) { + repo, db := newRepo(t) + for i := 0; i < 5; i++ { + sid := "s" + string(rune('0'+i)) + seedSession(t, db, "alice", "cc", "phoebe", sid, int64(1700000000+i*100), int64(1700000010+i*100)) + } + + page1, err := repo.List(context.Background(), session.ListFilter{ + Owner: session.OwnerScope{User: "alice"}, + Limit: 2, + Offset: 0, + }) + if err != nil { + t.Fatalf("page1: %v", err) + } + if len(page1) != 2 { + t.Fatalf("page1 len=%d; want 2", len(page1)) + } + // Newest first: s4, s3 + if page1[0].SessionID != "s4" || page1[1].SessionID != "s3" { + t.Fatalf("page1 unexpected: %s, %s", page1[0].SessionID, page1[1].SessionID) + } + + page2, err := repo.List(context.Background(), session.ListFilter{ + Owner: session.OwnerScope{User: "alice"}, + Limit: 2, + Offset: 2, + }) + if err != nil { + t.Fatalf("page2: %v", err) + } + if len(page2) != 2 { + t.Fatalf("page2 len=%d; want 2", len(page2)) + } + if page2[0].SessionID != "s2" || page2[1].SessionID != "s1" { + t.Fatalf("page2 unexpected: %s, %s", page2[0].SessionID, page2[1].SessionID) + } +} + +func TestList_OwnerAllOwners(t *testing.T) { + repo, db := newRepo(t) + seedSession(t, db, "alice", "cc", "phoebe", "sA", 1700000000, 1700000010) + seedSession(t, db, "bob", "cc", "phoebe", "sB", 1700000100, 1700000110) + + got, err := repo.List(context.Background(), session.ListFilter{ + Owner: session.OwnerScope{User: "admin", AllOwners: true}, + Limit: 50, + }) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(got) != 2 { + t.Fatalf("want 2 rows across owners, got %d", len(got)) + } + owners := map[string]bool{got[0].Owner: true, got[1].Owner: true} + if !owners["alice"] || !owners["bob"] { + t.Fatalf("expected alice + bob; got %v", owners) + } +} + +func TestList_OwnerSpecific(t *testing.T) { + repo, db := newRepo(t) + seedSession(t, db, "alice", "cc", "phoebe", "sA", 1700000000, 1700000010) + seedSession(t, db, "bob", "cc", "phoebe", "sB", 1700000100, 1700000110) + + got, err := repo.List(context.Background(), session.ListFilter{ + Owner: session.OwnerScope{User: "admin", SpecificOwner: ptrString("bob")}, + Limit: 50, + }) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(got) != 1 || got[0].SessionID != "sB" || got[0].Owner != "bob" { + t.Fatalf("expected only bob's sB; got %#v", got) + } +} + +func TestList_OwnerUserOnly(t *testing.T) { + repo, db := newRepo(t) + seedSession(t, db, "alice", "cc", "phoebe", "sA", 1700000000, 1700000010) + seedSession(t, db, "bob", "cc", "phoebe", "sB", 1700000100, 1700000110) + + got, err := repo.List(context.Background(), session.ListFilter{ + Owner: session.OwnerScope{User: "alice"}, + Limit: 50, + }) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(got) != 1 || got[0].Owner != "alice" { + t.Fatalf("expected only alice's row; got %#v", got) + } +} + +func TestGet_OwnRow_Returns200WithTurnsInSeqOrder(t *testing.T) { + repo, db := newRepo(t) + seedSession(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000200) + seedTurn(t, db, "alice", "cc", "phoebe", "s1", "tC", 3, 1700000150, "user", "third") + seedTurn(t, db, "alice", "cc", "phoebe", "s1", "tA", 1, 1700000010, "user", "first") + seedTurn(t, db, "alice", "cc", "phoebe", "s1", "tB", 2, 1700000080, "assistant", "second") + + got, err := repo.Get(context.Background(), session.OwnerScope{User: "alice"}, "cc", "phoebe", "s1") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got == nil || got.SessionID != "s1" { + t.Fatalf("missing session; got %#v", got) + } + if len(got.Turns) != 3 { + t.Fatalf("want 3 turns, got %d", len(got.Turns)) + } + if got.Turns[0].TurnID != "tA" || got.Turns[1].TurnID != "tB" || got.Turns[2].TurnID != "tC" { + t.Fatalf("turns out of order: %s, %s, %s", + got.Turns[0].TurnID, got.Turns[1].TurnID, got.Turns[2].TurnID) + } +} + +func TestGet_OtherOwnersRow_ReturnsNotFound(t *testing.T) { + repo, db := newRepo(t) + seedSession(t, db, "bob", "cc", "phoebe", "sB", 1700000000, 1700000010) + + _, err := repo.Get(context.Background(), session.OwnerScope{User: "alice"}, "cc", "phoebe", "sB") + if err == nil { + t.Fatalf("expected NOT_FOUND, got nil") + } + if code := codeOf(err); code != "NOT_FOUND" { + t.Fatalf("expected code NOT_FOUND, got %q", code) + } +} + +func TestGet_AdminAllOwners_FetchesAnyOwner(t *testing.T) { + repo, db := newRepo(t) + seedSession(t, db, "bob", "cc", "phoebe", "sB", 1700000000, 1700000010) + seedTurn(t, db, "bob", "cc", "phoebe", "sB", "tA", 1, 1700000005, "user", "hi") + + got, err := repo.Get(context.Background(), + session.OwnerScope{User: "admin", AllOwners: true}, + "cc", "phoebe", "sB", + ) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got == nil || got.Owner != "bob" { + t.Fatalf("expected bob's session; got %#v", got) + } + if len(got.Turns) != 1 || got.Turns[0].Owner != "bob" { + t.Fatalf("expected 1 turn owned by bob; got %#v", got.Turns) + } +} + +// codeOf walks the culpa chain for a CodeDetail and returns the string code, +// or "" if there isn't one. Local helper so tests don't reach into apierror's +// unexported lookup. +func codeOf(err error) string { + var cd culpa.CodeDetail + if !culpa.FindDetail(err, &cd) { + return "" + } + s, ok := cd.Code.(string) + if !ok { + return "" + } + return s +}