| 1 | package serve |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "maps" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | |
| 11 | "reasonix/internal/config" |
| 12 | "reasonix/internal/control" |
| 13 | ) |
| 14 | |
| 15 | var mutationRoutes = []string{ |
| 16 | "/submit", |
| 17 | "/composer-profile", |
| 18 | "/handoff", |
| 19 | "/approve", |
| 20 | "/plan-decision", |
| 21 | "/answer", |
| 22 | "/resolve-prompt", |
| 23 | "/mcp-interaction", |
| 24 | "/extension-form", |
| 25 | "/tool-approval-mode", |
| 26 | "/auto-approve-tools", |
| 27 | "/bypass", |
| 28 | "/permission/preset", |
| 29 | } |
| 30 | |
| 31 | // operatorHandler stands in for the operator's frontend, which holds the |
| 32 | // launch token that mutations require. |
| 33 | func operatorHandler(s *Server) http.Handler { |
| 34 | h := s.Handler() |
| 35 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 36 | r.Header.Set("Authorization", "Bearer "+s.AuthToken()) |
| 37 | h.ServeHTTP(w, r) |
| 38 | }) |
| 39 | } |
| 40 | |
| 41 | func postJSON(t *testing.T, url, body string, header http.Header) *http.Response { |
| 42 | t.Helper() |
| 43 | req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body)) |
| 44 | if err != nil { |
| 45 | t.Fatal(err) |
| 46 | } |
| 47 | req.Header.Set("Content-Type", "application/json") |
| 48 | maps.Copy(req.Header, header) |
| 49 | resp, err := http.DefaultClient.Do(req) |
| 50 | if err != nil { |
| 51 | t.Fatal(err) |
| 52 | } |
| 53 | return resp |
| 54 | } |
| 55 | |
| 56 | func TestAuthDisabledRefusesMutationsWithoutLaunchToken(t *testing.T) { |
| 57 | bc := NewBroadcaster() |
| 58 | ctrl := control.New(control.Options{Sink: bc}) |
| 59 | srv := httptest.NewServer(New(ctrl, bc, config.ServeConfig{AuthMode: "none"}).Handler()) |
| 60 | defer srv.Close() |
| 61 | |
| 62 | for _, route := range mutationRoutes { |
| 63 | resp := postJSON(t, srv.URL+route, `{"id":"1","allow":true,"session":true}`, nil) |
| 64 | var body struct { |
| 65 | Code string `json:"code"` |
| 66 | } |
| 67 | _ = json.NewDecoder(resp.Body).Decode(&body) |
| 68 | resp.Body.Close() |
| 69 | if resp.StatusCode != http.StatusForbidden || body.Code != launchTokenRequiredCode { |
| 70 | t.Errorf("POST %s without launch token = %d code=%q, want 403 %q", route, resp.StatusCode, body.Code, launchTokenRequiredCode) |
| 71 | } |
| 72 | } |
| 73 | resp, err := http.Get(srv.URL + "/pending-prompts") |
| 74 | if err != nil { |
| 75 | t.Fatal(err) |
| 76 | } |
| 77 | resp.Body.Close() |
| 78 | if resp.StatusCode != http.StatusOK { |
| 79 | t.Fatalf("GET /pending-prompts with auth disabled = %d, want 200: reads stay open", resp.StatusCode) |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | func TestAuthDisabledHonoursConfiguredToken(t *testing.T) { |
| 84 | s := New(control.New(control.Options{Sink: NewBroadcaster()}), nil, config.ServeConfig{AuthMode: "none", Token: "proxy-held"}) |
| 85 | if s.AuthToken() != "proxy-held" { |
| 86 | t.Fatalf("launch token = %q, want the configured token a fronting proxy injects", s.AuthToken()) |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | func TestAuthDisabledAcceptsHumanDecisionWithLaunchToken(t *testing.T) { |
| 91 | bc := NewBroadcaster() |
| 92 | ctrl := control.New(control.Options{Sink: bc}) |
| 93 | s := New(ctrl, bc, config.ServeConfig{AuthMode: "none"}) |
| 94 | srv := httptest.NewServer(s.Handler()) |
| 95 | defer srv.Close() |
| 96 | if s.AuthToken() == "" { |
| 97 | t.Fatal("auth-disabled serve has no launch token") |
| 98 | } |
| 99 | |
| 100 | resp := postJSON(t, srv.URL+"/approve", `{"allow":true}`, http.Header{"Authorization": {"Bearer " + s.AuthToken()}}) |
| 101 | resp.Body.Close() |
| 102 | if resp.StatusCode != http.StatusBadRequest { |
| 103 | t.Fatalf("POST /approve with bearer launch token = %d, want the handler's 400 for a missing id", resp.StatusCode) |
| 104 | } |
| 105 | |
| 106 | resp = postJSON(t, srv.URL+"/auth/token", `{"token":"`+s.AuthToken()+`"}`, nil) |
| 107 | resp.Body.Close() |
| 108 | var cookie *http.Cookie |
| 109 | for _, c := range resp.Cookies() { |
| 110 | if c.Name == cookieToken { |
| 111 | cookie = c |
| 112 | } |
| 113 | } |
| 114 | if resp.StatusCode != http.StatusNoContent || cookie == nil { |
| 115 | t.Fatalf("launch token bootstrap = %d cookie=%v, want 204 with %s", resp.StatusCode, cookie, cookieToken) |
| 116 | } |
| 117 | resp = postJSON(t, srv.URL+"/approve", `{"allow":true}`, http.Header{"Cookie": {cookie.Name + "=" + cookie.Value}}) |
| 118 | resp.Body.Close() |
| 119 | if resp.StatusCode != http.StatusBadRequest { |
| 120 | t.Fatalf("POST /approve with launch token cookie = %d, want the handler's 400 for a missing id", resp.StatusCode) |
| 121 | } |
| 122 | |
| 123 | resp = postJSON(t, srv.URL+"/approve", `{"allow":true}`, http.Header{"Authorization": {"Bearer wrong"}}) |
| 124 | resp.Body.Close() |
| 125 | if resp.StatusCode != http.StatusForbidden { |
| 126 | t.Fatalf("POST /approve with a wrong token = %d, want 403", resp.StatusCode) |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | func TestTokenModeAcceptsBearerLaunchToken(t *testing.T) { |
| 131 | bc := NewBroadcaster() |
| 132 | ctrl := control.New(control.Options{Sink: bc}) |
| 133 | s := New(ctrl, bc, config.ServeConfig{AuthMode: "token"}) |
| 134 | srv := httptest.NewServer(s.Handler()) |
| 135 | defer srv.Close() |
| 136 | |
| 137 | resp := postJSON(t, srv.URL+"/approve", `{"id":"1","allow":true}`, nil) |
| 138 | resp.Body.Close() |
| 139 | if resp.StatusCode != http.StatusUnauthorized { |
| 140 | t.Fatalf("unauthenticated POST /approve in token mode = %d, want 401", resp.StatusCode) |
| 141 | } |
| 142 | resp = postJSON(t, srv.URL+"/approve", `{"allow":true}`, http.Header{"Authorization": {"Bearer " + s.AuthToken()}}) |
| 143 | resp.Body.Close() |
| 144 | if resp.StatusCode != http.StatusBadRequest { |
| 145 | t.Fatalf("POST /approve with bearer token = %d, want the handler's 400 for a missing id", resp.StatusCode) |
| 146 | } |
| 147 | } |
| 148 |