mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-19 00:34:16 +00:00
111 lines
3.9 KiB
Go
111 lines
3.9 KiB
Go
//go:build integration
|
||
|
||
package integration
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/google/go-containerregistry/pkg/v1/random"
|
||
|
||
"atcr.io/internal/testharness"
|
||
"atcr.io/pkg/atproto"
|
||
"atcr.io/pkg/hold/quota"
|
||
)
|
||
|
||
// TestQuotaExceededDenied verifies that the appview's auth-phase gate denies
|
||
// a non-captain push once the user's recorded layer bytes exceed their tier
|
||
// limit. Wire-up:
|
||
//
|
||
// 1. Harness boots with a tier "tiny" capped at 1KB and NewCrewTier="tiny",
|
||
// so every new crew member is on the 1KB plan. The captain is exempt
|
||
// (owner is always unlimited per GetQuotaForUserWithTier).
|
||
// 2. A crew sailor pushes a >>1KB image. /auth/token sees totalSize=0 at
|
||
// that moment, allows. Push succeeds.
|
||
// 3. Manifest notification is async (notifyManifest fires from a goroutine
|
||
// after the push response), so we poll the hold's public getQuota
|
||
// 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) {
|
||
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")
|
||
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 := 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)
|
||
|
||
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)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// waitForQuota polls the hold's public getQuota endpoint until totalSize for
|
||
// userDID is at least minBytes, or the deadline expires. The endpoint is
|
||
// unauthenticated by design (the appview's gate calls it the same way).
|
||
func waitForQuota(t *testing.T, h *testharness.Harness, userDID string, minBytes int64, timeout time.Duration) {
|
||
t.Helper()
|
||
deadline := time.Now().Add(timeout)
|
||
endpoint := h.HoldURL + atproto.HoldGetQuota + "?userDid=" + url.QueryEscape(userDID)
|
||
for time.Now().Before(deadline) {
|
||
resp, err := http.Get(endpoint)
|
||
if err == nil {
|
||
var body struct {
|
||
TotalSize int64 `json:"totalSize"`
|
||
}
|
||
derr := json.NewDecoder(resp.Body).Decode(&body)
|
||
resp.Body.Close()
|
||
if derr == nil && body.TotalSize >= minBytes {
|
||
return
|
||
}
|
||
}
|
||
time.Sleep(100 * time.Millisecond)
|
||
}
|
||
t.Fatalf("hold getQuota for %s did not report >= %d bytes within %s", userDID, minBytes, timeout)
|
||
}
|