diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index ab91b4a..a49e4e1 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -13,9 +13,9 @@ env: # Keep shared stack versions in one place so compatibility upgrades are # deliberate and reviewable instead of being buried inside individual steps. PYTHON_VERSION: '3.12.6' - KERIPY_REF: '1.2.12' + KERIPY_REF: '4ee02c0213770d25a0114fe7ebd7ab4ab5500cde' KERIA_REF: '9e2461550f373ad7bdbe7eebeaceac689cb15397' - VLEI_REF: '1.0.2' + VLEI_REF: 'f514b9431c5f965b5f7f64a8693e19df2f181564' jobs: unit: @@ -159,7 +159,7 @@ jobs: signifypy/venv keria/venv vLEI/venv - key: ${{ runner.os }}-py${{ env.PYTHON_VERSION }}-live-venvs-${{ hashFiles('signifypy/pyproject.toml', 'signifypy/.github/constraints/integration.txt', 'keripy/requirements.txt', 'keripy/setup.py', 'keria/pyproject.toml', 'vLEI/requirements.txt', 'vLEI/setup.py') }} + key: ${{ runner.os }}-py${{ env.PYTHON_VERSION }}-live-venvs-${{ env.KERIPY_REF }}-${{ env.KERIA_REF }}-${{ env.VLEI_REF }}-${{ hashFiles('signifypy/pyproject.toml', 'signifypy/.github/constraints/integration.txt', 'keripy/requirements.txt', 'keripy/setup.py', 'keria/pyproject.toml', 'vLEI/requirements.txt', 'vLEI/setup.py') }} - name: Stage 5 - Build live-stack virtualenvs if: steps.cache-live-venvs.outputs.cache-hit != 'true' @@ -175,9 +175,9 @@ jobs: # SignifyPy's test runner installs local KERIpy so the client library # and the launched services resolve against the same source version. ./venv/bin/python -m pip install --upgrade pip setuptools wheel + make sync ./venv/bin/python -m pip install -c .github/constraints/integration.txt hio==0.6.14 ./venv/bin/python -m pip install -c .github/constraints/integration.txt -e ../keripy - make sync - name: Stage 7 - Install KERIA runtime if: steps.cache-live-venvs.outputs.cache-hit != 'true' @@ -203,6 +203,13 @@ jobs: - name: Stage 9 - Run integration tests working-directory: signifypy + env: + SIGNIFYPY_INTEGRATION_KERIPY_ROOT: ../keripy + SIGNIFYPY_INTEGRATION_KERIPY_REF: ${{ env.KERIPY_REF }} + SIGNIFYPY_INTEGRATION_KERIA_ROOT: ../keria + SIGNIFYPY_INTEGRATION_KERIA_REF: ${{ env.KERIA_REF }} + SIGNIFYPY_INTEGRATION_VLEI_ROOT: ../vLEI + SIGNIFYPY_INTEGRATION_VLEI_REF: ${{ env.VLEI_REF }} run: | # Run the full live suite with explicit parallelism so the CI # contract is visible in the workflow rather than hidden behind a diff --git a/.gitignore b/.gitignore index f28516f..bb62497 100644 --- a/.gitignore +++ b/.gitignore @@ -137,4 +137,5 @@ dmypy.json .vscode # temp files -.tmp \ No newline at end of file +.tmp +.integration-deps/ diff --git a/Makefile b/Makefile index 95eaa7e..a2c745d 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ UV ?= uv UV_ENV = UV_PROJECT_ENVIRONMENT=venv UV_CACHE = UV_CACHE_DIR=$(CURDIR)/.uv-cache +INTEGRATION_DEPS_ROOT ?= .integration-deps INTEGRATION_POLL_INTERVAL ?= 0.1 INTEGRATION_HEAVY_POLL_INTERVAL ?= 0.25 INTEGRATION_PORT_POLL_INTERVAL ?= 0.1 @@ -8,7 +9,7 @@ INTEGRATION_WORKERS ?= 2 INTEGRATION_DIST ?= loadscope INTEGRATION_TARGETS ?= tests/integration -.PHONY: help sync test test-fast test-ci test-integration test-integration-ci test-integration-parallel test-integration-parallel-ci build dist-check release-patch release-minor release-major release-bump docs clean guard-clean-worktree +.PHONY: help sync sync-integration-deps sync-integration test test-fast test-ci test-integration test-integration-ci test-integration-parallel test-integration-parallel-ci build dist-check release-patch release-minor release-major release-bump docs clean guard-clean-worktree help: ## Show available maintainer tasks @awk 'BEGIN {FS = ":.*## "}; /^[a-zA-Z0-9_-]+:.*## / {printf "%-18s %s\n", $$1, $$2}' $(MAKEFILE_LIST) @@ -16,6 +17,11 @@ help: ## Show available maintainer tasks sync: ## Sync the local maintainer environment into ./venv @$(UV_CACHE) $(UV_ENV) $(UV) sync --group dev +sync-integration-deps: sync ## Sync pinned local source deps for live integration tests + @./venv/bin/python scripts/sync_integration_deps.py --deps-root "$(INTEGRATION_DEPS_ROOT)" + +sync-integration: sync-integration-deps ## Alias for syncing the live integration runtime + test: ## Run the fast unit/contract suite @./venv/bin/python -m pytest -q $(PYTEST_ARGS) tests/app tests/core tests/peer diff --git a/scripts/sync_integration_deps.py b/scripts/sync_integration_deps.py new file mode 100644 index 0000000..f0a6904 --- /dev/null +++ b/scripts/sync_integration_deps.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python +"""Synchronize pinned source repos and virtualenvs for live integration tests.""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +SIGNIFYPY_ROOT = Path(__file__).resolve().parents[1] +CONSTRAINTS = SIGNIFYPY_ROOT / ".github" / "constraints" / "integration.txt" +SIGNIFYPY_VENV_PYTHON = SIGNIFYPY_ROOT / "venv" / "bin" / "python" + +sys.path.insert(0, str(SIGNIFYPY_ROOT)) +from tests.integration.dependencies import INTEGRATION_DEPENDENCIES, KERIA, KERIPY, VLEI # noqa: E402 + + +def run(*args: str, cwd: Path | None = None) -> None: + print("+", " ".join(args)) + subprocess.run(args, cwd=cwd, check=True) + + +def sync_repo(root: Path, repo: str, ref: str) -> None: + if not root.exists(): + root.parent.mkdir(parents=True, exist_ok=True) + run("git", "clone", repo, str(root)) + + run("git", "-C", str(root), "fetch", "--tags", "origin") + run("git", "-C", str(root), "fetch", "origin") + run("git", "-C", str(root), "checkout", "--detach", ref) + + +def ensure_venv(venv: Path) -> Path: + python = venv / "bin" / "python" + if not python.exists(): + run(sys.executable, "-m", "venv", str(venv)) + return python + + +def pip_install(python: Path, *args: str) -> None: + run(str(python), "-m", "pip", *args) + + +def install_signifypy_runtime(deps_root: Path) -> None: + if not SIGNIFYPY_VENV_PYTHON.exists(): + raise RuntimeError( + f"SignifyPy venv is missing at {SIGNIFYPY_VENV_PYTHON}. " + "Run `make sync` before `make sync-integration-deps`." + ) + + keripy_root = deps_root / KERIPY.path_name + pip_install(SIGNIFYPY_VENV_PYTHON, "install", "--upgrade", "pip", "setuptools", "wheel") + pip_install(SIGNIFYPY_VENV_PYTHON, "install", "-c", str(CONSTRAINTS), "hio==0.6.14") + pip_install(SIGNIFYPY_VENV_PYTHON, "install", "-c", str(CONSTRAINTS), "-e", str(keripy_root)) + + +def install_keria_runtime(deps_root: Path) -> None: + python = ensure_venv(deps_root / KERIA.path_name / "venv") + keripy_root = deps_root / KERIPY.path_name + keria_root = deps_root / KERIA.path_name + pip_install(python, "install", "--upgrade", "pip", "setuptools", "wheel") + pip_install(python, "install", "-c", str(CONSTRAINTS), "hio==0.6.14") + pip_install(python, "install", "-c", str(CONSTRAINTS), "-e", str(keripy_root), "-e", str(keria_root)) + + +def install_vlei_runtime(deps_root: Path) -> None: + python = ensure_venv(deps_root / VLEI.path_name / "venv") + keripy_root = deps_root / KERIPY.path_name + vlei_root = deps_root / VLEI.path_name + pip_install(python, "install", "--upgrade", "pip", "setuptools", "wheel") + pip_install(python, "install", "-c", str(CONSTRAINTS), "hio==0.6.14") + pip_install(python, "install", "-c", str(CONSTRAINTS), "-e", str(keripy_root), "-e", str(vlei_root)) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "--deps-root", + default=str(SIGNIFYPY_ROOT / ".integration-deps"), + help="directory where pinned integration source repos should be stored", + ) + args = parser.parse_args() + + deps_root = Path(args.deps_root).expanduser().resolve() + for dependency in INTEGRATION_DEPENDENCIES: + sync_repo(deps_root / dependency.path_name, dependency.repo, dependency.ref) + + install_signifypy_runtime(deps_root) + install_keria_runtime(deps_root) + install_vlei_runtime(deps_root) + + +if __name__ == "__main__": + main() diff --git a/src/signify/app/aiding.py b/src/signify/app/aiding.py index 6bc337a..9f20815 100644 --- a/src/signify/app/aiding.py +++ b/src/signify/app/aiding.py @@ -244,10 +244,11 @@ def addEndRole(self, name, *, role=Roles.agent, eid=None, stamp=None): In OOBI-heavy flows this record must exist before any role-specific OOBI becomes available. """ + resolved_eid = self._resolveEndRoleEid(role=role, eid=eid) hab = self.get(name) pre = hab["prefix"] - rpy = self.makeEndRole(pre, role, eid, stamp) + rpy = self.makeEndRole(pre, role, resolved_eid, stamp) keeper = self.client.manager.get(aid=hab) sigs = keeper.sign(ser=rpy.raw) rpy_msg = api.ReplyMessage( @@ -257,6 +258,21 @@ def addEndRole(self, name, *, role=Roles.agent, eid=None, stamp=None): res = self.client.post(f"/identifiers/{name}/endroles", json=asdict(rpy_msg)) return rpy, sigs, res.json() + def _resolveEndRoleEid(self, *, role, eid): + """Resolve the endpoint provider AID for endpoint-role authorization.""" + if eid: + return eid + + if role == Roles.agent: + agent = getattr(self.client, "agent", None) + agent_pre = getattr(agent, "pre", None) + if agent_pre: + return agent_pre + + raise kering.ConfigurationError("agent endpoint role authorization requires a connected agent AID") + + raise kering.ConfigurationError(f"endpoint role {role} authorization requires eid") + def addLocScheme(self, name, url, *, eid=None, scheme=None, stamp=None): """Publish a location-scheme reply for an identifier-scoped endpoint. diff --git a/src/signify/app/clienting.py b/src/signify/app/clienting.py index c992bde..2cd7479 100644 --- a/src/signify/app/clienting.py +++ b/src/signify/app/clienting.py @@ -7,7 +7,7 @@ request families documented in the feature guide. """ from dataclasses import asdict -from urllib.parse import urlparse, urljoin, urlsplit +from urllib.parse import quote, urlparse, urljoin, urlsplit import requests import sseclient @@ -491,5 +491,5 @@ def __call__(self, req): p = urlsplit(req.url) path = p.path if p.path else "/" - req.headers = self.authn.sign(headers, req.method, path) + req.headers = self.authn.sign(headers, req.method, quote(path)) return req diff --git a/src/signify/app/credentialing.py b/src/signify/app/credentialing.py index 3580c4c..0d47d18 100644 --- a/src/signify/app/credentialing.py +++ b/src/signify/app/credentialing.py @@ -162,7 +162,9 @@ def get(self, name, registryName): Returns: dict: Decoded registry record returned by KERIA. """ - res = self.client.get(f"/identifiers/{name}/registries/{registryName}") + res = self.client.get( + f"/identifiers/{name}/registries/{registryName}" + ) return res.json() def list(self, name): @@ -396,7 +398,10 @@ def rename(self, target, registryName, newName): """ name = target if isinstance(target, str) else target["name"] body = dict(name=newName) - resp = self.client.put(path=f"/identifiers/{name}/registries/{registryName}", json=body) + resp = self.client.put( + path=f"/identifiers/{name}/registries/{registryName}", + json=body, + ) return resp.json() diff --git a/src/signify/core/authing.py b/src/signify/core/authing.py index 9989e88..eb9d941 100644 --- a/src/signify/core/authing.py +++ b/src/signify/core/authing.py @@ -4,7 +4,7 @@ signify.core.authing module """ -from urllib.parse import urlparse +from urllib.parse import quote, urlparse from keri import kering from keri.app import keeping @@ -299,7 +299,7 @@ def verify(self, rep, **kwargs): raise kering.AuthNError("No valid signature from agent on response.") resource = rep.headers["SIGNIFY-RESOURCE"] - if resource != self.agent.pre or not self.verifysig(rep.headers, rep.request.method, url.path): + if resource != self.agent.pre or not self.verifysig(rep.headers, rep.request.method, quote(url.path)): raise kering.AuthNError("No valid signature from agent on response.") def verifysig(self, headers, method, path): diff --git a/tests/app/test_aiding.py b/tests/app/test_aiding.py index f25e068..284ed38 100644 --- a/tests/app/test_aiding.py +++ b/tests/app/test_aiding.py @@ -579,6 +579,7 @@ def test_aiding_add_end_role(): from signify.app.aiding import Identifiers ids = Identifiers(client=mock_client) # type: ignore + mock_client.agent = mock({"pre": "agent-pre"}, strict=False) # type: ignore mock_hab = {'prefix': 'hab prefix', 'name': 'aid1'} expect(ids, times=1).get('aid1').thenReturn(mock_hab) @@ -586,7 +587,7 @@ def test_aiding_add_end_role(): from keri.core import serdering mock_serder = mock({'ked': {'a': 'key event dictionary'}, 'raw': b'serder raw bytes'}, spec=serdering.SerderKERI, strict=True) - expect(ids, times=1).makeEndRole('hab prefix', 'agent', None, None).thenReturn(mock_serder) + expect(ids, times=1).makeEndRole('hab prefix', 'agent', 'agent-pre', None).thenReturn(mock_serder) from signify.core import keeping mock_keeper = mock({'params': lambda: {'keeper': 'params'}}, spec=keeping.SaltyKeeper, strict=True) @@ -608,6 +609,58 @@ def test_aiding_add_end_role(): unstub() +def test_aiding_add_end_role_preserves_explicit_eid(): + from signify.app.clienting import SignifyClient + mock_client = mock(spec=SignifyClient, strict=True) + + from signify.core import keeping + mock_manager = mock(spec=keeping.Manager, strict=True) + mock_client.manager = mock_manager # type: ignore + mock_client.agent = mock({"pre": "agent-pre"}, strict=False) # type: ignore + + from signify.app.aiding import Identifiers + ids = Identifiers(client=mock_client) # type: ignore + + mock_hab = {'prefix': 'hab prefix', 'name': 'aid1'} + expect(ids, times=1).get('aid1').thenReturn(mock_hab) + + from keri.core import serdering + mock_serder = mock({'ked': {'a': 'key event dictionary'}, 'raw': b'serder raw bytes'}, spec=serdering.SerderKERI, + strict=True) + expect(ids, times=1).makeEndRole('hab prefix', 'agent', 'explicit-eid', None).thenReturn(mock_serder) + + mock_keeper = mock({'params': lambda: {'keeper': 'params'}}, spec=keeping.SaltyKeeper, strict=True) + expect(mock_manager, times=1).get(aid=mock_hab).thenReturn(mock_keeper) + expect(mock_keeper, times=1).sign(ser=mock_serder.raw).thenReturn(['a signature']) + + from requests import Response + mock_response = mock(spec=Response, strict=True) + expected_data = {'rpy': {'a': 'key event dictionary'}, 'sigs': ['a signature']} + expect(mock_client, times=1).post('/identifiers/aid1/endroles', json=expected_data).thenReturn(mock_response) + expect(mock_response, times=1).json().thenReturn({'success': 'yay'}) + + _serder, _sig, out = ids.addEndRole('aid1', eid='explicit-eid') + assert out['success'] == 'yay' + + verifyNoUnwantedInteractions() + unstub() + + +def test_aiding_add_end_role_requires_eid_for_non_agent_role(): + from signify.app.clienting import SignifyClient + mock_client = mock(spec=SignifyClient, strict=True) + + from signify.app.aiding import Identifiers + ids = Identifiers(client=mock_client) # type: ignore + + from keri import kering + with pytest.raises(kering.ConfigurationError): + ids.addEndRole('aid1', role='mailbox') + + verifyNoUnwantedInteractions() + unstub() + + def test_aiding_sign(): from signify.app.clienting import SignifyClient mock_client = mock(spec=SignifyClient, strict=True) diff --git a/tests/app/test_clienting.py b/tests/app/test_clienting.py index 33f3ba3..462a940 100644 --- a/tests/app/test_clienting.py +++ b/tests/app/test_clienting.py @@ -979,3 +979,34 @@ def test_signify_auth(): unstub() verifyNoUnwantedInteractions() + + +def test_signify_auth_quotes_signed_path(): + from signify.core import authing + mock_controller = mock({'pre': 'a prefix'}, spec=authing.Controller, strict=True) + mock_authenticator = mock({'ctrl': mock_controller}, spec=authing.Authenticater, strict=True) + + from signify.app.clienting import SignifyAuth + signify_auth = SignifyAuth(mock_authenticator) + + import requests + mock_request = mock({ + 'method': 'GET', + 'url': 'http://example.com/identifiers/name/registries/did:webs_designated_aliases:Eaid', + 'headers': {}, + 'body': None, + }, spec=requests.Request, strict=True) + + from keri.help import helping + expect(helping).nowIso8601().thenReturn('now ISO8601!') + expect(mock_authenticator, times=1).sign( + {'Signify-Resource': 'a prefix', 'Signify-Timestamp': 'now ISO8601!'}, + 'GET', + '/identifiers/name/registries/did%3Awebs_designated_aliases%3AEaid', + ).thenReturn({'headers': 'modified'}) + + out = signify_auth.__call__(mock_request) + assert out.headers == {'headers': 'modified'} + + unstub() + verifyNoUnwantedInteractions() diff --git a/tests/core/test_authing.py b/tests/core/test_authing.py index cf04eea..c695595 100644 --- a/tests/core/test_authing.py +++ b/tests/core/test_authing.py @@ -45,6 +45,25 @@ def test_verify(): strict=True) authn.verify(rep=mock_rep) + + seen = {} + + def capture_path(_headers, _method, path): + seen["path"] = path + return True + + authn.verifysig = capture_path + mock_request = mock({'method': 'GET', + 'url': 'http://example.com/identifiers/name/registries/did:webs_designated_aliases:Eaid', + 'headers': {}, + 'body': "a body for len"}, + spec=requests.Request, strict=True) + mock_rep = mock({'request': mock_request, 'headers': {"SIGNIFY-RESOURCE": 'EEz01234'}}, spec=requests.Response, + strict=True) + + authn.verify(rep=mock_rep) + assert seen["path"] == "/identifiers/name/registries/did%3Awebs_designated_aliases%3AEaid" + verifyNoUnwantedInteractions() unstub() diff --git a/tests/integration/README.md b/tests/integration/README.md index 5689bb2..ab11956 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -27,38 +27,54 @@ make test-integration make test-integration-parallel ``` +Local live-stack dependencies are explicit. Before running this layer on a +fresh checkout, sync the pinned source repos and their service virtualenvs: + +```bash +make sync-integration-deps +``` + +This creates `.integration-deps/keripy`, `.integration-deps/keria`, and +`.integration-deps/vLEI`, checks each repo out at the pinned SHA below, and +builds the KERIA and vLEI repo-local virtualenvs used by the harness. The +directory is ignored by Git. + In CI, the same rule applies: the live layer should run from a dedicated workflow job, not from the default fast-test invocation. -## Local Sources Required +## Source Dependencies Required -The live integration fixture launches local services from sibling source repos, -so this workspace layout is part of the contract: +The live integration fixture launches local services from pinned source repos. +For local runs, `make sync-integration-deps` installs them under +`.integration-deps/`. CI may provide the same repos as siblings instead: - `../keripy` - `../keria` - `../vLEI` -The current CI stack pins those sibling repos to explicit compatibility refs: +The current stack pins those source dependencies to explicit compatibility +SHAs: -- `keripy`: `1.2.12` +- `keripy`: `4ee02c0213770d25a0114fe7ebd7ab4ab5500cde` (tag `1.2.12`) - `keria`: `9e2461550f373ad7bdbe7eebeaceac689cb15397` -- `vLEI`: `1.0.2` +- `vLEI`: `f514b9431c5f965b5f7f64a8693e19df2f181564` (tag `1.0.2`) The CI runtime also constrains `hio` to `0.6.14` across the live stack. `keripy` and `vLEI` both allow newer `hio` releases, but the pinned SignifyPy integration stack currently relies on the older doer API shape used by KERIA 0.4.0-prep and vLEI 1.0.2. -It also expects repo-local virtualenv interpreters to exist at: +The local default expects repo-local virtualenv interpreters to exist at: -- `../signifypy/venv/bin/python` -- `../keria/venv/bin/python` -- `../vLEI/venv/bin/python` +- `./venv/bin/python` +- `.integration-deps/keria/venv/bin/python` +- `.integration-deps/vLEI/venv/bin/python` -That requirement is why the GitHub Actions workflow checks out the four repos -as siblings and creates one virtualenv per repo instead of trying to run the -whole stack from a single Python environment. +CI may still check out the four repos as siblings and create one virtualenv per +repo. When it does, it points the harness at those sibling roots with +`SIGNIFYPY_INTEGRATION_*_ROOT` environment variables. Either way, missing repos, +wrong SHAs, or missing service virtualenvs are integration-test failures when +`--run-integration` is requested. The CI job also caches those repo-local virtualenvs by dependency-manifest hash. That keeps repeat runs fast without changing the local contract the diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index fb288a9..c1143f4 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -36,6 +36,7 @@ import pytest +from tests.integration.dependencies import KERIA, KERIPY, VLEI from tests.integration.helpers import poll_until from tests.integration.topology import make_stack_topology, stack_runtime_name @@ -44,12 +45,30 @@ SIGNIFYPY_ROOT = Path(__file__).resolve().parents[2] INTEGRATION_ROOT = Path(__file__).resolve().parent SOURCE_ROOT = SIGNIFYPY_ROOT.parent -KERIPY_ROOT = SOURCE_ROOT / "keripy" -KERIA_ROOT = SOURCE_ROOT / "keria" -VLEI_ROOT = SOURCE_ROOT / "vLEI" -SIGNIFYPY_PYTHON = SIGNIFYPY_ROOT / "venv" / "bin" / "python" -KERIA_PYTHON = KERIA_ROOT / "venv" / "bin" / "python" -VLEI_PYTHON = VLEI_ROOT / "venv" / "bin" / "python" +LOCAL_DEPS_ROOT = Path(os.getenv("SIGNIFYPY_INTEGRATION_DEPS_ROOT", SIGNIFYPY_ROOT / ".integration-deps")).expanduser() + + +def _dependency_root(dependency): + if root := os.getenv(dependency.env_root): + return Path(root).expanduser() + + local_root = LOCAL_DEPS_ROOT / dependency.path_name + if local_root.exists(): + return local_root + + sibling_root = SOURCE_ROOT / dependency.path_name + if sibling_root.exists(): + return sibling_root + + return local_root + + +KERIPY_ROOT = _dependency_root(KERIPY) +KERIA_ROOT = _dependency_root(KERIA) +VLEI_ROOT = _dependency_root(VLEI) +SIGNIFYPY_PYTHON = Path(os.getenv("SIGNIFYPY_INTEGRATION_SIGNIFYPY_PYTHON", SIGNIFYPY_ROOT / "venv" / "bin" / "python")).expanduser() +KERIA_PYTHON = Path(os.getenv("SIGNIFYPY_INTEGRATION_KERIA_PYTHON", KERIA_ROOT / "venv" / "bin" / "python")).expanduser() +VLEI_PYTHON = Path(os.getenv("SIGNIFYPY_INTEGRATION_VLEI_PYTHON", VLEI_ROOT / "venv" / "bin" / "python")).expanduser() KERIPY_WITNESS_CONFIG_DIR = KERIPY_ROOT / "scripts" / "keri" / "cf" / "main" WITNESS_CONFIG_NAMES = ("wan", "wil", "wes") @@ -60,15 +79,89 @@ PORT_POLL_INTERVAL = float(os.getenv("SIGNIFYPY_INTEGRATION_PORT_POLL_INTERVAL", "0.1")) -# Runtime and config helpers +# Runtime, dependency, and config helpers def _require_python(path: Path, name: str) -> str: - """Return the runtime path or skip when that repo-local interpreter is unavailable.""" + """Return the runtime path or fail when that repo-local interpreter is unavailable.""" if not path.exists(): - pytest.skip(f"{name} runtime is unavailable at {path}") + raise RuntimeError( + f"{name} runtime is unavailable at {path}. " + "Run `make sync-integration-deps` for local integration dependencies, " + "or set the SIGNIFYPY_INTEGRATION_*_ROOT/PYTHON environment variables." + ) return str(path) +def _expected_ref(dependency) -> str: + return os.getenv(dependency.env_ref, dependency.ref) + + +def _require_full_sha(dependency, ref: str) -> None: + if len(ref) != 40 or any(char not in "0123456789abcdefABCDEF" for char in ref): + raise RuntimeError( + f"{dependency.name} integration ref must be a full 40-character Git SHA, got {ref!r}. " + f"Set {dependency.env_ref} to a commit SHA if overriding the default." + ) + + +def _git_output(root: Path, *args: str) -> str: + try: + completed = subprocess.run( + ["git", "-C", str(root), *args], + check=True, + capture_output=True, + text=True, + ) + except FileNotFoundError as err: + raise RuntimeError("git is required to validate live integration source dependencies") from err + except subprocess.CalledProcessError as err: + raise RuntimeError( + f"failed to inspect git repo at {root}: {err.stderr.strip() or err.stdout.strip()}" + ) from err + + return completed.stdout.strip() + + +def _require_source_dependency(dependency, root: Path) -> None: + expected = _expected_ref(dependency) + _require_full_sha(dependency, expected) + + if not root.exists(): + raise RuntimeError( + f"{dependency.name} integration source dependency is missing at {root}. " + "Run `make sync-integration-deps` locally, or check out the pinned repo " + f"{dependency.repo} at {expected} and set {dependency.env_root}." + ) + + actual = _git_output(root, "rev-parse", "HEAD") + if actual != expected: + raise RuntimeError( + f"{dependency.name} integration source dependency at {root} is checked out at {actual}, " + f"but the live harness requires {expected}." + ) + + +def _require_path(path: Path, description: str) -> None: + if not path.exists(): + raise RuntimeError(f"{description} is unavailable at {path}") + + +def _require_integration_dependencies() -> None: + """Fail fast when the requested live integration stack dependencies are absent.""" + _require_source_dependency(KERIPY, KERIPY_ROOT) + _require_source_dependency(KERIA, KERIA_ROOT) + _require_source_dependency(VLEI, VLEI_ROOT) + _require_python(SIGNIFYPY_PYTHON, "SignifyPy") + _require_python(KERIA_PYTHON, "KERIA") + _require_python(VLEI_PYTHON, "vLEI") + _require_path(KERIPY_WITNESS_CONFIG_DIR / "wan.json", "KERIpy witness config") + _require_path(KERIPY_WITNESS_CONFIG_DIR / "wil.json", "KERIpy witness config") + _require_path(KERIPY_WITNESS_CONFIG_DIR / "wes.json", "KERIpy witness config") + _require_path(VLEI_ROOT / "schema" / "acdc", "vLEI schema directory") + _require_path(VLEI_ROOT / "samples" / "acdc", "vLEI sample credential directory") + _require_path(VLEI_ROOT / "samples" / "oobis", "vLEI sample OOBI directory") + + def _write_canonical_witness_configs(config_root: Path, live_stack: dict) -> None: """Copy canonical witness-demo configs into the exact path witnesses read. @@ -83,7 +176,7 @@ def _write_canonical_witness_configs(config_root: Path, live_stack: dict) -> Non for index, name in enumerate(WITNESS_CONFIG_NAMES): source = KERIPY_WITNESS_CONFIG_DIR / f"{name}.json" if not source.exists(): - pytest.skip(f"canonical witness config is unavailable at {source}") + raise RuntimeError(f"canonical witness config is unavailable at {source}") config = json.loads(source.read_text(encoding="utf-8")) config[name]["curls"] = [ curl if not curl.startswith("http://") else f"http://{live_stack['host']}:{live_stack['witness_ports'][index]}/" @@ -404,6 +497,8 @@ def _stack_fixture(tmp_path_factory: pytest.TempPathFactory, request: pytest.Fix topology launch is simpler and safer than trying to recover from a partial startup where one subprocess bound and another failed. """ + _require_integration_dependencies() + last_err = None for attempt in range(3): live_stack = _build_live_stack(tmp_path_factory, request, mode=mode, attempt=attempt) diff --git a/tests/integration/dependencies.py b/tests/integration/dependencies.py new file mode 100644 index 0000000..a685a53 --- /dev/null +++ b/tests/integration/dependencies.py @@ -0,0 +1,45 @@ +"""Pinned source dependencies for the live integration harness.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class IntegrationDependency: + name: str + repo: str + ref: str + path_name: str + env_root: str + env_ref: str + + +KERIPY = IntegrationDependency( + name="KERIpy", + repo="https://github.com/WebOfTrust/keripy.git", + ref="4ee02c0213770d25a0114fe7ebd7ab4ab5500cde", + path_name="keripy", + env_root="SIGNIFYPY_INTEGRATION_KERIPY_ROOT", + env_ref="SIGNIFYPY_INTEGRATION_KERIPY_REF", +) + +KERIA = IntegrationDependency( + name="KERIA", + repo="https://github.com/WebOfTrust/keria.git", + ref="9e2461550f373ad7bdbe7eebeaceac689cb15397", + path_name="keria", + env_root="SIGNIFYPY_INTEGRATION_KERIA_ROOT", + env_ref="SIGNIFYPY_INTEGRATION_KERIA_REF", +) + +VLEI = IntegrationDependency( + name="vLEI", + repo="https://github.com/WebOfTrust/vLEI.git", + ref="f514b9431c5f965b5f7f64a8693e19df2f181564", + path_name="vLEI", + env_root="SIGNIFYPY_INTEGRATION_VLEI_ROOT", + env_ref="SIGNIFYPY_INTEGRATION_VLEI_REF", +) + +INTEGRATION_DEPENDENCIES = (KERIPY, KERIA, VLEI) diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index e8ece57..8ae2932 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -657,7 +657,7 @@ def resolve_oobi(client: SignifyClient, oobi: str, alias: str | None = None) -> def get_end_roles(client: SignifyClient, name: str, role: str = "agent") -> list[dict]: """Fetch end-role authorizations for an identifier.""" - return client.get(f"/identifiers/{name}/endroles/{role}").json() + return client.endroles().list(name=name, role=role) def wait_for_end_role(