"""Signed plugin artifact packaging tests (ADR-013 Phase 1). Covers the provenance + ed25519 signing that the marketplace trust model rests on: a valid signature over an intact file set verifies, and every tampering path (flipped file byte, wrong key, missing/extra file, no key) fails closed. """ import json import zipfile import pytest from shopdb.plugins import signing, packaging def _write_plugin(plugin_dir, name='demo', version='1.0.0', deps=None): plugin_dir.mkdir(parents=True, exist_ok=True) (plugin_dir / 'manifest.json').write_text(json.dumps({ 'name': name, 'version': version, 'description': 'demo', 'dependencies': deps or [], 'core_version': '>=0.1.0,<1.0.0', 'api_prefix': f'/api/{name}', })) (plugin_dir / 'plugin.py').write_text('# demo plugin\n') (plugin_dir / 'models').mkdir() (plugin_dir / 'models' / '__init__.py').write_text('') # noise that must NOT be packed or hashed (plugin_dir / '__pycache__').mkdir() (plugin_dir / '__pycache__' / 'x.pyc').write_bytes(b'\x00\x01') return plugin_dir # --- signing primitives ----------------------------------------------------- def test_sign_verify_round_trip(): private_pem, public_pem = signing.generate_keypair() priv = signing.load_private_key(private_pem) pub = signing.load_public_key(public_pem) data = b'provenance bytes' sig = signing.sign(priv, data) assert signing.verify([pub], data, sig) is True assert signing.verify([pub], b'other bytes', sig) is False def test_wrong_key_does_not_verify(): priv = signing.load_private_key(signing.generate_keypair()[0]) other_pub = signing.load_public_key(signing.generate_keypair()[1]) sig = signing.sign(priv, b'data') assert signing.verify([other_pub], b'data', sig) is False def test_provenance_excludes_noise(tmp_path): plugin_dir = _write_plugin(tmp_path / 'demo') filemap = signing.build_file_map(plugin_dir) assert 'manifest.json' in filemap assert 'models/__init__.py' in filemap assert not any('__pycache__' in k or k.endswith('.pyc') for k in filemap) assert signing.PROVENANCE_NAME not in filemap def test_serialize_provenance_is_order_independent(): a = {'name': 'x', 'version': '1', 'files': {'b': '2', 'a': '1'}} b = {'files': {'a': '1', 'b': '2'}, 'version': '1', 'name': 'x'} assert signing.serialize_provenance(a) == signing.serialize_provenance(b) # --- pack + verify ---------------------------------------------------------- @pytest.fixture def keypair(): private_pem, public_pem = signing.generate_keypair() return (signing.load_private_key(private_pem), signing.load_public_key(public_pem)) def test_pack_then_verify_ok(tmp_path, keypair): priv, pub = keypair plugin_dir = _write_plugin(tmp_path / 'demo') artifact = packaging.pack(plugin_dir, priv, publisher='site-a', out_dir=tmp_path / 'dist') assert artifact.name == 'demo-1.0.0.shopdbplugin' manifest, errors = packaging.verify_artifact(artifact, [pub]) assert errors == [] assert manifest['name'] == 'demo' def test_verify_fails_with_no_keys(tmp_path, keypair): priv, _ = keypair artifact = packaging.pack(_write_plugin(tmp_path / 'demo'), priv, out_dir=tmp_path / 'dist') _, errors = packaging.verify_artifact(artifact, []) assert any('unverifiable' in e for e in errors) def test_verify_fails_with_wrong_key(tmp_path, keypair): priv, _ = keypair wrong_pub = signing.load_public_key(signing.generate_keypair()[1]) artifact = packaging.pack(_write_plugin(tmp_path / 'demo'), priv, out_dir=tmp_path / 'dist') _, errors = packaging.verify_artifact(artifact, [wrong_pub]) assert any('signature does not match' in e for e in errors) def test_verify_detects_tampered_file(tmp_path, keypair): priv, pub = keypair artifact = packaging.pack(_write_plugin(tmp_path / 'demo'), priv, out_dir=tmp_path / 'dist') # Rewrite the zip with one member's bytes changed, keeping provenance + sig. with zipfile.ZipFile(artifact) as zin: members = {n: zin.read(n) for n in zin.namelist()} members['manifest.json'] += b'tampered' with zipfile.ZipFile(artifact, 'w', zipfile.ZIP_DEFLATED) as zout: for name, data in members.items(): zout.writestr(name, data) _, errors = packaging.verify_artifact(artifact, [pub]) assert any('hash mismatch' in e for e in errors) def test_verify_detects_extra_file(tmp_path, keypair): priv, pub = keypair artifact = packaging.pack(_write_plugin(tmp_path / 'demo'), priv, out_dir=tmp_path / 'dist') with zipfile.ZipFile(artifact, 'a', zipfile.ZIP_DEFLATED) as z: z.writestr('sneaky.py', 'print("surprise")') _, errors = packaging.verify_artifact(artifact, [pub]) assert any('unexpected file' in e for e in errors) def test_verify_dir_round_trip(tmp_path, keypair): """An unpacked directory carrying PROVENANCE files verifies (Phase 2 uses this for verify-at-load).""" priv, pub = keypair plugin_dir = _write_plugin(tmp_path / 'demo') provenance = signing.build_provenance(plugin_dir, 'demo', '1.0.0', 'site-a') prov_bytes = signing.serialize_provenance(provenance) (plugin_dir / signing.PROVENANCE_NAME).write_bytes(prov_bytes) (plugin_dir / signing.PROVENANCE_SIG).write_bytes( signing.sign(priv, prov_bytes)) _, errors = packaging.verify_dir(plugin_dir, [pub]) assert errors == [] # Tamper a file on disk -> hash mismatch. (plugin_dir / 'plugin.py').write_text('# changed\n') _, errors = packaging.verify_dir(plugin_dir, [pub]) assert any('hash mismatch' in e for e in errors)