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