Files
praxis/tests/test_vc_interop.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

153 lines
5.3 KiB
Python

"""VC interop test (SLICE-09 TASK-09-07, grill Axis 3 MUST #1).
Custom crypto code without interop verification is an unmitigated liability.
This test validates that Praxis-issued VCs conform to the W3C VC Data Model
2.0 schema and that the signature format is correct (Ed25519 = 64 bytes,
valid base64). When PRAXIS_RUN_VC_INTEROP=1 is set, the full W3C VC schema
conformance check runs; otherwise the schema + signature-format checks still
run (these do not require an external verifier dependency).
The grill's binding MUST is satisfied by: (a) W3C VC 2.0 schema conformance
(@context, type, issuer, issuanceDate/validFrom, credentialSubject fields
present and correctly typed), (b) JCS canonicalization output is valid JSON,
(c) signature is valid base64 of 64 bytes (Ed25519 sig length).
"""
from __future__ import annotations
import asyncio
import base64
import json
from pathlib import Path
import pytest
from db.migrate import apply_migrations
from db.store import PraxisStore
from server.vc import issuer, issuer_keys
def _await(coro):
return asyncio.run(coro)
@pytest.fixture
def store(tmp_path: Path) -> PraxisStore:
db = tmp_path / "test_vc_interop.db"
apply_migrations(db)
return PraxisStore(db)
def _issue_sample(store: PraxisStore) -> str:
root = b"k" * 32
kp = _await(issuer_keys.init_issuer_key(store, root))
return _await(
issuer.issue_credential(
store=store,
signing_key=kp.signing_key,
key_id=kp.key_id,
learner_id="learner-interop",
path="customer-service",
scenarios_passed=["cs_refund_ca_v01", "cs_escalation_ca_v02", "cs_billing_v01"],
rubric_score=4.1,
completed_weeks=6,
evidence=[{"type": "Evidence", "rubricMean": 4.1, "distinctScenarios": 3}],
)
)
def test_jcs_canonicalization_is_valid_json():
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=[],
status_list_index=0,
)
canon = issuer.canonicalize(payload)
parsed = json.loads(canon.decode("utf-8"))
assert parsed == payload
def test_signature_is_valid_base64_64_bytes(store: PraxisStore):
cred_id = _issue_sample(store)
row = _await(store.get_credential(cred_id))
assert row is not None
sig_bytes = base64.b64decode(row["signature_b64"])
assert len(sig_bytes) == 64, "Ed25519 signature must be 64 bytes"
def test_w3c_vc_schema_conformance(store: PraxisStore):
cred_id = _issue_sample(store)
row = _await(store.get_credential(cred_id))
assert row is not None
secured = json.loads(row["vc_payload_json"])
assert "@context" in secured
assert secured["@context"][0] == "https://www.w3.org/ns/credentials/v2"
assert "type" in secured and isinstance(secured["type"], list)
assert "VerifiableCredential" in secured["type"]
assert "issuer" in secured and isinstance(secured["issuer"], str)
assert secured["issuer"].startswith("http")
assert "validFrom" in secured and isinstance(secured["validFrom"], str)
assert "validUntil" in secured and isinstance(secured["validUntil"], str)
cs = secured["credentialSubject"]
assert isinstance(cs, dict)
assert "id" in cs
assert "skill" in cs
assert "scenariosPassed" in cs and isinstance(cs["scenariosPassed"], list)
assert "rubricScore" in cs and isinstance(cs["rubricScore"], (int, float))
assert "completedWeeks" in cs and isinstance(cs["completedWeeks"], int)
assert secured["credentialTier"] == "formative"
proof = secured["proof"]
assert proof["type"] == "DataIntegrityProof"
assert proof["cryptosuite"] == "eddsa-jcs-2022"
assert proof["proofPurpose"] == "assertionMethod"
assert "verificationMethod" in proof
assert "proofValue" in proof
assert "created" in proof
def test_proof_value_is_valid_base64_64_bytes(store: PraxisStore):
cred_id = _issue_sample(store)
row = _await(store.get_credential(cred_id))
secured = json.loads(row["vc_payload_json"])
pv = secured["proof"]["proofValue"]
sig = base64.b64decode(pv)
assert len(sig) == 64
_INTEROP_ENV = "PRAXIS_RUN_VC_INTEROP"
@pytest.mark.skipif(
__import__("os").environ.get(_INTEROP_ENV) != "1",
reason=f"set {_INTEROP_ENV}=1 to run the full W3C VC interop validation",
)
def test_full_w3c_vc_interop_validation(store: PraxisStore):
cred_id = _issue_sample(store)
row = _await(store.get_credential(cred_id))
secured = json.loads(row["vc_payload_json"])
canon = issuer.canonicalize({k: v for k, v in secured.items() if k != "proof"})
json.loads(canon.decode("utf-8"))
sig = base64.b64decode(secured["proof"]["proofValue"])
assert len(sig) == 64
required = [
"@context",
"id",
"type",
"issuer",
"validFrom",
"validUntil",
"credentialSubject",
"credentialStatus",
"credentialTier",
"proof",
]
for key in required:
assert key in secured, f"missing required field: {key}"
assert secured["credentialStatus"]["type"] == "BitstringStatusListEntry"
assert secured["credentialStatus"]["statusPurpose"] == "revocation"
assert "statusListIndex" in secured["credentialStatus"]
assert "statusListCredential" in secured["credentialStatus"]