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 terminator empty name", args: []string{"kill", "--", ""}, wantErr: true},
466 {name: "leading ui socket without value", args: []string{"--ui-socket"}, wantErr: true},
467 {name: "leading ui flag before ls", args: []string{"--ui-observe", "ls"}, wantErr: true},
468 {name: "leading ui flag before ui-driver", args: []string{"--ui-observe", "--ui-driver"}, wantErr: true},
469 {name: "leading ui flag only", args: []string{"--ui-observe"}, wantKind: kindAttach, wantIntent: protocol.IntentEphemeral},
470 {name: "new remote with ui flag", args: []string{"new", "work", "user@example.com", "--ui-control"}, wantKind: kindAttach, wantIntent: protocol.IntentNew, wantName: "work", wantRemote: "user@example.com"},
471 {name: "web renew rejects args", args: []string{"--web-renew-token", "x"}, wantErr: true},
472 {name: "kill all rejects extra arg", args: []string{"kill", "--all", "extra"}, wantErr: true},
473 {name: "kill extra arg", args: []string{"kill", "work", "extra"}, wantErr: true},
474 {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon},
475 {name: "removed stdio", args: []string{"_stdio"}, wantErr: true},
476 {name: "removed quic bootstrap", args: []string{"_quic-bootstrap"}, wantErr: true},
477 {name: "removed quic proxy", args: []string{"_quic-proxy"}, wantErr: true},
478 {name: "removed udp bootstrap", args: []string{"_udp-bootstrap"}, wantErr: true},
479 {name: "removed udp proxy", args: []string{"_udp-proxy"}, wantErr: true},
480 {name: "removed udp proxy rejects session", args: []string{"_udp-proxy", "work"}, wantErr: true},
481 {name: "removed udp proxy rejects extra args", args: []string{"_udp-proxy", "work", "extra"}, wantErr: true},
482 {name: "help", args: []string{"--help"}, wantKind: kindHelp},
483 {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp},
484 {name: "version", args: []string{"--version"}, wantKind: kindVersion},
485 {name: "unknown", args: []string{"frobnicate"}, wantErr: true},
486 }
487
488 for _, tt := range tests {
489 t.Run(tt.name, func(t *testing.T) {
490 got, err := parseArgs(tt.args)
491 if tt.wantErr {
492 if err == nil {
493 t.Fatalf("parseArgs(%q) = %+v, want error", tt.args, got)
494 }
495 if tt.nonUsageErr {
496 _, usage := errors.AsType[*usageError](err)
497 if usage {
498 t.Fatalf("parseArgs(%q) error = *usageError, want non-usage error", tt.args)
499 }
500 } else {
501 _, usage := errors.AsType[*usageError](err)
502 if !usage {
503 t.Fatalf("parseArgs(%q) error = %T, want *usageError", tt.args, err)
504 }
505 }
506 return
507 }
508 if err != nil {
509 t.Fatalf("parseArgs(%q) unexpected error: %v", tt.args, err)
510 }
511 if got.kind != tt.wantKind {
512 t.Errorf("kind = %v, want %v", got.kind, tt.wantKind)
513 }
514 if got.kind == kindAttach && got.intent != tt.wantIntent {
515 t.Errorf("intent = %d, want %d", got.intent, tt.wantIntent)
516 }
517 if got.name != tt.wantName {
518 t.Errorf("name = %q, want %q", got.name, tt.wantName)
519 }
520 if got.remoteTarget != tt.wantRemote {
521 t.Errorf("remoteTarget = %q, want %q", got.remoteTarget, tt.wantRemote)
522 }
523 if got.listHost != tt.wantListHost {
524 t.Errorf("listHost = %q, want %q", got.listHost, tt.wantListHost)
525 }
526 if got.listAll != tt.wantListAll {
527 t.Errorf("listAll = %v, want %v", got.listAll, tt.wantListAll)
528 }
529 if got.hostAction != tt.wantHostAct {
530 t.Errorf("hostAction = %q, want %q", got.hostAction, tt.wantHostAct)
531 }
532 if got.hostTarget != tt.wantHostTgt {
533 t.Errorf("hostTarget = %q, want %q", got.hostTarget, tt.wantHostTgt)
534 }
535 if got.killAll != tt.wantAll {
536 t.Errorf("killAll = %v, want %v", got.killAll, tt.wantAll)
537 }
538 if got.killSessions != tt.wantSessions {
539 t.Errorf("killSessions = %v, want %v", got.killSessions, tt.wantSessions)
540 }
541 })
542 }
543}
544
545func TestParseArgsNewRejectsUnsafeSessionName(t *testing.T) {
546 _, err := parseArgs([]string{"new", "my work"})
547 if !errors.Is(err, domain.ErrInvalidSessionName) {
548 t.Fatalf("parseArgs new unsafe error = %v, want %v", err, domain.ErrInvalidSessionName)
549 }
550}
551
552func TestListShowsBroken(t *testing.T) {
553 var out bytes.Buffer
554 printSessions(&out, []protocol.SessionInfo{
555 {Name: "fresh", State: protocol.SessionDown},
556 {Name: "loading", State: protocol.SessionDown},
557 {Name: "broken", State: protocol.SessionBroken},
558 })
559 require.Contains(t, out.String(), "fresh")
560 require.Contains(t, out.String(), "down")
561 require.Contains(t, out.String(), "loading")
562 require.Contains(t, out.String(), "broken")
563 require.NotContains(t, out.String(), "restoring")
564 require.NotContains(t, out.String(), "degraded")
565}
566
567func TestPrintSessionsShowsDownState(t *testing.T) {
568 var out bytes.Buffer
569 printSessions(&out, []protocol.SessionInfo{
570 {Name: "main", State: protocol.SessionUp, Tabs: 2, Attached: true},
571 {Name: "old", State: protocol.SessionDown},
572 })
573 got := out.String()
574 for _, want := range []string{"NAME", "STATE", "main", "up", "2", "yes", "old", "down", "-"} {
575 if !strings.Contains(got, want) {
576 t.Fatalf("printSessions output %q missing %q", got, want)
577 }
578 }
579}
580
581func TestPrintSessionsMarksEphemeral(t *testing.T) {
582 var buf bytes.Buffer
583 printSessions(&buf, []protocol.SessionInfo{
584 {Name: "0", State: protocol.SessionUp, Ephemeral: true, Tabs: 1, Attached: false},
585 {Name: "work", State: protocol.SessionUp, Tabs: 2, Attached: true},
586 {Name: "old", State: protocol.SessionDown},
587 })
588 out := buf.String()
589 for _, want := range []string{"0", "temporary", "work", "up", "old", "down"} {
590 require.Contains(t, out, want)
591 }
592}
593
594func newTestPersister(t *testing.T, stateDir string) *persist.Persister {
595 t.Helper()
596 store, err := persist.OpenStore(persist.StorePath(stateDir))
597 require.NoError(t, err)
598 return persist.New(store)
599}
600
601func captureStdout(t *testing.T, fn func()) string {
602 t.Helper()
603 old := os.Stdout
604 r, w, err := os.Pipe()
605 if err != nil {
606 t.Fatalf("os.Pipe error = %v", err)
607 }
608 os.Stdout = w
609 defer func() { os.Stdout = old }()
610 defer func() { _ = r.Close() }()
611 outCh := make(chan []byte, 1)
612 errCh := make(chan error, 1)
613 go func() {
614 out, err := io.ReadAll(r)
615 outCh <- out
616 errCh <- err
617 }()
618
619 fn()
620 _ = w.Close()
621 out := <-outCh
622 if err := <-errCh; err != nil {
623 t.Fatalf("ReadAll stdout error = %v", err)
624 }
625 return string(out)
626}
627
628func TestPprofAddrIsLoopback(t *testing.T) {
629 tests := []struct {
630 addr string
631 want bool
632 }{
633 {addr: "127.0.0.1:6060", want: true},
634 {addr: "localhost:6060", want: true},
635 {addr: "[::1]:6060", want: true},
636 {addr: ":6060", want: false},
637 {addr: "0.0.0.0:6060", want: false},
638 {addr: "192.168.1.5:6060", want: false},
639 {addr: "garbage", want: false},
640 }
641
642 for _, tt := range tests {
643 t.Run(tt.addr, func(t *testing.T) {
644 if got := pprofAddrIsLoopback(tt.addr); got != tt.want {
645 t.Fatalf("pprofAddrIsLoopback(%q) = %v, want %v", tt.addr, got, tt.want)
646 }
647 })
648 }
649}