package main // api_test.go — bearer auth 401s, events endpoint, spawn validation. import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" ) func TestAPIAuthRejectsMissingOrBadToken(t *testing.T) { ts, _ := newTestServer(t) cases := []struct { name string header string }{ {"none", ""}, {"wrong", "Bearer nope"}, {"not-bearer", testToken}, {"empty", "Bearer "}, } for _, tc := range cases { req, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/sessions", nil) if tc.header != "" { req.Header.Set("Authorization", tc.header) } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("%s: %v", tc.name, err) } resp.Body.Close() if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("%s: status = %d, want 401", tc.name, resp.StatusCode) } } } func TestAPIEventsEndpointParams(t *testing.T) { ts, _ := newTestServer(t) get := func(path string) int { req, _ := http.NewRequest(http.MethodGet, ts.URL+path, nil) req.Header.Set("Authorization", "Bearer "+testToken) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("get %s: %v", path, err) } resp.Body.Close() return resp.StatusCode } if code := get("/api/sessions/s1/events?after=abc"); code != http.StatusBadRequest { t.Fatalf("after=abc → %d, want 400", code) } if code := get("/api/sessions/s1/events?limit=-1"); code != http.StatusBadRequest { t.Fatalf("limit=-1 → %d, want 400", code) } if code := get("/api/sessions/unknown/events"); code != http.StatusOK { t.Fatalf("unknown session → %d, want 200 with []", code) } } func TestAPIEventsReplayShape(t *testing.T) { ts, store := newTestServer(t) // Simulate a connected session by writing directly through the store. name := "sess" if err := store.UpsertSession(SessionInfo{ID: "s1", Name: &name, Cwd: "/w", Model: "glm-5.3", Provider: "zai-renaud", StartedAt: 1}); err != nil { t.Fatalf("upsert: %v", err) } if err := store.AppendEvent(Event{SessionID: "s1", Seq: 1, TS: 10, Type: evMessageEnd, Payload: []byte(`{"message":{"role":"user","id":"m1","text":"hi"}}`)}); err != nil { t.Fatalf("append: %v", err) } req, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/sessions/s1/events?after=0&limit=10", nil) req.Header.Set("Authorization", "Bearer "+testToken) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("events: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("events status = %d", resp.StatusCode) } if ct := resp.Header.Get("Content-Type"); ct != "application/json" { t.Fatalf("content-type = %q", ct) } var frames []map[string]any if err := json.NewDecoder(resp.Body).Decode(&frames); err != nil { t.Fatalf("decode: %v", err) } if len(frames) != 1 { t.Fatalf("frames = %d, want 1", len(frames)) } f := frames[0] if f["type"] != evMessageEnd || f["sessionId"] != "s1" || f["seq"].(float64) != 1 || f["v"].(float64) != 1 { t.Fatalf("frame = %v", f) } msg := f["message"].(map[string]any) if msg["text"] != "hi" { t.Fatalf("message = %v", msg) } } func TestAPISpawnValidation(t *testing.T) { ts, _ := newTestServer(t) post := func(body string) int { req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/spawn", strings.NewReader(body)) req.Header.Set("Authorization", "Bearer "+testToken) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("spawn: %v", err) } resp.Body.Close() return resp.StatusCode } if code := post(`{"repo":"no-slash"}`); code != http.StatusBadRequest { t.Fatalf("bad repo → %d, want 400", code) } // branch is passed to git: anything outside the safe charset → 400 for _, branch := range []string{ "main; rm -rf /", "feature one", // space "-oProxyCommand=x", // leading option-ish "main$(id)", } { if code := post(`{"repo":"group/project","branch":"` + branch + `"}`); code != http.StatusBadRequest { t.Fatalf("branch %q → %d, want 400", branch, code) } } // empty branch (default) stays accepted at this validation layer if code := post(`{"repo":"group/project","branch":""}`); code == http.StatusBadRequest { t.Fatal("empty branch must not be rejected as invalid") } } func TestAPIRecoverMiddleware(t *testing.T) { // A handler that panics must yield 500, not kill the server. handler := recoverMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { panic("boom") })) rec := httptest.NewRecorder() handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) if rec.Code != http.StatusInternalServerError { t.Fatalf("panic → %d, want 500", rec.Code) } if !strings.Contains(rec.Body.String(), "error") { t.Fatalf("body = %q", rec.Body.String()) } }