Skip to content

Commit 2a2691e

Browse files
leo-aa88cursoragent
andcommitted
test(inspect): close remaining PR #136 review gaps
- Security headers (CSP, nosniff) on all inspector HTTP responses - Panic on broken embed layout; sync.RWMutex for BoundAddr (Port 0 reserved) - CLI↔API contract test: logs -o json events vs /api/runs/{id} - Integration test: init/apply/run then inspector API over httptest - statejson run empty-input contract test Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent d766041 commit 2a2691e

7 files changed

Lines changed: 261 additions & 9 deletions

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package cli
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"io"
8+
"net/http"
9+
"net/http/httptest"
10+
"path/filepath"
11+
"testing"
12+
13+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/inspect"
14+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/state/sqlite"
15+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/statejson"
16+
)
17+
18+
// TestLogsInspectContract_eventsJSON verifies agentctl logs -o json events match inspector /api/runs/{id}.
19+
func TestLogsInspectContract_eventsJSON(t *testing.T) {
20+
db := filepath.Join(t.TempDir(), "contract.db")
21+
root := runProjRoot(t)
22+
23+
ResetGlobalsForTest()
24+
runCmd := NewRootCmd()
25+
runCmd.SetOut(io.Discard)
26+
runCmd.SetErr(io.Discard)
27+
runCmd.SetArgs([]string{
28+
"run", "workflow/demo",
29+
"--project", root,
30+
"--state", db,
31+
"--input", "topic=contract-test",
32+
})
33+
var runOut bytes.Buffer
34+
runCmd.SetOut(&runOut)
35+
if err := runCmd.Execute(); err != nil {
36+
t.Fatal(err)
37+
}
38+
runID := extractRunID(runOut.String())
39+
if runID == "" {
40+
t.Fatal("no run id")
41+
}
42+
43+
ResetGlobalsForTest()
44+
var logsOut bytes.Buffer
45+
logsCmd := NewRootCmd()
46+
logsCmd.SetOut(&logsOut)
47+
logsCmd.SetErr(&logsOut)
48+
logsCmd.SetArgs([]string{"logs", "--run", runID, "--project", root, "--state", db, "-o", "json"})
49+
if err := logsCmd.Execute(); err != nil {
50+
t.Fatal(err)
51+
}
52+
var logsPayload statejson.RunEventsPayload
53+
if err := json.Unmarshal(logsOut.Bytes(), &logsPayload); err != nil {
54+
t.Fatalf("logs json: %v\n%s", err, logsOut.String())
55+
}
56+
57+
ctx := context.Background()
58+
st, err := sqlite.OpenReadOnly(ctx, db)
59+
if err != nil {
60+
t.Fatal(err)
61+
}
62+
t.Cleanup(func() { _ = st.Close() })
63+
64+
srv, err := inspect.NewServer(st, inspect.Config{StatePath: db, Env: "staging"})
65+
if err != nil {
66+
t.Fatal(err)
67+
}
68+
ts := httptest.NewServer(srv.Handler())
69+
t.Cleanup(ts.Close)
70+
71+
res, err := http.Get(ts.URL + "/api/runs/" + runID)
72+
if err != nil {
73+
t.Fatal(err)
74+
}
75+
defer res.Body.Close()
76+
var api inspect.RunDetailResponse
77+
if err := json.NewDecoder(res.Body).Decode(&api); err != nil {
78+
t.Fatal(err)
79+
}
80+
81+
logsEvents, err := json.Marshal(logsPayload.Events)
82+
if err != nil {
83+
t.Fatal(err)
84+
}
85+
apiEvents, err := json.Marshal(api.Events)
86+
if err != nil {
87+
t.Fatal(err)
88+
}
89+
if string(logsEvents) != string(apiEvents) {
90+
t.Fatalf("events mismatch:\nlogs %s\napi %s", logsEvents, apiEvents)
91+
}
92+
}

