1package remote
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "os/exec"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/bnema/vev/internal/adapters/sshstdio"
14 "github.com/bnema/vev/internal/ports"
15)
16
17func TestRemoteCatalogClientCommandConstruction(t *testing.T) {
18 t.Parallel()
19
20 tests := []struct {
21 name string
22 target string
23 }{
24 {name: "plain host", target: "arch"},
25 {name: "metacharacters stay single local arg", target: "user@host;touch/tmp/pwn"},
26 {name: "user at host", target: "build@mule"},
27 }
28
29 for _, tt := range tests {
30 t.Run(tt.name, func(t *testing.T) {
31 t.Parallel()
32 var gotPath string
33 var gotArgs []string
34 client := &CatalogClient{
35 command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
36 gotPath = name
37 gotArgs = append([]string(nil), args...)
38 return stdoutCmd(ctx, mustCatalogJSON(t, ports.RemoteCatalog{
39 ProtocolVersion: ports.ProtocolVersion,
40 Sessions: []ports.RemoteCatalogSession{},
41 }))
42 },
43 }
44
45 if _, err := client.List(context.Background(), tt.target); err != nil {
46 t.Fatalf("List() error = %v", err)
47 }
48
49 want := sshstdio.BuildCommandForRemoteCommand(tt.target, "vev", "cmd", "remote-catalog", "--json")
50 if gotPath != want.Path {
51 t.Fatalf("Path = %q, want %q", gotPath, want.Path)
52 }
53 if len(gotArgs) != len(want.Args) {
54 t.Fatalf("Args len = %d, want %d (%q)", len(gotArgs), len(want.Args), gotArgs)
55 }
56 for i := range want.Args {
57 if gotArgs[i] != want.Args[i] {
58 t.Fatalf("Args[%d] = %q, want %q (all %q)", i, gotArgs[i], want.Args[i], gotArgs)
59 }
60 }
61 if gotArgs[0] != "--" || gotArgs[1] != tt.target {
62 t.Fatalf("target must remain one unquoted local argv word after --; got %q", gotArgs)
63 }
64 if !strings.Contains(gotArgs[2], "'vev'") || !strings.Contains(gotArgs[2], "'remote-catalog'") {
65 t.Fatalf("remote command words must be shell-quoted; got %q", gotArgs[2])
66 }
67 })
68 }
69}
70
71func TestRemoteCatalogClientRejectsUnsafeTarget(t *testing.T) {
72 t.Parallel()
73 client := NewCatalogClient()
74 _, err := client.List(context.Background(), "bad host")
75 if err == nil {
76 t.Fatal("List() error = nil, want target validation error")
77 }
78}
79
80func TestRemoteCatalogClientDecode(t *testing.T) {
81 t.Parallel()
82
83 valid := ports.RemoteCatalog{
84 ProtocolVersion: ports.ProtocolVersion,
85 Sessions: []ports.RemoteCatalogSession{
86 {Name: "work", State: "up", Ephemeral: false, Tabs: 2, Attached: true},
87 },
88 }
89 validJSON := mustCatalogJSON(t, valid)
90
91 tests := []struct {
92 name string
93 stdout string
94 stderr string
95 exit int
96 want ports.RemoteCatalog
97 wantErr error
98 check func(t *testing.T, err error)
99 }{
100 {
101 name: "matching protocol version",
102 stdout: validJSON,
103 want: valid,
104 },
105 {
106 name: "trailing whitespace accepted",
107 stdout: validJSON + " \n\t",
108 want: valid,
109 },
110 {
111 name: "mismatching protocol version",
112 stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion + 1, Sessions: []ports.RemoteCatalogSession{}}),
113 check: func(t *testing.T, err error) {
114 t.Helper()
115 var mismatch *ports.RemoteCatalogVersionMismatchError
116 if !errors.As(err, &mismatch) {
117 t.Fatalf("error = %v, want RemoteCatalogVersionMismatchError", err)
118 }
119 if mismatch.Got != ports.ProtocolVersion+1 || mismatch.Want != ports.ProtocolVersion {
120 t.Fatalf("mismatch = %#v", mismatch)
121 }
122 },
123 },
124 {
125 name: "malformed json",
126 stdout: `{"protocol_version":`,
127 wantErr: errCatalogDecode,
128 },
129 {
130 name: "truncated json",
131 stdout: `{"protocol_version":19,"sessions":[`,
132 wantErr: errCatalogDecode,
133 },
134 {
135 name: "trailing non-whitespace",
136 stdout: strings.TrimSpace(validJSON) + `{"extra":true}`,
137 wantErr: errCatalogTrailing,
138 },
139 {
140 name: "missing protocol_version",
141 stdout: `{"sessions":[]}` + "\n",
142 wantErr: errCatalogRequired,
143 },
144 {
145 name: "missing sessions",
146 stdout: fmt.Sprintf(`{"protocol_version":%d}`+"\n", ports.ProtocolVersion),
147 wantErr: errCatalogRequired,
148 },
149 {
150 name: "invalid session name",
151 stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "bad name", State: "up"}}}),
152 wantErr: errCatalogSession,
153 },
154 {
155 name: "invalid session state",
156 stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "weird"}}}),
157 wantErr: errCatalogSession,
158 },
159 {
160 name: "remote exit with stderr",
161 stdout: "",
162 stderr: "connection refused",
163 exit: 255,
164 wantErr: errCatalogSSH,
165 },
166 }
167
168 for _, tt := range tests {
169 t.Run(tt.name, func(t *testing.T) {
170 t.Parallel()
171 client := &CatalogClient{
172 command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
173 if tt.exit != 0 {
174 cmd := exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("cat >&2; exit %d", tt.exit))
175 cmd.Stdin = strings.NewReader(tt.stderr)
176 return cmd
177 }
178 return stdoutCmd(ctx, tt.stdout)
179 },
180 }
181 got, err := client.List(context.Background(), "arch")
182 if tt.check != nil {
183 if err == nil {
184 t.Fatal("List() error = nil, want error")
185 }
186 tt.check(t, err)
187 return
188 }
189 if tt.wantErr != nil {
190 if !errors.Is(err, tt.wantErr) {
191 t.Fatalf("List() error = %v, want %v", err, tt.wantErr)
192 }
193 return
194 }
195 if err != nil {
196 t.Fatalf("List() error = %v", err)
197 }
198 if got.ProtocolVersion != tt.want.ProtocolVersion {
199 t.Fatalf("ProtocolVersion = %d, want %d", got.ProtocolVersion, tt.want.ProtocolVersion)
200 }
201 if len(got.Sessions) != len(tt.want.Sessions) {
202 t.Fatalf("Sessions len = %d, want %d", len(got.Sessions), len(tt.want.Sessions))
203 }
204 for i := range tt.want.Sessions {
205 if got.Sessions[i] != tt.want.Sessions[i] {
206 t.Fatalf("Sessions[%d] = %#v, want %#v", i, got.Sessions[i], tt.want.Sessions[i])
207 }
208 }
209 })
210 }
211}
212
213func TestRemoteCatalogClientContextCanceled(t *testing.T) {
214 t.Parallel()
215 ctx, cancel := context.WithCancel(context.Background())
216 cancel()
217 client := NewCatalogClient()
218 _, err := client.List(ctx, "arch")
219 if !errors.Is(err, context.Canceled) {
220 t.Fatalf("List() error = %v, want context.Canceled", err)
221 }
222}
223
224func TestRemoteCatalogClientAppliesLifetimeAndWaitDelay(t *testing.T) {
225 t.Parallel()
226
227 var commandCtx context.Context
228 var cmd *exec.Cmd
229 client := &CatalogClient{
230 command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
231 commandCtx = ctx
232 cmd = stdoutCmd(ctx, mustCatalogJSON(t, ports.RemoteCatalog{
233 ProtocolVersion: ports.ProtocolVersion,
234 Sessions: []ports.RemoteCatalogSession{},
235 }))
236 return cmd
237 },
238 }
239 if _, err := client.List(context.Background(), "arch"); err != nil {
240 t.Fatalf("List() error = %v", err)
241 }
242 deadline, ok := commandCtx.Deadline()
243 if !ok {
244 t.Fatal("command context missing default lifetime deadline")
245 }
246 if remaining := time.Until(deadline); remaining <= 0 || remaining > catalogCommandTimeout {
247 t.Fatalf("deadline remaining = %v, want within (0, %v]", remaining, catalogCommandTimeout)
248 }
249 if cmd.WaitDelay != catalogCommandWaitDelay {
250 t.Fatalf("WaitDelay = %v, want %v", cmd.WaitDelay, catalogCommandWaitDelay)
251 }
252}
253
254func TestRemoteCatalogClientClassifiesRunContextErrors(t *testing.T) {
255 tests := []struct {
256 name string
257 newContext func() (context.Context, context.CancelFunc)
258 timeout time.Duration
259 cancel bool
260 want error
261 }{
262 {
263 name: "own deadline",
264 newContext: func() (context.Context, context.CancelFunc) {
265 return context.WithCancel(context.Background())
266 },
267 timeout: 20 * time.Millisecond,
268 want: context.DeadlineExceeded,
269 },
270 {
271 name: "parent deadline",
272 newContext: func() (context.Context, context.CancelFunc) {
273 return context.WithTimeout(context.Background(), 20*time.Millisecond)
274 },
275 timeout: time.Minute,
276 want: context.DeadlineExceeded,
277 },
278 {
279 name: "parent cancellation",
280 newContext: func() (context.Context, context.CancelFunc) {
281 return context.WithCancel(context.Background())
282 },
283 timeout: time.Minute,
284 cancel: true,
285 want: context.Canceled,
286 },
287 }
288
289 for _, tt := range tests {
290 t.Run(tt.name, func(t *testing.T) {
291 ctx, cancel := tt.newContext()
292 defer cancel()
293 started := make(chan struct{})
294 client := &CatalogClient{
295 timeout: tt.timeout,
296 command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
297 close(started)
298 return exec.CommandContext(ctx, "sleep", "10")
299 },
300 }
301
302 errCh := make(chan error, 1)
303 go func() {
304 _, err := client.List(ctx, "arch")
305 errCh <- err
306 }()
307 select {
308 case <-started:
309 case <-time.After(2 * time.Second):
310 t.Fatal("command did not start")
311 }
312 if tt.cancel {
313 cancel()
314 }
315 select {
316 case err := <-errCh:
317 if !errors.Is(err, tt.want) {
318 t.Fatalf("List() error = %v, want %v", err, tt.want)
319 }
320 case <-time.After(2 * time.Second):
321 t.Fatal("List() did not return after context ended")
322 }
323 })
324 }
325}
326
327func TestCatalogOutputSafety(t *testing.T) {
328 t.Parallel()
329
330 tests := []struct {
331 name string
332 command func(ctx context.Context) *exec.Cmd
333 wantErr error
334 check func(t *testing.T, err error)
335 }{
336 {
337 name: "oversized stdout",
338 command: func(ctx context.Context) *exec.Cmd {
339 return exec.CommandContext(ctx, "sh", "-c",
340 fmt.Sprintf("head -c %d /dev/zero", maxCatalogBytes+1))
341 },
342 wantErr: errCatalogTooLarge,
343 check: func(t *testing.T, err error) {
344 t.Helper()
345 if strings.Contains(err.Error(), strings.Repeat("\x00", 64)) {
346 t.Fatalf("error embeds oversized stdout: %v", err)
347 }
348 },
349 },
350 {
351 name: "oversized stderr",
352 command: func(ctx context.Context) *exec.Cmd {
353 return exec.CommandContext(ctx, "sh", "-c",
354 fmt.Sprintf("head -c %d /dev/zero >&2; exit 1", maxCatalogDiagnosticBytes+1))
355 },
356 wantErr: errCatalogTooLarge,
357 check: func(t *testing.T, err error) {
358 t.Helper()
359 if strings.Contains(err.Error(), strings.Repeat("\x00", 64)) {
360 t.Fatalf("error embeds oversized stderr: %v", err)
361 }
362 if errors.Is(err, errCatalogSSH) {
363 t.Fatalf("oversized stderr must not wrap SSH with captured output: %v", err)
364 }
365 },
366 },
367 {
368 name: "ansi control stderr",
369 command: func(ctx context.Context) *exec.Cmd {
370 cmd := exec.CommandContext(ctx, "sh", "-c", "cat >&2; exit 255")
371 cmd.Stdin = strings.NewReader("\x1b[31mconnection refused\x1b[0m\x07")
372 return cmd
373 },
374 wantErr: errCatalogSSH,
375 check: func(t *testing.T, err error) {
376 t.Helper()
377 msg := err.Error()
378 if strings.ContainsRune(msg, '\x1b') || strings.ContainsRune(msg, '\x07') {
379 t.Fatalf("error retains control/ANSI bytes: %q", msg)
380 }
381 if strings.Contains(msg, "[31m") || strings.Contains(msg, "[0m") {
382 t.Fatalf("error retains terminal escape remnants: %q", msg)
383 }
384 if !strings.Contains(msg, "connection refused") {
385 t.Fatalf("error = %q, want printable diagnostic text preserved", msg)
386 }
387 },
388 },
389 }
390
391 for _, tt := range tests {
392 t.Run(tt.name, func(t *testing.T) {
393 t.Parallel()
394 client := &CatalogClient{
395 command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
396 return tt.command(ctx)
397 },
398 }
399 _, err := client.List(context.Background(), "arch")
400 if !errors.Is(err, tt.wantErr) {
401 t.Fatalf("List() error = %v, want %v", err, tt.wantErr)
402 }
403 if tt.check != nil {
404 tt.check(t, err)
405 }
406 })
407 }
408}
409
410func TestBoundedBufferLimits(t *testing.T) {
411 t.Parallel()
412
413 tests := []struct {
414 name string
415 limit int
416 chunks [][]byte
417 wantLen int
418 wantOver bool
419 wantBytes string
420 }{
421 {
422 name: "under limit",
423 limit: 8,
424 chunks: [][]byte{[]byte("abcd"), []byte("ef")},
425 wantLen: 6,
426 wantOver: false,
427 wantBytes: "abcdef",
428 },
429 {
430 name: "exact limit",
431 limit: 4,
432 chunks: [][]byte{[]byte("abcd")},
433 wantLen: 4,
434 wantOver: false,
435 wantBytes: "abcd",
436 },
437 {
438 name: "overflow discards remainder",
439 limit: 4,
440 chunks: [][]byte{[]byte("abc"), []byte("defgh")},
441 wantLen: 4,
442 wantOver: true,
443 wantBytes: "abcd",
444 },
445 {
446 name: "further writes stay discarded",
447 limit: 2,
448 chunks: [][]byte{[]byte("abcdef"), []byte("xyz")},
449 wantLen: 2,
450 wantOver: true,
451 wantBytes: "ab",
452 },
453 }
454
455 for _, tt := range tests {
456 t.Run(tt.name, func(t *testing.T) {
457 t.Parallel()
458 var buf boundedBuffer
459 buf.limit = tt.limit
460 for _, chunk := range tt.chunks {
461 n, err := buf.Write(chunk)
462 if err != nil {
463 t.Fatalf("Write(%q) error = %v", chunk, err)
464 }
465 if n != len(chunk) {
466 t.Fatalf("Write(%q) n = %d, want %d", chunk, n, len(chunk))
467 }
468 }
469 if buf.overflow != tt.wantOver {
470 t.Fatalf("overflow = %v, want %v", buf.overflow, tt.wantOver)
471 }
472 if got := len(buf.Bytes()); got != tt.wantLen {
473 t.Fatalf("len = %d, want %d", got, tt.wantLen)
474 }
475 if got := string(buf.Bytes()); got != tt.wantBytes {
476 t.Fatalf("bytes = %q, want %q", got, tt.wantBytes)
477 }
478 })
479 }
480}
481
482func stdoutCmd(ctx context.Context, stdout string) *exec.Cmd {
483 cmd := exec.CommandContext(ctx, "cat")
484 cmd.Stdin = strings.NewReader(stdout)
485 return cmd
486}
487
488func mustCatalogJSON(t *testing.T, catalog ports.RemoteCatalog) string {
489 t.Helper()
490 raw, err := json.Marshal(catalog)
491 if err != nil {
492 t.Fatalf("Marshal: %v", err)
493 }
494 return string(raw) + "\n"
495}