diff --git a/internal/provider/docker/docker_extra_test.go b/internal/provider/docker/docker_extra_test.go new file mode 100644 index 0000000..e07f3e6 --- /dev/null +++ b/internal/provider/docker/docker_extra_test.go @@ -0,0 +1,53 @@ +package docker + +import ( + "context" + "testing" + + "git.unkin.net/unkin/artifactapi/internal/provider" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func TestDockerClassifyBranches(t *testing.T) { + p := &Provider{} + if p.Classify("library/nginx/tags/list") != provider.Mutable { + t.Error("tags/list should be mutable") + } + if p.Classify("library/nginx/manifests/latest") != provider.Mutable { + t.Error("tag manifest should be mutable") + } + if p.Classify("library/nginx/manifests/sha256:abcdef") != provider.Immutable { + t.Error("digest manifest should be immutable") + } + if p.Classify("library/nginx/blobs/sha256:abc") != provider.Immutable { + t.Error("blob should be immutable") + } +} + +func TestDockerContentType(t *testing.T) { + p := &Provider{} + if p.ContentType("x/blobs/sha256:abc") != "application/octet-stream" { + t.Error("blob content type") + } + if p.ContentType("x/manifests/latest") != "application/vnd.docker.distribution.manifest.v2+json" { + t.Error("manifest content type") + } + if p.ContentType("x/tags/list") != "application/json" { + t.Error("default content type") + } +} + +func TestDockerRewriteAndAuth(t *testing.T) { + p := &Provider{} + if out, err := p.RewriteResponse([]byte("x"), models.Remote{}, "http://p"); out != nil || err != nil { + t.Error("docker never rewrites") + } + h, _ := p.AuthHeaders(context.Background(), models.Remote{Username: "u", Password: "p"}) + if h.Get("Authorization") == "" { + t.Error("expected basic auth header") + } + h, _ = p.AuthHeaders(context.Background(), models.Remote{}) + if h.Get("Authorization") != "" { + t.Error("no creds, no header") + } +} diff --git a/internal/provider/pypi/pypi_test.go b/internal/provider/pypi/pypi_test.go index f740186..ecabe0e 100644 --- a/internal/provider/pypi/pypi_test.go +++ b/internal/provider/pypi/pypi_test.go @@ -2,6 +2,8 @@ package pypi import ( "context" + "net/http" + "net/http/httptest" "strings" "testing" @@ -140,6 +142,33 @@ func TestGenerateLocalIndex(t *testing.T) { } } +func TestServeLocalIndexHTTP(t *testing.T) { + p := &Provider{} + fs := &fakeFileStore{ + packages: []string{"foo"}, + files: map[string][]provider.FileEntry{ + "foo/": {{FilePath: "foo/foo-1.0-py3-none-any.whl", ContentHash: "sha256:aaa"}}, + }, + } + serve := func(path string) (*httptest.ResponseRecorder, bool) { + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/"+path, nil) + handled := p.ServeLocalIndex(w, r, fs, "local", path) + return w, handled + } + + if w, ok := serve("simple/"); !ok || w.Code != 200 || !strings.Contains(w.Body.String(), "foo") { + t.Errorf("simple index: handled=%v code=%d body=%s", ok, w.Code, w.Body.String()) + } + if w, ok := serve("simple/foo/"); !ok || w.Code != 200 || !strings.Contains(w.Body.String(), "foo-1.0-py3-none-any.whl") { + t.Errorf("package index: handled=%v code=%d body=%s", ok, w.Code, w.Body.String()) + } + // Non-simple paths are not handled. + if _, ok := serve("packages/foo.whl"); ok { + t.Error("non-index path should not be handled") + } +} + func TestAuthHeaders(t *testing.T) { h, _ := (&Provider{}).AuthHeaders(context.Background(), models.Remote{Username: "u", Password: "p"}) if h.Get("Authorization") == "" { diff --git a/internal/proxy/classifier_extra_test.go b/internal/proxy/classifier_extra_test.go new file mode 100644 index 0000000..dd5403a --- /dev/null +++ b/internal/proxy/classifier_extra_test.go @@ -0,0 +1,52 @@ +package proxy + +import ( + "testing" + + "git.unkin.net/unkin/artifactapi/internal/provider" + _ "git.unkin.net/unkin/artifactapi/internal/provider/generic" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func TestClassifierBranches(t *testing.T) { + gp, err := provider.Get(models.PackageGeneric) + if err != nil { + t.Fatal(err) + } + c := NewClassifier(gp) + + if c.Classify(models.Remote{Blocklist: []string{`\.exe$`}}, "x.exe") != ClassDenied { + t.Error("blocklist match should be denied") + } + // Allowlist present but path doesn't match -> denied. + allow := models.Remote{Patterns: []string{`^allowed/`}} + if c.Classify(allow, "other/x") != ClassDenied { + t.Error("non-allowlisted path should be denied") + } + if c.Classify(allow, "allowed/x") != ClassImmutable { + t.Error("allowlisted generic path should be immutable") + } + if c.Classify(models.Remote{MutablePatterns: []string{`index$`}}, "a/index") != ClassMutable { + t.Error("mutable pattern override failed") + } + if c.Classify(models.Remote{ImmutablePatterns: []string{`\.bin$`}}, "a.bin") != ClassImmutable { + t.Error("immutable pattern failed") + } + // An invalid regex is skipped (not treated as a match) rather than denying. + if c.Classify(models.Remote{Blocklist: []string{`[invalid`}}, "anything") == ClassDenied { + t.Error("invalid blocklist regex should be skipped, not deny everything") + } +} + +func TestClassificationString(t *testing.T) { + for c, want := range map[Classification]string{ + ClassImmutable: "immutable", + ClassMutable: "mutable", + ClassDenied: "denied", + Classification(99): "unknown", + } { + if c.String() != want { + t.Errorf("Classification(%d).String() = %q, want %q", c, c.String(), want) + } + } +} diff --git a/internal/proxy/engine_test.go b/internal/proxy/engine_test.go index de4f73e..87ce690 100644 --- a/internal/proxy/engine_test.go +++ b/internal/proxy/engine_test.go @@ -108,6 +108,10 @@ func mockUpstream(w http.ResponseWriter, r *http.Request) { w.Write([]byte("protected payload 2")) case "/token": w.Write([]byte(`{"token":"minted-token","expires_in":300}`)) + case "/token-at": + w.Write([]byte(`{"access_token":"at-token"}`)) + case "/token-500": + w.WriteHeader(http.StatusInternalServerError) case "/err500": w.WriteHeader(http.StatusInternalServerError) case "/noauth": // 401 with an unusable challenge (no realm) @@ -451,6 +455,35 @@ func TestHeadCachedIndex(t *testing.T) { } } +func TestFetchBearerTokenVariants(t *testing.T) { + requireStack(t) + ctx := context.Background() + + // access_token field + service/scope params + basic auth on the token req. + tok, _, err := fetchBearerToken(ctx, `Bearer realm="`+upstream.URL+`/token-at",service="reg",scope="repo:pull"`, models.Remote{Username: "u", Password: "p"}) + if err != nil || tok != "at-token" { + t.Errorf("access_token variant: tok=%q err=%v", tok, err) + } + // Token endpoint error status. + if _, _, err := fetchBearerToken(ctx, `Bearer realm="`+upstream.URL+`/token-500"`, models.Remote{}); err == nil { + t.Error("expected error for 500 token endpoint") + } +} + +func TestCheckUpstreamChanged(t *testing.T) { + requireStack(t) + ctx := context.Background() + r := genericRemote("eng-check") + // A non-matching ETag yields a normal 200 (not 304): not modified is false. + notModified, err := testEngine.checkUpstream(ctx, r, "pkg", `"stale-etag"`, prov(t, models.PackageNPM)) + if err != nil { + t.Fatalf("checkUpstream: %v", err) + } + if notModified { + t.Error("mismatched etag should report modified (notModified=false)") + } +} + func TestUpstreamErrorUnwrap(t *testing.T) { base := context.DeadlineExceeded ue := &UpstreamError{Err: base}