97 lines
3.2 KiB
Python
97 lines
3.2 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from datetime import datetime, timedelta, timezone
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from maturity_engine.compute import compute_level
|
||
|
|
from maturity_engine.errors import UnevaluableCriterion
|
||
|
|
from maturity_engine.models import Criterion, Evidence, Ladder, Level
|
||
|
|
from conftest import INSTANT, evidence
|
||
|
|
|
||
|
|
|
||
|
|
def test_floor_level_with_no_evidence(engine):
|
||
|
|
assessment = engine.assess("estate", "asm", at=INSTANT)
|
||
|
|
assert assessment.level == 0
|
||
|
|
assert assessment.level_id == "ASM-0"
|
||
|
|
assert assessment.next_level_id == "ASM-1"
|
||
|
|
assert assessment.unmet
|
||
|
|
|
||
|
|
|
||
|
|
def test_determinism_same_inputs_same_assessment(engine):
|
||
|
|
kinds = ["central-secret-manager", "secret-rbac", "basic-secret-scanning", "basic-rotation"]
|
||
|
|
for kind in kinds:
|
||
|
|
engine.submit_evidence(evidence(engine, "estate", kind))
|
||
|
|
first = engine.assess("estate", "asm", at=INSTANT)
|
||
|
|
second = engine.assess("estate", "asm", at=INSTANT)
|
||
|
|
assert first.id == second.id
|
||
|
|
assert first.level == second.level == 1
|
||
|
|
assert first.as_explanation() == second.as_explanation()
|
||
|
|
|
||
|
|
|
||
|
|
def test_explainability_lists_met_and_next(engine):
|
||
|
|
engine.submit_evidence(evidence(engine, "estate", "central-secret-manager"))
|
||
|
|
assessment = engine.assess("estate", "asm", at=INSTANT)
|
||
|
|
assert assessment.level == 0
|
||
|
|
kinds_unmet = {item.evidence_kind for item in assessment.unmet}
|
||
|
|
assert "secret-rbac" in kinds_unmet
|
||
|
|
explanation = assessment.as_explanation()
|
||
|
|
assert explanation["met"] == []
|
||
|
|
assert explanation["next_level_id"] == "ASM-1"
|
||
|
|
|
||
|
|
|
||
|
|
def test_expiry_demotes_a_subject(engine):
|
||
|
|
until = INSTANT + timedelta(hours=1)
|
||
|
|
kinds = ["central-secret-manager", "secret-rbac", "basic-secret-scanning", "basic-rotation"]
|
||
|
|
for kind in kinds:
|
||
|
|
engine.submit_evidence(evidence(engine, "estate", kind, until=until))
|
||
|
|
high = engine.assess("estate", "asm", at=INSTANT)
|
||
|
|
assert high.level == 1
|
||
|
|
later = INSTANT + timedelta(hours=2)
|
||
|
|
low = engine.assess("estate", "asm", at=later)
|
||
|
|
assert low.level == 0
|
||
|
|
assert low.level_id == "ASM-0"
|
||
|
|
assert low.expired_evidence_ids
|
||
|
|
history = engine.history("estate", "asm")
|
||
|
|
assert [item.level for item in history] == [1, 0]
|
||
|
|
|
||
|
|
|
||
|
|
def test_unevaluable_criterion_rejected():
|
||
|
|
ladder = Ladder(
|
||
|
|
id="bad",
|
||
|
|
version="1",
|
||
|
|
owner="nobody",
|
||
|
|
name="bad",
|
||
|
|
levels=(
|
||
|
|
Level(0, "L0", "floor", ()),
|
||
|
|
Level(
|
||
|
|
1,
|
||
|
|
"L1",
|
||
|
|
"judged",
|
||
|
|
(Criterion("c1", "judgment", "a human decides"),),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
)
|
||
|
|
with pytest.raises(UnevaluableCriterion):
|
||
|
|
ladder.validate()
|
||
|
|
|
||
|
|
|
||
|
|
def test_compute_is_pure(engine):
|
||
|
|
item = Evidence(
|
||
|
|
id="e1",
|
||
|
|
subject="s",
|
||
|
|
kind="central-secret-manager",
|
||
|
|
submitted_by="t",
|
||
|
|
submitted_at=INSTANT,
|
||
|
|
valid_from=INSTANT,
|
||
|
|
valid_until=None,
|
||
|
|
)
|
||
|
|
ladder = engine.store.get_ladder("asm")
|
||
|
|
a = compute_level(ladder, (item,), subject="s", instant=INSTANT)
|
||
|
|
b = compute_level(ladder, (item,), subject="s", instant=INSTANT)
|
||
|
|
assert a == b
|
||
|
|
other = compute_level(
|
||
|
|
ladder, (item,), subject="s", instant=datetime(2026, 8, 30, tzinfo=timezone.utc)
|
||
|
|
)
|
||
|
|
assert other.id != a.id
|