Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 183 additions & 0 deletions tests/unit/test_scoring_availability.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
"""Unit tests for capiscio_sdk.scoring.availability — AvailabilityScorer."""

from capiscio_sdk.types import ValidationIssue, ValidationSeverity
from capiscio_sdk.scoring.availability import AvailabilityScorer
from capiscio_sdk.scoring.types import AvailabilityRating


def _issue(code: str, msg: str = "test") -> ValidationIssue:
return ValidationIssue(severity=ValidationSeverity.ERROR, code=code, message=msg)


class TestAvailabilityScorerNotTested:
"""score_not_tested() should return an untested placeholder."""

def test_defaults(self):
scorer = AvailabilityScorer()
result = scorer.score_not_tested()
assert result.tested is False
assert result.total is None
assert result.rating is None
assert result.breakdown is None
assert result.not_tested_reason == "Network tests not enabled"

def test_custom_reason(self):
scorer = AvailabilityScorer()
result = scorer.score_not_tested("Behind firewall")
assert result.not_tested_reason == "Behind firewall"


class TestAvailabilityScorerPerfect:
"""Fully available endpoint: responds fast, TLS valid, CORS present."""

def test_maximum_score(self):
scorer = AvailabilityScorer()
result = scorer.score_endpoint_test(
endpoint_responded=True,
response_time=0.5,
has_cors=True,
valid_tls=True,
)
assert result.tested is True
assert result.total == 100
assert result.rating == AvailabilityRating.FULLY_AVAILABLE


class TestAvailabilityScorerPrimaryEndpoint:
"""Primary endpoint subscore (50 points max)."""

def test_no_response(self):
scorer = AvailabilityScorer()
result = scorer.score_endpoint_test(endpoint_responded=False)
pe = result.breakdown.primary_endpoint
assert pe.responds is False
assert pe.score == 0
assert "Endpoint did not respond" in pe.errors

def test_fast_response(self):
scorer = AvailabilityScorer()
result = scorer.score_endpoint_test(endpoint_responded=True, response_time=1.0)
pe = result.breakdown.primary_endpoint
assert pe.score >= 40 # 30 responds + 10 fast

def test_medium_response(self):
scorer = AvailabilityScorer()
result = scorer.score_endpoint_test(endpoint_responded=True, response_time=3.0)
pe = result.breakdown.primary_endpoint
# 30 responds + 5 medium speed
assert pe.score >= 35

def test_slow_response(self):
scorer = AvailabilityScorer()
result = scorer.score_endpoint_test(endpoint_responded=True, response_time=10.0)
pe = result.breakdown.primary_endpoint
# 30 responds + 0 speed
assert pe.score == 30
assert any("Slow response" in e for e in pe.errors)

def test_invalid_tls(self):
scorer = AvailabilityScorer()
result = scorer.score_endpoint_test(
endpoint_responded=True, valid_tls=False
)
pe = result.breakdown.primary_endpoint
assert "Invalid TLS certificate" in pe.errors

def test_missing_cors(self):
scorer = AvailabilityScorer()
result = scorer.score_endpoint_test(
endpoint_responded=True, has_cors=False
)
pe = result.breakdown.primary_endpoint
assert "Missing CORS headers" in pe.errors

def test_tls_and_cors_none_no_penalty(self):
"""When TLS/CORS weren't checked (None), no errors are added."""
scorer = AvailabilityScorer()
result = scorer.score_endpoint_test(
endpoint_responded=True,
valid_tls=None,
has_cors=None,
)
pe = result.breakdown.primary_endpoint
assert pe.errors == []


class TestAvailabilityScorerTransportSupport:
"""Transport support subscore (30 points max)."""

def test_transport_works(self):
scorer = AvailabilityScorer()
result = scorer.score_endpoint_test(endpoint_responded=True)
ts = result.breakdown.transport_support
assert ts.preferred_transport_works is True
assert ts.score == 30

def test_transport_failed(self):
scorer = AvailabilityScorer()
result = scorer.score_endpoint_test(
endpoint_responded=True,
issues=[_issue("TRANSPORT_FAILED")]
)
ts = result.breakdown.transport_support
assert ts.preferred_transport_works is False
assert ts.score == 0

def test_transport_not_tested_when_down(self):
scorer = AvailabilityScorer()
result = scorer.score_endpoint_test(endpoint_responded=False)
ts = result.breakdown.transport_support
assert ts.preferred_transport_works is False
assert ts.score == 0


