2026-10-11 23:41:16 +02:00
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
import (
|
2026-11-09 11:57:39 +01:00
|
|
|
"database/sql"
|
2026-10-11 23:41:16 +02:00
|
|
|
"fmt"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"sync"
|
|
|
|
|
"sync/atomic"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/git-pkgs/proxy/internal/packageurl"
|
|
|
|
|
"github.com/git-pkgs/registries/fetch"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestParseArchPackage(t *testing.T) {
|
|
|
|
|
assertPathParser(t, "parseArchPackage", parseArchPackage, []pathParseCase{
|
|
|
|
|
{"bash-5.2.037-1-x86_64.pkg.tar.zst", "bash", "5.2.037-1", "x86_64"},
|
|
|
|
|
{"python-pip-24.3.1-1-any.pkg.tar.zst", "python-pip", "24.3.1-1", "any"},
|
|
|
|
|
{"lz4-1:1.10.0-2-x86_64.pkg.tar.zst", "lz4", "1:1.10.0-2", "x86_64"},
|
|
|
|
|
{"wpa_supplicant-2:2.12-1-x86_64.pkg.tar.zst", "wpa_supplicant", "2:2.12-1", "x86_64"},
|
|
|
|
|
{"linux-aarch64-6.12.1-1-aarch64.pkg.tar.xz", "linux-aarch64", "6.12.1-1", "aarch64"},
|
|
|
|
|
{"libsigc++-3.6.0-1.1-x86_64.pkg.tar.lz4", "libsigc++", "3.6.0-1.1", "x86_64"},
|
|
|
|
|
{"plain-1.0-1-x86_64.pkg.tar", "plain", "1.0-1", "x86_64"},
|
|
|
|
|
{"bash-5.2.037-1-x86_64.pkg.tar.zst.sig", "bash", "5.2.037-1", "x86_64"},
|
|
|
|
|
{"bash-x86_64.pkg.tar.zst", "", "", ""},
|
|
|
|
|
{"bash-5.2.037-1-x86_64.tar.zst", "", "", ""},
|
|
|
|
|
{"core.db", "", "", ""},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestArchFileClassification(t *testing.T) {
|
|
|
|
|
tests := []struct {
|
|
|
|
|
path string
|
|
|
|
|
database, databaseSig, pkg, pkgSig bool
|
|
|
|
|
}{
|
|
|
|
|
{path: "core/os/x86_64/core.db", database: true},
|
|
|
|
|
{path: "core/os/x86_64/core.db.tar.gz", database: true},
|
|
|
|
|
{path: "core/os/x86_64/core.files", database: true},
|
|
|
|
|
{path: "core/os/x86_64/core.files.tar.zst", database: true},
|
|
|
|
|
{path: "aarch64/core/core.db", database: true},
|
|
|
|
|
{path: "core/os/x86_64/core.db.sig", databaseSig: true},
|
|
|
|
|
{path: "custom/os/x86_64/custom.db.tar.xz.sig", databaseSig: true},
|
|
|
|
|
{path: "core/os/x86_64/bash-5.2.037-1-x86_64.pkg.tar.zst", pkg: true},
|
|
|
|
|
{path: "core/os/x86_64/bash-5.2.037-1-x86_64.pkg.tar.zst.sig", pkgSig: true},
|
|
|
|
|
{path: "core/os/x86_64/core.db.tar.gz.old"},
|
|
|
|
|
{path: "lastupdate"},
|
|
|
|
|
{path: "iso/latest/archlinux-x86_64.iso"},
|
|
|
|
|
}
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
if got := isArchDatabase(tt.path); got != tt.database {
|
|
|
|
|
t.Errorf("isArchDatabase(%q) = %v, want %v", tt.path, got, tt.database)
|
|
|
|
|
}
|
|
|
|
|
if got := isArchDatabaseSignature(tt.path); got != tt.databaseSig {
|
|
|
|
|
t.Errorf("isArchDatabaseSignature(%q) = %v, want %v", tt.path, got, tt.databaseSig)
|
|
|
|
|
}
|
|
|
|
|
if got := isArchPackage(tt.path); got != tt.pkg {
|
|
|
|
|
t.Errorf("isArchPackage(%q) = %v, want %v", tt.path, got, tt.pkg)
|
|
|
|
|
}
|
|
|
|
|
if got := isArchPackageSignature(tt.path); got != tt.pkgSig {
|
|
|
|
|
t.Errorf("isArchPackageSignature(%q) = %v, want %v", tt.path, got, tt.pkgSig)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestArchPURL pins the package URL type: git-pkgs/purl maps the "arch"
|
|
|
|
|
// ecosystem to pkg:alpm with the "arch" namespace.
|
|
|
|
|
func TestArchPURL(t *testing.T) {
|
|
|
|
|
pkg, version := packageurl.MakeCacheStrings(archEcosystem, "lz4", "1:1.10.0-2")
|
|
|
|
|
if pkg != "pkg:alpm/arch/lz4" {
|
|
|
|
|
t.Errorf("package PURL = %q, want %q", pkg, "pkg:alpm/arch/lz4")
|
|
|
|
|
}
|
|
|
|
|
if version != "pkg:alpm/arch/lz4@1:1.10.0-2" {
|
|
|
|
|
t.Errorf("version PURL = %q, want %q", version, "pkg:alpm/arch/lz4@1:1.10.0-2")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestArchHandler_Routes(t *testing.T) {
|
|
|
|
|
h := NewArchHandler(nil, "http://localhost:8080", nil)
|
|
|
|
|
assertRoutesBasics(t, h.Routes(), "/archlinux/core/os/x86_64/core.db", "/archlinux/core/../../../etc/passwd")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestArchHandler_DefaultsToGeoMirror(t *testing.T) {
|
|
|
|
|
h := NewArchHandler(nil, "http://localhost:8080", nil)
|
|
|
|
|
if got := h.repositories[defaultArchRepositoryName]; got != defaultArchUpstream {
|
|
|
|
|
t.Errorf("default repository = %q, want %q", got, defaultArchUpstream)
|
|
|
|
|
}
|
|
|
|
|
if len(h.repositories) != 1 {
|
|
|
|
|
t.Errorf("repositories = %v, want only the default", h.repositories)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestArchHandler_UnknownRepositoryReturns404(t *testing.T) {
|
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
|
|
|
h := NewArchHandler(proxy, "http://localhost:8080", map[string]string{"archlinux": "https://example.test"})
|
|
|
|
|
|
|
|
|
|
for _, target := range []string{"/unknown/core/os/x86_64/core.db", "/archlinux", "/"} {
|
|
|
|
|
if w := serveArchRequest(h, http.MethodGet, target, nil); w.Code != http.StatusNotFound {
|
|
|
|
|
t.Errorf("%s: status = %d, want 404", target, w.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-12 10:59:22 +02:00
|
|
|
// archUpstream is a fake pacman mirror. Files can be swapped, single paths
|
|
|
|
|
// broken and the mirror taken offline; requests are counted per path.
|
2026-10-11 23:41:16 +02:00
|
|
|
type archUpstream struct {
|
|
|
|
|
*httptest.Server
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
files map[string][]byte
|
|
|
|
|
modified map[string]time.Time
|
|
|
|
|
hits map[string]int
|
2026-10-12 10:59:22 +02:00
|
|
|
broken map[string]bool
|
|
|
|
|
served func(path string) // called after a file is served, without mu
|
2026-10-11 23:41:16 +02:00
|
|
|
available atomic.Bool
|
|
|
|
|
auth atomic.Value
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newArchUpstream(t *testing.T, files map[string][]byte) *archUpstream {
|
|
|
|
|
t.Helper()
|
2026-10-12 10:59:22 +02:00
|
|
|
u := &archUpstream{files: files, modified: map[string]time.Time{}, hits: map[string]int{}, broken: map[string]bool{}}
|
2026-10-11 23:41:16 +02:00
|
|
|
u.available.Store(true)
|
|
|
|
|
u.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if !u.available.Load() {
|
|
|
|
|
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
u.auth.Store(r.Header.Get("Authorization"))
|
|
|
|
|
u.mu.Lock()
|
|
|
|
|
data, ok := u.files[r.URL.Path]
|
|
|
|
|
modified := u.modified[r.URL.Path]
|
2026-10-12 10:59:22 +02:00
|
|
|
broken := u.broken[r.URL.Path]
|
|
|
|
|
served := u.served
|
2026-10-11 23:41:16 +02:00
|
|
|
u.hits[r.URL.Path]++
|
|
|
|
|
u.mu.Unlock()
|
2026-10-12 10:59:22 +02:00
|
|
|
if broken {
|
|
|
|
|
http.Error(w, "broken", http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-11 23:41:16 +02:00
|
|
|
if !ok {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if !modified.IsZero() {
|
|
|
|
|
w.Header().Set("Last-Modified", modified.UTC().Format(http.TimeFormat))
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
|
|
|
_, _ = w.Write(data)
|
2026-10-12 10:59:22 +02:00
|
|
|
if served != nil {
|
|
|
|
|
served(r.URL.Path)
|
|
|
|
|
}
|
2026-10-11 23:41:16 +02:00
|
|
|
}))
|
|
|
|
|
t.Cleanup(u.Close)
|
|
|
|
|
return u
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (u *archUpstream) set(path string, data []byte) {
|
|
|
|
|
u.mu.Lock()
|
|
|
|
|
defer u.mu.Unlock()
|
|
|
|
|
u.files[path] = data
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (u *archUpstream) hitCount(path string) int {
|
|
|
|
|
u.mu.Lock()
|
|
|
|
|
defer u.mu.Unlock()
|
|
|
|
|
return u.hits[path]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newArchTestProxy(t *testing.T, upstream *archUpstream) *Proxy {
|
|
|
|
|
t.Helper()
|
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
|
|
|
proxy.CacheMetadata = true
|
|
|
|
|
proxy.MetadataTTL = time.Hour
|
|
|
|
|
proxy.HTTPClient = upstream.Client()
|
|
|
|
|
fetcher := fetch.NewFetcher(fetch.WithHTTPClient(upstream.Client()), fetch.WithMaxRetries(0))
|
|
|
|
|
proxy.Fetcher = fetcher
|
|
|
|
|
t.Cleanup(func() { _ = fetcher.Close() })
|
|
|
|
|
return proxy
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func assertArchBody(t *testing.T, w *httptest.ResponseRecorder, label string, want []byte) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("%s: status = %d, want 200: %s", label, w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if got := w.Body.String(); got != string(want) {
|
|
|
|
|
t.Errorf("%s: body altered:\ngot %q\nwant %q", label, got, want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestArchHandler_DatabasesServedUnchanged covers database and file-list
|
|
|
|
|
// downloads under the names pacman uses (core.db, core.files) and their
|
|
|
|
|
// .tar.* forms: bytes are served unchanged so signatures stay valid, cached
|
|
|
|
|
// copies are served within the TTL without contacting the upstream, and the
|
|
|
|
|
// cache keeps serving them when the upstream is down.
|
|
|
|
|
func TestArchHandler_DatabasesServedUnchanged(t *testing.T) {
|
|
|
|
|
files := map[string][]byte{
|
|
|
|
|
"/core/os/x86_64/core.db": []byte("\x1f\x8b\x08core-db-bytes"),
|
|
|
|
|
"/core/os/x86_64/core.db.tar.gz": []byte("\x1f\x8b\x08core-db-bytes"),
|
|
|
|
|
"/core/os/x86_64/core.db.sig": []byte("core-db-signature"),
|
|
|
|
|
"/core/os/x86_64/core.files": []byte("\x1f\x8b\x08core-files-bytes"),
|
|
|
|
|
"/core/os/x86_64/core.files.tar.zst": []byte("\x28\xb5\x2f\xfdcore-files-zst"),
|
|
|
|
|
}
|
|
|
|
|
upstream := newArchUpstream(t, files)
|
|
|
|
|
proxy := newArchTestProxy(t, upstream)
|
2026-10-14 08:38:51 +02:00
|
|
|
authorizeUpstream(proxy, "Bearer arch-token")
|
2026-10-11 23:41:16 +02:00
|
|
|
h := NewArchHandler(proxy, "http://proxy.example", map[string]string{"archlinux": upstream.URL})
|
|
|
|
|
|
|
|
|
|
// The database goes first, as pacman requests it.
|
|
|
|
|
paths := []string{
|
|
|
|
|
"/core/os/x86_64/core.db", "/core/os/x86_64/core.db.sig", "/core/os/x86_64/core.db.tar.gz",
|
|
|
|
|
"/core/os/x86_64/core.files", "/core/os/x86_64/core.files.tar.zst",
|
|
|
|
|
}
|
|
|
|
|
for _, path := range paths {
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/archlinux"+path, nil), path, files[path])
|
|
|
|
|
}
|
|
|
|
|
if got, _ := upstream.auth.Load().(string); got != "Bearer arch-token" {
|
|
|
|
|
t.Errorf("Authorization = %q, want %q", got, "Bearer arch-token")
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-12 10:59:22 +02:00
|
|
|
// core.db is fetched twice on its first request: once more after its
|
|
|
|
|
// signature, to make sure the two belong together.
|
|
|
|
|
wantHits := map[string]int{"/core/os/x86_64/core.db": 2}
|
2026-10-11 23:41:16 +02:00
|
|
|
for _, path := range paths {
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/archlinux"+path, nil), path+" cached", files[path])
|
2026-10-12 10:59:22 +02:00
|
|
|
want := max(wantHits[path], 1)
|
|
|
|
|
if got := upstream.hitCount(path); got != want {
|
|
|
|
|
t.Errorf("%s: upstream hits = %d, want %d (second read from cache)", path, got, want)
|
2026-10-11 23:41:16 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
upstream.available.Store(false)
|
|
|
|
|
for _, path := range paths {
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/archlinux"+path, nil), path+" offline", files[path])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestArchHandler_StaleDatabaseServedWhenUpstreamDown covers the stale
|
|
|
|
|
// fallback after the TTL.
|
|
|
|
|
func TestArchHandler_StaleDatabaseServedWhenUpstreamDown(t *testing.T) {
|
|
|
|
|
db := []byte("core-db-bytes")
|
|
|
|
|
upstream := newArchUpstream(t, map[string][]byte{"/core/os/x86_64/core.db": db})
|
|
|
|
|
proxy := newArchTestProxy(t, upstream)
|
|
|
|
|
h := NewArchHandler(proxy, "http://proxy.example", map[string]string{"archlinux": upstream.URL})
|
|
|
|
|
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/archlinux/core/os/x86_64/core.db", nil), "seed", db)
|
2026-10-12 10:59:22 +02:00
|
|
|
ageMetadata(t, proxy, h, "archlinux", upstream.URL, "core/os/x86_64/core.db")
|
2026-10-11 23:41:16 +02:00
|
|
|
upstream.available.Store(false)
|
|
|
|
|
|
|
|
|
|
w := serveArchRequest(h, http.MethodGet, "/archlinux/core/os/x86_64/core.db", nil)
|
|
|
|
|
assertArchBody(t, w, "stale", db)
|
|
|
|
|
if got := w.Header().Get("Warning"); got == "" {
|
|
|
|
|
t.Error("stale database served without a Warning header")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestArchHandler_DatabaseIfModifiedSince covers pacman's database refresh,
|
|
|
|
|
// which sends If-Modified-Since with the local copy's time.
|
|
|
|
|
func TestArchHandler_DatabaseIfModifiedSince(t *testing.T) {
|
|
|
|
|
modified := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)
|
|
|
|
|
upstream := newArchUpstream(t, map[string][]byte{"/core/os/x86_64/core.db": []byte("core-db-bytes")})
|
|
|
|
|
upstream.modified["/core/os/x86_64/core.db"] = modified
|
|
|
|
|
proxy := newArchTestProxy(t, upstream)
|
|
|
|
|
h := NewArchHandler(proxy, "http://proxy.example", map[string]string{"archlinux": upstream.URL})
|
|
|
|
|
|
|
|
|
|
target := "/archlinux/core/os/x86_64/core.db"
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, target, nil), "seed", []byte("core-db-bytes"))
|
|
|
|
|
|
|
|
|
|
upToDate := http.Header{"If-Modified-Since": {modified.Format(http.TimeFormat)}}
|
|
|
|
|
if w := serveArchRequest(h, http.MethodGet, target, upToDate); w.Code != http.StatusNotModified {
|
|
|
|
|
t.Errorf("unchanged database: status = %d, want 304", w.Code)
|
|
|
|
|
}
|
|
|
|
|
older := http.Header{"If-Modified-Since": {modified.Add(-time.Hour).Format(http.TimeFormat)}}
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, target, older), "older local copy", []byte("core-db-bytes"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestArchHandler_MissingDatabaseSignature covers the official repositories,
|
|
|
|
|
// which sign no databases: pacman with DatabaseOptional asks for core.db.sig
|
2026-10-12 10:59:22 +02:00
|
|
|
// and must get a 404. The database refresh found it missing, so the answer
|
|
|
|
|
// needs no upstream and holds offline too.
|
2026-10-11 23:41:16 +02:00
|
|
|
func TestArchHandler_MissingDatabaseSignature(t *testing.T) {
|
|
|
|
|
upstream := newArchUpstream(t, map[string][]byte{"/core/os/x86_64/core.db": []byte("core-db-bytes")})
|
|
|
|
|
proxy := newArchTestProxy(t, upstream)
|
|
|
|
|
h := NewArchHandler(proxy, "http://proxy.example", map[string]string{"archlinux": upstream.URL})
|
|
|
|
|
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/archlinux/core/os/x86_64/core.db", nil), "db", []byte("core-db-bytes"))
|
|
|
|
|
if w := serveArchRequest(h, http.MethodGet, "/archlinux/core/os/x86_64/core.db.sig", nil); w.Code != http.StatusNotFound {
|
|
|
|
|
t.Errorf("missing signature: status = %d, want 404", w.Code)
|
|
|
|
|
}
|
2026-10-12 10:59:22 +02:00
|
|
|
if got := upstream.hitCount("/core/os/x86_64/core.db.sig"); got != 1 {
|
|
|
|
|
t.Errorf("signature upstream hits = %d, want 1 (asked once, with the database)", got)
|
|
|
|
|
}
|
2026-10-11 23:41:16 +02:00
|
|
|
|
|
|
|
|
upstream.available.Store(false)
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/archlinux/core/os/x86_64/core.db", nil), "db offline", []byte("core-db-bytes"))
|
2026-10-12 10:59:22 +02:00
|
|
|
if w := serveArchRequest(h, http.MethodGet, "/archlinux/core/os/x86_64/core.db.sig", nil); w.Code != http.StatusNotFound {
|
|
|
|
|
t.Errorf("missing signature offline: status = %d, want 404", w.Code)
|
2026-10-11 23:41:16 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestArchHandler_DatabaseSignatureFollowsDatabase covers custom repositories
|
|
|
|
|
// with signed databases. The database and its signature are separate cache
|
|
|
|
|
// entries; when the database is refreshed, the signature must be refreshed
|
|
|
|
|
// with it even inside its own TTL, or pacman would get a new database with
|
|
|
|
|
// the old signature. While the database is not refreshed, the cached
|
|
|
|
|
// signature keeps matching it and is served without asking the upstream.
|
|
|
|
|
func TestArchHandler_DatabaseSignatureFollowsDatabase(t *testing.T) {
|
|
|
|
|
const dbPath, sigPath = "/custom/os/x86_64/custom.db", "/custom/os/x86_64/custom.db.sig"
|
|
|
|
|
upstream := newArchUpstream(t, map[string][]byte{dbPath: []byte("db v1"), sigPath: []byte("sig v1")})
|
|
|
|
|
proxy := newArchTestProxy(t, upstream)
|
|
|
|
|
h := NewArchHandler(proxy, "http://proxy.example", map[string]string{"custom": upstream.URL})
|
|
|
|
|
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/custom"+dbPath, nil), "db v1", []byte("db v1"))
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/custom"+sigPath, nil), "sig v1", []byte("sig v1"))
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/custom"+sigPath, nil), "sig v1 again", []byte("sig v1"))
|
|
|
|
|
if got := upstream.hitCount(sigPath); got != 1 {
|
|
|
|
|
t.Errorf("signature upstream hits = %d, want 1 while the database is unchanged", got)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Upstream publishes a new database. The cached database expires before
|
|
|
|
|
// the signature does.
|
|
|
|
|
upstream.set(dbPath, []byte("db v2"))
|
|
|
|
|
upstream.set(sigPath, []byte("sig v2"))
|
2026-10-12 10:59:22 +02:00
|
|
|
ageMetadata(t, proxy, h, "custom", upstream.URL, dbPath[1:])
|
2026-10-11 23:41:16 +02:00
|
|
|
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/custom"+dbPath, nil), "db v2", []byte("db v2"))
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/custom"+sigPath, nil), "sig after db refresh", []byte("sig v2"))
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/custom"+sigPath, nil), "sig v2 again", []byte("sig v2"))
|
|
|
|
|
if got := upstream.hitCount(sigPath); got != 2 {
|
|
|
|
|
t.Errorf("signature upstream hits = %d, want 2", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-12 10:59:22 +02:00
|
|
|
// TestArchHandler_OutdatedSignatureNeverServed covers a signature fetch that
|
|
|
|
|
// fails while the database is refreshed. The cached signature belongs to the
|
|
|
|
|
// old database, and pacman rejects a present but invalid signature even with
|
|
|
|
|
// DatabaseOptional, so the proxy must answer 404 or 502 rather than fall back
|
|
|
|
|
// to it.
|
2026-10-11 23:51:43 +02:00
|
|
|
func TestArchHandler_OutdatedSignatureNeverServed(t *testing.T) {
|
|
|
|
|
const dbPath, sigPath = "/custom/os/x86_64/custom.db", "/custom/os/x86_64/custom.db.sig"
|
|
|
|
|
|
|
|
|
|
for _, tt := range []struct {
|
|
|
|
|
name string
|
|
|
|
|
breakSig func(u *archUpstream)
|
|
|
|
|
want int
|
|
|
|
|
}{
|
2026-10-12 10:59:22 +02:00
|
|
|
{"signature removed upstream", func(u *archUpstream) { delete(u.files, sigPath) }, http.StatusNotFound},
|
|
|
|
|
{"signature fetch fails", func(u *archUpstream) { u.broken[sigPath] = true }, http.StatusBadGateway},
|
2026-10-11 23:51:43 +02:00
|
|
|
} {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
upstream := newArchUpstream(t, map[string][]byte{dbPath: []byte("db v1"), sigPath: []byte("sig v1")})
|
|
|
|
|
proxy := newArchTestProxy(t, upstream)
|
|
|
|
|
h := NewArchHandler(proxy, "http://proxy.example", map[string]string{"custom": upstream.URL})
|
|
|
|
|
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/custom"+dbPath, nil), "db v1", []byte("db v1"))
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/custom"+sigPath, nil), "sig v1", []byte("sig v1"))
|
|
|
|
|
|
2026-10-12 10:59:22 +02:00
|
|
|
upstream.mu.Lock()
|
|
|
|
|
upstream.files[dbPath] = []byte("db v2")
|
|
|
|
|
tt.breakSig(upstream)
|
|
|
|
|
upstream.mu.Unlock()
|
|
|
|
|
ageMetadata(t, proxy, h, "custom", upstream.URL, dbPath[1:])
|
2026-10-11 23:51:43 +02:00
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/custom"+dbPath, nil), "db v2", []byte("db v2"))
|
|
|
|
|
|
|
|
|
|
for i := range 2 {
|
|
|
|
|
w := serveArchRequest(h, http.MethodGet, "/custom"+sigPath, nil)
|
|
|
|
|
if w.Code != tt.want {
|
|
|
|
|
t.Errorf("request %d: status = %d, body = %q, want %d", i, w.Code, w.Body.String(), tt.want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-12 10:59:22 +02:00
|
|
|
// TestArchHandler_DelayedSignatureMatchesCachedDatabase covers a signature
|
|
|
|
|
// first requested after the upstream moved on: it must be the one for the
|
|
|
|
|
// database the proxy still serves, not the upstream's new one.
|
|
|
|
|
func TestArchHandler_DelayedSignatureMatchesCachedDatabase(t *testing.T) {
|
|
|
|
|
const dbPath, sigPath = "/custom/os/x86_64/custom.db", "/custom/os/x86_64/custom.db.sig"
|
|
|
|
|
upstream := newArchUpstream(t, map[string][]byte{dbPath: []byte("db v1"), sigPath: []byte("sig v1")})
|
|
|
|
|
proxy := newArchTestProxy(t, upstream)
|
|
|
|
|
h := NewArchHandler(proxy, "http://proxy.example", map[string]string{"custom": upstream.URL})
|
|
|
|
|
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/custom"+dbPath, nil), "db v1", []byte("db v1"))
|
|
|
|
|
|
|
|
|
|
upstream.set(dbPath, []byte("db v2"))
|
|
|
|
|
upstream.set(sigPath, []byte("sig v2"))
|
|
|
|
|
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/custom"+dbPath, nil), "db within TTL", []byte("db v1"))
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/custom"+sigPath, nil), "first signature request", []byte("sig v1"))
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-12 17:08:17 +02:00
|
|
|
// TestArchHandler_RecoveredSignatureMatchesServedDatabase covers a signature
|
|
|
|
|
// whose fetch failed while the database was served. If the upstream has moved
|
|
|
|
|
// on by the time pacman asks for the signature, the new one cannot verify the
|
|
|
|
|
// database pacman holds, so the proxy must fail rather than serve it.
|
|
|
|
|
func TestArchHandler_RecoveredSignatureMatchesServedDatabase(t *testing.T) {
|
|
|
|
|
const dbPath, sigPath = "/custom/os/x86_64/custom.db", "/custom/os/x86_64/custom.db.sig"
|
|
|
|
|
upstream := newArchUpstream(t, map[string][]byte{dbPath: []byte("db v1"), sigPath: []byte("sig v1")})
|
|
|
|
|
upstream.broken[sigPath] = true
|
|
|
|
|
proxy := newArchTestProxy(t, upstream)
|
|
|
|
|
h := NewArchHandler(proxy, "http://proxy.example", map[string]string{"custom": upstream.URL})
|
|
|
|
|
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/custom"+dbPath, nil), "db v1", []byte("db v1"))
|
|
|
|
|
|
|
|
|
|
upstream.mu.Lock()
|
|
|
|
|
upstream.files[dbPath] = []byte("db v2")
|
|
|
|
|
upstream.files[sigPath] = []byte("sig v2")
|
|
|
|
|
upstream.broken[sigPath] = false
|
|
|
|
|
upstream.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
if w := serveArchRequest(h, http.MethodGet, "/custom"+sigPath, nil); w.Code != http.StatusBadGateway {
|
|
|
|
|
t.Errorf("signature for db v1: status = %d, body = %q, want 502", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The next sync gets the new pair.
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/custom"+dbPath, nil), "db v2", []byte("db v2"))
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/custom"+sigPath, nil), "sig v2", []byte("sig v2"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestArchHandler_RecoveredSignatureForUnchangedDatabase covers the same
|
|
|
|
|
// failed signature fetch when the database has not changed: the signature is
|
|
|
|
|
// fetched on request and served.
|
|
|
|
|
func TestArchHandler_RecoveredSignatureForUnchangedDatabase(t *testing.T) {
|
|
|
|
|
const dbPath, sigPath = "/custom/os/x86_64/custom.db", "/custom/os/x86_64/custom.db.sig"
|
|
|
|
|
upstream := newArchUpstream(t, map[string][]byte{dbPath: []byte("db v1"), sigPath: []byte("sig v1")})
|
|
|
|
|
upstream.broken[sigPath] = true
|
|
|
|
|
proxy := newArchTestProxy(t, upstream)
|
|
|
|
|
h := NewArchHandler(proxy, "http://proxy.example", map[string]string{"custom": upstream.URL})
|
|
|
|
|
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/custom"+dbPath, nil), "db v1", []byte("db v1"))
|
|
|
|
|
upstream.mu.Lock()
|
|
|
|
|
upstream.broken[sigPath] = false
|
|
|
|
|
upstream.mu.Unlock()
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/custom"+sigPath, nil), "sig v1", []byte("sig v1"))
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-12 10:59:22 +02:00
|
|
|
// TestArchHandler_SignatureSurvivesUnchangedRefresh covers a database refresh
|
|
|
|
|
// that returns the same bytes: the cached signature still matches and must be
|
|
|
|
|
// served offline afterwards.
|
|
|
|
|
func TestArchHandler_SignatureSurvivesUnchangedRefresh(t *testing.T) {
|
|
|
|
|
const dbPath, sigPath = "/custom/os/x86_64/custom.db", "/custom/os/x86_64/custom.db.sig"
|
|
|
|
|
upstream := newArchUpstream(t, map[string][]byte{dbPath: []byte("db v1"), sigPath: []byte("sig v1")})
|
|
|
|
|
proxy := newArchTestProxy(t, upstream)
|
|
|
|
|
h := NewArchHandler(proxy, "http://proxy.example", map[string]string{"custom": upstream.URL})
|
|
|
|
|
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/custom"+dbPath, nil), "db", []byte("db v1"))
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/custom"+sigPath, nil), "sig", []byte("sig v1"))
|
|
|
|
|
|
|
|
|
|
ageMetadata(t, proxy, h, "custom", upstream.URL, dbPath[1:])
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/custom"+dbPath, nil), "db refreshed unchanged", []byte("db v1"))
|
|
|
|
|
|
|
|
|
|
upstream.available.Store(false)
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/custom"+sigPath, nil), "sig offline", []byte("sig v1"))
|
|
|
|
|
if got := upstream.hitCount(sigPath); got != 1 {
|
|
|
|
|
t.Errorf("signature upstream hits = %d, want 1", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestArchHandler_DatabaseReplacedDuringRefresh covers a mirror that swaps
|
|
|
|
|
// database and signature between the proxy's two fetches. The proxy must
|
|
|
|
|
// notice and store the new pair, not the old database with the new signature.
|
|
|
|
|
func TestArchHandler_DatabaseReplacedDuringRefresh(t *testing.T) {
|
|
|
|
|
const dbPath, sigPath = "/custom/os/x86_64/custom.db", "/custom/os/x86_64/custom.db.sig"
|
|
|
|
|
upstream := newArchUpstream(t, map[string][]byte{dbPath: []byte("db v1"), sigPath: []byte("sig v1")})
|
|
|
|
|
var swapped atomic.Bool
|
|
|
|
|
upstream.served = func(path string) {
|
|
|
|
|
if path == sigPath && swapped.CompareAndSwap(false, true) {
|
|
|
|
|
upstream.set(dbPath, []byte("db v2"))
|
|
|
|
|
upstream.set(sigPath, []byte("sig v2"))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
proxy := newArchTestProxy(t, upstream)
|
|
|
|
|
h := NewArchHandler(proxy, "http://proxy.example", map[string]string{"custom": upstream.URL})
|
|
|
|
|
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/custom"+dbPath, nil), "db", []byte("db v2"))
|
|
|
|
|
upstream.available.Store(false)
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/custom"+sigPath, nil), "sig", []byte("sig v2"))
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 23:51:43 +02:00
|
|
|
// TestArchHandler_RejectsQueryAndFragmentInPath covers a decoded '?' or '#'
|
|
|
|
|
// in the path, which would otherwise fetch one file and cache it under the
|
|
|
|
|
// name that follows.
|
|
|
|
|
func TestArchHandler_RejectsQueryAndFragmentInPath(t *testing.T) {
|
|
|
|
|
upstream := newArchUpstream(t, map[string][]byte{})
|
|
|
|
|
proxy := newArchTestProxy(t, upstream)
|
|
|
|
|
h := NewArchHandler(proxy, "http://proxy.example", map[string]string{"archlinux": upstream.URL})
|
|
|
|
|
|
|
|
|
|
for _, target := range []string{
|
|
|
|
|
"/archlinux/core/os/x86_64/bash-5.2-1-x86_64.pkg.tar.zst%3F/foo-9-9-any.pkg.tar.zst",
|
|
|
|
|
"/archlinux/core/os/x86_64/bash-5.2-1-x86_64.pkg.tar.zst%23/foo-9-9-any.pkg.tar.zst",
|
|
|
|
|
} {
|
|
|
|
|
if w := serveArchRequest(h, http.MethodGet, target, nil); w.Code != http.StatusBadRequest {
|
|
|
|
|
t.Errorf("%s: status = %d, want 400", target, w.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
upstream.mu.Lock()
|
|
|
|
|
defer upstream.mu.Unlock()
|
|
|
|
|
if len(upstream.hits) != 0 {
|
|
|
|
|
t.Errorf("upstream was asked for %v", upstream.hits)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-12 10:59:22 +02:00
|
|
|
// ageMetadata moves a metadata entry's fetch time past the one-hour TTL.
|
|
|
|
|
func ageMetadata(t *testing.T, proxy *Proxy, h *ArchHandler, repository, upstreamURL, path string) {
|
2026-10-11 23:41:16 +02:00
|
|
|
t.Helper()
|
|
|
|
|
entry, err := proxy.DB.GetMetadataCache(archEcosystem, h.metadataCacheKey(repository, upstreamURL, path))
|
|
|
|
|
if err != nil || entry == nil {
|
|
|
|
|
t.Fatalf("metadata entry for %s: %v", path, err)
|
|
|
|
|
}
|
2026-10-12 10:59:22 +02:00
|
|
|
entry.FetchedAt.Time = time.Now().Add(-2 * time.Hour)
|
2026-10-11 23:41:16 +02:00
|
|
|
if err := proxy.DB.UpsertMetadataCache(entry); err != nil {
|
|
|
|
|
t.Fatalf("aging metadata entry: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestArchHandler_PackagesAndSignatures covers package and signature
|
|
|
|
|
// downloads, cache hits, offline reads and epoch filenames (both as pacman
|
|
|
|
|
// sends them and percent-encoded).
|
|
|
|
|
func TestArchHandler_PackagesAndSignatures(t *testing.T) {
|
|
|
|
|
files := map[string][]byte{
|
|
|
|
|
"/core/os/x86_64/bash-5.2.037-1-x86_64.pkg.tar.zst": []byte("bash package"),
|
|
|
|
|
"/core/os/x86_64/bash-5.2.037-1-x86_64.pkg.tar.zst.sig": []byte("bash signature"),
|
|
|
|
|
"/core/os/x86_64/lz4-1:1.10.0-2-x86_64.pkg.tar.zst": []byte("lz4 package"),
|
|
|
|
|
"/core/os/x86_64/lz4-1:1.10.0-2-x86_64.pkg.tar.zst.sig": []byte("lz4 signature"),
|
|
|
|
|
}
|
|
|
|
|
upstream := newArchUpstream(t, files)
|
|
|
|
|
proxy := newArchTestProxy(t, upstream)
|
|
|
|
|
h := NewArchHandler(proxy, "http://proxy.example", map[string]string{"archlinux": upstream.URL})
|
|
|
|
|
|
|
|
|
|
for path, want := range files {
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/archlinux"+path, nil), path, want)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
upstream.available.Store(false)
|
|
|
|
|
for path, want := range files {
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/archlinux"+path, nil), path+" offline", want)
|
|
|
|
|
if got := upstream.hitCount(path); got != 1 {
|
|
|
|
|
t.Errorf("%s: upstream hits = %d, want 1", path, got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet,
|
|
|
|
|
"/archlinux/core/os/x86_64/lz4-1%3A1.10.0-2-x86_64.pkg.tar.zst", nil), "percent-encoded epoch", files["/core/os/x86_64/lz4-1:1.10.0-2-x86_64.pkg.tar.zst"])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestArchHandler_ArchLinuxARMLayout covers the Arch Linux ARM mirror layout
|
|
|
|
|
// ($arch/$repo), served through the same route.
|
|
|
|
|
func TestArchHandler_ArchLinuxARMLayout(t *testing.T) {
|
|
|
|
|
files := map[string][]byte{
|
|
|
|
|
"/aarch64/core/core.db": []byte("alarm core db"),
|
|
|
|
|
"/aarch64/core/bash-5.2.037-1-aarch64.pkg.tar.xz": []byte("alarm bash"),
|
|
|
|
|
"/aarch64/core/bash-5.2.037-1-aarch64.pkg.tar.xz.sig": []byte("alarm bash signature"),
|
|
|
|
|
}
|
|
|
|
|
upstream := newArchUpstream(t, files)
|
|
|
|
|
proxy := newArchTestProxy(t, upstream)
|
|
|
|
|
h := NewArchHandler(proxy, "http://proxy.example", map[string]string{"archlinuxarm": upstream.URL})
|
|
|
|
|
|
|
|
|
|
for path, want := range files {
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/archlinuxarm"+path, nil), path, want)
|
|
|
|
|
}
|
|
|
|
|
upstream.available.Store(false)
|
|
|
|
|
for path, want := range files {
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/archlinuxarm"+path, nil), path+" offline", want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestArchHandler_RepositoriesDoNotShareCache checks that two repositories
|
|
|
|
|
// with the same path keep their own bytes, for databases and packages.
|
|
|
|
|
func TestArchHandler_RepositoriesDoNotShareCache(t *testing.T) {
|
|
|
|
|
const dbPath, pkgPath = "/core/os/x86_64/core.db", "/core/os/x86_64/bash-5.2.037-1-x86_64.pkg.tar.zst"
|
|
|
|
|
a := newArchUpstream(t, map[string][]byte{dbPath: []byte("db A"), pkgPath: []byte("pkg A")})
|
|
|
|
|
b := newArchUpstream(t, map[string][]byte{dbPath: []byte("db B"), pkgPath: []byte("pkg B")})
|
|
|
|
|
proxy := newArchTestProxy(t, a)
|
|
|
|
|
proxy.HTTPClient = http.DefaultClient
|
|
|
|
|
fetcher := fetch.NewFetcher(fetch.WithHTTPClient(http.DefaultClient), fetch.WithMaxRetries(0))
|
|
|
|
|
proxy.Fetcher = fetcher
|
|
|
|
|
t.Cleanup(func() { _ = fetcher.Close() })
|
|
|
|
|
h := NewArchHandler(proxy, "http://proxy.example", map[string]string{"a": a.URL, "b": b.URL})
|
|
|
|
|
|
|
|
|
|
for _, repo := range []string{"a", "b"} {
|
|
|
|
|
suffix := map[string]string{"a": "A", "b": "B"}[repo]
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/"+repo+dbPath, nil), repo+" db", []byte("db "+suffix))
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/"+repo+pkgPath, nil), repo+" pkg", []byte("pkg "+suffix))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestArchHandler_PackageHead covers HEAD: headers without a body. Range
|
|
|
|
|
// requests (pacman resuming a .part download) go through ServeArtifactRequest
|
|
|
|
|
// and are covered by its tests on seekable storage.
|
|
|
|
|
func TestArchHandler_PackageHead(t *testing.T) {
|
|
|
|
|
const path = "/core/os/x86_64/bash-5.2.037-1-x86_64.pkg.tar.zst"
|
|
|
|
|
pkg := []byte("0123456789 package bytes")
|
|
|
|
|
upstream := newArchUpstream(t, map[string][]byte{path: pkg})
|
|
|
|
|
proxy := newArchTestProxy(t, upstream)
|
|
|
|
|
h := NewArchHandler(proxy, "http://proxy.example", map[string]string{"archlinux": upstream.URL})
|
|
|
|
|
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/archlinux"+path, nil), "seed", pkg)
|
|
|
|
|
|
|
|
|
|
w := serveArchRequest(h, http.MethodHead, "/archlinux"+path, nil)
|
|
|
|
|
if w.Code != http.StatusOK || w.Body.Len() != 0 {
|
|
|
|
|
t.Errorf("HEAD: status = %d, body length = %d, want 200 and 0", w.Code, w.Body.Len())
|
|
|
|
|
}
|
|
|
|
|
if got := w.Header().Get("Content-Length"); got != fmt.Sprint(len(pkg)) {
|
|
|
|
|
t.Errorf("HEAD Content-Length = %q, want %d", got, len(pkg))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestArchHandler_PackageDownloadSendsUpstreamAuth covers upstream.auth for
|
|
|
|
|
// package downloads; databases are covered in
|
|
|
|
|
// TestArchHandler_DatabasesServedUnchanged.
|
|
|
|
|
func TestArchHandler_PackageDownloadSendsUpstreamAuth(t *testing.T) {
|
|
|
|
|
const path = "/private/os/x86_64/tool-1.0-1-x86_64.pkg.tar.zst"
|
|
|
|
|
upstream := newArchUpstream(t, map[string][]byte{path: []byte("private package")})
|
|
|
|
|
proxy := newArchTestProxy(t, upstream)
|
|
|
|
|
client := upstream.Client()
|
|
|
|
|
client.Transport = &authRoundTripper{base: client.Transport, header: "Authorization", value: "Bearer arch-token"}
|
|
|
|
|
fetcher := fetch.NewFetcher(fetch.WithHTTPClient(client), fetch.WithMaxRetries(0))
|
|
|
|
|
proxy.Fetcher = fetcher
|
|
|
|
|
t.Cleanup(func() { _ = fetcher.Close() })
|
|
|
|
|
h := NewArchHandler(proxy, "http://proxy.example", map[string]string{"private": upstream.URL})
|
|
|
|
|
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/private"+path, nil), path, []byte("private package"))
|
|
|
|
|
if got, _ := upstream.auth.Load().(string); got != "Bearer arch-token" {
|
|
|
|
|
t.Errorf("Authorization = %q, want %q", got, "Bearer arch-token")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestArchHandler_OtherFilesProxiedUncached covers files that are neither
|
|
|
|
|
// databases nor packages, such as lastupdate.
|
|
|
|
|
func TestArchHandler_OtherFilesProxiedUncached(t *testing.T) {
|
|
|
|
|
upstream := newArchUpstream(t, map[string][]byte{"/lastupdate": []byte("1760000000")})
|
|
|
|
|
proxy := newArchTestProxy(t, upstream)
|
|
|
|
|
h := NewArchHandler(proxy, "http://proxy.example", map[string]string{"archlinux": upstream.URL})
|
|
|
|
|
|
|
|
|
|
for i := range 2 {
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/archlinux/lastupdate", nil), fmt.Sprint("lastupdate ", i), []byte("1760000000"))
|
|
|
|
|
}
|
|
|
|
|
if got := upstream.hitCount("/lastupdate"); got != 2 {
|
|
|
|
|
t.Errorf("upstream hits = %d, want 2 (not cached)", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func serveArchRequest(h *ArchHandler, method, target string, header http.Header) *httptest.ResponseRecorder {
|
|
|
|
|
req := httptest.NewRequest(method, target, nil)
|
|
|
|
|
for k, v := range header {
|
|
|
|
|
req.Header[k] = v
|
|
|
|
|
}
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
h.Routes().ServeHTTP(w, req)
|
|
|
|
|
return w
|
|
|
|
|
}
|
2026-11-09 11:57:39 +01:00
|
|
|
|
|
|
|
|
// TestArchHandler_DatabaseValidatorsDescribeBody checks a fetched database
|
|
|
|
|
// goes out with its own Last-Modified, not that of the cache row, which
|
|
|
|
|
// another instance may have rewritten while the signature was fetched.
|
|
|
|
|
func TestArchHandler_DatabaseValidatorsDescribeBody(t *testing.T) {
|
|
|
|
|
const dbPath, sigPath = "/core/os/x86_64/core.db", "/core/os/x86_64/core.db.sig"
|
|
|
|
|
fetched := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)
|
|
|
|
|
rewritten := fetched.Add(time.Hour)
|
|
|
|
|
upstream := newArchUpstream(t, map[string][]byte{dbPath: []byte("core-db-v1"), sigPath: []byte("sig")})
|
|
|
|
|
upstream.modified[dbPath] = fetched
|
|
|
|
|
proxy := newArchTestProxy(t, upstream)
|
|
|
|
|
h := NewArchHandler(proxy, "http://proxy.example", map[string]string{"archlinux": upstream.URL})
|
|
|
|
|
key := h.metadataCacheKey("archlinux", upstream.URL, "core/os/x86_64/core.db")
|
|
|
|
|
upstream.served = func(path string) {
|
|
|
|
|
if path != sigPath {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
entry, err := proxy.DB.GetMetadataCache(archEcosystem, key)
|
|
|
|
|
if err != nil || entry == nil {
|
|
|
|
|
t.Errorf("database row while fetching the signature: %v", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
entry.LastModified = sql.NullTime{Time: rewritten, Valid: true}
|
|
|
|
|
if err := proxy.DB.UpsertMetadataCache(entry); err != nil {
|
|
|
|
|
t.Error(err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
w := serveArchRequest(h, http.MethodGet, "/archlinux"+dbPath, nil)
|
|
|
|
|
assertArchBody(t, w, "database", []byte("core-db-v1"))
|
|
|
|
|
if got := w.Header().Get("Last-Modified"); got != fetched.Format(http.TimeFormat) {
|
|
|
|
|
t.Errorf("Last-Modified = %q, want the fetched database's %q", got, fetched.Format(http.TimeFormat))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestArchHandler_StaleDatabaseMarkedWithoutTTL checks the stale fallback
|
|
|
|
|
// carries the Warning header with metadata_ttl 0.
|
|
|
|
|
func TestArchHandler_StaleDatabaseMarkedWithoutTTL(t *testing.T) {
|
|
|
|
|
db := []byte("core-db-bytes")
|
|
|
|
|
upstream := newArchUpstream(t, map[string][]byte{"/core/os/x86_64/core.db": db})
|
|
|
|
|
proxy := newArchTestProxy(t, upstream)
|
|
|
|
|
proxy.MetadataTTL = 0
|
|
|
|
|
h := NewArchHandler(proxy, "http://proxy.example", map[string]string{"archlinux": upstream.URL})
|
|
|
|
|
|
|
|
|
|
assertArchBody(t, serveArchRequest(h, http.MethodGet, "/archlinux/core/os/x86_64/core.db", nil), "seed", db)
|
|
|
|
|
upstream.available.Store(false)
|
|
|
|
|
w := serveArchRequest(h, http.MethodGet, "/archlinux/core/os/x86_64/core.db", nil)
|
|
|
|
|
assertArchBody(t, w, "stale", db)
|
|
|
|
|
if got := w.Header().Get("Warning"); got != staleWarning {
|
|
|
|
|
t.Errorf("Warning = %q, want %q", got, staleWarning)
|
|
|
|
|
}
|
|
|
|
|
}
|