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