mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-19 01:50:51 -05:00
feat(connectors): connectors without named checks get the design's generic row
The fallback check of a source without named checks is the design's generic row: - Its name is "Test connection". A pass gives "Connected". - A failure of the legacy settings probe gets a line and a description for its kind: rejected sign-in, denied permission, not found, settings not accepted, rate limited, unreachable, or unexpected. - An untyped error gets its class from its HTTP status (401, 403, 404, 429), as the named checks map them. A connection error or a timeout is unreachable, so a form run fails it. - The blocking-validation recorder writes the same row. A source whose connector keeps the no-op validate_connector_settings gets no INDEXING check, and the recorder writes no row for its pass. Its plan and runs hold no check, so the form shows no card. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KFMazQbuG8ui3jxBxm47sC
This commit is contained in:
9 files changed
+490
-40
No files matched your search
@@ -458,7 +458,10 @@ decides which capabilities apply to a source on this build (CE: `INDEXING`
|
||||
only; EE adds the perm-sync ones). `capability_checks/registry.py` maps a
|
||||
source to named `CapabilityCheck`s (only Slack, Confluence, OneDrive, Outlook, Jira, Linear, HubSpot, Google Drive, Notion, SharePoint, and GitHub
|
||||
register real ones today; every other source gets a synthesized fallback wrapping
|
||||
`validate_connector_settings`). A source with named checks must have a
|
||||
`validate_connector_settings`, the generic "Test connection" row of
|
||||
`capability_checks/generic_row.py`. A source whose connector keeps the no-op
|
||||
`validate_connector_settings` (`factory.py:source_has_settings_probe`) gets no
|
||||
INDEXING check, so its plan and runs hold no row). A source with named checks must have a
|
||||
`SourceOperations` gateway; `get_capability_checks` asserts this. `capability_checks/runner.py:generate_capability_report`
|
||||
instantiates the connector in isolation with a timeout guard, builds the
|
||||
source's `SourceOperations` gateway if one is registered, and runs checks
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""The generic row: what a source without named checks shows for its legacy
|
||||
``validate_connector_settings`` probe. The fallback check and the
|
||||
blocking-validation recorder both build it here, so a draft run and a stored
|
||||
report show the same row.
|
||||
|
||||
Import-light, like the recorder: no registry, runner or factory imports.
|
||||
"""
|
||||
|
||||
import requests
|
||||
from pydantic import BaseModel
|
||||
|
||||
from onyx.connectors.exceptions import (
|
||||
ConnectorValidationError,
|
||||
CredentialExpiredError,
|
||||
CredentialInvalidError,
|
||||
InsufficientPermissionsError,
|
||||
RateLimitedValidationError,
|
||||
SourceApiError,
|
||||
UnexpectedValidationError,
|
||||
UnreachableValidationError,
|
||||
ValidationError,
|
||||
)
|
||||
|
||||
GENERIC_CHECK_NAME = "Test connection"
|
||||
GENERIC_PASS_LINE = "Connected"
|
||||
|
||||
_UNAUTHORIZED = 401
|
||||
_FORBIDDEN = 403
|
||||
_NOT_FOUND = 404
|
||||
_TOO_MANY_REQUESTS = 429
|
||||
# No answer from the source: the connection failed or timed out.
|
||||
_UNREACHABLE_ERRORS: tuple[type[Exception], ...] = (
|
||||
requests.ConnectionError,
|
||||
requests.Timeout,
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
)
|
||||
|
||||
|
||||
class _RowText(BaseModel):
|
||||
summary: str
|
||||
description: str
|
||||
|
||||
|
||||
_REJECTED_ROW = _RowText(
|
||||
summary="Sign-in rejected",
|
||||
description=(
|
||||
"The source rejected the account's credential.\n"
|
||||
"Update the credential in the account, then re-run."
|
||||
),
|
||||
)
|
||||
_DENIED_ROW = _RowText(
|
||||
summary="No permission for the connector",
|
||||
description=(
|
||||
"The credential signs in, but lacks a permission the connector needs.\n"
|
||||
"Give the account that permission, then re-run."
|
||||
),
|
||||
)
|
||||
_NOT_FOUND_ROW = _RowText(
|
||||
summary="Not found at the source",
|
||||
description=(
|
||||
"The source found nothing at the configured address or ID.\n"
|
||||
"Correct the connector settings, then re-run."
|
||||
),
|
||||
)
|
||||
_SETTINGS_ROW = _RowText(
|
||||
summary="Settings not accepted",
|
||||
description=(
|
||||
"The source did not accept the connector settings.\n"
|
||||
"Read the error details, then correct the settings."
|
||||
),
|
||||
)
|
||||
_RATE_LIMITED_ROW = _RowText(
|
||||
summary="Rate limited by the source",
|
||||
description=(
|
||||
"The source limited the number of requests.\nWait a minute, then re-run."
|
||||
),
|
||||
)
|
||||
_UNREACHABLE_ROW = _RowText(
|
||||
summary="Cannot reach the source",
|
||||
description=(
|
||||
"Onyx could not connect to the source.\n"
|
||||
"Check the address in the settings, then re-run."
|
||||
),
|
||||
)
|
||||
_UNEXPECTED_ROW = _RowText(
|
||||
summary="Unexpected response from the source",
|
||||
description=(
|
||||
"The source answered in a way the check does not know.\n"
|
||||
"Re-run the checks. If it repeats, share the error details with support."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _http_status(error: BaseException) -> int | None:
|
||||
"""The HTTP status of the first error in the chain that carries one."""
|
||||
seen: set[int] = set()
|
||||
current: BaseException | None = error
|
||||
while current is not None and id(current) not in seen:
|
||||
if isinstance(current, SourceApiError):
|
||||
return current.status_code
|
||||
if isinstance(current, requests.HTTPError) and current.response is not None:
|
||||
return current.response.status_code
|
||||
seen.add(id(current))
|
||||
current = current.__cause__ or current.__context__
|
||||
return None
|
||||
|
||||
|
||||
def _with_status(row: _RowText, status: int | None) -> _RowText:
|
||||
"""Adds the HTTP status to the row's first description line."""
|
||||
if status is None:
|
||||
return row
|
||||
first, newline, rest = row.description.partition("\n")
|
||||
return _RowText(
|
||||
summary=row.summary, description=f"{first} (HTTP {status}){newline}{rest}"
|
||||
)
|
||||
|
||||
|
||||
def _typed(
|
||||
error: Exception, status: int | None
|
||||
) -> tuple[type[ValidationError], _RowText]:
|
||||
"""The error's class and row. A typed error keeps its class; an untyped
|
||||
one gets its class from its HTTP status, as the named checks map them."""
|
||||
if isinstance(error, RateLimitedValidationError) or status == _TOO_MANY_REQUESTS:
|
||||
return RateLimitedValidationError, _RATE_LIMITED_ROW
|
||||
if isinstance(error, (UnreachableValidationError, *_UNREACHABLE_ERRORS)):
|
||||
return UnreachableValidationError, _UNREACHABLE_ROW
|
||||
if isinstance(error, CredentialExpiredError):
|
||||
return CredentialExpiredError, _REJECTED_ROW
|
||||
if isinstance(error, CredentialInvalidError):
|
||||
return CredentialInvalidError, _REJECTED_ROW
|
||||
if isinstance(error, InsufficientPermissionsError):
|
||||
return InsufficientPermissionsError, _DENIED_ROW
|
||||
if status == _UNAUTHORIZED:
|
||||
return CredentialInvalidError, _REJECTED_ROW
|
||||
if status == _FORBIDDEN:
|
||||
return InsufficientPermissionsError, _DENIED_ROW
|
||||
if status == _NOT_FOUND:
|
||||
return ConnectorValidationError, _NOT_FOUND_ROW
|
||||
if isinstance(error, ConnectorValidationError):
|
||||
return ConnectorValidationError, _SETTINGS_ROW
|
||||
return UnexpectedValidationError, _UNEXPECTED_ROW
|
||||
|
||||
|
||||
def generic_row_error(error: Exception) -> ValidationError:
|
||||
"""The validation error the generic row shows for a legacy probe's
|
||||
error, with its line and description. ``message`` keeps the probe's own
|
||||
text for Error Details. An error that already gives its row is kept."""
|
||||
if isinstance(error, ValidationError) and error.summary is not None:
|
||||
return error
|
||||
status = _http_status(error)
|
||||
error_class, row = _typed(error, status)
|
||||
row = _with_status(row, status)
|
||||
return error_class(str(error), summary=row.summary, description=row.description)
|
||||
@@ -19,6 +19,11 @@ from onyx.configs.constants import DocumentSource
|
||||
from onyx.connectors.capability_checks.applicability import (
|
||||
get_applicable_capabilities,
|
||||
)
|
||||
from onyx.connectors.capability_checks.generic_row import (
|
||||
GENERIC_CHECK_NAME,
|
||||
GENERIC_PASS_LINE,
|
||||
generic_row_error,
|
||||
)
|
||||
from onyx.connectors.capability_checks.models import (
|
||||
CapabilityCheckResult,
|
||||
CapabilityCheckStatus,
|
||||
@@ -58,28 +63,39 @@ def _synthesize_results(
|
||||
) -> list[CapabilityCheckResult]:
|
||||
"""Builds fallback-shaped results mirroring what the blocking path ran.
|
||||
|
||||
The INDEXING row is the generic row, as the fallback check gives it. A
|
||||
passing source with no settings probe gets none: it tested nothing.
|
||||
Perm-sync rows appear only when the caller knows ``validate_perm_sync`` ran
|
||||
(the success path with sync access). On failure the outcome cannot be
|
||||
attributed between the settings and perm-sync probes, so only the INDEXING
|
||||
row carries it.
|
||||
"""
|
||||
status = _outcome_status(error)
|
||||
# Inline import: the factory imports this module.
|
||||
from onyx.connectors.factory import source_has_settings_probe
|
||||
|
||||
typed = generic_row_error(error) if error is not None else None
|
||||
status = _outcome_status(typed)
|
||||
message = str(error) if error is not None else ""
|
||||
# The original class, for support: ``typed`` wraps it.
|
||||
error_type = type(error).__name__ if error is not None else None
|
||||
error_kind = check_error_kind(error) if error is not None else None
|
||||
results = [
|
||||
CapabilityCheckResult(
|
||||
capability=CredentialCapability.INDEXING,
|
||||
check_id=f"{source.value}_connector_settings",
|
||||
display_name="Connector settings validation",
|
||||
required=True,
|
||||
status=status,
|
||||
message=message,
|
||||
error_type=error_type,
|
||||
error_kind=error_kind,
|
||||
is_fallback=True,
|
||||
error_kind = check_error_kind(typed) if typed is not None else None
|
||||
results: list[CapabilityCheckResult] = []
|
||||
if typed is not None or source_has_settings_probe(source):
|
||||
results.append(
|
||||
CapabilityCheckResult(
|
||||
capability=CredentialCapability.INDEXING,
|
||||
check_id=f"{source.value}_connector_settings",
|
||||
display_name=GENERIC_CHECK_NAME,
|
||||
required=True,
|
||||
status=status,
|
||||
message=message,
|
||||
error_type=error_type,
|
||||
error_kind=error_kind,
|
||||
summary=typed.summary if typed is not None else GENERIC_PASS_LINE,
|
||||
description=typed.description if typed is not None else None,
|
||||
is_fallback=True,
|
||||
)
|
||||
)
|
||||
]
|
||||
if perm_sync_validated:
|
||||
results.extend(
|
||||
CapabilityCheckResult(
|
||||
@@ -118,6 +134,8 @@ def record_blocking_validation_outcome(
|
||||
try:
|
||||
applicable = get_applicable_capabilities(source)
|
||||
results = _synthesize_results(source, applicable, error, perm_sync_validated)
|
||||
if not results:
|
||||
return
|
||||
report = CredentialCapabilityReport(
|
||||
credential_id=credential_id,
|
||||
source=source,
|
||||
|
||||
@@ -6,14 +6,21 @@ from onyx.configs.constants import DocumentSource
|
||||
from onyx.connectors.capability_checks.applicability import (
|
||||
get_applicable_capabilities as get_applicable_capabilities,
|
||||
)
|
||||
from onyx.connectors.capability_checks.generic_row import (
|
||||
GENERIC_CHECK_NAME,
|
||||
GENERIC_PASS_LINE,
|
||||
generic_row_error,
|
||||
)
|
||||
from onyx.connectors.capability_checks.models import (
|
||||
CapabilityCheck,
|
||||
CapabilityCheckContext,
|
||||
CheckOutput,
|
||||
CredentialCapability,
|
||||
)
|
||||
from onyx.connectors.confluence.capability_checks import (
|
||||
build_confluence_indexing_checks,
|
||||
)
|
||||
from onyx.connectors.factory import source_has_settings_probe
|
||||
from onyx.connectors.github.capability_checks import build_github_indexing_checks
|
||||
from onyx.connectors.google_drive.capability_checks import (
|
||||
build_google_drive_indexing_checks,
|
||||
@@ -50,26 +57,33 @@ _INDEXING_CHECKS_BY_SOURCE: dict[DocumentSource, list[CapabilityCheck]] = {
|
||||
|
||||
|
||||
class _ConnectorSettingsFallbackCheck(CapabilityCheck):
|
||||
"""Baseline INDEXING check where the source registers no named checks.
|
||||
"""The generic row, where the source registers no named INDEXING 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.
|
||||
Wraps the legacy ``validate_connector_settings`` probe until a
|
||||
per-connector session registers named checks that shadow this. Like a
|
||||
sign-in, it fails on a form run when the source does not answer.
|
||||
"""
|
||||
|
||||
def __init__(self, source: DocumentSource) -> None:
|
||||
super().__init__(
|
||||
capability=CredentialCapability.INDEXING,
|
||||
check_id=f"{source.value}_connector_settings",
|
||||
display_name="Connector settings validation",
|
||||
display_name=GENERIC_CHECK_NAME,
|
||||
is_fallback=True,
|
||||
fails_when_unreachable=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 run(self, context: CapabilityCheckContext) -> CheckOutput:
|
||||
if context.connector is None:
|
||||
raise TypeError("Bug: The runner guarantees an instance of a connector.")
|
||||
try:
|
||||
context.connector.validate_connector_settings()
|
||||
except Exception as e:
|
||||
typed = generic_row_error(e)
|
||||
if typed is e:
|
||||
raise
|
||||
raise typed from e
|
||||
return CheckOutput(summary=GENERIC_PASS_LINE)
|
||||
|
||||
|
||||
def get_capability_checks(source: DocumentSource) -> list[CapabilityCheck]:
|
||||
@@ -81,6 +95,9 @@ def get_capability_checks(source: DocumentSource) -> list[CapabilityCheck]:
|
||||
implementation and are empty on OSS builds, where the perm-sync feature does
|
||||
not exist.
|
||||
|
||||
A source whose connector has no settings probe gets no INDEXING check:
|
||||
a check that reaches nothing must not pass.
|
||||
|
||||
Ratchet: named checks require a registered source-operations gateway --
|
||||
participation in the checks system is an anti-drift guarantee. Unmigrated
|
||||
sources keep the fallback path.
|
||||
@@ -90,7 +107,7 @@ def get_capability_checks(source: DocumentSource) -> list[CapabilityCheck]:
|
||||
f"{source.value} registers named INDEXING checks but no "
|
||||
"source-operations gateway; migrate the connector first."
|
||||
)
|
||||
if not checks:
|
||||
if not checks and source_has_settings_probe(source):
|
||||
checks.append(_ConnectorSettingsFallbackCheck(source))
|
||||
get_perm_sync_checks = fetch_ee_implementation_or_noop(
|
||||
"onyx.connectors.capability_checks",
|
||||
|
||||
@@ -133,6 +133,18 @@ def source_supports_windowed_runs(source: DocumentSource) -> bool:
|
||||
return issubclass(connector_class, (CheckpointedConnector, PollConnector))
|
||||
|
||||
|
||||
def source_has_settings_probe(source: DocumentSource) -> bool:
|
||||
"""True if the source's connector overrides ``validate_connector_settings``.
|
||||
The default is a no-op that reaches nothing, so it tests nothing."""
|
||||
if source not in CONNECTOR_CLASS_MAP:
|
||||
return False
|
||||
connector_class = _load_connector_class(source)
|
||||
return (
|
||||
connector_class.validate_connector_settings
|
||||
is not BaseConnector.validate_connector_settings
|
||||
)
|
||||
|
||||
|
||||
def source_prune_honors_indexing_start(source: DocumentSource) -> bool:
|
||||
"""True if a prune of the source removes documents older than the
|
||||
indexing start. A source without a connector class prunes nothing."""
|
||||
|
||||
@@ -19,10 +19,7 @@ from onyx.configs.constants import (
|
||||
OnyxCeleryTask,
|
||||
)
|
||||
from onyx.connectors.capabilities import CredentialCapability
|
||||
from onyx.connectors.capability_checks.models import (
|
||||
CapabilityCheckStatus,
|
||||
CapabilityVerdict,
|
||||
)
|
||||
from onyx.connectors.capability_checks.models import CapabilityVerdict
|
||||
from onyx.connectors.models import InputType
|
||||
from onyx.db.credential_capability import mark_capability_report_running
|
||||
from onyx.db.engine.sql_engine import get_session_with_current_tenant
|
||||
@@ -103,13 +100,8 @@ def test_credential_scoped_run_completes_and_persists_a_report(
|
||||
report = completed["report"]
|
||||
assert report["connector_id"] is None
|
||||
assert report["trigger"] == CapabilityCheckTrigger.MANUAL.value
|
||||
assert report["check_results"]
|
||||
# Config-less: the mock connector cannot be constructed, so every
|
||||
# instance-requiring check skips rather than probing anything.
|
||||
assert all(
|
||||
result["status"] == CapabilityCheckStatus.SKIPPED.value
|
||||
for result in report["check_results"]
|
||||
)
|
||||
# The mock connector has no settings probe, so the run holds no check.
|
||||
assert report["check_results"] == []
|
||||
indexing_verdict = report["verdicts"][CredentialCapability.INDEXING.value]
|
||||
assert indexing_verdict == CapabilityVerdict.SKIPPED.value
|
||||
|
||||
|
||||
+17
-2
@@ -11,6 +11,7 @@ from onyx.connectors.capability_checks.models import (
|
||||
CredentialCapability,
|
||||
)
|
||||
from onyx.connectors.capability_checks.registry import get_capability_checks
|
||||
from onyx.connectors.factory import source_has_settings_probe
|
||||
from onyx.connectors.source_operations import SourceOperations
|
||||
from tests.unit.onyx.connectors.source_operation_harnesses import (
|
||||
import_all_source_operation_gateways,
|
||||
@@ -141,7 +142,14 @@ def test_every_source_resolves_checks_without_tripping_the_ratchet() -> None:
|
||||
|
||||
# Under test and postcondition.
|
||||
for source in DocumentSource:
|
||||
assert get_capability_checks(source)
|
||||
checks = get_capability_checks(source)
|
||||
has_indexing_check = any(
|
||||
check.capability == CredentialCapability.INDEXING for check in checks
|
||||
)
|
||||
assert has_indexing_check == (
|
||||
source in registry._INDEXING_CHECKS_BY_SOURCE
|
||||
or source_has_settings_probe(source)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_ee")
|
||||
@@ -152,4 +160,11 @@ def test_every_source_resolves_checks_with_ee_enabled() -> None:
|
||||
|
||||
# Under test and postcondition.
|
||||
for source in DocumentSource:
|
||||
assert get_capability_checks(source)
|
||||
checks = get_capability_checks(source)
|
||||
has_indexing_check = any(
|
||||
check.capability == CredentialCapability.INDEXING for check in checks
|
||||
)
|
||||
assert has_indexing_check == (
|
||||
source in registry._INDEXING_CHECKS_BY_SOURCE
|
||||
or source_has_settings_probe(source)
|
||||
)
|
||||
@@ -0,0 +1,227 @@
|
||||
"""The generic row of a source without named checks: "Test connection",
|
||||
"Connected" on a pass, and a typed line and description for each failure of
|
||||
the legacy settings probe, in a draft run and in a recorded outcome alike."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.connectors.capability_checks.models import (
|
||||
CapabilityCheckContext,
|
||||
CapabilityCheckResult,
|
||||
CapabilityCheckStatus,
|
||||
CheckErrorKind,
|
||||
CredentialCapability,
|
||||
)
|
||||
from onyx.connectors.capability_checks.recorder import _synthesize_results
|
||||
from onyx.connectors.capability_checks.registry import get_capability_checks
|
||||
from onyx.connectors.capability_checks.runner import run_capability_checks
|
||||
from onyx.connectors.exceptions import (
|
||||
ConnectorValidationError,
|
||||
CredentialExpiredError,
|
||||
InsufficientPermissionsError,
|
||||
RateLimitedValidationError,
|
||||
SourceApiError,
|
||||
UnexpectedValidationError,
|
||||
)
|
||||
from onyx.connectors.interfaces import BaseConnector
|
||||
|
||||
_SOURCE = DocumentSource.BOX
|
||||
|
||||
|
||||
def _http_error(status: int) -> requests.HTTPError:
|
||||
response = requests.Response()
|
||||
response.status_code = status
|
||||
response._content = b'{"message": "denied"}'
|
||||
response.url = "https://api.example.com/2.0/folders/0?token=secret"
|
||||
return requests.HTTPError(f"{status} error", response=response)
|
||||
|
||||
|
||||
def _run(error: Exception | None, form_run: bool = False) -> CapabilityCheckResult:
|
||||
connector = MagicMock(spec=BaseConnector)
|
||||
connector.validate_connector_settings.side_effect = error
|
||||
checks = [
|
||||
check
|
||||
for check in get_capability_checks(_SOURCE)
|
||||
if check.capability == CredentialCapability.INDEXING
|
||||
]
|
||||
(result,) = run_capability_checks(
|
||||
checks,
|
||||
CapabilityCheckContext(source=_SOURCE, credential_json={}, connector=connector),
|
||||
form_run=form_run,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def test_pass_shows_connected() -> None:
|
||||
# Under test.
|
||||
result = _run(None)
|
||||
|
||||
# Postcondition.
|
||||
assert result.display_name == "Test connection"
|
||||
assert result.status == CapabilityCheckStatus.PASSED
|
||||
assert result.summary == "Connected"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error", "status", "kind", "error_type", "summary"),
|
||||
[
|
||||
(
|
||||
_http_error(401),
|
||||
CapabilityCheckStatus.FAILED,
|
||||
CheckErrorKind.REJECTED,
|
||||
"CredentialInvalidError",
|
||||
"Sign-in rejected",
|
||||
),
|
||||
(
|
||||
CredentialExpiredError("token expired"),
|
||||
CapabilityCheckStatus.FAILED,
|
||||
CheckErrorKind.REJECTED,
|
||||
"CredentialExpiredError",
|
||||
"Sign-in rejected",
|
||||
),
|
||||
(
|
||||
_http_error(403),
|
||||
CapabilityCheckStatus.FAILED,
|
||||
CheckErrorKind.PERMISSION,
|
||||
"InsufficientPermissionsError",
|
||||
"No permission for the connector",
|
||||
),
|
||||
(
|
||||
InsufficientPermissionsError("missing scope"),
|
||||
CapabilityCheckStatus.FAILED,
|
||||
CheckErrorKind.PERMISSION,
|
||||
"InsufficientPermissionsError",
|
||||
"No permission for the connector",
|
||||
),
|
||||
(
|
||||
SourceApiError("no folder", status_code=404),
|
||||
CapabilityCheckStatus.FAILED,
|
||||
CheckErrorKind.INVALID,
|
||||
"ConnectorValidationError",
|
||||
"Not found at the source",
|
||||
),
|
||||
(
|
||||
ConnectorValidationError("Folder ID must be numeric"),
|
||||
CapabilityCheckStatus.FAILED,
|
||||
CheckErrorKind.INVALID,
|
||||
"ConnectorValidationError",
|
||||
"Settings not accepted",
|
||||
),
|
||||
(
|
||||
_http_error(429),
|
||||
CapabilityCheckStatus.INCOMPLETE,
|
||||
CheckErrorKind.RATE_LIMITED,
|
||||
"RateLimitedValidationError",
|
||||
"Rate limited by the source",
|
||||
),
|
||||
(
|
||||
requests.ConnectionError("connection refused"),
|
||||
CapabilityCheckStatus.INCOMPLETE,
|
||||
CheckErrorKind.UNREACHABLE,
|
||||
"UnreachableValidationError",
|
||||
"Cannot reach the source",
|
||||
),
|
||||
(
|
||||
RuntimeError("boom"),
|
||||
CapabilityCheckStatus.INCOMPLETE,
|
||||
CheckErrorKind.UNEXPECTED,
|
||||
"UnexpectedValidationError",
|
||||
"Unexpected response from the source",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_failure_gets_its_typed_row(
|
||||
error: Exception,
|
||||
status: CapabilityCheckStatus,
|
||||
kind: CheckErrorKind,
|
||||
error_type: str,
|
||||
summary: str,
|
||||
) -> None:
|
||||
# Under test.
|
||||
result = _run(error)
|
||||
|
||||
# Postcondition.
|
||||
assert result.status == status
|
||||
assert result.error_kind == kind
|
||||
assert result.error_type == error_type
|
||||
assert result.summary == summary
|
||||
assert result.description is not None
|
||||
assert len(result.description.splitlines()) <= 3
|
||||
# The probe's own text stays the message, for Error Details.
|
||||
assert result.message == str(error)
|
||||
|
||||
|
||||
def test_description_names_the_http_status() -> None:
|
||||
# Under test.
|
||||
result = _run(_http_error(401))
|
||||
|
||||
# Postcondition.
|
||||
assert result.description is not None
|
||||
assert result.description.splitlines()[0].endswith("(HTTP 401)")
|
||||
assert result.error_detail is not None
|
||||
assert result.error_detail.status_code == 401
|
||||
assert result.error_detail.request == "api.example.com/2.0/folders/0"
|
||||
|
||||
|
||||
def test_error_with_its_own_row_keeps_it() -> None:
|
||||
# Precondition.
|
||||
error = UnexpectedValidationError(
|
||||
"Box API is down", summary="Box is down", description="Try later."
|
||||
)
|
||||
|
||||
# Under test.
|
||||
result = _run(error)
|
||||
|
||||
# Postcondition.
|
||||
assert result.summary == "Box is down"
|
||||
assert result.description == "Try later."
|
||||
|
||||
|
||||
def test_unreachable_source_fails_on_a_form_run_only() -> None:
|
||||
# Under test and postcondition.
|
||||
error = requests.ConnectionError("connection refused")
|
||||
assert _run(error, form_run=True).status == CapabilityCheckStatus.FAILED
|
||||
assert _run(error).status == CapabilityCheckStatus.INCOMPLETE
|
||||
|
||||
|
||||
def test_rate_limit_from_a_typed_error() -> None:
|
||||
# Under test.
|
||||
result = _run(RateLimitedValidationError())
|
||||
|
||||
# Postcondition.
|
||||
assert result.error_kind == CheckErrorKind.RATE_LIMITED
|
||||
assert result.summary == "Rate limited by the source"
|
||||
|
||||
|
||||
def test_recorded_outcome_is_the_generic_row() -> None:
|
||||
# Under test.
|
||||
passed = _synthesize_results(_SOURCE, {CredentialCapability.INDEXING}, None, False)
|
||||
failed = _synthesize_results(
|
||||
_SOURCE, {CredentialCapability.INDEXING}, _http_error(403), False
|
||||
)
|
||||
|
||||
# Postcondition.
|
||||
(passed_row,) = passed
|
||||
assert passed_row.display_name == "Test connection"
|
||||
assert passed_row.summary == "Connected"
|
||||
(failed_row,) = failed
|
||||
assert failed_row.status == CapabilityCheckStatus.FAILED
|
||||
assert failed_row.error_kind == CheckErrorKind.PERMISSION
|
||||
assert failed_row.summary == "No permission for the connector"
|
||||
# The original class, for support.
|
||||
assert failed_row.error_type == "HTTPError"
|
||||
|
||||
|
||||
def test_recorded_pass_without_a_settings_probe_has_no_row() -> None:
|
||||
"""A source whose connector keeps the no-op probe tested nothing, so its
|
||||
pass records no row; a failure, e.g. at construction, still does."""
|
||||
# Under test and postcondition.
|
||||
indexing = {CredentialCapability.INDEXING}
|
||||
assert _synthesize_results(DocumentSource.GITLAB, indexing, None, False) == []
|
||||
(row,) = _synthesize_results(
|
||||
DocumentSource.GITLAB, indexing, RuntimeError("bad config"), False
|
||||
)
|
||||
assert row.status == CapabilityCheckStatus.INCOMPLETE
|
||||
@@ -40,11 +40,21 @@ def test_unregistered_source_gets_connector_settings_fallback() -> None:
|
||||
assert len(indexing_checks) == 1
|
||||
fallback = indexing_checks[0]
|
||||
assert fallback.check_id == "box_connector_settings"
|
||||
assert fallback.display_name == "Test connection"
|
||||
assert fallback.is_fallback is True
|
||||
assert fallback.fails_when_unreachable is True
|
||||
assert fallback.required is True
|
||||
assert fallback.requires_connector_instance is True
|
||||
|
||||
|
||||
def test_source_without_a_settings_probe_gets_no_check() -> None:
|
||||
"""Verifies a source whose connector keeps the no-op
|
||||
``validate_connector_settings`` has no INDEXING check: it would pass
|
||||
without testing anything."""
|
||||
# Under test and postcondition.
|
||||
assert get_capability_checks(DocumentSource.GITLAB) == []
|
||||
|
||||
|
||||
def test_registered_source_gets_no_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Verifies that registered named checks clobber the INDEXING fallback."""
|
||||
# Precondition.
|
||||
@@ -91,10 +101,12 @@ def test_fallback_check_calls_validate_connector_settings() -> None:
|
||||
check = get_capability_checks(DocumentSource.BOX)[0]
|
||||
|
||||
# Under test.
|
||||
check.run(context)
|
||||
output = check.run(context)
|
||||
|
||||
# Postcondition.
|
||||
connector.validate_connector_settings.assert_called_once_with()
|
||||
assert output is not None
|
||||
assert output.summary == "Connected"
|
||||
|
||||
|
||||
def test_oss_build_only_has_indexing_capability() -> None:
|
||||
|
||||
Reference in new issue
Block a user