返回 DeepSeek-Reasonix
remote_app_test.go
根目录 / desktop / remote_app_test.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11
12 "reasonix/internal/config"
13 "reasonix/internal/remote"
14 )
15
16 // fakeRemoteKernel implements remoteKernel for binding-layer tests.
17 type fakeRemoteKernel struct {
18 hosts []RemoteHostView
19 statuses []RemoteConnectionStatusView
20 writeResult RemoteWriteResult
21 ensureView RemoteServerView
22 ensureToken string
23 ensureErr error
24 resolveCalls []bool
25 secretCalls []remoteSecretAnswer
26 secretPromptIDs []string
27 closed bool
28 }
29
30 func TestRemoteConnectionErrorDetailsPreserveHostKeyMismatch(t *testing.T) {
31 root := &remote.HostKeyMismatchError{
32 Host: "dev@example.test:2222",
33 PresentedFingerprint: "SHA256:new",
34 Locations: []remote.KnownHostLocation{
35 {Filename: "/home/dev/.ssh/known_hosts", Line: 7},
36 },
37 }
38 view := RemoteConnectionStatusView{HostID: "box", State: "stopped"}
39 applyRemoteConnectionError(&view, errors.Join(errors.New("ssh handshake failed"), root))
40
41 if view.ErrorDetails == nil || view.ErrorDetails.Code != "host_key_mismatch" {
42 t.Fatalf("error details = %+v", view.ErrorDetails)
43 }
44 if view.ErrorDetails.PresentedSHA256 != "SHA256:new" {
45 t.Fatalf("presented fingerprint = %q", view.ErrorDetails.PresentedSHA256)
46 }
47 if got := view.ErrorDetails.KnownHostRecords; len(got) != 1 || got[0].Path != "/home/dev/.ssh/known_hosts" || got[0].Line != 7 {
48 t.Fatalf("known_hosts records = %+v", got)
49 }
50 }
51
52 func TestRemoteConnectionErrorDetailsPreserveDegradedState(t *testing.T) {
53 view := RemoteConnectionStatusView{HostID: "box", State: "degraded"}
54 applyRemoteConnectionError(&view, errors.New("forward attach failed"))
55
56 if view.ErrorDetails != nil {
57 t.Fatalf("degraded error must not be classified as a connection failure: %+v", view.ErrorDetails)
58 }
59 if view.Error != "forward attach failed" {
60 t.Fatalf("raw error = %q", view.Error)
61 }
62 }
63
64 func (f *fakeRemoteKernel) Hosts() ([]RemoteHostView, error) { return f.hosts, nil }
65 func (f *fakeRemoteKernel) AddHost(in RemoteHostInput) (RemoteHostView, error) {
66 v := RemoteHostView{ID: in.Label, Label: in.Label, Host: in.Host}
67 f.hosts = append(f.hosts, v)
68 return v, nil
69 }
70 func (f *fakeRemoteKernel) UpdateHost(id string, in RemoteHostInput) (RemoteHostView, error) {
71 return RemoteHostView{ID: id, Host: in.Host}, nil
72 }
73 func (f *fakeRemoteKernel) RemoveHost(id string) error { return nil }
74 func (f *fakeRemoteKernel) ScanSSHConfig() ([]RemoteHostInput, error) { return nil, nil }
75 func (f *fakeRemoteKernel) Connect(hostID string) error { return nil }
76 func (f *fakeRemoteKernel) Disconnect(hostID string) error { return nil }
77 func (f *fakeRemoteKernel) Statuses() []RemoteConnectionStatusView { return f.statuses }
78 func (f *fakeRemoteKernel) ResolveHostKey(hostID string, accept bool) error {
79 f.resolveCalls = append(f.resolveCalls, accept)
80 return nil
81 }
82 func (f *fakeRemoteKernel) ResolveSecret(hostID, promptID, secret string, accept bool) error {
83 f.secretPromptIDs = append(f.secretPromptIDs, promptID)
84 f.secretCalls = append(f.secretCalls, remoteSecretAnswer{secret: secret, accept: accept})
85 return nil
86 }
87 func (f *fakeRemoteKernel) ListDir(context.Context, string, string) ([]RemoteDirEntry, error) {
88 return []RemoteDirEntry{{Name: "file.txt"}}, nil
89 }
90 func (f *fakeRemoteKernel) ReadFile(context.Context, string, string) (RemoteFilePreview, error) {
91 return RemoteFilePreview{Body: "hi"}, nil
92 }
93 func (f *fakeRemoteKernel) WriteFile(context.Context, string, string, string, int64) (RemoteWriteResult, error) {
94 return f.writeResult, nil
95 }
96 func (f *fakeRemoteKernel) Mkdir(context.Context, string, string) error { return nil }
97 func (f *fakeRemoteKernel) Rename(context.Context, string, string, string) error { return nil }
98 func (f *fakeRemoteKernel) Delete(context.Context, string, string, bool) error { return nil }
99 func (f *fakeRemoteKernel) Forwards(string) []RemoteForwardView { return nil }
100 func (f *fakeRemoteKernel) AddForward(string, RemoteForwardInput) (RemoteForwardView, error) {
101 return RemoteForwardView{}, nil
102 }
103 func (f *fakeRemoteKernel) RemoveForward(string, string) error { return nil }
104 func (f *fakeRemoteKernel) EnsureServer(context.Context, string, string) (RemoteServerView, string, error) {
105 return f.ensureView, f.ensureToken, f.ensureErr
106 }
107 func (f *fakeRemoteKernel) StopServer(string) error { return nil }
108 func (f *fakeRemoteKernel) ServerStatus(string) RemoteServerView { return f.ensureView }
109 func (f *fakeRemoteKernel) ServerLogs(context.Context, string, int) (string, error) {
110 return "log line", nil
111 }
112 func (f *fakeRemoteKernel) Close() error { f.closed = true; return nil }
113
114 func appWithFakeKernel(fake *fakeRemoteKernel) *App {
115 a := &App{ctx: context.Background()}
116 a.remoteRuntime = fake
117 return a
118 }
119
120 func TestRemoteBindingsDelegateToKernel(t *testing.T) {
121 fake := &fakeRemoteKernel{writeResult: RemoteWriteResult{OK: true, NewMtimeUnix: 42}}
122 a := appWithFakeKernel(fake)
123
124 if _, err := a.AddRemoteHost(RemoteHostInput{Label: "box", Host: "10.0.0.1"}); err != nil {
125 t.Fatal(err)
126 }
127 hosts, _ := a.RemoteHosts()
128 if len(hosts) != 1 || hosts[0].ID != "box" {
129 t.Fatalf("hosts = %+v", hosts)
130 }
131 entries, err := a.ListRemoteDir("box", "/")
132 if err != nil || len(entries) != 1 {
133 t.Fatalf("ListRemoteDir = %+v, %v", entries, err)
134 }
135 res, err := a.WriteRemoteFile("box", "/f", "data", 0)
136 if err != nil || !res.OK || res.NewMtimeUnix != 42 {
137 t.Fatalf("WriteRemoteFile = %+v, %v", res, err)
138 }
139 }
140
141 func TestConfirmRemoteHostKeyDelegates(t *testing.T) {
142 fake := &fakeRemoteKernel{}
143 a := appWithFakeKernel(fake)
144 if err := a.ConfirmRemoteHostKey("box", true); err != nil {
145 t.Fatal(err)
146 }
147 if len(fake.resolveCalls) != 1 || fake.resolveCalls[0] != true {
148 t.Fatalf("resolve calls = %+v", fake.resolveCalls)
149 }
150 }
151
152 func TestConfirmRemoteSecretDelegatesWithoutPersisting(t *testing.T) {
153 fake := &fakeRemoteKernel{}
154 a := appWithFakeKernel(fake)
155 if err := a.ConfirmRemoteSecret("box", "prompt-7", "one-shot-secret", true); err != nil {
156 t.Fatal(err)
157 }
158 if len(fake.secretCalls) != 1 || fake.secretCalls[0].secret != "one-shot-secret" || !fake.secretCalls[0].accept || fake.secretPromptIDs[0] != "prompt-7" {
159 t.Fatalf("secret calls = %+v", fake.secretCalls)
160 }
161 }
162
163 // TestRemoteStatusBridgesToAsyncEmitter verifies a kernel status callback lands
164 // on the async emitter as a remote:status event.
165 func TestRemoteStatusBridgesToAsyncEmitter(t *testing.T) {
166 a := &App{ctx: context.Background()}
167 events := make(chan runtimeEventEnvelope, 4)
168 a.runtimeEvents.emit = func(ctx context.Context, name string, payload ...interface{}) {
169 events <- runtimeEventEnvelope{ctx: ctx, name: name, payload: payload}
170 }
171 a.onStatus(RemoteConnectionStatusView{HostID: "box", State: "connected"})
172
173 // The async emitter delivers on a background goroutine, so block briefly.
174 select {
175 case ev := <-events:
176 if ev.name != "remote:status" {
177 t.Fatalf("event name = %q, want remote:status", ev.name)
178 }
179 s, ok := ev.payload[0].(RemoteConnectionStatusView)
180 if !ok || s.HostID != "box" || s.State != "connected" {
181 t.Fatalf("payload = %+v", ev.payload[0])
182 }
183 case <-time.After(2 * time.Second):
184 t.Fatal("no remote:status event emitted")
185 }
186 }
187
188 func TestStopRemoteRuntimeClosesKernel(t *testing.T) {
189 fake := &fakeRemoteKernel{}
190 a := appWithFakeKernel(fake)
191 a.stopRemoteRuntime()
192 if !fake.closed {
193 t.Fatal("kernel not closed on stopRemoteRuntime")
194 }
195 if a.remoteRuntime != nil {
196 t.Fatal("remoteRuntime not cleared")
197 }
198 }
199
200 // TestUpdateHostPreservesHiddenFields pins the data-loss fix: blank secret
201 // inputs and an edit that does not model forwards must not wipe those fields.
202 func TestUpdateHostPreservesHiddenFields(t *testing.T) {
203 home := t.TempDir()
204 t.Setenv("REASONIX_HOME", home)
205 t.Setenv("HOME", home)
206
207 mgr := newDesktopRemoteManager(&App{})
208 // Seed a host with credential refs + a forward via the kernel config API.
209 if err := editUserConfig(func(c *config.Config) error {
210 return c.UpsertRemoteHost(config.RemoteHostEntry{
211 Name: "box", Host: "10.0.0.9", User: "dev",
212 PassphraseEnv: "REMOTE_BOX_PASSPHRASE",
213 PasswordEnv: "REMOTE_BOX_PASSWORD",
214 Forwards: []config.RemoteForwardEntry{{Type: "local", Bind: "127.0.0.1:8080", Target: "127.0.0.1:80"}},
215 })
216 }); err != nil {
217 t.Fatal(err)
218 }
219
220 // Edit via the desktop input with blank secrets, changing only the user.
221 if _, err := mgr.UpdateHost("box", RemoteHostInput{Label: "box", Host: "10.0.0.9", Port: 22, User: "ops", ServeInstall: "auto"}); err != nil {
222 t.Fatalf("UpdateHost: %v", err)
223 }
224
225 cfg, err := config.Load()
226 if err != nil {
227 t.Fatal(err)
228 }
229 h, ok := cfg.RemoteHost("box")
230 if !ok {
231 t.Fatal("host missing after edit")
232 }
233 if h.User != "ops" {
234 t.Fatalf("edit did not apply: user=%q", h.User)
235 }
236 if h.PassphraseEnv != "REMOTE_BOX_PASSPHRASE" || h.PasswordEnv != "REMOTE_BOX_PASSWORD" {
237 t.Fatalf("edit wiped credential env refs: %+v", h)
238 }
239 if len(h.Forwards) != 1 || h.Forwards[0].Bind != "127.0.0.1:8080" {
240 t.Fatalf("edit wiped persisted forwards: %+v", h.Forwards)
241 }
242 }
243
244 func TestSSHConfigReimportPreservesReasonixSettings(t *testing.T) {
245 home := t.TempDir()
246 t.Setenv("REASONIX_HOME", home)
247 t.Setenv("HOME", home)
248 if err := editUserConfig(func(c *config.Config) error {
249 return c.UpsertRemoteHost(config.RemoteHostEntry{
250 Name: "box", Host: "old.example", Workspace: "/srv/app", ServeInstall: "never",
251 PasswordEnv: "REMOTE_BOX_PASSWORD",
252 Forwards: []config.RemoteForwardEntry{{Type: "local", Bind: "127.0.0.1:8080", Target: "127.0.0.1:80"}},
253 })
254 }); err != nil {
255 t.Fatal(err)
256 }
257
258 mgr := newDesktopRemoteManager(&App{})
259 if _, err := mgr.AddHost(RemoteHostInput{
260 Label: "box", Host: "box", UseSSHConfig: true, PreserveExistingSettings: true,
261 }); err != nil {
262 t.Fatal(err)
263 }
264 cfg, err := config.Load()
265 if err != nil {
266 t.Fatal(err)
267 }
268 host, ok := cfg.RemoteHost("box")
269 if !ok {
270 t.Fatal("reimported host is missing")
271 }
272 if host.Host != "box" || !host.UseSSHConfig || host.Workspace != "/srv/app" || host.ServeInstall != "never" {
273 t.Fatalf("reimported host settings = %+v", host)
274 }
275 if host.PasswordEnv != "REMOTE_BOX_PASSWORD" || len(host.Forwards) != 1 {
276 t.Fatalf("reimport wiped hidden settings: %+v", host)
277 }
278 }
279
280 func TestRemoteHostCredentialsStayOutOfConfigAndCanBeCleared(t *testing.T) {
281 home := t.TempDir()
282 t.Setenv("REASONIX_HOME", home)
283 t.Setenv("HOME", home)
284
285 mgr := newDesktopRemoteManager(&App{})
286 in := RemoteHostInput{
287 Label: "secure-box", Host: "10.0.0.12", Port: 22, User: "dev", ServeInstall: "auto",
288 Password: "server-password", KeyPassphrase: "private-key-passphrase",
289 }
290 view, err := mgr.AddHost(in)
291 if err != nil {
292 t.Fatalf("AddHost: %v", err)
293 }
294 if !view.PasswordSet || !view.KeyPassphraseSet {
295 t.Fatalf("credential flags = password:%v passphrase:%v", view.PasswordSet, view.KeyPassphraseSet)
296 }
297
298 cfg, err := config.Load()
299 if err != nil {
300 t.Fatal(err)
301 }
302 host, ok := cfg.RemoteHost("secure-box")
303 if !ok {
304 t.Fatal("saved host missing")
305 }
306 wantPasswordEnv := config.RemotePasswordCredentialEnvName("secure-box")
307 wantPassphraseEnv := config.RemotePassphraseCredentialEnvName("secure-box")
308 if host.PasswordEnv != wantPasswordEnv || host.PassphraseEnv != wantPassphraseEnv {
309 t.Fatalf("credential refs = password:%q passphrase:%q", host.PasswordEnv, host.PassphraseEnv)
310 }
311 t.Cleanup(func() {
312 _ = config.RemoveCredential(wantPasswordEnv)
313 _ = config.RemoveCredential(wantPassphraseEnv)
314 })
315 if got := config.ResolveCredentialForRootGlobalFirst(home, wantPasswordEnv); !got.Set || got.Value != in.Password {
316 t.Fatalf("stored password = set:%v value:%q", got.Set, got.Value)
317 }
318 if got := config.ResolveCredentialForRootGlobalFirst(home, wantPassphraseEnv); !got.Set || got.Value != in.KeyPassphrase {
319 t.Fatalf("stored passphrase = set:%v value:%q", got.Set, got.Value)
320 }
321 configBytes, err := os.ReadFile(config.UserConfigPath())
322 if err != nil {
323 t.Fatal(err)
324 }
325 if strings.Contains(string(configBytes), in.Password) || strings.Contains(string(configBytes), in.KeyPassphrase) {
326 t.Fatalf("plaintext secret leaked into config.toml:\n%s", configBytes)
327 }
328
329 // Blank secret fields preserve both references and stored values.
330 if _, err := mgr.UpdateHost("secure-box", RemoteHostInput{
331 Label: "secure-box", Host: "10.0.0.12", Port: 22, User: "ops", ServeInstall: "auto",
332 }); err != nil {
333 t.Fatalf("UpdateHost blank credentials: %v", err)
334 }
335 if got := config.ResolveCredentialForRootGlobalFirst(home, wantPasswordEnv); !got.Set || got.Value != in.Password {
336 t.Fatalf("blank edit changed password: %+v", got)
337 }
338
339 view, err = mgr.UpdateHost("secure-box", RemoteHostInput{
340 Label: "secure-box", Host: "10.0.0.12", Port: 22, User: "ops", ServeInstall: "auto", ClearPassword: true,
341 })
342 if err != nil {
343 t.Fatalf("UpdateHost clear password: %v", err)
344 }
345 if view.PasswordSet || !view.KeyPassphraseSet {
346 t.Fatalf("credential flags after clear = password:%v passphrase:%v", view.PasswordSet, view.KeyPassphraseSet)
347 }
348 if got := config.ResolveCredentialForRootGlobalFirst(home, wantPasswordEnv); got.Set {
349 t.Fatal("generated password credential remains after explicit clear")
350 }
351
352 if err := mgr.RemoveHost("secure-box"); err != nil {
353 t.Fatalf("RemoveHost: %v", err)
354 }
355 if got := config.ResolveCredentialForRootGlobalFirst(home, wantPassphraseEnv); got.Set {
356 t.Fatal("generated passphrase credential remains after host removal")
357 }
358 }
359
360 func TestClearRemoteHostCredentialDoesNotDeleteUserManagedEnv(t *testing.T) {
361 home := t.TempDir()
362 t.Setenv("REASONIX_HOME", home)
363 t.Setenv("HOME", home)
364 const key = "TEAM_SHARED_SSH_PASSWORD"
365 if _, err := config.SetCredential(key, "shared-secret"); err != nil {
366 t.Fatal(err)
367 }
368 t.Cleanup(func() { _ = config.RemoveCredential(key) })
369 if err := editUserConfig(func(c *config.Config) error {
370 return c.UpsertRemoteHost(config.RemoteHostEntry{
371 Name: "shared-box", Host: "10.0.0.15", User: "dev", PasswordEnv: key,
372 })
373 }); err != nil {
374 t.Fatal(err)
375 }
376
377 mgr := newDesktopRemoteManager(&App{})
378 if _, err := mgr.UpdateHost("shared-box", RemoteHostInput{
379 Label: "shared-box", Host: "10.0.0.15", Port: 22, User: "dev", ServeInstall: "auto", ClearPassword: true,
380 }); err != nil {
381 t.Fatal(err)
382 }
383 cfg, err := config.Load()
384 if err != nil {
385 t.Fatal(err)
386 }
387 host, ok := cfg.RemoteHost("shared-box")
388 if !ok || host.PasswordEnv != "" {
389 t.Fatalf("password reference was not cleared: %+v", host)
390 }
391 if got := config.ResolveCredentialForRootGlobalFirst(home, key); !got.Set || got.Value != "shared-secret" {
392 t.Fatalf("user-managed credential was deleted: %+v", got)
393 }
394 }
395
396 func TestRemoteHostCredentialWriteRollsBackOnFailure(t *testing.T) {
397 home := t.TempDir()
398 t.Setenv("REASONIX_HOME", home)
399 t.Setenv("HOME", home)
400
401 mgr := newDesktopRemoteManager(&App{})
402 _, err := mgr.AddHost(RemoteHostInput{
403 Label: "rollback-box", Host: "10.0.0.19", Port: 22, User: "dev", ServeInstall: "auto",
404 Password: "must-not-remain", KeyPassphrase: "invalid\npassphrase",
405 })
406 if err == nil {
407 t.Fatal("expected credential validation failure")
408 }
409 passwordEnv := config.RemotePasswordCredentialEnvName("rollback-box")
410 passphraseEnv := config.RemotePassphraseCredentialEnvName("rollback-box")
411 t.Cleanup(func() {
412 _ = config.RemoveCredential(passwordEnv)
413 _ = config.RemoveCredential(passphraseEnv)
414 })
415 if got := config.ResolveCredentialForRootGlobalFirst(home, passwordEnv); got.Set {
416 t.Fatal("first credential write was not rolled back after the second failed")
417 }
418 cfg, loadErr := config.Load()
419 if loadErr != nil {
420 t.Fatal(loadErr)
421 }
422 if _, ok := cfg.RemoteHost("rollback-box"); ok {
423 t.Fatal("host config was saved despite credential write failure")
424 }
425 }
426
427 // TestScanSSHConfigReturnsNonNil pins the JSON-contract fix: an empty scan must
428 // encode as [] (not null), which the React import page iterates safely.
429 func TestScanSSHConfigReturnsNonNil(t *testing.T) {
430 home := t.TempDir()
431 t.Setenv("REASONIX_HOME", home)
432 t.Setenv("HOME", home) // no ~/.ssh/config here => empty result
433 t.Setenv("USERPROFILE", home)
434 mgr := newDesktopRemoteManager(&App{})
435 out, err := mgr.ScanSSHConfig()
436 if err != nil {
437 t.Fatalf("ScanSSHConfig: %v", err)
438 }
439 if out == nil {
440 t.Fatal("ScanSSHConfig returned nil slice (would encode as JSON null and crash the import page)")
441 }
442 }
443
444 func TestScanSSHConfigPreservesAliasInsteadOfSnapshottingEffectiveFields(t *testing.T) {
445 home := t.TempDir()
446 t.Setenv("REASONIX_HOME", home)
447 t.Setenv("HOME", home)
448 t.Setenv("USERPROFILE", home)
449 sshDir := filepath.Join(home, ".ssh")
450 if err := os.MkdirAll(sshDir, 0o700); err != nil {
451 t.Fatal(err)
452 }
453 configBody := "Host live-box\n HostName 192.0.2.40\n User dev\n Port 2202\n IdentityFile ~/.ssh/live-box\n"
454 if err := os.WriteFile(filepath.Join(sshDir, "config"), []byte(configBody), 0o600); err != nil {
455 t.Fatal(err)
456 }
457 mgr := newDesktopRemoteManager(&App{})
458 out, err := mgr.ScanSSHConfig()
459 if err != nil {
460 t.Fatal(err)
461 }
462 if len(out) != 1 {
463 t.Fatalf("scan = %+v", out)
464 }
465 got := out[0]
466 if got.Label != "live-box" || got.Host != "live-box" || !got.UseSSHConfig || !got.PreserveExistingSettings {
467 t.Fatalf("alias was not preserved: %+v", got)
468 }
469 if got.Port != 0 || got.User != "" || got.IdentityFile != "" || got.ProxyJump != "" {
470 t.Fatalf("effective config was snapshotted instead of resolved live: %+v", got)
471 }
472 }
473
474 func TestOpenRemoteWorkspacePersistsLastWorkspace(t *testing.T) {
475 // Workbench path: OpenRemoteWorkspace no longer opens a Serve HTML window.
476 // Persistence of last workspace is still via saveLastRemoteWorkspace after a
477 // successful connect; unit-test the persistence helper directly.
478 home := t.TempDir()
479 t.Setenv("REASONIX_HOME", home)
480 t.Setenv("HOME", home)
481 a := &App{ctx: context.Background()}
482 a.saveLastRemoteWorkspace("box", "/home/dev/app")
483 got := a.RemoteLastWorkspace("box")
484 if got != "/home/dev/app" {
485 t.Fatalf("last workspace = %q, want /home/dev/app", got)
486 }
487 if _, err := os.Stat(filepath.Join(config.MemoryUserDir(), "desktop-remote.json")); err != nil {
488 t.Fatalf("desktop-remote.json not written: %v", err)
489 }
490 }
491
491 lines GO