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
17 changed files with 239 additions and 28 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 {
+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