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)
}
}
}