mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-22 03:14:06 -05:00
feat(connector-stability): credential capability check framework (#13519)
This commit is contained in:
1 parent
ad70f68e4c
commit
16000232bb
9 files changed
+1299
-2
No files matched your search
@@ -0,0 +1,89 @@
|
||||
"""EE implementation of perm-sync capability checks.
|
||||
|
||||
Fetched by ``onyx.connectors.capability_checks.registry`` via
|
||||
``fetch_ee_implementation_or_noop``. Only the dispatch lives here; the check
|
||||
implementations stay in the OSS connector modules, mirroring the
|
||||
``perm_sync_valid.py`` pattern.
|
||||
"""
|
||||
|
||||
from ee.onyx.external_permissions.sync_params import (
|
||||
source_requires_doc_sync,
|
||||
source_requires_external_group_sync,
|
||||
)
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.connectors.capability_checks.models import (
|
||||
CapabilityCheck,
|
||||
CapabilityCheckContext,
|
||||
CredentialCapability,
|
||||
)
|
||||
|
||||
# Named perm-sync checks per source. Empty at framework stage: per-connector
|
||||
# work registers named checks here.
|
||||
_DOC_PERMISSION_SYNC_CHECKS_BY_SOURCE: dict[DocumentSource, list[CapabilityCheck]] = {}
|
||||
|
||||
_EXTERNAL_GROUP_SYNC_CHECKS_BY_SOURCE: dict[DocumentSource, list[CapabilityCheck]] = {}
|
||||
|
||||
|
||||
class _PermSyncFallbackCheck(CapabilityCheck):
|
||||
"""
|
||||
Baseline check for a perm-sync capability where the source registers no
|
||||
named checks.
|
||||
|
||||
Wraps the legacy ``validate_perm_sync`` blob, which covers doc sync and
|
||||
group sync together; the checks for both capabilities are therefore two
|
||||
instances of this class sharing one check_id, and the runner executes the
|
||||
probe once per run and mirrors the outcome onto each.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, source: DocumentSource, capability: CredentialCapability
|
||||
) -> None:
|
||||
super().__init__(
|
||||
capability=capability,
|
||||
check_id=f"{source.value}_perm_sync",
|
||||
display_name="Permission sync validation",
|
||||
is_fallback=True,
|
||||
)
|
||||
|
||||
def run(self, context: CapabilityCheckContext) -> None:
|
||||
assert context.connector is not None, (
|
||||
"The runner guarantees an instance of a connector."
|
||||
)
|
||||
context.connector.validate_perm_sync()
|
||||
|
||||
|
||||
def get_applicable_perm_sync_capabilities(
|
||||
source: DocumentSource,
|
||||
) -> set[CredentialCapability]:
|
||||
"""Returns which perm-sync capabilities exist for this source."""
|
||||
applicable: set[CredentialCapability] = set()
|
||||
if source_requires_doc_sync(source):
|
||||
applicable.add(CredentialCapability.DOC_PERMISSION_SYNC)
|
||||
if source_requires_external_group_sync(source):
|
||||
applicable.add(CredentialCapability.EXTERNAL_GROUP_SYNC)
|
||||
return applicable
|
||||
|
||||
|
||||
def get_perm_sync_capability_checks(source: DocumentSource) -> list[CapabilityCheck]:
|
||||
"""Returns the perm-sync capability checks for a source.
|
||||
|
||||
Applicable capabilities with no registered named checks get the shared
|
||||
``validate_perm_sync`` fallback so every sync-capable source has day-one
|
||||
coverage.
|
||||
"""
|
||||
applicable = get_applicable_perm_sync_capabilities(source)
|
||||
registered_by_capability: dict[CredentialCapability, list[CapabilityCheck]] = {
|
||||
CredentialCapability.DOC_PERMISSION_SYNC: (
|
||||
_DOC_PERMISSION_SYNC_CHECKS_BY_SOURCE.get(source, [])
|
||||
),
|
||||
CredentialCapability.EXTERNAL_GROUP_SYNC: (
|
||||
_EXTERNAL_GROUP_SYNC_CHECKS_BY_SOURCE.get(source, [])
|
||||
),
|
||||
}
|
||||
checks: list[CapabilityCheck] = []
|
||||
for capability, registered in registered_by_capability.items():
|
||||
if registered:
|
||||
checks.extend(registered)
|
||||
elif capability in applicable:
|
||||
checks.append(_PermSyncFallbackCheck(source, capability))
|
||||
return checks
|
||||
@@ -0,0 +1,172 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Sequence
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.connectors.interfaces import BaseConnector
|
||||
|
||||
|
||||
class CredentialCapability(str, Enum):
|
||||
"""
|
||||
User-visible capabilities a credential may or may not support for a source.
|
||||
"""
|
||||
|
||||
INDEXING = "indexing"
|
||||
DOC_PERMISSION_SYNC = "doc_permission_sync"
|
||||
EXTERNAL_GROUP_SYNC = "external_group_sync"
|
||||
|
||||
|
||||
class CapabilityCheckStatus(str, Enum):
|
||||
PASSED = "passed"
|
||||
# A ``ConnectorValidationError``-family failure: real and actionable.
|
||||
FAILED = "failed"
|
||||
# Transient or unknown failure (``UnexpectedValidationError`` or any
|
||||
# unrecognized exception). Mirrors the contract that such errors must never
|
||||
# be treated as proof the credential is broken.
|
||||
INDETERMINATE = "indeterminate"
|
||||
# The check needs state (connector instance or config) that was not
|
||||
# supplied.
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
class CapabilityVerdict(str, Enum):
|
||||
PASSED = "passed"
|
||||
# Only non-required checks failed: the capability works with caveats.
|
||||
PASSED_WITH_WARNINGS = "passed_with_warnings"
|
||||
FAILED = "failed"
|
||||
INDETERMINATE = "indeterminate"
|
||||
SKIPPED = "skipped"
|
||||
# The source does not have this capability (e.g. no group sync for Slack).
|
||||
NOT_APPLICABLE = "not_applicable"
|
||||
|
||||
|
||||
class CapabilityCheckContext(BaseModel):
|
||||
"""Inputs available to a capability check at run time.
|
||||
|
||||
``connector`` and ``connector_specific_config`` are None for config-less
|
||||
credential-time runs; the runner skips checks that declare a requirement on
|
||||
them.
|
||||
"""
|
||||
|
||||
# ``BaseConnector`` is not a pydantic type; validate by isinstance.
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
source: DocumentSource
|
||||
credential_json: dict[str, Any]
|
||||
connector: BaseConnector | None = None
|
||||
connector_specific_config: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class CapabilityCheck(ABC):
|
||||
"""
|
||||
A named probe of one permission/capability assumption a connector makes.
|
||||
|
||||
Concrete checks pass their metadata to ``__init__`` and implement ``run``,
|
||||
which raises a ``ValidationError``-family exception on failure and returns
|
||||
nothing on success; the runner maps exceptions to statuses.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
capability: CredentialCapability,
|
||||
check_id: str,
|
||||
display_name: str,
|
||||
required: bool = True,
|
||||
requires_connector_instance: bool = True,
|
||||
requires_connector_config: bool = False,
|
||||
timeout_seconds: float | None = None,
|
||||
is_fallback: bool = False,
|
||||
remediation: str | None = None,
|
||||
docs_link: str | None = None,
|
||||
) -> None:
|
||||
self.capability = capability
|
||||
self.check_id = check_id
|
||||
self.display_name = display_name
|
||||
# Required checks gate the capability verdict; non-required checks only
|
||||
# downgrade PASSED to PASSED_WITH_WARNINGS (partial capability).
|
||||
self.required = required
|
||||
self.requires_connector_instance = requires_connector_instance
|
||||
self.requires_connector_config = requires_connector_config
|
||||
# Per-check hang guard in seconds; None falls back to the
|
||||
# ``CAPABILITY_CHECK_TIMEOUT_SECONDS`` default. Timing out maps to
|
||||
# INDETERMINATE, never FAILED.
|
||||
self.timeout_seconds = timeout_seconds
|
||||
# True for synthesized wrappers around the legacy validation blobs
|
||||
# (``validate_connector_settings`` / ``validate_perm_sync``) rather than
|
||||
# named per-permission probes.
|
||||
self.is_fallback = is_fallback
|
||||
self.remediation = remediation
|
||||
self.docs_link = docs_link
|
||||
|
||||
@abstractmethod
|
||||
def run(self, context: CapabilityCheckContext) -> None:
|
||||
"""Probes the capability; raises on failure, returns on success."""
|
||||
|
||||
|
||||
class CapabilityCheckResult(BaseModel):
|
||||
capability: CredentialCapability
|
||||
check_id: str
|
||||
display_name: str
|
||||
required: bool
|
||||
status: CapabilityCheckStatus
|
||||
# Exception text for failures, skip reason for skips, empty on success.
|
||||
message: str = ""
|
||||
# Exception class name, for support diagnosis.
|
||||
error_type: str | None = None
|
||||
is_fallback: bool = False
|
||||
remediation: str | None = None
|
||||
docs_link: str | None = None
|
||||
duration_ms: int | None = None
|
||||
|
||||
|
||||
def aggregate_capability_verdict(
|
||||
applicable: bool,
|
||||
results: Sequence[CapabilityCheckResult],
|
||||
) -> CapabilityVerdict:
|
||||
"""Rolls the per-check results of one capability up into a single verdict.
|
||||
|
||||
Pure function. Required checks gate the verdict: a required failure is
|
||||
FAILED, and a required indeterminate is INDETERMINATE, since the
|
||||
capability's core is unverified and no PASSED claim can be made.
|
||||
Non-required outcomes only downgrade: failures to PASSED_WITH_WARNINGS
|
||||
(definite, stable, actionable), indeterminates to INDETERMINATE when no
|
||||
warning outranks them (transient, self-resolves on re-run). An empty result
|
||||
list aggregates to SKIPPED.
|
||||
"""
|
||||
if not applicable:
|
||||
return CapabilityVerdict.NOT_APPLICABLE
|
||||
if any(
|
||||
result.status == CapabilityCheckStatus.FAILED and result.required
|
||||
for result in results
|
||||
):
|
||||
return CapabilityVerdict.FAILED
|
||||
if any(
|
||||
result.status == CapabilityCheckStatus.INDETERMINATE and result.required
|
||||
for result in results
|
||||
):
|
||||
return CapabilityVerdict.INDETERMINATE
|
||||
if any(result.status == CapabilityCheckStatus.FAILED for result in results):
|
||||
return CapabilityVerdict.PASSED_WITH_WARNINGS
|
||||
if any(result.status == CapabilityCheckStatus.INDETERMINATE for result in results):
|
||||
return CapabilityVerdict.INDETERMINATE
|
||||
if all(result.status == CapabilityCheckStatus.SKIPPED for result in results):
|
||||
return CapabilityVerdict.SKIPPED
|
||||
return CapabilityVerdict.PASSED
|
||||
|
||||
|
||||
def compute_capability_verdicts(
|
||||
applicable_capabilities: set[CredentialCapability],
|
||||
results: Sequence[CapabilityCheckResult],
|
||||
) -> dict[CredentialCapability, CapabilityVerdict]:
|
||||
"""Computes a verdict for every capability, applicable or not."""
|
||||
return {
|
||||
capability: aggregate_capability_verdict(
|
||||
capability in applicable_capabilities,
|
||||
[result for result in results if result.capability == capability],
|
||||
)
|
||||
for capability in CredentialCapability
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.connectors.capability_checks.models import (
|
||||
CapabilityCheck,
|
||||
CapabilityCheckContext,
|
||||
CredentialCapability,
|
||||
)
|
||||
from onyx.utils.variable_functionality import fetch_ee_implementation_or_noop
|
||||
|
||||
# INDEXING checks per source. Checks must be enumerable without an instantiated
|
||||
# connector, hence a registry module rather than a ``BaseConnector`` method.
|
||||
# Empty at framework stage: per-connector work registers named checks here.
|
||||
_INDEXING_CHECKS_BY_SOURCE: dict[DocumentSource, list[CapabilityCheck]] = {}
|
||||
|
||||
|
||||
class _ConnectorSettingsFallbackCheck(CapabilityCheck):
|
||||
"""Baseline INDEXING check where the source registers no named checks.
|
||||
|
||||
Wraps the legacy ``validate_connector_settings`` blob so every connector has
|
||||
day-one coverage until a per-connector session registers named,
|
||||
per-permission checks that shadow this.
|
||||
"""
|
||||
|
||||
def __init__(self, source: DocumentSource) -> None:
|
||||
super().__init__(
|
||||
capability=CredentialCapability.INDEXING,
|
||||
check_id=f"{source.value}_connector_settings",
|
||||
display_name="Connector settings validation",
|
||||
is_fallback=True,
|
||||
)
|
||||
|
||||
def run(self, context: CapabilityCheckContext) -> None:
|
||||
assert context.connector is not None, (
|
||||
"The runner guarantees an instance of a connector."
|
||||
)
|
||||
context.connector.validate_connector_settings()
|
||||
|
||||
|
||||
def get_capability_checks(source: DocumentSource) -> list[CapabilityCheck]:
|
||||
"""
|
||||
Returns all capability checks for a source, synthesizing fallbacks where
|
||||
there are no named checks.
|
||||
|
||||
INDEXING checks are registered here; perm-sync checks come from the EE
|
||||
implementation and are empty on OSS builds, where the perm-sync feature does
|
||||
not exist.
|
||||
"""
|
||||
checks = list(_INDEXING_CHECKS_BY_SOURCE.get(source, []))
|
||||
if not checks:
|
||||
checks.append(_ConnectorSettingsFallbackCheck(source))
|
||||
get_perm_sync_checks = fetch_ee_implementation_or_noop(
|
||||
"onyx.connectors.capability_checks",
|
||||
"get_perm_sync_capability_checks",
|
||||
noop_return_value=[],
|
||||
)
|
||||
checks.extend(get_perm_sync_checks(source))
|
||||
return checks
|
||||
|
||||
|
||||
def get_applicable_capabilities(source: DocumentSource) -> set[CredentialCapability]:
|
||||
"""Returns the capabilities that exist for this source on this build.
|
||||
|
||||
Capabilities outside this set aggregate to a NOT_APPLICABLE verdict. On OSS
|
||||
builds only INDEXING applies, which is correct since perm sync does not
|
||||
exist there.
|
||||
"""
|
||||
get_perm_sync_capabilities = fetch_ee_implementation_or_noop(
|
||||
"onyx.connectors.capability_checks",
|
||||
"get_applicable_perm_sync_capabilities",
|
||||
noop_return_value=set(),
|
||||
)
|
||||
return {CredentialCapability.INDEXING} | get_perm_sync_capabilities(source)
|
||||
@@ -0,0 +1,195 @@
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from onyx.connectors.capability_checks.models import (
|
||||
CapabilityCheck,
|
||||
CapabilityCheckContext,
|
||||
CapabilityCheckResult,
|
||||
CapabilityCheckStatus,
|
||||
CredentialCapability,
|
||||
)
|
||||
from onyx.connectors.exceptions import (
|
||||
ConnectorValidationError,
|
||||
UnexpectedValidationError,
|
||||
)
|
||||
from onyx.utils.logger import setup_logger
|
||||
from onyx.utils.threadpool_concurrency import run_with_timeout
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
# Default per-check hang guard. Deliberately long: checks run async with no
|
||||
# total budget, and a slow probe is vastly cheaper than a failed indexing run.
|
||||
# Its sole purpose is to stop a stuck probe from blocking a worker thread
|
||||
# forever. Known-slow probes override it via
|
||||
# ``CapabilityCheck.timeout_seconds``.
|
||||
CAPABILITY_CHECK_TIMEOUT_SECONDS = 600
|
||||
|
||||
_SKIP_NEEDS_INSTANCE_MESSAGE = (
|
||||
"Requires a connector instance -- will re-run automatically when the "
|
||||
"connector is instantiated."
|
||||
)
|
||||
_SKIP_NEEDS_CONFIG_MESSAGE = (
|
||||
"Requires connector configuration -- will re-run automatically when the "
|
||||
"connector is configured."
|
||||
)
|
||||
_TIMEOUT_MESSAGE = (
|
||||
"Check timed out; the source may be slow -- try re-running the checks."
|
||||
)
|
||||
|
||||
|
||||
class _CheckOutcome(BaseModel):
|
||||
"""
|
||||
Outcome of one check execution, before it is joined with the check's
|
||||
metadata into a result row. Shared across checks with one check_id.
|
||||
"""
|
||||
|
||||
status: CapabilityCheckStatus
|
||||
message: str = ""
|
||||
error_type: str | None = None
|
||||
duration_ms: int | None = None
|
||||
|
||||
|
||||
def _build_result(
|
||||
check: CapabilityCheck, outcome: _CheckOutcome
|
||||
) -> CapabilityCheckResult:
|
||||
return CapabilityCheckResult(
|
||||
capability=check.capability,
|
||||
check_id=check.check_id,
|
||||
display_name=check.display_name,
|
||||
required=check.required,
|
||||
status=outcome.status,
|
||||
message=outcome.message,
|
||||
error_type=outcome.error_type,
|
||||
is_fallback=check.is_fallback,
|
||||
remediation=check.remediation,
|
||||
docs_link=check.docs_link,
|
||||
duration_ms=outcome.duration_ms,
|
||||
)
|
||||
|
||||
|
||||
def _execute_check(
|
||||
check: CapabilityCheck, context: CapabilityCheckContext
|
||||
) -> _CheckOutcome:
|
||||
"""
|
||||
Executes one check under its hang guard and maps the outcome to a status.
|
||||
|
||||
A timeout maps to INDETERMINATE, never FAILED; a slow source is not proof of
|
||||
a broken credential.
|
||||
"""
|
||||
timeout_seconds = check.timeout_seconds or CAPABILITY_CHECK_TIMEOUT_SECONDS
|
||||
start = time.monotonic()
|
||||
|
||||
def elapsed_ms() -> int:
|
||||
return int((time.monotonic() - start) * 1000)
|
||||
|
||||
try:
|
||||
run_with_timeout(timeout_seconds, check.run, context)
|
||||
except ConnectorValidationError as e:
|
||||
return _CheckOutcome(
|
||||
status=CapabilityCheckStatus.FAILED,
|
||||
message=str(e),
|
||||
error_type=type(e).__name__,
|
||||
duration_ms=elapsed_ms(),
|
||||
)
|
||||
except TimeoutError as e:
|
||||
# ``run_with_timeout`` abandons the probe thread on timeout -- Python
|
||||
# has no safe thread cancellation -- so the probe may still have a
|
||||
# request in flight. Check bodies must keep per-request timeouts below
|
||||
# ``timeout_seconds`` so an abandoned probe dies with its current
|
||||
# request instead of running on.
|
||||
logger.warning(
|
||||
"Capability check %s timed out after %.0fs; its probe thread is "
|
||||
"abandoned and may still have a request in-flight.",
|
||||
check.check_id,
|
||||
timeout_seconds,
|
||||
)
|
||||
return _CheckOutcome(
|
||||
status=CapabilityCheckStatus.INDETERMINATE,
|
||||
message=_TIMEOUT_MESSAGE,
|
||||
error_type=type(e).__name__,
|
||||
duration_ms=elapsed_ms(),
|
||||
)
|
||||
except UnexpectedValidationError as e:
|
||||
return _CheckOutcome(
|
||||
status=CapabilityCheckStatus.INDETERMINATE,
|
||||
message=str(e),
|
||||
error_type=type(e).__name__,
|
||||
duration_ms=elapsed_ms(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Capability check %s raised an unexpected error: %s",
|
||||
check.check_id,
|
||||
e,
|
||||
)
|
||||
return _CheckOutcome(
|
||||
status=CapabilityCheckStatus.INDETERMINATE,
|
||||
message=str(e),
|
||||
error_type=type(e).__name__,
|
||||
duration_ms=elapsed_ms(),
|
||||
)
|
||||
return _CheckOutcome(status=CapabilityCheckStatus.PASSED, duration_ms=elapsed_ms())
|
||||
|
||||
|
||||
def run_capability_checks(
|
||||
checks: Sequence[CapabilityCheck],
|
||||
context: CapabilityCheckContext,
|
||||
) -> list[CapabilityCheckResult]:
|
||||
"""Runs checks sequentially and maps their outcomes to statuses.
|
||||
|
||||
Sequential on purpose: source APIs can rate limit aggressively, so
|
||||
concurrent probes against the same credential are counterproductive. Check
|
||||
failures become result rows; this function never raises for them.
|
||||
|
||||
Checks sharing one check_id (the perm-sync fallback registered under both
|
||||
sync capabilities) are one check surfaced per capability: they execute once
|
||||
and the outcome mirrors onto each result. Distinct check_ids always execute
|
||||
independently, even when they share an implementation.
|
||||
"""
|
||||
# A check_id may repeat only as one check mirrored across capabilities;
|
||||
# anything else is a registration bug, caught before it silently collapses
|
||||
# distinct checks into one execution.
|
||||
check_class_by_id: dict[str, type[CapabilityCheck]] = {}
|
||||
seen_capability_ids: set[tuple[str, CredentialCapability]] = set()
|
||||
for check in checks:
|
||||
capability_id = (check.check_id, check.capability)
|
||||
assert capability_id not in seen_capability_ids, (
|
||||
f"Duplicate check_id {check.check_id!r} within capability "
|
||||
f"{check.capability.value}."
|
||||
)
|
||||
seen_capability_ids.add(capability_id)
|
||||
check_class = check_class_by_id.setdefault(check.check_id, type(check))
|
||||
assert check_class is type(check), (
|
||||
f"check_id {check.check_id!r} is shared by {check_class.__name__} "
|
||||
f"and {type(check).__name__}; mirrored checks must be one class."
|
||||
)
|
||||
|
||||
results: list[CapabilityCheckResult] = []
|
||||
outcome_by_check_id: dict[str, _CheckOutcome] = {}
|
||||
for check in checks:
|
||||
skip_message: str | None = None
|
||||
if (
|
||||
check.requires_connector_config
|
||||
and context.connector_specific_config is None
|
||||
):
|
||||
skip_message = _SKIP_NEEDS_CONFIG_MESSAGE
|
||||
elif check.requires_connector_instance and context.connector is None:
|
||||
skip_message = _SKIP_NEEDS_INSTANCE_MESSAGE
|
||||
if skip_message is not None:
|
||||
results.append(
|
||||
_build_result(
|
||||
check,
|
||||
_CheckOutcome(
|
||||
status=CapabilityCheckStatus.SKIPPED,
|
||||
message=skip_message,
|
||||
),
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if check.check_id not in outcome_by_check_id:
|
||||
outcome_by_check_id[check.check_id] = _execute_check(check, context)
|
||||
results.append(_build_result(check, outcome_by_check_id[check.check_id]))
|
||||
return results
|
||||
@@ -526,8 +526,8 @@ def run_with_timeout(
|
||||
timeout: float, func: Callable[..., R], *args: Any, **kwargs: Any
|
||||
) -> R:
|
||||
"""
|
||||
Executes a function with a timeout. If the function doesn't complete within the specified
|
||||
timeout, raises TimeoutError.
|
||||
Executes a function with a timeout. If the function doesn't complete within
|
||||
the specified timeout, raises TimeoutError.
|
||||
"""
|
||||
context = contextvars.copy_context()
|
||||
task = TimeoutThread(timeout, context.run, func, *args, **kwargs)
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from ee.onyx.connectors import capability_checks as ee_capability_checks
|
||||
from ee.onyx.connectors.capability_checks import (
|
||||
get_applicable_perm_sync_capabilities,
|
||||
get_perm_sync_capability_checks,
|
||||
)
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.connectors.capability_checks.models import (
|
||||
CapabilityCheck,
|
||||
CapabilityCheckContext,
|
||||
CredentialCapability,
|
||||
)
|
||||
from onyx.connectors.interfaces import BaseConnector
|
||||
|
||||
|
||||
class _NamedCheck(CapabilityCheck):
|
||||
"""Minimal concrete named check; ``run`` never executes in these tests."""
|
||||
|
||||
def run(self, context: CapabilityCheckContext) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def test_slack_perm_sync_capabilities_exclude_group_sync() -> None:
|
||||
"""Verifies Slack applicability: doc sync yes, group sync no (by design)."""
|
||||
# Under test and postcondition.
|
||||
assert get_applicable_perm_sync_capabilities(DocumentSource.SLACK) == {
|
||||
CredentialCapability.DOC_PERMISSION_SYNC
|
||||
}
|
||||
|
||||
|
||||
def test_google_drive_has_both_perm_sync_capabilities() -> None:
|
||||
"""Verifies a both-capability source resolves from the sync registry."""
|
||||
# Under test and postcondition.
|
||||
assert get_applicable_perm_sync_capabilities(DocumentSource.GOOGLE_DRIVE) == {
|
||||
CredentialCapability.DOC_PERMISSION_SYNC,
|
||||
CredentialCapability.EXTERNAL_GROUP_SYNC,
|
||||
}
|
||||
|
||||
|
||||
def test_censoring_only_source_has_no_perm_sync_capabilities() -> None:
|
||||
"""
|
||||
Verifies Salesforce (query-time censoring only) maps to no capabilities.
|
||||
"""
|
||||
# Under test and postcondition.
|
||||
assert get_applicable_perm_sync_capabilities(DocumentSource.SALESFORCE) == set()
|
||||
|
||||
|
||||
def test_fallback_synthesis_respects_applicability() -> None:
|
||||
"""
|
||||
Verifies only applicable capabilities get a fallback (Slack has no group
|
||||
sync by design, so only the doc-sync fallback is synthesized).
|
||||
"""
|
||||
# Under test.
|
||||
checks = get_perm_sync_capability_checks(DocumentSource.SLACK)
|
||||
|
||||
# Postcondition.
|
||||
assert [check.capability for check in checks] == [
|
||||
CredentialCapability.DOC_PERMISSION_SYNC
|
||||
]
|
||||
assert checks[0].is_fallback is True
|
||||
assert checks[0].check_id == "slack_perm_sync"
|
||||
|
||||
|
||||
def test_unregistered_sync_source_gets_shared_fallback_checks() -> None:
|
||||
"""
|
||||
Verifies fallback synthesis for a sync-capable source with no named checks.
|
||||
"""
|
||||
# Under test.
|
||||
checks = get_perm_sync_capability_checks(DocumentSource.GOOGLE_DRIVE)
|
||||
|
||||
# Postcondition.
|
||||
# One fallback per applicable capability, sharing a single check_id (and
|
||||
# class) so the runner executes it once and mirrors.
|
||||
assert {check.capability for check in checks} == {
|
||||
CredentialCapability.DOC_PERMISSION_SYNC,
|
||||
CredentialCapability.EXTERNAL_GROUP_SYNC,
|
||||
}
|
||||
assert all(check.is_fallback for check in checks)
|
||||
assert len({type(check) for check in checks}) == 1
|
||||
assert {check.check_id for check in checks} == {"google_drive_perm_sync"}
|
||||
|
||||
|
||||
def test_registered_checks_clobber_only_their_capability(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""
|
||||
Verifies named checks clobber the fallback per capability, not per source.
|
||||
"""
|
||||
# Precondition.
|
||||
# Nothing is registered at framework stage, so register a named doc-sync
|
||||
# check the way a per-connector session would.
|
||||
named_check = _NamedCheck(
|
||||
capability=CredentialCapability.DOC_PERMISSION_SYNC,
|
||||
check_id="google_drive_named_check",
|
||||
display_name="Named check",
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
ee_capability_checks._DOC_PERMISSION_SYNC_CHECKS_BY_SOURCE,
|
||||
DocumentSource.GOOGLE_DRIVE,
|
||||
[named_check],
|
||||
)
|
||||
|
||||
# Under test.
|
||||
checks = get_perm_sync_capability_checks(DocumentSource.GOOGLE_DRIVE)
|
||||
|
||||
# Postcondition.
|
||||
# Doc sync uses the named check; group sync, applicable but still
|
||||
# unregistered, keeps its fallback.
|
||||
doc_sync_checks = [
|
||||
check
|
||||
for check in checks
|
||||
if check.capability == CredentialCapability.DOC_PERMISSION_SYNC
|
||||
]
|
||||
group_sync_checks = [
|
||||
check
|
||||
for check in checks
|
||||
if check.capability == CredentialCapability.EXTERNAL_GROUP_SYNC
|
||||
]
|
||||
assert doc_sync_checks == [named_check]
|
||||
assert len(group_sync_checks) == 1
|
||||
assert group_sync_checks[0].is_fallback is True
|
||||
|
||||
|
||||
def test_censoring_only_source_gets_no_fallback_checks() -> None:
|
||||
"""Verifies no fallback is synthesized when neither capability applies."""
|
||||
# Under test and postcondition.
|
||||
assert get_perm_sync_capability_checks(DocumentSource.SALESFORCE) == []
|
||||
|
||||
|
||||
def test_fallback_check_calls_validate_perm_sync() -> None:
|
||||
"""Verifies the synthesized fallback wraps ``validate_perm_sync``."""
|
||||
# Precondition.
|
||||
connector = MagicMock(spec=BaseConnector)
|
||||
context = CapabilityCheckContext(
|
||||
source=DocumentSource.GOOGLE_DRIVE,
|
||||
credential_json={},
|
||||
connector=connector,
|
||||
)
|
||||
check = get_perm_sync_capability_checks(DocumentSource.GOOGLE_DRIVE)[0]
|
||||
|
||||
# Under test.
|
||||
check.run(context)
|
||||
|
||||
# Postcondition.
|
||||
connector.validate_perm_sync.assert_called_once_with()
|
||||
@@ -0,0 +1,340 @@
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.connectors.capability_checks.models import (
|
||||
CapabilityCheck,
|
||||
CapabilityCheckContext,
|
||||
CapabilityCheckStatus,
|
||||
CredentialCapability,
|
||||
)
|
||||
from onyx.connectors.capability_checks.runner import run_capability_checks
|
||||
from onyx.connectors.exceptions import (
|
||||
InsufficientPermissionsError,
|
||||
UnexpectedValidationError,
|
||||
)
|
||||
from onyx.connectors.interfaces import BaseConnector
|
||||
|
||||
|
||||
class _CallableCheck(CapabilityCheck):
|
||||
"""Concrete check that delegates ``run`` to an injected callable."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
run: Callable[[CapabilityCheckContext], None],
|
||||
check_id: str = "dummy_check",
|
||||
required: bool = True,
|
||||
requires_connector_instance: bool = False,
|
||||
requires_connector_config: bool = False,
|
||||
capability: CredentialCapability = CredentialCapability.INDEXING,
|
||||
timeout_seconds: float | None = None,
|
||||
is_fallback: bool = False,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
capability=capability,
|
||||
check_id=check_id,
|
||||
display_name="Dummy check",
|
||||
required=required,
|
||||
requires_connector_instance=requires_connector_instance,
|
||||
requires_connector_config=requires_connector_config,
|
||||
timeout_seconds=timeout_seconds,
|
||||
is_fallback=is_fallback,
|
||||
)
|
||||
self._run = run
|
||||
|
||||
def run(self, context: CapabilityCheckContext) -> None:
|
||||
self._run(context)
|
||||
|
||||
|
||||
class _OtherCallableCheck(_CallableCheck):
|
||||
"""Second concrete class, for the mirrored-check class-identity guard."""
|
||||
|
||||
|
||||
def _context(
|
||||
connector: BaseConnector | None = None,
|
||||
connector_specific_config: dict | None = None,
|
||||
) -> CapabilityCheckContext:
|
||||
return CapabilityCheckContext(
|
||||
source=DocumentSource.SLACK,
|
||||
credential_json={},
|
||||
connector=connector,
|
||||
connector_specific_config=connector_specific_config,
|
||||
)
|
||||
|
||||
|
||||
def test_passing_check_maps_to_passed() -> None:
|
||||
"""Verifies that a check returning without raising is PASSED."""
|
||||
# Precondition.
|
||||
check = _CallableCheck(lambda _context: None)
|
||||
|
||||
# Under test.
|
||||
results = run_capability_checks([check], _context())
|
||||
|
||||
# Postcondition.
|
||||
assert len(results) == 1
|
||||
assert results[0].status == CapabilityCheckStatus.PASSED
|
||||
assert results[0].message == ""
|
||||
assert results[0].error_type is None
|
||||
assert results[0].duration_ms is not None
|
||||
|
||||
|
||||
def test_connector_validation_error_maps_to_failed() -> None:
|
||||
"""Verifies that the ``ConnectorValidationError`` family is FAILED."""
|
||||
|
||||
# Precondition.
|
||||
def run(_context: CapabilityCheckContext) -> None:
|
||||
raise InsufficientPermissionsError("Missing `channels:read`.")
|
||||
|
||||
check = _CallableCheck(run)
|
||||
|
||||
# Under test.
|
||||
results = run_capability_checks([check], _context())
|
||||
|
||||
# Postcondition.
|
||||
assert results[0].status == CapabilityCheckStatus.FAILED
|
||||
assert results[0].error_type == "InsufficientPermissionsError"
|
||||
assert "channels:read" in results[0].message
|
||||
|
||||
|
||||
def test_unexpected_validation_error_maps_to_indeterminate() -> None:
|
||||
"""Verifies that ``UnexpectedValidationError`` is transient, not FAILED."""
|
||||
|
||||
# Precondition.
|
||||
def run(_context: CapabilityCheckContext) -> None:
|
||||
raise UnexpectedValidationError("Slack rate limited the check.")
|
||||
|
||||
check = _CallableCheck(run)
|
||||
|
||||
# Under test.
|
||||
results = run_capability_checks([check], _context())
|
||||
|
||||
# Postcondition.
|
||||
assert results[0].status == CapabilityCheckStatus.INDETERMINATE
|
||||
assert results[0].error_type == "UnexpectedValidationError"
|
||||
|
||||
|
||||
def test_unknown_exception_maps_to_indeterminate() -> None:
|
||||
"""Verifies that unrecognized exceptions never count as real failures."""
|
||||
|
||||
# Precondition.
|
||||
def run(_context: CapabilityCheckContext) -> None:
|
||||
raise RuntimeError("Boom.")
|
||||
|
||||
check = _CallableCheck(run)
|
||||
|
||||
# Under test.
|
||||
results = run_capability_checks([check], _context())
|
||||
|
||||
# Postcondition.
|
||||
assert results[0].status == CapabilityCheckStatus.INDETERMINATE
|
||||
assert results[0].error_type == "RuntimeError"
|
||||
|
||||
|
||||
def test_skips_instance_requiring_check_without_connector() -> None:
|
||||
"""Verifies the skip path for checks that need a connector instance."""
|
||||
# Precondition.
|
||||
run = MagicMock()
|
||||
check = _CallableCheck(run, requires_connector_instance=True)
|
||||
|
||||
# Under test.
|
||||
results = run_capability_checks([check], _context(connector=None))
|
||||
|
||||
# Postcondition.
|
||||
assert results[0].status == CapabilityCheckStatus.SKIPPED
|
||||
run.assert_not_called()
|
||||
|
||||
|
||||
def test_skips_config_requiring_check_even_with_connector() -> None:
|
||||
"""
|
||||
Verifies that config-requiring checks skip when only an instance exists.
|
||||
"""
|
||||
# Precondition.
|
||||
run = MagicMock()
|
||||
check = _CallableCheck(run, requires_connector_config=True)
|
||||
connector = cast(BaseConnector, MagicMock(spec=BaseConnector))
|
||||
|
||||
# Under test.
|
||||
results = run_capability_checks(
|
||||
[check], _context(connector=connector, connector_specific_config=None)
|
||||
)
|
||||
|
||||
# Postcondition.
|
||||
assert results[0].status == CapabilityCheckStatus.SKIPPED
|
||||
run.assert_not_called()
|
||||
|
||||
|
||||
def test_config_requiring_check_runs_with_config() -> None:
|
||||
"""Verifies that a supplied config unlocks config-requiring checks."""
|
||||
# Precondition.
|
||||
run = MagicMock(return_value=None)
|
||||
check = _CallableCheck(run, requires_connector_config=True)
|
||||
|
||||
# Under test.
|
||||
results = run_capability_checks(
|
||||
[check], _context(connector_specific_config={"channels": ["general"]})
|
||||
)
|
||||
|
||||
# Postcondition.
|
||||
assert results[0].status == CapabilityCheckStatus.PASSED
|
||||
run.assert_called_once()
|
||||
|
||||
|
||||
def test_failure_does_not_stop_subsequent_checks() -> None:
|
||||
"""Verifies that the runner continues past failing checks."""
|
||||
|
||||
# Precondition.
|
||||
def failing_run(_context: CapabilityCheckContext) -> None:
|
||||
raise InsufficientPermissionsError("Missing scope.")
|
||||
|
||||
checks = [
|
||||
_CallableCheck(failing_run, check_id="failing_check"),
|
||||
_CallableCheck(lambda _context: None, check_id="passing_check"),
|
||||
]
|
||||
|
||||
# Under test.
|
||||
results = run_capability_checks(checks, _context())
|
||||
|
||||
# Postcondition.
|
||||
assert [result.status for result in results] == [
|
||||
CapabilityCheckStatus.FAILED,
|
||||
CapabilityCheckStatus.PASSED,
|
||||
]
|
||||
assert [result.check_id for result in results] == [
|
||||
"failing_check",
|
||||
"passing_check",
|
||||
]
|
||||
|
||||
|
||||
def test_timeout_maps_to_indeterminate() -> None:
|
||||
"""
|
||||
Verifies that the per-check hang guard yields INDETERMINATE, not FAILED.
|
||||
"""
|
||||
# Precondition.
|
||||
# The sleep must exceed the timeout.
|
||||
timeout_seconds = 0.5
|
||||
slow_run_duration_seconds = 1
|
||||
assert slow_run_duration_seconds > timeout_seconds
|
||||
|
||||
def slow_run(_context: CapabilityCheckContext) -> None:
|
||||
time.sleep(slow_run_duration_seconds)
|
||||
|
||||
check = _CallableCheck(slow_run, timeout_seconds=timeout_seconds)
|
||||
|
||||
# Under test.
|
||||
results = run_capability_checks([check], _context())
|
||||
|
||||
# Postcondition.
|
||||
assert results[0].status == CapabilityCheckStatus.INDETERMINATE
|
||||
assert results[0].error_type == "TimeoutError"
|
||||
assert "timed out" in results[0].message
|
||||
|
||||
|
||||
def test_shared_check_id_executes_once_and_mirrors() -> None:
|
||||
"""
|
||||
Verifies the shared perm-sync fallback contract: one execution, two results.
|
||||
"""
|
||||
# Precondition.
|
||||
# One check_id registered under both sync capabilities.
|
||||
call_count = 0
|
||||
|
||||
def shared_run(_context: CapabilityCheckContext) -> None:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
raise InsufficientPermissionsError("Missing directory scope.")
|
||||
|
||||
checks = [
|
||||
_CallableCheck(
|
||||
shared_run,
|
||||
check_id="perm_sync",
|
||||
capability=CredentialCapability.DOC_PERMISSION_SYNC,
|
||||
),
|
||||
_CallableCheck(
|
||||
shared_run,
|
||||
check_id="perm_sync",
|
||||
capability=CredentialCapability.EXTERNAL_GROUP_SYNC,
|
||||
),
|
||||
]
|
||||
|
||||
# Under test.
|
||||
results = run_capability_checks(checks, _context())
|
||||
|
||||
# Postcondition.
|
||||
# Both capabilities carry the mirrored outcome.
|
||||
assert call_count == 1
|
||||
assert [result.status for result in results] == [
|
||||
CapabilityCheckStatus.FAILED,
|
||||
CapabilityCheckStatus.FAILED,
|
||||
]
|
||||
assert {result.capability for result in results} == {
|
||||
CredentialCapability.DOC_PERMISSION_SYNC,
|
||||
CredentialCapability.EXTERNAL_GROUP_SYNC,
|
||||
}
|
||||
assert all("directory scope" in result.message for result in results)
|
||||
|
||||
|
||||
def test_shared_callable_with_distinct_check_ids_runs_independently() -> None:
|
||||
"""
|
||||
Verifies distinct checks are never collapsed by a shared implementation.
|
||||
"""
|
||||
# Precondition.
|
||||
shared_run = MagicMock(return_value=None)
|
||||
checks = [
|
||||
_CallableCheck(shared_run, check_id="first_check"),
|
||||
_CallableCheck(shared_run, check_id="second_check"),
|
||||
]
|
||||
|
||||
# Under test.
|
||||
run_capability_checks(checks, _context())
|
||||
|
||||
# Postcondition.
|
||||
assert shared_run.call_count == 2
|
||||
|
||||
|
||||
def test_duplicate_check_id_within_a_capability_is_rejected() -> None:
|
||||
"""Verifies a reused check_id within one capability is a hard error."""
|
||||
# Precondition.
|
||||
checks = [
|
||||
_CallableCheck(MagicMock(return_value=None), check_id="same_id"),
|
||||
_CallableCheck(MagicMock(return_value=None), check_id="same_id"),
|
||||
]
|
||||
|
||||
# Under test and postcondition.
|
||||
with pytest.raises(AssertionError, match="Duplicate check_id"):
|
||||
run_capability_checks(checks, _context())
|
||||
|
||||
|
||||
def test_mirrored_check_id_across_classes_is_rejected() -> None:
|
||||
"""Verifies cross-capability check_id reuse requires a single class."""
|
||||
# Precondition.
|
||||
checks = [
|
||||
_CallableCheck(
|
||||
MagicMock(return_value=None),
|
||||
check_id="perm_sync",
|
||||
capability=CredentialCapability.DOC_PERMISSION_SYNC,
|
||||
),
|
||||
_OtherCallableCheck(
|
||||
MagicMock(return_value=None),
|
||||
check_id="perm_sync",
|
||||
capability=CredentialCapability.EXTERNAL_GROUP_SYNC,
|
||||
),
|
||||
]
|
||||
|
||||
# Under test and postcondition.
|
||||
with pytest.raises(AssertionError, match="must be one class"):
|
||||
run_capability_checks(checks, _context())
|
||||
|
||||
|
||||
def test_is_fallback_propagates_to_result() -> None:
|
||||
"""Verifies that result rows carry the fallback marker for FE labeling."""
|
||||
# Precondition.
|
||||
check = _CallableCheck(lambda _context: None, is_fallback=True)
|
||||
|
||||
# Under test.
|
||||
results = run_capability_checks([check], _context())
|
||||
|
||||
# Postcondition.
|
||||
assert results[0].is_fallback is True
|
||||
@@ -0,0 +1,98 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.connectors.capability_checks import registry
|
||||
from onyx.connectors.capability_checks.models import (
|
||||
CapabilityCheck,
|
||||
CapabilityCheckContext,
|
||||
CredentialCapability,
|
||||
)
|
||||
from onyx.connectors.capability_checks.registry import (
|
||||
get_applicable_capabilities,
|
||||
get_capability_checks,
|
||||
)
|
||||
from onyx.connectors.interfaces import BaseConnector
|
||||
|
||||
|
||||
class _NamedCheck(CapabilityCheck):
|
||||
"""Minimal concrete named check; ``run`` never executes in these tests."""
|
||||
|
||||
def run(self, context: CapabilityCheckContext) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def test_unregistered_source_gets_connector_settings_fallback() -> None:
|
||||
"""
|
||||
Verifies fallback synthesis for a source with no named INDEXING checks.
|
||||
"""
|
||||
# Under test.
|
||||
checks = get_capability_checks(DocumentSource.GITHUB)
|
||||
|
||||
# Postcondition.
|
||||
# On OSS builds only the synthesized INDEXING check exists.
|
||||
indexing_checks = [
|
||||
check for check in checks if check.capability == CredentialCapability.INDEXING
|
||||
]
|
||||
assert len(indexing_checks) == 1
|
||||
fallback = indexing_checks[0]
|
||||
assert fallback.check_id == "github_connector_settings"
|
||||
assert fallback.is_fallback is True
|
||||
assert fallback.required is True
|
||||
assert fallback.requires_connector_instance is True
|
||||
|
||||
|
||||
def test_registered_source_gets_no_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Verifies that registered named checks clobber the INDEXING fallback."""
|
||||
# Precondition.
|
||||
# Nothing is registered at framework stage, so register a named check the
|
||||
# way a per-connector session would.
|
||||
named_check = _NamedCheck(
|
||||
capability=CredentialCapability.INDEXING,
|
||||
check_id="github_named_check",
|
||||
display_name="Named check",
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
registry._INDEXING_CHECKS_BY_SOURCE, DocumentSource.GITHUB, [named_check]
|
||||
)
|
||||
|
||||
# Under test.
|
||||
checks = get_capability_checks(DocumentSource.GITHUB)
|
||||
|
||||
# Postcondition.
|
||||
indexing_checks = [
|
||||
check for check in checks if check.capability == CredentialCapability.INDEXING
|
||||
]
|
||||
assert indexing_checks == [named_check]
|
||||
assert not any(check.is_fallback for check in indexing_checks)
|
||||
|
||||
|
||||
def test_fallback_check_calls_validate_connector_settings() -> None:
|
||||
"""
|
||||
Verifies the synthesized fallback wraps ``validate_connector_settings``.
|
||||
"""
|
||||
# Precondition.
|
||||
connector = MagicMock(spec=BaseConnector)
|
||||
context = CapabilityCheckContext(
|
||||
source=DocumentSource.GITHUB,
|
||||
credential_json={},
|
||||
connector=connector,
|
||||
)
|
||||
check = get_capability_checks(DocumentSource.GITHUB)[0]
|
||||
|
||||
# Under test.
|
||||
check.run(context)
|
||||
|
||||
# Postcondition.
|
||||
connector.validate_connector_settings.assert_called_once_with()
|
||||
|
||||
|
||||
def test_oss_build_only_has_indexing_capability() -> None:
|
||||
"""Verifies the EE-noop path: perm-sync capabilities do not exist on OSS."""
|
||||
# Under test and postcondition.
|
||||
# Google Drive is sync-capable on EE, so it is the strongest witness that
|
||||
# the OSS noop returns INDEXING only.
|
||||
assert get_applicable_capabilities(DocumentSource.GOOGLE_DRIVE) == {
|
||||
CredentialCapability.INDEXING
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
from onyx.connectors.capability_checks.models import (
|
||||
CapabilityCheckResult,
|
||||
CapabilityCheckStatus,
|
||||
CapabilityVerdict,
|
||||
CredentialCapability,
|
||||
aggregate_capability_verdict,
|
||||
compute_capability_verdicts,
|
||||
)
|
||||
|
||||
|
||||
def _result(
|
||||
status: CapabilityCheckStatus,
|
||||
required: bool = True,
|
||||
capability: CredentialCapability = CredentialCapability.INDEXING,
|
||||
) -> CapabilityCheckResult:
|
||||
return CapabilityCheckResult(
|
||||
capability=capability,
|
||||
check_id="check",
|
||||
display_name="Check",
|
||||
required=required,
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
def test_not_applicable_wins_over_all_results() -> None:
|
||||
"""
|
||||
Verifies that an inapplicable capability is NOT_APPLICABLE regardless of
|
||||
results.
|
||||
"""
|
||||
# Precondition.
|
||||
results = [_result(CapabilityCheckStatus.FAILED)]
|
||||
|
||||
# Under test and postcondition.
|
||||
assert (
|
||||
aggregate_capability_verdict(False, results) == CapabilityVerdict.NOT_APPLICABLE
|
||||
), "Inapplicable capabilities must ignore check results."
|
||||
|
||||
|
||||
def test_required_failure_fails_the_capability() -> None:
|
||||
"""Verifies that any required failure yields FAILED even among passes."""
|
||||
# Precondition.
|
||||
results = [
|
||||
_result(CapabilityCheckStatus.PASSED),
|
||||
_result(CapabilityCheckStatus.FAILED, required=True),
|
||||
_result(CapabilityCheckStatus.FAILED, required=False),
|
||||
_result(CapabilityCheckStatus.INDETERMINATE),
|
||||
]
|
||||
|
||||
# Under test and postcondition.
|
||||
assert aggregate_capability_verdict(True, results) == CapabilityVerdict.FAILED
|
||||
|
||||
|
||||
def test_only_optional_failures_pass_with_warnings() -> None:
|
||||
"""Verifies that non-required failures downgrade to PASSED_WITH_WARNINGS."""
|
||||
# Precondition.
|
||||
results = [
|
||||
_result(CapabilityCheckStatus.PASSED),
|
||||
_result(CapabilityCheckStatus.FAILED, required=False),
|
||||
]
|
||||
|
||||
# Under test and postcondition.
|
||||
assert (
|
||||
aggregate_capability_verdict(True, results)
|
||||
== CapabilityVerdict.PASSED_WITH_WARNINGS
|
||||
)
|
||||
|
||||
|
||||
def test_required_indeterminate_outranks_optional_failure() -> None:
|
||||
"""
|
||||
Verifies that an unverified required check blocks any PASSED claim, even
|
||||
when a non-required check produced a definite warning.
|
||||
"""
|
||||
# Precondition.
|
||||
results = [
|
||||
_result(CapabilityCheckStatus.FAILED, required=False),
|
||||
_result(CapabilityCheckStatus.INDETERMINATE, required=True),
|
||||
]
|
||||
|
||||
# Under test and postcondition.
|
||||
assert (
|
||||
aggregate_capability_verdict(True, results) == CapabilityVerdict.INDETERMINATE
|
||||
)
|
||||
|
||||
|
||||
def test_optional_failure_outranks_optional_indeterminate() -> None:
|
||||
"""
|
||||
Verifies that with the required core verified, a definite warning outranks
|
||||
a transient non-required indeterminate.
|
||||
"""
|
||||
# Precondition.
|
||||
results = [
|
||||
_result(CapabilityCheckStatus.PASSED, required=True),
|
||||
_result(CapabilityCheckStatus.FAILED, required=False),
|
||||
_result(CapabilityCheckStatus.INDETERMINATE, required=False),
|
||||
]
|
||||
|
||||
# Under test and postcondition.
|
||||
assert (
|
||||
aggregate_capability_verdict(True, results)
|
||||
== CapabilityVerdict.PASSED_WITH_WARNINGS
|
||||
)
|
||||
|
||||
|
||||
def test_indeterminate_without_failures() -> None:
|
||||
"""
|
||||
Verifies that a transient error yields INDETERMINATE when nothing failed.
|
||||
"""
|
||||
# Precondition.
|
||||
results = [
|
||||
_result(CapabilityCheckStatus.PASSED),
|
||||
_result(CapabilityCheckStatus.INDETERMINATE),
|
||||
]
|
||||
|
||||
# Under test and postcondition.
|
||||
assert (
|
||||
aggregate_capability_verdict(True, results) == CapabilityVerdict.INDETERMINATE
|
||||
)
|
||||
|
||||
|
||||
def test_all_skipped_yields_skipped() -> None:
|
||||
"""Verifies that a run with only skipped checks is SKIPPED, not PASSED."""
|
||||
# Precondition.
|
||||
results = [
|
||||
_result(CapabilityCheckStatus.SKIPPED),
|
||||
_result(CapabilityCheckStatus.SKIPPED),
|
||||
]
|
||||
|
||||
# Under test and postcondition.
|
||||
assert aggregate_capability_verdict(True, results) == CapabilityVerdict.SKIPPED
|
||||
|
||||
|
||||
def test_empty_results_yield_skipped() -> None:
|
||||
"""Verifies that an applicable capability with zero checks is SKIPPED."""
|
||||
# Under test and postcondition.
|
||||
assert aggregate_capability_verdict(True, []) == CapabilityVerdict.SKIPPED
|
||||
|
||||
|
||||
def test_skipped_mixed_with_passed_yields_passed() -> None:
|
||||
"""Verifies that partial skips do not mask passing required checks."""
|
||||
# Precondition.
|
||||
results = [
|
||||
_result(CapabilityCheckStatus.PASSED),
|
||||
_result(CapabilityCheckStatus.SKIPPED),
|
||||
]
|
||||
|
||||
# Under test and postcondition.
|
||||
assert aggregate_capability_verdict(True, results) == CapabilityVerdict.PASSED
|
||||
|
||||
|
||||
def test_all_passed_yields_passed() -> None:
|
||||
"""Verifies the all-green case."""
|
||||
# Precondition.
|
||||
results = [_result(CapabilityCheckStatus.PASSED)]
|
||||
|
||||
# Under test and postcondition.
|
||||
assert aggregate_capability_verdict(True, results) == CapabilityVerdict.PASSED
|
||||
|
||||
|
||||
def test_compute_capability_verdicts_slack_shaped_scenario() -> None:
|
||||
"""Verifies per-capability grouping for a Slack-shaped report."""
|
||||
# Precondition.
|
||||
# Indexing passes, doc perm sync has a required failure, and group sync is
|
||||
# not applicable (Slack has none by design).
|
||||
results = [
|
||||
_result(CapabilityCheckStatus.PASSED, capability=CredentialCapability.INDEXING),
|
||||
_result(
|
||||
CapabilityCheckStatus.FAILED,
|
||||
capability=CredentialCapability.DOC_PERMISSION_SYNC,
|
||||
),
|
||||
]
|
||||
applicable = {
|
||||
CredentialCapability.INDEXING,
|
||||
CredentialCapability.DOC_PERMISSION_SYNC,
|
||||
}
|
||||
|
||||
# Under test.
|
||||
verdicts = compute_capability_verdicts(applicable, results)
|
||||
|
||||
# Postcondition.
|
||||
assert verdicts == {
|
||||
CredentialCapability.INDEXING: CapabilityVerdict.PASSED,
|
||||
CredentialCapability.DOC_PERMISSION_SYNC: CapabilityVerdict.FAILED,
|
||||
CredentialCapability.EXTERNAL_GROUP_SYNC: CapabilityVerdict.NOT_APPLICABLE,
|
||||
}
|
||||
Reference in new issue
Block a user