mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
177 lines
8.7 KiB
Python
177 lines
8.7 KiB
Python
"""Cancel a real editor through Menu/CLI/Monitor contracts, never apply it."""
|
|
from contextlib import redirect_stdout
|
|
import copy
|
|
import io
|
|
import json
|
|
from pathlib import Path
|
|
import sys
|
|
from unittest.mock import patch
|
|
|
|
from test_oci_update_cancellation import CancellationFixture, ROOT
|
|
from proxmenux_oci import cli, management as menu, recreation
|
|
from proxmenux_oci.ui import UserCancelled
|
|
|
|
|
|
class ModifyCancellationTests(CancellationFixture):
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.record.update({
|
|
'installation_id': 'fixture-oci-edit-cancel',
|
|
'observed': {'config_sha256': 'fixture-config-hash'},
|
|
'template': {'id': 'fixture-app', 'catalog_ui': {'title': 'Fixture'},
|
|
'container_contract': {'image': {'reference': 'example/fixture:latest'}}},
|
|
'deployment': {'vmid': 110, 'hostname': 'fixture',
|
|
'resources': {'cores': 2, 'memory_mb': 512},
|
|
'rootfs': {'storage': 'fixture-storage', 'size_gb': 8},
|
|
'mounts': [], 'devices': [], 'environment': [
|
|
{'name': 'PASSWORD', 'value': 'private-fixture-value', 'sensitive': True}],
|
|
'network': {'bridge': 'vmbr-fixture', 'ipv4': 'dhcp'}}})
|
|
self.original = copy.deepcopy(self.record)
|
|
self.ui.back_enabled = False
|
|
self.path_write = self.guard(Path, 'write_text')
|
|
self.path_bytes = self.guard(Path, 'write_bytes')
|
|
|
|
def script(self, choices, answers=(), confirms=(), reviews=()):
|
|
self.ui.choose.side_effect = choices
|
|
self.ui.ask.side_effect = answers
|
|
self.ui.confirm.side_effect = confirms
|
|
self.ui.review.side_effect = reviews
|
|
|
|
def no_effects(self):
|
|
super().no_effects()
|
|
self.path_write.assert_not_called()
|
|
self.path_bytes.assert_not_called()
|
|
self.assertFalse(self.ui.back_enabled)
|
|
|
|
def test_main_cancel_is_distinct_falsy_cancellation(self):
|
|
self.script([None])
|
|
self.assertIs(menu.manage_instance(ROOT/'oci', self.ui, self.row, action='modify'), menu.CANCELLED)
|
|
self.assertFalse(menu.CANCELLED)
|
|
self.no_effects()
|
|
|
|
def test_main_discard_without_edits_returns_cancel_code(self):
|
|
self.script(['discard'])
|
|
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
|
|
self.ui.confirm.assert_not_called()
|
|
self.no_effects()
|
|
|
|
def test_real_cli_cancel_returns_code_six_without_extra_console_output(self):
|
|
self.script([None])
|
|
with patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()) as output:
|
|
self.assertEqual(cli.main(['manage', '110', '--action', 'modify']), 6)
|
|
self.assertEqual(output.getvalue(), '')
|
|
self.no_effects()
|
|
|
|
def test_real_cli_discard_after_edits_never_calls_backup_or_lifecycle(self):
|
|
self.script(['cores', 'discard'], ['3'], [True])
|
|
with patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()):
|
|
self.assertEqual(cli.main(['manage', '110', '--action', 'modify']), 6)
|
|
self.ui.confirm.assert_called_once_with('Discard unapplied changes?', False)
|
|
self.no_effects()
|
|
|
|
def test_main_cancel_after_edits_requires_discard_confirmation(self):
|
|
self.script(['cores', None], ['3'], [True])
|
|
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
|
|
self.ui.confirm.assert_called_once_with('Discard unapplied changes?', False)
|
|
self.no_effects()
|
|
|
|
def test_declining_discard_preserves_draft_until_later_main_cancel(self):
|
|
self.script(['cores', 'discard', None], ['3'], [False, True])
|
|
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
|
|
last_options = dict(self.ui.choose.call_args.args[1])
|
|
self.assertTrue(last_options['cores'].rstrip().endswith('3'))
|
|
self.assertIn('Unapplied changes', self.ui.choose.call_args.args[0])
|
|
self.assertEqual(self.ui.confirm.call_count, 2)
|
|
self.no_effects()
|
|
|
|
def test_section_cancel_returns_to_editor_before_main_cancel(self):
|
|
self.script(['cores', None], [UserCancelled()])
|
|
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
|
|
self.assertEqual(self.ui.choose.call_count, 2)
|
|
self.assertIn('No pending changes', self.ui.choose.call_args.args[0])
|
|
self.ui.review.assert_not_called()
|
|
self.no_effects()
|
|
|
|
def test_zero_cores_rejected_then_section_and_main_cancel_are_safe(self):
|
|
self.script(['cores', None], ['0', UserCancelled()])
|
|
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
|
|
self.assertIn('Cores: minimum 1', self.ui.message.call_args.args[0])
|
|
self.assertIn('No pending changes', self.ui.choose.call_args.args[0])
|
|
self.no_effects()
|
|
|
|
def test_review_no_returns_to_editor_with_draft_then_discard_cancels(self):
|
|
self.script(['cores', 'apply', 'discard'], ['3'], [True], [False])
|
|
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
|
|
self.assertEqual(self.ui.choose.call_count, 3)
|
|
self.assertIn('Unapplied changes', self.ui.choose.call_args.args[0])
|
|
self.assertNotIn('private-fixture-value', self.ui.review.call_args.args[0])
|
|
self.no_effects()
|
|
|
|
def test_native_menu_returns_to_list_after_editor_cancel(self):
|
|
self.script(['110', 'modify', None, None])
|
|
with patch.object(menu, '_clean_orphans'), patch.object(menu, 'offer_recovery'), \
|
|
patch.object(menu, '_records_unread', return_value=[]):
|
|
menu.interactive_management(ROOT/'oci', self.ui)
|
|
self.assertEqual(self.ui.choose.call_count, 4)
|
|
self.no_effects()
|
|
|
|
def test_legacy_editor_callers_still_receive_none_on_cancel(self):
|
|
for choice in (None, 'discard'):
|
|
with self.subTest(choice=choice):
|
|
self.script([choice])
|
|
self.assertIsNone(recreation.edit_recreation(self.record, self.ui))
|
|
self.no_effects()
|
|
|
|
def test_unchanged_review_is_not_mislabeled_as_user_cancellation(self):
|
|
self.script(['apply'])
|
|
self.assertIsNone(recreation.edit_recreation(self.record, self.ui, cancelled_result=menu.CANCELLED))
|
|
self.ui.message.assert_called_once_with('Nothing was changed.', 'Modify OCI')
|
|
self.no_effects()
|
|
|
|
def test_real_editor_error_is_not_converted_to_cancellation(self):
|
|
self.script(['cores'], [RuntimeError('fixture editor failure')])
|
|
with self.assertRaisesRegex(RuntimeError, 'fixture editor failure'):
|
|
menu.direct_management(ROOT/'oci', 110, 'modify')
|
|
self.no_effects()
|
|
|
|
def test_real_cli_validation_failure_remains_failure_one(self):
|
|
self.script(['cores'], [cli.InstallError('fixture validation failure')])
|
|
with patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()):
|
|
self.assertEqual(cli.main(['manage', '110', '--action', 'modify']), 1)
|
|
self.no_effects()
|
|
|
|
def test_unattended_modify_is_rejected_before_opening_the_editor(self):
|
|
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify', unattended=True), 1)
|
|
self.ui.choose.assert_not_called()
|
|
self.no_effects()
|
|
|
|
def test_accepted_proposal_keeps_original_backend_and_private_payload(self):
|
|
self.script(['cores', 'apply'], ['3'], reviews=[True])
|
|
self.backup.side_effect = None
|
|
self.backup.return_value = True
|
|
def lifecycle(command, title):
|
|
self.assertEqual(command[:3], [sys.executable, str(ROOT/'oci/remote/oci_update_current.py'), '110'])
|
|
proposal_file = Path(command[command.index('--proposal')+1])
|
|
self.assertEqual(proposal_file.stat().st_mode & 0o777, 0o600)
|
|
proposal = json.loads(proposal_file.read_text())
|
|
self.assertEqual(proposal['operation'], 'recreate')
|
|
self.assertEqual(proposal['candidate']['deployment']['resources']['cores'], 3)
|
|
self.assertEqual(proposal['base_config_sha256'], 'fixture-config-hash')
|
|
self.assertNotIn('private-fixture-value', ' '.join(command))
|
|
return True
|
|
self.lifecycle.side_effect = lifecycle
|
|
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 0)
|
|
self.backup.assert_called_once()
|
|
self.lifecycle.assert_called_once()
|
|
self.assertEqual(self.record, self.original)
|
|
self.write.assert_not_called()
|
|
|
|
def test_failed_backup_after_review_remains_failure_without_lifecycle(self):
|
|
self.script(['cores', 'apply'], ['3'], reviews=[True])
|
|
self.backup.side_effect = None
|
|
self.backup.return_value = False
|
|
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 1)
|
|
self.lifecycle.assert_not_called()
|
|
self.write.assert_not_called()
|
|
self.assertEqual(self.record, self.original)
|