Files
at-container-registry/test/integration/quota_test.go
T

111 lines
3.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//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)
}