diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1f35149..6d82176 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -2,49 +2,39 @@ image: archlinux:latest stages: - - lint - - integration - -variables: - PACMAN_CACHE: "${CI_PROJECT_DIR}/.pacman/pkg" - -cache: - paths: - - .pacman/pkg - key: ${CI_JOB_NAME} - -check-new-key: - stage: lint - needs: [] - script: - - install -d "${PACMAN_CACHE}" - - pacman -Syu --needed --noconfirm --cachedir "${PACMAN_CACHE}" git grep hopenpgp-tools sequoia-keyring-linter - - ./.gitlab/check-keyids-change - only: - refs: - - merge_requests - changes: - - master-keyids - - packager-keyids + - test lint: - stage: lint + stage: test + needs: [] before_script: - - install -d "${PACMAN_CACHE}" - - pacman -Syu --needed --noconfirm --cachedir "${PACMAN_CACHE}" make flake8 mypy python-black python-isort + - pacman -Syu --needed --noconfirm make flake8 mypy python-black python-isort script: - make lint only: - refs: - - merge_requests changes: - keyringctl + - libkeyringctl/* + - test/* + +test: + stage: test + needs: [] + before_script: + - pacman -Syu --needed --noconfirm make python sequoia-sq python-pytest + script: + - make test + only: + changes: + - keyringctl + - libkeyringctl/* + - test/* build_install: - stage: integration + stage: test + needs: [] before_script: - - install -d "${PACMAN_CACHE}" - - pacman -Syu --needed --noconfirm --cachedir "${PACMAN_CACHE}" make python sequoia-sq + - pacman -Syu --needed --noconfirm make python sequoia-sq script: - ./keyringctl import --main master master-revoked - ./keyringctl import packager packager-revoked diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c1ad443..b7cb9b3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,6 +31,7 @@ and develop this project: * python-black * python-isort +* python-pytest * flake8 * mypy @@ -41,3 +42,15 @@ The `keyringctl` script is written in typed python, which makes use of The script is type checked, linted and formatted using standard tooling. When providing a merge request make sure to run `make lint`. + +## Testing + +Test cases are developed per module in the [test](test) directory and should +consist of atomic single expectation tests. A Huge test case asserting various +different expectations are discouraged and should be split into finer grained +test cases. + +To execute all tests using pytest +```bash +make test +``` diff --git a/Makefile b/Makefile index 8b5ee03..b6471cb 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,9 @@ fmt: black . isort . +test: + py.test + build: ./keyringctl -v build @@ -24,4 +27,4 @@ uninstall: rm -f $(KEYRING_TARGET_DIR)/archlinux{.gpg,-trusted,-revoked} rmdir -p --ignore-fail-on-non-empty $(KEYRING_TARGET_DIR) -.PHONY: build install lint uninstall +.PHONY: all lint fmt test build install uninstall diff --git a/libkeyringctl/keyring.py b/libkeyringctl/keyring.py index 29ca490..3651a81 100644 --- a/libkeyringctl/keyring.py +++ b/libkeyringctl/keyring.py @@ -118,7 +118,7 @@ def convert_certificate( # noqa: ignore=C901 Returns ------- - The path of the user_dir (which is located below working_dir) + The path of the key directory (which is located below working_dir below the user_dir) """ # root packets @@ -279,7 +279,7 @@ def convert_certificate( # noqa: ignore=C901 key_dir=key_dir, ) - return user_dir + return key_dir def persist_public_key( @@ -579,8 +579,9 @@ def convert( ) for path in directories: - (target_dir / path.name).mkdir(parents=True, exist_ok=True) - copytree(src=path, dst=(target_dir / path.name), dirs_exist_ok=True) + user_dir = path.parent + (target_dir / user_dir.name).mkdir(parents=True, exist_ok=True) + copytree(src=user_dir, dst=(target_dir / user_dir.name), dirs_exist_ok=True) return target_dir diff --git a/libkeyringctl/sequoia.py b/libkeyringctl/sequoia.py index a7a68ff..267c04b 100644 --- a/libkeyringctl/sequoia.py +++ b/libkeyringctl/sequoia.py @@ -11,6 +11,7 @@ from typing import List from typing import Optional from .types import Fingerprint +from .types import Uid from .types import Username from .util import cwd from .util import natural_sort_path @@ -222,3 +223,63 @@ def latest_certification(certifications: Iterable[Path]) -> Path: lambda a, b: a if packet_signature_creation_time(a) > packet_signature_creation_time(b) else b, certifications, ) + + +def key_generate(uids: List[Uid], outfile: Path) -> str: + """Generate a PGP key with specific uids + + Parameters + ---------- + uids: List of uids that the key should have + outfile: Path to the file to which the key should be written to + + Returns + ------- + The result of the key generate call + """ + + cmd = ["sq", "key", "generate"] + for uid in uids: + cmd.extend(["--userid", str(uid)]) + cmd.extend(["--export", str(outfile)]) + return system(cmd) + + +def key_extract_certificate(key: Path, output: Optional[Path]) -> str: + """Extracts the non secret part from a key into a certificate + + Parameters + ---------- + key: Path to a file that contain secret key material + output: Path to the file to which the key should be written to, stdout if None + + Returns + ------- + The result of the extract in case output is None + """ + + cmd = ["sq", "key", "extract-cert", str(key)] + if output: + cmd.extend(["--output", str(output)]) + return system(cmd) + + +def certify(key: Path, certificate: Path, uid: Uid, output: Optional[Path]) -> str: + """Inspect PGP packet data and return the result + + Parameters + ---------- + key: Path to a file that contain secret key material + certificate: Path to a certificate file whose uid should be certified + uid: Uid contain in the certificate that should be certified + output: Path to the file to which the key should be written to, stdout if None + + Returns + ------- + The result of the certification in case output is None + """ + + cmd = ["sq", "certify", str(key), str(certificate), uid] + if output: + cmd.extend(["--output", str(output)]) + return system(cmd) diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..35e3c11 --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,126 @@ +from collections import defaultdict +from functools import wraps +from pathlib import Path +from shutil import copytree +from tempfile import TemporaryDirectory +from typing import Dict +from typing import List +from typing import Set + +from pytest import fixture + +from libkeyringctl.keyring import convert_certificate +from libkeyringctl.keyring import simplify_user_id +from libkeyringctl.sequoia import certify +from libkeyringctl.sequoia import key_extract_certificate +from libkeyringctl.sequoia import key_generate +from libkeyringctl.types import Fingerprint +from libkeyringctl.types import Uid +from libkeyringctl.types import Username +from libkeyringctl.util import cwd + +test_keys: Dict[Username, List[Path]] = defaultdict(list) +test_certificates: Dict[Username, List[Path]] = defaultdict(list) +test_keyring_certificates: Dict[Username, List[Path]] = defaultdict(list) +test_main_fingerprints: Set[Fingerprint] = set() + + +@fixture(autouse=True) +def reset_storage(): + test_keys.clear() + test_certificates.clear() + test_keyring_certificates.clear() + test_main_fingerprints.clear() + + +def create_certificate(username: Username, uids: List[Uid], keyring_type: str = "packager", func=None): + def decorator(decorated_func): + @wraps(decorated_func) + def wrapper(working_dir: Path, *args, **kwargs): + print(username) + + key_directory = working_dir / "secret" / f"{id}" + key_directory.mkdir(parents=True, exist_ok=True) + + key_file: Path = key_directory / f"{username}.asc" + key_generate(uids=uids, outfile=key_file) + test_keys[username].append(key_file) + + certificate_directory = working_dir / "certificate" / f"{id}" + certificate_directory.mkdir(parents=True, exist_ok=True) + + keyring_root: Path = working_dir / "keyring" + keyring_root.mkdir(parents=True, exist_ok=True) + certificate_file: Path = certificate_directory / f"{username}.asc" + + key_extract_certificate(key=key_file, output=certificate_file) + test_certificates[username].append(certificate_file) + + target_dir = keyring_root / keyring_type + + decomposed_path: Path = convert_certificate( + working_dir=working_dir, + certificate=certificate_file, + keyring_dir=keyring_root / keyring_type, + ) + user_dir = decomposed_path.parent + (target_dir / user_dir.name).mkdir(parents=True, exist_ok=True) + copytree(src=user_dir, dst=(target_dir / user_dir.name), dirs_exist_ok=True) + test_keyring_certificates[username].append(target_dir / user_dir.name / decomposed_path.name) + + if "main" == keyring_type: + test_main_fingerprints.add(Fingerprint(decomposed_path.name)) + + decorated_func(working_dir=working_dir, *args, **kwargs) + + return wrapper + + if not func: + return decorator + return decorator(func) + + +def create_uid_certification(issuer: Username, certified: Username, uid: Uid, func=None): + def decorator(decorated_func): + @wraps(decorated_func) + def wrapper(working_dir: Path, *args, **kwargs): + key: Path = test_keys[issuer][0] + certificate: Path = test_certificates[certified][0] + fingerprint: Fingerprint = Fingerprint(test_keyring_certificates[certified][0].name) + issuer_fingerprint: Fingerprint = Fingerprint(test_keyring_certificates[issuer][0].name) + simplified_uid = simplify_user_id(uid) + + output: Path = ( + working_dir + / "keyring" + / "packager" + / certified + / fingerprint + / "uid" + / simplified_uid + / "certification" + / f"{issuer_fingerprint}.asc" + ) + output.parent.mkdir(parents=True, exist_ok=True) + + certify(key, certificate, uid, output) + + decorated_func(working_dir=working_dir, *args, **kwargs) + + return wrapper + + if not func: + return decorator + return decorator(func) + + +@fixture(scope="function") +def working_dir(): + with TemporaryDirectory(prefix="arch-keyringctl-test-") as tempdir: + with cwd(tempdir): + yield Path(tempdir) + + +@fixture(scope="function") +def keyring_dir(working_dir: Path): + yield working_dir / "keyring" diff --git a/test/test_trust.py b/test/test_trust.py new file mode 100644 index 0000000..64b2370 --- /dev/null +++ b/test/test_trust.py @@ -0,0 +1,68 @@ +from pathlib import Path + +from libkeyringctl.trust import certificate_trust +from libkeyringctl.types import Trust +from libkeyringctl.types import Uid +from libkeyringctl.types import Username + +from .conftest import create_certificate +from .conftest import create_uid_certification +from .conftest import test_keyring_certificates +from .conftest import test_main_fingerprints + + +@create_certificate(username=Username("foobar"), uids=[Uid("foobar ")], keyring_type="main") +def test_certificate_trust_main_key_has_full_trust(working_dir: Path, keyring_dir: Path): + trust = certificate_trust( + test_keyring_certificates[Username("foobar")][0], + test_main_fingerprints, + ) + assert Trust.full == trust + + +@create_certificate(username=Username("main"), uids=[Uid("main ")]) +@create_certificate(username=Username("foobar"), uids=[Uid("foobar ")]) +def test_certificate_trust_no_signature_is_unknown(working_dir: Path, keyring_dir: Path): + trust = certificate_trust( + test_keyring_certificates[Username("foobar")][0], + test_main_fingerprints, + ) + assert Trust.unknown == trust + + +@create_certificate(username=Username("main"), uids=[Uid("main ")], keyring_type="main") +@create_certificate(username=Username("foobar"), uids=[Uid("foobar ")]) +@create_uid_certification(issuer=Username("main"), certified=Username("foobar"), uid=Uid("foobar ")) +def test_certificate_trust_one_signature_is_marginal(working_dir: Path, keyring_dir: Path): + trust = certificate_trust( + test_keyring_certificates[Username("foobar")][0], + test_main_fingerprints, + ) + assert Trust.marginal == trust + + +@create_certificate(username=Username("main"), uids=[Uid("main ")], keyring_type="main") +@create_certificate(username=Username("not_main"), uids=[Uid("main ")]) +@create_certificate(username=Username("foobar"), uids=[Uid("foobar ")]) +@create_uid_certification(issuer=Username("not_main"), certified=Username("foobar"), uid=Uid("foobar ")) +def test_certificate_trust_one_none_main_signature_gives_no_trust(working_dir: Path, keyring_dir: Path): + trust = certificate_trust( + test_keyring_certificates[Username("foobar")][0], + test_main_fingerprints, + ) + assert Trust.unknown == trust + + +@create_certificate(username=Username("main1"), uids=[Uid("main1 ")], keyring_type="main") +@create_certificate(username=Username("main2"), uids=[Uid("main2 ")], keyring_type="main") +@create_certificate(username=Username("main3"), uids=[Uid("main3 ")], keyring_type="main") +@create_certificate(username=Username("foobar"), uids=[Uid("foobar ")]) +@create_uid_certification(issuer=Username("main1"), certified=Username("foobar"), uid=Uid("foobar ")) +@create_uid_certification(issuer=Username("main2"), certified=Username("foobar"), uid=Uid("foobar ")) +@create_uid_certification(issuer=Username("main3"), certified=Username("foobar"), uid=Uid("foobar ")) +def test_certificate_trust_three_main_signature_gives_full_trust(working_dir: Path, keyring_dir: Path): + trust = certificate_trust( + test_keyring_certificates[Username("foobar")][0], + test_main_fingerprints, + ) + assert Trust.full == trust