Files
artifactapi/internal/provider/vcs/vcs_test.go
T

231 lines
7.8 KiB
Go

package vcs
import (
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
const (
shaA = "4f2cfe92e3499f8581bf39db77e0a1431fa7b55c"
shaB = "7fa5434dbc083c853eef435d1915020345219d2f"
)
func TestParsePath(t *testing.T) {
tests := []struct {
path string
ok bool
kind kind
name string
}{
{"cli/cli/archive/refs/tags/v2.60.0.tar.gz", true, tagArchive, "v2.60.0"},
{"/cli/cli/archive/refs/tags/v2.60.0.zip", true, tagArchive, "v2.60.0"},
{"o/r/archive/refs/tags/release/1.0.tar.gz", true, tagArchive, "release/1.0"},
{"o/r/archive/refs/heads/feature/x.tar.gz", true, branchArchive, "feature/x"},
{"o/r/archive/" + shaA + ".tar.gz", true, commitArchive, shaA},
{"o/r/releases/download/v1.0/tool_linux_amd64.tar.gz", true, releaseAsset, "tool_linux_amd64.tar.gz"},
{"o/r/releases/latest/download/tool.zip", true, latestReleaseAsset, "tool.zip"},
{"o/r/archive/main.tar.gz", false, 0, ""},
{"o/r/archive/" + shaA[:7] + ".tar.gz", false, 0, ""},
{"o/r/archive/refs/tags/v1.tar.bz2", false, 0, ""},
{"o/r/archive/refs/tags/.tar.gz", false, 0, ""},
{"../r/archive/refs/tags/v1.tar.gz", false, 0, ""},
{"o/../archive/refs/tags/v1.tar.gz", false, 0, ""},
{"o/r/archive/refs/tags/../../../etc/passwd.tar.gz", false, 0, ""},
{"o/r/archive/refs/heads/a//b.tar.gz", false, 0, ""},
{"o/r/archive/refs/heads/.hidden.tar.gz", false, 0, ""},
{"o/r/archive/refs/heads/a..b.tar.gz", false, 0, ""},
{"o/r/archive/refs/tags/v1%2f.tar.gz", false, 0, ""},
{"o/r/releases/download/v1/../x", false, 0, ""},
{"o/r/releases/download/asset", false, 0, ""},
{"o/r/raw/main/README.md", false, 0, ""},
{"o/r", false, 0, ""},
}
for _, tt := range tests {
ref, ok := parsePath(tt.path)
if ok != tt.ok || (ok && (ref.Kind != tt.kind || ref.Name != tt.name)) {
t.Errorf("parsePath(%q) = %+v, %v; want kind %v name %q ok %v", tt.path, ref, ok, tt.kind, tt.name, tt.ok)
}
}
}
func TestClassify(t *testing.T) {
p := New()
for path, want := range map[string]provider.Mutability{
"o/r/archive/refs/tags/v1.tar.gz": provider.Immutable,
"o/r/archive/" + shaA + ".zip": provider.Immutable,
"o/r/releases/download/v1/a.bin": provider.Immutable,
"o/r/archive/refs/heads/main.tar.gz": provider.Mutable,
"o/r/releases/latest/download/webui.zip": provider.Mutable,
} {
if got := p.Classify(path); got != want {
t.Errorf("Classify(%q) = %v, want %v", path, got, want)
}
}
}
type upstream struct {
srv *httptest.Server
hits atomic.Int32
commit atomic.Value
status atomic.Int32
accept atomic.Value
path atomic.Value
}
func newUpstream(t *testing.T, gitea bool) *upstream {
u := &upstream{}
u.commit.Store(shaA)
u.status.Store(http.StatusOK)
u.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u.hits.Add(1)
u.accept.Store(r.Header.Get("Accept"))
u.path.Store(r.URL.EscapedPath())
if s := int(u.status.Load()); s != http.StatusOK {
w.WriteHeader(s)
return
}
if gitea {
w.Write([]byte(`{"name":"main","commit":{"id":"` + u.commit.Load().(string) + `"}}`))
return
}
w.Write([]byte(u.commit.Load().(string)))
}))
t.Cleanup(u.srv.Close)
return u
}
func serve(p *Provider, remote models.Remote, path string) (*httptest.ResponseRecorder, bool) {
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/api/v1/remote/"+remote.Name+"/"+path, nil)
return w, p.ServeRemote(w, r, remote, path, "http://proxy", nil)
}
func TestServeRemoteGiteaBranchRedirectsToCommit(t *testing.T) {
u := newUpstream(t, true)
p := New()
remote := models.Remote{Name: "gitea", BaseURL: u.srv.URL, MutableTTL: 3600}
w, handled := serve(p, remote, "unkin/app/archive/refs/heads/feature/x.zip")
if !handled || w.Code != http.StatusFound {
t.Fatalf("handled=%v code=%d", handled, w.Code)
}
if got, want := w.Header().Get("Location"), "http://proxy/api/v1/remote/gitea/unkin/app/archive/"+shaA+".zip"; got != want {
t.Errorf("Location = %q, want %q", got, want)
}
if got := u.path.Load(); got != "/api/v1/repos/unkin/app/branches/feature/x" {
t.Errorf("api path = %q", got)
}
}
func TestServeRemoteGitHubBranchUsesAPI(t *testing.T) {
u := newUpstream(t, false)
p := New()
p.githubAPI = u.srv.URL
remote := models.Remote{Name: "gh", BaseURL: "https://github.com", MutableTTL: 3600}
w, _ := serve(p, remote, "cli/cli/archive/refs/heads/trunk.tar.gz")
if !strings.HasSuffix(w.Header().Get("Location"), "/cli/cli/archive/"+shaA+".tar.gz") {
t.Fatalf("Location = %q", w.Header().Get("Location"))
}
if u.path.Load() != "/repos/cli/cli/commits/trunk" || u.accept.Load() != "application/vnd.github.sha" {
t.Errorf("api path %q accept %q", u.path.Load(), u.accept.Load())
}
}
func TestServeRemoteBranchResolutionHonoursMutableTTL(t *testing.T) {
u := newUpstream(t, true)
p := New()
cached := models.Remote{Name: "cached", BaseURL: u.srv.URL, MutableTTL: 3600}
path := "o/r/archive/refs/heads/main.tar.gz"
serve(p, cached, path)
u.commit.Store(shaB)
w, _ := serve(p, cached, path)
if u.hits.Load() != 1 || !strings.Contains(w.Header().Get("Location"), shaA) {
t.Fatalf("within ttl: hits=%d location=%q", u.hits.Load(), w.Header().Get("Location"))
}
expired := cached
expired.Name = "expired"
expired.MutableTTL = 0
serve(p, expired, path)
w, _ = serve(p, expired, path)
if u.hits.Load() != 3 || !strings.Contains(w.Header().Get("Location"), shaB) {
t.Fatalf("past ttl: hits=%d location=%q", u.hits.Load(), w.Header().Get("Location"))
}
}
func TestServeRemoteBranchUpstreamFailure(t *testing.T) {
path := "o/r/archive/refs/heads/main.tar.gz"
for _, tc := range []struct {
name string
stale bool
want int
}{
{"stale", true, http.StatusFound},
{"strict", false, http.StatusBadGateway},
} {
t.Run(tc.name, func(t *testing.T) {
u := newUpstream(t, true)
p := New()
remote := models.Remote{Name: tc.name, BaseURL: u.srv.URL, StaleOnError: tc.stale}
serve(p, remote, path)
u.status.Store(http.StatusInternalServerError)
if w, _ := serve(p, remote, path); w.Code != tc.want {
t.Fatalf("code = %d, want %d", w.Code, tc.want)
}
})
}
}
func TestServeRemoteBranchNotFound(t *testing.T) {
u := newUpstream(t, false)
u.status.Store(http.StatusUnprocessableEntity)
p := New()
p.githubAPI = u.srv.URL
w, _ := serve(p, models.Remote{Name: "gh", BaseURL: "https://github.com"}, "o/r/archive/refs/heads/nope.tar.gz")
if w.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", w.Code)
}
}
func TestServeRemoteRejectsBadCommit(t *testing.T) {
u := newUpstream(t, true)
u.commit.Store("../../etc")
w, _ := serve(New(), models.Remote{Name: "g", BaseURL: u.srv.URL}, "o/r/archive/refs/heads/main.tar.gz")
if w.Code != http.StatusBadGateway {
t.Fatalf("code = %d, want 502", w.Code)
}
}
func TestServeRemoteAllowlist(t *testing.T) {
u := newUpstream(t, true)
remote := models.Remote{Name: "g", BaseURL: u.srv.URL, Patterns: []string{"^allowed/repo/"}}
if w, _ := serve(New(), remote, "other/repo/archive/refs/heads/main.tar.gz"); w.Code != http.StatusForbidden {
t.Fatalf("code = %d, want 403", w.Code)
}
if u.hits.Load() != 0 {
t.Fatalf("denied branch must not hit the API")
}
}
func TestServeRemotePassesThroughAndRejects(t *testing.T) {
p := New()
remote := models.Remote{Name: "g", BaseURL: "https://github.com"}
if _, handled := serve(p, remote, "o/r/archive/refs/tags/v1.tar.gz"); handled {
t.Error("tag archives must go through the proxy engine")
}
if _, handled := serve(p, remote, "o/r/releases/download/v1/a.bin"); handled {
t.Error("release assets must go through the proxy engine")
}
if w, handled := serve(p, remote, "o/r/raw/main/x"); !handled || w.Code != http.StatusNotFound {
t.Errorf("unsupported path: handled=%v code=%d", handled, w.Code)
}
}