|
| 1 | +# frozen_string_literal: true |
| 2 | + |
| 3 | +require_relative "spec_helper" |
| 4 | + |
| 5 | +# Proves bearer_for ALWAYS performs the GHCR /token exchange instead of |
| 6 | +# returning a raw GitHub token. GHCR's OCI manifest endpoint rejects a raw |
| 7 | +# GitHub Actions token with HTTP 403 — only a bearer minted via the /token |
| 8 | +# exchange is accepted. When a token is present the exchange MUST authenticate |
| 9 | +# with Basic auth (base64("x-access-token:<token>")); for public packages the |
| 10 | +# exchange also succeeds anonymously. |
| 11 | +class GHCRBearerTest < Minitest::Test |
| 12 | + # Recording fake HTTP layer: captures every (method, url, headers) call so |
| 13 | + # tests can assert what was actually sent on the wire. |
| 14 | + class RecordingHTTP |
| 15 | + attr_reader :calls |
| 16 | + |
| 17 | + def initialize(responses) |
| 18 | + @responses = responses |
| 19 | + @calls = [] |
| 20 | + end |
| 21 | + |
| 22 | + def call(method:, url:, headers: {}) |
| 23 | + @calls << { method: method, url: url, headers: headers } |
| 24 | + r = @responses[[method, url]] || @responses[url] |
| 25 | + raise "no fake response for #{[method, url].inspect}" unless r |
| 26 | + r |
| 27 | + end |
| 28 | + end |
| 29 | + |
| 30 | + DIGEST = "sha256:cafef00dcafef00dcafef00dcafef00dcafef00dcafef00dcafef00dcafef00d" |
| 31 | + REF = "ghcr.io/copilotkit/showcase-shell@#{DIGEST}" |
| 32 | + MANIFEST = "https://ghcr.io/v2/copilotkit/showcase-shell/manifests/#{DIGEST}" |
| 33 | + TOKEN_URL = "https://ghcr.io/token?service=ghcr.io&scope=repository:copilotkit/showcase-shell:pull" |
| 34 | + RAW_TOKEN = "ghs_rawGitHubActionsToken" |
| 35 | + MINTED = "minted-bearer-from-exchange" |
| 36 | + |
| 37 | + def fakes(extra = {}) |
| 38 | + RecordingHTTP.new({ |
| 39 | + TOKEN_URL => { status: 200, headers: {}, body: %({"token":"#{MINTED}"}) }, |
| 40 | + MANIFEST => { status: 200, headers: {}, body: "" }, |
| 41 | + }.merge(extra)) |
| 42 | + end |
| 43 | + |
| 44 | + def test_token_present_performs_basic_auth_exchange |
| 45 | + http = fakes |
| 46 | + g = Railway::GHCR.new(token: RAW_TOKEN, http: http) |
| 47 | + assert_equal :exists, g.manifest_exists(REF) |
| 48 | + |
| 49 | + token_call = http.calls.find { |c| c[:method] == :get && c[:url] == TOKEN_URL } |
| 50 | + refute_nil token_call, "bearer_for must hit the GHCR /token exchange even when a token is present" |
| 51 | + |
| 52 | + expected_basic = "Basic " + ["x-access-token:#{RAW_TOKEN}"].pack("m0") |
| 53 | + auth = token_call[:headers]["Authorization"] |
| 54 | + assert_equal expected_basic, auth, |
| 55 | + "token exchange must authenticate with Basic base64(x-access-token:<token>)" |
| 56 | + end |
| 57 | + |
| 58 | + def test_manifest_read_uses_minted_bearer_not_raw_token |
| 59 | + http = fakes |
| 60 | + g = Railway::GHCR.new(token: RAW_TOKEN, http: http) |
| 61 | + g.manifest_exists(REF) |
| 62 | + |
| 63 | + manifest_call = http.calls.find { |c| c[:method] == :head && c[:url] == MANIFEST } |
| 64 | + refute_nil manifest_call |
| 65 | + assert_equal "Bearer #{MINTED}", manifest_call[:headers]["Authorization"], |
| 66 | + "manifest read must use the minted bearer, never the raw GitHub token" |
| 67 | + refute_equal "Bearer #{RAW_TOKEN}", manifest_call[:headers]["Authorization"], |
| 68 | + "sending the raw GitHub token as a Bearer is exactly the bug that 403s" |
| 69 | + end |
| 70 | + |
| 71 | + def test_anonymous_exchange_still_works_for_public_packages |
| 72 | + # No token: the exchange must still run, anonymously (no Authorization |
| 73 | + # header on the /token request), and the minted token used downstream. |
| 74 | + http = fakes |
| 75 | + g = Railway::GHCR.new(token: nil, http: http) |
| 76 | + assert_equal :exists, g.manifest_exists(REF) |
| 77 | + |
| 78 | + token_call = http.calls.find { |c| c[:method] == :get && c[:url] == TOKEN_URL } |
| 79 | + refute_nil token_call, "anonymous path must still mint a bearer via /token" |
| 80 | + assert_nil token_call[:headers]["Authorization"], |
| 81 | + "anonymous exchange must not send an Authorization header" |
| 82 | + |
| 83 | + manifest_call = http.calls.find { |c| c[:method] == :head && c[:url] == MANIFEST } |
| 84 | + assert_equal "Bearer #{MINTED}", manifest_call[:headers]["Authorization"] |
| 85 | + end |
| 86 | + |
| 87 | + # When a token WAS supplied and the /token exchange returns a non-2xx, |
| 88 | + # that is a real failure (bad/insufficient token), NOT a license to fall |
| 89 | + # back to an anonymous manifest read. bearer_for must RAISE GHCR::Error so |
| 90 | + # manifest_exists's documented raise-on-failure contract holds. |
| 91 | + def test_token_present_exchange_401_raises |
| 92 | + http = fakes(TOKEN_URL => { status: 401, headers: {}, body: "unauthorized" }) |
| 93 | + g = Railway::GHCR.new(token: RAW_TOKEN, http: http) |
| 94 | + |
| 95 | + err = assert_raises(Railway::GHCR::Error) { g.manifest_exists(REF) } |
| 96 | + assert_match(/token exchange failed/i, err.message) |
| 97 | + end |
| 98 | + |
| 99 | + # A 200 with a body that is not parseable JSON is a broken exchange, not a |
| 100 | + # usable bearer. bearer_for must RAISE rather than swallow JSON::ParserError. |
| 101 | + def test_token_present_malformed_body_raises |
| 102 | + http = fakes(TOKEN_URL => { status: 200, headers: {}, body: "<html>not json</html>" }) |
| 103 | + g = Railway::GHCR.new(token: RAW_TOKEN, http: http) |
| 104 | + |
| 105 | + err = assert_raises(Railway::GHCR::Error) { g.manifest_exists(REF) } |
| 106 | + assert_match(/unparseable/i, err.message) |
| 107 | + end |
| 108 | + |
| 109 | + # Regression guard: when a token was supplied and the exchange failed, the |
| 110 | + # manifest HEAD must NEVER be sent — and certainly never anonymously. The |
| 111 | + # old swallow-to-nil behavior issued an anonymous HEAD, conflating "no |
| 112 | + # token" with "supplied token failed to exchange". |
| 113 | + def test_token_present_exchange_failure_never_does_anonymous_manifest_read |
| 114 | + http = fakes(TOKEN_URL => { status: 403, headers: {}, body: "forbidden" }) |
| 115 | + g = Railway::GHCR.new(token: RAW_TOKEN, http: http) |
| 116 | + |
| 117 | + assert_raises(Railway::GHCR::Error) { g.manifest_exists(REF) } |
| 118 | + |
| 119 | + manifest_call = http.calls.find { |c| c[:method] == :head && c[:url] == MANIFEST } |
| 120 | + assert_nil manifest_call, |
| 121 | + "no manifest HEAD must be issued when a supplied token fails the /token exchange" |
| 122 | + end |
| 123 | +end |
0 commit comments