222 lines
8.4 KiB
Python
222 lines
8.4 KiB
Python
"""Stage 9 acceptance: a protected scene package, opened from the application.
|
|
|
|
The container could always read a signed or sealed package, but the application never
|
|
passed a verification key or a content-key provider, so a legitimate protected release
|
|
was refused with "this package is signed; a verification key is required" — and the only
|
|
ways to run it were to ship a plain package or to disable the check. These tests are
|
|
about the *wiring*: preferences → ``SceneKeys`` → the loader the pane actually uses.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from orisys_app_shared.scene3d import protect, synthetic
|
|
from orisys_app_shared.scene3d.contracts import ExpectedGeometry, ThreeDError
|
|
from orisys_app_shared.scene3d.package import write_package
|
|
from orisys_studio.scene3d.asset_source import SceneSource, load_package
|
|
from orisys_studio.scene3d.keys import (
|
|
PREF_CONTENT_KEY_FILE,
|
|
PREF_CONTENT_KEY_ID,
|
|
PREF_REQUIRE_SIGNATURE,
|
|
PREF_VERIFY_KEY,
|
|
resolve_scene_keys,
|
|
)
|
|
|
|
KEY_ID = "finger-display"
|
|
CONTENT_KEY_HEX = "11" * 32
|
|
|
|
|
|
def _expected(scene) -> ExpectedGeometry:
|
|
return ExpectedGeometry(
|
|
"finger", "", scene.geometry_id, scene.field_domain_id, scene.shape_hw
|
|
)
|
|
|
|
|
|
def _keys_and_paths(tmp_path: Path):
|
|
"""A signing key pair and a content key file, as a release build would have."""
|
|
private = protect.generate_signing_key()
|
|
public = protect.public_key_pem(private)
|
|
verify_path = tmp_path / "verify.pem"
|
|
verify_path.write_bytes(public)
|
|
key_path = tmp_path / "content.key"
|
|
key_path.write_text(CONTENT_KEY_HEX + "\n", encoding="utf-8")
|
|
provider = protect.MappingKeyProvider({KEY_ID: CONTENT_KEY_HEX})
|
|
return private, public, verify_path, key_path, provider
|
|
|
|
|
|
def _write_signed(tmp_path: Path, scene, *, sealed: bool):
|
|
private, public, verify_path, key_path, provider = _keys_and_paths(tmp_path)
|
|
from orisys_app_shared.scene3d.contracts import Sealing
|
|
|
|
target = tmp_path / ("sealed.oryscene" if sealed else "signed.oryscene")
|
|
write_package(
|
|
scene,
|
|
target,
|
|
sealing=Sealing(
|
|
key_id=KEY_ID if sealed else "",
|
|
provider=provider if sealed else None,
|
|
signing_key_pem=private,
|
|
signing_key_reference="stage-9",
|
|
verify_key_pem=public,
|
|
),
|
|
)
|
|
return target, verify_path, key_path
|
|
|
|
|
|
def _source(path: Path) -> SceneSource:
|
|
return SceneSource(path=path, kind="package", directory=path.parent)
|
|
|
|
|
|
@pytest.mark.parametrize("sealed", (False, True), ids=("signed", "sealed"))
|
|
def test_a_protected_package_opens_from_the_application(tmp_path, sealed):
|
|
"""The review's R4: the library could read it; the application could not.
|
|
|
|
With keys resolved from preferences, the pane's own loader accepts both signed
|
|
and sealed release packages and returns their scene intact.
|
|
"""
|
|
scene = synthetic.poc_scene()
|
|
package, verify_path, key_path = _write_signed(tmp_path, scene, sealed=sealed)
|
|
prefs = {PREF_VERIFY_KEY: str(verify_path)}
|
|
if sealed:
|
|
prefs.update(
|
|
{
|
|
PREF_CONTENT_KEY_FILE: str(key_path),
|
|
PREF_CONTENT_KEY_ID: KEY_ID,
|
|
}
|
|
)
|
|
|
|
keys = resolve_scene_keys(prefs)
|
|
described = keys.describe()
|
|
assert keys.verify_key_pem is not None
|
|
assert described["verify_key"] is True
|
|
assert CONTENT_KEY_HEX.encode() not in keys.verify_key_pem
|
|
if sealed:
|
|
assert keys.provider is not None
|
|
assert described["content_key"] is True
|
|
assert described["content_key_id"] == KEY_ID
|
|
# The description carries paths and ids, never key material.
|
|
assert CONTENT_KEY_HEX not in json.dumps(described)
|
|
|
|
assets = load_package(_source(package), _expected(scene), keys)
|
|
assert assets.geometry_id == scene.geometry_id
|
|
# The writer replaces the synthetic scene's placeholder with the manifest hash.
|
|
assert len(assets.asset_hash) == 64
|
|
|
|
|
|
def test_without_the_verification_key_a_signed_package_is_refused(tmp_path):
|
|
"""Not a silent fallback to plain: the refusal names what is missing."""
|
|
scene = synthetic.poc_scene()
|
|
package, _verify_path, _key_path = _write_signed(tmp_path, scene, sealed=False)
|
|
keys = resolve_scene_keys({})
|
|
with pytest.raises(ThreeDError) as excinfo:
|
|
load_package(_source(package), _expected(scene), keys)
|
|
assert excinfo.value.diagnostic.code == "asset_auth_failed"
|
|
assert "verification key" in excinfo.value.diagnostic.message
|
|
|
|
|
|
def test_a_missing_or_unreadable_key_file_is_a_state_not_a_crash(tmp_path):
|
|
"""The 2D application must start whatever the key configuration looks like."""
|
|
keys = resolve_scene_keys(
|
|
{
|
|
PREF_VERIFY_KEY: str(tmp_path / "gone.pem"),
|
|
PREF_CONTENT_KEY_FILE: str(tmp_path / "gone.key"),
|
|
PREF_CONTENT_KEY_ID: KEY_ID,
|
|
}
|
|
)
|
|
assert keys.verify_key_pem is None and keys.provider is None
|
|
described = keys.describe()
|
|
assert described["verify_key_error"] == "missing"
|
|
assert described["content_key_error"] == "missing"
|
|
|
|
# A key file without a key id is not usable either, and says which half is absent.
|
|
key_path = tmp_path / "content.key"
|
|
key_path.write_text(CONTENT_KEY_HEX, encoding="utf-8")
|
|
keys = resolve_scene_keys({PREF_CONTENT_KEY_FILE: str(key_path)})
|
|
assert keys.provider is None
|
|
assert keys.describe()["content_key_error"] == "no key id configured"
|
|
|
|
|
|
def test_release_mode_refuses_an_unsigned_package(tmp_path):
|
|
"""The gate that replaces "ask the engineer to sign it"."""
|
|
scene = synthetic.poc_scene()
|
|
plain = tmp_path / "plain.oryscene"
|
|
write_package(scene, plain)
|
|
release = resolve_scene_keys({PREF_REQUIRE_SIGNATURE: True})
|
|
assert release.require_signature is True
|
|
with pytest.raises(ThreeDError) as excinfo:
|
|
load_package(_source(plain), _expected(scene), release)
|
|
assert excinfo.value.diagnostic.code == "asset_auth_failed"
|
|
assert "requires signed scene packages" in excinfo.value.diagnostic.message
|
|
|
|
# The same package is fine in a development build (and the shipped engineering
|
|
# package is exactly this case).
|
|
dev = resolve_scene_keys({})
|
|
assert load_package(_source(plain), _expected(scene), dev).geometry_id == scene.geometry_id
|
|
|
|
|
|
class _InlineExecutor:
|
|
"""Runs submitted work immediately: the loader's path without a thread."""
|
|
|
|
def submit(self, function, *args, **kwargs):
|
|
from concurrent.futures import Future
|
|
|
|
future: Future = Future()
|
|
try:
|
|
future.set_result(function(*args, **kwargs))
|
|
except BaseException as exc: # noqa: BLE001 - mirrored into the future
|
|
future.set_exception(exc)
|
|
return future
|
|
|
|
def shutdown(self, wait: bool = True) -> None: # noqa: ARG002 - executor contract
|
|
return None
|
|
|
|
|
|
def test_the_scene_loader_passes_the_keys_to_its_worker(qt_app, tmp_path):
|
|
"""The pane's loader is where the wiring has to happen, not just ``load_package``."""
|
|
from orisys_studio.scene3d.asset_source import SceneAssetLoader
|
|
|
|
scene = synthetic.poc_scene()
|
|
package, verify_path, _key_path = _write_signed(tmp_path, scene, sealed=False)
|
|
keys = resolve_scene_keys({PREF_VERIFY_KEY: str(verify_path)})
|
|
|
|
loader = SceneAssetLoader()
|
|
loader._executor = _InlineExecutor()
|
|
loaded: list = []
|
|
failures: list = []
|
|
loader.loaded.connect(
|
|
lambda assets, description, request_id, device: loaded.append(assets)
|
|
)
|
|
loader.failed.connect(lambda item, request_id: failures.append(item))
|
|
try:
|
|
loader.load(_source(package), _expected(scene), keys=keys)
|
|
assert not failures, [item.message for item in failures]
|
|
assert loaded and loaded[0].geometry_id == scene.geometry_id
|
|
finally:
|
|
loader.close()
|
|
|
|
# Without them the same file is refused, and the loader reports it rather than
|
|
# raising into the GUI thread.
|
|
loader = SceneAssetLoader()
|
|
loader._executor = _InlineExecutor()
|
|
failures = []
|
|
loader.failed.connect(lambda item, request_id: failures.append(item))
|
|
try:
|
|
loader.load(_source(package), _expected(scene), keys=resolve_scene_keys({}))
|
|
assert failures and failures[0].code == "asset_auth_failed"
|
|
finally:
|
|
loader.close()
|
|
|
|
|
|
def test_the_pane_carries_the_keys_into_its_loader(qt_app):
|
|
from orisys_studio.ui.mesh3d_panel import Mesh3dPanel
|
|
|
|
panel = Mesh3dPanel()
|
|
keys = resolve_scene_keys({})
|
|
assert panel.keys() is None
|
|
panel.set_keys(keys)
|
|
assert panel.keys() is keys
|