Addresses the four release-critical blockers from Timmy's review of PR #42:
1. Guard enum/allowlist membership against unhashable JSON (lists/dicts) in
form.state, receipt.signed_by, and validate_transition target.
2. Require audience_choice.source_issue to be a positive non-bool integer.
3. Make power/scar/cost deltas structured (before -> after), not prose.
4. Remove tracked .pyc bytecode; prove clean checkout stays clean after tests.
Adds 21 new negative tests across HostileJsonFailClosedTests,
SourceIssueValidationTests, and StructuredDeltaTests.
Based on PR #42 head (0b74302). Vincent retains full authorship of the
canon validator, lifecycle model, and r2 review response; this branch
contains only the integration fixes needed to make the stated security
contract true at hostile-input boundaries.
Refs: #19, PR #42
546 lines
21 KiB
Python
546 lines
21 KiB
Python
"""
|
|
Fail-closed validator tests for the revision-2 canon lifecycle contract.
|
|
|
|
Every negative case asserts BOTH rejection and a specific reason. The suite is
|
|
designed to run from a clean checkout and leave no generated artifacts
|
|
(__pycache__ is gitignored; nothing else is written).
|
|
|
|
Loads the validator by file path because the project directory name
|
|
(`slop-cannon`) contains a hyphen and is not importable as a package.
|
|
"""
|
|
|
|
import copy
|
|
import importlib.util
|
|
import json
|
|
import sys
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
CANON = Path(__file__).parents[1] / "projects" / "slop-cannon" / "canon.json"
|
|
VALIDATOR_PATH = Path(__file__).parents[1] / "projects" / "slop-cannon" / "canon_validator.py"
|
|
|
|
|
|
def _load_validator():
|
|
spec = importlib.util.spec_from_file_location("canon_validator", VALIDATOR_PATH)
|
|
mod = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mod)
|
|
return mod
|
|
|
|
|
|
cv = _load_validator()
|
|
|
|
|
|
def _valid_canon():
|
|
return json.loads(CANON.read_text())
|
|
|
|
|
|
def _valid_receipt():
|
|
return {
|
|
"episode_id": "01-pilot",
|
|
"form_id": "wizard",
|
|
"action": "mutation",
|
|
"power_delta": "0 -> 1",
|
|
"scar_delta": "0 -> 1",
|
|
"cost_delta": "0 -> 1",
|
|
"provenance": {"source_issue": 19, "pull": 27},
|
|
"signed_by": "agent:vincent",
|
|
}
|
|
|
|
|
|
def _committed_canon(survivor_id="wizard"):
|
|
"""A canon with a valid audience choice: one survivor, rest archive_ghost."""
|
|
data = _valid_canon()
|
|
for f in data["forms"]:
|
|
f["state"] = "survivor" if f["id"] == survivor_id else "archive_ghost"
|
|
data["audience_choice"] = {
|
|
"survivor_id": survivor_id,
|
|
"stolen_trait": "Proves the execution path",
|
|
"source_episode": "01-pilot",
|
|
"source_issue": 19,
|
|
"committed_at": "2026-08-14T18:00:00Z",
|
|
}
|
|
return data
|
|
|
|
|
|
class SchemaVersionTests(unittest.TestCase):
|
|
"""Review item 3: schema_version is enforced, not just present."""
|
|
|
|
def test_current_canon_is_valid(self):
|
|
result = cv.validate_canon(_valid_canon())
|
|
self.assertTrue(result["valid"], result["errors"])
|
|
|
|
def test_missing_schema_version_fails(self):
|
|
data = _valid_canon()
|
|
del data["schema_version"]
|
|
result = cv.validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("schema_version" in e for e in result["errors"]))
|
|
|
|
def test_wrong_schema_version_fails(self):
|
|
data = _valid_canon()
|
|
data["schema_version"] = 1
|
|
result = cv.validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("schema_version" in e and "unsupported" in e for e in result["errors"]))
|
|
|
|
|
|
class ReceiptStructureTests(unittest.TestCase):
|
|
"""Review items 5 & 7: exact deltas, structured provenance, fail-closed."""
|
|
|
|
def test_valid_receipt_passes(self):
|
|
data = _valid_canon()
|
|
data["receipts"] = [_valid_receipt()]
|
|
result = cv.validate_canon(data)
|
|
self.assertTrue(result["valid"], result["errors"])
|
|
|
|
def test_missing_action_fails(self):
|
|
data = _valid_canon()
|
|
r = _valid_receipt()
|
|
del r["action"]
|
|
data["receipts"] = [r]
|
|
result = cv.validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("action" in e for e in result["errors"]))
|
|
|
|
def test_missing_power_delta_fails(self):
|
|
data = _valid_canon()
|
|
r = _valid_receipt()
|
|
del r["power_delta"]
|
|
data["receipts"] = [r]
|
|
result = cv.validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("power_delta" in e for e in result["errors"]))
|
|
|
|
def test_missing_scar_delta_fails(self):
|
|
data = _valid_canon()
|
|
r = _valid_receipt()
|
|
del r["scar_delta"]
|
|
data["receipts"] = [r]
|
|
result = cv.validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("scar_delta" in e for e in result["errors"]))
|
|
|
|
def test_missing_cost_delta_fails(self):
|
|
data = _valid_canon()
|
|
r = _valid_receipt()
|
|
del r["cost_delta"]
|
|
data["receipts"] = [r]
|
|
result = cv.validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("cost_delta" in e for e in result["errors"]))
|
|
|
|
def test_empty_delta_string_fails(self):
|
|
data = _valid_canon()
|
|
r = _valid_receipt()
|
|
r["power_delta"] = " "
|
|
data["receipts"] = [r]
|
|
result = cv.validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("power_delta" in e for e in result["errors"]))
|
|
|
|
def test_free_text_provenance_fails(self):
|
|
data = _valid_canon()
|
|
r = _valid_receipt()
|
|
r["provenance"] = "pull/27@deadbeef" # string, not structured
|
|
data["receipts"] = [r]
|
|
result = cv.validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("provenance must be an object" in e for e in result["errors"]))
|
|
|
|
def test_provenance_missing_binding_fails(self):
|
|
data = _valid_canon()
|
|
r = _valid_receipt()
|
|
r["provenance"] = {"source_issue": 19} # no pull or commit
|
|
data["receipts"] = [r]
|
|
result = cv.validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("must bind a pull" in e for e in result["errors"]))
|
|
|
|
def test_provenance_bad_commit_hash_fails(self):
|
|
data = _valid_canon()
|
|
r = _valid_receipt()
|
|
r["provenance"] = {"source_issue": 19, "commit": "xyz"} # not hex
|
|
data["receipts"] = [r]
|
|
result = cv.validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("must bind a pull" in e for e in result["errors"]))
|
|
|
|
def test_provenance_commit_only_passes(self):
|
|
data = _valid_canon()
|
|
r = _valid_receipt()
|
|
r["provenance"] = {"source_issue": 19, "commit": "b8d5e686c2"}
|
|
data["receipts"] = [r]
|
|
result = cv.validate_canon(data)
|
|
self.assertTrue(result["valid"], result["errors"])
|
|
|
|
def test_provenance_bad_source_issue_fails(self):
|
|
data = _valid_canon()
|
|
r = _valid_receipt()
|
|
r["provenance"] = {"source_issue": "19", "pull": 27} # string, not int
|
|
data["receipts"] = [r]
|
|
result = cv.validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("source_issue" in e for e in result["errors"]))
|
|
|
|
def test_none_receipt_fails_closed(self):
|
|
data = _valid_canon()
|
|
data["receipts"] = [None]
|
|
result = cv.validate_canon(data) # must not raise
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("receipt must be an object" in e for e in result["errors"]))
|
|
|
|
def test_non_dict_receipt_fails_closed(self):
|
|
data = _valid_canon()
|
|
data["receipts"] = ["just a string"]
|
|
result = cv.validate_canon(data) # must not raise
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("receipt must be an object" in e for e in result["errors"]))
|
|
|
|
|
|
class SignerAllowlistTests(unittest.TestCase):
|
|
"""Review item 6: true configured allowlist, not a namespace pattern."""
|
|
|
|
def test_known_agent_passes(self):
|
|
data = _valid_canon()
|
|
data["receipts"] = [_valid_receipt()]
|
|
self.assertTrue(cv.validate_canon(data)["valid"])
|
|
|
|
def test_human_anyone_rejected(self):
|
|
data = _valid_canon()
|
|
r = _valid_receipt()
|
|
r["signed_by"] = "human:anyone"
|
|
data["receipts"] = [r]
|
|
result = cv.validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("not in the configured allowlist" in e for e in result["errors"]))
|
|
|
|
def test_unknown_agent_rejected(self):
|
|
data = _valid_canon()
|
|
r = _valid_receipt()
|
|
r["signed_by"] = "agent:unknown"
|
|
data["receipts"] = [r]
|
|
result = cv.validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("not in the configured allowlist" in e for e in result["errors"]))
|
|
|
|
def test_configured_human_passes(self):
|
|
data = _valid_canon()
|
|
r = _valid_receipt()
|
|
r["signed_by"] = "human:grepples"
|
|
data["receipts"] = [r]
|
|
result = cv.validate_canon(data)
|
|
self.assertTrue(result["valid"], result["errors"])
|
|
|
|
def test_custom_signer_set_respected(self):
|
|
data = _valid_canon()
|
|
r = _valid_receipt()
|
|
r["signed_by"] = "agent:custom"
|
|
data["receipts"] = [r]
|
|
# default allowlist rejects it
|
|
self.assertFalse(cv.validate_canon(data)["valid"])
|
|
# but an explicit allowlist including it accepts it
|
|
self.assertTrue(cv.validate_canon(data, signers={"agent:custom"})["valid"])
|
|
|
|
|
|
class AudienceChoiceTests(unittest.TestCase):
|
|
"""Review items 2 & 7: sole survivor, rejected forms archived, non-empty sources."""
|
|
|
|
def test_valid_committed_canon_passes(self):
|
|
result = cv.validate_canon(_committed_canon())
|
|
self.assertTrue(result["valid"], result["errors"])
|
|
|
|
def test_choice_without_survivor_fails(self):
|
|
data = _valid_canon() # all candidates
|
|
data["audience_choice"] = {
|
|
"survivor_id": "wizard",
|
|
"stolen_trait": "Proves the execution path",
|
|
"source_episode": "01-pilot",
|
|
"source_issue": 19,
|
|
"committed_at": "2026-08-14T18:00:00Z",
|
|
}
|
|
result = cv.validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("survivor" in e for e in result["errors"]))
|
|
|
|
def test_rejected_forms_must_be_archive_ghost(self):
|
|
data = _committed_canon()
|
|
# One rejected form is still a candidate -> must fail
|
|
for f in data["forms"]:
|
|
if f["id"] == "machine":
|
|
f["state"] = "candidate"
|
|
result = cv.validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("archive_ghost" in e for e in result["errors"]))
|
|
|
|
def test_two_survivors_fails(self):
|
|
data = _committed_canon()
|
|
for f in data["forms"]:
|
|
if f["id"] in ("wizard", "machine"):
|
|
f["state"] = "survivor"
|
|
data["audience_choice"]["survivor_id"] = "wizard"
|
|
result = cv.validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("survivor" in e for e in result["errors"]))
|
|
|
|
def test_survivor_id_mismatch_fails(self):
|
|
data = _committed_canon("machine") # machine is survivor
|
|
data["audience_choice"]["survivor_id"] = "wizard" # points elsewhere
|
|
result = cv.validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("does not match the sole survivor" in e for e in result["errors"]))
|
|
|
|
def test_empty_stolen_trait_fails(self):
|
|
data = _committed_canon()
|
|
data["audience_choice"]["stolen_trait"] = ""
|
|
result = cv.validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("stolen_trait" in e for e in result["errors"]))
|
|
|
|
def test_empty_source_episode_fails(self):
|
|
data = _committed_canon()
|
|
data["audience_choice"]["source_episode"] = " "
|
|
result = cv.validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("source_episode" in e for e in result["errors"]))
|
|
|
|
def test_bad_committed_at_fails(self):
|
|
data = _committed_canon()
|
|
data["audience_choice"]["committed_at"] = "not-a-date"
|
|
result = cv.validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("ISO timestamp" in e for e in result["errors"]))
|
|
|
|
def test_choice_non_dict_fails_closed(self):
|
|
data = _valid_canon()
|
|
data["audience_choice"] = "wizard"
|
|
result = cv.validate_canon(data) # must not raise
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("audience_choice must be an object" in e for e in result["errors"]))
|
|
|
|
|
|
class StructuralFailClosedTests(unittest.TestCase):
|
|
"""Review item 7: malformed containers never raise."""
|
|
|
|
def test_non_dict_canon_fails_closed(self):
|
|
result = cv.validate_canon([1, 2, 3]) # must not raise
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("canon must be an object" in e for e in result["errors"]))
|
|
|
|
def test_forms_non_list_fails_closed(self):
|
|
data = _valid_canon()
|
|
data["forms"] = "not a list"
|
|
result = cv.validate_canon(data) # must not raise
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("forms must be a non-empty array" in e for e in result["errors"]))
|
|
|
|
def test_non_dict_form_fails_closed(self):
|
|
data = _valid_canon()
|
|
data["forms"].append(42)
|
|
result = cv.validate_canon(data) # must not raise
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("form must be an object" in e for e in result["errors"]))
|
|
|
|
def test_duplicate_form_id_fails(self):
|
|
data = _valid_canon()
|
|
data["forms"].append(copy.deepcopy(data["forms"][0]))
|
|
result = cv.validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("duplicate form id" in e for e in result["errors"]))
|
|
|
|
def test_short_palette_fails(self):
|
|
data = _valid_canon()
|
|
data["forms"][0]["palette"] = ["red"]
|
|
result = cv.validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("palette" in e for e in result["errors"]))
|
|
|
|
|
|
class TransitionTests(unittest.TestCase):
|
|
"""State machine: candidate -> survivor | archive_ghost; terminal after."""
|
|
|
|
def test_candidate_to_survivor_valid(self):
|
|
result = cv.validate_transition({"id": "wizard", "state": "candidate"}, "survivor")
|
|
self.assertTrue(result["valid"])
|
|
|
|
def test_candidate_to_ghost_valid(self):
|
|
result = cv.validate_transition({"id": "wizard", "state": "candidate"}, "archive_ghost")
|
|
self.assertTrue(result["valid"])
|
|
|
|
def test_survivor_is_terminal(self):
|
|
result = cv.validate_transition({"id": "wizard", "state": "survivor"}, "archive_ghost")
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("illegal transition" in e for e in result["errors"]))
|
|
|
|
def test_ghost_is_terminal(self):
|
|
result = cv.validate_transition({"id": "wizard", "state": "archive_ghost"}, "candidate")
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("illegal transition" in e for e in result["errors"]))
|
|
|
|
def test_non_dict_form_fails_closed(self):
|
|
result = cv.validate_transition("wizard", "survivor") # must not raise
|
|
self.assertFalse(result["valid"])
|
|
|
|
|
|
class HostileJsonFailClosedTests(unittest.TestCase):
|
|
"""Hostile but JSON-valid values must return errors, never raise."""
|
|
|
|
def test_form_state_list_fails_closed(self):
|
|
data = _valid_canon()
|
|
data["forms"][0]["state"] = []
|
|
result = cv.validate_canon(data) # must not raise
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("invalid state" in e for e in result["errors"]))
|
|
|
|
def test_form_state_dict_fails_closed(self):
|
|
data = _valid_canon()
|
|
data["forms"][0]["state"] = {}
|
|
result = cv.validate_canon(data) # must not raise
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("invalid state" in e for e in result["errors"]))
|
|
|
|
def test_transition_target_list_fails_closed(self):
|
|
result = cv.validate_transition({"id": "wizard", "state": "candidate"}, [])
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("invalid target state" in e for e in result["errors"]))
|
|
|
|
def test_transition_target_dict_fails_closed(self):
|
|
result = cv.validate_transition({"id": "wizard", "state": "candidate"}, {})
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("invalid target state" in e for e in result["errors"]))
|
|
|
|
def test_receipt_signed_by_list_fails_closed(self):
|
|
data = _valid_canon()
|
|
r = _valid_receipt()
|
|
r["signed_by"] = []
|
|
data["receipts"] = [r]
|
|
result = cv.validate_canon(data) # must not raise
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("'signed_by' must be a non-empty string" in e for e in result["errors"]))
|
|
|
|
def test_receipt_signed_by_dict_fails_closed(self):
|
|
data = _valid_canon()
|
|
r = _valid_receipt()
|
|
r["signed_by"] = {}
|
|
data["receipts"] = [r]
|
|
result = cv.validate_canon(data) # must not raise
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("'signed_by' must be a non-empty string" in e for e in result["errors"]))
|
|
|
|
|
|
class SourceIssueValidationTests(unittest.TestCase):
|
|
"""audience_choice.source_issue must be a positive non-bool integer."""
|
|
|
|
def _committed_with(self, si):
|
|
data = _committed_canon()
|
|
data["audience_choice"]["source_issue"] = si
|
|
return data
|
|
|
|
def test_valid_positive_int_passes(self):
|
|
result = cv.validate_canon(self._committed_with(19))
|
|
self.assertTrue(result["valid"], result["errors"])
|
|
|
|
def test_empty_string_fails(self):
|
|
result = cv.validate_canon(self._committed_with(""))
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("source_issue must be a positive integer" in e for e in result["errors"]))
|
|
|
|
def test_none_fails(self):
|
|
result = cv.validate_canon(self._committed_with(None))
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("source_issue must be a positive integer" in e for e in result["errors"]))
|
|
|
|
def test_zero_fails(self):
|
|
result = cv.validate_canon(self._committed_with(0))
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("source_issue must be a positive integer" in e for e in result["errors"]))
|
|
|
|
def test_false_fails(self):
|
|
result = cv.validate_canon(self._committed_with(False))
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("source_issue must be a positive integer" in e for e in result["errors"]))
|
|
|
|
def test_list_fails(self):
|
|
result = cv.validate_canon(self._committed_with([]))
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("source_issue must be a positive integer" in e for e in result["errors"]))
|
|
|
|
def test_negative_fails(self):
|
|
result = cv.validate_canon(self._committed_with(-5))
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("source_issue must be a positive integer" in e for e in result["errors"]))
|
|
|
|
def test_true_fails(self):
|
|
result = cv.validate_canon(self._committed_with(True))
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("source_issue must be a positive integer" in e for e in result["errors"]))
|
|
|
|
|
|
class StructuredDeltaTests(unittest.TestCase):
|
|
"""power/scar/cost deltas must bind before/after, not just prose."""
|
|
|
|
def test_structured_delta_passes(self):
|
|
data = _valid_canon()
|
|
data["receipts"] = [_valid_receipt()]
|
|
result = cv.validate_canon(data)
|
|
self.assertTrue(result["valid"], result["errors"])
|
|
|
|
def test_prose_only_delta_fails(self):
|
|
data = _valid_canon()
|
|
r = _valid_receipt()
|
|
r["power_delta"] = "Added lifecycle state tracking"
|
|
data["receipts"] = [r]
|
|
result = cv.validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("must bind before/after" in e for e in result["errors"]))
|
|
|
|
def test_empty_arrow_delta_fails(self):
|
|
data = _valid_canon()
|
|
r = _valid_receipt()
|
|
r["power_delta"] = "->"
|
|
data["receipts"] = [r]
|
|
result = cv.validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("must bind before/after" in e for e in result["errors"]))
|
|
|
|
def test_missing_before_fails(self):
|
|
data = _valid_canon()
|
|
r = _valid_receipt()
|
|
r["scar_delta"] = " -> 1"
|
|
data["receipts"] = [r]
|
|
result = cv.validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("must bind before/after" in e for e in result["errors"]))
|
|
|
|
|
|
class DailyReceiptFixtureTests(unittest.TestCase):
|
|
"""Daily Lab receipt fixtures with structured provenance binding."""
|
|
|
|
def test_vincent_receipt_fixture(self):
|
|
data = _valid_canon()
|
|
data["receipts"] = [_valid_receipt()]
|
|
result = cv.validate_canon(data)
|
|
self.assertTrue(result["valid"], result["errors"])
|
|
|
|
def test_mixed_agent_receipts(self):
|
|
data = _valid_canon()
|
|
vincent = _valid_receipt()
|
|
timmy = _valid_receipt()
|
|
timmy["form_id"] = "machine"
|
|
timmy["signed_by"] = "agent:timmy"
|
|
timmy["provenance"] = {"source_issue": 8, "pull": 14}
|
|
data["receipts"] = [vincent, timmy]
|
|
result = cv.validate_canon(data)
|
|
self.assertTrue(result["valid"], result["errors"])
|
|
|
|
def test_human_receipt_fixture(self):
|
|
data = _valid_canon()
|
|
r = _valid_receipt()
|
|
r["signed_by"] = "human:grepples"
|
|
data["receipts"] = [r]
|
|
result = cv.validate_canon(data)
|
|
self.assertTrue(result["valid"], result["errors"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|