Files
praxis/tests/test_vc_issuer.py
T
Praxis CI 813bd586d6 docs(milestone): merge v0.3-mastery-scoring → main
v0.3 milestone merged to main. Mastery scoring + competency rubrics +
verifiable credentials (formative-tier) shipped. 13/13 REQ-IDs covered.
Next milestone: v0.4 (operator tier — cohort dashboard + auth + Postgres).

---ci---
project: praxis
phase: 2
milestone: v0.3
status: complete
milestone_complete: true
milestone_merged_to_main: true
---/ci---
2026-08-04 00:14:59 +00:00

186 lines
5.7 KiB
Python

"""VC issuer unit tests (SLICE-09 TASK-09-05).
Covers: key generation, sign/verify round-trip, tamper detection (flip a byte
in payload → verify fails), JCS canonicalization determinism (same dict → same
bytes, run twice), status list set/get, revocation invalidates verification.
"""
from __future__ import annotations
import asyncio
import base64
import json
from pathlib import Path
import nacl.signing
import pytest
from db.migrate import apply_migrations
from db.store import PraxisStore
from server.vc import issuer, issuer_keys
from server.vc.status_list import BitstringStatusList
def _await(coro):
return asyncio.run(coro)
@pytest.fixture
def tmp_db(tmp_path: Path) -> Path:
return tmp_path / "test_vc.db"
def _make_store(db_path: Path) -> PraxisStore:
apply_migrations(db_path)
return PraxisStore(db_path)
def test_init_issuer_key_generates_ed25519_keypair(tmp_db: Path):
store = _make_store(tmp_db)
root = b"k" * 32
kp = _await(issuer_keys.init_issuer_key(store, root))
assert kp.key_id.startswith("key-")
assert len(kp.public_key_b64) > 0
pk_bytes = base64.b64decode(kp.public_key_b64)
assert len(pk_bytes) == 32
assert bytes(kp.verify_key) == pk_bytes
def test_sign_verify_round_trip(tmp_db: Path):
store = _make_store(tmp_db)
root = b"k" * 32
kp = _await(issuer_keys.init_issuer_key(store, root))
payload = issuer.build_vc_payload(
learner_ref="learner-1",
path="customer-service",
scenarios_passed=["s1", "s2", "s3"],
rubric_score=4.1,
completed_weeks=6,
evidence=[{"type": "Evidence", "rubricMean": 4.1}],
status_list_index=0,
)
secured, sig_b64 = issuer.sign(payload, kp.signing_key, kp.key_id)
assert issuer.verify_proof(secured, kp.verify_key) is True
sig = base64.b64decode(sig_b64)
assert len(sig) == 64
def test_tamper_detection_flipped_byte_fails(tmp_db: Path):
store = _make_store(tmp_db)
root = b"k" * 32
kp = _await(issuer_keys.init_issuer_key(store, root))
payload = issuer.build_vc_payload(
learner_ref="learner-1",
path="customer-service",
scenarios_passed=["s1"],
rubric_score=3.8,
completed_weeks=6,
evidence=[],
status_list_index=0,
)
secured, _ = issuer.sign(payload, kp.signing_key, kp.key_id)
secured["credentialSubject"]["rubricScore"] = 1.1
assert issuer.verify_proof(secured, kp.verify_key) is False
def test_tamper_proof_value_fails(tmp_db: Path):
store = _make_store(tmp_db)
root = b"k" * 32
kp = _await(issuer_keys.init_issuer_key(store, root))
payload = issuer.build_vc_payload(
learner_ref="learner-1",
path="customer-service",
scenarios_passed=["s1"],
rubric_score=3.8,
completed_weeks=6,
evidence=[],
status_list_index=0,
)
secured, sig_b64 = issuer.sign(payload, kp.signing_key, kp.key_id)
flipped = bytearray(base64.b64decode(sig_b64))
flipped[0] ^= 0x01
secured["proof"]["proofValue"] = base64.b64encode(bytes(flipped)).decode("ascii")
assert issuer.verify_proof(secured, kp.verify_key) is False
def test_jcs_canonicalization_determinism():
d = {
"b": 2,
"a": 1,
"nested": {"z": [3, 2, 1], "y": "hello"},
}
c1 = issuer.canonicalize(d)
c2 = issuer.canonicalize(d)
assert c1 == c2
parsed = json.loads(c1.decode("utf-8"))
assert parsed == {"a": 1, "b": 2, "nested": {"y": "hello", "z": [3, 2, 1]}}
def test_jcs_key_ordering_is_sorted():
d = {"zeta": 1, "alpha": 2, "mid": 3}
c = issuer.canonicalize(d)
text = c.decode("utf-8")
assert text.index('"alpha"') < text.index('"mid"') < text.index('"zeta"')
def test_status_list_set_get_round_trip(tmp_db: Path):
store = _make_store(tmp_db)
sl = BitstringStatusList(store, "default")
_await(sl.set_status(5, True))
assert _await(sl.get_status(5)) is True
assert _await(sl.get_status(6)) is False
_await(sl.set_status(5, False))
assert _await(sl.get_status(5)) is False
def test_status_list_allocate_slot_returns_free_index(tmp_db: Path):
store = _make_store(tmp_db)
sl = BitstringStatusList(store, "default")
s1 = _await(sl.allocate_slot())
s2 = _await(sl.allocate_slot())
assert s1 == 0
assert s2 == 1
def test_revocation_invalidates_verification(tmp_db: Path):
store = _make_store(tmp_db)
root = b"k" * 32
kp = _await(issuer_keys.init_issuer_key(store, root))
cred_id = _await(
issuer.issue_credential(
store=store,
signing_key=kp.signing_key,
key_id=kp.key_id,
learner_id="learner-1",
path="customer-service",
scenarios_passed=["s1", "s2", "s3"],
rubric_score=4.1,
completed_weeks=6,
evidence=[{"type": "Evidence", "rubricMean": 4.1}],
)
)
row = _await(store.get_credential(cred_id))
assert row is not None
secured = json.loads(row["vc_payload_json"])
assert issuer.verify_proof(secured, kp.verify_key) is True
cs = secured["credentialStatus"]
idx = int(cs["statusListIndex"])
sl = BitstringStatusList(store, "default")
_await(sl.set_status(idx, True))
_await(store.set_credential_status(cred_id, "revoked"))
revoked = _await(sl.get_status(idx))
assert revoked is True
def test_credential_tier_is_formative_in_payload():
payload = issuer.build_vc_payload(
learner_ref="learner-1",
path="customer-service",
scenarios_passed=["s1"],
rubric_score=4.0,
completed_weeks=6,
evidence=[],
status_list_index=0,
)
assert payload["credentialTier"] == "formative"
assert payload["credentialSubject"]["credentialTier"] == "formative"