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}