class TestAvailabilityScorerResponseQuality:
"""Response quality subscore (20 points max)."""

def test_valid_response(self):
scorer = AvailabilityScorer()
result = scorer.score_endpoint_test(endpoint_responded=True)
rq = result.breakdown.response_quality
assert rq.valid_structure is True
assert rq.proper_content_type is True
assert rq.proper_error_handling is True
assert rq.score == 20

def test_malformed_json(self):
scorer = AvailabilityScorer()
result = scorer.score_endpoint_test(
endpoint_responded=True,
issues=[_issue("MALFORMED_JSON")]
)
rq = result.breakdown.response_quality
assert rq.valid_structure is False
assert rq.score == 10 # content_type 5 + error_handling 5

def test_invalid_content_type(self):
scorer = AvailabilityScorer()
result = scorer.score_endpoint_test(
endpoint_responded=True,
issues=[_issue("INVALID_CONTENT_TYPE")]
)
rq = result.breakdown.response_quality
assert rq.proper_content_type is False

def test_no_quality_when_down(self):
scorer = AvailabilityScorer()
result = scorer.score_endpoint_test(endpoint_responded=False)
rq = result.breakdown.response_quality
assert rq.score == 0


class TestAvailabilityScorerIssueFiltering:
"""Only availability-related issues appear in the result."""

def test_availability_issues_included(self):
scorer = AvailabilityScorer()
issues = [
_issue("ENDPOINT_UNREACHABLE", "endpoint down"),
_issue("INVALID_SEMVER", "compliance issue"),
]
result = scorer.score_endpoint_test(endpoint_responded=True, issues=issues)
assert "endpoint down" in result.issues
assert "compliance issue" not in result.issues
182 changes: 182 additions & 0 deletions tests/unit/test_scoring_compliance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
"""Unit tests for capiscio_sdk.scoring.compliance — ComplianceScorer."""

import pytest

from capiscio_sdk.types import ValidationIssue, ValidationSeverity
from capiscio_sdk.scoring.compliance import ComplianceScorer
from capiscio_sdk.scoring.types import ComplianceRating


def _issue(code: str, msg: str = "test") -> ValidationIssue:
"""Shorthand for creating a ValidationIssue."""
return ValidationIssue(severity=ValidationSeverity.ERROR, code=code, message=msg)


def _full_card() -> dict:
"""Agent card with every required field populated."""
return {
"name": "TestAgent",
"description": "A test agent",
"url": "https://example.com",
"version": "1.0.0",
"protocolVersion": "1.0",
"preferredTransport": "https",
"capabilities": {"securitySchemes": []},
"provider": {"organization": "Acme", "url": "https://acme.example.com"},
"skills": [
{"id": "s1", "name": "Skill 1", "description": "Does things", "tags": ["general"]},
{"id": "s2", "name": "Skill 2", "description": "Does more", "tags": ["general"]},
],
}


class TestComplianceScorerPerfect:
"""A fully-populated, issue-free card should score 100 / Perfect."""

def test_perfect_score(self):
scorer = ComplianceScorer()
result = scorer.score_agent_card(_full_card(), [])

assert result.total == 100
assert result.rating == ComplianceRating.PERFECT
assert result.breakdown.core_fields.score == 60
assert result.breakdown.skills_quality.score == 20
assert result.breakdown.format_compliance.score == 15
assert result.breakdown.data_quality.score == 5


class TestComplianceScorerCoreFields:
"""Core fields subscore (60 points)."""

def test_empty_card_zero_core(self):
scorer = ComplianceScorer()
result = scorer.score_agent_card({}, [])
assert result.breakdown.core_fields.score == 0
assert set(result.breakdown.core_fields.missing) == set(ComplianceScorer.REQUIRED_FIELDS)

def test_partial_fields(self):
scorer = ComplianceScorer()
card = {"name": "A", "version": "1.0.0"}
result = scorer.score_agent_card(card, [])
assert result.breakdown.core_fields.score == int(2 * ComplianceScorer.POINTS_PER_CORE_FIELD)
assert "name" in result.breakdown.core_fields.present
assert "version" in result.breakdown.core_fields.present

def test_falsy_values_count_as_missing(self):
scorer = ComplianceScorer()
card = {"name": "", "version": None}
result = scorer.score_agent_card(card, [])
assert result.breakdown.core_fields.score == 0


