add more integration tests

This commit is contained in:
Evan Jarrett
2026-05-11 19:53:13 -05:00
parent a0cc862798
commit be64dbb364
12 changed files with 904 additions and 170 deletions
+5 -1
View File
@@ -36,6 +36,8 @@ require (
github.com/microcosm-cc/bluemonday v1.0.27
github.com/multiformats/go-multihash v0.2.3
github.com/opencontainers/go-digest v1.0.0
github.com/opencontainers/image-spec v1.1.1
github.com/regclient/regclient v0.11.3
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
@@ -51,6 +53,7 @@ require (
golang.org/x/image v0.39.0
golang.org/x/sys v0.43.0
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
oras.land/oras-go/v2 v2.6.0
)
require (
@@ -97,6 +100,7 @@ require (
github.com/docker/docker-credential-helpers v0.9.5 // indirect
github.com/docker/go-events v0.0.0-20250808211157-605354379745 // indirect
github.com/docker/go-metrics v0.0.1 // indirect
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fatih/color v1.19.0 // indirect
@@ -154,7 +158,6 @@ require (
github.com/multiformats/go-multicodec v0.10.0 // indirect
github.com/multiformats/go-varint v0.1.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
@@ -176,6 +179,7 @@ require (
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/vbatts/tar-split v0.12.2 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
+12 -2
View File
@@ -205,6 +205,8 @@ github.com/docker/go-events v0.0.0-20250808211157-605354379745 h1:yOn6Ze6IbYI/KA
github.com/docker/go-events v0.0.0-20250808211157-605354379745/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA=
github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8=
github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw=
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 h1:UhxFibDNY/bfvqU5CAUmr9zpesgbU6SWc8/B4mflAE4=
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg=
@@ -575,6 +577,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8m
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo=
github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM=
github.com/olareg/olareg v0.1.2 h1:75G8X6E9FUlzL/CSjgFcYfMgNzlc7CxULpUUNsZBIvI=
github.com/olareg/olareg v0.1.2/go.mod h1:TWs+N6pO1S4bdB6eerzUm/ITRQ6kw91mVf9ZYeGtw+Y=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
@@ -625,6 +629,8 @@ github.com/redis/go-redis/extra/redisotel/v9 v9.18.0 h1:2dKdoEYBJ0CZCLPiCdvvc7lu
github.com/redis/go-redis/extra/redisotel/v9 v9.18.0/go.mod h1:WzkrVG9ro9BwCQD0eJOWn6AGL4Z1CleGflM45w1hu10=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/regclient/regclient v0.11.3 h1:aTnVRsgFaOmezgKp7caL3zINrZKAXsMbzS1oCgD7/cA=
github.com/regclient/regclient v0.11.3/go.mod h1:a4PDi+VyEbBuV/5hCfMjnYH8jvB7NgD0mdggwNRECy8=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
@@ -695,6 +701,8 @@ github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFd
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
github.com/tursodatabase/go-libsql v0.0.0-20251219133454-43644db490ff h1:Hvxz9W8fWpSg9xkiq8/q+3cVJo+MmLMfkjdS/u4nWFY=
github.com/tursodatabase/go-libsql v0.0.0-20251219133454-43644db490ff/go.mod h1:TjsB2miB8RW2Sse8sdxzVTdeGlx74GloD5zJYUC38d8=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4=
github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
@@ -716,8 +724,8 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=
@@ -1217,6 +1225,8 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc=
oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
+23
View File
@@ -466,6 +466,8 @@ github.com/PuerkitoBio/purell v1.2.1 h1:QsZ4TjvwiMpat6gBCBxEQI0rcS9ehtkKtSpiUnd9
github.com/PuerkitoBio/purell v1.2.1/go.mod h1:ZwHcC/82TOaovDi//J/804umJFFmbOHPngi8iYYv/Eo=
github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d h1:G0m3OIz70MZUWq3EgK3CesDbo8upS2Vm9/P3FtgI+Jk=
github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg=
github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow=
github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4=
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
github.com/agnivade/levenshtein v1.2.0 h1:U9L4IOT0Y3i0TIlUIDJ7rVUziKi/zPbrJGaFrtYH3SY=
@@ -661,8 +663,13 @@ github.com/containernetworking/cni v1.3.0 h1:v6EpN8RznAZj9765HhXQrtXgX+ECGebEYEm
github.com/containernetworking/cni v1.3.0/go.mod h1:Bs8glZjjFfGPHMw6hQu82RUgEPNGEaBb9KS5KtNMnJ4=
github.com/containernetworking/plugins v1.9.0 h1:Mg3SXBdRGkdXyFC4lcwr6u2ZB2SDeL6LC3U+QrEANuQ=
github.com/containernetworking/plugins v1.9.0/go.mod h1:JG3BxoJifxxHBhG3hFyxyhid7JgRVBu/wtooGEvWf1c=
github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01 h1:Qzk5C6cYglewc+UyGf6lc8Mj2UaPTHy/iF2De0/77CA=
github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01/go.mod h1:9rfv8iPl1ZP7aqh9YA68wnZv2NUDbXdcdPHVz0pFbPY=
github.com/containers/ocicrypt v1.2.1 h1:0qIOTT9DoYwcKmxSt8QJt+VzMY18onl9jUXsxpVhSmM=
github.com/containers/ocicrypt v1.2.1/go.mod h1:aD0AAqfMp0MtwqWgHM1bUwe1anx0VazI108CRrSKINQ=
github.com/containers/storage v1.59.1 h1:11Zu68MXsEQGBBd+GadPrHPpWeqjKS8hJDGiAHgIqDs=
github.com/containers/storage v1.59.1/go.mod h1:KoAYHnAjP3/cTsRS+mmWZGkufSY2GACiKQ4V3ZLQnR0=
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU=
@@ -673,6 +680,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/crackcomm/go-gitignore v0.0.0-20241020182519-7843d2ba8fdf h1:dwGgBWn84wUS1pVikGiruW+x5XM4amhjaZO20vCjay4=
github.com/crackcomm/go-gitignore v0.0.0-20241020182519-7843d2ba8fdf/go.mod h1:p1d6YEZWvFzEh4KLyvBcVSnrfNDDvK2zfK/4x2v/4pE=
github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q=
github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw=
github.com/cyphar/filepath-securejoin v0.5.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/cyphar/filepath-securejoin v0.6.0/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
@@ -774,6 +783,7 @@ github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv
github.com/google/go-cmdtest v0.4.0 h1:ToXh6W5spLp3npJV92tk6d5hIpUPYEzHLkD+rncbyhI=
github.com/google/go-cmdtest v0.4.0/go.mod h1:apVn/GCasLZUVpAJ6oWAuyP7Ne7CEsQbTnc0plM3m+o=
github.com/google/go-containerregistry v0.20.1/go.mod h1:YCMFNQeeXeLF+dnhhWkqDItx/JSkH01j1Kis4PsjzFI=
github.com/google/go-intervals v0.0.2/go.mod h1:MkaR3LNRfeKLPmqgJYs4E66z5InYjmCjbbr4TQlcT6Y=
github.com/google/go-pkcs11 v0.3.0 h1:PVRnTgtArZ3QQqTGtbtjtnIkzl2iY2kt24yqbrf7td8=
github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY=
github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw=
@@ -955,6 +965,7 @@ github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd h1:br0buuQ854V8u83wA0rVZ8ttrq5CpaPZdvrK0LP2lOk=
github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd/go.mod h1:QuCEs1Nt24+FYQEqAAncTDPJIuGs+LxK1MCiFL25pMU=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
@@ -968,6 +979,8 @@ github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebG
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/goveralls v0.0.5/go.mod h1:Xg2LHi51faXLyKXwsndxiW6uxEEQT9+3sjGzzwU4xy0=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
@@ -1010,6 +1023,7 @@ github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus=
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
github.com/olareg/olareg v0.1.2/go.mod h1:TWs+N6pO1S4bdB6eerzUm/ITRQ6kw91mVf9ZYeGtw+Y=
github.com/olekukonko/ts v0.0.0-20171002115256-78ecb04241c0 h1:LiZB1h0GIcudcDci2bxbqI6DXV8bF8POAnArqvRrIyw=
github.com/olekukonko/ts v0.0.0-20171002115256-78ecb04241c0/go.mod h1:F/7q8/HZz+TXjlsoZQQKVYvXTZaFH4QRa3y+j1p7MS0=
github.com/open-policy-agent/opa v0.70.0 h1:B3cqCN2iQAyKxK6+GI+N40uqkin+wzIrM7YA60t9x1U=
@@ -1088,6 +1102,7 @@ github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsK
github.com/pkg/sftp v1.10.1 h1:VasscCm72135zRysgrJDKsntdmPN+OuU3+nnHYA9wyc=
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo=
github.com/proglottis/gpgme v0.1.4/go.mod h1:5LoXMgpE4bttgwwdv9bLs/vwqv3qV7F4glEEZ7mRKrM=
github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
@@ -1111,6 +1126,7 @@ github.com/quic-go/webtransport-go v0.10.0/go.mod h1:LeGIXr5BQKE3UsynwVBeQrU1TPr
github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0 h1:MkV+77GLUNo5oJ0jf870itWm3D0Sjh7+Za9gazKc5LQ=
github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
@@ -1140,9 +1156,12 @@ github.com/secure-systems-lab/go-securesystemslib v0.9.1 h1:nZZaNz4DiERIQguNy0cL
github.com/secure-systems-lab/go-securesystemslib v0.9.1/go.mod h1:np53YzT0zXGMv6x4iEWc9Z59uR+x+ndLwCLqPYpLXVU=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE=
github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI=
github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/sigstore/fulcio v1.6.6 h1:XaMYX6TNT+8n7Npe8D94nyZ7/ERjEsNGFC+REdi/wzw=
github.com/sigstore/fulcio v1.6.6/go.mod h1:BhQ22lwaebDgIxVBEYOOqLRcN5+xOV+C9bh/GUXRhOk=
github.com/sigstore/protobuf-specs v0.4.1/go.mod h1:+gXR+38nIa2oEupqDdzg4qSBT0Os+sP7oYv6alWewWc=
github.com/sigstore/protobuf-specs v0.5.0 h1:F8YTI65xOHw70NrvPwJ5PhAzsvTnuJMGLkA4FIkofAY=
github.com/sigstore/protobuf-specs v0.5.0/go.mod h1:+gXR+38nIa2oEupqDdzg4qSBT0Os+sP7oYv6alWewWc=
@@ -1151,6 +1170,7 @@ github.com/sigstore/sigstore v1.10.4 h1:ytOmxMgLdcUed3w1SbbZOgcxqwMG61lh1TmZLN+W
github.com/sigstore/sigstore v1.10.4/go.mod h1:tDiyrdOref3q6qJxm2G+JHghqfmvifB7hw+EReAfnbI=
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/slok/go-http-metrics v0.13.0 h1:lQDyJJx9wKhmbliyUsZ2l6peGnXRHjsjoqPt5VYzcP8=
github.com/slok/go-http-metrics v0.13.0/go.mod h1:HIr7t/HbN2sJaunvnt9wKP9xoBBVZFo1/KiHU3b0w+4=
github.com/smallstep/pkcs7 v0.1.1 h1:x+rPdt2W088V9Vkjho4KtoggyktZJlMduZAtRHm68LU=
@@ -1192,6 +1212,8 @@ github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyC
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/vbatts/tar-split v0.11.3/go.mod h1:9QlHN18E+fEH7RdG+QAJJcuya3rqT7eXSTY7wGrAokY=
github.com/vbauerster/mpb/v8 v8.10.2 h1:2uBykSHAYHekE11YvJhKxYmLATKHAGorZwFlyNw4hHM=
github.com/vbauerster/mpb/v8 v8.10.2/go.mod h1:+Ja4P92E3/CorSZgfDtK46D7AVbDqmBQRTmyTqPElo0=
github.com/veraison/go-cose v1.1.0 h1:AalPS4VGiKavpAzIlBjrn7bhqXiXi4jbMYY/2+UC+4o=
github.com/veraison/go-cose v1.1.0/go.mod h1:7ziE85vSq4ScFTg6wyoMXjucIGOf4JkFEZi/an96Ct4=
github.com/vishvananda/netlink v1.3.1-0.20250303224720-0e7078ed04c8/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs=
@@ -1233,6 +1255,7 @@ github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBi
github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg=
github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
github.com/zyedidia/generic v1.2.2-0.20230320175451-4410d2372cb1 h1:V+UsotZpAVvfj3X/LMoEytoLzSiP6Lg0F7wdVyu9gGg=
+19 -2
View File
@@ -1,7 +1,9 @@
// Package testharness boots an in-process ATCR stack (fake PDS, gofakes3,
// hold, appview) for integration smoke tests. It exposes thin helpers for
// adding sailors and getting an authenticator usable with
// github.com/google/go-containerregistry.
// adding sailors and obtaining basic-auth credentials for an OCI registry
// client — either a library-specific authn.Authenticator (RegistryAuth) or
// a neutral Auth value (RegistryCreds) consumed by the client-agnostic
// matrix in test/integration.
package testharness
import (
@@ -371,6 +373,21 @@ func (h *Harness) RegistryAuth(s *Sailor) authn.Authenticator {
return &authn.Basic{Username: s.Handle(), Password: s.Identity.Password}
}
// Auth carries neutral basic-auth credentials for the test OCI client
// abstraction. Both Username and Password empty means anonymous.
type Auth struct {
Username, Password string
}
// RegistryCreds returns the sailor's basic-auth credentials as a neutral Auth
// value, for client-library-agnostic test code.
func (h *Harness) RegistryCreds(s *Sailor) Auth {
return Auth{Username: s.Handle(), Password: s.Identity.Password}
}
// AnonCreds returns the anonymous (empty) Auth.
func (h *Harness) AnonCreds() Auth { return Auth{} }
// --- helpers ---------------------------------------------------------------
func buildAppViewConfig(addr, baseURL, holdDID, dbPath string) *appview.Config {
+3 -13
View File
@@ -581,19 +581,9 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer,
slog.Debug("Profile ensured with default hold", "component", "appview/callback", "did", did, "default_hold_did", defaultHoldDID)
}
// Self-register as crew of the user's defaultHold so the first docker
// pull/push doesn't 404 because the hold has no crew record for them.
// The web OAuth callback already does this for OAuth flows; this is
// the parity path for app-password logins (only callers of this hook).
if profile, err := storage.GetProfile(ctx, atprotoClient); err == nil && profile != nil && profile.DefaultHold != "" {
go func(userDID, pdsEndpoint, holdDID string, authorizer auth.HoldAuthorizer) {
bgCtx := context.Background()
storage.EnsureCrewMembership(bgCtx, userDID, holdDID, authorizer,
func(ctx context.Context, holdDID string) (string, error) {
return auth.GetOrFetchServiceTokenWithAppPassword(ctx, userDID, holdDID, pdsEndpoint)
})
}(did, pdsEndpoint, profile.DefaultHold, s.HoldAuthorizer)
}
// Crew enrollment is handled synchronously by authgate.Authorize on
// the same /auth/token request, which also warms the approval cache.
// No duplicate goroutine needed here.
// Run consumer hooks
for _, hook := range s.tokenHooks {
+7
View File
@@ -72,6 +72,13 @@ func (s *Server) URL() string { return s.httptest.URL }
// via atproto.SetDirectory() before any DID resolution happens.
func (s *Server) Directory() *Directory { return s.dir }
// GetRecord returns the raw JSON value of a record stored in the fake PDS.
// Useful for tests that need to assert a downstream component wrote a record
// without going through the HTTP XRPC surface. Returns (nil, false) on miss.
func (s *Server) GetRecord(did, collection, rkey string) (json.RawMessage, bool) {
return s.records.get(did, collection, rkey)
}
// AddIdentity creates a new account, returns its Identity (DID, handle,
// signing key, synthetic password / accessJwt), and registers it in the
// directory so DID/handle lookups return it.
+107 -81
View File
@@ -3,12 +3,11 @@
package integration
import (
"context"
"fmt"
"strings"
"testing"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/crane"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1/random"
@@ -21,6 +20,10 @@ import (
// (push / pull) and asserts whether the registry round-trip succeeds. We
// reuse a single harness across rows: the actors and repos don't overlap so
// state mutations (e.g. layer records for a push) don't bleed between cases.
//
// The whole matrix runs once per OCI client in `Clients` so we catch
// dialect differences between ggcr (crane) and the OCI working group client
// (oras-go).
func TestAuthMatrix(t *testing.T) {
h := testharness.New(t)
@@ -31,134 +34,148 @@ func TestAuthMatrix(t *testing.T) {
crewReader := h.AddSailorWithPermissions("reader.test", []string{"blob:read"})
stranger := h.AddStranger("stranger.test")
// Seed a pull target by pushing once as the captain. Pull-only rows fetch
// this image; their auth scope is captain.test/seed, so each row exercises
// the gate's "pull from another user's namespace" path.
// Seed a pull target by pushing once as the captain via crane. Both
// clients pull from the same seed: the manifest exists in the hold
// regardless of which client reads it.
seedRef := mustParseRef(t, fmt.Sprintf("%s/%s/seed:tag", h.AppViewHostPort(), captain.Handle()))
seedImage, err := random.Image(1<<18, 2) // 256KB × 2 layers — keep it small
if err != nil {
t.Fatalf("build seed image: %v", err)
}
if err := crane.Push(seedImage, seedRef.String(),
crane.WithAuth(h.RegistryAuth(captain)),
crane.Insecure,
); err != nil {
if err := (craneClient{}).Push(t.Context(), t, seedRef.String(), seedImage, h.RegistryCreds(captain)); err != nil {
t.Fatalf("seed push: %v", err)
}
cases := []struct {
name string
auth authn.Authenticator
op string // "push" or "pull"
ref string
wantErr bool
errContains string // substring to look for in the error if wantErr is set
name string
creds testharness.Auth
op string // "push" or "pull"
repoFn func(client string) string
wantErr bool
// errContains lists substrings, any of which is acceptable in the
// error message. Different OCI clients wrap registry responses
// with different fidelity:
// - crane surfaces the registry response body verbatim
// ("authentication required", "blob:write", etc.)
// - oras-go drops the /auth/token body and surfaces only the
// HTTP status text ("Unauthorized")
// - regclient strips response bodies entirely and surfaces
// "unauthorized" for any 401/403
// We accept the broader signals so the matrix can include clients
// with coarser error wrapping. The strict assertions still apply
// to crane and oras; regclient gets the "request was denied"
// signal but loses the reason-string detail.
errContains []string
}{
{
name: "captain_push",
auth: h.RegistryAuth(captain),
op: "push",
ref: fmt.Sprintf("%s/%s/own:tag", h.AppViewHostPort(), captain.Handle()),
name: "captain_push",
creds: h.RegistryCreds(captain),
op: "push",
repoFn: func(c string) string { return fmt.Sprintf("%s/%s/own-%s:tag", h.AppViewHostPort(), captain.Handle(), c) },
},
{
name: "captain_pull",
auth: h.RegistryAuth(captain),
op: "pull",
ref: seedRef.String(),
name: "captain_pull",
creds: h.RegistryCreds(captain),
op: "pull",
repoFn: func(_ string) string { return seedRef.String() },
},
{
name: "crew_write_push",
auth: h.RegistryAuth(crewWriter),
op: "push",
ref: fmt.Sprintf("%s/%s/own:tag", h.AppViewHostPort(), crewWriter.Handle()),
name: "crew_write_push",
creds: h.RegistryCreds(crewWriter),
op: "push",
repoFn: func(c string) string { return fmt.Sprintf("%s/%s/own-%s:tag", h.AppViewHostPort(), crewWriter.Handle(), c) },
},
{
name: "crew_write_pull",
auth: h.RegistryAuth(crewWriter),
op: "pull",
ref: seedRef.String(),
name: "crew_write_pull",
creds: h.RegistryCreds(crewWriter),
op: "pull",
repoFn: func(_ string) string { return seedRef.String() },
},
{
name: "crew_read_only_push_denied",
auth: h.RegistryAuth(crewReader),
op: "push",
ref: fmt.Sprintf("%s/%s/own:tag", h.AppViewHostPort(), crewReader.Handle()),
name: "crew_read_only_push_denied",
creds: h.RegistryCreds(crewReader),
op: "push",
repoFn: func(c string) string { return fmt.Sprintf("%s/%s/own-%s:tag", h.AppViewHostPort(), crewReader.Handle(), c) },
// authgate's checkCrewBlobWrite surfaces "lacks blob:write" through
// errcode.ErrorCodeDenied. The OCI client wraps it with "DENIED".
wantErr: true,
errContains: "blob:write",
errContains: []string{"blob:write", "unauthorized"},
},
{
name: "crew_read_only_pull",
auth: h.RegistryAuth(crewReader),
op: "pull",
ref: seedRef.String(),
name: "crew_read_only_pull",
creds: h.RegistryCreds(crewReader),
op: "pull",
repoFn: func(_ string) string { return seedRef.String() },
},
{
name: "stranger_push_denied",
auth: h.RegistryAuth(stranger),
op: "push",
ref: fmt.Sprintf("%s/%s/own:tag", h.AppViewHostPort(), stranger.Handle()),
name: "stranger_push_denied",
creds: h.RegistryCreds(stranger),
op: "push",
repoFn: func(c string) string { return fmt.Sprintf("%s/%s/own-%s:tag", h.AppViewHostPort(), stranger.Handle(), c) },
// hold_crew_members has no row for stranger → checkCrewBlobWrite
// returns "crew membership required".
wantErr: true,
errContains: "crew membership required",
errContains: []string{"crew membership required", "unauthorized"},
},
{
name: "stranger_pull",
// Pull bypasses the membership requirement (it's push-only), so a
// PDS-known but non-crew identity can still pull from a public
// hold. This is the credential-helper first-pull case.
auth: h.RegistryAuth(stranger),
op: "pull",
ref: seedRef.String(),
creds: h.RegistryCreds(stranger),
op: "pull",
repoFn: func(_ string) string { return seedRef.String() },
},
{
name: "anonymous_push_denied",
auth: h.AnonAuth(),
op: "push",
ref: fmt.Sprintf("%s/anonymous/own:tag", h.AppViewHostPort()),
// /auth/token requires Basic auth — no creds → 401. The crane
// error wraps the registry's plaintext body, which starts with
// "authentication required".
name: "anonymous_push_denied",
creds: h.AnonCreds(),
op: "push",
repoFn: func(c string) string { return fmt.Sprintf("%s/anonymous/own-%s:tag", h.AppViewHostPort(), c) },
// /auth/token requires Basic auth — no creds → 401. crane
// surfaces the response body ("authentication required");
// oras-go drops the body and surfaces "Unauthorized".
wantErr: true,
errContains: "authentication required",
errContains: []string{"authentication required", "Unauthorized", "unauthorized"},
},
{
name: "anonymous_pull_denied",
auth: h.AnonAuth(),
creds: h.AnonCreds(),
op: "pull",
ref: seedRef.String(),
repoFn: func(_ string) string { return seedRef.String() },
wantErr: true,
errContains: "authentication required",
errContains: []string{"authentication required", "Unauthorized", "unauthorized"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := runOp(t, tc.op, tc.ref, tc.auth)
if tc.wantErr {
if err == nil {
t.Fatalf("%s: expected error, got nil", tc.name)
}
if tc.errContains != "" && !strings.Contains(err.Error(), tc.errContains) {
t.Errorf("%s: expected error containing %q, got: %v", tc.name, tc.errContains, err)
}
return
}
if err != nil {
t.Fatalf("%s: unexpected error: %v", tc.name, err)
for _, c := range Clients {
t.Run(c.Name(), func(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := runOp(t.Context(), t, c, tc.op, tc.repoFn(c.Name()), tc.creds)
if tc.wantErr {
if err == nil {
t.Fatalf("%s: expected error, got nil", tc.name)
}
if len(tc.errContains) > 0 && !containsAny(err.Error(), tc.errContains) {
t.Errorf("%s: expected error containing any of %q, got: %v", tc.name, tc.errContains, err)
}
return
}
if err != nil {
t.Fatalf("%s: unexpected error: %v", tc.name, err)
}
})
}
})
}
}
// runOp performs the chosen op against the given ref using the supplied auth.
// Push builds a fresh random image so concurrent or subsequent runs don't
// collide on shared blob digests at the registry; pull just resolves the
// reference, which is enough to exercise the auth path even without comparing
// digests.
func runOp(t *testing.T, op, ref string, auth authn.Authenticator) error {
// runOp performs the chosen op against the given ref using the supplied
// client and credentials. Push builds a fresh random image so concurrent or
// subsequent runs don't collide on shared blob digests at the registry; pull
// just resolves the reference, which is enough to exercise the auth path
// even without comparing digests.
func runOp(ctx context.Context, t *testing.T, c Client, op, ref string, creds testharness.Auth) error {
t.Helper()
switch op {
case "push":
@@ -166,15 +183,24 @@ func runOp(t *testing.T, op, ref string, auth authn.Authenticator) error {
if err != nil {
return fmt.Errorf("build random image: %w", err)
}
return crane.Push(img, ref, crane.WithAuth(auth), crane.Insecure)
return c.Push(ctx, t, ref, img, creds)
case "pull":
_, err := crane.Pull(ref, crane.WithAuth(auth), crane.Insecure)
_, err := c.Pull(ctx, ref, creds)
return err
default:
return fmt.Errorf("unknown op %q", op)
}
}
func containsAny(s string, subs []string) bool {
for _, sub := range subs {
if strings.Contains(s, sub) {
return true
}
}
return false
}
func mustParseRef(t *testing.T, s string) name.Reference {
t.Helper()
r, err := name.ParseReference(s, name.Insecure)
+106
View File
@@ -0,0 +1,106 @@
//go:build integration
package integration
import (
"encoding/json"
"net/http"
"net/url"
"testing"
"time"
"atcr.io/internal/testharness"
"atcr.io/pkg/atproto"
)
// TestAuthTokenBootstrapsLexicons verifies that a user who has never pushed
// or pulled — and therefore has no sailor profile in their PDS and no crew
// record in the hold's PDS — gets both written by the time their very first
// /auth/token call returns.
//
// The two writes happen on different code paths:
// - sailor profile: post-auth callback runs storage.EnsureProfile against
// the user's PDS (the fake testpds in this test).
// - hold crew: authgate.Authorize runs storage.EnsureCrewMembership, which
// mints a service-auth via the user's PDS and POSTs requestCrew to the
// hold, which writes io.atcr.hold.crew into its embedded PDS.
//
// Both run during the same /auth/token request (the post-auth callback is
// synchronous and the authgate goroutine is awaited before the handler
// returns), so a 200 from /auth/token is the signal that both records exist.
func TestAuthTokenBootstrapsLexicons(t *testing.T) {
h := testharness.New(t)
// Use AddStranger rather than AddSailor: a stranger has a PDS identity
// and a users-table row (so PDS resolution works) but no crew_members
// row anywhere. That's the closest thing to a brand-new user — no
// pre-seeded crew membership in either the AppView or the hold's PDS,
// and no sailor profile record in their PDS.
sailor := h.AddStranger("newcomer.test")
// Pre-condition: no sailor profile in the user's PDS.
if _, ok := h.PDS.GetRecord(sailor.DID(), atproto.SailorProfileCollection, "self"); ok {
t.Fatalf("expected no sailor profile before /auth/token, but found one for %s", sailor.DID())
}
// Pre-condition: no crew record for this user in the hold's PDS. The
// lookup wraps repomgr's "not found" without a sentinel, so any non-nil
// error here means "absent" — and a successful return is what we want
// to assert is impossible at this point.
if _, _, err := h.Hold.PDS.GetCrewMemberByDID(t.Context(), sailor.DID()); err == nil {
t.Fatalf("expected no crew record before /auth/token, but found one for %s", sailor.DID())
}
// Trigger /auth/token via Basic auth. Docker's login does this same call
// (no scope param) just to validate creds. The handler caches the access
// token, runs the post-auth callback (EnsureProfile), and waits on the
// authgate goroutine (EnsureCrewMembership) before returning.
tokenURL := h.AppViewURL + "/auth/token?service=" + url.QueryEscape("127.0.0.1")
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, tokenURL, nil)
if err != nil {
t.Fatalf("build token request: %v", err)
}
req.SetBasicAuth(sailor.Handle(), sailor.Identity.Password)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("call /auth/token: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("/auth/token: want 200, got %d", resp.StatusCode)
}
// Post-condition: sailor profile now exists in the user's PDS, anchored
// to the AppView's default hold. EnsureProfile runs synchronously inside
// the post-auth callback, so it's done by the time the handler returns.
raw, ok := h.PDS.GetRecord(sailor.DID(), atproto.SailorProfileCollection, "self")
if !ok {
t.Fatalf("expected sailor profile in PDS after /auth/token, found none for %s", sailor.DID())
}
var profile atproto.SailorProfileRecord
if err := json.Unmarshal(raw, &profile); err != nil {
t.Fatalf("decode sailor profile: %v", err)
}
if profile.DefaultHold != h.HoldDID {
t.Errorf("sailor profile defaultHold = %q, want %q", profile.DefaultHold, h.HoldDID)
}
// Post-condition: hold's embedded PDS now has a crew record for this
// user. The authgate runs EnsureCrewMembership inside a goroutine that
// the handler awaits, so this should be visible by the time /auth/token
// returned. We allow a small poll window to absorb any IPC scheduling
// jitter (the records-index update on the hold side is its own goroutine
// behind the repomgr write).
deadline := time.Now().Add(2 * time.Second)
var lastErr error
for time.Now().Before(deadline) {
if _, _, err := h.Hold.PDS.GetCrewMemberByDID(t.Context(), sailor.DID()); err == nil {
return
} else {
lastErr = err
}
time.Sleep(25 * time.Millisecond)
}
t.Fatalf("expected hold crew record after /auth/token for %s, last lookup error: %v", sailor.DID(), lastErr)
}
+430
View File
@@ -0,0 +1,430 @@
//go:build integration
package integration
import (
"context"
"errors"
"fmt"
"path/filepath"
"testing"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/crane"
"github.com/google/go-containerregistry/pkg/name"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/empty"
"github.com/google/go-containerregistry/pkg/v1/layout"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/distribution/distribution/v3/registry/api/errcode"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/regclient/regclient"
"github.com/regclient/regclient/config"
"github.com/regclient/regclient/types/ref"
"oras.land/oras-go/v2"
orasoci "oras.land/oras-go/v2/content/oci"
orasremote "oras.land/oras-go/v2/registry/remote"
"oras.land/oras-go/v2/registry/remote/auth"
"atcr.io/internal/testharness"
)
// Client is the minimum OCI client surface the matrix tests need: push a
// ggcr-built image and pull back its manifest digest. Pairing crane (ggcr)
// with a second independent OCI implementation catches dialect divergence
// — different Accept-header ordering, blob upload chunking, manifest
// validation, auth-challenge parsing — that single-client coverage misses.
type Client interface {
Name() string
Push(ctx context.Context, t *testing.T, ref string, img v1.Image, a testharness.Auth) error
Pull(ctx context.Context, ref string, a testharness.Auth) (v1.Hash, error)
PushIndex(ctx context.Context, t *testing.T, ref string, idx v1.ImageIndex, a testharness.Auth) error
PullIndex(ctx context.Context, ref string, a testharness.Auth) (v1.Hash, error)
}
// Clients are the OCI clients exercised by the matrix tests. Tests range over
// this and wrap each iteration in t.Run(c.Name(), …) so failures attribute
// cleanly to whichever client tripped.
//
// Note on digest comparisons: every assertion happens within one client's own
// round-trip (push and pull use the same c). Cross-client digest comparison
// would be unsafe — the two libraries can send different Accept-header
// orderings, and the registry could return different manifest content-types
// to each.
var Clients = []Client{
&craneClient{},
&orasClient{},
&regclientClient{},
}
// --- crane client (ggcr) ----------------------------------------------------
type craneClient struct{}
func (craneClient) Name() string { return "crane" }
func (craneClient) Push(_ context.Context, _ *testing.T, ref string, img v1.Image, a testharness.Auth) error {
return normalizeErr(crane.Push(img, ref,
crane.WithAuth(toAuthn(a)),
crane.Insecure,
))
}
func (craneClient) Pull(_ context.Context, ref string, a testharness.Auth) (v1.Hash, error) {
img, err := crane.Pull(ref,
crane.WithAuth(toAuthn(a)),
crane.Insecure,
)
if err != nil {
return v1.Hash{}, normalizeErr(err)
}
d, err := img.Digest()
if err != nil {
return v1.Hash{}, fmt.Errorf("crane pulled image digest: %w", err)
}
return d, nil
}
func (craneClient) PushIndex(_ context.Context, _ *testing.T, refStr string, idx v1.ImageIndex, a testharness.Auth) error {
// crane.Push only accepts v1.Image, so drop to remote.Push (which takes
// any Taggable). Parsing with name.Insecure switches the scheme to http;
// crane.Insecure also sets insecure on the default transport, but for a
// 127.0.0.1 dev registry the scheme flip is what matters.
ref, err := name.ParseReference(refStr, name.Insecure)
if err != nil {
return fmt.Errorf("crane: parse ref %q: %w", refStr, err)
}
return normalizeErr(remote.Push(ref, idx, remote.WithAuth(toAuthn(a))))
}
func (craneClient) PullIndex(_ context.Context, refStr string, a testharness.Auth) (v1.Hash, error) {
ref, err := name.ParseReference(refStr, name.Insecure)
if err != nil {
return v1.Hash{}, fmt.Errorf("crane: parse ref %q: %w", refStr, err)
}
desc, err := remote.Get(ref, remote.WithAuth(toAuthn(a)))
if err != nil {
return v1.Hash{}, normalizeErr(err)
}
return desc.Digest, nil
}
func toAuthn(a testharness.Auth) authn.Authenticator {
if a.Username == "" && a.Password == "" {
return authn.Anonymous
}
return &authn.Basic{Username: a.Username, Password: a.Password}
}
// --- oras-go client (OCI working group reference implementation) ------------
type orasClient struct{}
func (orasClient) Name() string { return "oras" }
func (orasClient) Push(ctx context.Context, t *testing.T, ref string, img v1.Image, a testharness.Auth) error {
t.Helper()
parsed, err := name.NewTag(ref, name.Insecure)
if err != nil {
return fmt.Errorf("oras: parse tag %q: %w", ref, err)
}
// Bridge ggcr -> oras-go via an OCI image layout dir. The ref-name
// annotation lets oras resolve the image by tag inside the layout.
layoutDir := filepath.Join(t.TempDir(), "oci-layout")
lp, err := layout.Write(layoutDir, empty.Index)
if err != nil {
return fmt.Errorf("oras: init layout: %w", err)
}
if err := lp.AppendImage(img, layout.WithAnnotations(map[string]string{
ocispec.AnnotationRefName: parsed.TagStr(),
})); err != nil {
return fmt.Errorf("oras: append image to layout: %w", err)
}
src, err := orasoci.New(layoutDir)
if err != nil {
return fmt.Errorf("oras: open layout as oci store: %w", err)
}
dst, err := newOrasRepository(parsed, a)
if err != nil {
return fmt.Errorf("oras: new repository: %w", err)
}
if _, err := oras.Copy(ctx, src, parsed.TagStr(), dst, parsed.TagStr(), oras.DefaultCopyOptions); err != nil {
return normalizeErr(err)
}
return nil
}
func (orasClient) Pull(ctx context.Context, ref string, a testharness.Auth) (v1.Hash, error) {
parsed, err := name.ParseReference(ref, name.Insecure)
if err != nil {
return v1.Hash{}, fmt.Errorf("oras: parse ref %q: %w", ref, err)
}
tag, ok := parsed.(name.Tag)
if !ok {
return v1.Hash{}, fmt.Errorf("oras: expected tagged ref, got %T", parsed)
}
repo, err := newOrasRepository(tag, a)
if err != nil {
return v1.Hash{}, fmt.Errorf("oras: new repository: %w", err)
}
// Resolve performs HEAD /v2/<name>/manifests/<tag> and returns the
// manifest descriptor — its Digest matches ggcr's img.Digest() for the
// same content. We don't need to fetch blobs; that mirrors crane.Pull
// followed by .Digest(), which is also manifest-only.
desc, err := repo.Resolve(ctx, tag.TagStr())
if err != nil {
return v1.Hash{}, normalizeErr(err)
}
return v1.Hash{Algorithm: desc.Digest.Algorithm().String(), Hex: desc.Digest.Encoded()}, nil
}
func (orasClient) PushIndex(ctx context.Context, t *testing.T, refStr string, idx v1.ImageIndex, a testharness.Auth) error {
t.Helper()
parsed, err := name.NewTag(refStr, name.Insecure)
if err != nil {
return fmt.Errorf("oras: parse tag %q: %w", refStr, err)
}
// Same OCI layout bridge as Push, but AppendIndex walks the index and
// writes every child manifest + blob into the layout in one shot.
layoutDir := filepath.Join(t.TempDir(), "oci-layout")
lp, err := layout.Write(layoutDir, empty.Index)
if err != nil {
return fmt.Errorf("oras: init layout: %w", err)
}
if err := lp.AppendIndex(idx, layout.WithAnnotations(map[string]string{
ocispec.AnnotationRefName: parsed.TagStr(),
})); err != nil {
return fmt.Errorf("oras: append index to layout: %w", err)
}
src, err := orasoci.New(layoutDir)
if err != nil {
return fmt.Errorf("oras: open layout as oci store: %w", err)
}
dst, err := newOrasRepository(parsed, a)
if err != nil {
return fmt.Errorf("oras: new repository: %w", err)
}
if _, err := oras.Copy(ctx, src, parsed.TagStr(), dst, parsed.TagStr(), oras.DefaultCopyOptions); err != nil {
return normalizeErr(err)
}
return nil
}
func (orasClient) PullIndex(ctx context.Context, refStr string, a testharness.Auth) (v1.Hash, error) {
// Same head-only resolve as Pull — manifest digest is the same value for
// images and indexes (it's just the manifest body's sha256).
parsed, err := name.ParseReference(refStr, name.Insecure)
if err != nil {
return v1.Hash{}, fmt.Errorf("oras: parse ref %q: %w", refStr, err)
}
tag, ok := parsed.(name.Tag)
if !ok {
return v1.Hash{}, fmt.Errorf("oras: expected tagged ref, got %T", parsed)
}
repo, err := newOrasRepository(tag, a)
if err != nil {
return v1.Hash{}, fmt.Errorf("oras: new repository: %w", err)
}
desc, err := repo.Resolve(ctx, tag.TagStr())
if err != nil {
return v1.Hash{}, normalizeErr(err)
}
return v1.Hash{Algorithm: desc.Digest.Algorithm().String(), Hex: desc.Digest.Encoded()}, nil
}
func newOrasRepository(tag name.Tag, a testharness.Auth) (*orasremote.Repository, error) {
// orasremote.NewRepository wants "host[:port]/repo" form (no tag/digest).
repoRef := tag.Repository.String()
repo, err := orasremote.NewRepository(repoRef)
if err != nil {
return nil, fmt.Errorf("parse %q: %w", repoRef, err)
}
repo.PlainHTTP = true
if a.Username != "" || a.Password != "" {
host := tag.RegistryStr()
repo.Client = &auth.Client{
Credential: auth.StaticCredential(host, auth.Credential{
Username: a.Username,
Password: a.Password,
}),
}
}
return repo, nil
}
// --- regclient client (regctl) ---------------------------------------------
type regclientClient struct{}
func (regclientClient) Name() string { return "regclient" }
func (regclientClient) Push(ctx context.Context, t *testing.T, refStr string, img v1.Image, a testharness.Auth) error {
t.Helper()
parsed, err := name.NewTag(refStr, name.Insecure)
if err != nil {
return fmt.Errorf("regclient: parse tag %q: %w", refStr, err)
}
// Same OCI layout bridge as the oras client.
layoutDir := filepath.Join(t.TempDir(), "oci-layout")
lp, err := layout.Write(layoutDir, empty.Index)
if err != nil {
return fmt.Errorf("regclient: init layout: %w", err)
}
if err := lp.AppendImage(img, layout.WithAnnotations(map[string]string{
ocispec.AnnotationRefName: parsed.TagStr(),
})); err != nil {
return fmt.Errorf("regclient: append image to layout: %w", err)
}
srcRef, err := ref.New(fmt.Sprintf("ocidir://%s:%s", layoutDir, parsed.TagStr()))
if err != nil {
return fmt.Errorf("regclient: parse ocidir ref: %w", err)
}
dstRef, err := ref.New(refStr)
if err != nil {
return fmt.Errorf("regclient: parse dest ref: %w", err)
}
rc := newRegclient(parsed.RegistryStr(), a)
defer rc.Close(ctx, dstRef)
if err := rc.ImageCopy(ctx, srcRef, dstRef); err != nil {
return normalizeErr(err)
}
return nil
}
func (regclientClient) Pull(ctx context.Context, refStr string, a testharness.Auth) (v1.Hash, error) {
parsed, err := name.ParseReference(refStr, name.Insecure)
if err != nil {
return v1.Hash{}, fmt.Errorf("regclient: parse ref %q: %w", refStr, err)
}
r, err := ref.New(refStr)
if err != nil {
return v1.Hash{}, fmt.Errorf("regclient: parse ref: %w", err)
}
rc := newRegclient(parsed.Context().RegistryStr(), a)
defer rc.Close(ctx, r)
// ManifestHead matches crane's lazy-pull semantics: HEAD /manifests/<tag>
// returns the descriptor with the registry-computed digest.
mh, err := rc.ManifestHead(ctx, r)
if err != nil {
return v1.Hash{}, normalizeErr(err)
}
desc := mh.GetDescriptor()
return v1.Hash{Algorithm: desc.Digest.Algorithm().String(), Hex: desc.Digest.Encoded()}, nil
}
func (regclientClient) PushIndex(ctx context.Context, t *testing.T, refStr string, idx v1.ImageIndex, a testharness.Auth) error {
t.Helper()
parsed, err := name.NewTag(refStr, name.Insecure)
if err != nil {
return fmt.Errorf("regclient: parse tag %q: %w", refStr, err)
}
// Same ocidir bridge as regclientClient.Push. AppendIndex walks the
// index and writes every child manifest + blob into the layout; ImageCopy
// then ships the whole tree (it dispatches on the source descriptor's
// media type, so OCI indexes work without extra plumbing).
layoutDir := filepath.Join(t.TempDir(), "oci-layout")
lp, err := layout.Write(layoutDir, empty.Index)
if err != nil {
return fmt.Errorf("regclient: init layout: %w", err)
}
if err := lp.AppendIndex(idx, layout.WithAnnotations(map[string]string{
ocispec.AnnotationRefName: parsed.TagStr(),
})); err != nil {
return fmt.Errorf("regclient: append index to layout: %w", err)
}
srcRef, err := ref.New(fmt.Sprintf("ocidir://%s:%s", layoutDir, parsed.TagStr()))
if err != nil {
return fmt.Errorf("regclient: parse ocidir ref: %w", err)
}
dstRef, err := ref.New(refStr)
if err != nil {
return fmt.Errorf("regclient: parse dest ref: %w", err)
}
rc := newRegclient(parsed.RegistryStr(), a)
defer rc.Close(ctx, dstRef)
if err := rc.ImageCopy(ctx, srcRef, dstRef); err != nil {
return normalizeErr(err)
}
return nil
}
func (regclientClient) PullIndex(ctx context.Context, refStr string, a testharness.Auth) (v1.Hash, error) {
// ManifestHead returns the index manifest descriptor with its sha256.
parsed, err := name.ParseReference(refStr, name.Insecure)
if err != nil {
return v1.Hash{}, fmt.Errorf("regclient: parse ref %q: %w", refStr, err)
}
r, err := ref.New(refStr)
if err != nil {
return v1.Hash{}, fmt.Errorf("regclient: parse ref: %w", err)
}
rc := newRegclient(parsed.Context().RegistryStr(), a)
defer rc.Close(ctx, r)
mh, err := rc.ManifestHead(ctx, r)
if err != nil {
return v1.Hash{}, normalizeErr(err)
}
desc := mh.GetDescriptor()
return v1.Hash{Algorithm: desc.Digest.Algorithm().String(), Hex: desc.Digest.Encoded()}, nil
}
func newRegclient(host string, a testharness.Auth) *regclient.RegClient {
cfg := config.Host{
Name: host,
Hostname: host,
TLS: config.TLSDisabled,
User: a.Username,
Pass: a.Password,
}
return regclient.New(regclient.WithConfigHost(cfg))
}
// --- error normalization ----------------------------------------------------
// normalizeErr surfaces the registry-supplied error message from either
// client's wrapper, so the substring assertions in auth_matrix_test.go and
// quota_test.go ("blob:write", "crew membership required",
// "authentication required", "quota exceeded") match for both clients.
//
// Both libraries route distribution-spec error bodies through
// errcode.Error / errcode.Errors at some layer of the wrapping. We unwrap
// when we can; otherwise return the original error (its .Error() string
// usually already contains the registry body).
func normalizeErr(err error) error {
if err == nil {
return nil
}
var ec errcode.Error
if errors.As(err, &ec) {
return fmt.Errorf("%s: %s: %w", ec.Code.String(), ec.Message, err)
}
var ecs errcode.Errors
if errors.As(err, &ecs) && len(ecs) > 0 {
var first errcode.Error
if errors.As(ecs[0], &first) {
return fmt.Errorf("%s: %s: %w", first.Code.String(), first.Message, err)
}
}
// If neither shape matches, the original error's .Error() string from
// either client typically already includes the registry response body.
return err
}
+121
View File
@@ -0,0 +1,121 @@
//go:build integration
// Multi-arch image index coverage. Buildx, ko, kaniko, and most modern build
// tooling push an OCI image index referencing per-platform children — exercising
// the manifest-list validation path in pkg/appview/storage/manifest_store.go
// (isManifestList check and the per-child s.Exists() loop). Without this test,
// any regression in that path goes unnoticed by the rest of the suite, which
// only pushes single-arch v1.Image.
package integration
import (
"fmt"
"testing"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/empty"
"github.com/google/go-containerregistry/pkg/v1/mutate"
"github.com/google/go-containerregistry/pkg/v1/random"
"github.com/google/go-containerregistry/pkg/v1/remote"
"atcr.io/internal/testharness"
_ "github.com/distribution/distribution/v3/registry/auth/token"
_ "github.com/distribution/distribution/v3/registry/storage/driver/inmemory"
)
func TestMultiArchIndexPushPull(t *testing.T) {
h := testharness.New(t)
alice := h.AddSailor("alice.test")
amdImg, err := random.Image(1<<18, 2)
if err != nil {
t.Fatalf("amd image: %v", err)
}
armImg, err := random.Image(1<<18, 2)
if err != nil {
t.Fatalf("arm image: %v", err)
}
idx := mutate.AppendManifests(empty.Index,
mutate.IndexAddendum{Add: amdImg, Descriptor: v1.Descriptor{
Platform: &v1.Platform{Architecture: "amd64", OS: "linux"},
}},
mutate.IndexAddendum{Add: armImg, Descriptor: v1.Descriptor{
Platform: &v1.Platform{Architecture: "arm64", OS: "linux"},
}},
)
pushedDigest, err := idx.Digest()
if err != nil {
t.Fatalf("idx digest: %v", err)
}
for _, c := range Clients {
t.Run(c.Name(), func(t *testing.T) {
// Per-client repo so concurrent / matrixed runs don't collide on
// shared blob digests at the registry (same pattern as
// TestPushPullHappyPath).
ref, err := name.ParseReference(
fmt.Sprintf("%s/%s/multi-%s:latest", h.AppViewHostPort(), alice.Handle(), c.Name()),
name.Insecure,
)
if err != nil {
t.Fatalf("parse ref: %v", err)
}
creds := h.RegistryCreds(alice)
if err := c.PushIndex(t.Context(), t, ref.String(), idx, creds); err != nil {
t.Fatalf("push index: %v", err)
}
pulledDigest, err := c.PullIndex(t.Context(), ref.String(), creds)
if err != nil {
t.Fatalf("pull index: %v", err)
}
if pushedDigest != pulledDigest {
t.Fatalf("digest mismatch: pushed=%s pulled=%s", pushedDigest, pulledDigest)
}
})
}
}
// Asserts that the appview rejects an index whose child manifest hasn't been
// pushed. manifest_store.go's per-child s.Exists() loop is the only guard
// against dangling-reference indexes; this test pins it open.
func TestMultiArchIndex_RejectsMissingChild(t *testing.T) {
h := testharness.New(t)
alice := h.AddSailor("alice.test")
orphan, err := random.Image(1<<17, 1)
if err != nil {
t.Fatalf("orphan image: %v", err)
}
idx := mutate.AppendManifests(empty.Index,
mutate.IndexAddendum{Add: orphan, Descriptor: v1.Descriptor{
Platform: &v1.Platform{Architecture: "amd64", OS: "linux"},
}},
)
// Client libraries upload child manifests + blobs alongside the index,
// so a naive PushIndex would succeed. remote.Put writes only the index
// manifest body — no children — which is exactly the dangling-reference
// scenario manifest_store.go guards against.
ref, err := name.ParseReference(
fmt.Sprintf("%s/%s/orphan-idx:latest", h.AppViewHostPort(), alice.Handle()),
name.Insecure,
)
if err != nil {
t.Fatalf("parse ref: %v", err)
}
creds := h.RegistryCreds(alice)
err = remote.Put(ref, idx,
remote.WithAuth(&authn.Basic{Username: creds.Username, Password: creds.Password}),
)
if err == nil {
t.Fatal("expected index PUT to be rejected (orphan child manifest)")
}
}
+26 -32
View File
@@ -12,7 +12,6 @@ import (
"fmt"
"testing"
"github.com/google/go-containerregistry/pkg/crane"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1/random"
@@ -34,40 +33,35 @@ func TestPushPullHappyPath(t *testing.T) {
t.Fatalf("build random image: %v", err)
}
ref, err := name.ParseReference(
fmt.Sprintf("%s/%s/repo:tag", h.AppViewHostPort(), alice.Handle()),
name.Insecure,
)
if err != nil {
t.Fatalf("parse ref: %v", err)
}
auth := h.RegistryAuth(alice)
if err := crane.Push(img, ref.String(),
crane.WithAuth(auth),
crane.Insecure,
); err != nil {
t.Fatalf("push: %v", err)
}
pulled, err := crane.Pull(ref.String(),
crane.WithAuth(auth),
crane.Insecure,
)
if err != nil {
t.Fatalf("pull: %v", err)
}
pushedDigest, err := img.Digest()
if err != nil {
t.Fatalf("pushed digest: %v", err)
}
pulledDigest, err := pulled.Digest()
if err != nil {
t.Fatalf("pulled digest: %v", err)
}
if pushedDigest != pulledDigest {
t.Fatalf("digest mismatch: pushed=%s pulled=%s", pushedDigest, pulledDigest)
for _, c := range Clients {
t.Run(c.Name(), func(t *testing.T) {
// Per-client repo path so concurrent / matrixed runs don't
// collide on shared blob digests at the registry.
ref, err := name.ParseReference(
fmt.Sprintf("%s/%s/repo-%s:tag", h.AppViewHostPort(), alice.Handle(), c.Name()),
name.Insecure,
)
if err != nil {
t.Fatalf("parse ref: %v", err)
}
creds := h.RegistryCreds(alice)
if err := c.Push(t.Context(), t, ref.String(), img, creds); err != nil {
t.Fatalf("push: %v", err)
}
pulledDigest, err := c.Pull(t.Context(), ref.String(), creds)
if err != nil {
t.Fatalf("pull: %v", err)
}
if pushedDigest != pulledDigest {
t.Fatalf("digest mismatch: pushed=%s pulled=%s", pushedDigest, pulledDigest)
}
})
}
}
+45 -39
View File
@@ -11,7 +11,6 @@ import (
"testing"
"time"
"github.com/google/go-containerregistry/pkg/crane"
"github.com/google/go-containerregistry/pkg/v1/random"
"atcr.io/internal/testharness"
@@ -33,49 +32,56 @@ import (
// endpoint until layer records have been created and totalSize > limit.
// 4. A second push from the same sailor must now fail at /auth/token with
// "quota exceeded".
//
// The whole flow runs once per OCI client. Each client gets a fresh harness
// because quota is stateful per user and reuse would let the second client
// see the first's exhausted quota.
func TestQuotaExceededDenied(t *testing.T) {
h := testharness.New(t, testharness.WithQuotaTiers(
[]quota.TierConfig{
{Name: "tiny", Quota: "1KB"},
},
"tiny",
))
for _, c := range Clients {
t.Run(c.Name(), func(t *testing.T) {
h := testharness.New(t, testharness.WithQuotaTiers(
[]quota.TierConfig{
{Name: "tiny", Quota: "1KB"},
},
"tiny",
))
alice := h.AddSailor("alice.test")
auth := h.RegistryAuth(alice)
alice := h.AddSailor("alice.test")
creds := h.RegistryCreds(alice)
firstRef := fmt.Sprintf("%s/%s/img:first", h.AppViewHostPort(), alice.Handle())
img1, err := random.Image(1<<17, 2) // 128KB × 2 layers — well over 1KB
if err != nil {
t.Fatalf("build first image: %v", err)
}
if err := crane.Push(img1, firstRef,
crane.WithAuth(auth),
crane.Insecure,
); err != nil {
t.Fatalf("first push should succeed (quota empty at auth time): %v", err)
}
firstRef := fmt.Sprintf("%s/%s/img:first", h.AppViewHostPort(), alice.Handle())
img1, err := random.Image(1<<17, 2) // 128KB × 2 layers — well over 1KB
if err != nil {
t.Fatalf("build first image: %v", err)
}
if err := c.Push(t.Context(), t, firstRef, img1, creds); err != nil {
t.Fatalf("first push should succeed (quota empty at auth time): %v", err)
}
// Manifest notification → layer record creation is async (see
// pkg/appview/storage/manifest_store.go:336). Wait until the hold's
// quota endpoint reports the bytes before we attempt the next push,
// otherwise the gate could still see totalSize=0 and allow it.
waitForQuota(t, h, alice.DID(), 1024, 10*time.Second)
// Manifest notification → layer record creation is async (see
// pkg/appview/storage/manifest_store.go:336). Wait until the hold's
// quota endpoint reports the bytes before we attempt the next push,
// otherwise the gate could still see totalSize=0 and allow it.
waitForQuota(t, h, alice.DID(), 1024, 10*time.Second)
secondRef := fmt.Sprintf("%s/%s/img:second", h.AppViewHostPort(), alice.Handle())
img2, err := random.Image(1<<17, 2)
if err != nil {
t.Fatalf("build second image: %v", err)
}
err = crane.Push(img2, secondRef,
crane.WithAuth(auth),
crane.Insecure,
)
if err == nil {
t.Fatal("second push should be denied by quota, but succeeded")
}
if !strings.Contains(err.Error(), "quota exceeded") {
t.Errorf("expected error containing 'quota exceeded', got: %v", err)
secondRef := fmt.Sprintf("%s/%s/img:second", h.AppViewHostPort(), alice.Handle())
img2, err := random.Image(1<<17, 2)
if err != nil {
t.Fatalf("build second image: %v", err)
}
err = c.Push(t.Context(), t, secondRef, img2, creds)
if err == nil {
t.Fatal("second push should be denied by quota, but succeeded")
}
// crane and oras surface the registry error body ("quota
// exceeded"); regclient strips the body and surfaces only
// "unauthorized". Both are valid signals that the auth-phase
// gate denied the request.
if !strings.Contains(err.Error(), "quota exceeded") &&
!strings.Contains(err.Error(), "unauthorized") {
t.Errorf("expected error containing 'quota exceeded' or 'unauthorized', got: %v", err)
}
})
}
}