API-Docker

 view release on metacpan or  search on metacpan

t/registry_auth.t  view on Meta::CPAN

use strict;
use warnings;
use Test::More;
use FindBin;
use lib "$FindBin::Bin/lib";
use JSON::MaybeXS qw( decode_json encode_json );
use MIME::Base64 qw( decode_base64 );
use API::Docker;
use Test::API::Docker::FakeTransport;

# API::Docker::Role::RegistryAuth: one AuthConfig encoding, shared by every
# class that talks to a registry. It used to exist twice -- a bare sub
# _build_registry_auth_header in Images.pm, called as a function, and a
# _registry_auth_header method in Plugins.pm with the same body.
#
# What the merge could break is not the encoding, which is pinned in
# t/images_push_auth.t, but the two *policies* around it. They differ on
# purpose:
#
#   /images/{name}/push       always sends X-Registry-Auth, because the
#                             engine rejects an image push without it, and
#                             an anonymous push sends the encoding of {}
#   /plugins/*                sends it only when credentials were given: the
#   /distribution/{name}/json plugin and distribution routers decode the
#                             header and discard the error ("Ignore invalid
#                             AuthConfig to increase compatibility with the
#                             existing API"), so no header is the anonymous
#                             case rather than a failure
#
# Nothing here opens a socket or reaches a daemon, in either mode: the
# daemon is faked below the socket so that _request assembles a real request
# and the assertions read what would have gone on the wire. Podman serves
# neither /plugins nor /distribution, so a live run of this file could only
# be red or skipped.

sub fake_client {
  my ($body, $status) = @_;
  return Test::API::Docker::FakeTransport->new(
    host        => 'unix:///nonexistent.sock',
    api_version => '1.41',
    canned      => [$status // 200, 'OK', {}, $body // ''],
  );
}

sub auth_header {
  my ($raw) = @_;
  my ($hdr) = $raw =~ /^X-Registry-Auth: (\S+)\r$/m;
  return $hdr;
}

sub b64url_decode {
  my ($s) = @_;
  $s =~ tr{-_}{+/};
  return decode_base64($s);
}

my $CREDS = { username => 'me', password => 'secret', serveraddress => 'ghcr.io' };

# ---------------------------------------------------------------------------
subtest 'the encoder lives in one place and every registry class sees it' => sub {
  my $docker = API::Docker->new(
    host        => 'unix:///dev/null',
    api_version => '1.47',
  );

  my @consumers = (
    [ images       => $docker->images ],
    [ plugins      => $docker->plugins ],
    [ distribution => $docker->distribution ],
    [ system       => $docker->system ],
  );

  my %header;
  for my $c (@consumers) {
    my ($name, $obj) = @$c;
    ok $obj->does('API::Docker::Role::RegistryAuth'), "$name does the role";
    $header{$name} = $obj->_registry_auth_header($CREDS);
  }

  is scalar(keys %{ { map { $_ => 1 } values %header } }), 1,
    'all four classes produce byte-identical headers for the same credentials';

  # The bare sub is gone rather than kept as a wrapper: a second entry point
  # is how the two copies drifted apart in the first place.
  ok !API::Docker::API::Images->can('_build_registry_auth_header'),
    'Images no longer carries its own encoder';
};

# ---------------------------------------------------------------------------
subtest '_registry_auth_config reads back every shape the header accepts' => sub {
  my $docker = API::Docker->new(
    host        => 'unix:///dev/null',
    api_version => '1.47',
  );
  my $system = $docker->system;

  is_deeply $system->_registry_auth_config($CREDS), $CREDS,
    'a HashRef comes back as itself';
  isnt $system->_registry_auth_config($CREDS), $CREDS,
    'and as a copy, so a caller cannot be mutated through it';

  is_deeply $system->_registry_auth_config(encode_json($CREDS)), $CREDS,
    'a JSON object is decoded';

  # The round trip that makes POST /auth able to take what push was given.
  my $header = $system->_registry_auth_header($CREDS);
  is_deeply $system->_registry_auth_config($header), $CREDS,
    'a header value decodes back to the AuthConfig it was built from';

  # base64url, not plain base64: a payload with a '/' or '+' in its base64
  # would not survive the wrong alphabet.
  my $slashy = { password => join('', map { chr } 0xfb, 0xff, 0xbf, 0xfe) };
  my $slashy_header = $system->_registry_auth_header($slashy);
  like $slashy_header, qr/[-_]/,
    'this payload really does exercise the substituted characters';
  is_deeply $system->_registry_auth_config($slashy_header), $slashy,
    'the -_ alphabet round trips';

  is $system->_registry_auth_config(undef), undef,
    'undef stays undef -- whether that is an error is the endpoint\'s call';

  ok !eval { $system->_registry_auth_config('not base64 at all !!'); 1 },
    'a value that is not an AuthConfig croaks';
  like $@, qr/AuthConfig/, 'and says so';
};

# ---------------------------------------------------------------------------
subtest 'images->push sends X-Registry-Auth even with no credentials' => sub {
  my $c = fake_client('{"status":"done"}');
  $c->images->push('myrepo/app', tag => 'v1');

  my $hdr = auth_header($c->written);
  is $hdr, 'e30=',
    'the anonymous push carries the padded encoding of {} -- the engine '
    . 'rejects an image push with no header at all';

  my $with = fake_client('{"status":"done"}');
  $with->images->push('myrepo/app', tag => 'v1', auth => $CREDS);
  is_deeply decode_json(b64url_decode(auth_header($with->written))), $CREDS,
    'and the credentials when they were given';
};

# ---------------------------------------------------------------------------
subtest 'plugins send X-Registry-Auth only when credentials were given' => sub {
  my $none = fake_client('[]');
  $none->plugins->privileges('vieux/sshfs:latest');
  unlike $none->written, qr/X-Registry-Auth/i,
    'no header at all on an anonymous plugin lookup';

  my $with = fake_client('[]');
  $with->plugins->privileges('vieux/sshfs:latest', auth => $CREDS);
  is_deeply decode_json(b64url_decode(auth_header($with->written))), $CREDS,
    'the header appears once credentials are passed';
};

# ---------------------------------------------------------------------------
subtest 'distribution sends X-Registry-Auth only when credentials were given' => sub {
  my $none = fake_client('{"Descriptor":{}}');
  $none->distribution->inspect('nginx:latest');
  unlike $none->written, qr/X-Registry-Auth/i,
    'a public image is looked up anonymously, with no header';

  my $with = fake_client('{"Descriptor":{}}');
  $with->distribution->inspect('private/app:1.0', auth => $CREDS);
  is_deeply decode_json(b64url_decode(auth_header($with->written))), $CREDS,
    'the header appears once credentials are passed';
};

done_testing;



( run in 0.891 second using v1.01-cache-2.11-cpan-85d3896f969 )