3
import (
4
"bufio"
5
"bytes"
6
+ "context"
7
+ "errors"
8
"fmt"
9
+ "net/http"
10
+ "net/http/httptest"
11
"os"
12
"path/filepath"
13
"runtime"
14
"strings"
15
+ "sync"
16
+ "sync/atomic"
17
"testing"
18
)
19
164
}
165
}
166
167
+// TestHttpFetcherUserAgent guards against a regression where NewHttpFetcher
168
+// accepts a userAgent parameter but forgets to store it on the struct,
169
+// silently sending Go's default "Go-http-client/1.1" instead of the
170
+// migration agent string.
171
+func TestHttpFetcherUserAgent(t *testing.T) {
172
+ const wantUA = "kubo/migration"
173
+
174
+ var gotUA string
175
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
176
+ gotUA = r.Header.Get("User-Agent")
177
+ w.WriteHeader(http.StatusNotFound)
178
+ }))
179
+ defer srv.Close()
180
+
181
+ fetcher := NewHttpFetcher("/ipfs/bafyreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy", srv.URL, wantUA, 0)
182
+ _, _ = fetcher.Fetch(t.Context(), "/anything")
183
+
184
+ if gotUA != wantUA {
185
+ t.Fatalf("User-Agent: got %q, want %q", gotUA, wantUA)
186
+ }
187
+}
188
+
189
+// TestMigrationDownloadSourcesFailover is an end-to-end check that two
190
+// gateways listed in Migration.DownloadSources (here passed straight into
191
+// GetMigrationFetcher, the same path ReadMigrationConfig feeds) cooperate via
192
+// MultiFetcher: when the first gateway either errors with 404 or returns
193
+// bytes that don't parse as a CAR, the second gateway is attempted and the
194
+// migration data flows through.
195
+func TestMigrationDownloadSourcesFailover(t *testing.T) {
196
+ ctx := t.Context()
197
+
198
+ t.Run("first gateway returns 404", func(t *testing.T) {
199
+ var badHits atomic.Int64
200
+ bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
201
+ badHits.Add(1)
202
+ http.Error(w, "not found", http.StatusNotFound)
203
+ }))
204
+ defer bad.Close()
205
+
206
+ // Migration.DownloadSources order: bad first, real test gateway second.
207
+ fetcher, err := GetMigrationFetcher([]string{bad.URL, testServer.URL}, testIpfsDist, nil)
208
+ if err != nil {
209
+ t.Fatalf("GetMigrationFetcher: %v", err)
210
+ }
211
+ defer fetcher.Close()
212
+
213
+ out, err := fetcher.Fetch(ctx, "/kubo/versions")
214
+ if err != nil {
215
+ t.Fatalf("expected failover to second gateway, got: %v", err)
216
+ }
217
+ if len(out) < 6 {
218
+ t.Fatalf("second gateway should have served the versions file, got %d bytes", len(out))
219
+ }
220
+ if badHits.Load() == 0 {
221
+ t.Fatal("first gateway was never tried; the failover path did not actually run")
222
+ }
223
+ })
224
+
225
+ t.Run("first gateway returns invalid CAR bytes", func(t *testing.T) {
226
+ var badHits atomic.Int64
227
+ bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
228
+ badHits.Add(1)
229
+ w.Header().Set("Content-Type", "application/vnd.ipld.car")
230
+ w.WriteHeader(http.StatusOK)
231
+ _, _ = w.Write([]byte("this is definitely not a valid CAR file"))
232
+ }))
233
+ defer bad.Close()
234
+
235
+ fetcher, err := GetMigrationFetcher([]string{bad.URL, testServer.URL}, testIpfsDist, nil)
236
+ if err != nil {
237
+ t.Fatalf("GetMigrationFetcher: %v", err)
238
+ }
239
+ defer fetcher.Close()
240
+
241
+ out, err := fetcher.Fetch(ctx, "/kubo/versions")
242
+ if err != nil {
243
+ t.Fatalf("expected failover to second gateway, got: %v", err)
244
+ }
245
+ if len(out) < 6 {
246
+ t.Fatalf("second gateway should have served the versions file, got %d bytes", len(out))
247
+ }
248
+ if badHits.Load() == 0 {
249
+ t.Fatal("first gateway was never tried; the failover path did not actually run")
250
+ }
251
+ })
252
+}
253
+
254
func TestMultiFetcher(t *testing.T) {
255
ctx := t.Context()
256
268
fmt.Println("unexpected more data")
269
}
270
}
271
+
272
+// TestMultiFetcherQuarantine verifies that a fetcher which fails once is
273
+// moved to the back of the rotation on subsequent calls, so a dead gateway
274
+// does not cost the full HTTP timeout on every parallel migration download.
275
+func TestMultiFetcherQuarantine(t *testing.T) {
276
+ ctx := t.Context()
277
+
278
+ tracker := &countingFetcher{}
279
+ good := NewHttpFetcher(testIpfsDist, testServer.URL, "", 0)
280
+ mf := NewMultiFetcher(tracker, good)
281
+
282
+ // First call: tracker is healthy, gets tried first, fails. good takes over.
283
+ if _, err := mf.Fetch(ctx, "/kubo/versions"); err != nil {
284
+ t.Fatalf("first fetch: %v", err)
285
+ }
286
+ if got := tracker.calls.Load(); got != 1 {
287
+ t.Fatalf("expected tracker to be tried once, got %d", got)
288
+ }
289
+
290
+ // Second call: tracker is quarantined and must not be tried while good
291
+ // is still healthy.
292
+ if _, err := mf.Fetch(ctx, "/kubo/versions"); err != nil {
293
+ t.Fatalf("second fetch: %v", err)
294
+ }
295
+ if got := tracker.calls.Load(); got != 1 {
296
+ t.Fatalf("expected tracker to stay quarantined, got %d calls", got)
297
+ }
298
+}
299
+
300
+// TestMultiFetcherQuarantineReset verifies that when every fetcher fails in a
301
+// single Fetch call, the quarantine resets so the next call retries all
302
+// fetchers from scratch rather than inheriting a fully poisoned set.
303
+func TestMultiFetcherQuarantineReset(t *testing.T) {
304
+ ctx := t.Context()
305
+
306
+ a := &countingFetcher{}
307
+ b := &countingFetcher{}
308
+ mf := NewMultiFetcher(a, b)
309
+
310
+ if _, err := mf.Fetch(ctx, "/anything"); err == nil {
311
+ t.Fatal("expected error when all fetchers fail")
312
+ }
313
+ if ca, cb := a.calls.Load(), b.calls.Load(); ca != 1 || cb != 1 {
314
+ t.Fatalf("first call: expected each fetcher tried once, got a=%d b=%d", ca, cb)
315
+ }
316
+
317
+ if _, err := mf.Fetch(ctx, "/anything"); err == nil {
318
+ t.Fatal("expected error when all fetchers fail")
319
+ }
320
+ // After the total wipeout, both should have been retried fresh, not
321
+ // skipped as quarantined.
322
+ if ca, cb := a.calls.Load(), b.calls.Load(); ca != 2 || cb != 2 {
323
+ t.Fatalf("second call after reset: expected each fetcher tried again, got a=%d b=%d", ca, cb)
324
+ }
325
+}
326
+
327
+// TestMultiFetcherExhaustionCap verifies the MultiFetcher gives up after
328
+// maxMultiFetcherFullLoopFailures full rotations, returning
329
+// ErrMultiFetcherExhausted without trying inner fetchers again.
330
+func TestMultiFetcherExhaustionCap(t *testing.T) {
331
+ ctx := t.Context()
332
+
333
+ a := &countingFetcher{}
334
+ b := &countingFetcher{}
335
+ mf := NewMultiFetcher(a, b)
336
+
337
+ // Three failed full rotations should latch the breaker.
338
+ for i := range maxMultiFetcherFullLoopFailures {
339
+ if _, err := mf.Fetch(ctx, "/x"); err == nil {
340
+ t.Fatalf("rotation %d: expected error", i+1)
341
+ }
342
+ }
343
+
344
+ expectedCalls := int64(maxMultiFetcherFullLoopFailures)
345
+ if ca, cb := a.calls.Load(), b.calls.Load(); ca != expectedCalls || cb != expectedCalls {
346
+ t.Fatalf("after %d rotations: expected each fetcher called %d times, got a=%d b=%d",
347
+ maxMultiFetcherFullLoopFailures, expectedCalls, ca, cb)
348
+ }
349
+
350
+ // Subsequent calls must hard-error with ErrMultiFetcherExhausted and
351
+ // must not invoke the inner fetchers again.
352
+ _, err := mf.Fetch(ctx, "/x")
353
+ if !errors.Is(err, ErrMultiFetcherExhausted) {
354
+ t.Fatalf("expected ErrMultiFetcherExhausted, got %v", err)
355
+ }
356
+ if ca, cb := a.calls.Load(), b.calls.Load(); ca != expectedCalls || cb != expectedCalls {
357
+ t.Fatalf("inner fetchers called after exhaustion: a=%d b=%d", ca, cb)
358
+ }
359
+}
360
+
361
+// TestMultiFetcherConcurrent exercises the locking paths under -race by
362
+// hammering one MultiFetcher from many goroutines, mirroring how
363
+// fetchMigrations spawns parallel downloads against a shared fetcher.
364
+func TestMultiFetcherConcurrent(t *testing.T) {
365
+ ctx := t.Context()
366
+
367
+ bad := &countingFetcher{}
368
+ good := NewHttpFetcher(testIpfsDist, testServer.URL, "", 0)
369
+ mf := NewMultiFetcher(bad, good)
370
+
371
+ const goroutines = 16
372
+ const callsPerGoroutine = 8
373
+
374
+ var wg sync.WaitGroup
375
+ wg.Add(goroutines)
376
+ for range goroutines {
377
+ go func() {
378
+ defer wg.Done()
379
+ for range callsPerGoroutine {
380
+ if _, err := mf.Fetch(ctx, "/kubo/versions"); err != nil {
381
+ t.Errorf("unexpected fetch error: %v", err)
382
+ return
383
+ }
384
+ }
385
+ }()
386
+ }
387
+ wg.Wait()
388
+}
389
+
390
+// TestMultiFetcherSuccessResetsCounter verifies that any successful Fetch
391
+// resets the loop-failure counter, so transient blips during a long session
392
+// don't accumulate toward the exhaustion cap.
393
+func TestMultiFetcherSuccessResetsCounter(t *testing.T) {
394
+ ctx := t.Context()
395
+
396
+ bad := &countingFetcher{}
397
+ good := NewHttpFetcher(testIpfsDist, testServer.URL, "", 0)
398
+ mf := NewMultiFetcher(bad, good)
399
+
400
+ // Many alternating success calls must not trip the breaker.
401
+ for i := range maxMultiFetcherFullLoopFailures * 3 {
402
+ if _, err := mf.Fetch(ctx, "/kubo/versions"); err != nil {
403
+ t.Fatalf("call %d: %v", i+1, err)
404
+ }
405
+ }
406
+ if err := mf.exhaustedErr(); err != nil {
407
+ t.Fatalf("breaker tripped despite repeated successes: %v", err)
408
+ }
409
+}
410
+
411
+// TestMultiFetcherContextCancelled verifies that a cancelled context exits
412
+// the rotation early without quarantining every fetcher or counting toward
413
+// the exhaustion cap. Otherwise three user Ctrl-Cs in a row would latch
414
+// ErrMultiFetcherExhausted on a perfectly healthy gateway list.
415
+func TestMultiFetcherContextCancelled(t *testing.T) {
416
+ a := &countingFetcher{}
417
+ b := &countingFetcher{}
418
+ mf := NewMultiFetcher(a, b)
419
+
420
+ ctx, cancel := context.WithCancel(t.Context())
421
+ cancel()
422
+
423
+ for i := range maxMultiFetcherFullLoopFailures + 2 {
424
+ _, err := mf.Fetch(ctx, "/x")
425
+ if !errors.Is(err, context.Canceled) {
426
+ t.Fatalf("call %d: expected context.Canceled, got %v", i+1, err)
427
+ }
428
+ }
429
+
430
+ // Each call should have exited after the first fetcher returned the
431
+ // cancellation error, so b is never tried and the breaker never latches.
432
+ if got := b.calls.Load(); got != 0 {
433
+ t.Fatalf("second fetcher should not be tried after cancellation, got %d calls", got)
434
+ }
435
+ if err := mf.exhaustedErr(); err != nil {
436
+ t.Fatalf("breaker latched on cancelled-context loops: %v", err)
437
+ }
438
+}
439
+
440
+// countingFetcher always errors and records how many times it was called.
441
+// The counter is atomic so the fetcher is safe to share across goroutines
442
+// during -race tests.
443
+type countingFetcher struct {
444
+ calls atomic.Int64
445
+}
446
+
447
+func (c *countingFetcher) Fetch(ctx context.Context, _ string) ([]byte, error) {
448
+ c.calls.Add(1)
449
+ return nil, fmt.Errorf("countingFetcher always fails")
450
+}
451
+
452
+func (c *countingFetcher) Close() error { return nil }