internal/inspect/embed.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,18 @@ import (
99
//go:embed web/*
1010
var webEmbed embed.FS
1111

12-
func (s *Server) staticFS() http.Handler {
12+
var staticAssets http.FileSystem
13+
14+
func init() {
1315
sub, err := fs.Sub(webEmbed, "web/static")
1416
if err != nil {
15-
return http.FileServer(http.FS(webEmbed))
17+
panic("inspect: embed web/static: " + err.Error())
1618
}
17-
return http.FileServer(http.FS(sub))
19+
staticAssets = http.FS(sub)
20+
}
21+
22+
func (s *Server) staticFS() http.Handler {
23+
return http.FileServer(staticAssets)
1824
}
1925

2026
func (s *Server) staticHandler(name string) http.Handler {

internal/inspect/middleware.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package inspect
2+
3+
import "net/http"
4+
5+
// securityHeaders sets baseline headers for the embedded localhost UI.
6+
func securityHeaders(next http.Handler) http.Handler {
7+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
8+
w.Header().Set("X-Content-Type-Options", "nosniff")
9+
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'")
10+
next.ServeHTTP(w, r)
11+
})
12+
}

internal/inspect/server.go

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"net"
1010
"net/http"
1111
"strings"
12+
"sync"
1213
"time"
1314

1415
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/state"
@@ -45,9 +46,11 @@ type Config struct {
4546

4647
// Server serves read-only JSON and static UI over HTTP.
4748
type Server struct {
48-
store *sqlite.Store
49-
cfg Config
50-
mux *http.ServeMux
49+
store *sqlite.Store
50+
cfg Config
51+
mux *http.ServeMux
52+
mu sync.RWMutex
53+
boundAddr string // set after Listen; use [Server.BoundAddr] when Port is 0
5154
}
5255

5356
// NewServer wires handlers for a read-only SQLite store opened via [sqlite.OpenReadOnly].
@@ -58,7 +61,7 @@ func NewServer(st *sqlite.Store, cfg Config) (*Server, error) {
5861
if strings.TrimSpace(cfg.StatePath) == "" {
5962
return nil, errors.New("inspect: empty state path")
6063
}
61-
if cfg.Port <= 0 {
64+
if cfg.Port < 0 {
6265
cfg.Port = DefaultPort
6366
}
6467
s := &Server{store: st, cfg: cfg, mux: http.NewServeMux()}
@@ -75,16 +78,31 @@ func (s *Server) registerRoutes() {
7578
s.mux.Handle("GET /static/", http.StripPrefix("/static/", s.staticFS()))
7679
}
7780

78-
// Handler returns the root HTTP handler (GET/HEAD only).
81+
// Handler returns the root HTTP handler (GET/HEAD only, security headers on responses).
7982
func (s *Server) Handler() http.Handler {
80-
return RejectMutation(s.mux)
83+
return securityHeaders(RejectMutation(s.mux))
84+
}
85+
86+
// BoundAddr returns the address the server is listening on after [Server.ListenAndServe] starts.
87+
// When Port is 0, this is the kernel-assigned port (127.0.0.1:NNNN).
88+
func (s *Server) BoundAddr() string {
89+
s.mu.RLock()
90+
ba := s.boundAddr
91+
s.mu.RUnlock()
92+
if ba != "" {
93+
return ba
94+
}
95+
return s.ListenAddr()
8196
}
8297

8398
// ListenAddr returns the address this server will bind when [Server.ListenAndServe] is called.
8499
func (s *Server) ListenAddr() string {
85100
if a := strings.TrimSpace(s.cfg.Addr); a != "" {
86101
return a
87102
}
103+
if s.cfg.Port == 0 {
104+
return "127.0.0.1:0"
105+
}
88106
return fmt.Sprintf("127.0.0.1:%d", s.cfg.Port)
89107
}
90108

@@ -94,6 +112,9 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
94112
if err != nil {
95113
return fmt.Errorf("inspect: listen %s: %w", s.ListenAddr(), err)
96114
}
115+
s.mu.Lock()
116+
s.boundAddr = ln.Addr().String()
117+
s.mu.Unlock()
97118

98119
srv := &http.Server{
99120
Handler: s.Handler(),

internal/inspect/server_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,44 @@ func TestServer_API_readOnly(t *testing.T) {
193193
})
194194
}
195195

196+
func TestServer_ListenAddr(t *testing.T) {
197+
path, st := seedInspectorDB(t)
198+
t.Cleanup(func() { _ = st.Close() })
199+
srv, err := NewServer(st, Config{StatePath: path, Port: 8787})
200+
if err != nil {
201+
t.Fatal(err)
202+
}
203+
if got := srv.ListenAddr(); got != "127.0.0.1:8787" {
204+
t.Fatalf("ListenAddr=%q", got)
205+
}
206+
srv.cfg.Port = 0
207+
if got := srv.ListenAddr(); got != "127.0.0.1:0" {
208+
t.Fatalf("ListenAddr port0=%q", got)
209+
}
210+
}
211+
212+
func TestServer_Handler_securityHeaders(t *testing.T) {
213+
path, st := seedInspectorDB(t)
214+
t.Cleanup(func() { _ = st.Close() })
215+
srv, err := NewServer(st, Config{StatePath: path, Env: "local"})
216+
if err != nil {
217+
t.Fatal(err)
218+
}
219+
ts := httptest.NewServer(srv.Handler())
220+
t.Cleanup(ts.Close)
221+
res, err := http.Get(ts.URL + "/")
222+
if err != nil {
223+
t.Fatal(err)
224+
}
225+
defer res.Body.Close()
226+
if res.Header.Get("X-Content-Type-Options") != "nosniff" {
227+
t.Fatalf("headers=%v", res.Header)
228+
}
229+
if csp := res.Header.Get("Content-Security-Policy"); csp == "" || !strings.Contains(csp, "default-src 'self'") {
230+
t.Fatalf("csp=%q", csp)
231+
}
232+
}
233+
196234
func TestServer_readOnlyStoreCannotMutate(t *testing.T) {
197235
_, st := seedInspectorDB(t)
198236
t.Cleanup(func() { _ = st.Close() })

internal/statejson/contract_test.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,17 @@ func TestContract_runRecord_matchesLogsFields(t *testing.T) {
4848
}
4949
}
5050

51+
func TestContract_run_emptyInputDefaultsToObject(t *testing.T) {
52+
r := state.Run{
53+
RunID: "r", WorkflowName: "w", Env: "local", Status: "running",
54+
StartedAt: time.Now().UTC(), InputJSON: "",
55+
}
56+
rec := Run(r)
57+
if string(rec.Input) != `{}` {
58+
t.Fatalf("input=%s want {}", rec.Input)
59+
}
60+
}
61+
5162
func TestContract_appliedResource_matchesStateCLI(t *testing.T) {
5263
at := time.Date(2026, 6, 4, 8, 0, 0, 0, time.UTC)
5364
row := state.AppliedResource{
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package integration_test
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"path/filepath"
8+
"testing"
9+
10+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/inspect"
11+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/state/sqlite"
12+
)
13+
14+
// TestIntegration_inspectWeb_API_afterRun seeds state via CLI run, then hits inspector HTTP API.
15+
func TestIntegration_inspectWeb_API_afterRun(t *testing.T) {
16+
parent := t.TempDir()
17+
projName := "inspectweb"
18+
projDir := filepath.Join(parent, projName)
19+
db := filepath.Join(t.TempDir(), "inspect-web.db")
20+
21+
out, err := runCLI(t, "init", projName, "--parent-dir", parent)
22+
if err != nil {
23+
t.Fatalf("init: %v\n%s", err, out)
24+
}
25+
out, err = runCLI(t, "apply", "--project", projDir, "--state", db, "--auto-approve")
26+
if err != nil {
27+
t.Fatalf("apply: %v\n%s", err, out)
28+
}
29+
out, err = runCLI(t, "run", "workflow/hello", "--project", projDir, "--state", db, "--input", "topic=web-int")
30+
if err != nil {
31+
t.Fatalf("run: %v\n%s", err, out)
32+
}
33+
runID := extractRunID(out)
34+
if runID == "" {
35+
t.Fatalf("no run id in:\n%s", out)
36+
}
37+
38+
st, err := sqlite.OpenReadOnly(t.Context(), db)
39+
if err != nil {
40+
t.Fatal(err)
41+
}
42+
t.Cleanup(func() { _ = st.Close() })
43+
44+
srv, err := inspect.NewServer(st, inspect.Config{StatePath: db, Env: "local"})
45+
if err != nil {
46+
t.Fatal(err)
47+
}
48+
ts := httptest.NewServer(srv.Handler())
49+
t.Cleanup(ts.Close)
50+
51+
res, err := http.Get(ts.URL + "/api/runs/" + runID)
52+
if err != nil {
53+
t.Fatal(err)
54+
}
55+
defer res.Body.Close()
56+
if res.StatusCode != http.StatusOK {
57+
t.Fatalf("status=%d", res.StatusCode)
58+
}
59+
if res.Header.Get("X-Content-Type-Options") != "nosniff" {
60+
t.Fatalf("missing security headers: %v", res.Header)
61+
}
62+
var body inspect.RunDetailResponse
63+
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
64+
t.Fatal(err)
65+
}
66+
if body.Run.RunID != runID {
67+
t.Fatalf("run=%+v", body.Run)
68+
}
69+
if len(body.Events) == 0 {
70+
t.Fatal("expected trace events")
71+
}
72+
}

0 commit comments

Comments
 (0)