From 0a2d9b9f79074893e969fa30734930dc7d978c55 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 03:02:03 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01KFMazQbuG8ui3jxBxm47sC --- .agents/feature-map/components/connectors.md | 5 +- .../capability_checks/generic_row.py | 154 ++++++++++++ .../connectors/capability_checks/recorder.py | 46 ++-- .../connectors/capability_checks/registry.py | 39 ++- backend/onyx/connectors/factory.py | 12 + .../test_capability_check_run.py | 14 +- .../test_capability_check_ratchet.py | 19 +- .../test_generic_check_row.py | 227 ++++++++++++++++++ .../test_registry_fallbacks.py | 14 +- 9 files changed, 490 insertions(+), 40 deletions(-) create mode 100644 backend/onyx/connectors/capability_checks/generic_row.py create mode 100644 backend/tests/unit/onyx/connectors/capability_checks/test_generic_check_row.py diff --git a/.agents/feature-map/components/connectors.md b/.agents/feature-map/components/connectors.md index 860b232282..74b2f39dc5 100644 --- a/.agents/feature-map/components/connectors.md +++ b/.agents/feature-map/components/connectors.md @@ -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 diff --git a/backend/onyx/connectors/capability_checks/generic_row.py b/backend/onyx/connectors/capability_checks/generic_row.py new file mode 100644 index 0000000000..c4a2d2c271 --- /dev/null +++ b/backend/onyx/connectors/capability_checks/generic_row.py @@ -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) diff --git a/backend/onyx/connectors/capability_checks/recorder.py b/backend/onyx/connectors/capability_checks/recorder.py index fb98cffd8c..007382ffd7 100644 --- a/backend/onyx/connectors/capability_checks/recorder.py +++ b/backend/onyx/connectors/capability_checks/recorder.py @@ -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, diff --git a/backend/onyx/connectors/capability_checks/registry.py b/backend/onyx/connectors/capability_checks/registry.py index 6d5a06eea1..c68f5f5d2d 100644 --- a/backend/onyx/connectors/capability_checks/registry.py +++ b/backend/onyx/connectors/capability_checks/registry.py @@ -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", diff --git a/backend/onyx/connectors/factory.py b/backend/onyx/connectors/factory.py index 34bc57fe42..1975a58a4c 100644 --- a/backend/onyx/connectors/factory.py +++ b/backend/onyx/connectors/factory.py @@ -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.""" diff --git a/backend/tests/integration/tests/capability_checks/test_capability_check_run.py b/backend/tests/integration/tests/capability_checks/test_capability_check_run.py index 687dae2e91..db19c05df1 100644 --- a/backend/tests/integration/tests/capability_checks/test_capability_check_run.py +++ b/backend/tests/integration/tests/capability_checks/test_capability_check_run.py @@ -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 diff --git a/backend/tests/unit/onyx/connectors/capability_checks/test_capability_check_ratchet.py b/backend/tests/unit/onyx/connectors/capability_checks/test_capability_check_ratchet.py index 82f44a3633..d004a37ec2 100644 --- a/backend/tests/unit/onyx/connectors/capability_checks/test_capability_check_ratchet.py +++ b/backend/tests/unit/onyx/connectors/capability_checks/test_capability_check_ratchet.py @@ -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) + ) diff --git a/backend/tests/unit/onyx/connectors/capability_checks/test_generic_check_row.py b/backend/tests/unit/onyx/connectors/capability_checks/test_generic_check_row.py new file mode 100644 index 0000000000..42f783a07b --- /dev/null +++ b/backend/tests/unit/onyx/connectors/capability_checks/test_generic_check_row.py @@ -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 diff --git a/backend/tests/unit/onyx/connectors/capability_checks/test_registry_fallbacks.py b/backend/tests/unit/onyx/connectors/capability_checks/test_registry_fallbacks.py index e9bf705578..4465894af4 100644 --- a/backend/tests/unit/onyx/connectors/capability_checks/test_registry_fallbacks.py +++ b/backend/tests/unit/onyx/connectors/capability_checks/test_registry_fallbacks.py @@ -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: