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---
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user