package token import ( "slices" "testing" "atcr.io/pkg/auth" ) func narrowRepo(name string, actions ...string) auth.AccessEntry { return auth.AccessEntry{Type: "repository", Name: name, Actions: actions} } // TestNarrowToPullOnly is the guard on the function standing between an // anonymous caller and a push token. IsPullOnlyScope only answers yes/no; // this one rewrites the access list, so what it emits is what gets signed. // // The load-bearing property is the allowlist: "pull" is the only action that // can survive. Every case below that expects a dropped entry or a trimmed // action list is really asserting that no other action can reach a token. func TestNarrowToPullOnly(t *testing.T) { tests := []struct { name string access []auth.AccessEntry want []auth.AccessEntry wantGrantable bool }{ {"nil access (the /v2/ ping)", nil, nil, true}, {"empty access", []auth.AccessEntry{}, nil, true}, {"pull only passes through", []auth.AccessEntry{narrowRepo("alice/app", "pull")}, []auth.AccessEntry{narrowRepo("alice/app", "pull")}, true}, {"pull,push narrows to pull", []auth.AccessEntry{narrowRepo("alice/app", "pull", "push")}, []auth.AccessEntry{narrowRepo("alice/app", "pull")}, true}, {"pull,push,delete narrows to pull", []auth.AccessEntry{narrowRepo("alice/app", "pull", "push", "delete")}, []auth.AccessEntry{narrowRepo("alice/app", "pull")}, true}, // No pull requested means the caller is not asking for a read, so there // is nothing to grant a subset of. Dropped entirely rather than emitted // with an empty action list. {"push alone is dropped", []auth.AccessEntry{narrowRepo("alice/app", "push")}, nil, false}, {"delete alone is dropped", []auth.AccessEntry{narrowRepo("alice/app", "delete")}, nil, false}, {"unknown action is dropped", []auth.AccessEntry{narrowRepo("alice/app", "frobnicate")}, nil, false}, // The critical pair. A wildcard ACTION means "any action" to // distribution's actionSet.contains, so expanding it into "pull" would // be the one rewrite that turns a wildcard request into a grant. // Anonymous tokens skip the authgate, so a token emitted here is // authorized by nothing downstream. {"wildcard action is dropped, NOT expanded to pull", []auth.AccessEntry{narrowRepo("alice/app", "*")}, nil, false}, {"wildcard action on wildcard name is dropped", []auth.AccessEntry{narrowRepo("*", "*")}, nil, false}, {"catalog wildcard is dropped", []auth.AccessEntry{{Type: "registry", Name: "catalog", Actions: []string{"*"}}}, nil, false}, // A wildcard riding alongside an explicit pull must not survive the trim. {"pull plus wildcard keeps only pull", []auth.AccessEntry{narrowRepo("alice/app", "pull", "*")}, []auth.AccessEntry{narrowRepo("alice/app", "pull")}, true}, // Preserved as-is because callers rely on the entry existing; it grants // nothing on its own, so it does not make the request grantable. Note // this diverges from IsPullOnlyScope, which answers true here. {"entry with no actions survives but is not grantable", []auth.AccessEntry{narrowRepo("alice/app")}, []auth.AccessEntry{narrowRepo("alice/app")}, false}, {"mixed entries keep only the pull-bearing one", []auth.AccessEntry{narrowRepo("alice/app", "pull"), narrowRepo("alice/other", "push")}, []auth.AccessEntry{narrowRepo("alice/app", "pull")}, true}, {"several pull-bearing entries all narrow", []auth.AccessEntry{narrowRepo("a/one", "pull", "push"), narrowRepo("a/two", "delete", "pull")}, []auth.AccessEntry{narrowRepo("a/one", "pull"), narrowRepo("a/two", "pull")}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, grantable := NarrowToPullOnly(tt.access) if grantable != tt.wantGrantable { t.Errorf("grantable = %v, want %v", grantable, tt.wantGrantable) } if len(got) != len(tt.want) { t.Fatalf("got %d entries %+v, want %d %+v", len(got), got, len(tt.want), tt.want) } for i := range got { if got[i].Type != tt.want[i].Type || got[i].Name != tt.want[i].Name { t.Errorf("entry %d = %+v, want %+v", i, got[i], tt.want[i]) } if !slices.Equal(got[i].Actions, tt.want[i].Actions) { t.Errorf("entry %d actions = %v, want %v", i, got[i].Actions, tt.want[i].Actions) } } // Nothing but "pull" may ever reach a signed token, whatever the // case above happens to assert. for _, e := range got { for _, a := range e.Actions { if a != "pull" { t.Errorf("action %q survived narrowing on %+v", a, e) } } } }) } } // The caller hands its own slice in and keeps using it — the handler logs the // requested scope after narrowing. Trimming in place (entry.Actions = // entry.Actions[:1]) would pass every case above while quietly rewriting the // caller's data. func TestNarrowToPullOnly_DoesNotMutateInput(t *testing.T) { // "pull" is deliberately NOT first. An in-place trim writes "pull" into // index 0, which is invisible when index 0 already holds "pull" — that // ordering makes this test pass against the very mutation it exists to catch. input := []auth.AccessEntry{ narrowRepo("alice/app", "push", "pull", "delete"), narrowRepo("alice/other", "push"), } if _, _ = NarrowToPullOnly(input); true { if want := []string{"push", "pull", "delete"}; !slices.Equal(input[0].Actions, want) { t.Errorf("input entry 0 was mutated: %v, want %v", input[0].Actions, want) } if want := []string{"push"}; !slices.Equal(input[1].Actions, want) { t.Errorf("input entry 1 was mutated: %v, want %v", input[1].Actions, want) } if len(input) != 2 { t.Errorf("input slice length changed to %d", len(input)) } } }