Files
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

4786 lines
132 KiB
Go

package server_test
import (
"bufio"
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"mime/multipart"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
stdlibsync "sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.kenn.io/agentsview/internal/config"
"go.kenn.io/agentsview/internal/db"
"go.kenn.io/agentsview/internal/dbtest"
"go.kenn.io/agentsview/internal/parser"
"go.kenn.io/agentsview/internal/server"
"go.kenn.io/agentsview/internal/service"
"go.kenn.io/agentsview/internal/sessionwatch"
"go.kenn.io/agentsview/internal/sync"
"go.kenn.io/agentsview/internal/testjsonl"
)
// Timestamp constants for test data.
const (
tsZero = "2024-01-01T00:00:00Z"
tsZeroS5 = "2024-01-01T00:00:05Z"
tsEarly = "2024-01-01T10:00:00Z"
tsEarlyS5 = "2024-01-01T10:00:05Z"
tsSeed = "2025-01-15T10:00:00Z"
tsSeedEnd = "2025-01-15T11:00:00Z"
)
// --- Test helpers ---
// testEnv sets up a server with a temporary database.
type testEnv struct {
srv *server.Server
handler http.Handler
db *db.DB
engine *sync.Engine
broadcaster *server.Broadcaster
claudeDir string
dataDir string
}
// setupOption customizes the config used by setup.
type setupOption func(*config.Config)
func withWriteTimeout(d time.Duration) setupOption {
return func(c *config.Config) { c.WriteTimeout = d }
}
type readOnlyTestStore struct {
db.Store
}
func (readOnlyTestStore) ReadOnly() bool { return true }
func withPublicOrigins(origins ...string) setupOption {
return func(c *config.Config) {
c.PublicOrigins = append([]string(nil), origins...)
}
}
func withPublicURL(url string) setupOption {
return func(c *config.Config) { c.PublicURL = url }
}
func setup(
t *testing.T,
opts ...setupOption,
) *testEnv {
return setupWithServerOpts(t, nil, opts...)
}
func setupWithServerOpts(
t *testing.T,
srvOpts []server.Option,
opts ...setupOption,
) *testEnv {
return setupWithServerOptsAndDBTemplate(t, srvOpts, nil, opts...)
}
func setupWithDBTemplate(
t *testing.T,
dbFiles map[string][]byte,
opts ...setupOption,
) *testEnv {
return setupWithServerOptsAndDBTemplate(t, nil, dbFiles, opts...)
}
func setupWithServerOptsAndDBTemplate(
t *testing.T,
srvOpts []server.Option,
dbFiles map[string][]byte,
opts ...setupOption,
) *testEnv {
t.Helper()
dir := tempDirWithRetryCleanup(t)
cfg := config.Config{
Host: "127.0.0.1",
Port: 0,
DataDir: dir,
DBPath: filepath.Join(dir, "test.db"),
WriteTimeout: 30 * time.Second,
}
for _, opt := range opts {
opt(&cfg)
}
if dbFiles != nil {
writeDBTemplateFiles(t, cfg.DBPath, dbFiles)
}
database := dbtest.OpenTestDBAt(t, cfg.DBPath)
claudeDir := filepath.Join(cfg.DataDir, "claude")
codexDir := filepath.Join(cfg.DataDir, "codex")
if err := os.MkdirAll(claudeDir, 0o755); err != nil {
t.Fatalf("creating claude dir: %v", err)
}
if err := os.MkdirAll(codexDir, 0o755); err != nil {
t.Fatalf("creating codex dir: %v", err)
}
// Disable coalescing in tests so emits fan out deterministically.
broadcaster := server.NewBroadcaster(0)
engineCfg := sync.EngineConfig{
AgentDirs: map[parser.AgentType][]string{
parser.AgentClaude: {claudeDir},
parser.AgentCodex: {codexDir},
},
Machine: "test",
Emitter: broadcaster,
}
engine := sync.NewEngine(database, engineCfg)
// Prepend so caller-provided srvOpts can still override.
srvOpts = append([]server.Option{server.WithBroadcaster(broadcaster)}, srvOpts...)
srv := server.New(cfg, database, engine, srvOpts...)
return &testEnv{
srv: srv,
handler: wrapTestHandler(cfg, srv.Handler()),
db: database,
engine: engine,
broadcaster: broadcaster,
claudeDir: claudeDir,
dataDir: dir,
}
}
func writeDBTemplateFiles(
t *testing.T,
dbPath string,
files map[string][]byte,
) {
t.Helper()
require.Contains(t, files, "", "db template is missing main file")
require.NoError(t, os.MkdirAll(filepath.Dir(dbPath), 0o755))
for _, suffix := range []string{"", "-wal", "-shm"} {
data, ok := files[suffix]
if !ok {
continue
}
require.NoError(t, os.WriteFile(dbPath+suffix, data, 0o600))
}
}
// wrapTestHandler wraps the server handler so test requests default
// to the configured Host and a loopback RemoteAddr, matching the test
// config (e.g. 127.0.0.1:0), and so mutating requests get an Origin
// matching that config. Individual tests can override by setting these
// on the request before calling te.srv.Handler() directly.
func wrapTestHandler(cfg config.Config, base http.Handler) http.Handler {
defaultHost := net.JoinHostPort(
cfg.Host, fmt.Sprintf("%d", cfg.Port),
)
defaultOrigin := fmt.Sprintf("http://%s", defaultHost)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Host == "example.com" || r.Host == "" {
r.Host = defaultHost
}
// httptest.NewRequest sets RemoteAddr to 192.0.2.1:1234
// (a non-routable test IP). Override to loopback so that
// auth middleware treats test requests as local.
if r.RemoteAddr == "192.0.2.1:1234" {
r.RemoteAddr = "127.0.0.1:1234"
}
// Auto-set Origin for mutating requests so tests
// don't need to set it manually on every inline
// httptest.NewRequest.
if r.Header.Get("Origin") == "" {
switch r.Method {
case http.MethodPost, http.MethodPut,
http.MethodPatch, http.MethodDelete:
r.Header.Set("Origin", defaultOrigin)
}
}
base.ServeHTTP(w, r)
})
}
// setupPGMode builds a testEnv with engine == nil and no
// broadcaster, mirroring the "pg serve" runtime mode where the
// server reads from PostgreSQL and does not run a local sync
// engine or live-refresh broadcaster.
func setupPGMode(t *testing.T) *testEnv {
t.Helper()
dir := tempDirWithRetryCleanup(t)
dbPath := filepath.Join(dir, "test.db")
database := dbtest.OpenTestDBAt(t, dbPath)
cfg := config.Config{
Host: "127.0.0.1",
Port: 0,
DataDir: dir,
DBPath: dbPath,
WriteTimeout: 30 * time.Second,
}
srv := server.New(cfg, readOnlyTestStore{Store: database}, nil)
return &testEnv{
srv: srv,
handler: wrapTestHandler(cfg, srv.Handler()),
db: database,
engine: nil,
broadcaster: nil,
dataDir: dir,
}
}
func setupNoSyncMode(t *testing.T) *testEnv {
t.Helper()
dir := tempDirWithRetryCleanup(t)
dbPath := filepath.Join(dir, "test.db")
database := dbtest.OpenTestDBAt(t, dbPath)
cfg := config.Config{
Host: "127.0.0.1",
Port: 0,
DataDir: dir,
DBPath: dbPath,
WriteTimeout: 30 * time.Second,
}
broadcaster := server.NewBroadcaster(0)
srv := server.New(
cfg, database, nil,
server.WithBroadcaster(broadcaster),
)
return &testEnv{
srv: srv,
handler: wrapTestHandler(cfg, srv.Handler()),
db: database,
engine: nil,
broadcaster: broadcaster,
dataDir: dir,
}
}
const hostMiddlewareProbePath = "/api/openapi"
func setupHostOnly(t *testing.T, opts ...setupOption) *testEnv {
t.Helper()
cfg := config.Config{
Host: "127.0.0.1",
Port: 0,
WriteTimeout: 30 * time.Second,
}
for _, opt := range opts {
opt(&cfg)
}
srv := server.New(cfg, readOnlyTestStore{}, nil)
return &testEnv{
srv: srv,
handler: wrapTestHandler(cfg, srv.Handler()),
}
}
func tempDirWithRetryCleanup(t *testing.T) string {
t.Helper()
return dbtest.MkdirTempWithCleanup(t, "agentsview-server-test-*")
}
func (te *testEnv) writeProjectFile(
t *testing.T, project, filename, content string,
) string {
t.Helper()
path := filepath.Join(te.claudeDir, project, filename)
dbtest.WriteTestFile(t, path, []byte(content))
return path
}
// writeSessionFile builds JSONL from a SessionBuilder and writes it
// as a project file, returning the file path.
func (te *testEnv) writeSessionFile(
t *testing.T,
project, filename string,
b *testjsonl.SessionBuilder,
) string {
t.Helper()
return te.writeProjectFile(t, project, filename, b.String())
}
func waitForPort(port int, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
addr := fmt.Sprintf("127.0.0.1:%d", port)
var lastDialErr error
for time.Now().Before(deadline) {
conn, err := net.DialTimeout(
"tcp", addr, 50*time.Millisecond,
)
if err == nil {
conn.Close()
return nil
}
lastDialErr = err
time.Sleep(10 * time.Millisecond)
}
return fmt.Errorf("server not ready: last dial error: %v", lastDialErr)
}
// firstNonLoopbackIP returns a host IP assigned to a non-loopback
// interface. The test is skipped when none is available.
func firstNonLoopbackIP(t *testing.T) string {
t.Helper()
ifaces, err := net.Interfaces()
if err != nil {
t.Skipf("listing interfaces: %v", err)
}
var firstV6 string
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 ||
iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
default:
continue
}
if ip == nil || ip.IsLoopback() {
continue
}
if ip4 := ip.To4(); ip4 != nil {
return ip4.String()
}
if firstV6 == "" {
firstV6 = ip.String()
}
}
}
if firstV6 != "" {
return firstV6
}
t.Skip("no non-loopback interface IP available")
return ""
}
func hostLiteral(host string) string {
if strings.Contains(host, ":") {
return "[" + host + "]"
}
return host
}
// listenAndServe starts the server on a real port and returns the
// base URL. The server is shut down when the test finishes.
func (te *testEnv) listenAndServe(t *testing.T) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
port := ln.Addr().(*net.TCPAddr).Port
te.srv.SetPort(port)
var serveErr error
done := make(chan struct{})
go func() {
serveErr = te.srv.Serve(ln)
close(done)
}()
// Wait for the port to accept connections.
if err := waitForPort(port, 2*time.Second); err != nil {
select {
case <-done:
t.Fatalf("server failed to start: %v", serveErr)
default:
}
t.Fatalf("server not ready after 2s: %v", err)
}
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(
context.Background(), 5*time.Second,
)
defer cancel()
if err := te.srv.Shutdown(ctx); err != nil &&
err != http.ErrServerClosed {
t.Errorf("server shutdown error: %v", err)
}
select {
case <-done:
if serveErr != nil &&
serveErr != http.ErrServerClosed {
t.Errorf(
"server exited with error: %v",
serveErr,
)
}
case <-time.After(5 * time.Second):
t.Error("timed out waiting for server goroutine")
}
})
return fmt.Sprintf("http://127.0.0.1:%d", port)
}
func TestListenAndServeUsesAvailablePortOutsideFixedRange(t *testing.T) {
listeners := make([]net.Listener, 0, 100)
for port := 40000; port < 40100; port++ {
addr := fmt.Sprintf("127.0.0.1:%d", port)
ln, err := net.Listen("tcp", addr)
if err == nil {
listeners = append(listeners, ln)
continue
}
conn, dialErr := net.DialTimeout(
"tcp", addr, 50*time.Millisecond,
)
if dialErr != nil {
for _, ln := range listeners {
require.NoError(t, ln.Close())
}
t.Skipf("port %d is neither bindable nor reachable: %v", port, err)
}
require.NoError(t, conn.Close())
}
t.Cleanup(func() {
for _, ln := range listeners {
require.NoError(t, ln.Close())
}
})
te := setup(t)
baseURL := te.listenAndServe(t)
parsedBaseURL, err := url.Parse(baseURL)
require.NoError(t, err)
_, portText, err := net.SplitHostPort(parsedBaseURL.Host)
require.NoError(t, err)
port, err := strconv.Atoi(portText)
require.NoError(t, err)
require.False(t, port >= 40000 && port < 40100)
resp, err := http.Get(baseURL + "/api/v1/version")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
func (te *testEnv) seedSession(
t *testing.T, id, project string, msgCount int,
opts ...func(*db.Session),
) {
t.Helper()
dbtest.SeedSession(t, te.db, id, project, func(s *db.Session) {
s.Machine = "test"
s.MessageCount = msgCount
s.UserMessageCount = max(msgCount, 2)
s.StartedAt = new(tsSeed)
s.EndedAt = new(tsSeedEnd)
s.FirstMessage = new("Hello world")
for _, opt := range opts {
opt(s)
}
})
}
func (te *testEnv) seedMessages(
t *testing.T, sessionID string, count int, mods ...func(i int, m *db.Message),
) {
t.Helper()
msgs := buildTestMessages(sessionID, count, mods...)
if err := te.db.ReplaceSessionMessages(
sessionID, msgs,
); err != nil {
t.Fatalf("seeding messages: %v", err)
}
}
func buildTestMessages(
sessionID string, count int, mods ...func(i int, m *db.Message),
) []db.Message {
msgs := make([]db.Message, count)
for i := range count {
role := "user"
if i%2 == 1 {
role = "assistant"
}
msgs[i] = db.Message{
SessionID: sessionID,
Ordinal: i,
Role: role,
Content: "Message " + string(rune('A'+i%26)),
Timestamp: tsSeed,
ContentLength: 10,
}
for _, mod := range mods {
mod(i, &msgs[i])
}
}
return msgs
}
// requireFTS skips the test when the database lacks FTS5 support.
func (te *testEnv) requireFTS(t *testing.T) {
t.Helper()
if !te.db.HasFTS() {
t.Skip("skipping search test: no FTS support")
}
}
func (te *testEnv) getWithContext(
t *testing.T, ctx context.Context, path string,
) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil).WithContext(ctx)
w := httptest.NewRecorder()
te.handler.ServeHTTP(w, req)
return w
}
func (te *testEnv) get(
t *testing.T, path string,
) *httptest.ResponseRecorder {
t.Helper()
return te.getWithContext(t, context.Background(), path)
}
func TestOpenAPIEndpointDocumentsExistingAPIRoutes(t *testing.T) {
te := setup(t)
w := te.get(t, "/api/openapi.json")
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
contentType := w.Header().Get("Content-Type")
assert.True(t,
strings.Contains(contentType, "application/json") ||
strings.Contains(contentType, "application/openapi+json"),
"Content-Type = %q", contentType)
var spec struct {
OpenAPI string `json:"openapi"`
Info struct {
Title string `json:"title"`
Version string `json:"version"`
} `json:"info"`
Paths map[string]map[string]any `json:"paths"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &spec))
require.Equal(t, "3.1.0", spec.OpenAPI)
assert.Equal(t, "AgentsView API", spec.Info.Title)
assert.NotEmpty(t, spec.Info.Version)
require.Contains(t, spec.Paths, "/api/v1/sessions")
assert.Contains(t, spec.Paths["/api/v1/sessions"], "get")
require.Contains(t, spec.Paths, "/api/v1/sessions/{id}")
assert.Contains(t, spec.Paths["/api/v1/sessions/{id}"], "get")
assert.Contains(t, spec.Paths["/api/v1/sessions/{id}"], "delete")
require.Contains(t, spec.Paths, "/api/v1/sessions/{id}/messages")
assert.Contains(t, spec.Paths["/api/v1/sessions/{id}/messages"], "get")
require.Contains(t, spec.Paths, "/api/v1/settings")
assert.Contains(t, spec.Paths["/api/v1/settings"], "put")
require.Contains(t, spec.Paths, "/api/v1/session-stats")
assert.Contains(t, spec.Paths["/api/v1/session-stats"], "get")
}
func TestOpenAPIEndpointKeepsUsageSummaryContract(t *testing.T) {
te := setup(t)
w := te.get(t, "/api/openapi.json")
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
type openAPISchema struct {
Ref string `json:"$ref"`
Items *openAPISchema `json:"items"`
Properties map[string]openAPISchema `json:"properties"`
}
type openAPIResponse struct {
Content map[string]struct {
Schema openAPISchema `json:"schema"`
} `json:"content"`
}
type openAPIOperation struct {
Responses map[string]openAPIResponse `json:"responses"`
}
var spec struct {
Paths map[string]map[string]openAPIOperation `json:"paths"`
Components struct {
Schemas map[string]openAPISchema `json:"schemas"`
} `json:"components"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &spec))
op := spec.Paths["/api/v1/usage/summary"]["get"]
response := op.Responses["200"]
jsonContent, ok := response.Content["application/json"]
require.True(t, ok, "usage summary 200 response missing application/json")
assert.Equal(t,
"#/components/schemas/UsageSummaryResponse",
jsonContent.Schema.Ref)
schema, ok := spec.Components.Schemas["UsageSummaryResponse"]
require.True(t, ok, "UsageSummaryResponse schema missing")
assert.Contains(t, schema.Properties, "comparison")
require.Contains(t, schema.Properties, "projectTotals")
require.NotNil(t, schema.Properties["projectTotals"].Items)
assert.Equal(t,
"#/components/schemas/ProjectTotal",
schema.Properties["projectTotals"].Items.Ref)
require.Contains(t, schema.Properties, "modelTotals")
require.NotNil(t, schema.Properties["modelTotals"].Items)
assert.Equal(t,
"#/components/schemas/ModelTotal",
schema.Properties["modelTotals"].Items.Ref)
require.Contains(t, schema.Properties, "agentTotals")
require.NotNil(t, schema.Properties["agentTotals"].Items)
assert.Equal(t,
"#/components/schemas/AgentTotal",
schema.Properties["agentTotals"].Items.Ref)
require.Contains(t, schema.Properties, "cacheStats")
assert.Equal(t,
"#/components/schemas/CacheStats",
schema.Properties["cacheStats"].Ref)
}
func TestOpenAPIEndpointDocumentsEnumsAndRequestBodies(t *testing.T) {
te := setup(t)
w := te.get(t, "/api/openapi.json")
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
type openAPISchema struct {
Ref string `json:"$ref"`
Enum []string `json:"enum"`
Properties map[string]openAPISchema `json:"properties"`
}
type openAPIParameter struct {
Name string `json:"name"`
In string `json:"in"`
Schema openAPISchema `json:"schema"`
}
type openAPIRequestBody struct {
Content map[string]struct {
Schema openAPISchema `json:"schema"`
} `json:"content"`
}
type openAPIOperation struct {
Parameters []openAPIParameter `json:"parameters"`
RequestBody *openAPIRequestBody `json:"requestBody"`
}
var spec struct {
Paths map[string]map[string]openAPIOperation `json:"paths"`
Components struct {
Schemas map[string]openAPISchema `json:"schemas"`
} `json:"components"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &spec))
resolveSchema := func(schema openAPISchema) openAPISchema {
if schema.Ref == "" {
return schema
}
const prefix = "#/components/schemas/"
require.True(t,
strings.HasPrefix(schema.Ref, prefix),
"unsupported schema ref %q", schema.Ref)
name := strings.TrimPrefix(schema.Ref, prefix)
resolved, ok := spec.Components.Schemas[name]
require.True(t, ok, "schema ref %q missing target", schema.Ref)
return resolved
}
for _, tt := range []struct {
path string
method string
name string
want []string
}{
{
path: "/api/v1/sessions/{id}/messages",
method: "get",
name: "direction",
want: []string{"asc", "desc"},
},
{
path: "/api/v1/search",
method: "get",
name: "sort",
want: []string{"relevance", "recency"},
},
{
path: "/api/v1/search/content",
method: "get",
name: "mode",
want: []string{"substring", "regex", "fts", "semantic", "hybrid"},
},
{
path: "/api/v1/search/content",
method: "get",
name: "scope",
want: []string{"top", "all", "subordinate"},
},
{
path: "/api/v1/sessions/{id}/md",
method: "get",
name: "depth",
want: []string{"1", "all"},
},
{
path: "/api/v1/analytics/activity",
method: "get",
name: "granularity",
want: []string{"day", "week", "month"},
},
{
path: "/api/v1/analytics/heatmap",
method: "get",
name: "metric",
want: []string{"messages", "sessions", "output_tokens"},
},
} {
pathItem, ok := spec.Paths[tt.path]
require.True(t, ok, "spec missing path %s", tt.path)
op, ok := pathItem[tt.method]
require.True(t, ok, "spec missing operation %s %s", tt.method, tt.path)
var got []string
for _, param := range op.Parameters {
if param.Name == tt.name && param.In == "query" {
got = param.Schema.Enum
break
}
}
require.NotNil(t, got,
"%s %s missing query parameter %q", tt.method, tt.path, tt.name)
assert.Equal(t, tt.want, got)
}
for _, tt := range []struct {
path string
method string
property string
}{
{
path: "/api/v1/sessions/{id}/rename",
method: "patch",
property: "display_name",
},
{
path: "/api/v1/settings/worktree-mappings",
method: "post",
property: "path_prefix",
},
{
path: "/api/v1/config/github",
method: "post",
property: "token",
},
} {
pathItem, ok := spec.Paths[tt.path]
require.True(t, ok, "spec missing path %s", tt.path)
op, ok := pathItem[tt.method]
require.True(t, ok, "spec missing operation %s %s", tt.method, tt.path)
require.NotNil(t, op.RequestBody,
"%s %s missing requestBody", tt.method, tt.path)
jsonContent, ok := op.RequestBody.Content["application/json"]
require.True(t, ok,
"%s %s missing application/json body", tt.method, tt.path)
schema := resolveSchema(jsonContent.Schema)
require.Contains(t, schema.Properties, tt.property)
}
pathItem, ok := spec.Paths["/api/v1/config/terminal"]
require.True(t, ok, "spec missing path /api/v1/config/terminal")
op, ok := pathItem["post"]
require.True(t, ok, "spec missing operation post /api/v1/config/terminal")
require.NotNil(t, op.RequestBody,
"post /api/v1/config/terminal missing requestBody")
jsonContent, ok := op.RequestBody.Content["application/json"]
require.True(t, ok,
"post /api/v1/config/terminal missing application/json body")
schema := resolveSchema(jsonContent.Schema)
mode, ok := schema.Properties["mode"]
require.True(t, ok, "post /api/v1/config/terminal missing mode property")
mode = resolveSchema(mode)
assert.Equal(t, []string{"auto", "custom", "clipboard"}, mode.Enum)
}
func TestSearchContentSemanticGETRequiresIntentHeader(t *testing.T) {
te := setup(t)
te.db.SetVectorSearcher(fakeTransientVectorSearcher{})
w := te.wrappedRequest(http.MethodGet, "/api/v1/search/content?pattern=fox&mode=semantic",
withOrigin("http://evil-site.com"))
assertStatus(t, w, http.StatusForbidden)
assert.Contains(t, w.Body.String(), "X-AgentsView-Search-Intent")
}
func TestSearchContentSemanticGETWithIntentHeaderReachesSearcher(t *testing.T) {
te := setup(t)
te.db.SetVectorSearcher(fakeTransientVectorSearcher{})
w := te.get(t, "/api/v1/search/content?pattern=fox&mode=semantic")
assertStatus(t, w, http.StatusForbidden)
w = te.wrappedRequest(http.MethodGet, "/api/v1/search/content?pattern=fox&mode=semantic",
withHeader("X-AgentsView-Search-Intent", "semantic"))
assertStatus(t, w, http.StatusServiceUnavailable)
}
// TestSearchContentSemanticModeUnavailable pins the end-to-end capability
// gate: a test server has no VectorSearcher wired in (db.HasSemantic is
// false), so a semantic or hybrid content search must respond 501 rather
// than 500 or a silently-empty page.
func TestSearchContentSemanticModeUnavailable(t *testing.T) {
te := setup(t)
for _, mode := range []string{"semantic", "hybrid"} {
w := te.wrappedRequest(http.MethodGet, "/api/v1/search/content?pattern=fox&mode="+mode,
withHeader("X-AgentsView-Search-Intent", "semantic"))
assertStatus(t, w, http.StatusNotImplemented)
}
}
// fakeTransientVectorSearcher implements db.VectorSearcher, always failing
// with an error wrapping db.ErrSemanticTransient — standing in for what
// cmd/agentsview's searcherAdapter returns when the embeddings endpoint
// itself is unreachable at query time (translateSearchError wraps a
// vector.QueryEncodeError this way).
type fakeTransientVectorSearcher struct{}
func (fakeTransientVectorSearcher) SemanticSearch(
_ context.Context, _ string, _ int,
) ([]db.VectorHit, error) {
return nil, fmt.Errorf("%w: dial tcp: connection refused", db.ErrSemanticTransient)
}
func (fakeTransientVectorSearcher) ResolveMessageUnits(
_ context.Context, refs []db.MessageRef,
) ([]db.UnitRef, error) {
return make([]db.UnitRef, len(refs)), nil
}
// TestSearchContentSemanticQueryEncodeFailureReturns503 covers the
// query-time embeddings-endpoint-down case: it must map to 503 (the
// feature is configured and the request can be retried), not 501 (which
// would read as "semantic search is disabled") or a bare 500.
func TestSearchContentSemanticQueryEncodeFailureReturns503(t *testing.T) {
te := setup(t)
te.db.SetVectorSearcher(fakeTransientVectorSearcher{})
w := te.wrappedRequest(http.MethodGet, "/api/v1/search/content?pattern=fox&mode=semantic",
withHeader("X-AgentsView-Search-Intent", "semantic"))
assertStatus(t, w, http.StatusServiceUnavailable)
}
// TestOpenAPIEndpointDocumentsBatchDeleteSessionIDsAsNonNullableArray guards
// the schema huma emits for the batch-delete request body: session_ids must
// serialize as a plain, non-nullable string array (schema type "array"), not
// the OpenAPI 3.1 nullable union ["array", "null"] huma's DefaultArrayNullable
// otherwise applies to every slice field. Losing that keeps the generated
// TypeScript client's session_ids typed as Array<string> rather than
// loosening to any[] | null.
func TestOpenAPIEndpointDocumentsBatchDeleteSessionIDsAsNonNullableArray(t *testing.T) {
te := setup(t)
w := te.get(t, "/api/openapi.json")
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
var spec struct {
Components struct {
Schemas map[string]struct {
Required []string `json:"required"`
Properties map[string]struct {
Type json.RawMessage `json:"type"`
} `json:"properties"`
} `json:"schemas"`
} `json:"components"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &spec))
schema, ok := spec.Components.Schemas["BatchDeleteInputBody"]
require.True(t, ok, "spec missing BatchDeleteInputBody schema")
assert.Contains(t, schema.Required, "session_ids")
prop, ok := schema.Properties["session_ids"]
require.True(t, ok, "session_ids property missing from BatchDeleteInputBody schema")
assert.JSONEq(t, `"array"`, string(prop.Type),
`session_ids must be a non-nullable array, not the nullable ["array","null"] union`)
}
func TestOpenAPIEndpointDocumentsOptionalProjectIdentity(t *testing.T) {
te := setup(t)
w := te.get(t, "/api/openapi.json")
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
var spec struct {
Components struct {
Schemas map[string]struct {
Required []string `json:"required"`
Properties map[string]struct {
Ref string `json:"$ref"`
} `json:"properties"`
} `json:"schemas"`
} `json:"components"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &spec))
schema, ok := spec.Components.Schemas["ExportProjectMapEntry"]
require.True(t, ok, "spec missing ExportProjectMapEntry schema")
assert.NotContains(t, schema.Required, "identity")
identity, ok := schema.Properties["identity"]
require.True(t, ok, "identity property missing from ExportProjectMapEntry")
assert.Equal(t, "#/components/schemas/ExportProjectIdentity", identity.Ref)
}
func TestOpenAPIEndpointDocumentsQualitySignalResponses(t *testing.T) {
te := setup(t)
w := te.get(t, "/api/openapi.json")
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
type openAPISchema struct {
Ref string `json:"$ref"`
Type any `json:"type"`
Items *openAPISchema `json:"items"`
Properties map[string]openAPISchema `json:"properties"`
}
var spec struct {
Components struct {
Schemas map[string]openAPISchema `json:"schemas"`
} `json:"components"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &spec))
for _, schemaName := range []string{"DbSession", "ServiceSessionDetail"} {
schema, ok := spec.Components.Schemas[schemaName]
require.True(t, ok, "schema %s missing", schemaName)
require.Contains(t, schema.Properties, "quality_signals",
"schema %s should expose runtime quality_signals", schemaName)
assert.Equal(t,
"#/components/schemas/DbQualitySignals",
schema.Properties["quality_signals"].Ref,
"schema %s quality_signals ref", schemaName)
}
response, ok := spec.Components.Schemas["DbSignalSessionsResponse"]
require.True(t, ok, "schema DbSignalSessionsResponse missing")
sessions, ok := response.Properties["sessions"]
require.True(t, ok, "DbSignalSessionsResponse.sessions missing")
assert.Equal(t, "array", sessions.Type,
"sessions should be a non-null array so the generated client keeps item type")
require.NotNil(t, sessions.Items, "sessions.items missing")
assert.Equal(t,
"#/components/schemas/DbSignalSessionExample",
sessions.Items.Ref,
"sessions item schema")
}
// TestOpenAPIEndpointDocumentsDecodeConfidence guards that the derived
// decode_confidence marker is a reflected field on the session-detail response
// schema, so Huma/OpenAPI and the generated TypeScript client document and type
// it. It is a detail-only derive-on-read field with no persisted column, so it
// must appear on ServiceSessionDetail but not on the plain DbSession schema.
func TestOpenAPIEndpointDocumentsDecodeConfidence(t *testing.T) {
te := setup(t)
w := te.get(t, "/api/openapi.json")
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
type openAPISchema struct {
Type any `json:"type"`
Properties map[string]openAPISchema `json:"properties"`
}
var spec struct {
Components struct {
Schemas map[string]openAPISchema `json:"schemas"`
} `json:"components"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &spec))
detail, ok := spec.Components.Schemas["ServiceSessionDetail"]
require.True(t, ok, "schema ServiceSessionDetail missing")
prop, ok := detail.Properties["decode_confidence"]
require.True(t, ok,
"ServiceSessionDetail should document the derived decode_confidence field")
assert.Equal(t, "string", prop.Type,
"decode_confidence should be typed as a string")
dbSession, ok := spec.Components.Schemas["DbSession"]
require.True(t, ok, "schema DbSession missing")
_, present := dbSession.Properties["decode_confidence"]
assert.False(t, present,
"decode_confidence is detail-only and must not leak into DbSession")
}
func TestOpenAPIEndpointDocumentsImportResponseContentTypes(t *testing.T) {
te := setup(t)
w := te.get(t, "/api/openapi.json")
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
type openAPIResponse struct {
Content map[string]struct{} `json:"content"`
}
type openAPIOperation struct {
Responses map[string]openAPIResponse `json:"responses"`
}
var spec struct {
Paths map[string]map[string]openAPIOperation `json:"paths"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &spec))
for _, path := range []string{
"/api/v1/import/claude-ai",
"/api/v1/import/chatgpt",
} {
pathItem, ok := spec.Paths[path]
require.True(t, ok, "spec missing path %s", path)
op, ok := pathItem["post"]
require.True(t, ok, "spec missing operation post %s", path)
response, ok := op.Responses["200"]
require.True(t, ok, "post %s missing 200 response", path)
require.Contains(t, response.Content, "text/event-stream")
require.Contains(t, response.Content, "application/json")
}
}
func (te *testEnv) post(
t *testing.T, path string, body string,
) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path,
strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Origin", "http://127.0.0.1:0")
w := httptest.NewRecorder()
te.handler.ServeHTTP(w, req)
return w
}
func (te *testEnv) del(
t *testing.T, path string,
) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodDelete, path, nil)
req.Header.Set("Origin", "http://127.0.0.1:0")
w := httptest.NewRecorder()
te.handler.ServeHTTP(w, req)
return w
}
// requestOpt customizes a request built by rawRequest/wrappedRequest.
type requestOpt func(*http.Request)
func withOrigin(origin string) requestOpt {
return func(r *http.Request) { r.Header.Set("Origin", origin) }
}
func withHost(host string) requestOpt {
return func(r *http.Request) { r.Host = host }
}
func withRemoteAddr(addr string) requestOpt {
return func(r *http.Request) { r.RemoteAddr = addr }
}
func withHeader(name, value string) requestOpt {
return func(r *http.Request) { r.Header.Set(name, value) }
}
func withBearer(token string) requestOpt {
return func(r *http.Request) {
r.Header.Set("Authorization", "Bearer "+token)
}
}
// rawRequest serves a request through srv.Handler() directly, bypassing
// the test wrapper that defaults Host/Origin/RemoteAddr. Use it for
// host-check, CORS, and auth tests that must control those values.
func (te *testEnv) rawRequest(
method, path string, opts ...requestOpt,
) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, nil)
for _, opt := range opts {
opt(req)
}
w := httptest.NewRecorder()
te.srv.Handler().ServeHTTP(w, req)
return w
}
// wrappedRequest serves a request through the wrapped test handler,
// which defaults Host/Origin/RemoteAddr to the test config.
func (te *testEnv) wrappedRequest(
method, path string, opts ...requestOpt,
) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, nil)
for _, opt := range opts {
opt(req)
}
w := httptest.NewRecorder()
te.handler.ServeHTTP(w, req)
return w
}
// uploadFile creates a multipart upload request.
func (te *testEnv) upload(
t *testing.T, filename, content, query string,
) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, err := mw.CreateFormFile("file", filename)
if err != nil {
t.Fatalf("creating form file: %v", err)
}
if _, err := fw.Write([]byte(content)); err != nil {
t.Fatalf("writing form file: %v", err)
}
if err := mw.Close(); err != nil {
t.Fatalf("closing multipart writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost,
"/api/v1/sessions/upload?"+query, &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Origin", "http://127.0.0.1:0")
w := httptest.NewRecorder()
te.handler.ServeHTTP(w, req)
return w
}
// decode unmarshals the response body into a typed struct.
func decode[T any](
t *testing.T, w *httptest.ResponseRecorder,
) T {
t.Helper()
var result T
if err := json.Unmarshal(
w.Body.Bytes(), &result,
); err != nil {
t.Fatalf("decoding JSON: %v\nbody: %s",
err, w.Body.String())
}
return result
}
func sidebarIndexRowsByID(
sessions []db.SidebarSessionIndexRow,
) map[string]db.SidebarSessionIndexRow {
rows := make(map[string]db.SidebarSessionIndexRow, len(sessions))
for _, s := range sessions {
rows[s.ID] = s
}
return rows
}
func sidebarIndexRowIDs(
sessions []db.SidebarSessionIndexRow,
) []string {
ids := make([]string, 0, len(sessions))
for _, s := range sessions {
ids = append(ids, s.ID)
}
return ids
}
func assertStatus(
t *testing.T, w *httptest.ResponseRecorder, code int,
) {
t.Helper()
if w.Code != code {
t.Fatalf("expected status %d, got %d: %s",
code, w.Code, w.Body.String())
}
}
func assertBodyContains(
t *testing.T, w *httptest.ResponseRecorder, substr string,
) {
t.Helper()
if !strings.Contains(w.Body.String(), substr) {
t.Errorf("body %q does not contain %q",
w.Body.String(), substr)
}
}
// assertErrorResponse checks that the response body is a JSON
// object with an "error" field matching wantMsg.
func assertErrorResponse(
t *testing.T, w *httptest.ResponseRecorder,
wantMsg string,
) {
t.Helper()
resp := decode[map[string]string](t, w)
if got := resp["error"]; got != wantMsg {
t.Errorf("error = %q, want %q", got, wantMsg)
}
}
// assertTimeoutRace validates a timeout response where either
// the middleware (503 "request timed out") or the handler
// (504 "gateway timeout") may win the race. Checks status,
// Content-Type, and error body.
func assertTimeoutRace(
t *testing.T, w *httptest.ResponseRecorder,
) {
t.Helper()
code := w.Code
ct := w.Header().Get("Content-Type")
if ct != "application/json" {
t.Errorf(
"Content-Type = %q, want application/json", ct,
)
}
switch code {
case http.StatusServiceUnavailable:
assertBodyContains(t, w, "request timed out")
case http.StatusGatewayTimeout:
assertBodyContains(t, w, "gateway timeout")
default:
t.Fatalf(
"expected 503 or 504, got %d: %s",
code, w.Body.String(),
)
}
}
// expiredContext returns a context with a deadline in the past.
func expiredContext(
t *testing.T,
) (context.Context, context.CancelFunc) {
t.Helper()
return context.WithDeadline(
context.Background(), time.Now().Add(-1*time.Hour),
)
}
type SSEEvent struct {
Event string
Data string
}
func parseSSE(body string) []SSEEvent {
var events []SSEEvent
scanner := bufio.NewScanner(strings.NewReader(body))
var currentEvent SSEEvent
hasData := false
for scanner.Scan() {
line := scanner.Text()
if ev, ok := strings.CutPrefix(line, "event: "); ok {
currentEvent.Event = ev
} else if data, ok := strings.CutPrefix(line, "data: "); ok {
if hasData {
currentEvent.Data += "\n" + data
} else {
currentEvent.Data = data
hasData = true
}
} else if line == "" {
if currentEvent.Event != "" || hasData {
events = append(events, currentEvent)
currentEvent = SSEEvent{}
hasData = false
}
} else if hasData {
currentEvent.Data += "\n" + line
}
}
if currentEvent.Event != "" || hasData {
events = append(events, currentEvent)
}
return events
}
func TestHumaScanSecretsEmitsSummaryEvent(t *testing.T) {
t.Parallel()
te := setup(t)
w := te.post(t, "/api/v1/secrets/scan", "")
assert.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
events := parseSSE(w.Body.String())
assert.NotEmpty(t, events)
assert.Equal(t, "summary", events[len(events)-1].Event)
}
func (te *testEnv) waitForSSEEvent(t *testing.T, w *flushRecorder, expectedEvent string, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for {
if hasSSEEvent(w, expectedEvent) {
return
}
remaining := time.Until(deadline)
if remaining <= 0 {
break
}
select {
case <-ticker.C:
case <-time.After(remaining):
}
}
t.Fatalf("timed out waiting for event: %s, got: %s", expectedEvent, w.BodyString())
}
func hasSSEEvent(w *flushRecorder, expectedEvent string) bool {
events := parseSSE(w.BodyString())
for _, e := range events {
if e.Event == expectedEvent {
return true
}
}
return false
}
func (te *testEnv) emitUntilSSEEvent(
t *testing.T,
w *flushRecorder,
scope string,
expectedEvent string,
timeout time.Duration,
) {
t.Helper()
deadline := time.Now().Add(timeout)
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for {
te.broadcaster.Emit(scope)
if hasSSEEvent(w, expectedEvent) {
return
}
remaining := time.Until(deadline)
if remaining <= 0 {
break
}
select {
case <-ticker.C:
case <-time.After(remaining):
}
}
t.Fatalf("timed out waiting for event: %s, got: %s", expectedEvent, w.BodyString())
}
// --- Typed response structs for JSON decoding ---
type sessionListResponse struct {
Sessions []db.Session `json:"sessions"`
Total int `json:"total"`
}
type messageListResponse struct {
Messages []db.Message `json:"messages"`
Count int `json:"count"`
}
type searchResponse struct {
Query string `json:"query"`
Results []db.SearchResult `json:"results"`
Count int `json:"count"`
}
type projectListResponse struct {
Projects []db.ProjectInfo `json:"projects"`
}
type syncStatusResponse struct {
LastSync string `json:"last_sync"`
Progress *sync.Progress `json:"progress"`
}
type githubConfigResponse struct {
Configured bool `json:"configured"`
}
type settingsConfigResponse struct {
GithubConfigured bool `json:"github_configured"`
}
type uploadResponse struct {
SessionID string `json:"session_id"`
Project string `json:"project"`
Machine string `json:"machine"`
Messages int `json:"messages"`
}
type machineListResponse struct {
Machines []string `json:"machines"`
}
type syncResultResponse struct {
TotalSessions int `json:"total_sessions"`
Synced int `json:"synced"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
Aborted bool `json:"aborted,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}
// --- Tests ---
func TestListSessions_Empty(t *testing.T) {
te := setup(t)
w := te.get(t, "/api/v1/sessions")
assertStatus(t, w, http.StatusOK)
// Verify raw JSON has "sessions":[] not "sessions":null.
var raw struct {
Sessions json.RawMessage `json:"sessions"`
}
if err := json.Unmarshal(
w.Body.Bytes(), &raw,
); err != nil {
t.Fatalf("unmarshaling raw response: %v", err)
}
if got := strings.TrimSpace(string(raw.Sessions)); got != "[]" {
t.Fatalf(
"expected sessions to be [], got: %s", got,
)
}
resp := decode[sessionListResponse](t, w)
if len(resp.Sessions) != 0 {
t.Fatalf("expected 0 sessions, got %d",
len(resp.Sessions))
}
}
func TestListSessions_WithData(t *testing.T) {
te := setup(t)
te.seedSession(t, "s1", "my-app", 5)
te.seedSession(t, "s2", "my-app", 3)
te.seedSession(t, "s3", "other-app", 1)
w := te.get(t, "/api/v1/sessions")
assertStatus(t, w, http.StatusOK)
resp := decode[sessionListResponse](t, w)
if len(resp.Sessions) != 3 {
t.Fatalf("expected 3 sessions, got %d",
len(resp.Sessions))
}
}
func TestListSessions_ProjectFilter(t *testing.T) {
te := setup(t)
te.seedSession(t, "s1", "my-app", 5)
te.seedSession(t, "s2", "other-app", 3)
w := te.get(t, "/api/v1/sessions?project=my-app")
assertStatus(t, w, http.StatusOK)
resp := decode[sessionListResponse](t, w)
if len(resp.Sessions) != 1 {
t.Fatalf("expected 1 session, got %d",
len(resp.Sessions))
}
}
func TestListSessions_ExcludeProjectFilter(t *testing.T) {
te := setup(t)
te.seedSession(t, "s1", "my-app", 5)
te.seedSession(t, "s2", "unknown", 3)
te.seedSession(t, "s3", "unknown", 7)
w := te.get(t,
"/api/v1/sessions?exclude_project=unknown",
)
assertStatus(t, w, http.StatusOK)
resp := decode[sessionListResponse](t, w)
if len(resp.Sessions) != 1 {
t.Fatalf("expected 1 session, got %d",
len(resp.Sessions))
}
if resp.Sessions[0].ID != "s1" {
t.Errorf("expected session s1, got %s",
resp.Sessions[0].ID)
}
}
func TestListSessions_ExcludeOneShotDefault(t *testing.T) {
te := setup(t)
te.seedSession(t, "s1", "my-app", 5, func(s *db.Session) {
s.UserMessageCount = 1
})
te.seedSession(t, "s2", "my-app", 10, func(s *db.Session) {
s.UserMessageCount = 5
})
te.seedSession(t, "s3", "my-app", 3, func(s *db.Session) {
s.UserMessageCount = 0
})
// Default: exclude one-shot sessions.
w := te.get(t, "/api/v1/sessions")
assertStatus(t, w, http.StatusOK)
resp := decode[sessionListResponse](t, w)
if len(resp.Sessions) != 1 {
t.Fatalf("default: expected 1 session, got %d",
len(resp.Sessions))
}
if resp.Sessions[0].ID != "s2" {
t.Errorf("default: expected s2, got %s",
resp.Sessions[0].ID)
}
// Explicit include_one_shot=true: include all.
w = te.get(t,
"/api/v1/sessions?include_one_shot=true",
)
assertStatus(t, w, http.StatusOK)
resp = decode[sessionListResponse](t, w)
if len(resp.Sessions) != 3 {
t.Fatalf("include: expected 3 sessions, got %d",
len(resp.Sessions))
}
}
func TestSidebarIndexReturnsSkinnyRows(t *testing.T) {
te := setup(t)
sessionName := "Important investigation"
te.seedSession(t, "named", "my-app", 5, func(s *db.Session) {
s.SessionName = &sessionName
s.UserMessageCount = 2
})
teammateFirstMessage := "<teammate-message from=\"codex\">review"
te.seedSession(t, "teammate", "my-app", 5, func(s *db.Session) {
s.FirstMessage = &teammateFirstMessage
s.UserMessageCount = 2
})
te.seedSession(t, "review", "my-app", 3, func(s *db.Session) {
fm := "You are a code reviewer. Review the code."
s.FirstMessage = &fm
s.UserMessageCount = 1
})
w := te.get(t, "/api/v1/sessions/sidebar-index")
assertStatus(t, w, http.StatusOK)
resp := decode[db.SidebarSessionIndex](t, w)
rows := sidebarIndexRowsByID(resp.Sessions)
if got := rows["named"].DisplayName; got == nil || *got != sessionName {
t.Fatalf("display_name = %v, want %q", got, sessionName)
}
if !rows["teammate"].IsTeammate {
t.Fatal("teammate is_teammate = false, want true")
}
if _, ok := rows["review"]; ok {
t.Fatal("automated review row returned without include_automated=true")
}
w = te.get(t, "/api/v1/sessions/sidebar-index?include_automated=true")
assertStatus(t, w, http.StatusOK)
resp = decode[db.SidebarSessionIndex](t, w)
rows = sidebarIndexRowsByID(resp.Sessions)
if _, ok := rows["review"]; !ok {
t.Fatal("automated review row missing with include_automated=true")
}
}
func TestSidebarIndexPaginatesRootCompleteTrees(t *testing.T) {
te := setup(t)
rootNewEnd := "2024-01-04T00:00:00Z"
childNewEnd := "2024-01-08T00:00:00Z"
rootOldEnd := "2024-01-01T00:00:00Z"
childOldEnd := "2024-01-07T00:00:00Z"
te.seedSession(t, "root-new", "my-app", 5, func(s *db.Session) {
s.EndedAt = &rootNewEnd
})
te.seedSession(t, "child-new", "my-app", 3, func(s *db.Session) {
s.ParentSessionID = new("root-new")
s.RelationshipType = "subagent"
s.EndedAt = &childNewEnd
})
te.seedSession(t, "root-old", "my-app", 5, func(s *db.Session) {
s.EndedAt = &rootOldEnd
})
te.seedSession(t, "child-old", "my-app", 3, func(s *db.Session) {
s.ParentSessionID = new("root-old")
s.RelationshipType = "subagent"
s.EndedAt = &childOldEnd
})
type sidebarPage struct {
Sessions []db.SidebarSessionIndexRow `json:"sessions"`
NextCursor string `json:"next_cursor,omitempty"`
Total int `json:"total"`
}
w := te.get(t, "/api/v1/sessions/sidebar-index?limit=1")
assertStatus(t, w, http.StatusOK)
first := decode[sidebarPage](t, w)
firstIDs := sidebarIndexRowIDs(first.Sessions)
assert.Equal(t, 2, first.Total)
assert.NotEmpty(t, first.NextCursor)
assert.ElementsMatch(t, []string{"root-new", "child-new"}, firstIDs)
assert.NotContains(t, firstIDs, "root-old")
assert.NotContains(t, firstIDs, "child-old")
w = te.get(t,
"/api/v1/sessions/sidebar-index?limit=1&cursor="+
url.QueryEscape(first.NextCursor),
)
assertStatus(t, w, http.StatusOK)
second := decode[sidebarPage](t, w)
secondIDs := sidebarIndexRowIDs(second.Sessions)
assert.Equal(t, 2, second.Total)
assert.Empty(t, second.NextCursor)
assert.ElementsMatch(t, []string{"root-old", "child-old"}, secondIDs)
}
func TestSidebarIndexPaginationTreatsContinuationsAsDescendants(t *testing.T) {
te := setup(t)
rootEnd := "2024-01-01T00:00:00Z"
continuationEnd := "2024-01-10T00:00:00Z"
otherEnd := "2024-01-05T00:00:00Z"
te.seedSession(t, "root", "my-app", 5, func(s *db.Session) {
s.EndedAt = &rootEnd
})
te.seedSession(t, "continuation", "my-app", 3, func(s *db.Session) {
s.ParentSessionID = new("root")
s.RelationshipType = "continuation"
s.EndedAt = &continuationEnd
})
te.seedSession(t, "other", "my-app", 5, func(s *db.Session) {
s.EndedAt = &otherEnd
})
w := te.get(t, "/api/v1/sessions/sidebar-index?limit=1")
assertStatus(t, w, http.StatusOK)
first := decode[db.SidebarSessionIndex](t, w)
firstIDs := sidebarIndexRowIDs(first.Sessions)
assert.Equal(t, 2, first.Total)
assert.NotEmpty(t, first.NextCursor)
assert.ElementsMatch(t, []string{"root", "continuation"}, firstIDs)
w = te.get(t,
"/api/v1/sessions/sidebar-index?limit=1&cursor="+
url.QueryEscape(first.NextCursor),
)
assertStatus(t, w, http.StatusOK)
second := decode[db.SidebarSessionIndex](t, w)
secondIDs := sidebarIndexRowIDs(second.Sessions)
assert.Equal(t, 2, second.Total)
assert.Empty(t, second.NextCursor)
assert.Equal(t, []string{"other"}, secondIDs)
assert.NotContains(t, secondIDs, "continuation")
}
func TestSidebarIndexPaginatesByDescendantFreshness(t *testing.T) {
te := setup(t)
rootEnd := "2024-01-01T00:00:00Z"
childEnd := "2024-01-10T00:00:00Z"
otherEnd := "2024-01-05T00:00:00Z"
te.seedSession(t, "root", "my-app", 5, func(s *db.Session) {
s.EndedAt = &rootEnd
})
te.seedSession(t, "child", "my-app", 3, func(s *db.Session) {
s.ParentSessionID = new("root")
s.RelationshipType = "subagent"
s.EndedAt = &childEnd
})
te.seedSession(t, "other", "my-app", 5, func(s *db.Session) {
s.EndedAt = &otherEnd
})
w := te.get(t, "/api/v1/sessions/sidebar-index?limit=1")
assertStatus(t, w, http.StatusOK)
first := decode[db.SidebarSessionIndex](t, w)
firstIDs := sidebarIndexRowIDs(first.Sessions)
assert.Equal(t, 2, first.Total)
assert.NotEmpty(t, first.NextCursor)
assert.ElementsMatch(t, []string{"root", "child"}, firstIDs)
w = te.get(t,
"/api/v1/sessions/sidebar-index?limit=1&cursor="+
url.QueryEscape(first.NextCursor),
)
assertStatus(t, w, http.StatusOK)
second := decode[db.SidebarSessionIndex](t, w)
secondIDs := sidebarIndexRowIDs(second.Sessions)
assert.Equal(t, 2, second.Total)
assert.Empty(t, second.NextCursor)
assert.Equal(t, []string{"other"}, secondIDs)
}
func TestSidebarIndexValidatesParams(t *testing.T) {
te := setup(t)
tests := []string{
"/api/v1/sessions/sidebar-index?min_messages=bad",
"/api/v1/sessions/sidebar-index?max_messages=bad",
"/api/v1/sessions/sidebar-index?min_user_messages=bad",
"/api/v1/sessions/sidebar-index?date=2024-99-99",
"/api/v1/sessions/sidebar-index?date_from=2024-06-02&date_to=2024-06-01",
"/api/v1/sessions/sidebar-index?active_since=not-a-timestamp",
}
for _, path := range tests {
t.Run(path, func(t *testing.T) {
w := te.get(t, path)
assertStatus(t, w, http.StatusBadRequest)
})
}
}
func TestGetSession_Found(t *testing.T) {
te := setup(t)
te.seedSession(t, "s1", "my-app", 5)
w := te.get(t, "/api/v1/sessions/s1")
assertStatus(t, w, http.StatusOK)
resp := decode[db.Session](t, w)
if resp.ID != "s1" {
t.Fatalf("expected id=s1, got %v", resp.ID)
}
}
func TestGetSession_NotFound(t *testing.T) {
te := setup(t)
w := te.get(t, "/api/v1/sessions/nonexistent")
assertStatus(t, w, http.StatusNotFound)
}
// TestGetSession_HealthBreakdownIncludesMidTaskCompactions
// guards against a regression where the recomputed
// health_penalties / health_score_basis on the session detail
// response omitted MidTaskCompactionCount, so a session
// penalized for mid-task compactions would show a breakdown
// inconsistent with its persisted health_score.
func TestGetSession_HealthBreakdownIncludesMidTaskCompactions(
t *testing.T,
) {
te := setup(t)
te.seedSession(t, "mt-1", "demo", 12)
score := 82
grade := "B"
if err := te.db.UpdateSessionSignals("mt-1", db.SessionSignalUpdate{
Outcome: "completed",
OutcomeConfidence: "medium",
EndedWithRole: "assistant",
HasToolCalls: true,
HasContextData: true,
CompactionCount: 2,
MidTaskCompactionCount: 2,
HealthScore: &score,
HealthGrade: &grade,
}); err != nil {
t.Fatalf("UpdateSessionSignals: %v", err)
}
w := te.get(t, "/api/v1/sessions/mt-1")
assertStatus(t, w, http.StatusOK)
var resp struct {
HealthScoreBasis []string `json:"health_score_basis"`
HealthPenalties map[string]int `json:"health_penalties"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
got, ok := resp.HealthPenalties["mid_task_compactions"]
if !ok {
t.Fatalf("mid_task_compactions missing from penalties: %+v",
resp.HealthPenalties)
}
// 2 mid-task compactions * 8 = 16 (cap is 18).
if got != 16 {
t.Errorf("mid_task_compactions penalty = %d, want 16", got)
}
if !slices.Contains(resp.HealthScoreBasis, "context_pressure") {
t.Errorf("basis missing context_pressure: %v",
resp.HealthScoreBasis)
}
}
func TestGetChildSessions_Found(t *testing.T) {
te := setup(t)
te.seedSession(t, "parent-1", "my-app", 10)
te.seedSession(t, "child-a", "my-app", 3, func(s *db.Session) {
s.ParentSessionID = new("parent-1")
s.RelationshipType = "subagent"
s.StartedAt = new("2025-01-15T10:05:00Z")
s.EndedAt = new("2025-01-15T10:10:00Z")
})
te.seedSession(t, "child-b", "my-app", 2, func(s *db.Session) {
s.ParentSessionID = new("parent-1")
s.RelationshipType = "fork"
s.StartedAt = new("2025-01-15T10:15:00Z")
s.EndedAt = new("2025-01-15T10:20:00Z")
})
w := te.get(t, "/api/v1/sessions/parent-1/children")
assertStatus(t, w, http.StatusOK)
var children []db.Session
if err := json.Unmarshal(w.Body.Bytes(), &children); err != nil {
t.Fatalf("decoding JSON: %v", err)
}
if len(children) != 2 {
t.Fatalf("expected 2 children, got %d", len(children))
}
if children[0].ID != "child-a" {
t.Errorf("children[0].ID = %q, want %q",
children[0].ID, "child-a")
}
if children[1].ID != "child-b" {
t.Errorf("children[1].ID = %q, want %q",
children[1].ID, "child-b")
}
}
func TestGetChildSessions_Empty(t *testing.T) {
te := setup(t)
te.seedSession(t, "no-kids", "my-app", 5)
w := te.get(t, "/api/v1/sessions/no-kids/children")
assertStatus(t, w, http.StatusOK)
var children []db.Session
if err := json.Unmarshal(w.Body.Bytes(), &children); err != nil {
t.Fatalf("decoding JSON: %v", err)
}
if len(children) != 0 {
t.Fatalf("expected 0 children, got %d", len(children))
}
}
func TestGetMessages_AscDefault(t *testing.T) {
te := setup(t)
te.seedSession(t, "s1", "my-app", 10)
te.seedMessages(t, "s1", 10)
w := te.get(t, "/api/v1/sessions/s1/messages")
assertStatus(t, w, http.StatusOK)
resp := decode[messageListResponse](t, w)
if len(resp.Messages) != 10 {
t.Fatalf("expected 10 messages, got %d",
len(resp.Messages))
}
first := resp.Messages[0]
last := resp.Messages[9]
if first.Ordinal > last.Ordinal {
t.Fatal("expected ascending ordinal order")
}
}
func TestGetMessages_DescDefault(t *testing.T) {
te := setup(t)
te.seedSession(t, "s1", "my-app", 10)
te.seedMessages(t, "s1", 10)
w := te.get(t,
"/api/v1/sessions/s1/messages?direction=desc",
)
assertStatus(t, w, http.StatusOK)
resp := decode[messageListResponse](t, w)
if len(resp.Messages) != 10 {
t.Fatalf("expected 10 messages, got %d",
len(resp.Messages))
}
first := resp.Messages[0]
last := resp.Messages[len(resp.Messages)-1]
if first.Ordinal < last.Ordinal {
t.Fatal("expected descending ordinal order")
}
}
func TestGetMessages_DescWithFrom(t *testing.T) {
te := setup(t)
te.seedSession(t, "s1", "my-app", 20)
te.seedMessages(t, "s1", 20)
w := te.get(t,
"/api/v1/sessions/s1/messages?direction=desc&from=10&limit=5",
)
assertStatus(t, w, http.StatusOK)
resp := decode[messageListResponse](t, w)
if len(resp.Messages) != 5 {
t.Fatalf("expected 5 messages, got %d",
len(resp.Messages))
}
if resp.Messages[0].Ordinal != 10 {
t.Fatalf("expected first ordinal=10, got %d",
resp.Messages[0].Ordinal)
}
}
// TestGetMessages_RolesTrimsSpacesAndDropsTrailingEmpty covers a regression
// where "roles=user, assistant," (a space after the comma, plus a trailing
// comma) silently narrowed the filter: an untrimmed " assistant" role never
// matches any stored row's plain "assistant" value, so assistant messages
// were dropped even though the caller asked for both roles.
func TestGetMessages_RolesTrimsSpacesAndDropsTrailingEmpty(t *testing.T) {
te := setup(t)
te.seedSession(t, "s1", "my-app", 4)
te.seedMessages(t, "s1", 4)
w := te.get(t, "/api/v1/sessions/s1/messages?roles=user,%20assistant,")
assertStatus(t, w, http.StatusOK)
resp := decode[messageListResponse](t, w)
require.Len(t, resp.Messages, 4,
"a space after the comma or a trailing empty element must not narrow the role filter")
}
func TestGetMessages_Pagination(t *testing.T) {
te := setup(t)
te.seedSession(t, "s1", "my-app", 20)
te.seedMessages(t, "s1", 20)
// First page
w := te.get(t,
"/api/v1/sessions/s1/messages?from=0&limit=5",
)
assertStatus(t, w, http.StatusOK)
resp := decode[messageListResponse](t, w)
if len(resp.Messages) != 5 {
t.Fatalf("expected 5 messages, got %d",
len(resp.Messages))
}
if resp.Messages[4].Ordinal != 4 {
t.Fatalf("expected last ordinal=4, got %d",
resp.Messages[4].Ordinal)
}
// Second page
w = te.get(t,
"/api/v1/sessions/s1/messages?from=5&limit=5",
)
assertStatus(t, w, http.StatusOK)
resp = decode[messageListResponse](t, w)
if len(resp.Messages) != 5 {
t.Fatalf("expected 5 messages, got %d",
len(resp.Messages))
}
if resp.Messages[0].Ordinal != 5 {
t.Fatalf("expected first ordinal=5, got %d",
resp.Messages[0].Ordinal)
}
}
func TestGetMessages_InvalidParams(t *testing.T) {
te := setup(t)
tests := []struct {
name string
path string
}{
{"InvalidLimit", "/api/v1/sessions/s1/messages?limit=abc"},
{"InvalidFrom", "/api/v1/sessions/s1/messages?from=xyz"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := te.get(t, tt.path)
assertStatus(t, w, http.StatusBadRequest)
})
}
}
func TestListSessions_InvalidLimit(t *testing.T) {
te := setup(t)
w := te.get(t, "/api/v1/sessions?limit=bad")
assertStatus(t, w, http.StatusBadRequest)
}
func TestListSessions_InvalidCursor(t *testing.T) {
te := setup(t)
w := te.get(t, "/api/v1/sessions?cursor=invalid-cursor")
assertStatus(t, w, http.StatusBadRequest)
}
func TestSearch_InvalidParams(t *testing.T) {
te := setup(t)
tests := []struct {
name string
path string
}{
{"InvalidLimit", "/api/v1/search?q=test&limit=nope"},
{"InvalidCursor", "/api/v1/search?q=test&cursor=bad"},
{"EmptyQuery", "/api/v1/search"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := te.get(t, tt.path)
assertStatus(t, w, http.StatusBadRequest)
})
}
}
func TestSearch_WithResults(t *testing.T) {
te := setup(t)
te.requireFTS(t)
te.seedSession(t, "s1", "my-app", 3)
te.seedMessages(t, "s1", 3, func(i int, m *db.Message) {
switch i {
case 0:
m.Role = "user"
m.Content = "fix the login bug"
m.ContentLength = 17
case 1:
m.Role = "assistant"
m.Content = "looking at auth module"
m.ContentLength = 22
case 2:
m.Role = "user"
m.Content = "ship it"
m.ContentLength = 7
}
})
w := te.get(t, "/api/v1/search?q=login")
assertStatus(t, w, http.StatusOK)
resp := decode[searchResponse](t, w)
if resp.Query != "login" {
t.Fatalf("expected query=login, got %v", resp.Query)
}
if resp.Count < 1 {
t.Fatal("expected at least 1 search result")
}
}
func TestSearch_Limits(t *testing.T) {
te := setup(t)
te.requireFTS(t)
// Seed enough distinct sessions to prove clamping at the maximum.
// Under session-grouped search, each session produces exactly one result,
// so limit/pagination operates at the session level.
const totalSessions = db.MaxSearchLimit + 1
writes := make([]db.SessionBatchWrite, 0, totalSessions)
content := "common search term"
for i := range totalSessions {
id := fmt.Sprintf("limit-test-%04d", i)
writes = append(writes, db.SessionBatchWrite{
Session: db.Session{
ID: id,
Project: "my-app",
Machine: "test",
Agent: "claude",
MessageCount: 1,
UserMessageCount: 1,
StartedAt: new(tsSeed),
EndedAt: new(tsSeedEnd),
FirstMessage: new("Hello world"),
},
Messages: []db.Message{{
SessionID: id,
Ordinal: 0,
Role: "user",
Content: content,
ContentLength: len(content),
Timestamp: tsSeed,
}},
})
}
result, err := te.db.WriteSessionBatchAtomic(writes)
require.NoError(t, err)
require.Equal(t, totalSessions, result.WrittenSessions)
require.Equal(t, totalSessions, result.WrittenMessages)
tests := []struct {
name string
queryVal string
wantCount int
}{
{"DefaultLimit", "", db.DefaultSearchLimit},
{"ExplicitLimit", "limit=10", 10},
{"ZeroLimit", "limit=0", db.DefaultSearchLimit},
{"LargeLimit", "limit=1000", db.MaxSearchLimit},
{"ExactMax", fmt.Sprintf("limit=%d", db.MaxSearchLimit), db.MaxSearchLimit},
{"JustOver", fmt.Sprintf("limit=%d", db.MaxSearchLimit+1), db.MaxSearchLimit},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := "/api/v1/search?q=common"
if tt.queryVal != "" {
path += "&" + tt.queryVal
}
w := te.get(t, path)
assertStatus(t, w, http.StatusOK)
resp := decode[searchResponse](t, w)
if resp.Count != tt.wantCount {
t.Errorf("limit=%q: got %d results, want %d",
tt.queryVal, resp.Count, tt.wantCount)
}
})
}
}
func TestSearch_CanceledContext(t *testing.T) {
te := setup(t)
te.requireFTS(t)
te.seedSession(t, "s1", "my-app", 1)
te.seedMessages(t, "s1", 1, func(i int, m *db.Message) {
m.Content = "searchable content"
m.ContentLength = 18
})
ctx, cancel := context.WithCancel(context.Background())
cancel()
w := te.getWithContext(t, ctx, "/api/v1/search?q=searchable")
// A canceled request should just return without writing a response
// (implicit 200 with empty body in httptest, but importantly NO content).
if w.Body.Len() > 0 {
t.Errorf("expected empty body for canceled context, got: %s",
w.Body.String())
}
}
func TestSearch_DeadlineExceeded(t *testing.T) {
te := setup(t)
te.requireFTS(t)
te.seedSession(t, "s1", "my-app", 1)
te.seedMessages(t, "s1", 1, func(i int, m *db.Message) {
m.Content = "searchable content"
m.ContentLength = 18
})
ctx, cancel := expiredContext(t)
defer cancel()
w := te.getWithContext(t, ctx, "/api/v1/search?q=searchable")
assertTimeoutRace(t, w)
}
func TestSearch_ZeroResults(t *testing.T) {
te := setup(t)
te.requireFTS(t)
te.seedSession(t, "s1", "my-app", 1)
te.seedMessages(t, "s1", 1)
w := te.get(t, "/api/v1/search?q=spamalot")
assertStatus(t, w, http.StatusOK)
resp := decode[searchResponse](t, w)
if resp.Results == nil {
t.Fatal("results must be [] not null")
}
if resp.Count != 0 {
t.Fatalf("expected count=0, got %d", resp.Count)
}
}
// TestSearch_Deduplication verifies that a session with many matching messages
// produces exactly one search result. This guards against FTS5 segment
// duplication bugs where multiple index segments could yield multiple rows
// for the same session_id.
func TestSearch_Deduplication(t *testing.T) {
te := setup(t)
te.requireFTS(t)
// Session s1: many messages all containing the search term.
te.seedSession(t, "s1", "proj-a", 1)
const n = 80
te.seedMessages(t, "s1", n, func(_ int, m *db.Message) {
m.Content = "needle in every message"
m.ContentLength = 23
})
// Session s2: one message containing the search term (control).
te.seedSession(t, "s2", "proj-b", 1)
te.seedMessages(t, "s2", 1, func(_ int, m *db.Message) {
m.Content = "needle single message"
m.ContentLength = 21
})
w := te.get(t, "/api/v1/search?q=needle&limit=100")
assertStatus(t, w, http.StatusOK)
resp := decode[searchResponse](t, w)
if resp.Count != 2 {
t.Errorf("got count=%d, want 2 (one result per session)", resp.Count)
}
// Verify no duplicate session_ids in the response.
seen := make(map[string]int)
for _, r := range resp.Results {
seen[r.SessionID]++
}
for sid, count := range seen {
if count > 1 {
t.Errorf("session_id %q appears %d times in results, want 1", sid, count)
}
}
}
func TestSearch_NotAvailable(t *testing.T) {
te := setup(t)
// Simulate missing FTS by dropping the virtual table.
// HasFTS() will return false because the query against messages_fts will fail.
err := te.db.Update(func(tx *sql.Tx) error {
_, err := tx.Exec("DROP TABLE IF EXISTS messages_fts")
return err
})
if err != nil {
t.Fatalf("dropping messages_fts: %v", err)
}
w := te.get(t, "/api/v1/search?q=foo")
assertStatus(t, w, http.StatusNotImplemented)
assertErrorResponse(t, w, "search not available")
}
func TestGetStats(t *testing.T) {
te := setup(t)
te.seedSession(t, "s1", "my-app", 5)
te.seedMessages(t, "s1", 5)
w := te.get(t, "/api/v1/stats")
assertStatus(t, w, http.StatusOK)
resp := decode[db.Stats](t, w)
if resp.SessionCount != 1 {
t.Fatalf("expected 1 session, got %d",
resp.SessionCount)
}
if resp.MessageCount != 5 {
t.Fatalf("expected 5 messages, got %d",
resp.MessageCount)
}
}
func TestGetStats_ExcludeOneShotDefault(t *testing.T) {
te := setup(t)
te.seedSession(t, "s1", "my-app", 5, func(s *db.Session) {
s.UserMessageCount = 1
})
te.seedSession(t, "s2", "my-app", 10, func(s *db.Session) {
s.UserMessageCount = 5
})
te.seedMessages(t, "s1", 5)
te.seedMessages(t, "s2", 10)
// Default: exclude one-shot sessions.
w := te.get(t, "/api/v1/stats")
assertStatus(t, w, http.StatusOK)
resp := decode[db.Stats](t, w)
if resp.SessionCount != 1 {
t.Errorf("default: session_count = %d, want 1",
resp.SessionCount)
}
if resp.MessageCount != 10 {
t.Errorf("default: message_count = %d, want 10",
resp.MessageCount)
}
// Explicit include: all sessions.
w = te.get(t, "/api/v1/stats?include_one_shot=true")
assertStatus(t, w, http.StatusOK)
resp = decode[db.Stats](t, w)
if resp.SessionCount != 2 {
t.Errorf("include: session_count = %d, want 2",
resp.SessionCount)
}
if resp.MessageCount != 15 {
t.Errorf("include: message_count = %d, want 15",
resp.MessageCount)
}
}
func TestSessionStats_DefaultVisibilityMatchesListDefaults(t *testing.T) {
te := setup(t)
startedAt := time.Now().UTC().Add(-2 * time.Hour).Format(time.RFC3339)
endedAt := time.Now().UTC().Add(-90 * time.Minute).Format(time.RFC3339)
te.seedSession(t, "deep", "my-app", 10, func(s *db.Session) {
s.UserMessageCount = 5
s.StartedAt = &startedAt
s.EndedAt = &endedAt
})
te.seedSession(t, "one-shot", "my-app", 5, func(s *db.Session) {
s.UserMessageCount = 1
s.StartedAt = &startedAt
s.EndedAt = &endedAt
})
te.seedSession(t, "automated", "my-app", 6, func(s *db.Session) {
fm := "You are a code reviewer. Review the code."
s.FirstMessage = &fm
s.UserMessageCount = 1
s.StartedAt = &startedAt
s.EndedAt = &endedAt
})
te.seedMessages(t, "deep", 10)
te.seedMessages(t, "one-shot", 5)
te.seedMessages(t, "automated", 6)
w := te.get(t, "/api/v1/session-stats")
assertStatus(t, w, http.StatusOK)
resp := decode[db.SessionStats](t, w)
assert.Equal(t, 1, resp.Totals.SessionsAll, "default sessions_all")
assert.Equal(t, 10, resp.Totals.MessagesTotal, "default messages_total")
w = te.get(t, "/api/v1/session-stats?include_one_shot=true")
assertStatus(t, w, http.StatusOK)
resp = decode[db.SessionStats](t, w)
assert.Equal(t, 2, resp.Totals.SessionsAll, "include_one_shot sessions_all")
assert.Equal(t, 15, resp.Totals.MessagesTotal, "include_one_shot messages_total")
w = te.get(t, "/api/v1/session-stats?include_automated=true")
assertStatus(t, w, http.StatusOK)
resp = decode[db.SessionStats](t, w)
assert.Equal(t, 2, resp.Totals.SessionsAll, "include_automated sessions_all")
assert.Equal(t, 16, resp.Totals.MessagesTotal, "include_automated messages_total")
w = te.get(t, "/api/v1/session-stats?include_one_shot=true&include_automated=true")
assertStatus(t, w, http.StatusOK)
resp = decode[db.SessionStats](t, w)
assert.Equal(t, 3, resp.Totals.SessionsAll, "include_all sessions_all")
assert.Equal(t, 21, resp.Totals.MessagesTotal, "include_all messages_total")
}
func TestListMachines_ExcludeOneShotDefault(t *testing.T) {
te := setup(t)
te.seedSession(t, "s1", "my-app", 5, func(s *db.Session) {
s.Machine = "laptop"
s.UserMessageCount = 1
})
te.seedSession(t, "s2", "my-app", 10, func(s *db.Session) {
s.Machine = "desktop"
s.UserMessageCount = 5
})
te.seedSession(t, "s3", "other-app", 3, func(s *db.Session) {
s.Machine = "desktop"
s.UserMessageCount = 5
})
// Default: exclude one-shot sessions.
w := te.get(t, "/api/v1/machines")
assertStatus(t, w, http.StatusOK)
resp := decode[machineListResponse](t, w)
if len(resp.Machines) != 1 {
t.Fatalf("default: expected 1 machine, got %d",
len(resp.Machines))
}
if resp.Machines[0] != "desktop" {
t.Errorf("default: expected desktop, got %s",
resp.Machines[0])
}
// Explicit include: all machines.
w = te.get(t, "/api/v1/machines?include_one_shot=true")
assertStatus(t, w, http.StatusOK)
resp = decode[machineListResponse](t, w)
if len(resp.Machines) != 2 {
t.Fatalf("include: expected 2 machines, got %d",
len(resp.Machines))
}
w = te.get(t, "/api/v1/projects")
assertStatus(t, w, http.StatusOK)
projects := decode[projectListResponse](t, w)
if len(projects.Projects) != 2 {
t.Fatalf("expected 2 projects, got %d",
len(projects.Projects))
}
}
func TestSyncStatus(t *testing.T) {
te := setup(t)
// Trigger a sync so LastSync is set
w := te.post(t, "/api/v1/sync", "{}")
assertStatus(t, w, http.StatusOK)
w = te.get(t, "/api/v1/sync/status")
assertStatus(t, w, http.StatusOK)
resp := decode[syncStatusResponse](t, w)
if resp.LastSync == "" {
t.Fatal("expected last_sync field")
}
}
func TestSyncStatusIncludesCurrentProgress(t *testing.T) {
te := setup(t)
te.writeSessionFile(t, "status-proj", "status.jsonl",
testjsonl.NewSessionBuilder().
AddClaudeUser(tsZero, "status progress"),
)
progressSeen := make(chan struct{})
release := make(chan struct{})
done := make(chan struct{})
go func() {
defer close(done)
te.engine.SyncAll(context.Background(), func(p sync.Progress) {
if p.SessionsTotal == 0 {
return
}
select {
case <-progressSeen:
default:
close(progressSeen)
<-release
}
})
}()
select {
case <-progressSeen:
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for sync progress")
}
w := te.get(t, "/api/v1/sync/status")
assertStatus(t, w, http.StatusOK)
close(release)
<-done
resp := decode[syncStatusResponse](t, w)
require.NotNil(t, resp.Progress, "expected current progress")
assert.Equal(t, sync.PhaseSyncing, resp.Progress.Phase)
assert.Equal(t, 1, resp.Progress.SessionsTotal)
}
func TestCORSHeaders(t *testing.T) {
te := setup(t)
// Request with matching origin should get CORS header.
w := te.wrappedRequest(http.MethodGet, "/api/v1/stats",
withOrigin("http://127.0.0.1:0"))
assertStatus(t, w, http.StatusOK)
cors := w.Header().Get("Access-Control-Allow-Origin")
if cors != "http://127.0.0.1:0" {
t.Fatalf("expected CORS origin http://127.0.0.1:0, got %q", cors)
}
}
func TestCORSRejectsUnknownOrigin(t *testing.T) {
te := setup(t)
// GET from a foreign origin: allowed (read-only) but no CORS header.
w := te.wrappedRequest(http.MethodGet, "/api/v1/stats",
withOrigin("http://evil-site.com"))
assertStatus(t, w, http.StatusOK)
cors := w.Header().Get("Access-Control-Allow-Origin")
if cors != "" {
t.Fatalf("expected no CORS header for foreign origin, got %q", cors)
}
}
func TestCORSBlocksMutatingFromUnknownOrigin(t *testing.T) {
te := setup(t)
// POST from a foreign origin should be blocked (CSRF protection).
w := te.wrappedRequest(http.MethodPost, "/api/v1/sync",
withOrigin("http://evil-site.com"))
assertStatus(t, w, http.StatusForbidden)
}
func TestCORSAllowsMutatingFromKnownOrigin(t *testing.T) {
te := setup(t)
// POST from the legitimate origin should succeed.
w := te.wrappedRequest(http.MethodPost, "/api/v1/sync",
withOrigin("http://127.0.0.1:0"))
// Sync returns 200 or 202, not 403.
if w.Code == http.StatusForbidden {
t.Fatal("legitimate origin should not be blocked")
}
}
func TestSyncEndpointLocalNoSyncDaemonUsesOnDemandEngine(t *testing.T) {
te := setupNoSyncMode(t)
w := te.post(t, "/api/v1/sync", "{}")
assert.NotEqual(t, http.StatusNotImplemented, w.Code)
assertStatus(t, w, http.StatusOK)
}
func TestPGPushLocalNoSyncDaemonReachesConfigValidation(t *testing.T) {
te := setupNoSyncMode(t)
w := te.post(t, "/api/v1/push/pg", `{"full":false}`)
assertStatus(t, w, http.StatusBadRequest)
assert.Contains(t, w.Body.String(), "pg push: url not configured")
}
func TestDuckDBPushLocalNoSyncDaemonWritesConfiguredPath(t *testing.T) {
if runtime.GOOS == "windows" && runtime.GOARCH == "arm64" {
t.Skip("duckdb-go-bindings does not ship a windows/arm64 library")
}
te := setupNoSyncMode(t)
target := filepath.Join(t.TempDir(), "agentsview.duckdb")
body, err := json.Marshal(struct {
Full bool `json:"full"`
DuckDB config.DuckDBConfig `json:"duckdb"`
}{
Full: true,
DuckDB: config.DuckDBConfig{
Path: target,
MachineName: "workstation",
},
})
require.NoError(t, err)
w := te.post(t, "/api/v1/push/duckdb", string(body))
assertStatus(t, w, http.StatusOK)
assert.FileExists(t, target)
}
// TestDuckDBPushStreamsSSEDoneEvent pins the push route's SSE mode: a client
// that accepts text/event-stream (the CLI's daemon-delegated push) gets an
// event stream ending in a done event carrying the push result, instead of a
// single JSON body after a silent wait.
func TestDuckDBPushStreamsSSEDoneEvent(t *testing.T) {
if runtime.GOOS == "windows" && runtime.GOARCH == "arm64" {
t.Skip("duckdb-go-bindings does not ship a windows/arm64 library")
}
te := setupNoSyncMode(t)
target := filepath.Join(t.TempDir(), "agentsview.duckdb")
body, err := json.Marshal(struct {
Full bool `json:"full"`
DuckDB config.DuckDBConfig `json:"duckdb"`
}{
Full: true,
DuckDB: config.DuckDBConfig{
Path: target,
MachineName: "workstation",
},
})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/api/v1/push/duckdb",
strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Origin", "http://127.0.0.1:0")
req.Header.Set("Accept", "text/event-stream")
w := httptest.NewRecorder()
te.handler.ServeHTTP(w, req)
assertStatus(t, w, http.StatusOK)
assert.Contains(t, w.Header().Get("Content-Type"), "text/event-stream")
assert.Contains(t, w.Body.String(), "event: done")
assert.FileExists(t, target)
}
func TestCORSPreflightRejectsBadOrigin(t *testing.T) {
te := setupHostOnly(t)
// OPTIONS preflight from foreign origin should return 403.
w := te.wrappedRequest(http.MethodOptions, "/api/v1/sessions",
withOrigin("http://evil-site.com"))
assertStatus(t, w, http.StatusForbidden)
}
func TestCORSBlocksMutatingWithNoOrigin(t *testing.T) {
te := setupHostOnly(t)
// POST with no Origin header should be blocked (prevents
// CSRF where browser omits Origin). Use rawRequest to
// bypass the test wrapper that auto-sets Origin.
w := te.rawRequest(http.MethodPost, "/api/v1/sync",
withHost("127.0.0.1:0"))
assertStatus(t, w, http.StatusForbidden)
}
func TestHostHeaderRejectsDNSRebinding(t *testing.T) {
te := setupHostOnly(t)
// A DNS rebinding attack uses a custom domain that resolves
// to 127.0.0.1. The Host header carries the attacker's domain.
w := te.wrappedRequest(http.MethodGet, hostMiddlewareProbePath,
withHost("evil.attacker.com:8080"))
assertStatus(t, w, http.StatusForbidden)
}
func TestHostHeaderRejectionBodyIsDescriptive(t *testing.T) {
te := setupHostOnly(t)
// A forwarded port produces a Host the server does not trust.
w := te.wrappedRequest(http.MethodGet, hostMiddlewareProbePath,
withHost("127.0.0.1:18080"))
assertStatus(t, w, http.StatusForbidden)
body := w.Body.String()
// The body must name the rejected Host and point at the fix so a
// user can self-diagnose without devtools.
assert.Contains(t, body, "127.0.0.1:18080")
assert.Contains(t, body, "--public-url")
}
func TestHostHeaderAllowsLegitimate(t *testing.T) {
te := setupHostOnly(t)
// Requests with legitimate Host should pass.
for _, host := range []string{
"127.0.0.1:0",
"localhost:0",
} {
w := te.wrappedRequest(http.MethodGet, hostMiddlewareProbePath,
withHost(host), withRemoteAddr("127.0.0.1:1234"))
if w.Code == http.StatusForbidden {
t.Errorf("host %s should be allowed, got 403", host)
}
}
}
func TestHostHeaderAllowsConfiguredPublicOriginHost(t *testing.T) {
te := setupHostOnly(t, withPublicURL("http://viewer.example.test:8004"))
// In the managed Caddy flow, the backend only accepts loopback
// connections. Set RemoteAddr to loopback so authMiddleware
// passes the request through to the host-check layer.
w := te.wrappedRequest(http.MethodGet, hostMiddlewareProbePath,
withHost("viewer.example.test:8004"),
withRemoteAddr("127.0.0.1:1234"))
assertStatus(t, w, http.StatusOK)
}
func TestHostHeaderPublicOriginsExpandTrustedHosts(t *testing.T) {
te := setupHostOnly(t, withPublicOrigins("http://viewer.example.test:8004"))
w := te.wrappedRequest(http.MethodGet, hostMiddlewareProbePath,
withHost("viewer.example.test:8004"),
withRemoteAddr("127.0.0.1:1234"))
// public_origins should expand the host allowlist so
// reverse proxies forwarding the origin's Host are allowed.
assertStatus(t, w, http.StatusOK)
}
func TestHostHeaderHTTPSPublicOriginExpandsTrustedHosts(
t *testing.T,
) {
te := setupHostOnly(t, withPublicOrigins(
"https://viewer.example.test",
))
// Browsers omit :443 for HTTPS, so test the bare hostname
// that a reverse proxy would forward.
for _, host := range []string{
"viewer.example.test",
"viewer.example.test:443",
} {
t.Run(host, func(t *testing.T) {
w := te.wrappedRequest(http.MethodGet, hostMiddlewareProbePath,
withHost(host), withRemoteAddr("127.0.0.1:1234"))
assertStatus(t, w, http.StatusOK)
})
}
}
func TestCORSAllowsConfiguredHTTPSPublicOrigin(t *testing.T) {
te := setupHostOnly(t, withPublicOrigins("https://viewer.example.test"))
w := te.wrappedRequest(http.MethodOptions, "/api/v1/sync",
withOrigin("https://viewer.example.test"))
assertStatus(t, w, http.StatusNoContent)
}
func TestCORSAllowsLocalhost(t *testing.T) {
te := setupHostOnly(t)
// localhost variant should also be allowed when bound to 127.0.0.1.
w := te.wrappedRequest(http.MethodGet, hostMiddlewareProbePath,
withOrigin("http://localhost:0"))
assertStatus(t, w, http.StatusOK)
cors := w.Header().Get("Access-Control-Allow-Origin")
if cors != "http://localhost:0" {
t.Fatalf("expected CORS origin http://localhost:0, got %q", cors)
}
}
func TestHostHeaderBindAllPort80AllowsPortlessLoopback(t *testing.T) {
for _, bindHost := range []string{"0.0.0.0", "::"} {
t.Run(bindHost, func(t *testing.T) {
te := setupHostOnly(t, func(c *config.Config) {
c.Host = bindHost
c.Port = 80
})
for _, host := range []string{
"127.0.0.1:80",
"127.0.0.1",
"localhost:80",
"localhost",
"[::1]:80",
"[::1]",
} {
w := te.wrappedRequest(http.MethodGet, hostMiddlewareProbePath,
withHost(host), withRemoteAddr("127.0.0.1:1234"))
assertStatus(t, w, http.StatusOK)
}
})
}
}
func TestCORSBindAllPort80AllowsPortlessLoopbackOrigins(t *testing.T) {
for _, bindHost := range []string{"0.0.0.0", "::"} {
t.Run(bindHost, func(t *testing.T) {
te := setupHostOnly(t, func(c *config.Config) {
c.Host = bindHost
c.Port = 80
})
for _, origin := range []string{
"http://127.0.0.1:80",
"http://127.0.0.1",
"http://localhost:80",
"http://localhost",
"http://[::1]:80",
"http://[::1]",
} {
w := te.wrappedRequest(http.MethodGet, hostMiddlewareProbePath,
withOrigin(origin))
assertStatus(t, w, http.StatusOK)
cors := w.Header().Get("Access-Control-Allow-Origin")
if cors != origin {
t.Fatalf(
"origin %s: expected CORS %s, got %q",
origin, origin, cors,
)
}
}
})
}
}
func TestCORSBindAllPort80AllowsPortlessLANOrigin(t *testing.T) {
lanIP := firstNonLoopbackIP(t)
origin := "http://" + hostLiteral(lanIP)
for _, bindHost := range []string{"0.0.0.0", "::"} {
t.Run(bindHost, func(t *testing.T) {
te := setupHostOnly(t, func(c *config.Config) {
c.Host = bindHost
c.Port = 80
})
w := te.wrappedRequest(http.MethodGet, hostMiddlewareProbePath,
withOrigin(origin))
assertStatus(t, w, http.StatusOK)
cors := w.Header().Get("Access-Control-Allow-Origin")
if cors != origin {
t.Fatalf("expected CORS origin %s, got %q", origin, cors)
}
})
}
}
func TestHostHeaderBindAllPort80AllowsPortlessLANIP(t *testing.T) {
lanIP := firstNonLoopbackIP(t)
host := hostLiteral(lanIP)
for _, bindHost := range []string{"0.0.0.0", "::"} {
t.Run(bindHost, func(t *testing.T) {
te := setupHostOnly(t, func(c *config.Config) {
c.Host = bindHost
c.Port = 80
})
w := te.wrappedRequest(http.MethodGet, hostMiddlewareProbePath,
withHost(host), withRemoteAddr(lanIP+":1234"))
assertStatus(t, w, http.StatusOK)
})
}
}
func TestCORSBindAllPort80RejectsNonLocalIPOrigin(t *testing.T) {
const origin = "http://198.51.100.10"
for _, bindHost := range []string{"0.0.0.0", "::"} {
t.Run(bindHost, func(t *testing.T) {
te := setupHostOnly(t, func(c *config.Config) {
c.Host = bindHost
c.Port = 80
})
w := te.wrappedRequest(http.MethodPost, "/api/v1/sync",
withOrigin(origin))
assertStatus(t, w, http.StatusForbidden)
})
}
}
func TestHostHeaderBindAllPort80RejectsNonLocalIP(t *testing.T) {
const host = "198.51.100.10"
for _, bindHost := range []string{"0.0.0.0", "::"} {
t.Run(bindHost, func(t *testing.T) {
te := setupHostOnly(t, func(c *config.Config) {
c.Host = bindHost
c.Port = 80
})
w := te.wrappedRequest(http.MethodGet, hostMiddlewareProbePath,
withHost(host))
assertStatus(t, w, http.StatusForbidden)
})
}
}
func TestCORSBindAllInterfaces(t *testing.T) {
for _, bindHost := range []string{"0.0.0.0", "::"} {
t.Run(bindHost, func(t *testing.T) {
te := setupHostOnly(t, func(c *config.Config) {
c.Host = bindHost
})
// In bind-all mode, all loopback origins must be allowed
// (including IPv6 [::1]).
for _, origin := range []string{
"http://127.0.0.1:0",
"http://localhost:0",
"http://[::1]:0",
} {
w := te.wrappedRequest(http.MethodGet, hostMiddlewareProbePath,
withOrigin(origin))
assertStatus(t, w, http.StatusOK)
cors := w.Header().Get("Access-Control-Allow-Origin")
if cors != origin {
t.Errorf("origin %s: expected CORS %s, got %q", origin, origin, cors)
}
}
})
}
}
func TestCORSBindAllAllowsLANIPOrigin(t *testing.T) {
lanIP := firstNonLoopbackIP(t)
origin := "http://" + net.JoinHostPort(lanIP, "0")
for _, bindHost := range []string{"0.0.0.0", "::"} {
t.Run(bindHost, func(t *testing.T) {
te := setupHostOnly(t, func(c *config.Config) {
c.Host = bindHost
})
w := te.wrappedRequest(http.MethodGet, hostMiddlewareProbePath,
withOrigin(origin))
assertStatus(t, w, http.StatusOK)
cors := w.Header().Get("Access-Control-Allow-Origin")
if cors != origin {
t.Fatalf("expected CORS origin %s, got %q", origin, cors)
}
})
}
}
func TestHostHeaderBindAllAllowsLANIP(t *testing.T) {
lanIP := firstNonLoopbackIP(t)
host := net.JoinHostPort(lanIP, "0")
for _, bindHost := range []string{"0.0.0.0", "::"} {
t.Run(bindHost, func(t *testing.T) {
te := setupHostOnly(t, func(c *config.Config) {
c.Host = bindHost
})
w := te.wrappedRequest(http.MethodGet, hostMiddlewareProbePath,
withHost(host), withRemoteAddr(lanIP+":1234"))
assertStatus(t, w, http.StatusOK)
})
}
}
func TestCORSBindAllRejectsNonLocalIPOrigin(t *testing.T) {
const origin = "http://198.51.100.10:0"
for _, bindHost := range []string{"0.0.0.0", "::"} {
t.Run(bindHost, func(t *testing.T) {
te := setupHostOnly(t, func(c *config.Config) {
c.Host = bindHost
})
w := te.wrappedRequest(http.MethodPost, "/api/v1/sync",
withOrigin(origin))
assertStatus(t, w, http.StatusForbidden)
})
}
}
func TestHostHeaderBindAllRejectsNonLocalIP(t *testing.T) {
const host = "198.51.100.10:0"
for _, bindHost := range []string{"0.0.0.0", "::"} {
t.Run(bindHost, func(t *testing.T) {
te := setupHostOnly(t, func(c *config.Config) {
c.Host = bindHost
})
w := te.wrappedRequest(http.MethodGet, hostMiddlewareProbePath,
withHost(host))
assertStatus(t, w, http.StatusForbidden)
})
}
}
func TestCORSBindAllRejectsForeignOrigin(t *testing.T) {
for _, bindHost := range []string{"0.0.0.0", "::"} {
t.Run(bindHost, func(t *testing.T) {
te := setupHostOnly(t, func(c *config.Config) {
c.Host = bindHost
})
w := te.wrappedRequest(http.MethodPost, "/api/v1/sync",
withOrigin("http://evil-site.com"))
assertStatus(t, w, http.StatusForbidden)
})
}
}
func TestHostHeaderBindAllRejectsDNSRebinding(t *testing.T) {
for _, bindHost := range []string{"0.0.0.0", "::"} {
t.Run(bindHost, func(t *testing.T) {
te := setupHostOnly(t, func(c *config.Config) {
c.Host = bindHost
})
w := te.wrappedRequest(http.MethodGet, hostMiddlewareProbePath,
withHost("evil.attacker.com:8080"))
assertStatus(t, w, http.StatusForbidden)
})
}
}
func TestCORSVaryAlwaysSet(t *testing.T) {
te := setupHostOnly(t)
// Vary: Origin should be set even for disallowed origins.
w := te.wrappedRequest(http.MethodGet, hostMiddlewareProbePath,
withOrigin("http://evil-site.com"))
assertStatus(t, w, http.StatusOK)
vary := w.Header().Get("Vary")
if vary != "Origin" {
t.Fatalf("expected Vary: Origin, got %q", vary)
}
}
func TestCORSPreflight(t *testing.T) {
te := setupHostOnly(t)
w := te.wrappedRequest(http.MethodOptions, "/api/v1/sessions",
withOrigin("http://127.0.0.1:0"))
assertStatus(t, w, http.StatusNoContent)
}
func TestCORSAllowMethods(t *testing.T) {
te := setupHostOnly(t)
w := te.wrappedRequest(http.MethodGet, hostMiddlewareProbePath,
withOrigin("http://127.0.0.1:0"))
assertStatus(t, w, http.StatusOK)
methods := w.Header().Get(
"Access-Control-Allow-Methods",
)
for _, want := range []string{
http.MethodGet, http.MethodPost, http.MethodPut,
http.MethodPatch, http.MethodDelete, http.MethodOptions,
} {
if !strings.Contains(methods, want) {
t.Errorf(
"Allow-Methods %q missing %s",
methods, want,
)
}
}
}
func TestAuthErrorIncludesCORSHeaders(t *testing.T) {
te := setup(t, func(c *config.Config) {
c.Host = "0.0.0.0"
c.RequireAuth = true
c.AuthToken = "secret-token"
})
// Request with wrong token from a cross-origin remote client.
w := te.rawRequest(http.MethodGet, "/api/v1/stats",
withOrigin("http://192.168.1.50:8080"),
withBearer("wrong-token"),
withRemoteAddr("192.168.1.50:9999"))
assertStatus(t, w, http.StatusUnauthorized)
cors := w.Header().Get("Access-Control-Allow-Origin")
if cors != "http://192.168.1.50:8080" {
t.Fatalf(
"expected CORS Allow-Origin on auth error, got %q",
cors,
)
}
}
func TestAuthErrorNoCORSWithoutOrigin(t *testing.T) {
te := setup(t, func(c *config.Config) {
c.Host = "0.0.0.0"
c.RequireAuth = true
c.AuthToken = "secret-token"
})
// Request without Origin header should not get CORS headers.
w := te.rawRequest(http.MethodGet, "/api/v1/stats",
withBearer("wrong-token"),
withRemoteAddr("192.168.1.50:9999"))
assertStatus(t, w, http.StatusUnauthorized)
cors := w.Header().Get("Access-Control-Allow-Origin")
if cors != "" {
t.Fatalf(
"expected no CORS header without Origin, got %q",
cors,
)
}
}
func TestNoAuthWhenRemoteDisabled(t *testing.T) {
te := setup(t, func(c *config.Config) {
c.Host = "0.0.0.0"
// require_auth is false — auth is not enforced, so
// non-loopback requests pass through without a token.
})
// Use localhost Host header to pass host-check; the point
// of this test is that auth middleware doesn't block when
// require_auth is off.
w := te.rawRequest(http.MethodGet, "/api/v1/stats",
withHost("127.0.0.1:0"),
withRemoteAddr("192.168.1.50:9999"))
if w.Code == http.StatusForbidden ||
w.Code == http.StatusUnauthorized {
t.Fatalf(
"expected no auth gate when remote disabled, got %d",
w.Code,
)
}
}
func TestAuthRequiredButNoToken(t *testing.T) {
te := setup(t, func(c *config.Config) {
c.Host = "0.0.0.0"
c.RequireAuth = true
// AuthToken intentionally left empty.
})
w := te.rawRequest(http.MethodGet, "/api/v1/stats",
withHost("127.0.0.1:0"))
if w.Code != http.StatusInternalServerError {
t.Fatalf(
"expected 500 when auth required but no token, got %d",
w.Code,
)
}
}
func TestAuthRequiredProtectsPing(t *testing.T) {
te := setup(t, func(c *config.Config) {
c.RequireAuth = true
c.AuthToken = "secret-token"
})
w := te.rawRequest(http.MethodGet, "/api/ping")
assertStatus(t, w, http.StatusUnauthorized)
w = te.rawRequest(http.MethodGet, "/api/ping",
withBearer("secret-token"))
assertStatus(t, w, http.StatusOK)
}
func TestGetGithubConfig(t *testing.T) {
t.Setenv("AGENTSVIEW_GITHUB_TOKEN", "")
t.Setenv("PATH", t.TempDir())
te := setup(t)
w := te.get(t, "/api/v1/config/github")
assertStatus(t, w, http.StatusOK)
resp := decode[githubConfigResponse](t, w)
if resp.Configured {
t.Fatal("expected configured=false")
}
}
func TestExportSession(t *testing.T) {
te := setup(t)
te.seedSession(t, "s1", "my-app", 3)
te.seedMessages(t, "s1", 3)
w := te.get(t, "/api/v1/sessions/s1/export")
assertStatus(t, w, http.StatusOK)
ct := w.Header().Get("Content-Type")
if !strings.Contains(ct, "text/html") {
t.Fatalf("expected text/html content type, got %q", ct)
}
cd := w.Header().Get("Content-Disposition")
if !strings.Contains(cd, "attachment") {
t.Fatalf("expected attachment disposition, got %q", cd)
}
assertBodyContains(t, w, "my-app")
}
func TestExportSession_NotFound(t *testing.T) {
te := setup(t)
w := te.get(t, "/api/v1/sessions/nonexistent/export")
assertStatus(t, w, http.StatusNotFound)
}
func TestMarkdownSessionExport(t *testing.T) {
te := setup(t)
te.seedSession(t, "s1", "my-app", 3)
te.seedMessages(t, "s1", 3)
w := te.get(t, "/api/v1/sessions/s1/md")
assertStatus(t, w, http.StatusOK)
ct := w.Header().Get("Content-Type")
if !strings.Contains(ct, "text/markdown") {
t.Fatalf("expected text/markdown content type, got %q", ct)
}
cd := w.Header().Get("Content-Disposition")
if !strings.Contains(cd, "inline") {
t.Fatalf("expected inline disposition, got %q", cd)
}
assertBodyContains(t, w, "# Session: my-app")
}
func TestMarkdownSessionExport_NotFound(t *testing.T) {
te := setup(t)
w := te.get(t, "/api/v1/sessions/nonexistent/md")
assertStatus(t, w, http.StatusNotFound)
}
func TestMarkdownSessionExport_InvalidDepth(t *testing.T) {
te := setup(t)
te.seedSession(t, "s1", "my-app", 1)
w := te.get(t, "/api/v1/sessions/s1/md?depth=2")
assertStatus(t, w, http.StatusBadRequest)
}
func TestMarkdownSessionExport_DepthOneIncludesChildSessions(t *testing.T) {
te := setup(t)
te.seedSession(t, "parent", "my-app", 1)
te.seedMessages(t, "parent", 1, func(i int, m *db.Message) {
m.Role = "assistant"
m.Content = "[Task]\nchild work"
m.HasToolUse = true
m.ToolCalls = []db.ToolCall{{
ToolName: "Task",
Category: "Task",
ToolUseID: "toolu_child",
InputJSON: `{"prompt":"inspect child"}`,
SubagentSessionID: "child-a",
}}
})
te.seedSession(t, "child-a", "my-app", 1, func(s *db.Session) {
s.ParentSessionID = new("parent")
s.RelationshipType = "subagent"
})
te.seedMessages(t, "child-a", 1)
w := te.get(t, "/api/v1/sessions/parent/md?depth=1")
assertStatus(t, w, http.StatusOK)
assertBodyContains(t, w, `<subagent_anchor session_id="child-a" tool_call_id="toolu_child" depth="1">`)
assertBodyContains(t, w, `<subagent_session id="child-a" parent_session_id="parent" relationship="subagent"`)
}
func TestMarkdownSessionExport_DefaultOmitsChildSessions(t *testing.T) {
te := setup(t)
te.seedSession(t, "parent", "my-app", 1)
te.seedMessages(t, "parent", 1, func(i int, m *db.Message) {
m.Role = "assistant"
m.Content = "[Task]\nchild work"
m.HasToolUse = true
m.ToolCalls = []db.ToolCall{{
ToolName: "Task",
Category: "Task",
ToolUseID: "toolu_child",
InputJSON: `{"prompt":"inspect child"}`,
SubagentSessionID: "child-a",
}}
})
te.seedSession(t, "child-a", "my-app", 1, func(s *db.Session) {
s.ParentSessionID = new("parent")
s.RelationshipType = "subagent"
})
te.seedMessages(t, "child-a", 1)
w := te.get(t, "/api/v1/sessions/parent/md")
assertStatus(t, w, http.StatusOK)
if strings.Contains(w.Body.String(), `<subagent_session id="child-a"`) {
t.Fatalf("expected default markdown export to omit child session, got:\n%s", w.Body.String())
}
}
func TestMarkdownSessionExport_DepthAllRecurses(t *testing.T) {
te := setup(t)
te.seedSession(t, "root", "my-app", 1)
te.seedMessages(t, "root", 1, func(i int, m *db.Message) {
m.Role = "assistant"
m.Content = "[Task]\nchild work"
m.HasToolUse = true
m.ToolCalls = []db.ToolCall{{
ToolName: "Task",
Category: "Task",
ToolUseID: "toolu_child",
InputJSON: `{"prompt":"inspect child"}`,
SubagentSessionID: "child-a",
}}
})
te.seedSession(t, "child-a", "my-app", 1, func(s *db.Session) {
s.ParentSessionID = new("root")
s.RelationshipType = "subagent"
})
te.seedMessages(t, "child-a", 1, func(i int, m *db.Message) {
m.Role = "assistant"
m.Content = "[Task]\ngrandchild work"
m.HasToolUse = true
m.ToolCalls = []db.ToolCall{{
ToolName: "Task",
Category: "Task",
ToolUseID: "toolu_grandchild",
InputJSON: `{"prompt":"inspect grandchild"}`,
SubagentSessionID: "child-b",
}}
})
te.seedSession(t, "child-b", "my-app", 1, func(s *db.Session) {
s.ParentSessionID = new("child-a")
s.RelationshipType = "subagent"
})
te.seedMessages(t, "child-b", 1)
w := te.get(t, "/api/v1/sessions/root/md?depth=all")
assertStatus(t, w, http.StatusOK)
assertBodyContains(t, w, `<subagent_session id="child-a" parent_session_id="root" relationship="subagent"`)
assertBodyContains(t, w, `<subagent_session id="child-b" parent_session_id="child-a" relationship="subagent"`)
}
func TestPublishSession_NoToken(t *testing.T) {
t.Setenv("AGENTSVIEW_GITHUB_TOKEN", "")
t.Setenv("PATH", t.TempDir())
te := setup(t)
te.seedSession(t, "s1", "my-app", 3)
w := te.post(t, "/api/v1/sessions/s1/publish", "{}")
assertStatus(t, w, http.StatusUnauthorized)
}
func TestGetGithubConfig_UsesGitHubCLIAuthTokenFallback(t *testing.T) {
useGitHubCLIAuthTokenStub(t)
te := setup(t)
w := te.get(t, "/api/v1/config/github")
assertStatus(t, w, http.StatusOK)
assert.JSONEq(t, `{"configured":true}`, w.Body.String())
}
func TestGetGithubConfig_DoesNotUseGitHubCLIAuthTokenFallbackForForwardedRequest(t *testing.T) {
useGitHubCLIAuthTokenStub(t)
te := setup(t)
w := te.wrappedRequest(http.MethodGet, "/api/v1/config/github",
withHeader("X-Forwarded-For", "203.0.113.10"))
assertStatus(t, w, http.StatusOK)
assert.JSONEq(t, `{"configured":false}`, w.Body.String())
}
func TestGetGithubConfig_UsesSavedGitHubTokenForForwardedRequest(t *testing.T) {
useGitHubCLIAuthTokenStub(t)
te := setup(t)
te.srv.SetGithubToken("saved-token")
w := te.wrappedRequest(http.MethodGet, "/api/v1/config/github",
withHeader("X-Forwarded-For", "203.0.113.10"))
assertStatus(t, w, http.StatusOK)
assert.JSONEq(t, `{"configured":true}`, w.Body.String())
}
func TestGetSettings_UsesGitHubCLIAuthTokenFallback(t *testing.T) {
useGitHubCLIAuthTokenStub(t)
te := setup(t)
w := te.get(t, "/api/v1/settings")
assertStatus(t, w, http.StatusOK)
resp := decode[settingsConfigResponse](t, w)
assert.True(t, resp.GithubConfigured)
}
func TestSettingsRemainLockedInPGMode(t *testing.T) {
te := setupPGMode(t)
te.srv.SetGithubToken("settings-test-token")
w := te.get(t, "/api/v1/settings")
assertStatus(t, w, http.StatusOK)
type readOnlySettingsResponse struct {
ReadOnly bool `json:"read_only"`
}
resp := decode[readOnlySettingsResponse](t, w)
assert.True(t, resp.ReadOnly)
req := httptest.NewRequest(http.MethodPut, "/api/v1/settings",
strings.NewReader(`{"require_auth":true}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Origin", "http://127.0.0.1:0")
w = httptest.NewRecorder()
te.handler.ServeHTTP(w, req)
assertStatus(t, w, http.StatusNotImplemented)
assertBodyContains(t, w, "settings cannot be modified")
}
func TestSettingsRemainWritableInLocalNoSyncMode(t *testing.T) {
te := setupNoSyncMode(t)
te.srv.SetGithubToken("settings-test-token")
w := te.get(t, "/api/v1/settings")
assertStatus(t, w, http.StatusOK)
type readOnlySettingsResponse struct {
ReadOnly bool `json:"read_only"`
}
resp := decode[readOnlySettingsResponse](t, w)
assert.False(t, resp.ReadOnly)
req := httptest.NewRequest(http.MethodPut, "/api/v1/settings",
strings.NewReader(`{"require_auth":true}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Origin", "http://127.0.0.1:0")
w = httptest.NewRecorder()
te.handler.ServeHTTP(w, req)
assertStatus(t, w, http.StatusOK)
resp = decode[readOnlySettingsResponse](t, w)
assert.False(t, resp.ReadOnly)
}
func TestPublishSession_DoesNotUseGitHubCLIAuthTokenFallbackForForwardedRequest(t *testing.T) {
useGitHubCLIAuthTokenStub(t)
te := setup(t)
te.seedSession(t, "s1", "my-app", 3)
req := httptest.NewRequest(http.MethodPost, "/api/v1/sessions/s1/publish",
strings.NewReader("{}"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Origin", "http://127.0.0.1:0")
req.Header.Set("X-Forwarded-For", "203.0.113.10")
w := httptest.NewRecorder()
te.handler.ServeHTTP(w, req)
assertStatus(t, w, http.StatusUnauthorized)
}
func useGitHubCLIAuthTokenStub(t *testing.T) {
t.Helper()
t.Setenv("AGENTSVIEW_GITHUB_TOKEN", "")
binDir := t.TempDir()
if runtime.GOOS == "windows" {
ghPath := filepath.Join(binDir, "gh.cmd")
require.NoError(t, os.WriteFile(ghPath, []byte(`@echo off
if "%1"=="auth" if "%2"=="token" (
echo gh-token-from-cli
exit /b 0
)
exit /b 1
`), 0o644))
t.Setenv("PATH", binDir)
return
}
ghPath := filepath.Join(binDir, "gh")
require.NoError(t, os.WriteFile(ghPath, []byte(`#!/bin/sh
if [ "$1" = "auth" ] && [ "$2" = "token" ]; then
printf 'gh-token-from-cli\n'
exit 0
fi
exit 1
`), 0o755))
t.Setenv("PATH", binDir)
}
func TestSetGithubConfig_InvalidInput(t *testing.T) {
te := setup(t)
tests := []struct {
name string
body string
}{
{"EmptyToken", `{"token": ""}`},
{"InvalidJSON", `{bad json`},
{"WhitespaceToken", `{"token": " "}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := te.post(t, "/api/v1/config/github", tt.body)
assertStatus(t, w, http.StatusBadRequest)
})
}
}
func TestPublishSession_NotFound(t *testing.T) {
te := setup(t)
te.srv.SetGithubToken("fake-token")
w := te.post(t,
"/api/v1/sessions/nonexistent/publish", "{}")
assertStatus(t, w, http.StatusNotFound)
}
func TestExportSession_HTMLContent(t *testing.T) {
te := setup(t)
te.seedSession(t, "s1", "my-app", 3)
te.seedMessages(t, "s1", 3)
w := te.get(t, "/api/v1/sessions/s1/export")
assertStatus(t, w, http.StatusOK)
body := w.Body.String()
for _, want := range []string{
"<!DOCTYPE html>",
"<header>",
"<main>",
"message-content",
"message-role",
"Agent Session",
} {
if !strings.Contains(body, want) {
t.Errorf(
"expected to contain %q, got:\n%s",
want, body,
)
}
}
}
func TestUploadSessionVariants(t *testing.T) {
te := setup(t)
t.Run("stores token metadata", func(t *testing.T) {
assistantWithUsage, err := json.Marshal(map[string]any{
"type": "assistant",
"timestamp": tsEarlyS5,
"message": map[string]any{
"model": "claude-sonnet-4-20250514",
"usage": map[string]any{
"input_tokens": 100,
"cache_creation_input_tokens": 200,
"cache_read_input_tokens": 200,
"output_tokens": 200,
},
"content": []map[string]any{
{"type": "text", "text": "Hi!"},
},
},
})
if err != nil {
t.Fatalf("marshal assistant fixture: %v", err)
}
content := testjsonl.NewSessionBuilder().
AddClaudeUser(tsEarly, "Hello upload").
AddRaw(string(assistantWithUsage)).
String()
w := te.upload(t, "upload-test.jsonl", content,
"project=myproj&machine=remote")
assertStatus(t, w, http.StatusOK)
resp := decode[uploadResponse](t, w)
if resp.SessionID != "upload-test" {
t.Errorf("session_id = %v", resp.SessionID)
}
if resp.Project != "myproj" {
t.Errorf("project = %v", resp.Project)
}
if resp.Machine != "remote" {
t.Errorf("machine = %v", resp.Machine)
}
if resp.Messages != 2 {
t.Errorf("messages = %v", resp.Messages)
}
sess, err := te.db.GetSession(context.Background(), "upload-test")
if err != nil {
t.Fatalf("GetSession: %v", err)
}
if sess == nil {
t.Fatal("session not found in DB")
return
}
if sess.Project != "myproj" {
t.Errorf("stored project = %q", sess.Project)
}
if !sess.HasTotalOutputTokens {
t.Error("stored HasTotalOutputTokens = false, want true")
}
if !sess.HasPeakContextTokens {
t.Error("stored HasPeakContextTokens = false, want true")
}
if sess.TotalOutputTokens != 200 {
t.Errorf("stored TotalOutputTokens = %d, want 200",
sess.TotalOutputTokens)
}
if sess.PeakContextTokens != 500 {
t.Errorf("stored PeakContextTokens = %d, want 500",
sess.PeakContextTokens)
}
msgs, err := te.db.GetMessages(context.Background(), "upload-test", 0, 10, true)
if err != nil {
t.Fatalf("GetMessages: %v", err)
}
if len(msgs) != 2 {
t.Fatalf("message count = %d, want 2", len(msgs))
}
if !msgs[1].HasContextTokens {
t.Error("assistant HasContextTokens = false, want true")
}
if !msgs[1].HasOutputTokens {
t.Error("assistant HasOutputTokens = false, want true")
}
if msgs[1].OutputTokens != 200 {
t.Errorf("assistant OutputTokens = %d, want 200", msgs[1].OutputTokens)
}
if msgs[1].ContextTokens != 500 {
t.Errorf("assistant ContextTokens = %d, want 500", msgs[1].ContextTokens)
}
})
t.Run("sanitizes parsed rows", func(t *testing.T) {
rawInput := db.MaxPlausibleTokens + 200
rawOutput := db.MaxPlausibleTokens + 100
longModel := strings.Repeat("m", 160)
assistantWithBadUsage, err := json.Marshal(map[string]any{
"type": "assistant",
"timestamp": tsEarlyS5,
"message": map[string]any{
"model": longModel,
"usage": map[string]any{
"input_tokens": rawInput,
"output_tokens": rawOutput,
},
"content": []map[string]any{
{"type": "text", "text": "Hi!"},
},
},
})
require.NoError(t, err)
content := testjsonl.NewSessionBuilder().
AddClaudeUser(tsEarly, "Hello \x1b[31mupload\x07").
AddRaw(string(assistantWithBadUsage)).
String()
w := te.upload(t, "upload-sanitize.jsonl", content,
"project=myproj&machine=remote")
assertStatus(t, w, http.StatusOK)
msgs, err := te.db.GetMessages(
context.Background(), "upload-sanitize", 0, 10, true,
)
require.NoError(t, err)
require.Len(t, msgs, 2)
assert.Equal(t, "Hello [31mupload", msgs[0].Content)
assert.Equal(t, len("Hello [31mupload"), msgs[0].ContentLength)
assert.Len(t, msgs[1].Model, 128)
assert.Equal(t, db.MaxPlausibleTokens, msgs[1].ContextTokens)
assert.Equal(t, db.MaxPlausibleTokens, msgs[1].OutputTokens)
sess, err := te.db.GetSession(context.Background(), "upload-sanitize")
require.NoError(t, err)
require.NotNil(t, sess)
assert.Equal(t, db.MaxPlausibleTokens, sess.TotalOutputTokens)
assert.Equal(t, db.MaxPlausibleTokens, sess.PeakContextTokens)
})
t.Run("infers relationship type", func(t *testing.T) {
// Build a session whose first entry has a different sessionId,
// making it a child session. The filename starts with "agent-"
// so it should be inferred as a subagent.
content := testjsonl.NewSessionBuilder().
AddClaudeUserWithSessionID(
tsEarly, "Run task", "parent-session",
).
AddClaudeAssistant(tsEarlyS5, "Done.").
String()
w := te.upload(t, "agent-task42.jsonl", content,
"project=myproj&machine=remote")
assertStatus(t, w, http.StatusOK)
sess, err := te.db.GetSession(
context.Background(), "agent-task42",
)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
if sess == nil {
t.Fatal("session not found in DB")
return
}
if sess.RelationshipType != "subagent" {
t.Errorf(
"RelationshipType = %q, want %q",
sess.RelationshipType, "subagent",
)
}
})
}
func TestUploadSession_Errors(t *testing.T) {
te := setup(t)
tests := []struct {
name string
filename string
content string
query string
}{
{
"InvalidExtension",
"bad.txt", "content", "project=myproj",
},
{
"MissingProject",
"test.jsonl", "{}", "",
},
{
"TraversalProject",
"test.jsonl", "{}", "project=../../../etc",
},
{
"TraversalFilename",
"..secret.jsonl", "{}", "project=safe",
},
{
"DotPrefixProject",
"test.jsonl", "{}", "project=.hidden",
},
{
"DotPrefixFilename",
".hidden.jsonl", "{}", "project=safe",
},
{
"SlashInProject",
"test.jsonl", "{}", "project=foo/bar",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := te.upload(t,
tt.filename, tt.content, tt.query)
assertStatus(t, w, http.StatusBadRequest)
})
}
}
func TestUploadSession_ExcludedOrTrashedConflict(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T, te *testEnv, id string)
}{
{
name: "excluded",
setup: func(t *testing.T, te *testEnv, id string) {
t.Helper()
require.NoError(t, te.db.UpsertSession(db.Session{
ID: id, Project: "myproj", Machine: "remote", Agent: "claude",
}), "seed session")
require.NoError(t, te.db.DeleteSession(id), "DeleteSession")
},
},
{
name: "trashed",
setup: func(t *testing.T, te *testEnv, id string) {
t.Helper()
require.NoError(t, te.db.UpsertSession(db.Session{
ID: id, Project: "myproj", Machine: "remote", Agent: "claude",
}), "seed session")
require.NoError(t, te.db.SoftDeleteSession(id), "SoftDeleteSession")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
te := setup(t)
const id = "upload-conflict"
tt.setup(t, te, id)
content := testjsonl.NewSessionBuilder().
AddClaudeUser(tsEarly, "Hello upload").
AddClaudeAssistant(tsEarlyS5, "Done.").
String()
w := te.upload(t, id+".jsonl", content, "project=myproj&machine=remote")
assertStatus(t, w, http.StatusConflict)
assertErrorResponse(t, w, "session upload rejected: session is excluded or trashed")
destPath := filepath.Join(
te.dataDir, "uploads", "myproj", id+".jsonl",
)
if _, err := os.Stat(destPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("rejected upload file exists at %s: %v", destPath, err)
}
})
}
}
func TestUploadSession_MultiSessionConflictDoesNotPartiallyWrite(t *testing.T) {
te := setup(t)
const filename = "upload-multi-conflict.jsonl"
const mainID = "upload-multi-conflict"
const forkID = "upload-multi-conflict-i"
require.NoError(t, te.db.UpsertSession(db.Session{
ID: forkID, Project: "myproj", Machine: "remote", Agent: "claude",
}), "seed fork session")
require.NoError(t, te.db.DeleteSession(forkID), "DeleteSession")
content := testjsonl.NewSessionBuilder().
AddClaudeUserWithUUID(tsEarly, "q1", "a", "").
AddClaudeAssistantWithUUID("2024-01-01T10:00:01Z", "a1", "b", "a").
AddClaudeUserWithUUID(tsEarlyS5, "q2", "c", "b").
AddClaudeAssistantWithUUID("2024-01-01T10:00:06Z", "a2", "d", "c").
AddClaudeUserWithUUID("2024-01-01T10:00:07Z", "q3", "e", "d").
AddClaudeAssistantWithUUID("2024-01-01T10:00:08Z", "a3", "f", "e").
AddClaudeUserWithUUID("2024-01-01T10:00:09Z", "q4", "g", "f").
AddClaudeAssistantWithUUID("2024-01-01T10:00:10Z", "a4", "h", "g").
AddClaudeUserWithUUID("2024-01-01T10:00:11Z", "q5", "k", "h").
AddClaudeAssistantWithUUID("2024-01-01T10:00:12Z", "a5", "l", "k").
AddClaudeUserWithUUID("2024-01-01T10:00:13Z", "fork q", "i", "b").
AddClaudeAssistantWithUUID("2024-01-01T10:00:14Z", "fork a", "j", "i").
String()
w := te.upload(t, filename, content, "project=myproj&machine=remote")
assertStatus(t, w, http.StatusConflict)
assertErrorResponse(t, w, "session upload rejected: session is excluded or trashed")
main, err := te.db.GetSessionFull(context.Background(), mainID)
require.NoError(t, err, "GetSessionFull main")
if main != nil {
t.Fatalf("main session was partially written: %+v", main)
}
destPath := filepath.Join(te.dataDir, "uploads", "myproj", filename)
if _, err := os.Stat(destPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("rejected upload file exists at %s: %v", destPath, err)
}
}
func TestUploadSession_ReuploadPreservesPins(t *testing.T) {
te := setup(t)
initial := testjsonl.NewSessionBuilder().
AddClaudeUser(tsEarly, "original upload").
AddClaudeAssistant(tsEarlyS5, "original reply").
String()
w := te.upload(t, "upload-pinned.jsonl", initial,
"project=myproj&machine=remote")
assertStatus(t, w, http.StatusOK)
msgs, err := te.db.GetAllMessages(context.Background(), "upload-pinned")
require.NoError(t, err, "GetAllMessages")
require.Len(t, msgs, 2, "initial messages")
note := "keep this"
_, err = te.db.PinMessage("upload-pinned", msgs[0].ID, &note)
require.NoError(t, err, "PinMessage")
updated := testjsonl.NewSessionBuilder().
AddClaudeUser(tsEarly, "updated upload").
AddClaudeAssistant(tsEarlyS5, "updated reply").
String()
w = te.upload(t, "upload-pinned.jsonl", updated,
"project=myproj&machine=remote")
assertStatus(t, w, http.StatusOK)
pins, err := te.db.ListPinnedMessages(
context.Background(), "upload-pinned", "",
)
require.NoError(t, err, "ListPinnedMessages")
require.Len(t, pins, 1, "pins after re-upload")
if pins[0].Ordinal != 0 {
t.Fatalf("pin ordinal = %d, want 0", pins[0].Ordinal)
}
if pins[0].Note == nil || *pins[0].Note != note {
t.Fatalf("pin note = %v, want %q", pins[0].Note, note)
}
}
func TestUploadSession_EmptyFile(t *testing.T) {
te := setup(t)
w := te.upload(t, "empty.jsonl", "",
"project=myproj")
assertStatus(t, w, http.StatusOK)
resp := decode[uploadResponse](t, w)
if resp.Messages != 0 {
t.Errorf("messages = %v, want 0", resp.Messages)
}
}
// noFlushWriter wraps an http.ResponseWriter without Flusher.
type noFlushWriter struct {
http.ResponseWriter
}
func TestTriggerSync_NonStreaming(t *testing.T) {
te := setup(t)
// Seed a session file so we expect at least one session in the sync result.
te.writeSessionFile(t, "test-proj", "sync-test.jsonl",
testjsonl.NewSessionBuilder().
AddClaudeUser(tsZero, "msg"),
)
rec := httptest.NewRecorder()
nf := &noFlushWriter{rec}
req := httptest.NewRequest(http.MethodPost, "/api/v1/sync", nil)
req.Header.Set("Content-Type", "application/json")
te.handler.ServeHTTP(nf, req)
assertStatus(t, rec, http.StatusOK)
resp := decode[syncResultResponse](t, rec)
if resp.TotalSessions != 1 {
t.Fatalf("expected 1 total_session, got %d", resp.TotalSessions)
}
}
// flushRecorder wraps httptest.ResponseRecorder to implement
// http.Flusher, enabling SSE streaming tests.
type flushRecorder struct {
*httptest.ResponseRecorder
mu stdlibsync.Mutex
}
func (f *flushRecorder) Write(b []byte) (int, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.ResponseRecorder.Write(b)
}
func (f *flushRecorder) Flush() {
f.ResponseRecorder.Flush()
}
func (f *flushRecorder) BodyString() string {
f.mu.Lock()
defer f.mu.Unlock()
return f.Body.String()
}
func newFlushRecorder() *flushRecorder {
return &flushRecorder{ResponseRecorder: httptest.NewRecorder()}
}
// postSSE issues a POST to path through the wrapped handler and returns
// the flushRecorder after the handler finishes writing the SSE stream.
func (te *testEnv) postSSE(path string) *flushRecorder {
req := httptest.NewRequest(http.MethodPost, path, nil)
w := newFlushRecorder()
te.handler.ServeHTTP(w, req)
return w
}
// syncSSE triggers /api/v1/sync and returns the parsed "done" stats.
func (te *testEnv) syncSSE(t *testing.T) syncResultResponse {
t.Helper()
return parseSSEDoneStats(t, te.postSSE("/api/v1/sync").BodyString())
}
// resyncSSE triggers /api/v1/resync and returns the parsed "done" stats.
func (te *testEnv) resyncSSE(t *testing.T) syncResultResponse {
t.Helper()
return parseSSEDoneStats(t, te.postSSE("/api/v1/resync").BodyString())
}
func TestTriggerSync_SSE(t *testing.T) {
te := setup(t)
te.writeSessionFile(t, "test-proj", "sse-test.jsonl",
testjsonl.NewSessionBuilder().
AddClaudeUser(tsZero, "msg"),
)
w := te.postSSE("/api/v1/sync")
te.waitForSSEEvent(t, w, "done", 5*time.Second)
te.waitForSSEEvent(t, w, "progress", 5*time.Second)
}
func TestWatchSession_Events(t *testing.T) {
const watchPoll = 25 * time.Millisecond
t.Cleanup(sessionwatch.SetTimingsForTest(
watchPoll, 50*time.Millisecond,
))
te := setup(t)
b := testjsonl.NewSessionBuilder().
AddClaudeUser(tsZero, "initial")
content := b.String()
sessionPath := te.writeSessionFile(t, "watch-proj", "watch-sess.jsonl", b)
engine := sync.NewEngine(te.db, sync.EngineConfig{
AgentDirs: map[parser.AgentType][]string{
parser.AgentClaude: {te.claudeDir},
parser.AgentCodex: {filepath.Join(te.dataDir, "codex")},
},
Machine: "test",
})
engine.SyncAll(context.Background(), nil)
ctx, cancel := context.WithTimeout(
context.Background(), 5*time.Second,
)
defer cancel()
req := httptest.NewRequest(
http.MethodGet, "/api/v1/sessions/watch-sess/watch", nil,
).WithContext(ctx)
w := newFlushRecorder()
done := make(chan struct{})
go func() {
te.handler.ServeHTTP(w, req)
close(done)
}()
time.Sleep(2 * watchPoll)
updated := content + testjsonl.NewSessionBuilder().
AddClaudeAssistant(tsZeroS5, "response").
String()
if err := os.WriteFile(
sessionPath, []byte(updated), 0o644,
); err != nil {
t.Fatalf("writing updated session file: %v", err)
}
// Sync the file to update the DB — in production the
// file watcher does this via SyncPaths.
engine.SyncPaths([]string{sessionPath})
te.waitForSSEEvent(t, w, "session_updated", 5*time.Second)
cancel()
<-done
}
func TestWatchSession_FileDisappearAndResolve(t *testing.T) {
const watchPoll = 25 * time.Millisecond
t.Cleanup(sessionwatch.SetTimingsForTest(
watchPoll, 50*time.Millisecond,
))
te := setup(t)
b := testjsonl.NewSessionBuilder().
AddClaudeUser(tsZero, "initial")
content := b.String()
sessionPath := te.writeSessionFile(t, "vanish-proj", "vanish-sess.jsonl", b)
engine := sync.NewEngine(te.db, sync.EngineConfig{
AgentDirs: map[parser.AgentType][]string{
parser.AgentClaude: {te.claudeDir},
parser.AgentCodex: {filepath.Join(te.dataDir, "codex")},
},
Machine: "test",
})
engine.SyncAll(context.Background(), nil)
ctx, cancel := context.WithTimeout(
context.Background(), 15*time.Second,
)
defer cancel()
req := httptest.NewRequest(
http.MethodGet, "/api/v1/sessions/vanish-sess/watch", nil,
).WithContext(ctx)
w := newFlushRecorder()
done := make(chan struct{})
go func() {
te.handler.ServeHTTP(w, req)
close(done)
}()
// Let the monitor start and record the initial mtime.
time.Sleep(2 * watchPoll)
// Delete the source file to simulate disappearance.
if err := os.Remove(sessionPath); err != nil {
t.Fatalf("removing session file: %v", err)
}
// Wait for at least one poll tick to notice the missing
// file and clear the cached path.
time.Sleep(2 * watchPoll)
// Recreate the file with updated content at a NEW location
// so we verify that FindSourceFile re-scans and the
// fallback sync picks up the change.
updated := content + testjsonl.NewSessionBuilder().
AddClaudeAssistant(tsZeroS5, "recovered").
String()
te.writeProjectFile(t, "moved-proj", "vanish-sess.jsonl", updated)
te.waitForSSEEvent(t, w, "session_updated", 12*time.Second)
cancel()
<-done
}
func TestTriggerSync_SSEEvents(t *testing.T) {
te := setup(t)
for _, name := range []string{"a", "b"} {
te.writeSessionFile(t, "sse-proj", name+".jsonl",
testjsonl.NewSessionBuilder().
AddClaudeUser(tsZero, fmt.Sprintf("msg %s", name)),
)
}
w := te.postSSE("/api/v1/sync")
events := parseSSE(w.BodyString())
hasDone := false
hasProgress := false
for _, e := range events {
if e.Event == "done" {
hasDone = true
}
if e.Event == "progress" {
hasProgress = true
}
}
if !hasDone {
t.Error("expected done event")
}
if !hasProgress {
t.Error("expected progress event")
}
}
func TestResyncEndpoint(t *testing.T) {
te := setup(t)
te.writeSessionFile(t, "resync-proj", "resync.jsonl",
testjsonl.NewSessionBuilder().
AddClaudeUser(tsZero, "msg resync"),
)
// Initial sync — session gets processed normally.
syncStats := te.syncSSE(t)
if syncStats.Synced != 1 {
t.Fatalf("initial sync: synced = %d, want 1",
syncStats.Synced)
}
// Second normal sync — file is unchanged so it's skipped.
sync2Stats := te.syncSSE(t)
if sync2Stats.Synced != 0 {
t.Fatalf("second sync: synced = %d, want 0 (skipped)",
sync2Stats.Synced)
}
// Resync — should re-process the same unchanged file.
resyncStats := te.resyncSSE(t)
if resyncStats.Synced != 1 {
t.Fatalf("resync: synced = %d, want 1 (reprocessed)",
resyncStats.Synced)
}
}
func TestResyncEndpointFallsBackToIncrementalWhenResyncAborts(t *testing.T) {
te := setup(t)
sessionPath := te.writeSessionFile(t, "resync-proj", "resync.jsonl",
testjsonl.NewSessionBuilder().
AddClaudeUser(tsZero, "msg resync"),
)
syncStats := te.syncSSE(t)
require.Equal(t, 1, syncStats.Synced, "initial sync")
require.NoError(t, os.Remove(sessionPath), "remove session file")
resyncStats := te.resyncSSE(t)
assert.False(t, resyncStats.Aborted,
"route should return the incremental fallback result")
assert.Empty(t, resyncStats.Warnings,
"aborted resync warnings should not be the final response")
assert.Equal(t, 0, resyncStats.Synced)
assert.Equal(t, 0, resyncStats.Failed)
}
// TestResyncPreservesDataThroughSwap verifies the full resync
// flow end-to-end: initial sync, resync (which rebuilds the DB
// from scratch and swaps files), then verifies sessions and
// messages are accessible via the API. This exercises the
// close-rename-reopen sequence that is critical on Windows.
func TestResyncPreservesDataThroughSwap(t *testing.T) {
te := setup(t)
// Write two session files in different projects.
te.writeSessionFile(t, "proj-a", "a.jsonl",
testjsonl.NewSessionBuilder().
AddClaudeUser(tsZero, "hello from proj-a").
AddClaudeAssistant(tsZeroS5, "response a"),
)
te.writeSessionFile(t, "proj-b", "b.jsonl",
testjsonl.NewSessionBuilder().
AddClaudeUser(tsEarly, "hello from proj-b").
AddClaudeAssistant(tsEarlyS5, "response b"),
)
// Initial sync.
syncStats := te.syncSSE(t)
if syncStats.Synced != 2 {
t.Fatalf(
"initial sync: synced = %d, want 2",
syncStats.Synced,
)
}
// Verify sessions are accessible before resync.
w := te.get(t,
"/api/v1/sessions?include_one_shot=true",
)
assertStatus(t, w, http.StatusOK)
before := decode[sessionListResponse](t, w)
if before.Total != 2 {
t.Fatalf(
"before resync: total = %d, want 2",
before.Total,
)
}
// Resync — rebuilds the database from scratch and swaps.
resyncStats := te.resyncSSE(t)
if resyncStats.Synced != 2 {
t.Fatalf(
"resync: synced = %d, want 2",
resyncStats.Synced,
)
}
// Verify sessions survived the DB swap.
w = te.get(t,
"/api/v1/sessions?include_one_shot=true",
)
assertStatus(t, w, http.StatusOK)
after := decode[sessionListResponse](t, w)
if after.Total != 2 {
t.Fatalf(
"after resync: total = %d, want 2",
after.Total,
)
}
// Verify messages are accessible for each session.
for _, s := range after.Sessions {
msgW := te.get(t, fmt.Sprintf(
"/api/v1/sessions/%s/messages", s.ID,
))
assertStatus(t, msgW, http.StatusOK)
msgs := decode[messageListResponse](t, msgW)
if msgs.Count < 2 {
t.Errorf(
"session %s: messages = %d, want >= 2",
s.ID, msgs.Count,
)
}
}
// Verify projects endpoint works (exercises reader pool).
projW := te.get(t,
"/api/v1/projects?include_one_shot=true",
)
assertStatus(t, projW, http.StatusOK)
projects := decode[projectListResponse](t, projW)
if len(projects.Projects) != 2 {
t.Errorf(
"projects = %d, want 2",
len(projects.Projects),
)
}
}
// TestResyncConcurrentReads verifies that concurrent API reads
// don't panic or deadlock during resync, and that reads succeed
// after resync completes. During the close->rename->reopen
// window, SQLite may return various transient errors (database
// is closed, no such file, no such table). These are expected
// since resync is a rare manual operation.
func TestResyncConcurrentReads(t *testing.T) {
te := setup(t)
te.writeSessionFile(t, "conc-proj", "c.jsonl",
testjsonl.NewSessionBuilder().
AddClaudeUser(tsZero, "concurrent test"),
)
// Initial sync.
te.postSSE("/api/v1/sync")
// Spin up concurrent readers with a barrier to ensure
// they are actively querying before resync starts.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var wg stdlibsync.WaitGroup
var readersReady stdlibsync.WaitGroup
readersReady.Add(4)
for range 4 {
wg.Go(func() {
readySignaled := false
for {
select {
case <-ctx.Done():
return
default:
}
req := httptest.NewRequest(
http.MethodGet, "/api/v1/sessions",
nil,
)
w := httptest.NewRecorder()
te.handler.ServeHTTP(w, req)
if !readySignaled && w.Code == http.StatusOK {
readersReady.Done()
readySignaled = true
}
// Transient 500s are expected during the
// close->reopen window. We only care that
// no panics/deadlocks occur and reads
// succeed after resync (verified below).
}
})
}
// Wait for all readers to complete at least one
// successful request before triggering resync.
readersReady.Wait()
// Trigger resync while readers are active.
resyncStats := te.resyncSSE(t)
if resyncStats.Synced != 1 {
t.Errorf(
"resync: synced = %d, want 1",
resyncStats.Synced,
)
}
cancel()
wg.Wait()
// The real assertion: reads must succeed after resync
// completes. If the close->reopen cycle left the DB
// in a bad state, this will fail.
w := te.get(t,
"/api/v1/sessions?include_one_shot=true",
)
assertStatus(t, w, http.StatusOK)
resp := decode[sessionListResponse](t, w)
if resp.Total != 1 {
t.Errorf("post-resync sessions = %d, want 1", resp.Total)
}
}
// parseSSEDoneStats extracts the SyncStats from the "done" SSE
// event in a response body. Fails the test if no done event.
func parseSSEDoneStats(
t *testing.T, body string,
) syncResultResponse {
t.Helper()
events := parseSSE(body)
for _, e := range events {
if e.Event == "done" {
var stats syncResultResponse
if err := json.Unmarshal([]byte(e.Data), &stats); err != nil {
t.Fatalf("parsing done data: %v", err)
}
return stats
}
}
t.Fatal("no done event in SSE stream")
return syncResultResponse{}
}
func TestListSessions_Limits(t *testing.T) {
te := setup(t)
totalSessions := db.MaxSessionLimit + 5
writes := make([]db.SessionBatchWrite, 0, totalSessions)
for i := range totalSessions {
writes = append(writes, db.SessionBatchWrite{
Session: db.Session{
ID: fmt.Sprintf("s%d", i),
Project: "my-app",
Machine: "test",
Agent: "claude",
MessageCount: 1,
UserMessageCount: 2,
StartedAt: new(tsSeed),
EndedAt: new(tsSeedEnd),
FirstMessage: new("Hello world"),
},
})
}
result, err := te.db.WriteSessionBatchAtomic(writes)
require.NoError(t, err)
require.Equal(t, totalSessions, result.WrittenSessions)
tests := []struct {
name string
limitVal string
wantCount int
}{
{"DefaultLimit", "", db.DefaultSessionLimit},
{"ExplicitLimit", "limit=10", 10},
{"LargeLimit", "limit=1000", db.MaxSessionLimit},
{"ExactMax", fmt.Sprintf("limit=%d", db.MaxSessionLimit), db.MaxSessionLimit},
{"JustOver", fmt.Sprintf("limit=%d", db.MaxSessionLimit+1), db.MaxSessionLimit},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := "/api/v1/sessions"
if tt.limitVal != "" {
path += "?" + tt.limitVal
}
w := te.get(t, path)
assertStatus(t, w, http.StatusOK)
resp := decode[sessionListResponse](t, w)
if len(resp.Sessions) != tt.wantCount {
t.Errorf("limit=%q: got %d sessions, want %d",
tt.limitVal, len(resp.Sessions), tt.wantCount)
}
})
}
}
func TestGetMessages_Limits(t *testing.T) {
te := setup(t)
te.seedSession(t, "s1", "my-app", db.MaxMessageLimit+5)
te.seedMessages(t, "s1", db.MaxMessageLimit+5)
tests := []struct {
name string
limitVal string
wantCount int
}{
{"DefaultLimit", "", db.DefaultMessageLimit},
{"ExplicitLimit", "limit=10", 10},
{"LargeLimit", "limit=2000", db.MaxMessageLimit},
{"ExactMax", fmt.Sprintf("limit=%d", db.MaxMessageLimit), db.MaxMessageLimit},
{"JustOver", fmt.Sprintf("limit=%d", db.MaxMessageLimit+1), db.MaxMessageLimit},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := "/api/v1/sessions/s1/messages"
if tt.limitVal != "" {
path += "?" + tt.limitVal
}
w := te.get(t, path)
assertStatus(t, w, http.StatusOK)
resp := decode[messageListResponse](t, w)
if len(resp.Messages) != tt.wantCount {
t.Errorf("limit=%q: got %d messages, want %d",
tt.limitVal, len(resp.Messages), tt.wantCount)
}
})
}
}
// TestGetMessages_InvalidDirection verifies that the HTTP
// endpoint rejects direction values outside {asc, desc} with
// 400 instead of silently coercing to asc. The CLI enforces the
// same contract; both must agree.
func TestGetMessages_InvalidDirection(t *testing.T) {
te := setup(t)
te.seedSession(t, "s1", "my-app", 1)
w := te.get(t, "/api/v1/sessions/s1/messages?direction=backwards")
assertStatus(t, w, http.StatusBadRequest)
assert.Contains(t, w.Body.String(), "direction",
"error body should mention 'direction'")
}
// TestHandleWatchSession_UnknownID_Returns404 verifies that the
// SSE watch endpoint fails fast on an unknown session id so a
// typo doesn't leave a heartbeat stream open indefinitely.
func TestHandleWatchSession_UnknownID_Returns404(t *testing.T) {
te := setup(t)
w := te.get(t, "/api/v1/sessions/no-such-id/watch")
assertStatus(t, w, http.StatusNotFound)
assert.Contains(t, w.Body.String(), "no-such-id")
}
func TestGetVersion(t *testing.T) {
v := server.VersionInfo{
Version: "v1.2.3",
Commit: "abc1234",
BuildDate: "2025-01-15T00:00:00Z",
InsightGenerationAvailable: true,
}
te := setupWithServerOpts(t, []server.Option{
server.WithVersion(v),
})
w := te.get(t, "/api/v1/version")
assertStatus(t, w, http.StatusOK)
resp := decode[server.VersionInfo](t, w)
if resp.Version != "v1.2.3" {
t.Errorf("version = %q, want v1.2.3", resp.Version)
}
if resp.Commit != "abc1234" {
t.Errorf("commit = %q, want abc1234", resp.Commit)
}
if resp.BuildDate != "2025-01-15T00:00:00Z" {
t.Errorf(
"build_date = %q, want 2025-01-15T00:00:00Z",
resp.BuildDate,
)
}
assert.True(t, resp.InsightGenerationAvailable)
assert.Equal(t, 2, resp.APIVersion)
assert.Equal(t, db.CurrentDataVersion(), resp.DataVersion)
}
func TestGetVersion_Default(t *testing.T) {
te := setup(t)
w := te.get(t, "/api/v1/version")
assertStatus(t, w, http.StatusOK)
resp := decode[server.VersionInfo](t, w)
if resp.Version != "" {
t.Errorf("version = %q, want empty", resp.Version)
}
assert.Equal(t, 2, resp.APIVersion)
assert.Equal(t, db.CurrentDataVersion(), resp.DataVersion)
}
func TestFindAvailablePortSkipsOccupied(t *testing.T) {
// Bind a port on 127.0.0.1 so FindAvailablePort must skip it.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
occupied := ln.Addr().(*net.TCPAddr).Port
got := server.FindAvailablePort("127.0.0.1", occupied)
if got == occupied {
t.Errorf(
"FindAvailablePort returned occupied port %d", occupied,
)
}
}
func TestFindAvailablePortZeroReturnsAssignedPort(t *testing.T) {
got := server.FindAvailablePort("127.0.0.1", 0)
if got == 0 {
t.Fatal("FindAvailablePort returned literal port 0")
}
}
func TestEvents_StreamsDataChangedAfterSync(t *testing.T) {
te := setup(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req := httptest.NewRequest(http.MethodGet, "/api/v1/events", nil).WithContext(ctx)
w := newFlushRecorder()
done := make(chan struct{})
go func() {
te.handler.ServeHTTP(w, req)
close(done)
}()
// Emit directly via the broadcaster to isolate the handler
// from sync engine timing.
te.emitUntilSSEEvent(t, w, "messages", "data_changed", 3*time.Second)
cancel()
<-done
}
func TestEvents_StreamsInLocalNoSyncMode(t *testing.T) {
te := setupNoSyncMode(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req := httptest.NewRequest(http.MethodGet, "/api/v1/events", nil).WithContext(ctx)
w := newFlushRecorder()
done := make(chan struct{})
go func() {
te.handler.ServeHTTP(w, req)
close(done)
}()
te.emitUntilSSEEvent(t, w, "sessions", "data_changed", 3*time.Second)
cancel()
<-done
}
func TestEvents_ReturnsServiceUnavailableInPGMode(t *testing.T) {
// A server with engine == nil (PG serve mode) must not stream.
te := setupPGMode(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/events", nil)
w := httptest.NewRecorder()
te.handler.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("got status %d, want 503", w.Code)
}
if got := w.Header().Get("Retry-After"); got != "300" {
t.Errorf("got Retry-After %q, want 300", got)
}
}
func withAuth(token string) setupOption {
return func(c *config.Config) {
c.RequireAuth = true
c.AuthToken = token
}
}
func TestEvents_AuthViaQueryTokenSucceeds(t *testing.T) {
te := setup(t, withAuth("secret"))
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/events?token=secret", nil).WithContext(ctx)
w := newFlushRecorder()
done := make(chan struct{})
go func() {
te.handler.ServeHTTP(w, req)
close(done)
}()
te.emitUntilSSEEvent(t, w, "messages", "data_changed", 2*time.Second)
cancel()
<-done
}
func TestEvents_AuthViaBearerHeaderSucceeds(t *testing.T) {
te := setup(t, withAuth("secret"))
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/events", nil).WithContext(ctx)
req.Header.Set("Authorization", "Bearer secret")
w := newFlushRecorder()
done := make(chan struct{})
go func() {
te.handler.ServeHTTP(w, req)
close(done)
}()
te.emitUntilSSEEvent(t, w, "messages", "data_changed", 2*time.Second)
cancel()
<-done
}
func TestEvents_AuthMissingTokenReturns401(t *testing.T) {
te := setup(t, withAuth("secret"))
req := httptest.NewRequest(http.MethodGet, "/api/v1/events", nil)
w := httptest.NewRecorder()
te.handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("got status %d, want 401", w.Code)
}
}
func TestEvents_AuthInvalidTokenReturns401(t *testing.T) {
te := setup(t, withAuth("secret"))
req := httptest.NewRequest(http.MethodGet,
"/api/v1/events?token=wrong", nil)
w := httptest.NewRecorder()
te.handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("got status %d, want 401", w.Code)
}
}
// TestSessionWatch_AuthViaQueryTokenSucceeds guards the existing
// /api/v1/sessions/{id}/watch query-token flow against future
// isSSEPath changes. The auth path now routes both /watch and
// /api/v1/events through the same helper; this test ensures the
// session-watch branch keeps working.
func TestSessionWatch_AuthViaQueryTokenSucceeds(t *testing.T) {
te := setup(t, withAuth("secret"))
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/sessions/missing/watch?token=secret", nil).WithContext(ctx)
w := newFlushRecorder()
done := make(chan struct{})
go func() {
te.handler.ServeHTTP(w, req)
close(done)
}()
// The handler opens an SSE stream and starts emitting
// heartbeats even for unknown sessions; a quick wait
// confirms we got past auth (anything non-401 counts).
time.Sleep(100 * time.Millisecond)
cancel()
<-done
if w.Code == http.StatusUnauthorized {
t.Fatalf("query-token auth failed on /watch: status %d", w.Code)
}
}
func TestHandleToolCalls_Basic(t *testing.T) {
te := setup(t)
te.seedSession(t, "tc-1", "my-app", 2)
te.seedMessages(t, "tc-1", 2, func(i int, m *db.Message) {
if i == 1 {
m.Role = "assistant"
m.HasToolUse = true
m.ToolCalls = []db.ToolCall{
{
ToolName: "Read",
Category: "Read",
ToolUseID: "toolu_1",
InputJSON: `{"file_path":"/tmp/x"}`,
},
{
ToolName: "Bash",
Category: "Bash",
ToolUseID: "toolu_2",
InputJSON: `{"command":"ls"}`,
},
}
}
})
w := te.get(t, "/api/v1/sessions/tc-1/tool-calls")
assertStatus(t, w, http.StatusOK)
var body struct {
ToolCalls []service.ToolCall `json:"tool_calls"`
Count int `json:"count"`
}
require.NoError(t, json.NewDecoder(w.Body).Decode(&body))
require.Equal(t, 2, body.Count)
require.Len(t, body.ToolCalls, 2)
assert.Equal(t, "Read", body.ToolCalls[0].ToolName)
assert.Equal(t, "toolu_1", body.ToolCalls[0].ToolUseID)
assert.Equal(t, `{"file_path":"/tmp/x"}`, body.ToolCalls[0].InputJSON)
assert.Equal(t, "Bash", body.ToolCalls[1].ToolName)
assert.NotEmpty(t, body.ToolCalls[0].Timestamp)
assert.Equal(t, 1, body.ToolCalls[0].Ordinal)
}
func TestHandleSyncSession_MissingFields(t *testing.T) {
te := setup(t)
body := strings.NewReader(`{}`)
req := httptest.NewRequest(http.MethodPost,
"/api/v1/sessions/sync", body)
w := httptest.NewRecorder()
te.handler.ServeHTTP(w, req)
assertStatus(t, w, http.StatusBadRequest)
}
func TestHandleSyncSession_BothFields(t *testing.T) {
te := setup(t)
body := strings.NewReader(
`{"path":"/tmp/a","id":"s-1"}`)
req := httptest.NewRequest(http.MethodPost,
"/api/v1/sessions/sync", body)
w := httptest.NewRecorder()
te.handler.ServeHTTP(w, req)
assertStatus(t, w, http.StatusBadRequest)
}
func TestHandleSyncSession_InvalidJSON(t *testing.T) {
te := setup(t)
body := strings.NewReader(`not json`)
req := httptest.NewRequest(http.MethodPost,
"/api/v1/sessions/sync", body)
w := httptest.NewRecorder()
te.handler.ServeHTTP(w, req)
assertStatus(t, w, http.StatusBadRequest)
}