返回 DeepSeek-Reasonix
remote_app_test.go
根目录 / desktop / remote_app_test.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "io"
8 "net/http"
9 "net/http/httptest"
10 "os"
11 "path/filepath"
12 "strings"
13 "sync"
14 "testing"
15 "time"
16
17 "reasonix/internal/config"
18 "reasonix/internal/jobs"
19 "reasonix/internal/remote"
20 )
21
22 func TestReloadServeProvidersCancelsBusyTurn(t *testing.T) {
23 if remoteProviderReloadTimeout <= jobs.DefaultTeardownGrace {
24 t.Fatalf("provider reload timeout = %s, want more than teardown grace", remoteProviderReloadTimeout)
25 }
26 var mu sync.Mutex
27 canceled := false
28 jobsCanceled := false
29 reloadCalls := 0
30 mux := http.NewServeMux()
31 mux.HandleFunc("POST /auth/token", func(w http.ResponseWriter, _ *http.Request) {
32 w.WriteHeader(http.StatusNoContent)
33 })
34 mux.HandleFunc("POST /cancel", func(w http.ResponseWriter, _ *http.Request) {
35 mu.Lock()
36 canceled = true
37 mu.Unlock()
38 w.WriteHeader(http.StatusNoContent)
39 })
40 mux.HandleFunc("GET /status", func(w http.ResponseWriter, _ *http.Request) {
41 _, _ = w.Write([]byte(`{"jobs":[{"id":"job-1"}]}`))
42 })
43 mux.HandleFunc("POST /jobs/cancel", func(w http.ResponseWriter, r *http.Request) {
44 var body struct {
45 IDs []string `json:"ids"`
46 }
47 if json.NewDecoder(r.Body).Decode(&body) != nil || len(body.IDs) != 1 || body.IDs[0] != "job-1" {
48 http.Error(w, "bad jobs", http.StatusBadRequest)
49 return
50 }
51 mu.Lock()
52 jobsCanceled = true
53 mu.Unlock()
54 w.WriteHeader(http.StatusNoContent)
55 })
56 mux.HandleFunc("POST /providers/reload", func(w http.ResponseWriter, _ *http.Request) {
57 mu.Lock()
58 defer mu.Unlock()
59 reloadCalls++
60 if !canceled || !jobsCanceled {
61 http.Error(w, "busy", http.StatusConflict)
62 return
63 }
64 w.WriteHeader(http.StatusNoContent)
65 })
66 srv := httptest.NewServer(mux)
67 defer srv.Close()
68
69 mgr := newDesktopRemoteManager(&App{})
70 mgr.mu.Lock()
71 mgr.hosts["box"] = &managedHost{serves: map[string]*serveEntry{
72 "ws": {view: RemoteServerView{LocalURL: srv.URL + "/"}, token: "tok"},
73 }}
74 mgr.mu.Unlock()
75
76 if ok := mgr.reloadServeProviders(context.Background(), mgr.hosts["box"], "box", "ws", srv.URL+"/", "tok"); !ok {
77 t.Fatal("reloadServeProviders = false, want true after cancel + retry")
78 }
79 mu.Lock()
80 defer mu.Unlock()
81 if !canceled || !jobsCanceled || reloadCalls < 2 {
82 t.Fatalf("canceled=%v jobsCanceled=%v reloadCalls=%d, want turn/jobs cancellation and retry", canceled, jobsCanceled, reloadCalls)
83 }
84 }
85
86 func TestCredentialProviderReloadBudgetScalesWithTrackedServes(t *testing.T) {
87 if got, want := credentialProviderReloadBudget(4), 4*remoteProviderReloadTimeout; got != want {
88 t.Fatalf("four-target reload budget = %s, want %s", got, want)
89 }
90 if got := credentialProviderReloadBudget(0); got != remoteProviderReloadTimeout {
91 t.Fatalf("empty-target reload budget = %s, want one-target floor %s", got, remoteProviderReloadTimeout)
92 }
93 }
94
95 func TestCredentialProviderHealBudgetScalesWithTrackedWorkspaces(t *testing.T) {
96 if got, want := credentialProviderHealBudget(4), 4*30*time.Second; got != want {
97 t.Fatalf("four-workspace heal budget = %s, want %s", got, want)
98 }
99 if got := credentialProviderHealBudget(0); got != 30*time.Second {
100 t.Fatalf("empty-workspace heal budget = %s, want one-workspace floor", got)
101 }
102 }
103
104 func TestReloadServeProvidersRejectsReplacedHostGeneration(t *testing.T) {
105 var reloadCalls int
106 mux := http.NewServeMux()
107 mux.HandleFunc("POST /auth/token", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
108 mux.HandleFunc("POST /providers/reload", func(w http.ResponseWriter, _ *http.Request) { reloadCalls++; w.WriteHeader(http.StatusNoContent) })
109 srv := httptest.NewServer(mux)
110 defer srv.Close()
111 mgr := newDesktopRemoteManager(&App{})
112 old := &managedHost{serves: map[string]*serveEntry{"ws": {view: RemoteServerView{LocalURL: srv.URL}, token: "old"}}}
113 mgr.hosts["box"] = old
114 mgr.hosts["box"] = &managedHost{serves: map[string]*serveEntry{"ws": {view: RemoteServerView{LocalURL: srv.URL}, token: "new"}}}
115 if mgr.reloadServeProviders(context.Background(), old, "box", "ws", "", "") {
116 t.Fatal("obsolete host generation reloaded replacement serves")
117 }
118 if reloadCalls != 0 {
119 t.Fatalf("replacement serve received %d reloads from obsolete watchdog", reloadCalls)
120 }
121 }
122
123 func TestCredentialChannelDecision(t *testing.T) {
124 cases := []struct {
125 name string
126 d credentialChannelDecision
127 want bool
128 }{
129 {"healthy", credentialChannelDecision{HasForward: true, ForwardPort: 41000, HealedPort: 41000, ProbeOK: true}, false},
130 {"no forward", credentialChannelDecision{}, true},
131 {"probe dead", credentialChannelDecision{HasForward: true, ForwardPort: 41000, HealedPort: 41000}, true},
132 {"never healed", credentialChannelDecision{HasForward: true, ForwardPort: 41000, ProbeOK: true}, true},
133 {"port rebound", credentialChannelDecision{HasForward: true, ForwardPort: 42000, HealedPort: 41000, ProbeOK: true}, true},
134 }
135 for _, tc := range cases {
136 t.Run(tc.name, func(t *testing.T) {
137 if got := tc.d.needsHeal(); got != tc.want {
138 t.Fatalf("needsHeal=%v, want %v", got, tc.want)
139 }
140 })
141 }
142 for state, want := range map[string]bool{"connected": true, "degraded": true, "connecting": false, "error": false} {
143 if got := credentialWatchdogEligibleState(state); got != want {
144 t.Fatalf("credentialWatchdogEligibleState(%q)=%v, want %v", state, got, want)
145 }
146 }
147 }
148
149 // fakeRemoteKernel implements remoteKernel for binding-layer tests.
150 type fakeRemoteKernel struct {
151 hosts []RemoteHostView
152 statuses []RemoteConnectionStatusView
153 writeResult RemoteWriteResult
154 ensureView RemoteServerView
155 ensureToken string
156 ensureErr error
157 ensureErrs []error
158 ensureCalls int
159 snapshotMiss bool
160 switchProxyErr error
161 switchProxyCalls [][5]string
162 platformErr error
163 platformChecks []string
164 stoppedWorkspaces []string
165 resolveCalls []bool
166 secretCalls []remoteSecretAnswer
167 secretPromptIDs []string
168 closed bool
169 }
170
171 func TestRemoteConnectionErrorDetailsPreserveHostKeyMismatch(t *testing.T) {
172 root := &remote.HostKeyMismatchError{
173 Host: "dev@example.test:2222",
174 PresentedFingerprint: "SHA256:new",
175 Locations: []remote.KnownHostLocation{
176 {Filename: "/home/dev/.ssh/known_hosts", Line: 7},
177 },
178 }
179 view := RemoteConnectionStatusView{HostID: "box", State: "stopped"}
180 applyRemoteConnectionError(&view, errors.Join(errors.New("ssh handshake failed"), root))
181
182 if view.ErrorDetails == nil || view.ErrorDetails.Code != "host_key_mismatch" {
183 t.Fatalf("error details = %+v", view.ErrorDetails)
184 }
185 if view.ErrorDetails.PresentedSHA256 != "SHA256:new" {
186 t.Fatalf("presented fingerprint = %q", view.ErrorDetails.PresentedSHA256)
187 }
188 if got := view.ErrorDetails.KnownHostRecords; len(got) != 1 || got[0].Path != "/home/dev/.ssh/known_hosts" || got[0].Line != 7 {
189 t.Fatalf("known_hosts records = %+v", got)
190 }
191 }
192
193 func TestRemoteConnectionErrorDetailsPreserveDegradedState(t *testing.T) {
194 view := RemoteConnectionStatusView{HostID: "box", State: "degraded"}
195 applyRemoteConnectionError(&view, errors.New("forward attach failed"))
196
197 if view.ErrorDetails != nil {
198 t.Fatalf("degraded error must not be classified as a connection failure: %+v", view.ErrorDetails)
199 }
200 if view.Error != "forward attach failed" {
201 t.Fatalf("raw error = %q", view.Error)
202 }
203 }
204
205 func (f *fakeRemoteKernel) Hosts() ([]RemoteHostView, error) { return f.hosts, nil }
206 func (f *fakeRemoteKernel) AddHost(in RemoteHostInput) (RemoteHostView, error) {
207 v := RemoteHostView{ID: in.Label, Label: in.Label, Host: in.Host}
208 f.hosts = append(f.hosts, v)
209 return v, nil
210 }
211 func (f *fakeRemoteKernel) UpdateHost(id string, in RemoteHostInput) (RemoteHostView, error) {
212 return RemoteHostView{ID: id, Host: in.Host}, nil
213 }
214 func (f *fakeRemoteKernel) RemoveHost(id string) error { return nil }
215 func (f *fakeRemoteKernel) ScanSSHConfig() ([]RemoteHostInput, error) { return nil, nil }
216 func (f *fakeRemoteKernel) Connect(hostID string) error { return nil }
217 func (f *fakeRemoteKernel) Disconnect(hostID string) error { return nil }
218 func (f *fakeRemoteKernel) Statuses() []RemoteConnectionStatusView { return f.statuses }
219 func (f *fakeRemoteKernel) ResolveHostKey(hostID string, accept bool) error {
220 f.resolveCalls = append(f.resolveCalls, accept)
221 return nil
222 }
223 func (f *fakeRemoteKernel) ResolveSecret(hostID, promptID, secret string, accept bool) error {
224 f.secretPromptIDs = append(f.secretPromptIDs, promptID)
225 f.secretCalls = append(f.secretCalls, remoteSecretAnswer{secret: secret, accept: accept})
226 return nil
227 }
228 func (f *fakeRemoteKernel) ListDir(context.Context, string, string) ([]RemoteDirEntry, error) {
229 return []RemoteDirEntry{{Name: "file.txt"}}, nil
230 }
231 func (f *fakeRemoteKernel) ReadFile(context.Context, string, string) (RemoteFilePreview, error) {
232 return RemoteFilePreview{Body: "hi"}, nil
233 }
234 func (f *fakeRemoteKernel) DownloadFile(_ context.Context, _ string, _ string, dst io.Writer) (int64, error) {
235 n, err := io.WriteString(dst, "hi")
236 return int64(n), err
237 }
238 func (f *fakeRemoteKernel) WriteFile(context.Context, string, string, string, int64) (RemoteWriteResult, error) {
239 return f.writeResult, nil
240 }
241 func (f *fakeRemoteKernel) Mkdir(context.Context, string, string) error { return nil }
242 func (f *fakeRemoteKernel) Rename(context.Context, string, string, string) error { return nil }
243 func (f *fakeRemoteKernel) Delete(context.Context, string, string, bool) error { return nil }
244 func (f *fakeRemoteKernel) Forwards(string) []RemoteForwardView { return nil }
245 func (f *fakeRemoteKernel) AddForward(string, RemoteForwardInput) (RemoteForwardView, error) {
246 return RemoteForwardView{}, nil
247 }
248 func (f *fakeRemoteKernel) RemoveForward(string, string) error { return nil }
249 func (f *fakeRemoteKernel) EnsureServer(context.Context, string, string) (RemoteServerView, string, error) {
250 f.ensureCalls++
251 if len(f.ensureErrs) > 0 {
252 err := f.ensureErrs[0]
253 f.ensureErrs = f.ensureErrs[1:]
254 return RemoteServerView{}, "", err
255 }
256 return f.ensureView, f.ensureToken, f.ensureErr
257 }
258 func (f *fakeRemoteKernel) SwitchCredentialProxyModel(_ context.Context, hostID, workspace, currentRef, nextRef, expectedPath string) error {
259 f.switchProxyCalls = append(f.switchProxyCalls, [5]string{hostID, workspace, currentRef, nextRef, expectedPath})
260 return f.switchProxyErr
261 }
262 func (f *fakeRemoteKernel) StopServer(_ string, workspace string) error {
263 f.stoppedWorkspaces = append(f.stoppedWorkspaces, workspace)
264 return nil
265 }
266 func (f *fakeRemoteKernel) ServerStatus(string, string) RemoteServerView { return f.ensureView }
267 func (f *fakeRemoteKernel) ServeSnapshot(string, string) (RemoteServerView, string, bool) {
268 if f.snapshotMiss || f.ensureErr != nil || f.ensureView.State != "ready" || f.ensureView.LocalURL == "" || f.ensureToken == "" {
269 return RemoteServerView{}, "", false
270 }
271 return f.ensureView, f.ensureToken, true
272 }
273 func (f *fakeRemoteKernel) ServerLogs(context.Context, string, string, int) (string, error) {
274 return "log line", nil
275 }
276
277 func (f *fakeRemoteKernel) CheckPlatform(_ context.Context, hostID string) error {
278 f.platformChecks = append(f.platformChecks, hostID)
279 return f.platformErr
280 }
281 func (f *fakeRemoteKernel) Close() error { f.closed = true; return nil }
282
283 func appWithFakeKernel(fake *fakeRemoteKernel) *App {
284 a := &App{ctx: context.Background()}
285 a.remoteRuntime = fake
286 return a
287 }
288
289 func TestRemoteBindingsDelegateToKernel(t *testing.T) {
290 fake := &fakeRemoteKernel{writeResult: RemoteWriteResult{OK: true, NewMtimeUnix: 42}}
291 a := appWithFakeKernel(fake)
292
293 if _, err := a.AddRemoteHost(RemoteHostInput{Label: "box", Host: "10.0.0.1"}); err != nil {
294 t.Fatal(err)
295 }
296 hosts, _ := a.RemoteHosts()
297 if len(hosts) != 1 || hosts[0].ID != "box" {
298 t.Fatalf("hosts = %+v", hosts)
299 }
300 entries, err := a.ListRemoteDir("box", "/")
301 if err != nil || len(entries) != 1 {
302 t.Fatalf("ListRemoteDir = %+v, %v", entries, err)
303 }
304 res, err := a.WriteRemoteFile("box", "/f", "data", 0)
305 if err != nil || !res.OK || res.NewMtimeUnix != 42 {
306 t.Fatalf("WriteRemoteFile = %+v, %v", res, err)
307 }
308 }
309
310 func TestConfirmRemoteHostKeyDelegates(t *testing.T) {
311 fake := &fakeRemoteKernel{}
312 a := appWithFakeKernel(fake)
313 if err := a.ConfirmRemoteHostKey("box", true); err != nil {
314 t.Fatal(err)
315 }
316 if len(fake.resolveCalls) != 1 || fake.resolveCalls[0] != true {
317 t.Fatalf("resolve calls = %+v", fake.resolveCalls)
318 }
319 }
320
321 func TestCheckRemotePlatformDelegatesAndPropagatesError(t *testing.T) {
322 fake := &fakeRemoteKernel{platformErr: errors.New("unsupported remote OS")}
323 a := appWithFakeKernel(fake)
324 if err := a.CheckRemotePlatform("box"); err == nil || !strings.Contains(err.Error(), "unsupported remote OS") {
325 t.Fatalf("err = %v, want unsupported remote OS", err)
326 }
327 if len(fake.platformChecks) != 1 || fake.platformChecks[0] != "box" {
328 t.Fatalf("platform checks = %+v", fake.platformChecks)
329 }
330
331 ok := &fakeRemoteKernel{}
332 a2 := appWithFakeKernel(ok)
333 if err := a2.CheckRemotePlatform("box"); err != nil {
334 t.Fatalf("unexpected error: %v", err)
335 }
336 }
337
338 func TestConfirmRemoteSecretDelegatesWithoutPersisting(t *testing.T) {
339 fake := &fakeRemoteKernel{}
340 a := appWithFakeKernel(fake)
341 if err := a.ConfirmRemoteSecret("box", "prompt-7", "one-shot-secret", true); err != nil {
342 t.Fatal(err)
343 }
344 if len(fake.secretCalls) != 1 || fake.secretCalls[0].secret != "one-shot-secret" || !fake.secretCalls[0].accept || fake.secretPromptIDs[0] != "prompt-7" {
345 t.Fatalf("secret calls = %+v", fake.secretCalls)
346 }
347 }
348
349 // TestRemoteStatusBridgesToAsyncEmitter verifies a kernel status callback lands
350 // on the async emitter as a remote:status event.
351 func TestRemoteStatusBridgesToAsyncEmitter(t *testing.T) {
352 a := &App{ctx: context.Background()}
353 events := make(chan runtimeEventEnvelope, 4)
354 a.runtimeEvents.emit = func(ctx context.Context, name string, payload ...any) {
355 events <- runtimeEventEnvelope{ctx: ctx, name: name, payload: payload}
356 }
357 a.onStatus(RemoteConnectionStatusView{HostID: "box", State: "connected"})
358
359 // The async emitter delivers on a background goroutine, so block briefly.
360 select {
361 case ev := <-events:
362 if ev.name != "remote:status" {
363 t.Fatalf("event name = %q, want remote:status", ev.name)
364 }
365 s, ok := ev.payload[0].(RemoteConnectionStatusView)
366 if !ok || s.HostID != "box" || s.State != "connected" {
367 t.Fatalf("payload = %+v", ev.payload[0])
368 }
369 case <-time.After(2 * time.Second):
370 t.Fatal("no remote:status event emitted")
371 }
372 }
373
374 func TestStopRemoteRuntimeClosesKernel(t *testing.T) {
375 fake := &fakeRemoteKernel{}
376 a := appWithFakeKernel(fake)
377 a.stopRemoteRuntime()
378 if !fake.closed {
379 t.Fatal("kernel not closed on stopRemoteRuntime")
380 }
381 if a.remoteRuntime != nil {
382 t.Fatal("remoteRuntime not cleared")
383 }
384 }
385
386 // TestUpdateHostPreservesHiddenFields pins the data-loss fix: blank secret
387 // inputs and an edit that does not model forwards must not wipe those fields.
388 func TestUpdateHostPreservesHiddenFields(t *testing.T) {
389 home := t.TempDir()
390 t.Setenv("REASONIX_HOME", home)
391 t.Setenv("HOME", home)
392
393 mgr := newDesktopRemoteManager(&App{})
394 // Seed a host with credential refs + a forward via the kernel config API.
395 if err := editUserConfig(func(c *config.Config) error {
396 return c.UpsertRemoteHost(config.RemoteHostEntry{
397 Name: "box", Host: "10.0.0.9", User: "dev",
398 PassphraseEnv: "REMOTE_BOX_PASSPHRASE",
399 PasswordEnv: "REMOTE_BOX_PASSWORD",
400 Forwards: []config.RemoteForwardEntry{{Type: "local", Bind: "127.0.0.1:8080", Target: "127.0.0.1:80"}},
401 })
402 }); err != nil {
403 t.Fatal(err)
404 }
405
406 // Edit via the desktop input with blank secrets, changing only the user.
407 if _, err := mgr.UpdateHost("box", RemoteHostInput{Label: "box", Host: "10.0.0.9", Port: 22, User: "ops", ServeInstall: "auto"}); err != nil {
408 t.Fatalf("UpdateHost: %v", err)
409 }
410
411 cfg, err := config.Load()
412 if err != nil {
413 t.Fatal(err)
414 }
415 h, ok := cfg.RemoteHost("box")
416 if !ok {
417 t.Fatal("host missing after edit")
418 }
419 if h.User != "ops" {
420 t.Fatalf("edit did not apply: user=%q", h.User)
421 }
422 if h.PassphraseEnv != "REMOTE_BOX_PASSPHRASE" || h.PasswordEnv != "REMOTE_BOX_PASSWORD" {
423 t.Fatalf("edit wiped credential env refs: %+v", h)
424 }
425 if len(h.Forwards) != 1 || h.Forwards[0].Bind != "127.0.0.1:8080" {
426 t.Fatalf("edit wiped persisted forwards: %+v", h.Forwards)
427 }
428 }
429
430 func TestUpdateHostStopsCredentialWatchdogWhenLocalProxyDisabled(t *testing.T) {
431 home := t.TempDir()
432 t.Setenv("REASONIX_HOME", home)
433 t.Setenv("HOME", home)
434 if err := editUserConfig(func(c *config.Config) error {
435 return c.UpsertRemoteHost(config.RemoteHostEntry{
436 Name: "box", Host: "10.0.0.9", Port: 22, User: "dev", CredentialMode: "local-proxy",
437 })
438 }); err != nil {
439 t.Fatal(err)
440 }
441
442 watchCtx, cancel := context.WithCancel(context.Background())
443 mgr := newDesktopRemoteManager(&App{})
444 mh := &managedHost{}
445 mh.credWatch.cancel = cancel
446 mh.credWatch.workspace = "/srv/app"
447 mgr.hosts["box"] = mh
448
449 if _, err := mgr.UpdateHost("box", RemoteHostInput{
450 Label: "box", Host: "10.0.0.9", Port: 22, User: "dev", ServeInstall: "auto", CredentialMode: "remote",
451 }); err != nil {
452 t.Fatal(err)
453 }
454 select {
455 case <-watchCtx.Done():
456 default:
457 t.Fatal("credential watchdog remained active after local-proxy was disabled")
458 }
459 mh.credWatch.mu.Lock()
460 defer mh.credWatch.mu.Unlock()
461 if mh.credWatch.cancel != nil || mh.credWatch.workspace != "" {
462 t.Fatalf("credential watchdog state = cancel:%v workspace:%q, want stopped", mh.credWatch.cancel != nil, mh.credWatch.workspace)
463 }
464 }
465
466 func TestSSHConfigReimportPreservesReasonixSettings(t *testing.T) {
467 home := t.TempDir()
468 t.Setenv("REASONIX_HOME", home)
469 t.Setenv("HOME", home)
470 if err := editUserConfig(func(c *config.Config) error {
471 return c.UpsertRemoteHost(config.RemoteHostEntry{
472 Name: "box", Host: "old.example", Workspace: "/srv/app", ServeInstall: "never",
473 PasswordEnv: "REMOTE_BOX_PASSWORD",
474 Forwards: []config.RemoteForwardEntry{{Type: "local", Bind: "127.0.0.1:8080", Target: "127.0.0.1:80"}},
475 })
476 }); err != nil {
477 t.Fatal(err)
478 }
479
480 mgr := newDesktopRemoteManager(&App{})
481 if _, err := mgr.AddHost(RemoteHostInput{
482 Label: "box", Host: "box", UseSSHConfig: true, PreserveExistingSettings: true,
483 }); err != nil {
484 t.Fatal(err)
485 }
486 cfg, err := config.Load()
487 if err != nil {
488 t.Fatal(err)
489 }
490 host, ok := cfg.RemoteHost("box")
491 if !ok {
492 t.Fatal("reimported host is missing")
493 }
494 if host.Host != "box" || !host.UseSSHConfig || host.Workspace != "/srv/app" || host.ServeInstall != "never" {
495 t.Fatalf("reimported host settings = %+v", host)
496 }
497 if host.PasswordEnv != "REMOTE_BOX_PASSWORD" || len(host.Forwards) != 1 {
498 t.Fatalf("reimport wiped hidden settings: %+v", host)
499 }
500 }
501
502 func TestRemoteHostCredentialsStayOutOfConfigAndCanBeCleared(t *testing.T) {
503 home := t.TempDir()
504 t.Setenv("REASONIX_HOME", home)
505 t.Setenv("HOME", home)
506
507 mgr := newDesktopRemoteManager(&App{})
508 in := RemoteHostInput{
509 Label: "secure-box", Host: "10.0.0.12", Port: 22, User: "dev", ServeInstall: "auto",
510 Password: "server-password", KeyPassphrase: "private-key-passphrase",
511 }
512 view, err := mgr.AddHost(in)
513 if err != nil {
514 t.Fatalf("AddHost: %v", err)
515 }
516 if !view.PasswordSet || !view.KeyPassphraseSet {
517 t.Fatalf("credential flags = password:%v passphrase:%v", view.PasswordSet, view.KeyPassphraseSet)
518 }
519
520 cfg, err := config.Load()
521 if err != nil {
522 t.Fatal(err)
523 }
524 host, ok := cfg.RemoteHost("secure-box")
525 if !ok {
526 t.Fatal("saved host missing")
527 }
528 wantPasswordEnv := config.RemotePasswordCredentialEnvName("secure-box")
529 wantPassphraseEnv := config.RemotePassphraseCredentialEnvName("secure-box")
530 if host.PasswordEnv != wantPasswordEnv || host.PassphraseEnv != wantPassphraseEnv {
531 t.Fatalf("credential refs = password:%q passphrase:%q", host.PasswordEnv, host.PassphraseEnv)
532 }
533 t.Cleanup(func() {
534 _ = config.RemoveCredential(wantPasswordEnv)
535 _ = config.RemoveCredential(wantPassphraseEnv)
536 })
537 if got := config.ResolveCredentialForRootGlobalFirst(home, wantPasswordEnv); !got.Set || got.Value != in.Password {
538 t.Fatalf("stored password = set:%v value:%q", got.Set, got.Value)
539 }
540 if got := config.ResolveCredentialForRootGlobalFirst(home, wantPassphraseEnv); !got.Set || got.Value != in.KeyPassphrase {
541 t.Fatalf("stored passphrase = set:%v value:%q", got.Set, got.Value)
542 }
543 configBytes, err := os.ReadFile(config.UserConfigPath())
544 if err != nil {
545 t.Fatal(err)
546 }
547 if strings.Contains(string(configBytes), in.Password) || strings.Contains(string(configBytes), in.KeyPassphrase) {
548 t.Fatalf("plaintext secret leaked into config.toml:\n%s", configBytes)
549 }
550
551 // Blank secret fields preserve both references and stored values.
552 if _, err := mgr.UpdateHost("secure-box", RemoteHostInput{
553 Label: "secure-box", Host: "10.0.0.12", Port: 22, User: "ops", ServeInstall: "auto",
554 }); err != nil {
555 t.Fatalf("UpdateHost blank credentials: %v", err)
556 }
557 if got := config.ResolveCredentialForRootGlobalFirst(home, wantPasswordEnv); !got.Set || got.Value != in.Password {
558 t.Fatalf("blank edit changed password: %+v", got)
559 }
560
561 view, err = mgr.UpdateHost("secure-box", RemoteHostInput{
562 Label: "secure-box", Host: "10.0.0.12", Port: 22, User: "ops", ServeInstall: "auto", ClearPassword: true,
563 })
564 if err != nil {
565 t.Fatalf("UpdateHost clear password: %v", err)
566 }
567 if view.PasswordSet || !view.KeyPassphraseSet {
568 t.Fatalf("credential flags after clear = password:%v passphrase:%v", view.PasswordSet, view.KeyPassphraseSet)
569 }
570 if got := config.ResolveCredentialForRootGlobalFirst(home, wantPasswordEnv); got.Set {
571 t.Fatal("generated password credential remains after explicit clear")
572 }
573
574 if err := mgr.RemoveHost("secure-box"); err != nil {
575 t.Fatalf("RemoveHost: %v", err)
576 }
577 if got := config.ResolveCredentialForRootGlobalFirst(home, wantPassphraseEnv); got.Set {
578 t.Fatal("generated passphrase credential remains after host removal")
579 }
580 }
581
582 func TestClearRemoteHostCredentialDoesNotDeleteUserManagedEnv(t *testing.T) {
583 home := t.TempDir()
584 t.Setenv("REASONIX_HOME", home)
585 t.Setenv("HOME", home)
586 const key = "TEAM_SHARED_SSH_PASSWORD"
587 if _, err := config.SetCredential(key, "shared-secret"); err != nil {
588 t.Fatal(err)
589 }
590 t.Cleanup(func() { _ = config.RemoveCredential(key) })
591 if err := editUserConfig(func(c *config.Config) error {
592 return c.UpsertRemoteHost(config.RemoteHostEntry{
593 Name: "shared-box", Host: "10.0.0.15", User: "dev", PasswordEnv: key,
594 })
595 }); err != nil {
596 t.Fatal(err)
597 }
598
599 mgr := newDesktopRemoteManager(&App{})
600 if _, err := mgr.UpdateHost("shared-box", RemoteHostInput{
601 Label: "shared-box", Host: "10.0.0.15", Port: 22, User: "dev", ServeInstall: "auto", ClearPassword: true,
602 }); err != nil {
603 t.Fatal(err)
604 }
605 cfg, err := config.Load()
606 if err != nil {
607 t.Fatal(err)
608 }
609 host, ok := cfg.RemoteHost("shared-box")
610 if !ok || host.PasswordEnv != "" {
611 t.Fatalf("password reference was not cleared: %+v", host)
612 }
613 if got := config.ResolveCredentialForRootGlobalFirst(home, key); !got.Set || got.Value != "shared-secret" {
614 t.Fatalf("user-managed credential was deleted: %+v", got)
615 }
616 }
617
618 func TestRemoteHostCredentialWriteRollsBackOnFailure(t *testing.T) {
619 home := t.TempDir()
620 t.Setenv("REASONIX_HOME", home)
621 t.Setenv("HOME", home)
622
623 mgr := newDesktopRemoteManager(&App{})
624 _, err := mgr.AddHost(RemoteHostInput{
625 Label: "rollback-box", Host: "10.0.0.19", Port: 22, User: "dev", ServeInstall: "auto",
626 Password: "must-not-remain", KeyPassphrase: "invalid\npassphrase",
627 })
628 if err == nil {
629 t.Fatal("expected credential validation failure")
630 }
631 passwordEnv := config.RemotePasswordCredentialEnvName("rollback-box")
632 passphraseEnv := config.RemotePassphraseCredentialEnvName("rollback-box")
633 t.Cleanup(func() {
634 _ = config.RemoveCredential(passwordEnv)
635 _ = config.RemoveCredential(passphraseEnv)
636 })
637 if got := config.ResolveCredentialForRootGlobalFirst(home, passwordEnv); got.Set {
638 t.Fatal("first credential write was not rolled back after the second failed")
639 }
640 cfg, loadErr := config.Load()
641 if loadErr != nil {
642 t.Fatal(loadErr)
643 }
644 if _, ok := cfg.RemoteHost("rollback-box"); ok {
645 t.Fatal("host config was saved despite credential write failure")
646 }
647 }
648
649 // TestScanSSHConfigReturnsNonNil pins the JSON-contract fix: an empty scan must
650 // encode as [] (not null), which the React import page iterates safely.
651 func TestScanSSHConfigReturnsNonNil(t *testing.T) {
652 home := t.TempDir()
653 t.Setenv("REASONIX_HOME", home)
654 t.Setenv("HOME", home) // no ~/.ssh/config here => empty result
655 t.Setenv("USERPROFILE", home)
656 mgr := newDesktopRemoteManager(&App{})
657 out, err := mgr.ScanSSHConfig()
658 if err != nil {
659 t.Fatalf("ScanSSHConfig: %v", err)
660 }
661 if out == nil {
662 t.Fatal("ScanSSHConfig returned nil slice (would encode as JSON null and crash the import page)")
663 }
664 }
665
666 func TestScanSSHConfigPreservesAliasInsteadOfSnapshottingEffectiveFields(t *testing.T) {
667 home := t.TempDir()
668 t.Setenv("REASONIX_HOME", home)
669 t.Setenv("HOME", home)
670 t.Setenv("USERPROFILE", home)
671 sshDir := filepath.Join(home, ".ssh")
672 if err := os.MkdirAll(sshDir, 0o700); err != nil {
673 t.Fatal(err)
674 }
675 configBody := "Host live-box\n HostName 192.0.2.40\n User dev\n Port 2202\n IdentityFile ~/.ssh/live-box\n"
676 if err := os.WriteFile(filepath.Join(sshDir, "config"), []byte(configBody), 0o600); err != nil {
677 t.Fatal(err)
678 }
679 mgr := newDesktopRemoteManager(&App{})
680 out, err := mgr.ScanSSHConfig()
681 if err != nil {
682 t.Fatal(err)
683 }
684 if len(out) != 1 {
685 t.Fatalf("scan = %+v", out)
686 }
687 got := out[0]
688 if got.Label != "live-box" || got.Host != "live-box" || !got.UseSSHConfig || !got.PreserveExistingSettings {
689 t.Fatalf("alias was not preserved: %+v", got)
690 }
691 if got.Port != 0 || got.User != "" || got.IdentityFile != "" || got.ProxyJump != "" {
692 t.Fatalf("effective config was snapshotted instead of resolved live: %+v", got)
693 }
694 }
695
696 func TestOpenRemoteWorkspacePersistsLastWorkspace(t *testing.T) {
697 // Workbench path: OpenRemoteWorkspace no longer opens a Serve HTML window.
698 // Persistence of last workspace is still via saveLastRemoteWorkspace after a
699 // successful connect; unit-test the persistence helper directly.
700 home := t.TempDir()
701 t.Setenv("REASONIX_HOME", home)
702 t.Setenv("HOME", home)
703 a := &App{ctx: context.Background()}
704 if err := a.saveLastRemoteWorkspace("box", "/home/dev/app"); err != nil {
705 t.Fatal(err)
706 }
707 got := a.RemoteLastWorkspace("box")
708 if got != "/home/dev/app" {
709 t.Fatalf("last workspace = %q, want /home/dev/app", got)
710 }
711 if _, err := os.Stat(filepath.Join(config.MemoryUserDir(), "desktop-remote.json")); err != nil {
712 t.Fatalf("desktop-remote.json not written: %v", err)
713 }
714 }
715
715 lines GO