Files
archy/scripts/tests/test_companion_apk_publish_refusal.py
Dorian 5af14de526 Harden companion publishing without changing runtime UX
Adapt the audited publisher refusal changes, derive metadata from the signed APK, and constrain all regression staging to disposable repositories.
2026-11-13 04:17:15 -05:00

95 lines
5.2 KiB
Python

"""Publisher checks use a disposable Git repository and fake SDK only."""
import importlib.util
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[2]
spec = importlib.util.spec_from_file_location('apk_metadata', ROOT / 'scripts/companion_apk_metadata.py')
metadata = importlib.util.module_from_spec(spec)
spec.loader.exec_module(metadata)
class PublisherTests(unittest.TestCase):
def test_actual_variant_version_is_retained(self):
for version in ('0.5.40-debug', '0.5.40-bump28-debug'):
self.assertEqual(metadata.parse_badging(
f"package: name='com.archipelago.app.debug' versionCode='60' versionName='{version}' platformBuildVersionName='15'"
), {'versionName': version, 'versionCode': 60})
def test_invalid_or_different_package_is_refused(self):
for line in ('', "package: name='foreign.app' versionCode='60' versionName='1'",
"package: name='com.archipelago.app.debug' versionCode='0' versionName='1'",
"package: name='com.archipelago.app.debug' versionCode='x' versionName='1'"):
with self.assertRaises(ValueError):
metadata.parse_badging(line)
def fixture(self, root, badging):
subprocess.run(['git', 'init', '-q', str(root)], check=True)
for name in ('scripts', 'Android/app/build/outputs/apk/debug', 'Android/app/src/main/res',
'neode-ui/public/packages', 'jdk/bin', 'sdk/build-tools/35.0'):
(root / name).mkdir(parents=True, exist_ok=True)
for name in ('publish-companion-apk.sh', 'companion_apk_metadata.py'):
shutil.copyfile(ROOT / 'scripts' / name, root / 'scripts' / name)
(root / 'Android/app/debug.keystore').write_text('fixture; not a key')
# Deliberately disagrees with the built APK: extraction must ignore it.
(root / 'Android/app/build.gradle.kts').write_text('versionName = "old"\nversionCode = 1\n')
(root / 'neode-ui/public/packages/archipelago-companion.apk').write_bytes(b'old apk')
(root / 'neode-ui/public/packages/archipelago-companion.json').write_text('{"old":true}\n')
programs = {
'jdk/bin/java': '#!/bin/sh\nexit 0\n',
'Android/gradlew': '#!/bin/sh\nprintf "new apk" > app/build/outputs/apk/debug/app-debug.apk\n',
'sdk/build-tools/35.0/zipalign': '#!/bin/sh\ncp "$4" "$5"\n',
'sdk/build-tools/35.0/apksigner': '''#!/bin/sh
if [ "$1" = verify ]; then
echo 'Verified using v1 scheme: true'
echo 'Verified using v2 scheme: true'
echo 'Verified using v3 scheme: true'
echo 'Signer #1 certificate SHA-256 digest: d622e07e7f474246e8e2bcee828ca9bf6c6cdf9434c065c58fe89bb4eec2664d'
fi
''',
'sdk/build-tools/35.0/aapt': '#!/bin/sh\ncat "$(dirname "$0")/badging.txt"\n',
}
for name, source in programs.items():
(root / name).write_text(source)
(root / name).chmod(0o700)
(root / 'sdk/build-tools/35.0/badging.txt').write_text(badging)
return dict(os.environ, JAVA_HOME=str(root / 'jdk'), ANDROID_HOME=str(root / 'sdk'))
def run_publisher(self, root, env):
return subprocess.run(['bash', str(root / 'scripts/publish-companion-apk.sh')],
cwd=root, env=env, capture_output=True, text=True, timeout=30)
def test_success_stages_exact_apk_version_only_in_fixture(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
env = self.fixture(root, "package: name='com.archipelago.app.debug' versionCode='60' versionName='0.5.40-bump28-debug'\n")
result = self.run_publisher(root, env)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads((root / 'neode-ui/public/packages/archipelago-companion.json').read_text()),
{'versionName': '0.5.40-bump28-debug', 'versionCode': 60})
staged = subprocess.check_output(['git', 'diff', '--cached', '--name-only'], cwd=root, text=True).splitlines()
self.assertEqual(set(staged), {'neode-ui/public/packages/archipelago-companion.apk',
'neode-ui/public/packages/archipelago-companion.json'})
def test_refusal_preserves_old_pair_and_stages_nothing(self):
for missing_sdk in (False, True):
with self.subTest(missing_sdk=missing_sdk), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
env = self.fixture(root, 'invalid badging')
if missing_sdk:
env['ANDROID_HOME'] = str(root / 'missing')
result = self.run_publisher(root, env)
self.assertNotEqual(result.returncode, 0)
self.assertEqual((root / 'neode-ui/public/packages/archipelago-companion.apk').read_bytes(), b'old apk')
self.assertEqual(json.loads((root / 'neode-ui/public/packages/archipelago-companion.json').read_text()), {'old': True})
self.assertEqual(subprocess.check_output(['git', 'diff', '--cached', '--name-only'], cwd=root), b'')
if __name__ == '__main__':
unittest.main()