1package server
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "net/http"
8 "net/http/httptest"
9 "testing"
10
11 "github.com/charmbracelet/crush/internal/backend"
12 "github.com/charmbracelet/crush/internal/proto"
13 "github.com/google/uuid"
14 "github.com/stretchr/testify/require"
15)
16
17// installSyntheticWorkspace creates a synthetic [backend.Workspace]
18// registered with the controller's backend, suitable for handler-level
19// tests that do not need a real [app.App]. The workspace's ID is a
20// fresh UUID and its path is a tempdir; teardown is the caller's
21// responsibility (handlers should not rely on synthetic workspaces
22// disappearing automatically).
23func installSyntheticWorkspace(t *testing.T, c *controllerV1) *backend.Workspace {
24 t.Helper()
25 ws := &backend.Workspace{
26 ID: uuid.New().String(),
27 Path: t.TempDir(),
28 }
29 backend.InsertWorkspaceForTest(c.backend, ws)
30 return ws
31}
32
33// newTestController builds a controllerV1 around a backend without a
34// real config store, suitable for handler-level 400 tests.
35func newTestController() *controllerV1 {
36 s := &Server{}
37 s.backend = backend.New(context.Background(), nil, nil)
38 return &controllerV1{backend: s.backend, server: s}
39}
40
41func TestPostWorkspaces_RejectsMissingClientID(t *testing.T) {
42 t.Parallel()
43 c := newTestController()
44
45 body, err := json.Marshal(proto.Workspace{Path: t.TempDir()})
46 require.NoError(t, err)
47 req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/v1/workspaces", bytes.NewReader(body))
48 req.Header.Set("Content-Type", "application/json")
49 rec := httptest.NewRecorder()
50
51 c.handlePostWorkspaces(rec, req)
52
53 require.Equal(t, http.StatusBadRequest, rec.Code)
54 var perr proto.Error
55 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &perr))
56 require.Contains(t, perr.Message, "client_id")
57}
58
59func TestPostWorkspaces_RejectsMalformedClientID(t *testing.T) {
60 t.Parallel()
61 c := newTestController()
62
63 body, err := json.Marshal(proto.Workspace{Path: t.TempDir(), ClientID: "not-a-uuid"})
64 require.NoError(t, err)
65 req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/v1/workspaces", bytes.NewReader(body))
66 req.Header.Set("Content-Type", "application/json")
67 rec := httptest.NewRecorder()
68
69 c.handlePostWorkspaces(rec, req)
70
71 require.Equal(t, http.StatusBadRequest, rec.Code)
72}
73
74func TestDeleteWorkspace_RejectsMissingClientID(t *testing.T) {
75 t.Parallel()
76 c := newTestController()
77
78 req := httptest.NewRequestWithContext(t.Context(), http.MethodDelete, "/v1/workspaces/abc", nil)
79 req.SetPathValue("id", "abc")
80 rec := httptest.NewRecorder()
81
82 c.handleDeleteWorkspaces(rec, req)
83
84 require.Equal(t, http.StatusBadRequest, rec.Code)
85}
86
87func TestDeleteWorkspace_RejectsMalformedClientID(t *testing.T) {
88 t.Parallel()
89 c := newTestController()
90
91 req := httptest.NewRequestWithContext(t.Context(), http.MethodDelete, "/v1/workspaces/abc?client_id=nope", nil)
92 req.SetPathValue("id", "abc")
93 rec := httptest.NewRecorder()
94
95 c.handleDeleteWorkspaces(rec, req)
96
97 require.Equal(t, http.StatusBadRequest, rec.Code)
98}
99
100func TestSubscribeEvents_RejectsMissingClientID(t *testing.T) {
101 t.Parallel()
102 c := newTestController()
103
104 req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/v1/workspaces/abc/events", nil)
105 req.SetPathValue("id", "abc")
106 rec := httptest.NewRecorder()
107
108 c.handleGetWorkspaceEvents(rec, req)
109
110 require.Equal(t, http.StatusBadRequest, rec.Code)
111}
112
113func TestSubscribeEvents_RejectsMalformedClientID(t *testing.T) {
114 t.Parallel()
115 c := newTestController()
116
117 req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/v1/workspaces/abc/events?client_id=nope", nil)
118 req.SetPathValue("id", "abc")
119 rec := httptest.NewRecorder()
120
121 c.handleGetWorkspaceEvents(rec, req)
122
123 require.Equal(t, http.StatusBadRequest, rec.Code)
124}
125
126// postCurrentSession is a small helper that POSTs the JSON body to
127// /v1/workspaces/{id}/current-session?client_id=cid and returns the
128// recorder. It does not require a real listener.
129func postCurrentSession(t *testing.T, c *controllerV1, wsID, clientID, sessionID string) *httptest.ResponseRecorder {
130 t.Helper()
131 body, err := json.Marshal(proto.CurrentSession{SessionID: sessionID})
132 require.NoError(t, err)
133 url := "/v1/workspaces/" + wsID + "/current-session"
134 if clientID != "" {
135 url += "?client_id=" + clientID
136 }
137 req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, url, bytes.NewReader(body))
138 req.SetPathValue("id", wsID)
139 req.Header.Set("Content-Type", "application/json")
140 rec := httptest.NewRecorder()
141 c.handlePostWorkspaceCurrentSession(rec, req)
142 return rec
143}
144
145func TestPostCurrentSession_RejectsMissingClientID(t *testing.T) {
146 t.Parallel()
147 c := newTestController()
148
149 body, err := json.Marshal(proto.CurrentSession{SessionID: "S1"})
150 require.NoError(t, err)
151 req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/v1/workspaces/abc/current-session", bytes.NewReader(body))
152 req.SetPathValue("id", "abc")
153 req.Header.Set("Content-Type", "application/json")
154 rec := httptest.NewRecorder()
155
156 c.handlePostWorkspaceCurrentSession(rec, req)
157
158 require.Equal(t, http.StatusBadRequest, rec.Code)
159}
160
161func TestPostCurrentSession_RejectsMalformedClientID(t *testing.T) {
162 t.Parallel()
163 c := newTestController()
164
165 rec := postCurrentSession(t, c, "abc", "not-a-uuid", "S1")
166 require.Equal(t, http.StatusBadRequest, rec.Code)
167}
168
169func TestPostCurrentSession_RejectsBadBody(t *testing.T) {
170 t.Parallel()
171 c := newTestController()
172
173 cid := uuid.New().String()
174 url := "/v1/workspaces/abc/current-session?client_id=" + cid
175 req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, url, bytes.NewReader([]byte("not-json")))
176 req.SetPathValue("id", "abc")
177 req.Header.Set("Content-Type", "application/json")
178 rec := httptest.NewRecorder()
179
180 c.handlePostWorkspaceCurrentSession(rec, req)
181
182 require.Equal(t, http.StatusBadRequest, rec.Code)
183}
184
185func TestPostCurrentSession_UnknownWorkspace(t *testing.T) {
186 t.Parallel()
187 c := newTestController()
188
189 rec := postCurrentSession(t, c, uuid.New().String(), uuid.New().String(), "S1")
190 require.Equal(t, http.StatusNotFound, rec.Code)
191}
192
193func TestPostCurrentSession_UnknownClient(t *testing.T) {
194 t.Parallel()
195 c := newTestController()
196 ws := installSyntheticWorkspace(t, c)
197
198 rec := postCurrentSession(t, c, ws.ID, uuid.New().String(), "S1")
199 require.Equal(t, http.StatusNotFound, rec.Code)
200}
201
202func TestPostCurrentSession_HoldOnly(t *testing.T) {
203 t.Parallel()
204 c := newTestController()
205 ws := installSyntheticWorkspace(t, c)
206
207 cid := uuid.New().String()
208 require.NoError(t, backend.RegisterClientForTesting(c.backend, ws, cid))
209 t.Cleanup(func() { _ = c.backend.DeleteWorkspace(ws.ID, cid) })
210
211 rec := postCurrentSession(t, c, ws.ID, cid, "S1")
212 require.Equal(t, http.StatusNotFound, rec.Code, "hold-only client must be rejected")
213}
214
215func TestPostCurrentSession_AttachedClientSucceeds(t *testing.T) {
216 t.Parallel()
217 c := newTestController()
218 ws := installSyntheticWorkspace(t, c)
219
220 cid := uuid.New().String()
221 require.NoError(t, c.backend.AttachClient(ws.ID, cid))
222 t.Cleanup(func() { c.backend.DetachClient(ws.ID, cid) })
223
224 rec := postCurrentSession(t, c, ws.ID, cid, "S1")
225 require.Equal(t, http.StatusOK, rec.Code)
226
227 // Clearing also returns 200.
228 rec = postCurrentSession(t, c, ws.ID, cid, "")
229 require.Equal(t, http.StatusOK, rec.Code)
230}