1package daemon
2
3import (
4 "testing"
5
6 snapcodec "github.com/bnema/vev/internal/snapshotcodec"
7)
8
9func TestProcessRestorePlan(t *testing.T) {
10 defaultAllow := map[string]struct{}{
11 "less": {},
12 "pi": {},
13 "claude": {},
14 "opencode": {},
15 "codex": {},
16 }
17 tests := []struct {
18 name string
19 proc *snapcodec.Process
20 allow map[string]struct{}
21 restore bool
22 command string
23 reason string
24 }{
25 {
26 name: "generic less allowed",
27 proc: &snapcodec.Process{Argv: []string{"/usr/bin/less", "README.md"}, Strategy: processStrategyGeneric},
28 restore: true,
29 command: "/usr/bin/less README.md",
30 },
31 {
32 name: "bash denied",
33 proc: &snapcodec.Process{Argv: []string{"bash", "-lc", "rm -rf /"}, Strategy: processStrategyGeneric},
34 reason: "not_allowlisted",
35 },
36 {
37 name: "pi with ID resumes",
38 proc: &snapcodec.Process{Argv: []string{"pi", "cli"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "session-123"}},
39 restore: true,
40 command: "pi --resume session-123",
41 },
42 {
43 name: "pi no ID continues",
44 proc: &snapcodec.Process{Argv: []string{"pi", "cli"}, Strategy: processStrategyPi},
45 restore: true,
46 command: "pi --continue",
47 },
48 {
49 name: "claude no ID continues",
50 proc: &snapcodec.Process{Argv: []string{"claude"}, Strategy: processStrategyClaude},
51 restore: true,
52 command: "claude --continue",
53 },
54 {
55 name: "opencode session command",
56 proc: &snapcodec.Process{Argv: []string{"opencode"}, Strategy: processStrategyOpenCode, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc"}},
57 restore: true,
58 command: "opencode --session abc",
59 },
60 {
61 name: "opencode no ID continues",
62 proc: &snapcodec.Process{Argv: []string{"opencode"}, Strategy: processStrategyOpenCode},
63 restore: true,
64 command: "opencode --continue",
65 },
66 {
67 name: "codex resume command",
68 proc: &snapcodec.Process{Argv: []string{"codex"}, Strategy: processStrategyCodex, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc"}},
69 restore: true,
70 command: "codex resume abc",
71 },
72 {
73 name: "codex no ID resumes last",
74 proc: &snapcodec.Process{Argv: []string{"codex"}, Strategy: processStrategyCodex},
75 restore: true,
76 command: "codex resume --last",
77 },
78 {
79 name: "agent strategy matches allowlist by strategy not argv basename",
80 proc: &snapcodec.Process{Argv: []string{"less"}, Strategy: processStrategyClaude, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc"}},
81 allow: map[string]struct{}{"less": {}},
82 reason: "not_allowlisted",
83 },
84 {
85 name: "agent ID with control byte rejected",
86 proc: &snapcodec.Process{Argv: []string{"pi", "cli"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "session\rmalicious"}},
87 reason: "invalid_agent_session_id",
88 },
89 {
90 name: "empty executable rejected",
91 proc: &snapcodec.Process{Argv: []string{""}, Strategy: processStrategyGeneric},
92 reason: "missing_process",
93 },
94 }
95
96 for _, tt := range tests {
97 t.Run(tt.name, func(t *testing.T) {
98 allow := tt.allow
99 if allow == nil {
100 allow = defaultAllow
101 }
102 got := planProcessRestore(tt.proc, allow)
103 if got.Restore != tt.restore || got.Command != tt.command || got.Reason != tt.reason {
104 t.Fatalf("planProcessRestore() = %+v, want restore=%v command=%q reason=%q", got, tt.restore, tt.command, tt.reason)
105 }
106 })
107 }
108}
109
110func TestExtractAgentSessionID(t *testing.T) {
111 tests := []struct {
112 name string
113 strategy string
114 argv []string
115 want string
116 }{
117 {name: "claude session flag extraction", strategy: processStrategyClaude, argv: []string{"claude", "--session-id", "uuid-123"}, want: "uuid-123"},
118 {name: "claude short resume extraction", strategy: processStrategyClaude, argv: []string{"claude", "-r", "uuid-456"}, want: "uuid-456"},
119 {name: "opencode short session flag extraction", strategy: processStrategyOpenCode, argv: []string{"opencode", "-s", "abc"}, want: "abc"},
120 {name: "codex resume extraction", strategy: processStrategyCodex, argv: []string{"codex", "resume", "abc"}, want: "abc"},
121 {name: "claude flag-looking ID rejected", strategy: processStrategyClaude, argv: []string{"claude", "--resume", "--continue"}},
122 {name: "pi flag-looking ID rejected", strategy: processStrategyPi, argv: []string{"pi", "-r", "--last"}},
123 {name: "control byte ID rejected", strategy: processStrategyPi, argv: []string{"pi", "--resume", "abc\nmalicious"}},
124 }
125
126 for _, tt := range tests {
127 t.Run(tt.name, func(t *testing.T) {
128 if got := extractAgentSessionID(tt.strategy, tt.argv); got != tt.want {
129 t.Fatalf("extractAgentSessionID() = %q, want %q", got, tt.want)
130 }
131 })
132 }
133}
134
135func TestShellQuoteArgv(t *testing.T) {
136 tests := []struct {
137 name string
138 argv []string
139 want string
140 ok bool
141 }{
142 {name: "empty argv", ok: false},
143 {name: "spaces and single quote", argv: []string{"cmd", "two words", "it's"}, want: "cmd 'two words' 'it'\\''s'", ok: true},
144 {name: "shell metacharacters", argv: []string{"cmd", "$(touch /tmp/pwned)", "a;b", "line\nbreak"}, want: "cmd '$(touch /tmp/pwned)' 'a;b' 'line\nbreak'", ok: true},
145 {name: "empty argument", argv: []string{"cmd", ""}, want: "cmd ''", ok: true},
146 }
147
148 for _, tt := range tests {
149 t.Run(tt.name, func(t *testing.T) {
150 got, ok := shellQuoteArgv(tt.argv)
151 if ok != tt.ok || got != tt.want {
152 t.Fatalf("shellQuoteArgv() = %q, %v; want %q, %v", got, ok, tt.want, tt.ok)
153 }
154 })
155 }
156}