Compare commits

..

2 Commits

Author SHA1 Message Date
unkin-agent c1571f1960 Drop the deb index poll change and cite #180 in registry-token skips
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
2026-11-02 22:19:19 +11:00
unkin-agent a161c44575 Run the dockerised e2e suite with write auth enabled
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
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.
2026-11-02 22:01:44 +11:00
21 changed files with 248 additions and 261 deletions
+3
View File
@@ -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
+12
View File
@@ -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
+7
View File
@@ -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_<type>_test.go` case each) proxied through the mock upstream: first fetch `X-Artifact-Source: remote`, second `cache`, bytes
+6 -4
View File
@@ -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)
}
+2 -2
View File
@@ -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)
+65 -2
View File
@@ -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 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)
}
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", "-")
})
}
+4 -3
View File
@@ -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)
}
+2 -2
View File
@@ -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)
+2
View File
@@ -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)
+25 -7
View File
@@ -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 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://")
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)
}
})
}
+76
View File
@@ -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)
}
}
+19 -1
View File
@@ -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, "<repomd") || !strings.Contains(s, "primary") {
t.Fatalf("repomd.xml not a valid repodata document: %s", s)
}
assertWriteGated(t, http.MethodPut, api("/api/v2/remotes/local-rpm/files/e2e-testpkg-1.0-1.noarch.rpm"), rpm, "application/x-rpm", api("/api/v1/local/local-rpm/repodata/repomd.xml"))
}
// TestLocalDebRepo uploads a .deb and validates that the flat apt index
@@ -138,6 +154,7 @@ func TestLocalDebRepo(t *testing.T) {
if !bytes.Equal(body, deb) {
t.Fatalf("deb content mismatch")
}
assertWriteGated(t, http.MethodPut, api("/api/v2/remotes/local-deb/files/e2e-testpkg_2.0.0_amd64.deb"), deb, "application/vnd.debian.binary-package", api("/api/v1/local/local-deb/Packages"))
}
// TestLocalAlpineIndex uploads an .apk to an alpine local repo and validates
@@ -174,6 +191,7 @@ func TestLocalAlpineIndex(t *testing.T) {
if !bytes.Equal(body, apk) {
t.Fatalf("apk content mismatch")
}
assertWriteGated(t, http.MethodPut, api("/api/v2/remotes/local-alpine/files/x86_64/e2e-testpkg-2.0-r0.apk"), apk, "application/vnd.android.package-archive", api("/api/v1/local/local-alpine/x86_64/APKINDEX.tar.gz"))
}
func apkIndexText(t *testing.T, body []byte) string {
+10 -3
View File
@@ -30,7 +30,7 @@ func TestLocalNPMPublishInstall(t *testing.T) {
reg := strings.TrimRight(internal, "/") + "/api/v1/local/" + name + "/"
script := `set -euo pipefail
printf 'registry=%s\n%s:_authToken=unused\n' "$REG" "${REG#http:}" > ~/.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)
+2 -2
View File
@@ -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)
@@ -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"]}
+1
View File
@@ -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 {
+3 -8
View File
@@ -7,7 +7,6 @@ require (
github.com/cavaliergopher/rpm v1.3.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/containerd/containerd/v2 v2.2.1
github.com/coreos/go-oidc/v3 v3.21.0
github.com/go-chi/chi/v5 v5.3.0
github.com/go-jose/go-jose/v4 v4.1.4
@@ -15,8 +14,6 @@ require (
github.com/jackc/pgx/v5 v5.10.0
github.com/klauspost/compress v1.19.2
github.com/minio/minio-go/v7 v7.2.0
github.com/opencontainers/go-digest v1.0.0
github.com/opencontainers/image-spec v1.1.1
github.com/redis/go-redis/v9 v9.20.0
github.com/testcontainers/testcontainers-go v0.44.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0
@@ -45,8 +42,7 @@ require (
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v1.0.0-rc.2 // indirect
github.com/containerd/typeurl/v2 v2.2.3 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
@@ -59,7 +55,6 @@ require (
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
@@ -76,7 +71,6 @@ require (
github.com/minio/md5-simd v1.1.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/locker v1.0.1 // indirect
github.com/moby/moby/api v1.55.0 // indirect
github.com/moby/moby/client v0.5.0 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
@@ -87,6 +81,8 @@ require (
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
@@ -112,6 +108,5 @@ require (
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/ini.v1 v1.67.2 // indirect
)
+4 -43
View File
@@ -36,18 +36,14 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/containerd/containerd/v2 v2.2.1 h1:TpyxcY4AL5A+07dxETevunVS5zxqzuq7ZqJXknM11yk=
github.com/containerd/containerd/v2 v2.2.1/go.mod h1:NR70yW1iDxe84F2iFWbR9xfAN0N2F0NcjTi1OVth4nU=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v1.0.0-rc.2 h1:0SPgaNZPVWGEi4grZdV8VRYQn78y+nm6acgLGv/QzE4=
github.com/containerd/platforms v1.0.0-rc.2/go.mod h1:J71L7B+aiM5SdIEqmd9wp6THLVRzJGXfNuWCZCllLA4=
github.com/containerd/typeurl/v2 v2.2.3 h1:yNA/94zxWdvYACdYO8zofhrTVuQY73fFU1y++dYSw40=
github.com/containerd/typeurl/v2 v2.2.3/go.mod h1:95ljDnPfD3bAbDJRugOiShd/DlAAsxGtUBhJxIn7SCk=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
@@ -83,8 +79,6 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -97,8 +91,6 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
@@ -136,8 +128,6 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg=
github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc=
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
@@ -209,8 +199,6 @@ github.com/ulikunitz/xz v0.5.16 h1:ld6NyySjx5lowVKwJvMRLnW5nxKX/xnpSiFYZ/Lxur0=
github.com/ulikunitz/xz v0.5.16/go.mod h1:H9Rt/W6/Qj27PGauhQc6nfCDy7vHpzsOThBSaYDoEhw=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
@@ -235,34 +223,19 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+hlyhrhei9uErw2B5GJo=
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -272,24 +245,12 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+2 -15
View File
@@ -186,7 +186,7 @@ func (b *BearerOIDC) RequireRegistryWrites(next http.Handler, realm func(*http.R
hasAuth := r.Header.Get("Authorization") != ""
switch {
case r.URL.Path == "/v2/token":
writeToken(w, b.registryToken(w, r))
writeToken(w, b.registryToken(r))
case r.URL.Path == "/v2/" && !hasAuth:
unauthorized(w, challenge, "authentication required")
default:
@@ -198,18 +198,8 @@ func (b *BearerOIDC) RequireRegistryWrites(next http.Handler, realm func(*http.R
// registryToken echoes a valid credential back as the registry token. Missing
// or invalid credentials (e.g. an expired `docker login`) get the anonymous
// token so pulls keep working; writes are still verified per request.
// containerd/buildkit POST an OAuth2 password or refresh_token grant form;
// only a capped urlencoded body is read, never multipart.
func (b *BearerOIDC) registryToken(w http.ResponseWriter, r *http.Request) string {
func (b *BearerOIDC) registryToken(r *http.Request) string {
raw := token(r)
if raw == "" && r.Method == http.MethodPost {
r.Body = http.MaxBytesReader(w, r.Body, maxTokenForm)
if r.ParseForm() == nil {
if raw = r.PostForm.Get("password"); raw == "" {
raw = r.PostForm.Get("refresh_token")
}
}
}
if raw == "" {
return "anonymous"
}
@@ -220,9 +210,6 @@ func (b *BearerOIDC) registryToken(w http.ResponseWriter, r *http.Request) strin
return raw
}
// maxTokenForm fits a large OIDC JWT plus the grant's other fields.
const maxTokenForm = 16 << 10
func writeToken(w http.ResponseWriter, tok string) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"token": tok, "access_token": tok})
-167
View File
@@ -5,12 +5,8 @@ import (
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"sync"
@@ -18,14 +14,9 @@ import (
"testing"
"time"
"github.com/containerd/containerd/v2/core/remotes"
"github.com/containerd/containerd/v2/core/remotes/docker"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"github.com/opencontainers/go-digest"
specs "github.com/opencontainers/image-spec/specs-go"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"git.unkin.net/unkin/artifactapi/internal/testsupport"
)
@@ -330,47 +321,6 @@ func TestRequireRegistryWrites(t *testing.T) {
t.Errorf("rejected credential %q: %d %s", authz, w.Code, w.Body)
}
}
post := func(form url.Values) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodPost, "/v2/token", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
for _, tc := range []struct {
name string
form url.Values
want string
}{
{"password grant", url.Values{"grant_type": {"password"}, "username": {"ci"}, "password": {valid}, "service": {"artifactapi"}}, valid},
{"refresh grant", url.Values{"grant_type": {"refresh_token"}, "refresh_token": {valid}}, valid},
{"bogus password", url.Values{"grant_type": {"password"}, "username": {"ci"}, "password": {"bogus"}}, "anonymous"},
{"expired refresh", url.Values{"grant_type": {"refresh_token"}, "refresh_token": {expired}}, "anonymous"},
{"no credential", url.Values{"grant_type": {"password"}, "username": {"ci"}}, "anonymous"},
} {
if w := post(tc.form); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"access_token":"`+tc.want+`"`) {
t.Errorf("oauth %s: %d %s", tc.name, w.Code, w.Body)
}
}
var mp strings.Builder
mw := multipart.NewWriter(&mp)
_ = mw.WriteField("password", valid)
_ = mw.Close()
r := httptest.NewRequest(http.MethodPost, "/v2/token", strings.NewReader(mp.String()))
r.Header.Set("Content-Type", mw.FormDataContentType())
w = httptest.NewRecorder()
h.ServeHTTP(w, r)
if !strings.Contains(w.Body.String(), `"token":"anonymous"`) || r.MultipartForm != nil {
t.Errorf("multipart form honoured: %s", w.Body)
}
big := &countingReader{r: io.MultiReader(strings.NewReader("password="+valid+"&pad="), strings.NewReader(strings.Repeat("a", 1<<20)))}
r = httptest.NewRequest(http.MethodPost, "/v2/token", big)
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w = httptest.NewRecorder()
h.ServeHTTP(w, r)
if !strings.Contains(w.Body.String(), `"token":"anonymous"`) || big.n > 2*maxTokenForm {
t.Errorf("oversized form: read %d bytes, %s", big.n, w.Body)
}
for _, m := range []string{http.MethodGet, http.MethodHead} {
if w := do(m, "/v2/repo/img/manifests/t", ""); w.Code != http.StatusNoContent {
t.Errorf("anonymous %s: %d", m, w.Code)
@@ -391,120 +341,3 @@ func TestRequireRegistryWrites(t *testing.T) {
t.Errorf("disabled: status %d", got)
}
}
// stubRegistry is just enough of a Registry V2 for containerd to push a blob
// and manifest and resolve/fetch them back.
func stubRegistry() http.Handler {
var mu sync.Mutex
store := map[string][]byte{}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
p := r.URL.Path
switch {
case r.Method == http.MethodPost && strings.HasSuffix(p, "/blobs/uploads/"):
w.Header().Set("Location", p+"u1")
w.WriteHeader(http.StatusAccepted)
case r.Method == http.MethodPut && strings.Contains(p, "/blobs/uploads/"):
dgst := r.URL.Query().Get("digest")
store[strings.SplitN(p, "/blobs/", 2)[0]+"/blobs/"+dgst], _ = io.ReadAll(r.Body)
w.Header().Set("Docker-Content-Digest", dgst)
w.WriteHeader(http.StatusCreated)
case r.Method == http.MethodPut:
body, _ := io.ReadAll(r.Body)
store[p] = body
store[strings.SplitN(p, "/manifests/", 2)[0]+"/manifests/"+digest.FromBytes(body).String()] = body
w.Header().Set("Docker-Content-Digest", digest.FromBytes(body).String())
w.WriteHeader(http.StatusCreated)
default:
body, found := store[p]
if !found {
w.WriteHeader(http.StatusNotFound)
return
}
if strings.Contains(p, "/manifests/") {
w.Header().Set("Content-Type", ocispec.MediaTypeImageManifest)
}
w.Header().Set("Docker-Content-Digest", digest.FromBytes(body).String())
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
if r.Method == http.MethodGet {
_, _ = w.Write(body)
}
}
})
}
// TestRegistryContainerdClient drives the real containerd resolver (used by
// buildkit), which fetches tokens with an OAuth2 POST form.
func TestRegistryContainerdClient(t *testing.T) {
iss := testsupport.NewOIDCIssuer()
defer iss.Close()
var srv *httptest.Server
srv = httptest.NewServer(NewBearerOIDC(iss.URL, []string{"artifactapi"}).RequireRegistryWrites(stubRegistry(), func(*http.Request) string { return srv.URL + "/v2/token" }))
defer srv.Close()
ref := strings.TrimPrefix(srv.URL, "http://") + "/repo/img:t"
ctx := context.Background()
resolver := func(secret string) remotes.Resolver {
return docker.NewResolver(docker.ResolverOptions{Hosts: docker.ConfigureDefaultRegistries(
docker.WithPlainHTTP(docker.MatchAllHosts),
docker.WithAuthorizer(docker.NewDockerAuthorizer(docker.WithAuthCreds(func(string) (string, string, error) { return "ci", secret, nil }))),
)})
}
push := func(res remotes.Resolver, mediaType string, data []byte) (ocispec.Descriptor, error) {
desc := ocispec.Descriptor{MediaType: mediaType, Digest: digest.FromBytes(data), Size: int64(len(data))}
p, err := res.Pusher(ctx, ref)
if err != nil {
return desc, err
}
cw, err := p.Push(ctx, desc)
if err != nil {
return desc, err
}
defer cw.Close()
if _, err := cw.Write(data); err != nil {
return desc, err
}
return desc, cw.Commit(ctx, desc.Size, desc.Digest)
}
pushImage := func(res remotes.Resolver, arch string) error {
cfg, err := push(res, ocispec.MediaTypeImageConfig, []byte(`{"architecture":"`+arch+`","os":"linux"}`))
if err != nil {
return err
}
manifest, _ := json.Marshal(ocispec.Manifest{Versioned: specs.Versioned{SchemaVersion: 2}, MediaType: ocispec.MediaTypeImageManifest, Config: cfg, Layers: []ocispec.Descriptor{}})
_, err = push(res, ocispec.MediaTypeImageManifest, manifest)
return err
}
if err := pushImage(resolver(iss.Token(nil, "artifactapi", "ci", time.Hour)), "amd64"); err != nil {
t.Fatalf("push with valid credential: %v", err)
}
for name, secret := range map[string]string{"bogus": "bogus", "expired": iss.Token(nil, "artifactapi", "ci", -time.Minute)} {
if err := pushImage(resolver(secret), name); err == nil || !strings.Contains(err.Error(), "401") {
t.Errorf("push with %s credential: want 401, got %v", name, err)
}
res := resolver(secret)
_, desc, err := res.Resolve(ctx, ref)
if err != nil {
t.Fatalf("resolve with %s credential: %v", name, err)
}
f, _ := res.Fetcher(ctx, ref)
rc, err := f.Fetch(ctx, desc)
if err != nil {
t.Fatalf("pull with %s credential: %v", name, err)
}
_ = rc.Close()
}
}
type countingReader struct {
r io.Reader
n int
}
func (c *countingReader) Read(p []byte) (int, error) {
n, err := c.r.Read(p)
c.n += n
return n, err
}
+2 -2
View File
@@ -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