v0.3.0 99dc266

catalog_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close