v0.27.1 c78ce83

github_test.go RawHistoryFolder

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

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 9

Tags 42

↑↓ moveenter openesc close