18c58dc9c7
Bundler needs one source for internal and public gems, but gems virtuals have no merger, so a local gems repo and rubygems.org cannot be served together. - merge `info/<gem>` across members (member order wins a duplicate version-platform); pass a single-member gem through verbatim - regenerate `versions` lines and md5s for gems several members list; union `names` - 302 `gems/*.gem` and `quick/Marshal.4.8/*` to the member owning that version, via the shared member-path validation - return 502 on a member error; cache merges in the shared virtual cache; route HEAD on virtuals - add `docs/gems/virtual.md` and a stock `ruby:3.3` bundler e2e-docker case Reviewed-on: #154 Co-authored-by: unkin-agent <unkin-agent@unkin.net> Co-committed-by: unkin-agent <unkin-agent@unkin.net>
181 lines
7.4 KiB
Go
181 lines
7.4 KiB
Go
package virtual
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.unkin.net/unkin/artifactapi/internal/provider/gems"
|
|
"git.unkin.net/unkin/artifactapi/pkg/models"
|
|
)
|
|
|
|
func TestMergeGemsInfoMemberOrderWins(t *testing.T) {
|
|
local := GemsMember{RemoteName: "internal", Body: []byte("---\n1.0.0 |checksum:aa\n1.0.0-java |checksum:ab\n")}
|
|
remote := GemsMember{RemoteName: "rubygems", Body: []byte("---\n0.9.0 |checksum:99\n1.0.0 |checksum:bb\n2.0.0 rack:>= 0|checksum:cc,ruby:>= 3.0\n")}
|
|
d, err := MergeGemsInfo([]GemsMember{local, {RemoteName: "empty"}, remote})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := "---\n1.0.0 |checksum:aa\n1.0.0-java |checksum:ab\n0.9.0 |checksum:99\n2.0.0 rack:>= 0|checksum:cc,ruby:>= 3.0\n"
|
|
if string(d.Body) != want {
|
|
t.Fatalf("merged info =\n%q\nwant\n%q", d.Body, want)
|
|
}
|
|
for k, owner := range map[string]string{"1.0.0": "internal", "1.0.0-java": "internal", "0.9.0": "rubygems", "2.0.0": "rubygems"} {
|
|
if d.Owners[k] != owner {
|
|
t.Errorf("owner of %s = %q, want %q", k, d.Owners[k], owner)
|
|
}
|
|
}
|
|
|
|
odd := GemsMember{RemoteName: "rubygems", Body: []byte("---\n1.0.0 |checksum:bb\n\n")}
|
|
if d, _ := MergeGemsInfo([]GemsMember{{RemoteName: "internal"}, odd}); string(d.Body) != string(odd.Body) {
|
|
t.Fatalf("a gem only one member has must pass through verbatim, got %q", d.Body)
|
|
}
|
|
if _, err := MergeGemsInfo([]GemsMember{{RemoteName: "a"}}); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("want ErrNotFound, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMergeGemsVersions(t *testing.T) {
|
|
local := GemsMember{RemoteName: "internal", Body: []byte("created_at: 2026-10-18T00:00:00Z\n---\nhello 0.1.0 11\nrack 9.0.0 22\n")}
|
|
remote := GemsMember{RemoteName: "rubygems", Body: []byte("created_at: 2024-04-01T00:00:05Z\n---\n" +
|
|
"rack 1.0.0,2.0.0 33\nrails 7.0.0 44\nrack -1.0.0,3.0.0 55\n")}
|
|
merged := &GemsDoc{Body: []byte("---\n9.0.0 |checksum:x\n2.0.0 |checksum:y\n3.0.0 |checksum:z\n")}
|
|
var asked []string
|
|
at := time.Date(2026, 10, 18, 1, 2, 3, 0, time.UTC)
|
|
body, err := MergeGemsVersions([]GemsMember{local, remote}, func(name string) (*GemsDoc, error) {
|
|
asked = append(asked, name)
|
|
return merged, nil
|
|
}, at)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := "created_at: 2026-10-18T01:02:03Z\n---\nhello 0.1.0 11\nrails 7.0.0 44\nrack 9.0.0,2.0.0,3.0.0 " + gems.MD5Hex(merged.Body) + "\n"
|
|
if string(body) != want {
|
|
t.Fatalf("versions =\n%q\nwant\n%q", body, want)
|
|
}
|
|
if len(asked) != 1 || asked[0] != "rack" {
|
|
t.Fatalf("merged info fetched for %v, want only the shared gem", asked)
|
|
}
|
|
|
|
if _, err := MergeGemsVersions([]GemsMember{{RemoteName: "x", Body: []byte("no separator")}}, nil, at); err == nil {
|
|
t.Fatal("malformed member versions must fail")
|
|
}
|
|
if _, err := MergeGemsVersions([]GemsMember{{RemoteName: "x"}}, nil, at); err == nil {
|
|
t.Fatal("a member without versions must fail")
|
|
}
|
|
}
|
|
|
|
func TestMergeGemsNames(t *testing.T) {
|
|
body, err := MergeGemsNames([]GemsMember{{RemoteName: "a", Body: []byte("---\nzeta\nalpha\n")}, {RemoteName: "b", Body: []byte("---\nalpha\nmid\n")}})
|
|
if err != nil || string(body) != "---\nalpha\nmid\nzeta\n" {
|
|
t.Fatalf("names = %q %v", body, err)
|
|
}
|
|
}
|
|
|
|
func gemsEngine(fetch func(name, path string) (*GemsMember, error)) *Engine {
|
|
return &Engine{
|
|
mergeTTL: time.Minute,
|
|
getRemote: func(_ context.Context, name string) (*models.Remote, error) {
|
|
rt := models.RepoTypeRemote
|
|
if name == "internal" {
|
|
rt = models.RepoTypeLocal
|
|
}
|
|
return &models.Remote{Name: name, RepoType: rt}, nil
|
|
},
|
|
gemsMember: func(_ context.Context, name, path string) (*GemsMember, error) { return fetch(name, path) },
|
|
}
|
|
}
|
|
|
|
func gemsVirt(members ...string) models.Virtual {
|
|
return models.Virtual{Name: "gems", PackageType: models.PackageGems, Members: members}
|
|
}
|
|
|
|
func TestGemsRedirectToOwner(t *testing.T) {
|
|
var mu sync.Mutex
|
|
asked := map[string]int{}
|
|
e := gemsEngine(func(name, path string) (*GemsMember, error) {
|
|
mu.Lock()
|
|
asked[name+" "+path]++
|
|
mu.Unlock()
|
|
switch name + " " + path {
|
|
case "internal info/foo-2fa":
|
|
return &GemsMember{RemoteName: name, Body: []byte("---\n1.0.0 |checksum:a\n")}, nil
|
|
case "rubygems info/foo-2fa":
|
|
return &GemsMember{RemoteName: name, Body: []byte("---\n1.0.0 |checksum:b\n1.1.0-x86_64-linux |checksum:c\n")}, nil
|
|
case "rubygems info/foo":
|
|
return &GemsMember{RemoteName: name, Body: []byte("---\n2.0.0 |checksum:d\n")}, nil
|
|
}
|
|
return &GemsMember{RemoteName: name}, nil
|
|
})
|
|
virt := gemsVirt("internal", "rubygems")
|
|
for path, want := range map[string]string{
|
|
"gems/foo-2fa-1.0.0.gem": "http://host/api/v1/local/internal/gems/foo-2fa-1.0.0.gem",
|
|
"gems/foo-2fa-1.1.0-x86_64-linux.gem": "http://host/api/v1/remote/rubygems/gems/foo-2fa-1.1.0-x86_64-linux.gem",
|
|
"quick/Marshal.4.8/foo-2fa-1.0.0.gemspec.rz": "http://host/api/v1/local/internal/quick/Marshal.4.8/foo-2fa-1.0.0.gemspec.rz",
|
|
"quick/Marshal.4.8/foo-2.0.0.gemspec.rz": "http://host/api/v1/remote/rubygems/quick/Marshal.4.8/foo-2.0.0.gemspec.rz",
|
|
} {
|
|
loc, err := e.GemsRedirect(context.Background(), virt, path, "", "http://host")
|
|
if err != nil || loc != want {
|
|
t.Errorf("%s -> %q %v, want %q", path, loc, err, want)
|
|
}
|
|
}
|
|
for _, path := range []string{"gems/foo-2fa-9.9.9.gem", "gems/nodash.gem", "gems/x/../foo-2.0.0.gem", "other/foo-2.0.0.gem", "specs.4.8.gz"} {
|
|
if _, err := e.GemsRedirect(context.Background(), virt, path, "", "http://host"); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("%s: want ErrNotFound, got %v", path, err)
|
|
}
|
|
}
|
|
if asked["internal info/foo-2fa"] != 1 {
|
|
t.Fatalf("merged info must be cached across redirects, internal asked %d times", asked["internal info/foo-2fa"])
|
|
}
|
|
}
|
|
|
|
func TestFetchGemsFailsClosed(t *testing.T) {
|
|
e := gemsEngine(func(name, path string) (*GemsMember, error) {
|
|
if name == "rubygems" {
|
|
return nil, errors.New("upstream down")
|
|
}
|
|
return &GemsMember{RemoteName: name, Body: []byte("created_at: x\n---\nhello 0.1.0 11\n")}, nil
|
|
})
|
|
for _, path := range []string{"versions", "names", "info/hello"} {
|
|
if _, _, err := e.FetchGems(context.Background(), gemsVirt("internal", "rubygems"), path); err == nil || errors.Is(err, ErrNotFound) {
|
|
t.Errorf("%s: want member error, got %v", path, err)
|
|
}
|
|
}
|
|
if _, err := e.GemsRedirect(context.Background(), gemsVirt("internal", "rubygems"), "gems/hello-0.1.0.gem", "", "http://h"); err == nil || errors.Is(err, ErrNotFound) {
|
|
t.Errorf("redirect: want member error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFetchGemsVersionsUsesMergedInfo(t *testing.T) {
|
|
e := gemsEngine(func(name, path string) (*GemsMember, error) {
|
|
docs := map[string]string{
|
|
"internal versions": "created_at: x\n---\nrack 9.0.0 11\n",
|
|
"rubygems versions": "created_at: y\n---\nrack 3.0.0 22\nrails 7.0.0 33\n",
|
|
"internal info/rack": "---\n9.0.0 |checksum:a\n",
|
|
"rubygems info/rack": "---\n3.0.0 |checksum:b\n",
|
|
}
|
|
if d, ok := docs[name+" "+path]; ok {
|
|
return &GemsMember{RemoteName: name, Body: []byte(d)}, nil
|
|
}
|
|
return &GemsMember{RemoteName: name}, nil
|
|
})
|
|
virt := gemsVirt("internal", "rubygems")
|
|
info, ct, err := e.FetchGems(context.Background(), virt, "info/rack")
|
|
if err != nil || !strings.HasPrefix(ct, "text/plain") {
|
|
t.Fatalf("info: %v %q", err, ct)
|
|
}
|
|
versions, _, err := e.FetchGems(context.Background(), virt, "versions")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(versions), "\nrails 7.0.0 33\nrack 9.0.0,3.0.0 "+gems.MD5Hex(info)+"\n") {
|
|
t.Fatalf("versions = %q", versions)
|
|
}
|
|
if _, _, err := e.FetchGems(context.Background(), virt, "info/../x"); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("bad name: %v", err)
|
|
}
|
|
}
|