From a161c44575ffc2e01d6b57e0ef79003f22d6a50b Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Mon, 2 Nov 2026 22:01:44 +1100 Subject: [PATCH 1/2] Run the dockerised e2e suite with write auth enabled Real clients now authenticate every local publish against a static mock OIDC issuer, and each type checks unauthenticated publishes are refused while anonymous reads keep working. --- .gitignore | 3 + docker-compose.e2e.yml | 12 +++ e2e-docker/README.md | 7 ++ e2e-docker/ansible_local_test.go | 10 ++- e2e-docker/ansible_virtual_test.go | 4 +- e2e-docker/docker_local_test.go | 67 +++++++++++++++- e2e-docker/gems_local_test.go | 7 +- e2e-docker/gems_virtual_test.go | 4 +- e2e-docker/goproxy_local_test.go | 2 + e2e-docker/helm_local_test.go | 32 ++++++-- e2e-docker/helpers_test.go | 76 +++++++++++++++++++ e2e-docker/local_test.go | 36 +++++++-- e2e-docker/npm_local_test.go | 13 +++- e2e-docker/npm_virtual_test.go | 4 +- .../oidc/.well-known/openid-configuration | 1 + e2e-docker/puppet_local_test.go | 1 + scripts/docker-e2e.sh | 4 +- 17 files changed, 249 insertions(+), 34 deletions(-) create mode 100644 e2e-docker/oidc/.well-known/openid-configuration diff --git a/.gitignore b/.gitignore index 3ce9dad..82d4c62 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ bin/ # e2e-docker fixtures are real package files (.rpm, .tgz, .whl, .zip, ...) that # are intentionally tracked, overriding any global ignore of those extensions. !e2e-docker/fixtures/** + +# Written per run by the dockerised e2e TestMain. +e2e-docker/oidc/jwks.json diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index 9590563..3bc43b3 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -21,7 +21,17 @@ services: volumes: - ./e2e-docker/mirror-conf/b.conf:/etc/nginx/conf.d/default.conf:ro,z + # Static OIDC issuer gating writes; the suite's TestMain writes jwks.json. + mockoidc: + image: nginx:alpine + volumes: + - ./e2e-docker/oidc:/usr/share/nginx/html:ro,z + artifactapi: + environment: + ARTIFACTAPI_OIDC_ISSUER: http://mockoidc + ARTIFACTAPI_OIDC_AUDIENCE: artifactapi + ARTIFACTAPI_AUTH_UPLOADS: "true" # The host port is set via ARTIFACTAPI_PORT (see scripts/docker-e2e.sh), # defaulting to 8000; the e2e run uses 8001 to avoid colliding with a # locally-running instance. @@ -32,3 +42,5 @@ services: condition: service_started mockupstreamb: condition: service_started + mockoidc: + condition: service_started diff --git a/e2e-docker/README.md b/e2e-docker/README.md index b5dbd00..a60efd0 100644 --- a/e2e-docker/README.md +++ b/e2e-docker/README.md @@ -21,6 +21,13 @@ already-running stack. ## Coverage +- **Write auth** — the stack runs with `ARTIFACTAPI_AUTH_UPLOADS=true` against a + static `mockoidc` issuer (`oidc/`); `TestMain` generates the signing key, writes + `oidc/jwks.json` and mints the tokens. Every local publish authenticates the way + its real client does (curl Bearer/Basic, `docker login`, npm `_authToken`, + `GEM_HOST_API_KEY`, `ansible-galaxy --api-key`), and each type checks a + publish without a token or with a foreign-key token is refused while + anonymous reads still work. - **Repository lifecycle** — add / change / delete for remote, local and virtual repos. - **Caching** — one immutable artifact per remote package type (one `caching__test.go` case each) proxied through the mock upstream: first fetch `X-Artifact-Source: remote`, second `cache`, bytes diff --git a/e2e-docker/ansible_local_test.go b/e2e-docker/ansible_local_test.go index feb1b13..6c0748b 100644 --- a/e2e-docker/ansible_local_test.go +++ b/e2e-docker/ansible_local_test.go @@ -47,18 +47,20 @@ func TestLocalAnsiblePublishInstall(t *testing.T) { defer deleteRepo(t, name) script := "set -euo pipefail\n" + ansibleHelloScript + ` -ansible-galaxy collection publish /w/dist/unkin-e2e_hello-1.0.0.tar.gz --server "$SRV" --api-key unused -if ansible-galaxy collection publish /w/dist/unkin-e2e_hello-1.0.0.tar.gz --server "$SRV" --api-key unused; then echo "republish accepted"; exit 1; fi +ansible-galaxy collection publish /w/dist/unkin-e2e_hello-1.0.0.tar.gz --server "$SRV" --api-key "$TOKEN" +if ansible-galaxy collection publish /w/dist/unkin-e2e_hello-1.0.0.tar.gz --server "$SRV" --api-key "$TOKEN"; then echo "republish accepted"; exit 1; fi +cd /w/unkin/e2e_hello && sed -i 's/^version: .*/version: 1.0.1/' galaxy.yml && ansible-galaxy collection build --output-path /w/dist >/dev/null +if ansible-galaxy collection publish /w/dist/unkin-e2e_hello-1.0.1.tar.gz --server "$SRV" --api-key "$BAD"; then echo "invalid key publish accepted"; exit 1; fi ansible-galaxy collection install unkin.e2e_hello:==1.0.0 --server "$SRV" -p /w/coll ` + ansibleHelloPlay srv := strings.TrimRight(internal, "/") + "/api/v1/local/" + name + "/" out, err := exec.Command("docker", "run", "--rm", "--network", network, - "-e", "SRV="+srv, "python:3.12-slim", "bash", "-c", script).CombinedOutput() + "-e", "SRV="+srv, "-e", "TOKEN="+authToken, "-e", "BAD="+badToken, "python:3.12-slim", "bash", "-c", script).CombinedOutput() if err != nil { t.Fatalf("ansible-galaxy client run failed: %v\n%s", err, out) } - for _, want := range []string{"successfully published and imported", "HTTP Code: 409", "unkin.e2e_hello:1.0.0 was installed successfully", "PLAY hello e2e"} { + for _, want := range []string{"successfully published and imported", "HTTP Code: 409", "HTTP Code: 401", "unkin.e2e_hello:1.0.0 was installed successfully", "PLAY hello e2e"} { if !strings.Contains(string(out), want) { t.Fatalf("missing %q in client output:\n%s", want, out) } diff --git a/e2e-docker/ansible_virtual_test.go b/e2e-docker/ansible_virtual_test.go index 2c54bcc..0a8d607 100644 --- a/e2e-docker/ansible_virtual_test.go +++ b/e2e-docker/ansible_virtual_test.go @@ -35,7 +35,7 @@ func TestVirtualAnsibleInstall(t *testing.T) { base := strings.TrimRight(internal, "/") script := "set -euo pipefail\n" + ansibleHelloScript + ` -ansible-galaxy collection publish /w/dist/unkin-e2e_hello-1.0.0.tar.gz --server "$BASE/api/v1/local/$B/" --api-key unused +ansible-galaxy collection publish /w/dist/unkin-e2e_hello-1.0.0.tar.gz --server "$BASE/api/v1/local/$B/" --api-key "$TOKEN" V="$BASE/api/v1/virtual/$VIRT" ansible-galaxy collection install unkin.e2e_hello --server "$V/" -p /w/coll DL=$(python3 -c 'import json,sys,urllib.request as u;print(json.load(u.urlopen(sys.argv[1]))["download_url"])' "$V/api/v3/collections/unkin/e2e_hello/versions/1.0.0/") @@ -47,7 +47,7 @@ except u.HTTPError as e: print(e.code, e.headers["Location"])' "$DL")" ` + ansibleHelloPlay out, err := exec.Command("docker", "run", "--rm", "--network", network, - "-e", "BASE="+base, "-e", "B="+b, "-e", "VIRT="+virt, + "-e", "BASE="+base, "-e", "B="+b, "-e", "VIRT="+virt, "-e", "TOKEN="+authToken, "python:3.12-slim", "bash", "-c", script).CombinedOutput() if err != nil { t.Fatalf("ansible-galaxy client run failed: %v\n%s", err, out) diff --git a/e2e-docker/docker_local_test.go b/e2e-docker/docker_local_test.go index e35e633..0b219d7 100644 --- a/e2e-docker/docker_local_test.go +++ b/e2e-docker/docker_local_test.go @@ -8,6 +8,8 @@ import ( "encoding/hex" "fmt" "net/http" + "os" + "os/exec" "strings" "testing" ) @@ -79,8 +81,12 @@ func TestLocalDockerPushPull(t *testing.T) { const image = "team/app" const tag = "v1.0.0" - // /v2/ version check. - resp, _ := doRequest(t, http.MethodGet, api("/v2/"), nil, "") + // /v2/ version check: anonymous gets the token challenge, a token passes. + resp, _ := doRequestAuth(t, http.MethodGet, api("/v2/"), nil, "", "") + if resp.StatusCode != http.StatusUnauthorized || !strings.Contains(resp.Header.Get("WWW-Authenticate"), "/v2/token") { + t.Fatalf("anonymous /v2/ ping: status %d, challenge %q", resp.StatusCode, resp.Header.Get("WWW-Authenticate")) + } + resp, _ = doRequest(t, http.MethodGet, api("/v2/"), nil, "") if resp.StatusCode != http.StatusOK { t.Fatalf("/v2/ ping: status %d", resp.StatusCode) } @@ -169,9 +175,66 @@ func TestLocalDockerPushPull(t *testing.T) { t.Fatalf("tags/list wrong repository name: %s", body) } + assertWriteGated(t, http.MethodPut, api("/v2/docker-internal/"+image+"/manifests/v2"), manifest, manifestType, api("/v2/docker-internal/"+image+"/manifests/"+tag)) + // A now-present blob HEAD should succeed (client would skip re-upload). resp, _ = doRequest(t, http.MethodHead, api("/v2/docker-internal/"+image+"/blobs/"+layerDigest), nil, "") if resp.StatusCode != http.StatusOK { t.Fatalf("post-push blob HEAD: expected 200, got %d", resp.StatusCode) } } + +// TestLocalDockerCLIPushPull drives the host docker CLI: docker login with the +// token as password then push, a push without login rejected, an anonymous +// pull, and a buildx (buildkit) push. +func TestLocalDockerCLIPushPull(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available on the test host") + } + createRepo(t, `{"name":"docker-cli","package_type":"docker","repo_type":"local"}`) + defer deleteRepo(t, "docker-cli") + + reg := strings.TrimPrefix(baseURL(), "http://") + img := reg + "/docker-cli/e2e/hello" + cfg := t.TempDir() + docker := func(stdin []byte, args ...string) (string, error) { + cmd := exec.Command("docker", args...) + cmd.Env = append(os.Environ(), "DOCKER_CONFIG="+cfg) + cmd.Stdin = bytes.NewReader(stdin) + out, err := cmd.CombinedOutput() + return string(out), err + } + must := func(t *testing.T, stdin []byte, args ...string) { + t.Helper() + if out, err := docker(stdin, args...); err != nil { + t.Fatalf("docker %s: %v\n%s", strings.Join(args, " "), err, out) + } + } + defer func() { _, _ = docker(nil, "rmi", "-f", img+":v1", img+":anon", img+":buildx") }() + + must(t, make([]byte, 1024), "import", "-", img+":v1") // an empty tar + + must(t, []byte(authToken), "login", reg, "-u", "ci", "--password-stdin") + must(t, nil, "push", img+":v1") + + must(t, nil, "logout", reg) + must(t, nil, "tag", img+":v1", img+":anon") + if out, err := docker(nil, "push", img+":anon"); err == nil { + t.Fatalf("push without login accepted:\n%s", out) + } + must(t, nil, "rmi", img+":v1", img+":anon") + must(t, nil, "pull", img+":v1") + + t.Run("buildx", func(t *testing.T) { + t.Skip("buildkit fetches the registry token with an OAuth2 POST form the server ignores; fixed on benvin/registry-token-oauth-post") + conf := cfg + "/buildkitd.toml" + if err := os.WriteFile(conf, []byte(fmt.Sprintf("[registry.%q]\n http = true\n", reg)), 0o644); err != nil { + t.Fatal(err) + } + builder := fmt.Sprintf("e2e-%d", os.Getpid()) + must(t, nil, "buildx", "create", "--name", builder, "--driver", "docker-container", "--driver-opt", "network=host", "--buildkitd-config", conf) + defer func() { _, _ = docker(nil, "buildx", "rm", "-f", builder) }() + must(t, []byte(authToken), "login", reg, "-u", "ci", "--password-stdin") + must(t, []byte("FROM "+img+":v1\nLABEL e2e=buildx\n"), "buildx", "build", "--builder", builder, "--push", "-t", img+":buildx", "-") + }) +} diff --git a/e2e-docker/gems_local_test.go b/e2e-docker/gems_local_test.go index 17d1068..28d996c 100644 --- a/e2e-docker/gems_local_test.go +++ b/e2e-docker/gems_local_test.go @@ -31,7 +31,7 @@ func TestLocalGemsPushInstall(t *testing.T) { src := strings.TrimRight(internal, "/") + "/api/v1/local/" + name script := `set -euo pipefail -export GEM_HOST_API_KEY=unused +export GEM_HOST_API_KEY="$TOKEN" mk() { d=/w/src-$1${2:+-b}; mkdir -p $d/lib; cd $d echo "module UnkinE2eHello; VERSION = '$1'; end${2:-}" > lib/unkin-e2e-hello.rb printf 'Gem::Specification.new { |s| s.name = "unkin-e2e-hello"; s.version = "%s"; s.summary = "e2e"; s.authors = ["e2e"]; s.files = ["lib/unkin-e2e-hello.rb"] }' "$1" > g.gemspec @@ -52,14 +52,15 @@ echo "INFO $(curl -s "$SRC/info/unkin-e2e-hello" | cut -d' ' -f1 | tr '\n' ' ')" mk 0.2.0 '# changed' && if gem push --host "$SRC" unkin-e2e-hello-0.2.0.gem; then echo "yanked re-push accepted"; exit 1; fi cd /w/app && gem uninstall -a -x unkin-e2e-hello >/dev/null if gem install --remote --no-document unkin-e2e-hello -v 0.2.0; then echo "yanked version installed"; exit 1; fi +mk 0.3.0 && if GEM_HOST_API_KEY="$BAD" gem push --host "$SRC" unkin-e2e-hello-0.3.0.gem; then echo "invalid key push accepted"; exit 1; fi echo "VERSIONS $(curl -s "$SRC/versions" | grep '^unkin-e2e-hello ' | cut -d' ' -f2)"` out, err := exec.Command("docker", "run", "--rm", "--network", network, - "-e", "SRC="+src, "ruby:3.3", "bash", "-c", script).CombinedOutput() + "-e", "SRC="+src, "-e", "TOKEN="+authToken, "-e", "BAD="+badToken, "ruby:3.3", "bash", "-c", script).CombinedOutput() if err != nil { t.Fatalf("gem client run failed: %v\n%s", err, out) } - for _, want := range []string{"GEM 0.2.0", "BUNDLE 0.2.0", "INFO --- 0.1.0 ", "re-pushing a yanked version is not allowed", "VERSIONS 0.1.0\n"} { + for _, want := range []string{"GEM 0.2.0", "BUNDLE 0.2.0", "INFO --- 0.1.0 ", "re-pushing a yanked version is not allowed", `{"error":"invalid token"}`, "VERSIONS 0.1.0\n"} { if !strings.Contains(string(out), want) { t.Fatalf("missing %q in gem output:\n%s", want, out) } diff --git a/e2e-docker/gems_virtual_test.go b/e2e-docker/gems_virtual_test.go index 1eae0be..fac4775 100644 --- a/e2e-docker/gems_virtual_test.go +++ b/e2e-docker/gems_virtual_test.go @@ -35,7 +35,7 @@ func TestVirtualGemsInstall(t *testing.T) { base := strings.TrimRight(internal, "/") script := `set -euo pipefail -export GEM_HOST_API_KEY=unused +export GEM_HOST_API_KEY="$TOKEN" pub() { d=/w/src-$1-$2; mkdir -p $d/lib; cd $d echo "module UnkinE2eHello; VERSION = '$1:$2'; end" > lib/unkin-e2e-hello.rb printf 'Gem::Specification.new { |s| s.name = "unkin-e2e-hello"; s.version = "%s"; s.summary = "e2e"; s.authors = ["e2e"]; s.files = ["lib/unkin-e2e-hello.rb"] }' "$2" > g.gemspec @@ -52,7 +52,7 @@ bundle exec ruby -e 'require "unkin-e2e-hello"; puts "REQ #{UnkinE2eHello::VERSI echo "REDIRECT $(curl -s -o /dev/null -w '%{redirect_url}' "$V/gems/unkin-e2e-hello-0.2.0.gem")"` out, err := exec.Command("docker", "run", "--rm", "--network", network, - "-e", "BASE="+base, "-e", "A="+a, "-e", "B="+b, "-e", "V="+virt, + "-e", "BASE="+base, "-e", "A="+a, "-e", "B="+b, "-e", "V="+virt, "-e", "TOKEN="+authToken, "ruby:3.3", "bash", "-c", script).CombinedOutput() if err != nil { t.Fatalf("bundler run failed: %v\n%s", err, out) diff --git a/e2e-docker/goproxy_local_test.go b/e2e-docker/goproxy_local_test.go index 8e190d6..bbb0873 100644 --- a/e2e-docker/goproxy_local_test.go +++ b/e2e-docker/goproxy_local_test.go @@ -37,6 +37,8 @@ func TestLocalGoProxy(t *testing.T) { uploadFile(t, "local-go", mod+"/@v/"+ver+".mod", gomod, "text/plain") } + assertWriteGated(t, http.MethodPut, api("/api/v2/remotes/local-go/files/"+mod+"/@v/v0.3.0.mod"), gomod, "text/plain", api("/api/v1/local/local-go/"+mod+"/@v/list")) + resp, body := doRequest(t, http.MethodPut, api("/api/v2/remotes/local-go/files/"+mod+"/@v/v0.3.0.zip"), []byte("junk"), "application/zip") if resp.StatusCode != http.StatusBadRequest { t.Fatalf("invalid zip: status %d: %s", resp.StatusCode, body) diff --git a/e2e-docker/helm_local_test.go b/e2e-docker/helm_local_test.go index a0c9615..b7debcc 100644 --- a/e2e-docker/helm_local_test.go +++ b/e2e-docker/helm_local_test.go @@ -29,10 +29,10 @@ func TestLocalHelmRealClient(t *testing.T) { createVirtual(t, `{"name":"vhelm","package_type":"helm","members":["local-helm"]}`) defer deleteVirtual(t, "vhelm") - helm := func(script string) string { + helm := func(t *testing.T, script string) string { t.Helper() - out, err := exec.Command("docker", "run", "--rm", "--network", network, "--entrypoint", "sh", - "alpine/helm:3.16.4", "-c", "set -eu; "+script).Output() + out, err := exec.Command("docker", "run", "--rm", "--network", network, "-e", "TOKEN="+authToken, "-e", "BAD="+badToken, + "--entrypoint", "sh", "alpine/helm:3.16.4", "-c", "set -eu; "+script).Output() if err != nil { stderr := "" if ee, ok := err.(*exec.ExitError); ok { @@ -43,7 +43,7 @@ func TestLocalHelmRealClient(t *testing.T) { return string(out) } - chart := helm("cd /tmp && helm create e2echart >/dev/null && helm package e2echart >/dev/null && cat e2echart-0.1.0.tgz") + chart := helm(t, "cd /tmp && helm create e2echart >/dev/null && helm package e2echart >/dev/null && cat e2echart-0.1.0.tgz") uploadFile(t, "local-helm", "e2echart-0.1.0.tgz", []byte(chart), "application/gzip") // Chart metadata is recorded asynchronously after upload; poll the index. @@ -58,14 +58,32 @@ func TestLocalHelmRealClient(t *testing.T) { if !strings.Contains(string(body), "e2echart-0.1.0.tgz") || !strings.Contains(string(body), "digest: ") { t.Fatalf("index.yaml never listed the chart:\n%s", body) } + assertWriteGated(t, http.MethodPut, api("/api/v2/remotes/local-helm/files/e2echart-0.2.0.tgz"), []byte(chart), "application/gzip", api("/api/v1/local/local-helm/index.yaml")) for _, repoURL := range []string{internal + "/api/v1/local/local-helm", internal + "/api/v1/virtual/vhelm"} { - out := helm("helm repo add e2e " + repoURL + " >/dev/null && " + - "helm search repo e2e/e2echart && " + - "cd /tmp && helm pull e2e/e2echart --version 0.1.0 && " + + out := helm(t, "helm repo add e2e "+repoURL+" >/dev/null && "+ + "helm search repo e2e/e2echart && "+ + "cd /tmp && helm pull e2e/e2echart --version 0.1.0 && "+ "helm template rel e2echart-0.1.0.tgz | grep -c 'kind: Deployment'") if !strings.Contains(out, "e2e/e2echart") || !strings.Contains(out, "0.1.0") || !strings.HasSuffix(strings.TrimSpace(out), "1") { t.Fatalf("helm via %s:\n%s", repoURL, out) } } + + t.Run("oci", func(t *testing.T) { + t.Skip("helm (oras-go) fetches the registry token with an OAuth2 POST form the server ignores; fixed on benvin/registry-token-oauth-post") + createRepo(t, `{"name":"local-helm-oci","package_type":"docker","repo_type":"local"}`) + defer deleteRepo(t, "local-helm-oci") + reg := strings.TrimPrefix(internal, "http://") + out := helm(t, "cd /tmp && helm create e2echart >/dev/null && helm package e2echart >/dev/null && "+ + "echo \"$BAD\" | helm registry login "+reg+" -u ci --password-stdin --insecure && "+ + "if helm push e2echart-0.1.0.tgz oci://"+reg+"/local-helm-oci --plain-http; then echo 'invalid token push accepted'; exit 1; fi && "+ + "echo \"$TOKEN\" | helm registry login "+reg+" -u ci --password-stdin --insecure && "+ + "helm push e2echart-0.1.0.tgz oci://"+reg+"/local-helm-oci --plain-http && "+ + "helm registry logout "+reg+" && rm -rf e2echart* && "+ + "helm pull oci://"+reg+"/local-helm-oci/e2echart --version 0.1.0 --plain-http && ls e2echart-0.1.0.tgz") + if !strings.Contains(out, "e2echart-0.1.0.tgz") { + t.Fatalf("helm OCI push/pull:\n%s", out) + } + }) } diff --git a/e2e-docker/helpers_test.go b/e2e-docker/helpers_test.go index c9bea92..8dd1486 100644 --- a/e2e-docker/helpers_test.go +++ b/e2e-docker/helpers_test.go @@ -7,15 +7,67 @@ package e2edocker import ( "bytes" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" "io" + "log" "net/http" "os" "path/filepath" "strings" "testing" "time" + + jose "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" ) +// authToken is accepted by the stack's write gate; badToken carries the same +// claims signed by a key the issuer never published. +var authToken, badToken string + +// TestMain publishes a fresh signing key through the static mockoidc issuer +// (e2e-docker/oidc, served by docker-compose.e2e.yml) and mints both tokens. +func TestMain(m *testing.M) { + key := mustRSAKey() + jwks, _ := json.Marshal(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{{Key: &key.PublicKey, KeyID: "e2e", Algorithm: "RS256", Use: "sig"}}}) + if err := os.WriteFile(filepath.Join("oidc", "jwks.json"), jwks, 0o644); err != nil { + log.Fatalf("write jwks: %v", err) + } + authToken, badToken = mintToken(key), mintToken(mustRSAKey()) + os.Exit(m.Run()) +} + +func mustRSAKey() *rsa.PrivateKey { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + log.Fatalf("generate key: %v", err) + } + return key +} + +func mintToken(key *rsa.PrivateKey) string { + sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: jose.JSONWebKey{Key: key, KeyID: "e2e"}}, (&jose.SignerOptions{}).WithType("JWT")) + if err != nil { + log.Fatalf("signer: %v", err) + } + now := time.Now() + tok, err := jwt.Signed(sig).Claims(jwt.Claims{ + Issuer: "http://mockoidc", Subject: "e2e", Audience: jwt.Audience{"artifactapi"}, + IssuedAt: jwt.NewNumericDate(now), Expiry: jwt.NewNumericDate(now.Add(time.Hour)), + }).Serialize() + if err != nil { + log.Fatalf("sign token: %v", err) + } + return tok +} + +func basicAuth(token string) string { + return "Basic " + base64.StdEncoding.EncodeToString([]byte("ci:"+token)) +} + func baseURL() string { if v := os.Getenv("ARTIFACTAPI_URL"); v != "" { return strings.TrimRight(v, "/") @@ -43,7 +95,14 @@ func fixtureBytes(t *testing.T, rel string) []byte { return b } +// doRequest sends authToken as a Bearer token; doRequestAuth sends authz +// verbatim, or no Authorization header when it is empty. func doRequest(t *testing.T, method, url string, body []byte, contentType string) (*http.Response, []byte) { + t.Helper() + return doRequestAuth(t, method, url, body, contentType, "Bearer "+authToken) +} + +func doRequestAuth(t *testing.T, method, url string, body []byte, contentType, authz string) (*http.Response, []byte) { t.Helper() var r io.Reader if body != nil { @@ -56,6 +115,9 @@ func doRequest(t *testing.T, method, url string, body []byte, contentType string if contentType != "" { req.Header.Set("Content-Type", contentType) } + if authz != "" { + req.Header.Set("Authorization", authz) + } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("%s %s: %v", method, url, err) @@ -106,3 +168,17 @@ func getEventually(t *testing.T, url string, timeout time.Duration) (*http.Respo time.Sleep(250 * time.Millisecond) } } + +// assertWriteGated checks a write is refused with no token and with a token +// from an unknown key, and that readURL still serves anonymously. +func assertWriteGated(t *testing.T, method, url string, body []byte, contentType, readURL string) { + t.Helper() + for _, authz := range []string{"", "Bearer " + badToken, basicAuth(badToken)} { + if resp, b := doRequestAuth(t, method, url, body, contentType, authz); resp.StatusCode != http.StatusUnauthorized { + t.Errorf("%s %s with %.12q: status %d, want 401: %s", method, url, authz, resp.StatusCode, b) + } + } + if resp, b := doRequestAuth(t, http.MethodGet, readURL, nil, "", ""); resp.StatusCode != http.StatusOK { + t.Errorf("anonymous GET %s: status %d: %s", readURL, resp.StatusCode, b) + } +} diff --git a/e2e-docker/local_test.go b/e2e-docker/local_test.go index 6d90eed..ecd4409 100644 --- a/e2e-docker/local_test.go +++ b/e2e-docker/local_test.go @@ -15,10 +15,11 @@ import ( "git.unkin.net/unkin/artifactapi/internal/testsupport" ) +// uploadFile PUTs with Basic credentials, as `curl -u ci:$TOKEN` does. func uploadFile(t *testing.T, repo, filePath string, body []byte, contentType string) { t.Helper() url := api("/api/v2/remotes/" + repo + "/files/" + filePath) - resp, respBody := doRequest(t, http.MethodPut, url, body, contentType) + resp, respBody := doRequestAuth(t, http.MethodPut, url, body, contentType, basicAuth(authToken)) if resp.StatusCode != http.StatusCreated { t.Fatalf("upload %s: status %d: %s", filePath, resp.StatusCode, respBody) } @@ -31,6 +32,7 @@ func TestLocalGenericUpload(t *testing.T) { content := []byte("artifactapi local generic upload payload") uploadFile(t, "local-generic", "data/hello.bin", content, "application/octet-stream") + assertWriteGated(t, http.MethodPut, api("/api/v2/remotes/local-generic/files/data/other.bin"), content, "application/octet-stream", api("/api/v1/local/local-generic/data/hello.bin")) resp, body := doRequest(t, http.MethodGet, api("/api/v1/local/local-generic/data/hello.bin"), nil, "") if resp.StatusCode != http.StatusOK { @@ -41,6 +43,18 @@ func TestLocalGenericUpload(t *testing.T) { } } +// TestLocalTerraformUpload uploads a provider zip the way a release pipeline +// does and downloads it back anonymously. +func TestLocalTerraformUpload(t *testing.T) { + createRepo(t, `{"name":"local-terraform","package_type":"terraform","repo_type":"local"}`) + defer deleteRepo(t, "local-terraform") + + const path = "unkin/e2e/terraform-provider-e2e_1.0.0_linux_amd64.zip" + zip := []byte("PK\x05\x06" + strings.Repeat("\x00", 18)) + uploadFile(t, "local-terraform", path, zip, "application/zip") + assertWriteGated(t, http.MethodPut, api("/api/v2/remotes/local-terraform/files/unkin/e2e/terraform-provider-e2e_1.1.0_linux_amd64.zip"), zip, "application/zip", api("/api/v2/remotes/local-terraform/files/"+path)) +} + // TestLocalPyPIUpload uploads a wheel and validates the generated simple index. func TestLocalPyPIUpload(t *testing.T) { createRepo(t, `{"name":"local-pypi","package_type":"pypi","repo_type":"local"}`) @@ -48,6 +62,7 @@ func TestLocalPyPIUpload(t *testing.T) { wheel := fixtureBytes(t, "packages/foo-1.0-py3-none-any.whl") uploadFile(t, "local-pypi", "foo-1.0-py3-none-any.whl", wheel, "application/zip") + assertWriteGated(t, http.MethodPut, api("/api/v2/remotes/local-pypi/files/foo-2.0-py3-none-any.whl"), wheel, "application/zip", api("/api/v1/local/local-pypi/simple/foo/")) // Root index lists the package. resp, body := doRequest(t, http.MethodGet, api("/api/v1/local/local-pypi/simple/"), nil, "") @@ -95,6 +110,7 @@ func TestLocalRPMRepodata(t *testing.T) { if !strings.Contains(s, " ~/.npmrc +printf 'registry=%s\n%s:_authToken=%s\n' "$REG" "${REG#http:}" "$TOKEN" > ~/.npmrc mk() { d="/w/src/$1-$2"; mkdir -p "$d"; cd "$d" printf '{"name":"%s","version":"%s","main":"index.js"}' "$1" "$2" > package.json echo "module.exports = '$1@$2';" > index.js; } @@ -43,10 +43,15 @@ npm install --no-audit --cache /tmp/fresh-cache e2e-npm-plain @e2e/npm-scoped node -e "console.log('REQ', require('e2e-npm-plain'), require('@e2e/npm-scoped'))" mk e2e-npm-plain 1.1.0 && npm publish echo "VERSIONS $(npm view e2e-npm-plain versions --json | tr -d ' \n')" -echo "LATEST $(npm view e2e-npm-plain dist-tags.latest)"` +echo "LATEST $(npm view e2e-npm-plain dist-tags.latest)" +printf 'registry=%s\n%s:_authToken=%s\n' "$REG" "${REG#http:}" "$BAD" > ~/.npmrc +mk e2e-npm-plain 2.0.0 +if npm publish; then echo "invalid token publish accepted"; exit 1; fi +printf 'registry=%s\n' "$REG" > ~/.npmrc +echo "ANON $(npm view e2e-npm-plain dist-tags.latest)"` out, err := exec.Command("docker", "run", "--rm", "--network", network, - "-e", "REG="+reg, "node:22", "bash", "-c", script).CombinedOutput() + "-e", "REG="+reg, "-e", "TOKEN="+authToken, "-e", "BAD="+badToken, "node:22", "bash", "-c", script).CombinedOutput() if err != nil { t.Fatalf("npm client run failed: %v\n%s", err, out) } @@ -54,6 +59,8 @@ echo "LATEST $(npm view e2e-npm-plain dist-tags.latest)"` "REQ e2e-npm-plain@1.0.0 @e2e/npm-scoped@1.0.0", `VERSIONS ["1.0.0","1.1.0"]`, "LATEST 1.1.0", + "code E401", + "ANON 1.1.0", } { if !strings.Contains(string(out), want) { t.Fatalf("missing %q in npm output:\n%s", want, out) diff --git a/e2e-docker/npm_virtual_test.go b/e2e-docker/npm_virtual_test.go index 31f92b0..0463efe 100644 --- a/e2e-docker/npm_virtual_test.go +++ b/e2e-docker/npm_virtual_test.go @@ -38,7 +38,7 @@ func TestVirtualNPMInstall(t *testing.T) { pub() { d="/w/src/$2-$3-$1"; mkdir -p "$d"; cd "$d" printf '{"name":"%s","version":"%s","main":"index.js"}' "$2" "$3" > package.json echo "module.exports = '$1:$2@$3';" > index.js - printf 'registry=%s\n%s:_authToken=unused\n' "$BASE/api/v1/local/$1/" "${BASE#http:}/api/v1/local/$1/" > .npmrc + printf 'registry=%s\n%s:_authToken=%s\n' "$BASE/api/v1/local/$1/" "${BASE#http:}/api/v1/local/$1/" "$TOKEN" > .npmrc npm publish; } pub "$A" @e2e/virt 1.0.0 pub "$B" @e2e/virt 1.0.0 @@ -51,7 +51,7 @@ node -e "console.log('REQ', require('@e2e/virt'))" echo "RESOLVED $(node -p "require('./package-lock.json').packages['node_modules/@e2e/virt'].resolved")"` out, err := exec.Command("docker", "run", "--rm", "--network", network, - "-e", "BASE="+base, "-e", "A="+a, "-e", "B="+b, "-e", "V="+virt, + "-e", "BASE="+base, "-e", "A="+a, "-e", "B="+b, "-e", "V="+virt, "-e", "TOKEN="+authToken, "node:22", "bash", "-c", script).CombinedOutput() if err != nil { t.Fatalf("npm client run failed: %v\n%s", err, out) diff --git a/e2e-docker/oidc/.well-known/openid-configuration b/e2e-docker/oidc/.well-known/openid-configuration new file mode 100644 index 0000000..1ef9983 --- /dev/null +++ b/e2e-docker/oidc/.well-known/openid-configuration @@ -0,0 +1 @@ +{"issuer":"http://mockoidc","jwks_uri":"http://mockoidc/jwks.json","authorization_endpoint":"http://mockoidc/authorize","response_types_supported":["id_token"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256"]} diff --git a/e2e-docker/puppet_local_test.go b/e2e-docker/puppet_local_test.go index b7d5a55..e2c8952 100644 --- a/e2e-docker/puppet_local_test.go +++ b/e2e-docker/puppet_local_test.go @@ -68,6 +68,7 @@ func TestLocalPuppetRealClient(t *testing.T) { if resp, body := getEventually(t, api("/api/v1/local/local-puppet/v3/releases/unkin-hello-0.1.0"), 15*time.Second); resp.StatusCode != http.StatusOK || !strings.Contains(string(body), `"file_md5"`) { t.Fatalf("release never indexed: %d %s", resp.StatusCode, body) } + assertWriteGated(t, http.MethodPut, api("/api/v2/remotes/local-puppet/files/unkin-hello-0.2.0.tar.gz"), tarball, "application/gzip", api("/api/v1/local/local-puppet/v3/releases/unkin-hello-0.1.0")) forge := internal + "/api/v1/local/local-puppet" run := func(image, script string) string { diff --git a/scripts/docker-e2e.sh b/scripts/docker-e2e.sh index 0318a8d..caa62b3 100755 --- a/scripts/docker-e2e.sh +++ b/scripts/docker-e2e.sh @@ -17,8 +17,8 @@ cleanup() { } trap cleanup EXIT -echo "==> building and starting stack (postgres, redis, minio, mockupstream(s), artifactapi)" -"${COMPOSE[@]}" up -d --build postgres redis minio mockupstream mockupstreama mockupstreamb artifactapi +echo "==> building and starting stack (postgres, redis, minio, mockupstream(s), mockoidc, artifactapi)" +"${COMPOSE[@]}" up -d --build postgres redis minio mockupstream mockupstreama mockupstreamb mockoidc artifactapi echo "==> waiting for artifactapi health at ${API_URL}" for i in $(seq 1 60); do -- 2.47.3 From c1571f1960ceec36571a94fbf4de9c76a0282cf6 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Mon, 2 Nov 2026 22:19:19 +1100 Subject: [PATCH 2/2] Drop the deb index poll change and cite #180 in registry-token skips --- e2e-docker/docker_local_test.go | 2 +- e2e-docker/helm_local_test.go | 2 +- e2e-docker/local_test.go | 16 ++++++---------- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/e2e-docker/docker_local_test.go b/e2e-docker/docker_local_test.go index 0b219d7..d022010 100644 --- a/e2e-docker/docker_local_test.go +++ b/e2e-docker/docker_local_test.go @@ -226,7 +226,7 @@ func TestLocalDockerCLIPushPull(t *testing.T) { must(t, nil, "pull", img+":v1") t.Run("buildx", func(t *testing.T) { - t.Skip("buildkit fetches the registry token with an OAuth2 POST form the server ignores; fixed on benvin/registry-token-oauth-post") + t.Skip("buildkit fetches the registry token with an OAuth2 POST form the server ignores; fixed by unkin/artifactapi#180") conf := cfg + "/buildkitd.toml" if err := os.WriteFile(conf, []byte(fmt.Sprintf("[registry.%q]\n http = true\n", reg)), 0o644); err != nil { t.Fatal(err) diff --git a/e2e-docker/helm_local_test.go b/e2e-docker/helm_local_test.go index b7debcc..d19fddd 100644 --- a/e2e-docker/helm_local_test.go +++ b/e2e-docker/helm_local_test.go @@ -71,7 +71,7 @@ func TestLocalHelmRealClient(t *testing.T) { } t.Run("oci", func(t *testing.T) { - t.Skip("helm (oras-go) fetches the registry token with an OAuth2 POST form the server ignores; fixed on benvin/registry-token-oauth-post") + t.Skip("helm (oras-go) fetches the registry token with an OAuth2 POST form the server ignores; fixed by unkin/artifactapi#180") createRepo(t, `{"name":"local-helm-oci","package_type":"docker","repo_type":"local"}`) defer deleteRepo(t, "local-helm-oci") reg := strings.TrimPrefix(internal, "http://") diff --git a/e2e-docker/local_test.go b/e2e-docker/local_test.go index ecd4409..d6a571e 100644 --- a/e2e-docker/local_test.go +++ b/e2e-docker/local_test.go @@ -123,23 +123,19 @@ func TestLocalDebRepo(t *testing.T) { deb := testsupport.MinimalDeb("e2e-testpkg", "1.0.0", "amd64") uploadFile(t, "local-deb", "e2e-testpkg_1.0.0_amd64.deb", deb, "application/vnd.debian.binary-package") - // The index is served (empty) at once and lists the package only once the - // upload is parsed asynchronously; poll until it does. - var pkgs string - for deadline := time.Now().Add(15 * time.Second); !strings.Contains(pkgs, "Package: e2e-testpkg") && time.Now().Before(deadline); time.Sleep(250 * time.Millisecond) { - resp, body := doRequest(t, http.MethodGet, api("/api/v1/local/local-deb/Packages"), nil, "") - if resp.StatusCode != http.StatusOK { - t.Fatalf("Packages: status %d: %s", resp.StatusCode, body) - } - pkgs = string(body) + // The index is generated asynchronously after upload; poll for it. + resp, body := getEventually(t, api("/api/v1/local/local-deb/Packages"), 15*time.Second) + if resp.StatusCode != http.StatusOK { + t.Fatalf("Packages: status %d: %s", resp.StatusCode, body) } + pkgs := string(body) for _, want := range []string{"Package: e2e-testpkg", "Version: 1.0.0", "Architecture: amd64", "Filename: pool/e2e-testpkg_1.0.0_amd64.deb", "SHA256:"} { if !strings.Contains(pkgs, want) { t.Fatalf("Packages missing %q:\n%s", want, pkgs) } } - resp, body := doRequest(t, http.MethodGet, api("/api/v1/local/local-deb/Release"), nil, "") + resp, body = doRequest(t, http.MethodGet, api("/api/v1/local/local-deb/Release"), nil, "") if resp.StatusCode != http.StatusOK { t.Fatalf("Release: status %d: %s", resp.StatusCode, body) } -- 2.47.3