1package app
2
3import (
4 "bufio"
5 "bytes"
6 "context"
7 "encoding/json"
8 "errors"
9 "io"
10 "log/slog"
11 "net"
12 "os"
13 "os/exec"
14 "path/filepath"
15 "strings"
16 "testing"
17 "time"
18
19 "github.com/bnema/vev/internal/adapters/lifecycle"
20 remoteadapter "github.com/bnema/vev/internal/adapters/remote"
21 "github.com/bnema/vev/internal/domain"
22 "github.com/bnema/vev/internal/persist"
23 "github.com/bnema/vev/internal/ports"
24 portsmocks "github.com/bnema/vev/internal/ports/mocks"
25 "github.com/bnema/vev/internal/usecase/client"
26 "github.com/bnema/vev/internal/usecase/daemon"
27 "github.com/bnema/vev/pkg/kv"
28 "github.com/bnema/vev/pkg/safedir"
29 "github.com/stretchr/testify/mock"
30 "github.com/stretchr/testify/require"
31)
32
33func decodeJSONLogs(t *testing.T, data []byte) []map[string]any {
34 t.Helper()
35 var entries []map[string]any
36 scanner := bufio.NewScanner(bytes.NewReader(data))
37 for scanner.Scan() {
38 var entry map[string]any
39 require.NoError(t, json.Unmarshal(scanner.Bytes(), &entry))
40 entries = append(entries, entry)
41 }
42 require.NoError(t, scanner.Err())
43 return entries
44}
45
46func eventNames(entries []map[string]any) []string {
47 names := make([]string, 0, len(entries))
48 for _, entry := range entries {
49 if name, ok := entry["msg"].(string); ok {
50 names = append(names, name)
51 }
52 }
53 return names
54}
55
56func requireEvent(t *testing.T, entries []map[string]any, name string) map[string]any {
57 t.Helper()
58 for _, entry := range entries {
59 if entry["msg"] == name {
60 return entry
61 }
62 }
63 t.Fatalf("event %q not found in %v", name, eventNames(entries))
64 return nil
65}
66
67func TestRecoveryObservability(t *testing.T) {
68 var logBuffer bytes.Buffer
69 log := slog.New(slog.NewJSONHandler(&logBuffer, nil))
70 owner := fakeLifecycleOwnership{release: func() error { return nil }}
71 deps := lifecycleStartupDeps{
72 ensurePrivate: func(string) error { return nil },
73 acquire: func(context.Context, string, time.Duration) (lifecycleOwnership, error) {
74 return owner, nil
75 },
76 log: log,
77 }
78 ref := &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}
79 records := []domain.CatalogueRecord{
80 {Committed: ref},
81 {Committed: ref},
82 {},
83 {Committed: ref, DegradedReason: "checkpoint validation failed"},
84 }
85 require.NoError(t, runWithLifecycleOwnerDeps(context.Background(), "/runtime/vev", "/state/vev", func(context.Context) error {
86 logCatalogueRecovery(log, records, "current")
87 logStartupRecoveryCounts(log, records, 0)
88 return nil
89 }, deps))
90
91 entries := decodeJSONLogs(t, logBuffer.Bytes())
92 for _, name := range []string{"lifecycle_owner_wait", "lifecycle_owner_acquired", "catalogue_validated", "daemon_startup_complete", "lifecycle_owner_released"} {
93 require.Contains(t, eventNames(entries), name)
94 }
95 startup := requireEvent(t, entries, "daemon_startup_complete")
96 require.EqualValues(t, 2, startup["healthy"])
97 require.EqualValues(t, 1, startup["fresh"])
98 require.EqualValues(t, 0, startup["restoring"])
99 require.EqualValues(t, 1, startup["broken"])
100 require.NotContains(t, eventNames(entries), "interrupted_transaction_recovery_complete")
101}
102
103// A session-scoped conflict no longer aborts startup, so the daemon comes up
104// looking healthy. Startup must therefore name the fenced session in the log,
105// persist a notice for it, and never report the recovery as clean.
106type fakeLifecycleOwnership struct {
107 release func() error
108}
109
110func (o fakeLifecycleOwnership) Release() error { return o.release() }
111
112type fakeLifecycleProbe struct {
113 owner lifecycleOwnership
114 err error
115}
116
117func (p fakeLifecycleProbe) TryAcquire(string) (lifecycleOwnership, error) {
118 return p.owner, p.err
119}
120
121func TestJoinLifecycleReleaseError(t *testing.T) {
122 operationErr := errors.New("operation failed")
123 releaseErr := errors.New("release failed")
124
125 tests := []struct {
126 name string
127 operationErr error
128 releaseErr error
129 wantErrors []error
130 }{
131 {
132 name: "successful operation preserves release failure",
133 releaseErr: releaseErr,
134 wantErrors: []error{releaseErr},
135 },
136 {
137 name: "operation and release failures are joined",
138 operationErr: operationErr,
139 releaseErr: releaseErr,
140 wantErrors: []error{operationErr, releaseErr},
141 },
142 }
143
144 for _, tt := range tests {
145 t.Run(tt.name, func(t *testing.T) {
146 got := tt.operationErr
147 joinLifecycleReleaseError(&got, fakeLifecycleOwnership{release: func() error {
148 return tt.releaseErr
149 }})
150
151 for _, wantErr := range tt.wantErrors {
152 require.ErrorIs(t, got, wantErr)
153 }
154 })
155 }
156}
157
158func TestOfflineCommandsPropagateLifecycleReleaseErrors(t *testing.T) {
159 releaseErr := errors.New("release failed")
160
161 tests := []struct {
162 name string
163 run func(context.Context) error
164 wantOperationErr string
165 }{
166 {
167 name: "list joins release failure to success",
168 run: func(ctx context.Context) error {
169 return runList(ctx, command{kind: kindList})
170 },
171 },
172 {
173 name: "kill joins release failure to operation failure",
174 run: func(ctx context.Context) error {
175 return runKill(ctx, "", false, false)
176 },
177 wantOperationErr: "vev: no daemon running",
178 },
179 }
180
181 originalProbe := daemonLifecycleProbe
182 t.Cleanup(func() { daemonLifecycleProbe = originalProbe })
183 for _, tt := range tests {
184 t.Run(tt.name, func(t *testing.T) {
185 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
186 t.Setenv("XDG_STATE_HOME", t.TempDir())
187 daemonLifecycleProbe = fakeLifecycleProbe{
188 owner: fakeLifecycleOwnership{release: func() error { return releaseErr }},
189 }
190
191 err := tt.run(context.Background())
192 require.ErrorIs(t, err, releaseErr)
193 if tt.wantOperationErr != "" {
194 require.ErrorContains(t, err, tt.wantOperationErr)
195 }
196 })
197 }
198}
199
200func TestCatalogueFailureDoesNotListen(t *testing.T) {
201 originalOpenCatalogue, originalListenDaemon := openCatalogue, listenDaemon
202 t.Cleanup(func() {
203 openCatalogue = originalOpenCatalogue
204 listenDaemon = originalListenDaemon
205 })
206
207 for _, catalogueErr := range []error{errors.New("catalogue corrupt"), errors.New("catalogue unavailable")} {
208 t.Run(catalogueErr.Error(), func(t *testing.T) {
209 runtimeRoot, stateRoot := t.TempDir(), t.TempDir()
210 runtimeDir := filepath.Join(runtimeRoot, "vev")
211 stateDir := filepath.Join(stateRoot, "vev")
212 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
213 t.Setenv("XDG_STATE_HOME", stateRoot)
214
215 listenCalls := 0
216 openCatalogue = func(gotStateDir string) (persist.OpenResult, error) {
217 require.Equal(t, stateDir, gotStateDir)
218 return persist.OpenResult{}, catalogueErr
219 }
220 listenDaemon = func(string, ports.SerializedRuntimeObserver) (ports.Listener, error) {
221 listenCalls++
222 return nil, errors.New("IPC listen must not run after catalogue failure")
223 }
224
225 err := runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, func(ctx context.Context) error {
226 return runDaemonOwnedWithLogger(ctx, slog.New(slog.NewTextHandler(io.Discard, nil)))
227 })
228 require.ErrorIs(t, err, catalogueErr)
229 require.Zero(t, listenCalls)
230 require.NoFileExists(t, filepath.Join(runtimeDir, "daemon.sock"))
231
232 owner, acquireErr := lifecycle.TryAcquire(runtimeDir)
233 require.NoError(t, acquireErr, "catalogue failure must release lifecycle ownership")
234 require.NoError(t, owner.Release())
235 })
236 }
237}
238
239func TestCatalogueRegistryConstructionPrecedesSocketPublication(t *testing.T) {
240 var events []string
241 _, _, err := constructDaemonBeforeSocketPublication(
242 func() *daemon.Daemon {
243 events = append(events, "catalogue-registry")
244 return nil
245 },
246 func(*daemon.Daemon) error {
247 require.Equal(t, []string{"catalogue-registry"}, events)
248 events = append(events, "startup-garbage-collection")
249 return nil
250 },
251 func() (ports.Listener, error) {
252 require.Equal(t, []string{"catalogue-registry", "startup-garbage-collection"}, events)
253 events = append(events, "socket-publication")
254 return nil, nil
255 },
256 )
257 require.NoError(t, err)
258 require.Equal(t, []string{"catalogue-registry", "startup-garbage-collection", "socket-publication"}, events)
259}
260
261func TestLifecycleOwnershipPrecedesDaemonStartup(t *testing.T) {
262 t.Run("ownership prefix is exact", func(t *testing.T) {
263 var events []string
264 owner := fakeLifecycleOwnership{release: func() error {
265 events = append(events, "unlock")
266 return nil
267 }}
268 deps := lifecycleStartupDeps{
269 ensurePrivate: func(path string) error {
270 if path == "runtime" {
271 events = append(events, "ensure-runtime")
272 } else {
273 events = append(events, "ensure-state")
274 }
275 return nil
276 },
277 acquire: func(context.Context, string, time.Duration) (lifecycleOwnership, error) {
278 events = append(events, "lock")
279 return owner, nil
280 },
281 }
282
283 err := runWithLifecycleOwnerDeps(context.Background(), "runtime", "state", func(context.Context) error {
284 events = append(events, "durable-open", "listen", "serve")
285 return nil
286 }, deps)
287 require.NoError(t, err)
288 require.Equal(t, []string{"ensure-runtime", "ensure-state", "lock"}, events[:3])
289 require.Equal(t, []string{"ensure-runtime", "ensure-state", "lock", "durable-open", "listen", "serve", "unlock"}, events)
290 })
291
292 t.Run("callback observes held lock and failure releases it", func(t *testing.T) {
293 runtimeDir := filepath.Join(t.TempDir(), "runtime")
294 stateDir := filepath.Join(t.TempDir(), "state")
295 startErr := errors.New("catalogue corrupt")
296
297 err := runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, func(context.Context) error {
298 _, lockErr := lifecycle.TryAcquire(runtimeDir)
299 require.ErrorIs(t, lockErr, lifecycle.ErrBusy)
300 return startErr
301 })
302 require.ErrorIs(t, err, startErr)
303
304 owner, err := lifecycle.TryAcquire(runtimeDir)
305 require.NoError(t, err, "callback failure must release lifecycle ownership")
306 require.NoError(t, owner.Release())
307 })
308
309 t.Run("busy and unavailable ownership fail before startup", func(t *testing.T) {
310 runtimeDir := filepath.Join(t.TempDir(), "runtime")
311 stateDir := filepath.Join(t.TempDir(), "state")
312 owner, err := lifecycle.TryAcquire(runtimeDir)
313 require.NoError(t, err)
314 defer func() { require.NoError(t, owner.Release()) }()
315
316 ctx, cancel := context.WithCancel(context.Background())
317 cancel()
318 started := false
319 err = runWithLifecycleOwner(ctx, runtimeDir, stateDir, func(context.Context) error {
320 started = true
321 return nil
322 })
323 require.ErrorIs(t, err, context.Canceled)
324 require.False(t, started)
325
326 badState := filepath.Join(t.TempDir(), "state-file")
327 require.NoError(t, os.WriteFile(badState, []byte("unavailable"), 0o600))
328 err = runWithLifecycleOwner(context.Background(), filepath.Join(t.TempDir(), "other-runtime"), badState, func(context.Context) error {
329 started = true
330 return nil
331 })
332 require.Error(t, err)
333 require.False(t, started)
334 })
335
336 t.Run("corrupt catalogue fails before socket publication", func(t *testing.T) {
337 runtimeRoot, stateRoot := t.TempDir(), t.TempDir()
338 runtimeDir := filepath.Join(runtimeRoot, "vev")
339 stateDir := filepath.Join(stateRoot, "vev")
340 require.NoError(t, safedir.EnsurePrivate(stateDir))
341 store, err := kv.Open(persist.StorePath(stateDir))
342 require.NoError(t, err)
343 require.NoError(t, store.Set([]byte("work"), []byte("malformed catalogue value")))
344 require.NoError(t, store.Close())
345
346 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
347 t.Setenv("XDG_STATE_HOME", stateRoot)
348 before, err := os.ReadFile(persist.StorePath(stateDir))
349 require.NoError(t, err)
350 err = runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, runDaemonOwned)
351 require.Error(t, err)
352 require.Contains(t, err.Error(), stateDir)
353 require.Contains(t, err.Error(), "rm -rf "+stateDir)
354 after, readErr := os.ReadFile(persist.StorePath(stateDir))
355 require.NoError(t, readErr)
356 require.Equal(t, before, after, "failed startup must leave the catalogue untouched")
357 _, statErr := os.Stat(filepath.Join(runtimeDir, "daemon.sock"))
358 require.ErrorIs(t, statErr, os.ErrNotExist)
359 })
360
361 t.Run("proven absence creates the catalogue before socket publication", func(t *testing.T) {
362 runtimeRoot, err := os.MkdirTemp("/tmp", "vev-")
363 require.NoError(t, err)
364 t.Cleanup(func() { require.NoError(t, os.RemoveAll(runtimeRoot)) })
365 stateRoot := t.TempDir()
366 runtimeDir := filepath.Join(runtimeRoot, "vev")
367 stateDir := filepath.Join(stateRoot, "vev")
368 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
369 t.Setenv("XDG_STATE_HOME", stateRoot)
370 ctx, cancel := context.WithCancel(t.Context())
371 result := make(chan error, 1)
372 go func() {
373 result <- runWithLifecycleOwner(ctx, runtimeDir, stateDir, runDaemonOwned)
374 }()
375 t.Cleanup(func() {
376 cancel()
377 select {
378 case err := <-result:
379 require.NoError(t, err)
380 case <-time.After(5 * time.Second):
381 t.Error("daemon lifecycle did not stop after cancellation")
382 }
383 })
384
385 require.Eventually(t, func() bool {
386 _, err := os.Stat(filepath.Join(runtimeDir, "daemon.sock"))
387 return err == nil
388 }, 5*time.Second, time.Millisecond, "daemon socket was not published")
389 require.FileExists(t, persist.StorePath(stateDir))
390 })
391
392 t.Run("callback and release errors are joined", func(t *testing.T) {
393 startErr := errors.New("catalogue unavailable")
394 releaseErr := errors.New("unlock failed")
395 deps := lifecycleStartupDeps{
396 ensurePrivate: func(string) error { return nil },
397 acquire: func(context.Context, string, time.Duration) (lifecycleOwnership, error) {
398 return fakeLifecycleOwnership{release: func() error { return releaseErr }}, nil
399 },
400 }
401
402 err := runWithLifecycleOwnerDeps(context.Background(), "runtime", "state", func(context.Context) error {
403 return startErr
404 }, deps)
405 require.ErrorIs(t, err, startErr)
406 require.ErrorIs(t, err, releaseErr)
407 })
408}
409
410// scriptRecv makes a transport yield frames in order, then wait for done
411// before returning EOF. The shared done channel coordinates proxy readers
412// without relying on scheduling or sleeps.
413func TestRunUDPProxyUsesBoundedClientMaxPending(t *testing.T) {
414 require.Equal(t, 32, udpProxyClientTransportOptions.MaxPending)
415}
416
417func TestRunUDPProxyClientDeadAfterExceedsIdleTTL(t *testing.T) {
418 require.Positive(t, udpProxyIdleTTL)
419 require.Greater(t, udpProxyClientTransportOptions.DeadAfter, udpProxyIdleTTL)
420}
421
422func TestParseArgs(t *testing.T) {
423 tests := []struct {
424 name string
425 args []string
426 wantKind cmdKind
427 wantIntent uint8
428 wantName string
429 wantRemote string
430 wantListHost string
431 wantListAll bool
432 wantHostAct string
433 wantHostTgt string
434 wantAll bool
435 wantDaemon bool
436 wantErr bool
437 nonUsageErr bool
438 }{
439 {name: "no args -> ephemeral attach", args: nil, wantKind: kindAttach, wantIntent: ports.IntentEphemeral},
440 {name: "empty slice -> ephemeral attach", args: []string{}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral},
441 {name: "new named", args: []string{"new", "work"}, wantKind: kindAttach, wantIntent: ports.IntentNew, wantName: "work"},
442 {name: "new without name", args: []string{"new"}, wantErr: true},
443 {name: "new empty name", args: []string{"new", ""}, wantErr: true},
444 {name: "new command override unsupported", args: []string{"new", "work", "--", "sh"}, wantErr: true},
445 {name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},
446 {name: "attach preserves legacy unsafe name", args: []string{"attach", "my work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "my work"},
447 {name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},
448 {name: "attach remote host uses ephemeral", args: []string{"attach", "user@example.com"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},
449 {name: "attach remote host with empty session uses ephemeral", args: []string{"attach", "user@example.com:"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},
450 {name: "attach remote host with session", args: []string{"attach", "user@example.com:work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work", wantRemote: "user@example.com"},
451 {name: "attach remote ipv4 with session", args: []string{"attach", "user@192.0.2.10:work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work", wantRemote: "user@192.0.2.10"},
452 {name: "attach remote ipv6 with session", args: []string{"attach", "user@[2001:db8::1]:work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work", wantRemote: "user@[2001:db8::1]"},
453 {name: "attach remote invalid session", args: []string{"attach", "user@example.com:my work"}, wantErr: true, nonUsageErr: true},
454 {name: "attach extra arg", args: []string{"attach", "work", "extra"}, wantErr: true},
455 {name: "attach without name", args: []string{"attach"}, wantErr: true},
456 {name: "ls", args: []string{"ls"}, wantKind: kindList},
457 {name: "list", args: []string{"list"}, wantKind: kindList},
458 {name: "ls host", args: []string{"ls", "arch"}, wantKind: kindList, wantListHost: "arch"},
459 {name: "list host", args: []string{"list", "build@mule"}, wantKind: kindList, wantListHost: "build@mule"},
460 {name: "ls --all", args: []string{"ls", "--all"}, wantKind: kindList, wantListAll: true},
461 {name: "list --all", args: []string{"list", "--all"}, wantKind: kindList, wantListAll: true},
462 {name: "ls host extra", args: []string{"ls", "arch", "extra"}, wantErr: true},
463 {name: "ls --all host", args: []string{"ls", "--all", "arch"}, wantErr: true},
464 {name: "ls invalid host", args: []string{"ls", "bad host"}, wantErr: true, nonUsageErr: true},
465 {name: "host add", args: []string{"host", "add", "arch"}, wantKind: kindHost, wantHostAct: "add", wantHostTgt: "arch"},
466 {name: "host rm", args: []string{"host", "rm", "build@mule"}, wantKind: kindHost, wantHostAct: "rm", wantHostTgt: "build@mule"},
467 {name: "host list", args: []string{"host", "list"}, wantKind: kindHost, wantHostAct: "list"},
468 {name: "host alone", args: []string{"host"}, wantErr: true},
469 {name: "host add missing target", args: []string{"host", "add"}, wantErr: true},
470 {name: "host add extra", args: []string{"host", "add", "arch", "extra"}, wantErr: true},
471 {name: "host add invalid", args: []string{"host", "add", " bad"}, wantErr: true, nonUsageErr: true},
472 {name: "host unknown action", args: []string{"host", "rename", "arch"}, wantErr: true},
473 {name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"},
474 {name: "kill preserves legacy unsafe name via terminator", args: []string{"kill", "--", "my work"}, wantKind: kindKill, wantName: "my work"},
475 {name: "kill dashed name via terminator", args: []string{"kill", "--", "--all"}, wantKind: kindKill, wantName: "--all"},
476 {name: "kill all", args: []string{"kill", "--all"}, wantKind: kindKill, wantAll: true},
477 {name: "kill daemon", args: []string{"kill", "--daemon"}, wantKind: kindKill, wantDaemon: true},
478 {name: "kill without name", args: []string{"kill"}, wantErr: true},
479 {name: "kill terminator without name", args: []string{"kill", "--"}, wantErr: true},
480 {name: "kill all rejects extra arg", args: []string{"kill", "--all", "extra"}, wantErr: true},
481 {name: "kill daemon rejects extra arg", args: []string{"kill", "--daemon", "extra"}, wantErr: true},
482 {name: "kill extra arg", args: []string{"kill", "work", "extra"}, wantErr: true},
483 {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon},
484 {name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio},
485 {name: "stdio rejects session", args: []string{"_stdio", "work"}, wantErr: true},
486 {name: "stdio rejects extra args", args: []string{"_stdio", "work", "extra"}, wantErr: true},
487 {name: "udp bootstrap", args: []string{"_udp-bootstrap"}, wantKind: kindUDPBootstrap},
488 {name: "udp bootstrap rejects session", args: []string{"_udp-bootstrap", "work"}, wantErr: true},
489 {name: "udp proxy", args: []string{"_udp-proxy"}, wantKind: kindUDPProxy},
490 {name: "udp proxy rejects session", args: []string{"_udp-proxy", "work"}, wantErr: true},
491 {name: "udp proxy rejects extra args", args: []string{"_udp-proxy", "work", "extra"}, wantErr: true},
492 {name: "help", args: []string{"--help"}, wantKind: kindHelp},
493 {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp},
494 {name: "version", args: []string{"--version"}, wantKind: kindVersion},
495 {name: "unknown", args: []string{"frobnicate"}, wantErr: true},
496 }
497
498 for _, tt := range tests {
499 t.Run(tt.name, func(t *testing.T) {
500 got, err := parseArgs(tt.args)
501 if tt.wantErr {
502 if err == nil {
503 t.Fatalf("parseArgs(%q) = %+v, want error", tt.args, got)
504 }
505 if tt.nonUsageErr {
506 _, usage := errors.AsType[*usageError](err)
507 if usage {
508 t.Fatalf("parseArgs(%q) error = *usageError, want non-usage error", tt.args)
509 }
510 } else {
511 _, usage := errors.AsType[*usageError](err)
512 if !usage {
513 t.Fatalf("parseArgs(%q) error = %T, want *usageError", tt.args, err)
514 }
515 }
516 return
517 }
518 if err != nil {
519 t.Fatalf("parseArgs(%q) unexpected error: %v", tt.args, err)
520 }
521 if got.kind != tt.wantKind {
522 t.Errorf("kind = %v, want %v", got.kind, tt.wantKind)
523 }
524 if got.kind == kindAttach && got.intent != tt.wantIntent {
525 t.Errorf("intent = %d, want %d", got.intent, tt.wantIntent)
526 }
527 if got.name != tt.wantName {
528 t.Errorf("name = %q, want %q", got.name, tt.wantName)
529 }
530 if got.remoteTarget != tt.wantRemote {
531 t.Errorf("remoteTarget = %q, want %q", got.remoteTarget, tt.wantRemote)
532 }
533 if got.listHost != tt.wantListHost {
534 t.Errorf("listHost = %q, want %q", got.listHost, tt.wantListHost)
535 }
536 if got.listAll != tt.wantListAll {
537 t.Errorf("listAll = %v, want %v", got.listAll, tt.wantListAll)
538 }
539 if got.hostAction != tt.wantHostAct {
540 t.Errorf("hostAction = %q, want %q", got.hostAction, tt.wantHostAct)
541 }
542 if got.hostTarget != tt.wantHostTgt {
543 t.Errorf("hostTarget = %q, want %q", got.hostTarget, tt.wantHostTgt)
544 }
545 if got.killAll != tt.wantAll {
546 t.Errorf("killAll = %v, want %v", got.killAll, tt.wantAll)
547 }
548 if got.killDaemon != tt.wantDaemon {
549 t.Errorf("killDaemon = %v, want %v", got.killDaemon, tt.wantDaemon)
550 }
551 })
552 }
553}
554
555func TestParseArgsNewRejectsUnsafeSessionName(t *testing.T) {
556 _, err := parseArgs([]string{"new", "my work"})
557 if !errors.Is(err, domain.ErrInvalidSessionName) {
558 t.Fatalf("parseArgs new unsafe error = %v, want %v", err, domain.ErrInvalidSessionName)
559 }
560}
561
562func TestAttachTargetsCreateCommonSessionRequest(t *testing.T) {
563 tests := []struct {
564 name string
565 arg string
566 want client.AttachRequest
567 }{
568 {name: "local host", arg: "work", want: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}},
569 {name: "remote host", arg: "user@example.com:work", want: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}},
570 {name: "remote ipv4", arg: "user@192.0.2.10:work", want: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}},
571 {name: "remote ipv6", arg: "user@[2001:db8::1]:work", want: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}},
572 }
573 for _, tt := range tests {
574 t.Run(tt.name, func(t *testing.T) {
575 cmd, err := parseArgs([]string{"attach", tt.arg})
576 require.NoError(t, err)
577 connection, err := client.NewSessionConnection(portsmocks.NewMockTransport(t), client.SessionTarget{
578 Intent: cmd.intent,
579 SessionName: cmd.name,
580 })
581 require.NoError(t, err)
582 require.Equal(t, tt.want, connection.AttachRequest())
583 })
584 }
585}
586
587func TestListShowsBroken(t *testing.T) {
588 var out bytes.Buffer
589 printSessions(&out, []ports.SessionInfo{
590 {Name: "fresh", State: ports.SessionDown},
591 {Name: "loading", State: ports.SessionDown},
592 {Name: "broken", State: ports.SessionBroken},
593 })
594 require.Contains(t, out.String(), "fresh")
595 require.Contains(t, out.String(), "down")
596 require.Contains(t, out.String(), "loading")
597 require.Contains(t, out.String(), "broken")
598 require.NotContains(t, out.String(), "restoring")
599 require.NotContains(t, out.String(), "degraded")
600}
601
602func TestPrintSessionsShowsDownState(t *testing.T) {
603 var out bytes.Buffer
604 printSessions(&out, []ports.SessionInfo{
605 {Name: "main", State: ports.SessionUp, Tabs: 2, Attached: true},
606 {Name: "old", State: ports.SessionDown},
607 })
608 got := out.String()
609 for _, want := range []string{"NAME", "STATE", "main", "up", "2", "yes", "old", "down", "-"} {
610 if !strings.Contains(got, want) {
611 t.Fatalf("printSessions output %q missing %q", got, want)
612 }
613 }
614}
615
616func TestPrintSessionsMarksEphemeral(t *testing.T) {
617 var buf bytes.Buffer
618 printSessions(&buf, []ports.SessionInfo{
619 {Name: "0", State: ports.SessionUp, Ephemeral: true, Tabs: 1, Attached: false},
620 {Name: "work", State: ports.SessionUp, Tabs: 2, Attached: true},
621 {Name: "old", State: ports.SessionDown},
622 })
623 out := buf.String()
624 for _, want := range []string{"0", "temporary", "work", "up", "old", "down"} {
625 require.Contains(t, out, want)
626 }
627}
628
629func newTestPersister(t *testing.T, stateDir string) *persist.Persister {
630 t.Helper()
631 store, err := persist.OpenStore(persist.StorePath(stateDir))
632 require.NoError(t, err)
633 return persist.New(store)
634}
635
636func TestRunListReadsDownSessionsWithoutDaemon(t *testing.T) {
637 stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
638 t.Setenv("XDG_STATE_HOME", stateRoot)
639 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
640
641 p := newTestPersister(t, filepath.Join(stateRoot, "vev"))
642 now := time.Now().UnixNano()
643 if err := p.Save(persist.Record{Name: "stored", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil {
644 t.Fatalf("Save error = %v", err)
645 }
646 if err := p.Close(); err != nil {
647 t.Fatalf("Close error = %v", err)
648 }
649
650 got := captureStdout(t, func() {
651 if err := runList(context.Background(), command{kind: kindList}); err != nil {
652 t.Fatalf("runList error = %v", err)
653 }
654 })
655 for _, want := range []string{"stored", "down", "-"} {
656 if !strings.Contains(got, want) {
657 t.Fatalf("runList output %q missing %q", got, want)
658 }
659 }
660}
661
662func TestRunListUnreadableCatalogueProvidesResetGuidance(t *testing.T) {
663 stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
664 stateDir := filepath.Join(stateRoot, "vev")
665 runtimeDir := filepath.Join(runtimeRoot, "vev")
666 t.Setenv("XDG_STATE_HOME", stateRoot)
667 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
668 require.NoError(t, safedir.EnsurePrivate(stateDir))
669
670 catalogue := persist.StorePath(stateDir)
671 before := []byte("corrupt catalogue")
672 require.NoError(t, os.WriteFile(catalogue, before, 0o600))
673
674 var runErr error
675 stdout := captureStdout(t, func() { runErr = Run([]string{"ls"}) })
676 require.Empty(t, stdout)
677 require.Error(t, runErr)
678 require.ErrorContains(t, runErr, stateDir)
679 require.ErrorContains(t, runErr, "rm -rf "+stateDir)
680 require.NoFileExists(t, filepath.Join(runtimeDir, "daemon.sock"))
681 after, err := os.ReadFile(catalogue)
682 require.NoError(t, err)
683 require.Equal(t, before, after)
684}
685
686func TestRunKillMissingStoppedSessionDoesNotCreateStore(t *testing.T) {
687 stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
688 t.Setenv("XDG_STATE_HOME", stateRoot)
689 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
690
691 err := runKill(context.Background(), "missing", false, false)
692 if err == nil || !strings.Contains(err.Error(), "no such session: missing") {
693 t.Fatalf("runKill error = %v, want no such session", err)
694 }
695 if _, statErr := os.Stat(filepath.Join(stateRoot, "vev", "sessions.kv")); !errors.Is(statErr, os.ErrNotExist) {
696 t.Fatalf("sessions.kv stat error = %v, want not exist", statErr)
697 }
698}
699
700func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) {
701 stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
702 t.Setenv("XDG_STATE_HOME", stateRoot)
703 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
704
705 p := newTestPersister(t, filepath.Join(stateRoot, "vev"))
706 now := time.Now().UnixNano()
707 if err := p.Save(persist.Record{Name: "stored", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil {
708 t.Fatalf("Save error = %v", err)
709 }
710 if err := p.Close(); err != nil {
711 t.Fatalf("Close error = %v", err)
712 }
713 got := captureStdout(t, func() {
714 if err := runKill(context.Background(), "stored", false, false); err != nil {
715 t.Fatalf("runKill error = %v", err)
716 }
717 })
718 if !strings.Contains(got, "killed stored") {
719 t.Fatalf("runKill output = %q, want success", got)
720 }
721 records, err := persist.LoadReadOnly(filepath.Join(stateRoot, "vev"))
722 if err != nil {
723 t.Fatalf("LoadReadOnly error = %v", err)
724 }
725 if len(records) != 0 {
726 t.Fatalf("records after kill = %#v, want none", records)
727 }
728}
729
730func captureStdout(t *testing.T, fn func()) string {
731 t.Helper()
732 old := os.Stdout
733 r, w, err := os.Pipe()
734 if err != nil {
735 t.Fatalf("os.Pipe error = %v", err)
736 }
737 os.Stdout = w
738 defer func() { os.Stdout = old }()
739 defer func() { _ = r.Close() }()
740 outCh := make(chan []byte, 1)
741 errCh := make(chan error, 1)
742 go func() {
743 out, err := io.ReadAll(r)
744 outCh <- out
745 errCh <- err
746 }()
747
748 fn()
749 _ = w.Close()
750 out := <-outCh
751 if err := <-errCh; err != nil {
752 t.Fatalf("ReadAll stdout error = %v", err)
753 }
754 return string(out)
755}
756
757func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) {
758 called := false
759 err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "outer", nil, runAttachDeps{
760 createDetached: func(context.Context, string) error {
761 called = true
762 return nil
763 },
764 })
765 if err == nil {
766 t.Fatal("runAttach with VEV set returned nil error")
767 }
768 if called {
769 t.Fatal("runAttach dialed while nested attach should be rejected")
770 }
771 if !strings.Contains(err.Error(), "sessions should be nested with care") {
772 t.Fatalf("runAttach error = %q, want nested-session warning", err)
773 }
774}
775
776func TestRunAttachNestedNewCreatesDetachedSession(t *testing.T) {
777 var gotName string
778 err := runAttachWithDeps(context.Background(), ports.IntentNew, "scratch", "", "outer", nil, runAttachDeps{
779 createDetached: func(_ context.Context, name string) error {
780 gotName = name
781 return nil
782 },
783 })
784 if err != nil {
785 t.Fatalf("runAttachWithDeps returned error: %v", err)
786 }
787 if gotName != "scratch" {
788 t.Fatalf("detached name = %q, want scratch", gotName)
789 }
790}
791
792func TestDetachedLocalHelloIncludesTrueColor(t *testing.T) {
793 t.Setenv("TERM", "xterm-direct")
794 t.Setenv("COLORTERM", "")
795
796 hello := detachedLocalHello("scratch", "/tmp/work")
797
798 require.Equal(t, ports.IntentNew, hello.Intent)
799 require.Equal(t, "scratch", hello.Name)
800 require.Equal(t, "xterm-direct", hello.TermEnv)
801 require.Equal(t, "/tmp/work", hello.Cwd)
802 require.True(t, hello.TrueColor)
803 require.Equal(t, os.Environ(), hello.Env)
804}
805
806type namedDialer struct{ name string }
807
808func (d namedDialer) Dial(context.Context) (ports.Transport, error) {
809 return nil, errors.New("not used")
810}
811
812// fakeClipboardReader is a distinguishable ports.ClipboardReader used only to
813// verify identity (that runAttachWithDeps threads the *same* reader through),
814// never actually invoked in these wiring tests.
815type fakeClipboardReader struct{ ports.ClipboardReader }
816
817func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
818 tests := []struct {
819 name string
820 selectedTransport string
821 wantMode ports.RemoteTransportMode
822 }{
823 {name: "default remote mode is udp", selectedTransport: "", wantMode: ports.RemoteTransportUDP},
824 {name: "explicit stdio mode", selectedTransport: "stdio", wantMode: ports.RemoteTransportStdio},
825 }
826
827 for _, tt := range tests {
828 t.Run(tt.name, func(t *testing.T) {
829 var gotDialer string
830 var gotRemote bool
831 var gotClipboard ports.ClipboardReader
832 clip := &fakeClipboardReader{}
833 factory := portsmocks.NewMockRemoteDialerFactory(t)
834 factory.EXPECT().DialerForRemote("remote.example", "work", tt.wantMode, mock.Anything).Return(namedDialer{name: "remote"}, nil)
835
836 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
837 remoteDialerFactory: factory,
838 selectedRemoteTransport: tt.selectedTransport,
839 clipboard: clip,
840 runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
841 nd, ok := deps.Dialer.(namedDialer)
842 if !ok {
843 t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer)
844 }
845 gotDialer = nd.name
846 gotRemote = deps.Remote
847 if !request.Remote || request.EnvironmentPolicy != ports.EnvironmentPolicyClientOwned {
848 t.Fatal("direct CLI remote attach must preserve client-owned environment policy")
849 }
850 gotClipboard = deps.Clipboard
851 if request.Intent != ports.IntentAttach || request.SessionName != "work" {
852 t.Fatalf("intent/name = %d/%q, want attach/work", request.Intent, request.SessionName)
853 }
854 return nil
855 },
856 })
857 if err != nil {
858 t.Fatalf("runAttachWithDeps returned error: %v", err)
859 }
860 if gotDialer != "remote" {
861 t.Fatalf("remote dialer = %q, want remote", gotDialer)
862 }
863 if !gotRemote {
864 t.Fatal("remote attach must pass remote=true to runClient")
865 }
866 if gotClipboard != ports.ClipboardReader(clip) {
867 t.Fatalf("remote attach must thread the configured ClipboardReader through, got %#v", gotClipboard)
868 }
869 })
870 }
871}
872
873func TestRunAttachWithDepsDirectLegacyCallbackPreservesClientOwnership(t *testing.T) {
874 factory := portsmocks.NewMockRemoteDialerFactory(t)
875 factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-1"}, nil).Once()
876 factory.EXPECT().DialerForRemote("selected.example", "picked", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-2"}, nil).Once()
877
878 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
879 remoteDialerFactory: factory,
880 runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
881 require.True(t, request.Remote)
882 require.Equal(t, ports.EnvironmentPolicyClientOwned, request.EnvironmentPolicy)
883 nextDialer, nextRequest, err := deps.AttachHandoff(ports.AttachTarget{
884 Endpoint: "selected.example", Session: "picked", Intent: ports.IntentAttach,
885 EnvironmentPolicy: ports.EnvironmentPolicyClientOwned,
886 })
887 require.NoError(t, err)
888 require.NotNil(t, nextDialer)
889 require.Equal(t, ports.EnvironmentPolicyClientOwned, nextRequest.EnvironmentPolicy)
890 return nil
891 },
892 })
893 require.NoError(t, err)
894}
895
896func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing.T) {
897 factory := portsmocks.NewMockRemoteDialerFactory(t)
898 factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-1"}, nil).Once()
899 factory.EXPECT().DialerForRemote("selected.example", "picked", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-2"}, nil).Once()
900 var calls int
901 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
902 remoteDialerFactory: factory,
903 runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
904 calls++
905 require.NotNil(t, deps.Dialer)
906 require.True(t, request.Remote)
907 require.Equal(t, ports.EnvironmentPolicyClientOwned, request.EnvironmentPolicy, "direct CLI remote ownership must survive a handoff")
908 if calls == 1 {
909 return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "selected.example", Session: "picked", Intent: ports.IntentAttach}}
910 }
911 return nil
912 },
913 })
914 require.NoError(t, err)
915 require.Equal(t, 2, calls, "a picker handoff must close the old connection and open a fresh direct connection")
916}
917
918func TestRunAttachWithDepsAllowsRevisitingRemoteHandoff(t *testing.T) {
919 factory := portsmocks.NewMockRemoteDialerFactory(t)
920 factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
921 factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-revisit"}, nil).Once()
922
923 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
924 remoteDialerFactory: factory,
925 runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {
926 dialer, request, err := deps.AttachHandoff(ports.AttachTarget{
927 Endpoint: "remote.example", Session: "work", Intent: ports.IntentAttach,
928 })
929 require.NoError(t, err)
930 require.NotNil(t, dialer)
931 require.Equal(t, "work", request.SessionName)
932 return nil
933 },
934 })
935 require.NoError(t, err)
936}
937
938func TestRunAttachWithDepsBoundsRepeatedAttachTargetHandoffs(t *testing.T) {
939 factory := portsmocks.NewMockRemoteDialerFactory(t)
940 factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Times(maxAttachTargetHandoffs + 1)
941
942 calls := 0
943 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
944 remoteDialerFactory: factory,
945 runClient: func(_ context.Context, _ client.Dependencies, _ client.AttachRequest) error {
946 calls++
947 return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "remote.example", Session: "work", Intent: ports.IntentAttach}}
948 },
949 })
950 require.ErrorContains(t, err, "attach handoff exceeded maximum")
951 require.Equal(t, maxAttachTargetHandoffs+1, calls)
952}
953
954func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T) {
955 factory := portsmocks.NewMockRemoteDialerFactory(t)
956 factory.EXPECT().DialerForRemote("selected.example", "picked", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
957
958 localCalls, remoteCalls := 0, 0
959 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "", "", nil, runAttachDeps{
960 localDialer: func() ports.Dialer { return namedDialer{name: "local"} },
961 remoteDialerFactory: factory,
962 runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
963 if deps.Remote {
964 remoteCalls++
965 require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "picked", Remote: true, Origin: ports.RouteOriginDiscovery, OriginKey: "selected.example", RemoteOrigin: "selected.example", EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, request)
966 return nil
967 }
968 localCalls++
969 require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work", Origin: ports.RouteOriginLocal, OriginKey: "local"}, request)
970 return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "selected.example", Session: "picked", Intent: ports.IntentAttach}}
971 },
972 })
973 require.NoError(t, err)
974 require.Equal(t, 1, localCalls, "the failed local picker attach must not retry locally")
975 require.Equal(t, 1, remoteCalls, "the selected target must be dialed and attached exactly once")
976}
977
978func TestRunAttachWithDepsRejectsInvalidHandoffBeforeDialing(t *testing.T) {
979 tests := []struct {
980 name string
981 target ports.AttachTarget
982 }{
983 {name: "invalid host", target: ports.AttachTarget{Endpoint: "remote host", Session: "work", Intent: ports.IntentAttach}},
984 {name: "invalid session", target: ports.AttachTarget{Endpoint: "remote.example", Session: "bad name", Intent: ports.IntentAttach}},
985 {name: "tokenless resume", target: ports.AttachTarget{Endpoint: "remote.example", Session: "work", Intent: ports.IntentResume}},
986 {
987 name: "tokenless resume with remote target",
988 target: ports.AttachTarget{
989 Endpoint: "remote.example", Session: "work", Intent: ports.IntentResume,
990 RemoteTarget: &domain.RemoteSessionTarget{
991 Endpoint: "remote.example", DisplayOrigin: "remote.example",
992 LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work", LiveTabID: "tab-1",
993 },
994 EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
995 },
996 },
997 }
998 for _, tt := range tests {
999 t.Run(tt.name, func(t *testing.T) {
1000 factory := portsmocks.NewMockRemoteDialerFactory(t)
1001 factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
1002 calls := 0
1003 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
1004 remoteDialerFactory: factory,
1005 runClient: func(_ context.Context, _ client.Dependencies, _ client.AttachRequest) error {
1006 calls++
1007 return &client.AttachTargetError{Target: tt.target}
1008 },
1009 })
1010 require.ErrorContains(t, err, "invalid remote attach handoff")
1011 require.Equal(t, 1, calls)
1012 })
1013 }
1014}
1015
1016func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing.T) {
1017 factory := portsmocks.NewMockRemoteDialerFactory(t)
1018 runClientCalled := false
1019
1020 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
1021 remoteDialerFactory: factory,
1022 selectedRemoteTransport: "serial",
1023 runClient: func(context.Context, client.Dependencies, client.AttachRequest) error {
1024 runClientCalled = true
1025 return nil
1026 },
1027 })
1028 if err == nil || err.Error() != `vev: invalid remote transport "serial" (want "udp" or "stdio")` {
1029 t.Fatalf("runAttachWithDeps error = %v, want invalid transport", err)
1030 }
1031 if runClientCalled {
1032 t.Fatal("runClient called after invalid remote transport")
1033 }
1034}
1035
1036func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) {
1037 factoryErr := errors.New("factory failed")
1038 factory := portsmocks.NewMockRemoteDialerFactory(t)
1039 factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(nil, factoryErr)
1040 runClientCalled := false
1041
1042 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
1043 remoteDialerFactory: factory,
1044 runClient: func(context.Context, client.Dependencies, client.AttachRequest) error {
1045 runClientCalled = true
1046 return nil
1047 },
1048 })
1049 if !errors.Is(err, factoryErr) {
1050 t.Fatalf("runAttachWithDeps error = %v, want %v", err, factoryErr)
1051 }
1052 if runClientCalled {
1053 t.Fatal("runClient called after remote factory error")
1054 }
1055}
1056
1057func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
1058 var gotDialer string
1059 gotRemote := true
1060 gotClipboard := ports.ClipboardReader(&fakeClipboardReader{})
1061 factory := portsmocks.NewMockRemoteDialerFactory(t)
1062 err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", nil, runAttachDeps{
1063 localDialer: func() ports.Dialer { return namedDialer{name: "local"} },
1064 remoteDialerFactory: factory,
1065 clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach
1066 runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
1067 nd, ok := deps.Dialer.(namedDialer)
1068 if !ok {
1069 t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer)
1070 }
1071 gotDialer = nd.name
1072 gotRemote = deps.Remote
1073 if request.Remote {
1074 t.Fatal("local carriage metadata must not be in the attach request")
1075 }
1076 gotClipboard = deps.Clipboard
1077 if request.Intent != ports.IntentEphemeral || request.SessionName != "" {
1078 t.Fatalf("intent/name = %d/%q, want ephemeral/empty", request.Intent, request.SessionName)
1079 }
1080 return nil
1081 },
1082 })
1083 if err != nil {
1084 t.Fatalf("runAttachWithDeps returned error: %v", err)
1085 }
1086 if gotDialer != "local" {
1087 t.Fatalf("local dialer = %q, want local", gotDialer)
1088 }
1089 if gotRemote {
1090 t.Fatal("local attach must pass remote=false to runClient")
1091 }
1092 if gotClipboard != nil {
1093 t.Fatalf("local attach must not thread a ClipboardReader through, got %#v", gotClipboard)
1094 }
1095}
1096
1097func TestRunUDPBootstrapForwardsReadinessAndExits(t *testing.T) {
1098 oldTimeout := udpBootstrapTimeout
1099 udpBootstrapTimeout = time.Second
1100 t.Cleanup(func() { udpBootstrapTimeout = oldTimeout })
1101 oldCommand := udpProxyCommand
1102 var gotCmd *exec.Cmd
1103 udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd {
1104 cmd := exec.Command("/bin/sh", "-c", "printf 'VEV-UDP 4242 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\\n'")
1105 gotCmd = cmd
1106 return cmd
1107 }
1108 t.Cleanup(func() { udpProxyCommand = oldCommand })
1109
1110 got := captureStdout(t, func() {
1111 if err := runUDPBootstrap(context.Background()); err != nil {
1112 t.Fatalf("runUDPBootstrap() error = %v", err)
1113 }
1114 })
1115 if got != "VEV-UDP 4242 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n" {
1116 t.Fatalf("readiness = %q", got)
1117 }
1118 if gotCmd.Stdin == nil || gotCmd.Stderr == nil {
1119 t.Fatalf("proxy stdio = stdin %T stderr %T, want detached from SSH channels", gotCmd.Stdin, gotCmd.Stderr)
1120 }
1121 if gotCmd.Stdout == os.Stdout || gotCmd.Stderr == os.Stderr || gotCmd.Stdin == os.Stdin {
1122 t.Fatalf("proxy inherited SSH stdio handles")
1123 }
1124}
1125
1126func TestRunUDPBootstrapReturnsReadinessEOF(t *testing.T) {
1127 oldTimeout := udpBootstrapTimeout
1128 udpBootstrapTimeout = time.Second
1129 t.Cleanup(func() { udpBootstrapTimeout = oldTimeout })
1130 oldCommand := udpProxyCommand
1131 udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd {
1132 return exec.Command("/bin/sh", "-c", "exit 7")
1133 }
1134 t.Cleanup(func() { udpProxyCommand = oldCommand })
1135
1136 err := runUDPBootstrap(context.Background())
1137 if !errors.Is(err, io.EOF) {
1138 t.Fatalf("runUDPBootstrap() error = %v, want EOF", err)
1139 }
1140}
1141
1142func TestRunUDPBootstrapTimesOutWaitingForReadiness(t *testing.T) {
1143 oldTimeout := udpBootstrapTimeout
1144 udpBootstrapTimeout = 10 * time.Millisecond
1145 t.Cleanup(func() { udpBootstrapTimeout = oldTimeout })
1146 oldCommand := udpProxyCommand
1147 udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd {
1148 return exec.Command("/bin/sh", "-c", "sleep 5")
1149 }
1150 t.Cleanup(func() { udpProxyCommand = oldCommand })
1151
1152 err := runUDPBootstrap(context.Background())
1153 if !errors.Is(err, context.DeadlineExceeded) {
1154 t.Fatalf("runUDPBootstrap() error = %v, want deadline exceeded", err)
1155 }
1156}
1157
1158func TestParseUDPPortRange(t *testing.T) {
1159 tests := []struct {
1160 name string
1161 value string
1162 want udpPortRange
1163 wantError bool
1164 }{
1165 {name: "empty uses default", value: "", want: udpPortRange{start: defaultUDPPortStart, end: defaultUDPPortEnd}},
1166 {name: "zero is ephemeral", value: "0", want: udpPortRange{start: 0, end: 0}},
1167 {name: "zero range rejected", value: "0-100", wantError: true},
1168 {name: "single port", value: "61000", want: udpPortRange{start: 61000, end: 61000}},
1169 {name: "range", value: "61000-61023", want: udpPortRange{start: 61000, end: 61023}},
1170 {name: "range with spaces", value: " 61000 - 61023 ", want: udpPortRange{start: 61000, end: 61023}},
1171 {name: "reversed range", value: "61023-61000", wantError: true},
1172 {name: "non-numeric", value: "abc", wantError: true},
1173 {name: "missing end", value: "61000-", wantError: true},
1174 {name: "port too high", value: "70000", wantError: true},
1175 }
1176 for _, tt := range tests {
1177 t.Run(tt.name, func(t *testing.T) {
1178 got, err := parseUDPPortRange(tt.value)
1179 if tt.wantError {
1180 if err == nil {
1181 t.Fatalf("parseUDPPortRange(%q) error = nil, want error", tt.value)
1182 }
1183 return
1184 }
1185 if err != nil {
1186 t.Fatalf("parseUDPPortRange(%q) error = %v", tt.value, err)
1187 }
1188 if got != tt.want {
1189 t.Fatalf("parseUDPPortRange(%q) = %+v, want %+v", tt.value, got, tt.want)
1190 }
1191 })
1192 }
1193}
1194
1195func TestListenUDPInRange(t *testing.T) {
1196 // Occupy a wildcard UDP port, then assert listenUDPInRange skips it.
1197 var lc net.ListenConfig
1198 occupied, err := lc.ListenPacket(context.Background(), "udp", ":0")
1199 if err != nil {
1200 t.Fatal(err)
1201 }
1202 defer func() { _ = occupied.Close() }()
1203 udpPort := func(t *testing.T, conn net.PacketConn) int {
1204 t.Helper()
1205 addr, ok := conn.LocalAddr().(*net.UDPAddr)
1206 if !ok {
1207 t.Fatalf("LocalAddr() = %T, want *net.UDPAddr", conn.LocalAddr())
1208 }
1209 return addr.Port
1210 }
1211 busyPort := udpPort(t, occupied)
1212
1213 t.Run("skips busy port", func(t *testing.T) {
1214 conn, err := listenUDPInRange(context.Background(), udpPortRange{start: busyPort, end: busyPort + 8})
1215 if err != nil {
1216 t.Fatalf("listenUDPInRange error = %v", err)
1217 }
1218 defer func() { _ = conn.Close() }()
1219 got := udpPort(t, conn)
1220 if got == busyPort {
1221 t.Fatalf("bound busy port %d, want a different port in range", busyPort)
1222 }
1223 if got < busyPort || got > busyPort+8 {
1224 t.Fatalf("bound port %d outside range %d-%d", got, busyPort, busyPort+8)
1225 }
1226 })
1227
1228 t.Run("exhausted range errors", func(t *testing.T) {
1229 _, err := listenUDPInRange(context.Background(), udpPortRange{start: busyPort, end: busyPort})
1230 if err == nil {
1231 t.Fatal("listenUDPInRange error = nil, want exhausted-range error")
1232 }
1233 if !strings.Contains(err.Error(), "no free UDP port in range") {
1234 t.Fatalf("error = %v, want 'no free UDP port in range'", err)
1235 }
1236 })
1237
1238 t.Run("invalid direct range errors", func(t *testing.T) {
1239 _, err := listenUDPInRange(context.Background(), udpPortRange{start: busyPort + 8, end: busyPort})
1240 if err == nil {
1241 t.Fatal("listenUDPInRange error = nil, want invalid-range error")
1242 }
1243 if !strings.Contains(err.Error(), "invalid UDP port range") {
1244 t.Fatalf("error = %v, want 'invalid UDP port range'", err)
1245 }
1246 })
1247
1248 t.Run("ephemeral binds", func(t *testing.T) {
1249 conn, err := listenUDPInRange(context.Background(), udpPortRange{start: 0, end: 0})
1250 if err != nil {
1251 t.Fatalf("listenUDPInRange ephemeral error = %v", err)
1252 }
1253 defer func() { _ = conn.Close() }()
1254 if udpPort(t, conn) == 0 {
1255 t.Fatal("ephemeral bind returned port 0")
1256 }
1257 })
1258}
1259
1260func TestPprofAddrIsLoopback(t *testing.T) {
1261 tests := []struct {
1262 addr string
1263 want bool
1264 }{
1265 {addr: "127.0.0.1:6060", want: true},
1266 {addr: "localhost:6060", want: true},
1267 {addr: "[::1]:6060", want: true},
1268 {addr: ":6060", want: false},
1269 {addr: "0.0.0.0:6060", want: false},
1270 {addr: "192.168.1.5:6060", want: false},
1271 {addr: "garbage", want: false},
1272 }
1273
1274 for _, tt := range tests {
1275 t.Run(tt.addr, func(t *testing.T) {
1276 if got := pprofAddrIsLoopback(tt.addr); got != tt.want {
1277 t.Fatalf("pprofAddrIsLoopback(%q) = %v, want %v", tt.addr, got, tt.want)
1278 }
1279 })
1280 }
1281}
1282
1283type appCatalogCacheStub struct {
1284 loads int
1285}
1286
1287func (s *appCatalogCacheStub) Load() ([]ports.RemoteCatalogCacheEntry, error) {
1288 s.loads++
1289 return nil, nil
1290}
1291
1292func (s *appCatalogCacheStub) Store([]ports.RemoteCatalogCacheEntry) error { return nil }
1293
1294func TestRemoteDiscoveryDaemonOptionWiresProductionPorts(t *testing.T) {
1295 oldHostStore := newRemoteHostStore
1296 oldCatalogCache := newRemoteCatalogCache
1297 oldCatalogClient := newRemoteCatalogClient
1298 oldDialerFactory := newRemoteDialerFactoryWithRuntimeObserver
1299 t.Cleanup(func() {
1300 newRemoteHostStore = oldHostStore
1301 newRemoteCatalogCache = oldCatalogCache
1302 newRemoteCatalogClient = oldCatalogClient
1303 newRemoteDialerFactoryWithRuntimeObserver = oldDialerFactory
1304 })
1305
1306 stateDir := t.TempDir()
1307 cache := &appCatalogCacheStub{}
1308 var hostPath, cachePath string
1309 newRemoteHostStore = func(path string) ports.RemoteHostStore {
1310 hostPath = path
1311 return nil
1312 }
1313 newRemoteCatalogCache = func(path string) ports.RemoteCatalogCache {
1314 cachePath = path
1315 return cache
1316 }
1317 newRemoteCatalogClient = func() ports.RemoteCatalogClient { return nil }
1318 newRemoteDialerFactoryWithRuntimeObserver = func(ports.SerializedRuntimeObserver) ports.RemoteDialerFactory { return nil }
1319
1320 option, err := remoteDiscoveryDaemonOption(stateDir, nil, "stdio")
1321 require.NoError(t, err)
1322 _ = daemon.New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), option)
1323 require.Equal(t, remoteadapter.HostStorePath(stateDir), hostPath)
1324 require.Equal(t, remoteadapter.CatalogCachePath(stateDir), cachePath)
1325 require.Equal(t, 1, cache.loads, "daemon startup must load the cache once")
1326}
1327
1328func TestRemoteDiscoveryDaemonOptionRejectsInvalidTransport(t *testing.T) {
1329 _, err := remoteDiscoveryDaemonOption(t.TempDir(), nil, "serial")
1330 require.EqualError(t, err, `vev: invalid remote transport "serial" (want "udp" or "stdio")`)
1331}