Files
artifactapi/internal/virtual/gems_merger_test.go
T
unkin-agent 18c58dc9c7 Add gems virtual repositories (#154)
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>
2026-10-18 02:07:52 +11:00

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)
}
}