Add private registry accounts to OCI manager Apps

This commit is contained in:
Jonatan
2026-10-16 19:58:40 +02:00
parent 3000c1cd66
commit 7d2d430320
10 changed files with 1298 additions and 5 deletions
+168
View File
@@ -0,0 +1,168 @@
import contextlib
import io
from pathlib import Path
import sys
from types import SimpleNamespace
import unittest
from unittest.mock import MagicMock, patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(ROOT / "remote"))
from proxmenux_oci import registry_accounts as accounts
class Cancelled(RuntimeError):
pass
class UI:
def __init__(self, choices, answers=(), password='MENU_TEST_TOKEN'):
self.choices = iter(choices)
self.answers = iter(answers)
self.password_value = password
self.seen = []
def choose(self, text, options, default=None, **kwargs):
self.seen.append((text, options, kwargs))
return next(self.choices)
def ask(self, text, default=None):
self.seen.append(text)
return next(self.answers)
def password(self, text):
self.seen.append(text)
if isinstance(self.password_value, Exception):
raise self.password_value
return self.password_value
def message(self, text, title=None):
self.seen.append((title, text))
def info(self, text, title=None):
self.seen.append((title, text))
def confirm(self, text, default=False, **kwargs):
self.seen.append(text)
return False
class RegistryMenuTests(unittest.TestCase):
def run_menu(self, ui, auth, extra_modules=None):
modules = {'proxmenux_oci.ui': SimpleNamespace(UserCancelled=Cancelled),
'proxmenux_oci.host': SimpleNamespace(storages=lambda content: [{'storage': 'local', 'avail': 10**9}])}
modules.update(extra_modules or {})
with patch.object(accounts, 'require_root'), patch.object(accounts.importlib, 'import_module', side_effect=lambda name: modules[name]):
accounts.registry_accounts_menu(ui, engine='/fake/engine', auth=auth)
def account(self):
return {'registry': 'ghcr.io', 'account': 'example', 'method': 'Username / token', 'status': 'Saved'}
def auth(self, rows=None):
return SimpleNamespace(accounts=MagicMock(return_value=rows or []), login=MagicMock(), logout=MagicMock())
def test_native_menu_can_add_an_account_without_exposing_the_token(self):
auth = self.auth()
ui = UI(['add', None], ['ghcr.io', 'example'])
self.run_menu(ui, auth)
auth.login.assert_called_once_with('ghcr.io', 'example', 'MENU_TEST_TOKEN')
self.assertNotIn('MENU_TEST_TOKEN', repr(ui.seen))
def test_cancelled_password_does_not_change_credentials(self):
auth = self.auth()
ui = UI(['add', None], ['ghcr.io', 'example'], password=Cancelled())
self.run_menu(ui, auth)
auth.login.assert_not_called()
def test_invalid_authority_cannot_reach_login(self):
auth = self.auth()
ui = UI(['add', None], ['https://ghcr.io'])
self.run_menu(ui, auth)
auth.login.assert_not_called()
self.assertIn('registry hostname', repr(ui.seen))
def test_account_can_download_through_the_shared_native_cache_helper(self):
auth = self.auth([self.account()])
ui = UI(['account-0', 'download', 'local', None], ['ghcr.io/example/private:latest'])
result = {'archive': '/cache/proxmenux-update-amd64-fixture.tar', 'size': 1024}
with patch.object(accounts, 'download_registry_image', return_value=result) as download:
self.run_menu(ui, auth)
download.assert_called_once_with('ghcr.io/example/private:latest', 'local')
self.assertIn('Image downloaded and verified', repr(ui.seen))
def test_selected_account_does_not_pull_from_another_registry(self):
auth = self.auth([self.account()])
ui = UI(['account-0', 'download', None], ['quay.io/example/private:latest'])
with patch.object(accounts, 'download_registry_image') as download:
self.run_menu(ui, auth)
download.assert_not_called()
def test_logout_requires_confirmation_in_the_menu(self):
auth = self.auth([self.account()])
ui = UI(['account-0', 'logout', None])
self.run_menu(ui, auth)
auth.logout.assert_not_called()
def test_menu_offers_install_download_and_credential_management(self):
auth = self.auth([self.account()])
ui = UI(['account-0', None, None])
self.run_menu(ui, auth)
actions = ui.seen[1][1]
self.assertEqual([tag for tag, label in actions], ['inspect', 'download', 'install', 'login', 'logout'])
def test_menu_preserves_the_configured_language_when_an_operation_fails(self):
from proxmenux_oci import i18n
cache = {'Saved': 'Guardado'}
with patch.object(i18n, '_language', 'es'), patch.object(i18n, '_cache', cache), \
patch.object(accounts, 'registry_accounts_menu', side_effect=RuntimeError('test')):
with self.assertRaises(RuntimeError):
accounts.open_registry_accounts(UI([]))
self.assertEqual(i18n._language, 'es')
self.assertIs(i18n._cache, cache)
def test_native_main_menu_dispatches_to_registry_accounts(self):
from proxmenux_oci import cli
catalog = SimpleNamespace(index_path=SimpleNamespace(exists=lambda: True),
load_index=lambda: {'applications': []})
ui = UI(['registries', None])
with patch.object(cli, 'interactive_ui', return_value=ui), \
patch.object(accounts, 'open_registry_accounts') as open_accounts:
self.assertEqual(cli.interactive(catalog), 0)
open_accounts.assert_called_once_with(ui)
def test_terminal_fallback_can_sign_in_without_exposing_the_password(self):
from proxmenux_oci.ui import TerminalUI
auth = self.auth()
output = io.StringIO()
with patch.object(accounts, 'require_root'), \
patch('builtins.input', side_effect=['1', 'registry.test', 'example', '0']), \
patch('getpass.getpass', return_value='MENU_TEST_TOKEN'), \
contextlib.redirect_stdout(output):
accounts.registry_accounts_menu(TerminalUI(), auth=auth)
auth.login.assert_called_once_with('registry.test', 'example', 'MENU_TEST_TOKEN')
self.assertIn('Signed in to registry.test', output.getvalue())
self.assertNotIn('MENU_TEST_TOKEN', output.getvalue())
def test_startup_configuration_errors_return_a_message_to_the_main_menu(self):
from proxmenux_oci.registry_auth import RegistryError
ui = UI([])
with patch.object(accounts, 'registry_accounts_menu', side_effect=RegistryError('Invalid auth path')):
accounts.open_registry_accounts(ui)
self.assertIn('Invalid auth path', repr(ui.seen))
def test_private_image_install_uses_the_existing_native_lxc_wizard(self):
auth = self.auth([self.account()])
ui = UI(['account-0', 'install', 'default', None], ['ghcr.io/example/private:latest'])
template = {'id': 'private-app', 'compatibility': {'automatic_install_candidate': True}}
install = MagicMock()
modules = {'proxmenux_oci.installer': SimpleNamespace(DEFAULT_MODE='default', ADVANCED_MODE='advanced'),
'proxmenux_oci.cli': SimpleNamespace(install_template=install)}
with patch.object(accounts, 'image_template', return_value=template), \
patch.object(accounts, 'registry_environment', contextlib.nullcontext):
self.run_menu(ui, auth, modules)
install.assert_called_once_with(ui, template, 'private-app', 'default')
if __name__ == '__main__':
unittest.main()
+224
View File
@@ -0,0 +1,224 @@
import base64
import contextlib
import errno
import io
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(ROOT / "remote"))
from proxmenux_oci import registry_auth as auth_module
class AuthenticationTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.directory = Path(self.temp.name).resolve() / 'containers'
self.directory.mkdir(mode=0o700)
self.path = self.directory / 'auth.json'
self.auth = auth_module.RegistryAuth(self.path)
self.token = 'TEST_TOKEN_MUST_NEVER_APPEAR_IN_OUTPUT'
self.output = io.StringIO()
def save(self, registry='ghcr.io', username='example', token='old-token'):
data = {'auths': {registry: {'auth': base64.b64encode(f'{username}:{token}'.encode()).decode()}}}
self.path.write_text(json.dumps(data))
self.path.chmod(0o600)
return data
def skopeo_login(self, argv, **kwargs):
self.assertEqual(argv[:2], ['skopeo', 'login'])
self.assertNotIn(self.token, ' '.join(argv))
self.assertNotIn('env', kwargs)
self.assertEqual(kwargs['input'], self.token + '\n')
stage = Path(argv[argv.index('--authfile') + 1])
self.assertEqual(stage.stat().st_mode & 0o777, 0o600)
data = json.loads(stage.read_text())
data.setdefault('auths', {})[argv[-1]] = {'auth': base64.b64encode(f'new-user:{self.token}'.encode()).decode()}
stage.write_text(json.dumps(data))
return SimpleNamespace(returncode=0, stderr='', stdout='Login Succeeded!')
def test_login_is_atomic_private_and_preserves_other_accounts(self):
original = self.save('docker.io')
with patch.object(auth_module.subprocess, 'run', side_effect=self.skopeo_login):
self.auth.login('ghcr.io', 'new-user', self.token)
data = json.loads(self.path.read_text())
self.assertEqual(data['auths']['docker.io'], original['auths']['docker.io'])
self.assertIn('ghcr.io', data['auths'])
self.assertEqual(self.path.stat().st_mode & 0o777, 0o600)
self.assertEqual(list(self.directory.glob('.proxmenux-auth-*')), [])
def test_failed_login_retains_previous_file_and_never_echoes_registry_error(self):
self.save()
before = self.path.read_bytes()
result = SimpleNamespace(returncode=1, stderr='unauthorized: ' + self.token)
with patch.object(auth_module.subprocess, 'run', return_value=result), self.assertRaises(auth_module.RegistryError) as error:
self.auth.login('ghcr.io', 'new-user', self.token)
self.assertIn('Authentication failed', str(error.exception))
self.assertNotIn(self.token, str(error.exception))
self.assertEqual(self.path.read_bytes(), before)
self.assertEqual(list(self.directory.glob('.proxmenux-auth-*')), [])
def test_timed_out_login_does_not_create_a_credential_file(self):
with patch.object(auth_module.subprocess, 'run', side_effect=subprocess.TimeoutExpired('skopeo', 5)), \
self.assertRaises(auth_module.RegistryError):
self.auth.login('ghcr.io', 'new-user', self.token, 5)
self.assertFalse(self.path.exists())
def test_external_credential_edit_is_not_overwritten(self):
self.save()
def concurrent_edit(argv, **kwargs):
result = self.skopeo_login(argv, **kwargs)
self.save('quay.io', 'other-task')
return result
with patch.object(auth_module.subprocess, 'run', side_effect=concurrent_edit), self.assertRaises(auth_module.RegistryError):
self.auth.login('ghcr.io', 'new-user', self.token)
self.assertEqual(set(json.loads(self.path.read_text())['auths']), {'quay.io'})
def test_empty_listing_does_not_create_paths(self):
path = self.directory / 'new' / 'auth.json'
auth = auth_module.RegistryAuth(path)
self.assertEqual(auth.accounts(), [])
self.assertFalse(path.parent.exists())
def test_logout_preserves_unrelated_accounts(self):
self.save()
data = json.loads(self.path.read_text())
data['auths']['docker.io'] = {'auth': base64.b64encode(b'user:other-token').decode()}
self.path.write_text(json.dumps(data))
def logout(argv, **kwargs):
stage = Path(argv[argv.index('--authfile') + 1])
saved = json.loads(stage.read_text())
saved['auths'].pop('ghcr.io')
stage.write_text(json.dumps(saved))
return SimpleNamespace(returncode=0)
with patch.object(auth_module.subprocess, 'run', side_effect=logout):
self.assertTrue(self.auth.logout('ghcr.io'))
self.assertEqual(set(json.loads(self.path.read_text())['auths']), {'docker.io'})
def test_logout_unknown_registry_does_not_modify_file(self):
self.save()
before = self.path.read_bytes()
with patch.object(auth_module.subprocess, 'run') as run:
self.assertFalse(self.auth.logout('quay.io'))
run.assert_not_called()
self.assertEqual(self.path.read_bytes(), before)
def test_logout_removes_repository_scopes_without_affecting_other_registries(self):
self.save('ghcr.io/private/team')
with patch.object(auth_module.subprocess, 'run') as run:
self.assertTrue(self.auth.logout('ghcr.io'))
run.assert_not_called()
self.assertFalse(json.loads(self.path.read_text())['auths'])
def test_symlink_authfile_is_rejected_without_reading_target(self):
target = self.directory / 'other'
target.write_text('sensitive target')
self.path.symlink_to(target)
with self.assertRaises(auth_module.RegistryError):
self.auth.accounts()
self.assertEqual(target.read_text(), 'sensitive target')
def test_insecure_directory_or_file_permissions_are_rejected(self):
self.save()
self.path.chmod(0o644)
with self.assertRaises(auth_module.RegistryError):
self.auth.accounts()
self.path.chmod(0o600)
self.directory.chmod(0o755)
with self.assertRaises(auth_module.RegistryError):
self.auth.accounts()
def test_non_regular_credential_file_cannot_block_the_command(self):
os.mkfifo(self.path, mode=0o600)
with self.assertRaises(auth_module.RegistryError):
self.auth.accounts()
def test_writable_parent_directory_is_rejected(self):
self.directory.parent.chmod(0o777)
self.addCleanup(self.directory.parent.chmod, 0o700)
with self.assertRaises(auth_module.RegistryError):
self.auth.accounts()
def test_corrupt_file_is_not_overwritten(self):
self.path.write_text('{invalid')
self.path.chmod(0o600)
with patch.object(auth_module.subprocess, 'run') as run, self.assertRaises(auth_module.RegistryError):
self.auth.login('ghcr.io', 'new-user', self.token)
run.assert_not_called()
self.assertEqual(self.path.read_text(), '{invalid')
def test_durable_authfile_is_used_by_descendants_and_environment_is_restored(self):
self.save()
with patch.dict(os.environ, {'REGISTRY_AUTH_FILE': str(self.path)}):
with auth_module.registry_environment():
self.assertEqual(os.environ['REGISTRY_AUTH_FILE'], str(self.path))
self.assertEqual(os.environ['REGISTRY_AUTH_FILE'], str(self.path))
with patch.dict(os.environ, clear=True), patch.object(auth_module.Path, 'home', return_value=self.directory.parent):
# Use the normal XDG_CONFIG_HOME fallback without touching /root.
os.environ['XDG_CONFIG_HOME'] = str(self.directory.parent)
with auth_module.registry_environment():
self.assertEqual(os.environ['REGISTRY_AUTH_FILE'], str(self.path))
self.assertNotIn('REGISTRY_AUTH_FILE', os.environ)
def test_account_listing_never_returns_a_password_or_token(self):
self.save(token=self.token)
with patch.object(auth_module.subprocess, 'run') as run:
rows = self.auth.accounts()
run.assert_not_called()
text = json.dumps(rows)
self.assertEqual(rows[0]['account'], 'example')
self.assertNotIn(self.token, text)
self.assertNotIn(base64.b64encode(f'example:{self.token}'.encode()).decode(), text)
def test_invalid_staged_credentials_cannot_replace_a_working_account(self):
self.save()
before = self.path.read_bytes()
def corrupt_login(argv, **kwargs):
Path(argv[argv.index('--authfile') + 1]).write_text('{invalid')
return SimpleNamespace(returncode=0, stderr='')
with patch.object(auth_module.subprocess, 'run', side_effect=corrupt_login), \
self.assertRaises(auth_module.RegistryError):
self.auth.login('ghcr.io', 'new-user', self.token)
self.assertEqual(self.path.read_bytes(), before)
self.assertEqual(list(self.directory.glob('.proxmenux-auth-*')), [])
def test_credential_commit_failure_preserves_saved_file_and_cleans_staging(self):
for failure in (OSError(errno.ENOSPC, 'No space left on device'),
PermissionError(errno.EACCES, 'Permission denied')):
self.save()
before = self.path.read_bytes()
with self.subTest(error=type(failure).__name__), \
patch.object(auth_module.subprocess, 'run', side_effect=self.skopeo_login), \
patch.object(auth_module.os, 'replace', side_effect=failure), \
self.assertRaises(OSError):
self.auth.login('ghcr.io', 'new-user', self.token)
self.assertEqual(self.path.read_bytes(), before)
self.assertEqual(list(self.directory.glob('.proxmenux-auth-*')), [])
def test_failed_or_timed_out_logout_keeps_saved_accounts(self):
self.save()
before = self.path.read_bytes()
for failure in (None, subprocess.TimeoutExpired('skopeo', 30), FileNotFoundError()):
response = SimpleNamespace(returncode=1, stderr=self.token)
with self.subTest(error=type(failure).__name__), \
patch.object(auth_module.subprocess, 'run', side_effect=failure, return_value=response), \
self.assertRaises(auth_module.RegistryError) as error:
self.auth.logout('ghcr.io')
self.assertNotIn(self.token, str(error.exception))
self.assertEqual(self.path.read_bytes(), before)
self.assertEqual(list(self.directory.glob('.proxmenux-auth-*')), [])
if __name__ == '__main__':
unittest.main()
+160
View File
@@ -0,0 +1,160 @@
from contextlib import nullcontext
import errno
import io
from pathlib import Path
import subprocess
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import MagicMock, patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(ROOT / "remote"))
from proxmenux_oci import registry_auth as auth
from proxmenux_oci import registry_accounts as accounts
class FailureHandlingTests(unittest.TestCase):
def setUp(self):
self.platform = patch.object(accounts, 'host_architecture', return_value='amd64')
self.platform.start()
self.addCleanup(self.platform.stop)
def test_transport_errors_are_specific_and_never_echo_server_text(self):
cases = [
('unauthorized', 'Access denied'), ('denied', 'Access denied'),
('manifest unknown', 'image or tag was not found'),
('received unexpected HTTP status: 404 Not Found', 'image or tag was not found'),
('toomanyrequests', 'rate limiting'), ('StatusCode: 429', 'rate limiting'),
('500 Internal Server Error', 'server error'), ('503 Service Unavailable', 'server error'),
('x509: certificate has expired', 'TLS verification failed'),
('x509: certificate signed by unknown authority', 'TLS verification failed'),
('http: server gave HTTP response to HTTPS client', 'plain HTTP'),
('tls: first record does not look like a TLS handshake', 'plain HTTP'),
('dial: no such host', 'Could not reach'), ('connection refused', 'Could not reach'),
('i/o timeout', 'Could not reach'), ('connection reset', 'Could not reach'),
('invalid character in manifest', 'invalid or unsupported'),
('docker-credential-missing: executable file not found', 'credential helper'),
]
for error, expected in cases:
with self.subTest(error=error):
message = auth.registry_error('registry.test', error + ' SECRET_TOKEN\x1b[31m')
self.assertIn(expected, message)
self.assertNotIn('SECRET_TOKEN', message)
self.assertNotIn('\x1b', message)
def test_bad_login_credentials_have_a_specific_safe_error(self):
for error in ('invalid username/password', 'invalid username', 'invalid password',
'invalid credentials', 'unauthorized',
'Requesting bearer token: invalid status code from registry 403 (Forbidden)'):
with self.subTest(error=error):
message = auth.registry_error('registry.test', error + ' SECRET_TOKEN', login=True)
self.assertIn('Authentication failed', message)
self.assertNotIn('SECRET_TOKEN', message)
def test_corrupt_authfile_does_not_trap_the_user_in_a_message_loop(self):
class Cancelled(RuntimeError):
pass
ui = SimpleNamespace(message=MagicMock(), choose=MagicMock())
saved = SimpleNamespace(accounts=MagicMock(side_effect=auth.RegistryError('Invalid auth file')))
with patch.object(accounts, 'require_root'), \
patch.object(accounts.importlib, 'import_module', return_value=SimpleNamespace(UserCancelled=Cancelled)):
accounts.registry_accounts_menu(ui, auth=saved)
ui.message.assert_called_once()
ui.choose.assert_not_called()
def images(self):
image = SimpleNamespace(command=MagicMock())
def resolve(ref, architecture):
image.command('skopeo', 'inspect', '--raw', 'docker://' + ref)
return {'manifest_digest': 'sha256:' + 'a' * 64, 'size': 10, 'version': '1.0'}
image.resolve_candidate = resolve
return image
def test_native_resolver_transport_hook_is_scoped_and_restored(self):
image = self.images()
original = image.command
with patch.object(accounts.importlib, 'import_module', return_value=image), \
patch.object(accounts, 'registry_environment', nullcontext), \
patch.object(accounts.subprocess, 'run', return_value=SimpleNamespace(returncode=0, stdout=b'{}')):
self.assertEqual(accounts.inspect_registry_image('registry.test/team/app:latest')['version'], '1.0')
self.assertIs(image.command, original)
def test_native_resolver_restores_transport_hook_after_access_denied(self):
image = self.images()
original = image.command
response = SimpleNamespace(returncode=1, stderr=b'unauthorized SECRET_TOKEN')
with patch.object(accounts.importlib, 'import_module', return_value=image), \
patch.object(accounts, 'registry_environment', nullcontext), \
patch.object(accounts.subprocess, 'run', return_value=response), \
self.assertRaises(auth.RegistryError) as error:
accounts.inspect_registry_image('registry.test/team/app:latest')
self.assertIn('Access denied', str(error.exception))
self.assertNotIn('SECRET_TOKEN', str(error.exception))
self.assertIs(image.command, original)
def test_image_query_timeout_does_not_fall_back_to_a_long_native_timeout(self):
image = self.images()
with patch.object(accounts.importlib, 'import_module', return_value=image), \
patch.object(accounts, 'registry_environment', nullcontext), \
patch.object(accounts.subprocess, 'run', side_effect=subprocess.TimeoutExpired('skopeo', 30)), \
self.assertRaises(auth.RegistryError) as error:
accounts.inspect_registry_image('registry.test/team/app:latest')
self.assertIn('within 30 seconds', str(error.exception))
def test_wrong_architecture_and_malformed_metadata_do_not_recommend_relogin(self):
for native_error, expected in [(ValueError('A single matching image platform cannot be resolved'), 'compatible Linux'),
(ValueError('Invalid JSON'), 'invalid or unsupported'),
(KeyError('config'), 'incomplete or unsupported'),
(AttributeError('bad config'), 'incomplete or unsupported')]:
image = self.images()
image.resolve_candidate = MagicMock(side_effect=native_error)
with self.subTest(error=type(native_error).__name__), \
patch.object(accounts.importlib, 'import_module', return_value=image), \
patch.object(accounts, 'registry_environment', nullcontext), \
self.assertRaises(auth.RegistryError) as error:
accounts.inspect_registry_image('registry.test/team/app:latest')
self.assertIn(expected, str(error.exception))
def test_storage_failures_have_storage_messages_and_keep_native_lock_scope(self):
for failure, expected in [(ValueError('The new image does not fit on the storage'), 'Not enough free space'),
(OSError(errno.ENOSPC, 'No space left on device'), 'Not enough free space'),
(PermissionError('denied'), 'Cannot write'),
(OSError(errno.EROFS, 'Read-only file system'), 'Cannot write'),
(ValueError('Unsafe OCI archive path'), 'unsafe'),
(RuntimeError('Could not obtain an intact image'), 'integrity verification')]:
native = SimpleNamespace(instances=SimpleNamespace(ROOT='/fixture', locked=lambda root: nullcontext()),
resolve_archive=MagicMock(side_effect=failure))
with self.subTest(error=str(failure)), patch.object(accounts, 'inspect_registry_image'), \
patch.object(accounts.importlib, 'import_module', return_value=native), \
patch.object(accounts, 'registry_environment', nullcontext), \
self.assertRaises(auth.RegistryError) as error:
accounts.download_registry_image('registry.test/team/app:latest', 'local')
self.assertIn(expected, str(error.exception))
def test_missing_skopeo_does_not_create_a_saved_account(self):
with tempfile.TemporaryDirectory() as temp:
path = Path(temp).resolve() / 'containers/auth.json'
manager = auth.RegistryAuth(path)
with patch.object(auth.subprocess, 'run', side_effect=FileNotFoundError()), \
self.assertRaises(auth.RegistryError) as error:
manager.login('registry.test', 'example', 'example-token')
self.assertIn('Skopeo is not available', str(error.exception))
self.assertFalse(path.exists())
def test_invalid_usernames_and_multiline_or_empty_tokens_never_reach_skopeo(self):
with tempfile.TemporaryDirectory() as temp:
manager = auth.RegistryAuth(Path(temp).resolve() / 'containers/auth.json')
for username, token in [('', 'token'), (' ', 'token'), ('user:colon', 'token'),
('user\x7f', 'token'), ('name', ''), ('name', 'x\ny'),
('name', 'x\0y'), ('name', 'x' * 65537)]:
with self.subTest(username=username, token_length=len(token)), \
patch.object(auth.subprocess, 'run') as run, self.assertRaises(auth.RegistryError):
manager.login('registry.test', username, token)
run.assert_not_called()
if __name__ == '__main__':
unittest.main()
+45
View File
@@ -0,0 +1,45 @@
from pathlib import Path
import json
import sys
import unittest
from types import SimpleNamespace
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(ROOT / "remote"))
from proxmenux_oci.registry_auth import RegistryError, validate_registry, validate_image
class ReferenceTests(unittest.TestCase):
def test_native_image_importer_preserves_registry_port_and_digest(self):
from proxmenux_oci import custom
import yaml
for reference in ('registry.test:5443/team/app:stable',
'[::1]:5443/team/app@sha256:' + 'a' * 64):
with self.subTest(reference=reference), \
patch.object(custom.subprocess, 'run', return_value=SimpleNamespace(
returncode=0, stdout=json.dumps({'config': {}}))) as run:
compose = yaml.safe_load(custom.compose_from_image(reference))
self.assertEqual(next(iter(compose['services'].values()))['image'], reference)
self.assertEqual(run.call_args.args[0][-1], 'docker://' + reference)
def test_private_registries_with_ports_and_digest_references_are_valid(self):
for reference in ('ghcr.io/team/app:latest', 'registry.local:5000/team/app:stable',
'[::1]:5000/team/app:latest', 'ghcr.io/team/app@sha256:' + 'a' * 64):
self.assertEqual(validate_image(reference), reference)
def test_credentials_and_urls_cannot_be_used_as_image_references(self):
for reference in ('https://ghcr.io/team/app', 'user:token@ghcr.io/app', 'ghcr.io/app;id',
'ghcr.io/../app', 'ghcr.io/app@sha256:short'):
with self.assertRaises(RegistryError):
validate_image(reference)
def test_registry_authorities_do_not_accept_image_paths_or_credentials(self):
for registry in ('https://ghcr.io', 'ghcr.io/team', 'user:token@ghcr.io', 'host:99999'):
with self.assertRaises(RegistryError):
validate_registry(registry)
if __name__ == '__main__':
unittest.main()