返回 DeepSeek-Reasonix
provider_setup_test.go
根目录 / internal / serve / provider_setup_test.go
1 package serve
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "io"
9 "net/http"
10 "net/http/httptest"
11 "os"
12 "path/filepath"
13 "runtime"
14 "strings"
15 "testing"
16 "time"
17
18 "reasonix/internal/config"
19 "reasonix/internal/control"
20 )
21
22 const providerSetupTestKeyEnv = "REASONIX_REMOTE_SETUP_TEST_KEY"
23
24 func TestProviderSetupStoresRemoteCredentialAndRebuildsController(t *testing.T) {
25 s, secret := newProviderSetupTestServer(t)
26 if !s.EnableProviderSetupForListener("127.0.0.1:8787") {
27 t.Fatal("loopback listener did not enable Provider setup")
28 }
29
30 built := 0
31 s.buildController = func(_ context.Context, ref string) (*control.Controller, error) {
32 built++
33 if ref != "remote-demo/model-a" {
34 t.Fatalf("rebuilt ref = %q, want remote-demo/model-a", ref)
35 }
36 return control.New(control.Options{
37 Sink: s.bc,
38 Label: "model-a",
39 ModelRef: ref,
40 SessionDir: t.TempDir(),
41 }), nil
42 }
43
44 httpServer := httptest.NewServer(s.Handler())
45 defer httpServer.Close()
46
47 index := getProviderSetupBody(t, httpServer.URL+"/")
48 if !strings.Contains(index, "Reasonix Provider Setup") {
49 t.Fatalf("missing-key index did not serve Provider setup page:\n%s", index)
50 }
51 if strings.Contains(index, secret) {
52 t.Fatal("setup page reflected the Provider secret")
53 }
54
55 resp, err := http.Get(httpServer.URL + "/provider-setup")
56 if err != nil {
57 t.Fatal(err)
58 }
59 if resp.StatusCode != http.StatusOK {
60 resp.Body.Close()
61 t.Fatalf("setup status = %d, want 200", resp.StatusCode)
62 }
63 if got := resp.Header.Get("Cache-Control"); got != "no-store" {
64 resp.Body.Close()
65 t.Fatalf("setup Cache-Control = %q, want no-store", got)
66 }
67 var state providerSetupState
68 if err := json.NewDecoder(resp.Body).Decode(&state); err != nil {
69 resp.Body.Close()
70 t.Fatal(err)
71 }
72 resp.Body.Close()
73 if !state.Required || state.Provider != "remote-demo" || state.Model != "model-a" || state.KeyEnv != providerSetupTestKeyEnv {
74 t.Fatalf("unexpected setup state: %+v", state)
75 }
76
77 resp = postProviderSetup(t, httpServer.URL, `{"apiKey":"`+secret+`"}`)
78 body, _ := io.ReadAll(resp.Body)
79 resp.Body.Close()
80 if resp.StatusCode != http.StatusNoContent {
81 t.Fatalf("setup save = %d, want 204: %s", resp.StatusCode, body)
82 }
83 if bytes.Contains(body, []byte(secret)) {
84 t.Fatal("setup response reflected the Provider secret")
85 }
86 if built != 1 {
87 t.Fatalf("controller builds = %d, want 1", built)
88 }
89 resp = postProviderSetup(t, httpServer.URL, `{"apiKey":"second-secret"}`)
90 resp.Body.Close()
91 if resp.StatusCode != http.StatusConflict {
92 t.Fatalf("stale setup save = %d, want 409", resp.StatusCode)
93 }
94 if built != 1 {
95 t.Fatalf("stale setup triggered %d controller builds, want 1 total", built)
96 }
97 resolved := config.ResolveCredentialForRootGlobalFirst(".", providerSetupTestKeyEnv)
98 if !resolved.Set || resolved.Value != secret {
99 t.Fatalf("stored credential = set:%v value:%q, want saved secret", resolved.Set, resolved.Value)
100 }
101 info, err := os.Stat(config.UserCredentialsPath())
102 if err != nil {
103 t.Fatal(err)
104 }
105 if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
106 t.Fatalf("credential file mode = %o, want 600", info.Mode().Perm())
107 }
108
109 resp, err = http.Get(httpServer.URL + "/provider-setup")
110 if err != nil {
111 t.Fatal(err)
112 }
113 if err := json.NewDecoder(resp.Body).Decode(&state); err != nil {
114 resp.Body.Close()
115 t.Fatal(err)
116 }
117 resp.Body.Close()
118 if state.Required {
119 t.Fatalf("setup still required after save: %+v", state)
120 }
121 index = getProviderSetupBody(t, httpServer.URL+"/")
122 if strings.Contains(index, "Reasonix Provider Setup") {
123 t.Fatal("normal Serve UI did not replace setup page after controller rebuild")
124 }
125 }
126
127 func TestProviderSetupActivationFailureKeepsCredentialAndHidesDetails(t *testing.T) {
128 s, secret := newProviderSetupTestServer(t)
129 s.EnableProviderSetupForListener("127.0.0.1:8787")
130 built := 0
131 s.buildController = func(_ context.Context, ref string) (*control.Controller, error) {
132 built++
133 if built == 1 {
134 return nil, errors.New("sensitive remote path: /srv/private/config.toml")
135 }
136 return control.New(control.Options{
137 Sink: s.bc,
138 Label: "model-a",
139 ModelRef: ref,
140 SessionDir: t.TempDir(),
141 }), nil
142 }
143 httpServer := httptest.NewServer(s.Handler())
144 defer httpServer.Close()
145
146 resp := postProviderSetup(t, httpServer.URL, `{"apiKey":"`+secret+`"}`)
147 body, _ := io.ReadAll(resp.Body)
148 resp.Body.Close()
149 if resp.StatusCode != http.StatusInternalServerError {
150 t.Fatalf("activation failure = %d, want 500: %s", resp.StatusCode, body)
151 }
152 if bytes.Contains(body, []byte(secret)) || bytes.Contains(body, []byte("/srv/private")) {
153 t.Fatalf("activation failure reflected sensitive details: %s", body)
154 }
155 resolved := config.ResolveCredentialForRootGlobalFirst(".", providerSetupTestKeyEnv)
156 if !resolved.Set || resolved.Value != secret {
157 t.Fatal("activation failure did not retain the successfully saved credential")
158 }
159 state, ok := s.providerSetupSnapshot()
160 if !ok || !state.Required || !state.ActivationPending || !strings.Contains(state.Error, "credential was saved") {
161 t.Fatalf("activation failure state = %+v, enabled:%v", state, ok)
162 }
163 if strings.Contains(state.Error, secret) || strings.Contains(state.Error, "/srv/private") {
164 t.Fatalf("activation failure state exposed sensitive details: %q", state.Error)
165 }
166
167 // Retrying activates the already-saved credential without asking the user to
168 // enter it again or attempting a second revision-guarded write.
169 resp = postProviderSetup(t, httpServer.URL, `{"apiKey":""}`)
170 body, _ = io.ReadAll(resp.Body)
171 resp.Body.Close()
172 if resp.StatusCode != http.StatusNoContent {
173 t.Fatalf("activation retry = %d, want 204: %s", resp.StatusCode, body)
174 }
175 if built != 2 {
176 t.Fatalf("controller builds after retry = %d, want 2", built)
177 }
178 resolved = config.ResolveCredentialForRootGlobalFirst(".", providerSetupTestKeyEnv)
179 if !resolved.Set || resolved.Value != secret {
180 t.Fatal("activation retry rewrote the saved credential")
181 }
182 state, ok = s.providerSetupSnapshot()
183 if !ok || state.Required || state.ActivationPending {
184 t.Fatalf("activation retry did not clear setup state: %+v, enabled:%v", state, ok)
185 }
186 }
187
188 func TestProviderSetupActivationRetryReturnsToMissingWhenCredentialWasRemoved(t *testing.T) {
189 s, secret := newProviderSetupTestServer(t)
190 s.EnableProviderSetupForListener("127.0.0.1:8787")
191 built := 0
192 s.buildController = func(context.Context, string) (*control.Controller, error) {
193 built++
194 return nil, errors.New("transient activation failure")
195 }
196 httpServer := httptest.NewServer(s.Handler())
197 defer httpServer.Close()
198
199 resp := postProviderSetup(t, httpServer.URL, `{"apiKey":"`+secret+`"}`)
200 resp.Body.Close()
201 if resp.StatusCode != http.StatusInternalServerError {
202 t.Fatalf("activation failure = %d, want 500", resp.StatusCode)
203 }
204 if err := config.RemoveCredential(providerSetupTestKeyEnv); err != nil {
205 t.Fatal(err)
206 }
207 resp = postProviderSetup(t, httpServer.URL, `{"apiKey":""}`)
208 resp.Body.Close()
209 if resp.StatusCode != http.StatusBadRequest {
210 t.Fatalf("retry after credential removal = %d, want 400", resp.StatusCode)
211 }
212 if built != 1 {
213 t.Fatalf("credential-less retry triggered %d builds, want 1 total", built)
214 }
215 state, ok := s.providerSetupSnapshot()
216 if !ok || !state.Required || state.ActivationPending || state.CredentialRevision == "" {
217 t.Fatalf("credential removal did not restore missing-key setup: %+v, enabled:%v", state, ok)
218 }
219 }
220
221 func TestProviderSetupRejectsCredentialSavedByAnotherProcess(t *testing.T) {
222 s, _ := newProviderSetupTestServer(t)
223 s.EnableProviderSetupForListener("127.0.0.1:8787")
224 state, ok := s.providerSetupSnapshot()
225 if !ok || !state.Required || state.CredentialRevision == "" {
226 t.Fatalf("initial setup state = %+v, enabled:%v", state, ok)
227 }
228 if _, err := config.SetCredential(providerSetupTestKeyEnv, "newer-external-secret"); err != nil {
229 t.Fatal(err)
230 }
231 s.buildController = func(context.Context, string) (*control.Controller, error) {
232 t.Fatal("stale setup request rebuilt the controller")
233 return nil, nil
234 }
235
236 httpServer := httptest.NewServer(s.Handler())
237 defer httpServer.Close()
238 resp := postProviderSetup(t, httpServer.URL, `{"apiKey":"stale-browser-secret"}`)
239 resp.Body.Close()
240 if resp.StatusCode != http.StatusConflict {
241 t.Fatalf("stale cross-process setup save = %d, want 409", resp.StatusCode)
242 }
243 resolved := config.ResolveCredentialForRootGlobalFirst(".", providerSetupTestKeyEnv)
244 if !resolved.Set || resolved.Value != "newer-external-secret" {
245 t.Fatalf("credential after stale setup = set:%v value:%q, want external value", resolved.Set, resolved.Value)
246 }
247 state, ok = s.providerSetupSnapshot()
248 if !ok || state.Required {
249 t.Fatalf("setup state did not refresh after stale save: %+v, enabled:%v", state, ok)
250 }
251 }
252
253 func TestProviderSetupRefreshDoesNotAcquireConfigEditLock(t *testing.T) {
254 s, _ := newProviderSetupTestServer(t)
255 s.EnableProviderSetupForListener("127.0.0.1:8787")
256
257 // Config+credential writers take the config lock first. Holding it here
258 // forces the inverse-order failure mode: refresh must still finish because
259 // it only performs a read-only config load while holding the credential lock.
260 unlockConfig := config.LockUserConfigEdits()
261 done := make(chan struct{})
262 go func() {
263 s.refreshProviderSetup("remote-demo/model-a")
264 close(done)
265 }()
266
267 select {
268 case <-done:
269 unlockConfig()
270 case <-time.After(2 * time.Second):
271 unlockConfig()
272 <-done
273 t.Fatal("Provider setup refresh waited for the config edit lock while holding the credential lock")
274 }
275 }
276
277 func TestProviderSetupIsLoopbackOnlyAndAuthenticated(t *testing.T) {
278 s, _ := newProviderSetupTestServer(t)
279 if s.EnableProviderSetupForListener("0.0.0.0:8787") {
280 t.Fatal("non-loopback listener enabled Provider setup")
281 }
282
283 req := httptest.NewRequest(http.MethodGet, "/provider-setup", nil)
284 rec := httptest.NewRecorder()
285 s.Handler().ServeHTTP(rec, req)
286 if rec.Code != http.StatusNotFound {
287 t.Fatalf("disabled setup endpoint = %d, want 404", rec.Code)
288 }
289 if strings.Contains(rec.Body.String(), providerSetupTestKeyEnv) {
290 t.Fatal("disabled setup endpoint exposed Provider metadata")
291 }
292
293 s.EnableProviderSetupForListener("[::1]:8787")
294 protected := New(s.ctl(), s.bc, config.ServeConfig{AuthMode: "token", Token: "serve-token"})
295 protected.EnableProviderSetupForListener("127.0.0.1:8787")
296 req = httptest.NewRequest(http.MethodGet, "/provider-setup", nil)
297 req.Header.Set("Accept", "application/json")
298 rec = httptest.NewRecorder()
299 protected.Handler().ServeHTTP(rec, req)
300 if rec.Code != http.StatusUnauthorized {
301 t.Fatalf("unauthenticated setup endpoint = %d, want 401", rec.Code)
302 }
303 }
304
305 func TestProviderSetupRejectsUnsafeOrAmbiguousRequests(t *testing.T) {
306 s, _ := newProviderSetupTestServer(t)
307 s.EnableProviderSetupForListener("127.0.0.1:8787")
308 httpServer := httptest.NewServer(s.Handler())
309 defer httpServer.Close()
310
311 req, err := http.NewRequest(http.MethodPost, httpServer.URL+"/provider-setup", strings.NewReader(`{"apiKey":"secret"}`))
312 if err != nil {
313 t.Fatal(err)
314 }
315 resp, err := http.DefaultClient.Do(req)
316 if err != nil {
317 t.Fatal(err)
318 }
319 resp.Body.Close()
320 if resp.StatusCode != http.StatusUnsupportedMediaType {
321 t.Fatalf("non-JSON setup = %d, want 415", resp.StatusCode)
322 }
323
324 cases := []string{
325 `{"apiKey":""}`,
326 `{"apiKey":"secret","extra":true}`,
327 `{"apiKey":"secret"}{"apiKey":"second"}`,
328 `{"apiKey":"` + strings.Repeat("x", providerSetupMaxBody) + `"}`,
329 }
330 for _, body := range cases {
331 resp = postProviderSetup(t, httpServer.URL, body)
332 resp.Body.Close()
333 if resp.StatusCode != http.StatusBadRequest {
334 t.Fatalf("unsafe setup body status = %d, want 400", resp.StatusCode)
335 }
336 }
337 if config.CredentialStored(providerSetupTestKeyEnv) {
338 t.Fatal("rejected setup request persisted a credential")
339 }
340
341 page := string(providerSetupHTML)
342 if !strings.Contains(page, `type="password"`) {
343 t.Fatal("setup UI does not use a password input")
344 }
345 if strings.Contains(strings.ToLower(page), "localstorage") {
346 t.Fatal("setup UI must not persist Provider secrets in localStorage")
347 }
348 if !strings.Contains(page, "activationPending?'':input.value") {
349 t.Fatal("setup UI does not retry activation without resubmitting the Provider secret")
350 }
351 }
352
353 func newProviderSetupTestServer(t *testing.T) (*Server, string) {
354 t.Helper()
355 home := t.TempDir()
356 t.Setenv("REASONIX_HOME", home)
357 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
358 t.Setenv(providerSetupTestKeyEnv, "")
359 configPath := config.UserConfigPath()
360 if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
361 t.Fatal(err)
362 }
363 configBody := `default_model = "remote-demo/model-a"
364
365 [[providers]]
366 name = "remote-demo"
367 kind = "openai"
368 base_url = "https://example.invalid/v1"
369 models = ["model-a"]
370 default = "model-a"
371 api_key_env = "` + providerSetupTestKeyEnv + `"
372 `
373 if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
374 t.Fatal(err)
375 }
376
377 bc := NewBroadcaster()
378 ctrl := control.New(control.Options{
379 Sink: bc,
380 Label: "model-a",
381 ModelRef: "remote-demo/model-a",
382 SessionDir: t.TempDir(),
383 })
384 return New(ctrl, bc, config.ServeConfig{}), "remote-secret-for-test"
385 }
386
387 func postProviderSetup(t *testing.T, baseURL, body string) *http.Response {
388 t.Helper()
389 req, err := http.NewRequest(http.MethodPost, baseURL+"/provider-setup", strings.NewReader(body))
390 if err != nil {
391 t.Fatal(err)
392 }
393 req.Header.Set("Content-Type", "application/json")
394 resp, err := http.DefaultClient.Do(req)
395 if err != nil {
396 t.Fatal(err)
397 }
398 return resp
399 }
400
401 func getProviderSetupBody(t *testing.T, url string) string {
402 t.Helper()
403 resp, err := http.Get(url)
404 if err != nil {
405 t.Fatal(err)
406 }
407 defer resp.Body.Close()
408 body, err := io.ReadAll(resp.Body)
409 if err != nil {
410 t.Fatal(err)
411 }
412 if resp.StatusCode != http.StatusOK {
413 t.Fatalf("GET %s = %d: %s", url, resp.StatusCode, body)
414 }
415 return string(body)
416 }
417
417 lines GO