231 lines
7.8 KiB
Go
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)
|
|
}
|
|
}
|