| ... | @@ -7,7 +7,10 @@ import json | ... | @@ -7,7 +7,10 @@ import json |
| 7 | from pathlib import Path | 7 | from pathlib import Path |
| 8 | import runpy | 8 | import runpy |
| 9 | import stat | 9 | import stat |
| | 10 | import subprocess |
| | 11 | import sys |
| 10 | import tempfile | 12 | import tempfile |
| | 13 | import traceback |
| 11 | import unittest | 14 | import unittest |
| 12 | from unittest.mock import Mock, patch | 15 | from unittest.mock import Mock, patch |
| 13 | | 16 | |
| ... | @@ -19,6 +22,130 @@ def utc(text): | ... | @@ -19,6 +22,130 @@ def utc(text): |
| 19 | | 22 | |
| 20 | | 23 | |
| 21 | class ReleaseTest(unittest.TestCase): | 24 | class ReleaseTest(unittest.TestCase): |
| | 25 | def test_notary_arguments_reach_child_without_entering_logs_or_tracebacks(self): |
| | 26 | for status in (0, 7): |
| | 27 | with self.subTest(status=status), tempfile.TemporaryDirectory() as folder: |
| | 28 | record = Path(folder) / 'arguments.json' |
| | 29 | secrets = ['PRIVATE-KEY-PATH', 'PRIVATE-KEY-ID', 'PRIVATE-ISSUER'] |
| | 30 | command = ['xcrun', 'notarytool', 'submit', Path('archive.zip'), |
| | 31 | '--key', secrets[0], '--key-id', secrets[1], '--issuer', secrets[2], '--wait'] |
| | 32 | child = ('import json, pathlib, sys; ' |
| | 33 | 'pathlib.Path(sys.argv[1]).write_text(json.dumps(sys.argv[3:])); ' |
| | 34 | 'print("notary output"); print("notary diagnostic", file=sys.stderr); ' |
| | 35 | 'sys.exit(int(sys.argv[2]))') |
| | 36 | actual_run = subprocess.run |
| | 37 | |
| | 38 | def notary(arguments, **kwargs): |
| | 39 | return actual_run([sys.executable, '-c', child, str(record), str(status), |
| | 40 | *map(str, arguments)], **kwargs) |
| | 41 | |
| | 42 | printed = io.StringIO() |
| | 43 | with tempfile.TemporaryFile(mode='w+') as output, tempfile.TemporaryFile(mode='w+') as errors, \ |
| | 44 | patch.object(release['subprocess'], 'run', side_effect=notary), \ |
| | 45 | redirect_stdout(printed): |
| | 46 | if status: |
| | 47 | try: |
| | 48 | release['run'](command, stdout=output, stderr=errors) |
| | 49 | except subprocess.CalledProcessError as error: |
| | 50 | self.assertIs(type(error), subprocess.CalledProcessError) |
| | 51 | self.assertEqual(error.returncode, status) |
| | 52 | try: |
| | 53 | raise RuntimeError('Release failed') from error |
| | 54 | except RuntimeError as chained: |
| | 55 | trace = ''.join(traceback.format_exception(type(chained), chained, chained.__traceback__)) |
| | 56 | trace += repr(chained.__cause__) + repr(error.args) |
| | 57 | else: |
| | 58 | self.fail('The notary child did not fail') |
| | 59 | else: |
| | 60 | self.assertIsNone(release['run'](command, stdout=output, stderr=errors)) |
| | 61 | trace = '' |
| | 62 | output.seek(0) |
| | 63 | errors.seek(0) |
| | 64 | self.assertEqual(output.read(), 'notary output\n') |
| | 65 | self.assertEqual(errors.read(), 'notary diagnostic\n') |
| | 66 | self.assertEqual(json.loads(record.read_text()), list(map(str, command))) |
| | 67 | self.assertIn('xcrun notarytool', printed.getvalue()) |
| | 68 | for secret in secrets: |
| | 69 | self.assertNotIn(secret, printed.getvalue() + trace) |
| | 70 | |
| | 71 | def test_notary_timeout_redacts_arguments_without_changing_failure(self): |
| | 72 | command = ['xcrun', 'notarytool', 'submit', '--password', 'PRIVATE-PASSWORD'] |
| | 73 | error = subprocess.TimeoutExpired(command, 3, output=b'output', stderr=b'diagnostic') |
| | 74 | with patch.object(release['subprocess'], 'run', side_effect=error), redirect_stdout(io.StringIO()): |
| | 75 | with self.assertRaises(subprocess.TimeoutExpired) as raised: |
| | 76 | release['run'](command, timeout=3) |
| | 77 | self.assertIs(raised.exception, error) |
| | 78 | self.assertEqual(error.timeout, 3) |
| | 79 | self.assertEqual(error.output, b'output') |
| | 80 | self.assertEqual(error.stderr, b'diagnostic') |
| | 81 | self.assertNotIn('PRIVATE-PASSWORD', repr(error) + repr(error.args) + |
| | 82 | ''.join(traceback.format_exception(type(error), error, error.__traceback__))) |
| | 83 | |
| | 84 | def test_other_command_failure_keeps_its_arguments_and_output(self): |
| | 85 | command = ['codesign', '--verify', Path('Snowbound.app')] |
| | 86 | error = subprocess.CalledProcessError(2, command, output=b'output', stderr=b'diagnostic') |
| | 87 | printed = io.StringIO() |
| | 88 | with patch.object(release['subprocess'], 'run', side_effect=error), redirect_stdout(printed): |
| | 89 | with self.assertRaises(subprocess.CalledProcessError) as raised: |
| | 90 | release['run'](command) |
| | 91 | self.assertIs(raised.exception, error) |
| | 92 | self.assertIs(error.cmd, command) |
| | 93 | self.assertEqual(error.output, b'output') |
| | 94 | self.assertEqual(error.stderr, b'diagnostic') |
| | 95 | self.assertIn('codesign --verify Snowbound.app', printed.getvalue()) |
| | 96 | |
| | 97 | def test_staple_retries_only_a_lost_connection_and_propagates_second_failure(self): |
| | 98 | network = 'error is Error Domain=NSURLErrorDomain Code=-1005 "The network connection was lost."\n' |
| | 99 | for failures in ([(68, network)], [(68, network), (68, network)], |
| | 100 | [(68, 'The bundle has not been notarized.\n')], |
| | 101 | [(77, network)], [(68, network.replace('-1005', '-10050'))]): |
| | 102 | with self.subTest(failures=failures), tempfile.TemporaryDirectory() as folder: |
| | 103 | calls = [] |
| | 104 | errors = [] |
| | 105 | |
| | 106 | def failing_tool(command, **kwargs): |
| | 107 | calls.append((command, kwargs)) |
| | 108 | if len(calls) <= len(failures): |
| | 109 | status, diagnostic = failures[len(calls) - 1] |
| | 110 | kwargs['stdout'].write(diagnostic) |
| | 111 | error = subprocess.CalledProcessError(status, command) |
| | 112 | errors.append(error) |
| | 113 | raise error |
| | 114 | kwargs['stdout'].write('The staple and validate action worked!\n') |
| | 115 | |
| | 116 | printed = io.StringIO() |
| | 117 | with patch.dict(release['staple'].__globals__, {'run': failing_tool}), redirect_stdout(printed): |
| | 118 | if len(failures) == 1 and failures[0] == (68, network): |
| | 119 | self.assertIsNone(release['staple'](Path(folder) / 'Snowbound.app')) |
| | 120 | else: |
| | 121 | with self.assertRaises(subprocess.CalledProcessError) as raised: |
| | 122 | release['staple'](Path(folder) / 'Snowbound.app') |
| | 123 | self.assertIs(raised.exception, errors[-1]) |
| | 124 | expected = 2 if failures[0] == (68, network) else 1 |
| | 125 | self.assertEqual(len(calls), expected) |
| | 126 | self.assertEqual(printed.getvalue(), ''.join(text for _, text in failures) + |
| | 127 | ('The staple and validate action worked!\n' if len(errors) < len(calls) else '')) |
| | 128 | for command, kwargs in calls: |
| | 129 | self.assertEqual(command, ['xcrun', 'stapler', 'staple', Path(folder) / 'Snowbound.app']) |
| | 130 | self.assertEqual(kwargs['timeout'], 120) |
| | 131 | self.assertEqual(kwargs['stderr'], subprocess.STDOUT) |
| | 132 | |
| | 133 | def test_staple_timeout_preserves_diagnostic_without_retrying(self): |
| | 134 | command = ['xcrun', 'stapler', 'staple', Path('Snowbound.app')] |
| | 135 | error = subprocess.TimeoutExpired(command, 120) |
| | 136 | |
| | 137 | def timeout(_, **kwargs): |
| | 138 | kwargs['stdout'].write('Incomplete ticket download\n') |
| | 139 | raise error |
| | 140 | |
| | 141 | printed = io.StringIO() |
| | 142 | with patch.dict(release['staple'].__globals__, {'run': Mock(side_effect=timeout)}), redirect_stdout(printed): |
| | 143 | with self.assertRaises(subprocess.TimeoutExpired) as raised: |
| | 144 | release['staple'](command[-1]) |
| | 145 | release['staple'].__globals__['run'].assert_called_once() |
| | 146 | self.assertIs(raised.exception, error) |
| | 147 | self.assertEqual(printed.getvalue(), 'Incomplete ticket download\n') |
| | 148 | |
| 22 | def test_download_bytes_survive_rejected_permissions(self): | 149 | def test_download_bytes_survive_rejected_permissions(self): |
| 23 | with tempfile.TemporaryDirectory() as folder: | 150 | with tempfile.TemporaryDirectory() as folder: |
| 24 | source, destination = Path(folder) / 'source', Path(folder) / 'download' | 151 | source, destination = Path(folder) / 'source', Path(folder) / 'download' |