v0.33.2 57f21d6

github_test.go RawHistoryFolder

1package updater
2
3import (
4	"context"
5	"crypto/sha256"
6	"encoding/hex"
7	"errors"
8	"io"
9	"net"
10	"net/http"
11	"os"
12	"path/filepath"
13	"strings"
14	"sync/atomic"
15	"syscall"
16	"testing"
17	"time"
18
19	"github.com/bnema/dumber/internal/application/port"
20)
21
22func TestCompareVersions(t *testing.T) {
23	tests := []struct {
24		name     string
25		v1       string
26		v2       string
27		expected int
28	}{
29		// Equal versions
30		{"equal simple", "1.0.0", "1.0.0", 0},
31		{"equal two parts", "1.0", "1.0", 0},
32		{"equal one part", "1", "1", 0},
33
34		// v1 < v2 (should update)
35		{"major less", "1.0.0", "2.0.0", -1},
36		{"minor less", "1.1.0", "1.2.0", -1},
37		{"patch less", "1.1.1", "1.1.2", -1},
38		{"complex less", "0.20.1", "0.21.0", -1},
39		{"real world less", "0.20.1", "0.20.2", -1},
40
41		// v1 > v2 (no update needed)
42		{"major greater", "2.0.0", "1.0.0", 1},
43		{"minor greater", "1.2.0", "1.1.0", 1},
44		{"patch greater", "1.1.2", "1.1.1", 1},
45
46		// Pre-release versions (suffix stripped)
47		{"prerelease equal", "1.0.0-alpha", "1.0.0", 0},
48		{"prerelease less", "1.0.0-alpha", "1.0.1", -1},
49		{"prerelease greater", "1.0.1-beta", "1.0.0", 1},
50
51		// Partial versions
52		{"partial v1", "1", "1.0.0", 0},
53		{"partial v2", "1.0.0", "1", 0},
54		{"partial less", "1", "2.0.0", -1},
55		{"partial greater", "2", "1.0.0", 1},
56
57		// Edge cases
58		{"zero versions", "0.0.0", "0.0.0", 0},
59		{"zero less", "0.0.0", "0.0.1", -1},
60	}
61
62	for _, tt := range tests {
63		t.Run(tt.name, func(t *testing.T) {
64			result := compareVersions(tt.v1, tt.v2)
65			if result != tt.expected {
66				t.Errorf("compareVersions(%q, %q) = %d, want %d", tt.v1, tt.v2, result, tt.expected)
67			}
68		})
69	}
70}
71
72func TestGetArchName(t *testing.T) {
73	// This test just ensures the function doesn't panic and returns something.
74	arch := getArchName()
75	if arch == "" {
76		t.Error("getArchName() returned empty string")
77	}
78}
79
80func TestValidateDownloadURL(t *testing.T) {
81	tests := []struct {
82		name    string
83		url     string
84		wantErr bool
85		errMsg  string
86	}{
87		// Valid URLs
88		{
89			name:    "valid latest release URL",
90			url:     "https://github.com/bnema/dumber/releases/latest/download/dumber_linux_x86_64.tar.gz",
91			wantErr: false,
92		},
93		{
94			name:    "valid versioned release URL",
95			url:     "https://github.com/bnema/dumber/releases/download/v0.15.0/dumber_linux_x86_64.tar.gz",
96			wantErr: false,
97		},
98
99		// Invalid scheme
100		{
101			name:    "http not allowed",
102			url:     "http://github.com/bnema/dumber/releases/latest/download/dumber_linux_x86_64.tar.gz",
103			wantErr: true,
104			errMsg:  "must use HTTPS",
105		},
106		{
107			name:    "ftp not allowed",
108			url:     "ftp://github.com/bnema/dumber/releases/latest/download/file.tar.gz",
109			wantErr: true,
110			errMsg:  "must use HTTPS",
111		},
112
113		// Invalid host
114		{
115			name:    "wrong host",
116			url:     "https://evil.com/bnema/dumber/releases/latest/download/file.tar.gz",
117			wantErr: true,
118			errMsg:  "must be from github.com",
119		},
120		{
121			name:    "subdomain not allowed",
122			url:     "https://raw.github.com/bnema/dumber/releases/latest/download/file.tar.gz",
123			wantErr: true,
124			errMsg:  "must be from github.com",
125		},
126		{
127			name:    "typosquat domain",
128			url:     "https://githuh.com/bnema/dumber/releases/latest/download/file.tar.gz",
129			wantErr: true,
130			errMsg:  "must be from github.com",
131		},
132
133		// Invalid path
134		{
135			name:    "not a releases URL",
136			url:     "https://github.com/bnema/dumber/raw/main/README.md",
137			wantErr: true,
138			errMsg:  "must be a GitHub releases URL",
139		},
140		{
141			name:    "blob URL not allowed",
142			url:     "https://github.com/bnema/dumber/blob/main/cmd/dumber/main.go",
143			wantErr: true,
144			errMsg:  "must be a GitHub releases URL",
145		},
146
147		// Malformed URLs
148		{
149			name:    "empty URL",
150			url:     "",
151			wantErr: true,
152		},
153		{
154			name:    "invalid URL format",
155			url:     "://not-a-url",
156			wantErr: true,
157		},
158	}
159
160	for _, tt := range tests {
161		t.Run(tt.name, func(t *testing.T) {
162			err := validateDownloadURL(tt.url)
163			if tt.wantErr {
164				if err == nil {
165					t.Errorf("validateDownloadURL(%q) expected error, got nil", tt.url)
166				} else if tt.errMsg != "" && !contains(err.Error(), tt.errMsg) {
167					t.Errorf("validateDownloadURL(%q) error = %q, want to contain %q", tt.url, err.Error(), tt.errMsg)
168				}
169			} else {
170				if err != nil {
171					t.Errorf("validateDownloadURL(%q) unexpected error: %v", tt.url, err)
172				}
173			}
174		})
175	}
176}
177
178func TestSanitizeTarPath(t *testing.T) {
179	destDir := "/tmp/extract"
180
181	tests := []struct {
182		name     string
183		path     string
184		wantErr  bool
185		errMsg   string
186		wantPath string
187	}{
188		// Valid paths
189		{
190			name:     "simple filename",
191			path:     "dumber",
192			wantErr:  false,
193			wantPath: "/tmp/extract/dumber",
194		},
195		{
196			name:     "nested path",
197			path:     "dumber_v0.15.0/dumber",
198			wantErr:  false,
199			wantPath: "/tmp/extract/dumber_v0.15.0/dumber",
200		},
201		{
202			name:     "deep nested path",
203			path:     "release/bin/dumber",
204			wantErr:  false,
205			wantPath: "/tmp/extract/release/bin/dumber",
206		},
207
208		// Path traversal attempts
209		{
210			name:    "parent directory escape",
211			path:    "../../../etc/passwd",
212			wantErr: true,
213			errMsg:  "path traversal",
214		},
215		{
216			name:    "hidden parent escape",
217			path:    "dumber_v0.15.0/../../etc/passwd",
218			wantErr: true,
219			errMsg:  "path traversal",
220		},
221		{
222			name:     "double dot at start of filename",
223			path:     "..dumber",
224			wantErr:  false, // Valid filename, not a path traversal (.. is not a path component)
225			wantPath: "/tmp/extract/..dumber",
226		},
227		{
228			name:    "absolute path unix",
229			path:    "/etc/passwd",
230			wantErr: true,
231			errMsg:  "absolute path",
232		},
233		{
234			name:    "dot dot in middle",
235			path:    "foo/../../../bar",
236			wantErr: true,
237			errMsg:  "path traversal",
238		},
239
240		// Edge cases
241		{
242			name:     "current directory",
243			path:     "./dumber",
244			wantErr:  false,
245			wantPath: "/tmp/extract/dumber",
246		},
247		{
248			name:     "multiple slashes",
249			path:     "dumber_v0.15.0//dumber",
250			wantErr:  false,
251			wantPath: "/tmp/extract/dumber_v0.15.0/dumber",
252		},
253	}
254
255	for _, tt := range tests {
256		t.Run(tt.name, func(t *testing.T) {
257			result, err := sanitizeTarPath(tt.path, destDir)
258			if tt.wantErr {
259				if err == nil {
260					t.Errorf("sanitizeTarPath(%q, %q) expected error, got nil", tt.path, destDir)
261				} else if tt.errMsg != "" && !contains(err.Error(), tt.errMsg) {
262					t.Errorf("sanitizeTarPath(%q, %q) error = %q, want to contain %q", tt.path, destDir, err.Error(), tt.errMsg)
263				}
264			} else {
265				if err != nil {
266					t.Errorf("sanitizeTarPath(%q, %q) unexpected error: %v", tt.path, destDir, err)
267				} else if tt.wantPath != "" && result != tt.wantPath {
268					t.Errorf("sanitizeTarPath(%q, %q) = %q, want %q", tt.path, destDir, result, tt.wantPath)
269				}
270			}
271		})
272	}
273}
274
275func TestVerifyChecksum(t *testing.T) {
276	// Create a temporary file with known content
277	tmpDir := t.TempDir()
278	testFile := filepath.Join(tmpDir, "testfile")
279	testContent := []byte("hello world\n")
280
281	if err := os.WriteFile(testFile, testContent, 0o644); err != nil {
282		t.Fatalf("Failed to create test file: %v", err)
283	}
284
285	// Calculate expected SHA256 hash
286	hash := sha256.Sum256(testContent)
287	expectedHash := hex.EncodeToString(hash[:])
288
289	tests := []struct {
290		name     string
291		filePath string
292		hash     string
293		wantErr  bool
294		errMsg   string
295	}{
296		{
297			name:     "valid checksum lowercase",
298			filePath: testFile,
299			hash:     expectedHash,
300			wantErr:  false,
301		},
302		{
303			name:     "valid checksum uppercase",
304			filePath: testFile,
305			hash:     hex.EncodeToString(hash[:]),
306			wantErr:  false,
307		},
308		{
309			name:     "invalid checksum",
310			filePath: testFile,
311			hash:     "0000000000000000000000000000000000000000000000000000000000000000",
312			wantErr:  true,
313			errMsg:   "checksum mismatch",
314		},
315		{
316			name:     "wrong length hash",
317			filePath: testFile,
318			hash:     "abc123",
319			wantErr:  true,
320			errMsg:   "checksum mismatch",
321		},
322		{
323			name:     "file not found",
324			filePath: filepath.Join(tmpDir, "nonexistent"),
325			hash:     expectedHash,
326			wantErr:  true,
327			errMsg:   "failed to open",
328		},
329	}
330
331	for _, tt := range tests {
332		t.Run(tt.name, func(t *testing.T) {
333			err := verifyChecksum(tt.filePath, tt.hash)
334			if tt.wantErr {
335				if err == nil {
336					t.Errorf("verifyChecksum() expected error, got nil")
337				} else if tt.errMsg != "" && !contains(err.Error(), tt.errMsg) {
338					t.Errorf("verifyChecksum() error = %q, want to contain %q", err.Error(), tt.errMsg)
339				}
340			} else {
341				if err != nil {
342					t.Errorf("verifyChecksum() unexpected error: %v", err)
343				}
344			}
345		})
346	}
347}
348
349func TestSizeLimits(t *testing.T) {
350	// Verify our size constants are sensible
351	if maxArchiveSize <= 0 {
352		t.Error("maxArchiveSize should be positive")
353	}
354	if maxBinarySize <= 0 {
355		t.Error("maxBinarySize should be positive")
356	}
357	if minBinarySize <= 0 {
358		t.Error("minBinarySize should be positive")
359	}
360	if minBinarySize >= maxBinarySize {
361		t.Errorf("minBinarySize (%d) should be less than maxBinarySize (%d)", minBinarySize, maxBinarySize)
362	}
363	if maxBinarySize >= maxArchiveSize {
364		t.Errorf("maxBinarySize (%d) should be less than maxArchiveSize (%d)", maxBinarySize, maxArchiveSize)
365	}
366}
367
368func TestRetryDelayForAttempt(t *testing.T) {
369	tests := []struct {
370		name    string
371		attempt int
372		want    time.Duration
373	}{
374		{name: "first attempt", attempt: 1, want: retryBaseDelay},
375		{name: "second attempt", attempt: 2, want: retryBaseDelay * 2},
376		{name: "third attempt", attempt: 3, want: retryBaseDelay * 4},
377		{name: "capped attempt", attempt: 6, want: retryMaxDelay},
378	}
379
380	for _, tt := range tests {
381		t.Run(tt.name, func(t *testing.T) {
382			got := retryDelayForAttempt(tt.attempt, func(_ int64) int64 { return 0 })
383			if got != tt.want {
384				t.Fatalf("retryDelayForAttempt(%d) = %v, want %v", tt.attempt, got, tt.want)
385			}
386		})
387	}
388}
389
390func TestRetryDelayForAttemptAddsJitter(t *testing.T) {
391	got := retryDelayForAttempt(1, func(_ int64) int64 { return int64(50 * time.Millisecond) })
392	want := retryBaseDelay + (50 * time.Millisecond)
393	if got != want {
394		t.Fatalf("retryDelayForAttempt with jitter = %v, want %v", got, want)
395	}
396}
397
398func TestIsRetryableRequestError(t *testing.T) {
399	tests := []struct {
400		name string
401		err  error
402		want bool
403	}{
404		{name: "nil", err: nil, want: false},
405		{name: "context canceled", err: context.Canceled, want: false},
406		{name: "context deadline exceeded", err: context.DeadlineExceeded, want: true},
407		{name: "timeout net error", err: &net.DNSError{IsTimeout: true}, want: true},
408		{name: "temporary dns error", err: &net.DNSError{IsTemporary: true}, want: true},
409		{name: "non-timeout net error", err: &net.DNSError{Err: "lookup failed"}, want: false},
410		{name: "transient errno through net op error", err: &net.OpError{Op: "read", Net: "tcp", Err: syscall.ECONNRESET}, want: true},
411		{name: "non-transient errno", err: syscall.EINVAL, want: false},
412		{name: "ordinary error", err: errors.New("ordinary"), want: false},
413	}
414
415	for _, tt := range tests {
416		t.Run(tt.name, func(t *testing.T) {
417			if got := isRetryableRequestError(tt.err); got != tt.want {
418				t.Fatalf("isRetryableRequestError(%v) = %v, want %v", tt.err, got, tt.want)
419			}
420		})
421	}
422}
423
424func TestGitHubCheckerDoRequestWithRetry_RetryableStatus(t *testing.T) {
425	var calls int32
426	tr := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
427		attempt := atomic.AddInt32(&calls, 1)
428		if attempt < maxRetryAttempts {
429			return testResponse(http.StatusForbidden, "forbidden"), nil
430		}
431		return testResponse(http.StatusOK, `{"tag_name":"v1.2.3"}`), nil
432	})
433
434	checker := NewGitHubChecker()
435	checker.client = &http.Client{Transport: tr}
436	checker.randInt63 = func(_ int64) int64 { return 0 }
437	checker.sleep = func(context.Context, time.Duration) error { return nil }
438
439	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, githubAPIURL, http.NoBody)
440	if err != nil {
441		t.Fatalf("failed to create request: %v", err)
442	}
443
444	resp, err := checker.doRequestWithRetry(context.Background(), req)
445	if err != nil {
446		t.Fatalf("doRequestWithRetry() unexpected error: %v", err)
447	}
448	defer func() { _ = resp.Body.Close() }()
449
450	if resp.StatusCode != http.StatusOK {
451		t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
452	}
453	if atomic.LoadInt32(&calls) != maxRetryAttempts {
454		t.Fatalf("calls = %d, want %d", calls, maxRetryAttempts)
455	}
456}
457
458func TestGitHubCheckerDoRequestWithRetry_RetryableRequestError(t *testing.T) {
459	var calls int32
460	tr := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
461		attempt := atomic.AddInt32(&calls, 1)
462		if attempt == 1 {
463			return nil, &net.OpError{Op: "read", Net: "tcp", Err: syscall.ECONNRESET}
464		}
465		return testResponse(http.StatusOK, `{"tag_name":"v1.2.3"}`), nil
466	})
467
468	checker := NewGitHubChecker()
469	checker.client = &http.Client{Transport: tr}
470	checker.randInt63 = func(_ int64) int64 { return 0 }
471	checker.sleep = func(context.Context, time.Duration) error { return nil }
472
473	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, githubAPIURL, http.NoBody)
474	if err != nil {
475		t.Fatalf("failed to create request: %v", err)
476	}
477
478	resp, err := checker.doRequestWithRetry(context.Background(), req)
479	if err != nil {
480		t.Fatalf("doRequestWithRetry() unexpected error: %v", err)
481	}
482	defer func() { _ = resp.Body.Close() }()
483
484	if resp.StatusCode != http.StatusOK {
485		t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
486	}
487	if atomic.LoadInt32(&calls) != 2 {
488		t.Fatalf("calls = %d, want 2", calls)
489	}
490}
491
492func TestGitHubCheckerDoRequestWithRetry_NoRetryOnNonRetryableRequestError(t *testing.T) {
493	var calls int32
494	wantErr := errors.New("ordinary request failure")
495	tr := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
496		atomic.AddInt32(&calls, 1)
497		return nil, wantErr
498	})
499
500	checker := NewGitHubChecker()
501	checker.client = &http.Client{Transport: tr}
502	checker.randInt63 = func(_ int64) int64 { return 0 }
503	checker.sleep = func(context.Context, time.Duration) error { return nil }
504
505	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, githubAPIURL, http.NoBody)
506	if err != nil {
507		t.Fatalf("failed to create request: %v", err)
508	}
509
510	resp, err := checker.doRequestWithRetry(context.Background(), req)
511	if err == nil {
512		if resp != nil && resp.Body != nil {
513			_ = resp.Body.Close()
514		}
515		t.Fatal("doRequestWithRetry() returned nil error, want request failure")
516	}
517	if !errors.Is(err, wantErr) {
518		t.Fatalf("doRequestWithRetry() error = %v, want %v", err, wantErr)
519	}
520	if atomic.LoadInt32(&calls) != 1 {
521		t.Fatalf("calls = %d, want 1", calls)
522	}
523}
524
525func TestGitHubCheckerCheckForUpdate_TransientError(t *testing.T) {
526	var calls int32
527	tr := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
528		atomic.AddInt32(&calls, 1)
529		return testResponse(http.StatusForbidden, "forbidden"), nil
530	})
531
532	checker := NewGitHubChecker()
533	checker.client = &http.Client{Transport: tr}
534	checker.randInt63 = func(_ int64) int64 { return 0 }
535	checker.sleep = func(context.Context, time.Duration) error { return nil }
536
537	_, err := checker.CheckForUpdate(context.Background(), "1.0.0")
538	if err == nil {
539		t.Fatal("expected error, got nil")
540	}
541	if !errors.Is(err, port.ErrUpdateCheckTransient) {
542		t.Fatalf("expected transient error, got %v", err)
543	}
544	if atomic.LoadInt32(&calls) != maxRetryAttempts {
545		t.Fatalf("calls = %d, want %d", calls, maxRetryAttempts)
546	}
547}
548
549func TestGitHubDownloaderDoRequestWithRetry_NoRetryOnClientError(t *testing.T) {
550	var calls int32
551	tr := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
552		atomic.AddInt32(&calls, 1)
553		return testResponse(http.StatusNotFound, "not found"), nil
554	})
555
556	downloader := NewGitHubDownloader()
557	downloader.client = &http.Client{Transport: tr}
558	downloader.randInt63 = func(_ int64) int64 { return 0 }
559	downloader.sleep = func(context.Context, time.Duration) error { return nil }
560
561	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, checksumsURLTemplate, http.NoBody)
562	if err != nil {
563		t.Fatalf("failed to create request: %v", err)
564	}
565
566	resp, err := downloader.doRequestWithRetry(context.Background(), req)
567	if err != nil {
568		t.Fatalf("doRequestWithRetry() unexpected error: %v", err)
569	}
570	defer func() { _ = resp.Body.Close() }()
571
572	if resp.StatusCode != http.StatusNotFound {
573		t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusNotFound)
574	}
575	if atomic.LoadInt32(&calls) != 1 {
576		t.Fatalf("calls = %d, want 1", calls)
577	}
578}
579
580// contains checks if substr is in s (helper for error message checks).
581func contains(s, substr string) bool {
582	return strings.Contains(s, substr)
583}
584
585type roundTripFunc func(*http.Request) (*http.Response, error)
586
587func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
588	return f(req)
589}
590
591func testResponse(status int, body string) *http.Response {
592	return &http.Response{
593		StatusCode: status,
594		Body:       io.NopCloser(strings.NewReader(body)),
595		Header:     make(http.Header),
596	}
597}

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 8

Tags 42

↑↓ moveenter openesc close