mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-05-23 16:31:31 +00:00
212 lines
7.2 KiB
Go
212 lines
7.2 KiB
Go
//go:build integration
|
||
|
||
package integration
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/google/go-containerregistry/pkg/name"
|
||
"github.com/google/go-containerregistry/pkg/v1/random"
|
||
|
||
"atcr.io/internal/testharness"
|
||
)
|
||
|
||
// TestAuthMatrix exercises the authorization matrix that /auth/token plus the
|
||
// hold authorizer enforce together. Each row picks an actor (captain / crew
|
||
// with write / crew with read-only / stranger / anonymous) and an operation
|
||
// (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)
|
||
|
||
// Identities. Each row references one of these — strangers and read-only
|
||
// crew get separate handles so their state stays isolated.
|
||
captain := h.Captain
|
||
crewWriter := h.AddSailor("writer.test")
|
||
crewReader := h.AddSailorWithPermissions("reader.test", []string{"blob:read"})
|
||
stranger := h.AddStranger("stranger.test")
|
||
|
||
// 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 := (craneClient{}).Push(t.Context(), t, seedRef.String(), seedImage, h.RegistryCreds(captain)); err != nil {
|
||
t.Fatalf("seed push: %v", err)
|
||
}
|
||
|
||
cases := []struct {
|
||
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",
|
||
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",
|
||
creds: h.RegistryCreds(captain),
|
||
op: "pull",
|
||
repoFn: func(_ string) string { return seedRef.String() },
|
||
},
|
||
{
|
||
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",
|
||
creds: h.RegistryCreds(crewWriter),
|
||
op: "pull",
|
||
repoFn: func(_ string) string { return seedRef.String() },
|
||
},
|
||
{
|
||
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: []string{"blob:write", "unauthorized"},
|
||
},
|
||
{
|
||
name: "crew_read_only_pull",
|
||
creds: h.RegistryCreds(crewReader),
|
||
op: "pull",
|
||
repoFn: func(_ string) string { return seedRef.String() },
|
||
},
|
||
{
|
||
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: []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.
|
||
creds: h.RegistryCreds(stranger),
|
||
op: "pull",
|
||
repoFn: func(_ string) string { return seedRef.String() },
|
||
},
|
||
{
|
||
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: []string{"authentication required", "Unauthorized", "unauthorized"},
|
||
},
|
||
{
|
||
name: "anonymous_pull_denied",
|
||
creds: h.AnonCreds(),
|
||
op: "pull",
|
||
repoFn: func(_ string) string { return seedRef.String() },
|
||
wantErr: true,
|
||
errContains: []string{"authentication required", "Unauthorized", "unauthorized"},
|
||
},
|
||
}
|
||
|
||
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
|
||
// 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":
|
||
img, err := random.Image(1<<17, 2) // 128KB × 2 layers
|
||
if err != nil {
|
||
return fmt.Errorf("build random image: %w", err)
|
||
}
|
||
return c.Push(ctx, t, ref, img, creds)
|
||
case "pull":
|
||
_, 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)
|
||
if err != nil {
|
||
t.Fatalf("parse ref %q: %v", s, err)
|
||
}
|
||
return r
|
||
}
|