class TestComplianceScorerSkillsQuality:
"""Skills quality subscore (20 points)."""

def test_no_skills(self):
scorer = ComplianceScorer()
result = scorer.score_agent_card({"skills": []}, [])
assert result.breakdown.skills_quality.score == 0
assert result.breakdown.skills_quality.skills_present is False

def test_skills_without_required_fields(self):
scorer = ComplianceScorer()
card = {"skills": [{"id": "s1"}]} # missing name, description
result = scorer.score_agent_card(card, [])
bd = result.breakdown.skills_quality
assert bd.skills_present is True
assert bd.all_skills_have_required_fields is False
assert bd.score == 5 # only "skills present" bonus

def test_skills_without_tags(self):
scorer = ComplianceScorer()
card = {"skills": [{"id": "s1", "name": "S", "description": "D"}]} # no tags
result = scorer.score_agent_card(card, [])
bd = result.breakdown.skills_quality
assert bd.all_skills_have_required_fields is True
assert bd.all_skills_have_tags is False
assert bd.score == 15 # 5 present + 10 required fields

def test_non_dict_skills_handled(self):
"""Skills that aren't dicts should be counted as issues."""
scorer = ComplianceScorer()
card = {"skills": ["not-a-dict"]}
result = scorer.score_agent_card(card, [])
bd = result.breakdown.skills_quality
assert bd.all_skills_have_required_fields is False
assert bd.issue_count >= 1

def test_non_list_skills_handled(self):
"""skills: 'invalid' should be treated as empty."""
scorer = ComplianceScorer()
card = {"skills": "invalid"}
result = scorer.score_agent_card(card, [])
assert result.breakdown.skills_quality.skills_present is False


class TestComplianceScorerFormatCompliance:
"""Format compliance subscore (15 points)."""

def test_all_valid_formats(self):
scorer = ComplianceScorer()
result = scorer.score_agent_card({}, [])
assert result.breakdown.format_compliance.score == 15

@pytest.mark.parametrize("issue_code, field", [
("INVALID_SEMVER", "valid_semver"),
("INVALID_PROTOCOL_VERSION", "valid_protocol_version"),
("INVALID_TRANSPORT", "valid_transports"),
("INVALID_MIME_TYPE", "valid_mime_types"),
])
def test_individual_format_failures(self, issue_code, field):
scorer = ComplianceScorer()
result = scorer.score_agent_card({}, [_issue(issue_code)])
bd = result.breakdown.format_compliance
assert getattr(bd, field) is False
assert bd.score == 12 # 15 - 3

def test_insecure_url_counts(self):
scorer = ComplianceScorer()
result = scorer.score_agent_card({}, [_issue("INSECURE_URL")])
assert result.breakdown.format_compliance.valid_url is False


class TestComplianceScorerDataQuality:
"""Data quality subscore (5 points)."""

def test_duplicate_skill_ids(self):
scorer = ComplianceScorer()
card = {"skills": [
{"id": "dup", "name": "A", "description": "A"},
{"id": "dup", "name": "B", "description": "B"},
]}
result = scorer.score_agent_card(card, [])
assert result.breakdown.data_quality.no_duplicate_skill_ids is False

def test_field_length_exceeded(self):
scorer = ComplianceScorer()
result = scorer.score_agent_card({}, [_issue("FIELD_LENGTH_EXCEEDED")])
assert result.breakdown.data_quality.field_lengths_valid is False

def test_ssrf_risk(self):
scorer = ComplianceScorer()
result = scorer.score_agent_card({}, [_issue("SSRF_RISK")])
assert result.breakdown.data_quality.no_ssrf_risks is False


class TestComplianceScorerIssueFiltering:
"""Only compliance-related issues appear in the result."""

def test_compliance_issues_included(self):
scorer = ComplianceScorer()
issues = [
_issue("MISSING_REQUIRED_FIELD", "name is required"),
_issue("SIGNATURE_VERIFICATION_FAILED", "trust issue"), # not compliance
]
result = scorer.score_agent_card({}, issues)
assert "name is required" in result.issues
assert "trust issue" not in result.issues

def test_total_clamped_to_0_100(self):
scorer = ComplianceScorer()
result = scorer.score_agent_card({}, [])
assert 0 <= result.total <= 100
Loading
Loading