helm/pkg/registry/client_test.go
Benoit Tigeot 5595c0d005
Prevent failing helm push on ghcr.io using standard GET auth token flow
Fix GHCR auth by not forcing OAuth2 POST but also reset
ForceAttemptOAuth2 after login.

- Remove ForceAttemptOAuth2 in NewClient and only enable during Login
ping and always restore to false.
- Aligns with OCI Distribution auth (token via GET), avoiding GHCR 405
on POST /token.
- Some tests

Failures logs:

```sh
~/p/lifen/test/helm-f/quicktest ❯ ../../../helm/bin/helm push quicktest-0.1.0.tgz oci://ghcr.io/benoittgt/helm-charts --debug
level=DEBUG msg=HEAD id=0 url=https://ghcr.io/v2/benoittgt/helm-charts/quicktest/manifests/sha256:af359fd8fb968ec1097afbd6e8e1dac9ee130861082e54dc2340d0c019407873 header="   \"Accept\": \"application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.oci.artifact.manifest.v1+json\"\n   \"User-Agent\": \"Helm/4.0+unreleased\""
level=DEBUG msg=Resp id=0 status="401 Unauthorized" header="   \"Www-Authenticate\": \"Bearer realm=\\\"https://ghcr.io/token\\\",service=\\\"ghcr.io\\\",scope=\\\"repository:benoittgt/helm-charts/quicktest:pull\\\"\"\n   \"Date\": \"Mon, 01 Sep 2025 13:56:35 GMT\"\n   \"Content-Length\": \"73\"\n   \"X-Github-Request-Id\": \"DC73:115F:2B40F2C:2BAB567:68B5A613\"\n   \"Content-Type\": \"application/json\"" body="   Response body is empty"
level=DEBUG msg=POST id=1 url=https://ghcr.io/token header="   \"Content-Type\": \"application/x-www-form-urlencoded\"\n   \"User-Agent\": \"Helm/4.0+unreleased\""
level=DEBUG msg=Resp id=1 status="405 Method Not Allowed" header="   \"Docker-Distribution-Api-Version\": \"registry/2.0\"\n   \"Strict-Transport-Security\": \"max-age=63072000; includeSubDomains; preload\"\n   \"Date\": \"Mon, 01 Sep 2025 13:56:35 GMT\"\n   \"Content-Length\": \"78\"\n   \"X-Github-Request-Id\": \"DC73:115F:2B40F75:2BAB5C2:68B5A613\"\n   \"Content-Type\": \"application/json\"" body="{\"errors\":[{\"code\":\"UNSUPPORTED\",\"message\":\"The operation is unsupported.\"}]}\n"
Error: failed to perform "Exists" on destination: HEAD "https://ghcr.io/v2/benoittgt/helm-charts/quicktest/manifests/sha256:af359fd8fb968ec1097afbd6e8e1dac9ee130861082e54dc2340d0c019407873": POST "https://ghcr.io/token": response status code 405: unsupported: The operation is unsupported.
```

Signed-off-by: Benoit Tigeot <benoit.tigeot@lifen.fr>
2025-09-01 18:07:39 +02:00

122 lines
3.8 KiB
Go

/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package registry
import (
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/stretchr/testify/require"
"oras.land/oras-go/v2/content/memory"
)
// Inspired by oras test
// https://github.com/oras-project/oras-go/blob/05a2b09cbf2eab1df691411884dc4df741ec56ab/content_test.go#L1802
func TestTagManifestTransformsReferences(t *testing.T) {
memStore := memory.New()
client := &Client{out: io.Discard}
ctx := t.Context()
refWithPlus := "test-registry.io/charts/test:1.0.0+metadata"
expectedRef := "test-registry.io/charts/test:1.0.0_metadata" // + becomes _
configDesc := ocispec.Descriptor{MediaType: ConfigMediaType, Digest: "sha256:config", Size: 100}
layers := []ocispec.Descriptor{{MediaType: ChartLayerMediaType, Digest: "sha256:layer", Size: 200}}
parsedRef, err := newReference(refWithPlus)
require.NoError(t, err)
desc, err := client.tagManifest(ctx, memStore, configDesc, layers, nil, parsedRef)
require.NoError(t, err)
transformedDesc, err := memStore.Resolve(ctx, expectedRef)
require.NoError(t, err, "Should find the reference with _ instead of +")
require.Equal(t, desc.Digest, transformedDesc.Digest)
_, err = memStore.Resolve(ctx, refWithPlus)
require.Error(t, err, "Should NOT find the reference with the original +")
}
// Verifies that Login always restores ForceAttemptOAuth2 to false on success.
func TestLogin_ResetsForceAttemptOAuth2_OnSuccess(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v2/" {
// Accept either HEAD or GET
w.WriteHeader(http.StatusOK)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
host := strings.TrimPrefix(srv.URL, "http://")
credFile := filepath.Join(t.TempDir(), "config.json")
c, err := NewClient(
ClientOptWriter(io.Discard),
ClientOptCredentialsFile(credFile),
)
if err != nil {
t.Fatalf("NewClient error: %v", err)
}
if c.authorizer == nil || c.authorizer.ForceAttemptOAuth2 {
t.Fatalf("expected ForceAttemptOAuth2 default to be false")
}
// Call Login with plain HTTP against our test server
if err := c.Login(host, LoginOptPlainText(true), LoginOptBasicAuth("u", "p")); err != nil {
t.Fatalf("Login error: %v", err)
}
if c.authorizer.ForceAttemptOAuth2 {
t.Errorf("ForceAttemptOAuth2 should be false after successful Login")
}
}
// Verifies that Login restores ForceAttemptOAuth2 to false even when ping fails.
func TestLogin_ResetsForceAttemptOAuth2_OnFailure(t *testing.T) {
t.Parallel()
// Start and immediately close, so connections will fail
srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}))
host := strings.TrimPrefix(srv.URL, "http://")
srv.Close()
credFile := filepath.Join(t.TempDir(), "config.json")
c, err := NewClient(
ClientOptWriter(io.Discard),
ClientOptCredentialsFile(credFile),
)
if err != nil {
t.Fatalf("NewClient error: %v", err)
}
// Invoke Login, expect an error but ForceAttemptOAuth2 must end false
_ = c.Login(host, LoginOptPlainText(true), LoginOptBasicAuth("u", "p"))
if c.authorizer.ForceAttemptOAuth2 {
t.Errorf("ForceAttemptOAuth2 should be false after failed Login")
}
}