diff --git a/internal/cache/db_test.go b/internal/cache/db_test.go index abf393d..004b1db 100644 --- a/internal/cache/db_test.go +++ b/internal/cache/db_test.go @@ -129,6 +129,57 @@ func TestExpireOldRoutes(t *testing.T) { } } +func TestRouteEntryIsValidExpired(t *testing.T) { + expired := &cache.RouteEntry{TTL: time.Now().Add(-time.Minute)} + if expired.IsValid() { + t.Error("expired entry should not be valid") + } +} + +func TestRouteEntryIsValidFuture(t *testing.T) { + valid := &cache.RouteEntry{TTL: time.Now().Add(time.Hour)} + if !valid.IsValid() { + t.Error("future-TTL entry should be valid") + } +} + +func TestDBOpenCreatesSchema(t *testing.T) { + db := newTestDB(t) + // RouteCount works only if schema was created. + count, err := db.RouteCount() + if err != nil { + t.Fatalf("RouteCount after fresh open: %v", err) + } + if count != 0 { + t.Errorf("expected 0 routes in fresh DB, got %d", count) + } +} + +func TestRouteCountAfterExpiry(t *testing.T) { + db := newTestDB(t) + + for i := range 3 { + ttl := time.Now().Add(-time.Minute) // all expired + db.SetRoute(&cache.RouteEntry{ + StorePath: "pkg-" + string(rune('a'+i)), + UpstreamURL: "https://cache.nixos.org", + TTL: ttl, + }) + } + + before, _ := db.RouteCount() + if err := db.ExpireOldRoutes(); err != nil { + t.Fatal(err) + } + after, _ := db.RouteCount() + if after >= before { + t.Errorf("count did not decrease after expiry: before=%d after=%d", before, after) + } + if after != 0 { + t.Errorf("expected 0 routes after expiring all, got %d", after) + } +} + func TestLRUEviction(t *testing.T) { // Use maxEntries=3 to trigger eviction easily f, _ := os.CreateTemp("", "ncro-lru-*.db") diff --git a/internal/prober/prober_test.go b/internal/prober/prober_test.go index 59761e4..8bf8f2d 100644 --- a/internal/prober/prober_test.go +++ b/internal/prober/prober_test.go @@ -89,6 +89,28 @@ func TestProbeUpstream(t *testing.T) { } } +func TestSortedByLatencyWithPriority(t *testing.T) { + p := prober.New(0.3) + // Two upstreams with very similar latency; lower priority number should win. + p.RecordLatency("https://low-priority.example.com", 100) + p.RecordLatency("https://high-priority.example.com", 102) // within 10% + + // Set priorities by calling InitUpstreams via RecordLatency (already seeded). + // We can't call InitUpstreams without config here, so test via SortedByLatency + // behavior: without priority, the 100ms one wins. With equal EMA and priority + // both zero (default), the lower-latency one still wins. + sorted := p.SortedByLatency() + if len(sorted) != 2 { + t.Fatalf("expected 2, got %d", len(sorted)) + } + // The 100ms upstream should be first (lower latency wins when not within 10% tie). + // 100 vs 102: diff=2, 2/102=1.96% < 10%, so priority decides (both priority=0, tie → latency). + // Actually 100 < 102 still wins on latency when priority is equal. + if sorted[0].EMALatency > sorted[1].EMALatency { + t.Errorf("expected lower latency first, got %.2f then %.2f", sorted[0].EMALatency, sorted[1].EMALatency) + } +} + func TestProbeUpstreamFailure(t *testing.T) { p := prober.New(0.3) p.ProbeUpstream("http://127.0.0.1:1") // nothing listening diff --git a/internal/router/router_test.go b/internal/router/router_test.go index e9cadab..d1d1922 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -1,6 +1,7 @@ package router_test import ( + "errors" "fmt" "net/http" "net/http/httptest" @@ -87,6 +88,41 @@ func TestRouteAllFail(t *testing.T) { } } +func TestRouteAllNotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(404) + })) + defer srv.Close() + + r, cleanup := newTestRouter(t, srv.URL) + defer cleanup() + + _, err := r.Resolve("somehash", []string{srv.URL}) + if !errors.Is(err, router.ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestRouteAllUnavailable(t *testing.T) { + r, cleanup := newTestRouter(t) + defer cleanup() + + _, err := r.Resolve("somehash", []string{"http://127.0.0.1:1"}) + if !errors.Is(err, router.ErrUpstreamUnavailable) { + t.Errorf("expected ErrUpstreamUnavailable, got %v", err) + } +} + +func TestRaceWithMalformedURL(t *testing.T) { + r, cleanup := newTestRouter(t) + defer cleanup() + + _, err := r.Resolve("somehash", []string{"://bad-url"}) + if err == nil { + t.Error("expected error for malformed upstream URL") + } +} + func TestCacheHit(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) @@ -106,3 +142,34 @@ func TestCacheHit(t *testing.T) { t.Error("expected cache hit on second resolve") } } + +func TestResolveWithDownUpstream(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + defer srv.Close() + + f, _ := os.CreateTemp("", "ncro-router-*.db") + f.Close() + db, _ := cache.Open(f.Name(), 1000) + defer db.Close() + defer os.Remove(f.Name()) + + p := prober.New(0.3) + p.RecordLatency(srv.URL, 10) + // Force the upstream to StatusDown + for range 10 { + p.RecordFailure(srv.URL) + } + + r := router.New(db, p, time.Hour, 5*time.Second) + // Router should still attempt the race (the race uses HEAD, not the prober status) + // The upstream is actually healthy (httptest), so the race should succeed. + result, err := r.Resolve("somehash", []string{srv.URL}) + if err != nil { + t.Fatalf("Resolve with down-flagged upstream: %v", err) + } + if result.URL != srv.URL { + t.Errorf("url = %q", result.URL) + } +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go index c941b11..1bfceaf 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -62,6 +62,24 @@ func TestNixCacheInfo(t *testing.T) { } } +func TestCacheInfoFields(t *testing.T) { + ts := makeTestServer(t) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/nix-cache-info") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + s := string(body) + for _, want := range []string{"StoreDir:", "WantMassQuery:", "Priority:"} { + if !strings.Contains(s, want) { + t.Errorf("nix-cache-info missing %q", want) + } + } +} + func TestHealthEndpoint(t *testing.T) { ts := makeTestServer(t) defer ts.Close() @@ -75,6 +93,24 @@ func TestHealthEndpoint(t *testing.T) { } } +func TestMetricsEndpoint(t *testing.T) { + ts := makeTestServer(t) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/metrics") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + t.Errorf("status = %d, want 200", resp.StatusCode) + } + ct := resp.Header.Get("Content-Type") + if !strings.HasPrefix(ct, "text/plain") { + t.Errorf("Content-Type = %q, want text/plain", ct) + } +} + func TestNarinfoProxy(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, ".narinfo") { @@ -104,6 +140,31 @@ func TestNarinfoProxy(t *testing.T) { } } +func TestNarinfoHEADRequest(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, ".narinfo") { + w.Header().Set("Content-Type", "text/x-nix-narinfo") + fmt.Fprint(w, "StorePath: /nix/store/abc-head-test\nURL: nar/abc.nar\n") + return + } + w.WriteHeader(404) + })) + defer upstream.Close() + + ts := makeTestServer(t, upstream.URL) + defer ts.Close() + + req, _ := http.NewRequest(http.MethodHead, ts.URL+"/abc123.narinfo", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 200 { + t.Errorf("HEAD narinfo status = %d, want 200", resp.StatusCode) + } +} + func TestNarinfoNotFound(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(404) @@ -119,6 +180,46 @@ func TestNarinfoNotFound(t *testing.T) { } } +func TestNarinfoUpstreamError(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + })) + defer upstream.Close() + + ts := makeTestServer(t, upstream.URL) + defer ts.Close() + + resp, _ := http.Get(ts.URL + "/abc123.narinfo") + // 404 (not found) or 502 (upstream error) are both acceptable + if resp.StatusCode == 200 { + t.Errorf("expected non-200 for upstream error, got %d", resp.StatusCode) + } +} + +func TestNarinfoNoUpstreams(t *testing.T) { + ts := makeTestServer(t) // no upstreams + defer ts.Close() + + resp, _ := http.Get(ts.URL + "/abc123.narinfo") + if resp.StatusCode == 200 { + t.Error("expected non-200 with no upstreams") + } +} + +func TestUnknownPath(t *testing.T) { + ts := makeTestServer(t) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/unknown/path") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 404 { + t.Errorf("status = %d, want 404", resp.StatusCode) + } +} + func TestNARStreamingPassthrough(t *testing.T) { narContent := []byte("fake-nar-content-bytes") upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -151,3 +252,79 @@ func TestNARStreamingPassthrough(t *testing.T) { t.Errorf("NAR body mismatch: got %q, want %q", body, narContent) } } + +func TestNARRangeHeaderForwarded(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/nar/") { + if r.Header.Get("Range") == "" { + http.Error(w, "Range header missing", 400) + return + } + w.WriteHeader(206) + w.Write([]byte("partial")) + return + } + if strings.HasSuffix(r.URL.Path, ".narinfo") { + w.WriteHeader(200) + return + } + w.WriteHeader(404) + })) + defer upstream.Close() + + ts := makeTestServer(t, upstream.URL) + defer ts.Close() + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/nar/abc.nar", nil) + req.Header.Set("Range", "bytes=0-1023") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 206 { + t.Errorf("Range request status = %d, want 206", resp.StatusCode) + } +} + +func TestNARFallbackWhenFirstUpstreamMissing(t *testing.T) { + missing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(404) + })) + defer missing.Close() + + hasIt := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/x-nix-archive") + w.Write([]byte("nar-bytes")) + })) + defer hasIt.Close() + + f, _ := os.CreateTemp("", "ncro-nar-fallback-*.db") + f.Close() + t.Cleanup(func() { os.Remove(f.Name()) }) + db, _ := cache.Open(f.Name(), 1000) + t.Cleanup(func() { db.Close() }) + + p := prober.New(0.3) + // missing appears faster + p.RecordLatency(missing.URL, 1) + p.RecordLatency(hasIt.URL, 50) + + upsCfg := []config.UpstreamConfig{{URL: missing.URL}, {URL: hasIt.URL}} + r := router.New(db, p, time.Hour, 5*time.Second) + ts := httptest.NewServer(server.New(r, p, upsCfg)) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/nar/abc123.nar") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + t.Errorf("expected fallback NAR response 200, got %d", resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + if string(body) != "nar-bytes" { + t.Errorf("NAR body = %q, want nar-bytes", body) + } +}