1package app
2
3import (
4 "bufio"
5 "bytes"
6 "context"
7 "encoding/json"
8 "errors"
9 "io"
10 "log/slog"
11 "os"
12 "path/filepath"
13 "strings"
14 "testing"
15 "time"
16
17 "github.com/bnema/vev/internal/adapters/daemonmux"
18 "github.com/bnema/vev/internal/adapters/lifecycle"
19 "github.com/bnema/vev/internal/domain"
20 "github.com/bnema/vev/internal/persist"
21 "github.com/bnema/vev/internal/ports"
22 "github.com/bnema/vev/internal/protocol"
23 "github.com/bnema/vev/internal/protocol/wire"
24 "github.com/bnema/vev/pkg/kv"
25 "github.com/bnema/vev/pkg/safedir"
26 "github.com/stretchr/testify/require"
27)
28
29func decodeJSONLogs(t *testing.T, data []byte) []map[string]any {
30 t.Helper()
31 var entries []map[string]any
32 scanner := bufio.NewScanner(bytes.NewReader(data))
33 for scanner.Scan() {
34 var entry map[string]any
35 require.NoError(t, json.Unmarshal(scanner.Bytes(), &entry))
36 entries = append(entries, entry)
37 }
38 require.NoError(t, scanner.Err())
39 return entries
40}
41
42func eventNames(entries []map[string]any) []string {
43 names := make([]string, 0, len(entries))
44 for _, entry := range entries {
45 if name, ok := entry["msg"].(string); ok {
46 names = append(names, name)
47 }
48 }
49 return names
50}
51
52func requireEvent(t *testing.T, entries []map[string]any, name string) map[string]any {
53 t.Helper()
54 for _, entry := range entries {
55 if entry["msg"] == name {
56 return entry
57 }
58 }
59 t.Fatalf("event %q not found in %v", name, eventNames(entries))
60 return nil
61}
62
63func TestRecoveryObservability(t *testing.T) {
64 var logBuffer bytes.Buffer
65 log := slog.New(slog.NewJSONHandler(&logBuffer, nil))
66 owner := fakeLifecycleOwnership{release: func() error { return nil }}
67 deps := lifecycleStartupDeps{
68 ensurePrivate: func(string) error { return nil },
69 acquire: func(context.Context, string, time.Duration) (lifecycleOwnership, error) {
70 return owner, nil
71 },
72 log: log,
73 }
74 ref := &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}
75 records := []domain.CatalogueRecord{
76 {Committed: ref},
77 {Committed: ref},
78 {},
79 {Committed: ref, DegradedReason: "checkpoint validation failed"},
80 }
81 require.NoError(t, runWithLifecycleOwnerDeps(context.Background(), "/runtime/vev", "/state/vev", func(context.Context) error {
82 logCatalogueRecovery(log, records, "current")
83 logStartupRecoveryCounts(log, records, 0)
84 return nil
85 }, deps))
86
87 entries := decodeJSONLogs(t, logBuffer.Bytes())
88 for _, name := range []string{"lifecycle_owner_wait", "lifecycle_owner_acquired", "catalogue_validated", "daemon_startup_complete", "lifecycle_owner_released"} {
89 require.Contains(t, eventNames(entries), name)
90 }
91 startup := requireEvent(t, entries, "daemon_startup_complete")
92 require.EqualValues(t, 2, startup["healthy"])
93 require.EqualValues(t, 1, startup["fresh"])
94 require.EqualValues(t, 0, startup["restoring"])
95 require.EqualValues(t, 1, startup["broken"])
96 require.NotContains(t, eventNames(entries), "interrupted_transaction_recovery_complete")
97}
98
99// A session-scoped conflict no longer aborts startup, so the daemon comes up
100// looking healthy. Startup must therefore name the fenced session in the log,
101// persist a notice for it, and never report the recovery as clean.
102type fakeLifecycleOwnership struct {
103 release func() error
104}
105
106func (o fakeLifecycleOwnership) Release() error { return o.release() }
107
108func TestCatalogueFailureDoesNotListen(t *testing.T) {
109 originalOpenCatalogue, originalListenDaemon := openCatalogue, listenDaemon
110 t.Cleanup(func() {
111 openCatalogue = originalOpenCatalogue
112 listenDaemon = originalListenDaemon
113 })
114
115 for _, catalogueErr := range []error{errors.New("catalogue corrupt"), errors.New("catalogue unavailable")} {
116 t.Run(catalogueErr.Error(), func(t *testing.T) {
117 runtimeRoot, stateRoot := t.TempDir(), t.TempDir()
118 runtimeDir := filepath.Join(runtimeRoot, "vev")
119 stateDir := filepath.Join(stateRoot, "vev")
120 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
121 t.Setenv("XDG_STATE_HOME", stateRoot)
122
123 listenCalls := 0
124 openCatalogue = func(gotStateDir string) (persist.OpenResult, error) {
125 require.Equal(t, stateDir, gotStateDir)
126 return persist.OpenResult{}, catalogueErr
127 }
128 listenDaemon = func(string, ports.SerializedRuntimeObserver) (wire.Listener, error) {
129 listenCalls++
130 return nil, errors.New("IPC listen must not run after catalogue failure")
131 }
132
133 err := runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, func(ctx context.Context) error {
134 return runDaemonOwnedWithLogger(ctx, slog.New(slog.NewTextHandler(io.Discard, nil)))
135 })
136 require.ErrorIs(t, err, catalogueErr)
137 require.Zero(t, listenCalls)
138 require.NoFileExists(t, filepath.Join(runtimeDir, "daemon.sock"))
139
140 owner, acquireErr := lifecycle.TryAcquire(runtimeDir)
141 require.NoError(t, acquireErr, "catalogue failure must release lifecycle ownership")
142 require.NoError(t, owner.Release())
143 })
144 }
145}
146
147// TestLifecycleOwnershipAcceptsOwnerOnlyVariantLock is the production-daemon
148// regression for the relaxed lock-permission rule: a pre-existing lock written
149// with an owner-only mode other than 0600 must still let the daemon start, while
150// group/other access stays refused (covered by the lifecycle unit tests).
151func TestLifecycleOwnershipAcceptsOwnerOnlyVariantLock(t *testing.T) {
152 runtimeDir := filepath.Join(t.TempDir(), "runtime")
153 stateDir := filepath.Join(t.TempDir(), "state")
154 require.NoError(t, safedir.EnsurePrivate(runtimeDir))
155 require.NoError(t, os.WriteFile(lifecycle.Path(runtimeDir), nil, 0o600))
156 require.NoError(t, os.Chmod(lifecycle.Path(runtimeDir), 0o700))
157
158 started := false
159 err := runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, func(context.Context) error {
160 started = true
161 return nil
162 })
163 require.NoError(t, err, "an owner-only variant lock must not block production daemon startup")
164 require.True(t, started)
165
166 owner, err := lifecycle.TryAcquire(runtimeDir)
167 require.NoError(t, err, "lifecycle ownership must be released after startup")
168 require.NoError(t, owner.Release())
169}
170
171func TestLifecycleOwnershipPrecedesDaemonStartup(t *testing.T) {
172 t.Run("ownership prefix is exact", func(t *testing.T) {
173 var events []string
174 owner := fakeLifecycleOwnership{release: func() error {
175 events = append(events, "unlock")
176 return nil
177 }}
178 deps := lifecycleStartupDeps{
179 ensurePrivate: func(path string) error {
180 if path == "runtime" {
181 events = append(events, "ensure-runtime")
182 } else {
183 events = append(events, "ensure-state")
184 }
185 return nil
186 },
187 acquire: func(context.Context, string, time.Duration) (lifecycleOwnership, error) {
188 events = append(events, "lock")
189 return owner, nil
190 },
191 }
192
193 err := runWithLifecycleOwnerDeps(context.Background(), "runtime", "state", func(context.Context) error {
194 events = append(events, "durable-open", "listen", "serve")
195 return nil
196 }, deps)
197 require.NoError(t, err)
198 require.Equal(t, []string{"ensure-runtime", "ensure-state", "lock"}, events[:3])
199 require.Equal(t, []string{"ensure-runtime", "ensure-state", "lock", "durable-open", "listen", "serve", "unlock"}, events)
200 })
201
202 t.Run("callback observes held lock and failure releases it", func(t *testing.T) {
203 runtimeDir := filepath.Join(t.TempDir(), "runtime")
204 stateDir := filepath.Join(t.TempDir(), "state")
205 startErr := errors.New("catalogue corrupt")
206
207 err := runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, func(context.Context) error {
208 _, lockErr := lifecycle.TryAcquire(runtimeDir)
209 require.ErrorIs(t, lockErr, lifecycle.ErrBusy)
210 return startErr
211 })
212 require.ErrorIs(t, err, startErr)
213
214 owner, err := lifecycle.TryAcquire(runtimeDir)
215 require.NoError(t, err, "callback failure must release lifecycle ownership")
216 require.NoError(t, owner.Release())
217 })
218
219 t.Run("busy and unavailable ownership fail before startup", func(t *testing.T) {
220 runtimeDir := filepath.Join(t.TempDir(), "runtime")
221 stateDir := filepath.Join(t.TempDir(), "state")
222 owner, err := lifecycle.TryAcquire(runtimeDir)
223 require.NoError(t, err)
224 defer func() { require.NoError(t, owner.Release()) }()
225
226 ctx, cancel := context.WithCancel(context.Background())
227 cancel()
228 started := false
229 err = runWithLifecycleOwner(ctx, runtimeDir, stateDir, func(context.Context) error {
230 started = true
231 return nil
232 })
233 require.ErrorIs(t, err, context.Canceled)
234 require.False(t, started)
235
236 badState := filepath.Join(t.TempDir(), "state-file")
237 require.NoError(t, os.WriteFile(badState, []byte("unavailable"), 0o600))
238 err = runWithLifecycleOwner(context.Background(), filepath.Join(t.TempDir(), "other-runtime"), badState, func(context.Context) error {
239 started = true
240 return nil
241 })
242 require.Error(t, err)
243 require.False(t, started)
244 })
245
246 t.Run("corrupt catalogue fails before socket publication", func(t *testing.T) {
247 runtimeRoot, stateRoot := t.TempDir(), t.TempDir()
248 runtimeDir := filepath.Join(runtimeRoot, "vev")
249 stateDir := filepath.Join(stateRoot, "vev")
250 require.NoError(t, safedir.EnsurePrivate(stateDir))
251 store, err := kv.Open(persist.StorePath(stateDir))
252 require.NoError(t, err)
253 require.NoError(t, store.Set([]byte("work"), []byte("malformed catalogue value")))
254 require.NoError(t, store.Close())
255
256 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
257 t.Setenv("XDG_STATE_HOME", stateRoot)
258 before, err := os.ReadFile(persist.StorePath(stateDir))
259 require.NoError(t, err)
260 err = runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, runDaemonOwned)
261 require.Error(t, err)
262 require.Contains(t, err.Error(), stateDir)
263 require.Contains(t, err.Error(), "rm -rf "+stateDir)
264 after, readErr := os.ReadFile(persist.StorePath(stateDir))
265 require.NoError(t, readErr)
266 require.Equal(t, before, after, "failed startup must leave the catalogue untouched")
267 _, statErr := os.Stat(daemonmux.SocketPath(runtimeDir))
268 require.ErrorIs(t, statErr, os.ErrNotExist)
269 })
270
271 t.Run("proven absence creates the catalogue before socket publication", func(t *testing.T) {
272 runtimeRoot, err := os.MkdirTemp("/tmp", "vev-")
273 require.NoError(t, err)
274 t.Cleanup(func() { require.NoError(t, os.RemoveAll(runtimeRoot)) })
275 stateRoot := t.TempDir()
276 runtimeDir := filepath.Join(runtimeRoot, "vev")
277 stateDir := filepath.Join(stateRoot, "vev")
278 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
279 t.Setenv("XDG_STATE_HOME", stateRoot)
280 ctx, cancel := context.WithCancel(t.Context())
281 result := make(chan error, 1)
282 go func() {
283 result <- runWithLifecycleOwner(ctx, runtimeDir, stateDir, runDaemonOwned)
284 }()
285 t.Cleanup(func() {
286 cancel()
287 select {
288 case err := <-result:
289 require.NoError(t, err)
290 case <-time.After(5 * time.Second):
291 t.Error("daemon lifecycle did not stop after cancellation")
292 }
293 })
294
295 require.Eventually(t, func() bool {
296 _, err := os.Stat(daemonmux.SocketPath(runtimeDir))
297 return err == nil
298 }, 5*time.Second, time.Millisecond, "daemon mux socket was not published")
299 require.FileExists(t, persist.StorePath(stateDir))
300 })
301
302 t.Run("callback and release errors are joined", func(t *testing.T) {
303 startErr := errors.New("catalogue unavailable")
304 releaseErr := errors.New("unlock failed")
305 deps := lifecycleStartupDeps{
306 ensurePrivate: func(string) error { return nil },
307 acquire: func(context.Context, string, time.Duration) (lifecycleOwnership, error) {
308 return fakeLifecycleOwnership{release: func() error { return releaseErr }}, nil
309 },
310 }
311
312 err := runWithLifecycleOwnerDeps(context.Background(), "runtime", "state", func(context.Context) error {
313 return startErr
314 }, deps)
315 require.ErrorIs(t, err, startErr)
316 require.ErrorIs(t, err, releaseErr)
317 })
318}
319
320func TestDevelopmentTempDirOption(t *testing.T) {
321 absoluteBase := filepath.Join(t.TempDir(), ".dev")
322 creatorErr := errors.New("cannot secure directory")
323 tests := []struct {
324 name string
325 environment string
326 base string
327 creatorErr error
328 wantPath string
329 wantOption bool
330 wantErr error
331 }{
332 {
333 name: "inactive environment",
334 base: absoluteBase,
335 },
336 {
337 name: "active environment",
338 environment: "work",
339 base: absoluteBase,
340 wantPath: filepath.Join(absoluteBase, "work", "tmp"),
341 wantOption: true,
342 },
343 {
344 name: "creator error",
345 environment: "work",
346 base: absoluteBase,
347 creatorErr: creatorErr,
348 wantPath: filepath.Join(absoluteBase, "work", "tmp"),
349 wantErr: creatorErr,
350 },
351 }
352
353 for _, tt := range tests {
354 t.Run(tt.name, func(t *testing.T) {
355 t.Setenv("VEV_ENV", tt.environment)
356 t.Setenv("VEV_ENV_ROOT", tt.base)
357 var creatorCalls []string
358
359 option, err := developmentTempDirOption(func(path string) error {
360 creatorCalls = append(creatorCalls, path)
361 return tt.creatorErr
362 })
363
364 if tt.wantErr != nil {
365 require.ErrorIs(t, err, tt.wantErr)
366 require.Nil(t, option)
367 } else {
368 require.NoError(t, err)
369 if tt.wantOption {
370 require.NotNil(t, option)
371 } else {
372 require.Nil(t, option)
373 }
374 }
375 if tt.wantPath == "" {
376 require.Empty(t, creatorCalls)
377 } else {
378 require.Equal(t, []string{tt.wantPath}, creatorCalls)
379 require.True(t, filepath.IsAbs(tt.wantPath))
380 }
381 })
382 }
383}
384
385func TestRunActivatesDevelopmentEnvironmentBeforeParsing(t *testing.T) {
386 t.Setenv("VEV_ENV", "../live")
387 t.Setenv("XDG_CONFIG_HOME", "/existing/config")
388 t.Setenv("XDG_STATE_HOME", "/existing/state")
389 t.Setenv("XDG_RUNTIME_DIR", "/existing/runtime")
390
391 err := Run([]string{"unknown"})
392 require.ErrorContains(t, err, "invalid VEV_ENV")
393 require.NotContains(t, err.Error(), "unknown command")
394 require.Equal(t, "/existing/config", os.Getenv("XDG_CONFIG_HOME"))
395 require.Equal(t, "/existing/state", os.Getenv("XDG_STATE_HOME"))
396 require.Equal(t, "/existing/runtime", os.Getenv("XDG_RUNTIME_DIR"))
397}
398
399func TestParseArgs(t *testing.T) {
400 tests := []struct {
401 name string
402 args []string
403 wantKind cmdKind
404 wantIntent uint8
405 wantName string
406 wantRemote string
407 wantListHost string
408 wantListAll bool
409 wantHostAct string
410 wantHostTgt string
411 wantAll bool
412 wantSessions bool
413 wantErr bool
414 nonUsageErr bool
415 }{
416 {name: "no args -> ephemeral attach", args: nil, wantKind: kindAttach, wantIntent: protocol.IntentEphemeral},
417 {name: "empty slice -> ephemeral attach", args: []string{}, wantKind: kindAttach, wantIntent: protocol.IntentEphemeral},
418 {name: "new named", args: []string{"new", "work"}, wantKind: kindAttach, wantIntent: protocol.IntentNew, wantName: "work"},
419 {name: "new remote", args: []string{"new", "work", "user@example.com"}, wantKind: kindAttach, wantIntent: protocol.IntentNew, wantName: "work", wantRemote: "user@example.com"},
420 {name: "new invalid remote", args: []string{"new", "work", "bad host"}, wantErr: true, nonUsageErr: true},
421 {name: "new without name", args: []string{"new"}, wantErr: true},
422 {name: "new empty name", args: []string{"new", ""}, wantErr: true},
423 {name: "new command override unsupported", args: []string{"new", "work", "--", "sh"}, wantErr: true},
424 {name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: protocol.IntentAttach, wantName: "work"},
425 {name: "attach preserves legacy unsafe name", args: []string{"attach", "my work"}, wantKind: kindAttach, wantIntent: protocol.IntentAttach, wantName: "my work"},
426 {name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: protocol.IntentAttach, wantName: "work"},
427 {name: "attach remote host uses ephemeral", args: []string{"attach", "user@example.com"}, wantKind: kindAttach, wantIntent: protocol.IntentEphemeral, wantRemote: "user@example.com"},
428 {name: "attach remote host with empty session uses ephemeral", args: []string{"attach", "user@example.com:"}, wantKind: kindAttach, wantIntent: protocol.IntentEphemeral, wantRemote: "user@example.com"},
429 {name: "attach remote host with session", args: []string{"attach", "user@example.com:work"}, wantKind: kindAttach, wantIntent: protocol.IntentAttach, wantName: "work", wantRemote: "user@example.com"},
430 {name: "attach remote ipv4 with session", args: []string{"attach", "user@192.0.2.10:work"}, wantKind: kindAttach, wantIntent: protocol.IntentAttach, wantName: "work", wantRemote: "user@192.0.2.10"},
431 {name: "attach remote ipv6 with session", args: []string{"attach", "user@[2001:db8::1]:work"}, wantKind: kindAttach, wantIntent: protocol.IntentAttach, wantName: "work", wantRemote: "user@[2001:db8::1]"},
432 {name: "attach remote invalid session", args: []string{"attach", "user@example.com:my work"}, wantErr: true, nonUsageErr: true},
433 {name: "attach extra arg", args: []string{"attach", "work", "extra"}, wantErr: true},
434 {name: "attach without name", args: []string{"attach"}, wantErr: true},
435 {name: "ls", args: []string{"ls"}, wantKind: kindList},
436 {name: "list", args: []string{"list"}, wantKind: kindList},
437 {name: "ls host", args: []string{"ls", "arch"}, wantKind: kindList, wantListHost: "arch"},
438 {name: "list host", args: []string{"list", "build@mule"}, wantKind: kindList, wantListHost: "build@mule"},
439 {name: "ls --all", args: []string{"ls", "--all"}, wantKind: kindList, wantListAll: true},
440 {name: "list --all", args: []string{"list", "--all"}, wantKind: kindList, wantListAll: true},
441 {name: "ls host extra", args: []string{"ls", "arch", "extra"}, wantErr: true},
442 {name: "ls --all host", args: []string{"ls", "--all", "arch"}, wantErr: true},
443 {name: "ls invalid host", args: []string{"ls", "bad host"}, wantErr: true, nonUsageErr: true},
444 {name: "host add", args: []string{"host", "add", "arch"}, wantKind: kindHost, wantHostAct: "add", wantHostTgt: "arch"},
445 {name: "host rm", args: []string{"host", "rm", "build@mule"}, wantKind: kindHost, wantHostAct: "rm", wantHostTgt: "build@mule"},
446 {name: "host list", args: []string{"host", "list"}, wantKind: kindHost, wantHostAct: "list"},
447 {name: "host alone", args: []string{"host"}, wantErr: true},
448 {name: "host add missing target", args: []string{"host", "add"}, wantErr: true},
449 {name: "host add extra", args: []string{"host", "add", "arch", "extra"}, wantErr: true},
450 {name: "host add invalid", args: []string{"host", "add", " bad"}, wantErr: true, nonUsageErr: true},
451 {name: "host unknown action", args: []string{"host", "rename", "arch"}, wantErr: true},
452 {name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"},
453 {name: "kill preserves legacy unsafe name via terminator", args: []string{"kill", "--", "my work"}, wantKind: kindKill, wantName: "my work"},
454 {name: "kill dashed name via terminator", args: []string{"kill", "--", "--all"}, wantKind: kindKill, wantName: "--all"},
455 {name: "kill all", args: []string{"kill", "--all"}, wantKind: kindKill, wantAll: true},
456 {name: "kill sessions", args: []string{"kill", "--sessions"}, wantKind: kindKill, wantSessions: true},
457 {name: "kill sessions rejects extra arg", args: []string{"kill", "--sessions", "extra"}, wantErr: true},
458 {name: "hidden daemon stop", args: []string{"_daemon-stop"}, wantKind: kindDaemonStop},
459 {name: "hidden daemon stop rejects args", args: []string{"_daemon-stop", "x"}, wantErr: true},
460 {name: "kill unknown flag", args: []string{"kill", "-x"}, wantErr: true},
461 {name: "kill removed daemon flag", args: []string{"kill", "--daemon"}, wantErr: true},
462 {name: "kill removed broker flag", args: []string{"kill", "--broker"}, wantErr: true},
463 {name: "kill without name", args: []string{"kill"}, wantErr: true},
464 {name: "kill terminator without name", args: []string{"kill", "--"}, wantErr: true},
465 {name: "kill all rejects extra arg", args: []string{"kill", "--all", "extra"}, wantErr: true},
466 {name: "kill extra arg", args: []string{"kill", "work", "extra"}, wantErr: true},
467 {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon},
468 {name: "removed stdio", args: []string{"_stdio"}, wantErr: true},
469 {name: "removed quic bootstrap", args: []string{"_quic-bootstrap"}, wantErr: true},
470 {name: "removed quic proxy", args: []string{"_quic-proxy"}, wantErr: true},
471 {name: "removed udp bootstrap", args: []string{"_udp-bootstrap"}, wantErr: true},
472 {name: "removed udp proxy", args: []string{"_udp-proxy"}, wantErr: true},
473 {name: "removed udp proxy rejects session", args: []string{"_udp-proxy", "work"}, wantErr: true},
474 {name: "removed udp proxy rejects extra args", args: []string{"_udp-proxy", "work", "extra"}, wantErr: true},
475 {name: "help", args: []string{"--help"}, wantKind: kindHelp},
476 {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp},
477 {name: "version", args: []string{"--version"}, wantKind: kindVersion},
478 {name: "unknown", args: []string{"frobnicate"}, wantErr: true},
479 }
480
481 for _, tt := range tests {
482 t.Run(tt.name, func(t *testing.T) {
483 got, err := parseArgs(tt.args)
484 if tt.wantErr {
485 if err == nil {
486 t.Fatalf("parseArgs(%q) = %+v, want error", tt.args, got)
487 }
488 if tt.nonUsageErr {
489 _, usage := errors.AsType[*usageError](err)
490 if usage {
491 t.Fatalf("parseArgs(%q) error = *usageError, want non-usage error", tt.args)
492 }
493 } else {
494 _, usage := errors.AsType[*usageError](err)
495 if !usage {
496 t.Fatalf("parseArgs(%q) error = %T, want *usageError", tt.args, err)
497 }
498 }
499 return
500 }
501 if err != nil {
502 t.Fatalf("parseArgs(%q) unexpected error: %v", tt.args, err)
503 }
504 if got.kind != tt.wantKind {
505 t.Errorf("kind = %v, want %v", got.kind, tt.wantKind)
506 }
507 if got.kind == kindAttach && got.intent != tt.wantIntent {
508 t.Errorf("intent = %d, want %d", got.intent, tt.wantIntent)
509 }
510 if got.name != tt.wantName {
511 t.Errorf("name = %q, want %q", got.name, tt.wantName)
512 }
513 if got.remoteTarget != tt.wantRemote {
514 t.Errorf("remoteTarget = %q, want %q", got.remoteTarget, tt.wantRemote)
515 }
516 if got.listHost != tt.wantListHost {
517 t.Errorf("listHost = %q, want %q", got.listHost, tt.wantListHost)
518 }
519 if got.listAll != tt.wantListAll {
520 t.Errorf("listAll = %v, want %v", got.listAll, tt.wantListAll)
521 }
522 if got.hostAction != tt.wantHostAct {
523 t.Errorf("hostAction = %q, want %q", got.hostAction, tt.wantHostAct)
524 }
525 if got.hostTarget != tt.wantHostTgt {
526 t.Errorf("hostTarget = %q, want %q", got.hostTarget, tt.wantHostTgt)
527 }
528 if got.killAll != tt.wantAll {
529 t.Errorf("killAll = %v, want %v", got.killAll, tt.wantAll)
530 }
531 if got.killSessions != tt.wantSessions {
532 t.Errorf("killSessions = %v, want %v", got.killSessions, tt.wantSessions)
533 }
534 })
535 }
536}
537
538func TestParseArgsNewRejectsUnsafeSessionName(t *testing.T) {
539 _, err := parseArgs([]string{"new", "my work"})
540 if !errors.Is(err, domain.ErrInvalidSessionName) {
541 t.Fatalf("parseArgs new unsafe error = %v, want %v", err, domain.ErrInvalidSessionName)
542 }
543}
544
545func TestListShowsBroken(t *testing.T) {
546 var out bytes.Buffer
547 printSessions(&out, []protocol.SessionInfo{
548 {Name: "fresh", State: protocol.SessionDown},
549 {Name: "loading", State: protocol.SessionDown},
550 {Name: "broken", State: protocol.SessionBroken},
551 })
552 require.Contains(t, out.String(), "fresh")
553 require.Contains(t, out.String(), "down")
554 require.Contains(t, out.String(), "loading")
555 require.Contains(t, out.String(), "broken")
556 require.NotContains(t, out.String(), "restoring")
557 require.NotContains(t, out.String(), "degraded")
558}
559
560func TestPrintSessionsShowsDownState(t *testing.T) {
561 var out bytes.Buffer
562 printSessions(&out, []protocol.SessionInfo{
563 {Name: "main", State: protocol.SessionUp, Tabs: 2, Attached: true},
564 {Name: "old", State: protocol.SessionDown},
565 })
566 got := out.String()
567 for _, want := range []string{"NAME", "STATE", "main", "up", "2", "yes", "old", "down", "-"} {
568 if !strings.Contains(got, want) {
569 t.Fatalf("printSessions output %q missing %q", got, want)
570 }
571 }
572}
573
574func TestPrintSessionsMarksEphemeral(t *testing.T) {
575 var buf bytes.Buffer
576 printSessions(&buf, []protocol.SessionInfo{
577 {Name: "0", State: protocol.SessionUp, Ephemeral: true, Tabs: 1, Attached: false},
578 {Name: "work", State: protocol.SessionUp, Tabs: 2, Attached: true},
579 {Name: "old", State: protocol.SessionDown},
580 })
581 out := buf.String()
582 for _, want := range []string{"0", "temporary", "work", "up", "old", "down"} {
583 require.Contains(t, out, want)
584 }
585}
586
587func newTestPersister(t *testing.T, stateDir string) *persist.Persister {
588 t.Helper()
589 store, err := persist.OpenStore(persist.StorePath(stateDir))
590 require.NoError(t, err)
591 return persist.New(store)
592}
593
594func captureStdout(t *testing.T, fn func()) string {
595 t.Helper()
596 old := os.Stdout
597 r, w, err := os.Pipe()
598 if err != nil {
599 t.Fatalf("os.Pipe error = %v", err)
600 }
601 os.Stdout = w
602 defer func() { os.Stdout = old }()
603 defer func() { _ = r.Close() }()
604 outCh := make(chan []byte, 1)
605 errCh := make(chan error, 1)
606 go func() {
607 out, err := io.ReadAll(r)
608 outCh <- out
609 errCh <- err
610 }()
611
612 fn()
613 _ = w.Close()
614 out := <-outCh
615 if err := <-errCh; err != nil {
616 t.Fatalf("ReadAll stdout error = %v", err)
617 }
618 return string(out)
619}
620
621func TestPprofAddrIsLoopback(t *testing.T) {
622 tests := []struct {
623 addr string
624 want bool
625 }{
626 {addr: "127.0.0.1:6060", want: true},
627 {addr: "localhost:6060", want: true},
628 {addr: "[::1]:6060", want: true},
629 {addr: ":6060", want: false},
630 {addr: "0.0.0.0:6060", want: false},
631 {addr: "192.168.1.5:6060", want: false},
632 {addr: "garbage", want: false},
633 }
634
635 for _, tt := range tests {
636 t.Run(tt.addr, func(t *testing.T) {
637 if got := pprofAddrIsLoopback(tt.addr); got != tt.want {
638 t.Fatalf("pprofAddrIsLoopback(%q) = %v, want %v", tt.addr, got, tt.want)
639 }
640 })
641 }
642}