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}