4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
526 lines
17 KiB
Python
526 lines
17 KiB
Python
"""Dedicated unit tests for OpenObserveLogsTool.
|
|
|
|
Covers contract metadata, source-availability gating (token vs basic auth),
|
|
parameter extraction, default query fallback, bounded result limits, record
|
|
extraction across the supported response shapes (Elastic-style hits.hits,
|
|
top-level list, ``records`` field, ``data`` field), bearer vs basic
|
|
``Authorization`` header behavior, and HTTP failure handling.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from integrations.openobserve.tools.openobserve_logs_tool import query_openobserve_logs
|
|
from tests.tools.conftest import BaseToolContract, MockHttpxResponse
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Contract — metadata, is_available, extract_params surface
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestOpenObserveLogsToolContract(BaseToolContract):
|
|
def get_tool_under_test(self) -> Any:
|
|
return query_openobserve_logs.__opensre_registered_tool__
|
|
|
|
|
|
def _rt() -> Any:
|
|
return query_openobserve_logs.__opensre_registered_tool__
|
|
|
|
|
|
def test_is_available_true_with_api_token() -> None:
|
|
sources = {
|
|
"openobserve": {
|
|
"connection_verified": True,
|
|
"base_url": "https://oo.example.invalid",
|
|
"api_token": "oo-token",
|
|
}
|
|
}
|
|
assert _rt().is_available(sources) is True
|
|
|
|
|
|
def test_is_available_true_with_username_password() -> None:
|
|
sources = {
|
|
"openobserve": {
|
|
"connection_verified": True,
|
|
"base_url": "https://oo.example.invalid",
|
|
"username": "u",
|
|
"password": "p",
|
|
}
|
|
}
|
|
assert _rt().is_available(sources) is True
|
|
|
|
|
|
def test_is_available_false_without_credentials() -> None:
|
|
sources = {
|
|
"openobserve": {
|
|
"connection_verified": True,
|
|
"base_url": "https://oo.example.invalid",
|
|
}
|
|
}
|
|
assert _rt().is_available(sources) is False
|
|
|
|
|
|
def test_is_available_false_when_only_username_no_password() -> None:
|
|
sources = {
|
|
"openobserve": {
|
|
"connection_verified": True,
|
|
"base_url": "https://oo.example.invalid",
|
|
"username": "u",
|
|
"password": "",
|
|
}
|
|
}
|
|
assert _rt().is_available(sources) is False
|
|
|
|
|
|
def test_is_available_false_when_connection_unverified() -> None:
|
|
sources = {
|
|
"openobserve": {
|
|
"connection_verified": False,
|
|
"base_url": "https://oo.example.invalid",
|
|
"api_token": "oo-token",
|
|
}
|
|
}
|
|
assert _rt().is_available(sources) is False
|
|
|
|
|
|
def test_is_available_false_without_base_url() -> None:
|
|
sources = {
|
|
"openobserve": {
|
|
"connection_verified": True,
|
|
"api_token": "oo-token",
|
|
}
|
|
}
|
|
assert _rt().is_available(sources) is False
|
|
|
|
|
|
def test_is_available_false_when_no_openobserve_source() -> None:
|
|
assert _rt().is_available({}) is False
|
|
|
|
|
|
def test_extract_params_strips_and_uses_defaults() -> None:
|
|
sources = {
|
|
"openobserve": {
|
|
"base_url": " https://oo.example.invalid ",
|
|
"stream": " app_logs ",
|
|
"query": " level = 'error' ",
|
|
"api_token": " oo-token ",
|
|
"username": " ",
|
|
"password": " ",
|
|
"integration_id": " oo-1 ",
|
|
}
|
|
}
|
|
params = _rt().extract_params(sources)
|
|
assert params["base_url"] == "https://oo.example.invalid"
|
|
assert params["org"] == "default" # default fallback
|
|
assert params["stream"] == "app_logs"
|
|
assert params["query"] == "level = 'error'"
|
|
assert params["api_token"] == "oo-token"
|
|
assert params["username"] == ""
|
|
assert params["password"] == ""
|
|
assert params["time_range_minutes"] == 60 # default
|
|
assert params["limit"] == 50
|
|
assert params["max_results"] == 100 # _DEFAULT_MAX_RESULTS
|
|
assert params["integration_id"] == "oo-1"
|
|
|
|
|
|
def test_extract_params_falls_back_to_default_org_for_blank() -> None:
|
|
sources = {"openobserve": {"base_url": "https://x.invalid", "org": " "}}
|
|
assert _rt().extract_params(sources)["org"] == "default"
|
|
|
|
|
|
def test_extract_params_uses_default_max_results_when_zero() -> None:
|
|
sources = {"openobserve": {"base_url": "https://x.invalid", "max_results": 0}}
|
|
assert _rt().extract_params(sources)["max_results"] == 100
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Validation guards — missing config / required fields
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_returns_missing_url_when_base_url_blank() -> None:
|
|
result = query_openobserve_logs(base_url=" ", api_token="oo-token")
|
|
assert result["available"] is False
|
|
assert result["error"] == "Missing OpenObserve URL."
|
|
assert result["records"] == []
|
|
|
|
|
|
def test_returns_missing_credentials_when_no_auth_provided() -> None:
|
|
result = query_openobserve_logs(base_url="https://oo.example.invalid")
|
|
assert result["available"] is False
|
|
assert result["error"] == "Missing OpenObserve credentials."
|
|
assert result["records"] == []
|
|
|
|
|
|
def test_returns_missing_credentials_when_only_username_no_password() -> None:
|
|
result = query_openobserve_logs(
|
|
base_url="https://oo.example.invalid",
|
|
username="u",
|
|
password="",
|
|
)
|
|
assert result["available"] is False
|
|
assert result["error"] == "Missing OpenObserve credentials."
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auth header behavior — bearer token vs HTTP basic
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_uses_bearer_authorization_when_api_token_present(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
captured: dict[str, Any] = {}
|
|
|
|
def _fake_post(url: str, headers: dict[str, str], json: dict[str, Any], timeout: float):
|
|
captured["headers"] = headers
|
|
return MockHttpxResponse({"hits": []})
|
|
|
|
monkeypatch.setattr(
|
|
"integrations.openobserve.tools.openobserve_logs_tool.httpx.post", _fake_post
|
|
)
|
|
|
|
query_openobserve_logs(
|
|
base_url="https://oo.example.invalid",
|
|
api_token="oo-token",
|
|
)
|
|
assert captured["headers"]["Authorization"] == "Bearer oo-token"
|
|
assert captured["headers"]["Content-Type"] == "application/json"
|
|
|
|
|
|
def test_uses_basic_authorization_when_only_username_password_present(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
captured: dict[str, Any] = {}
|
|
|
|
def _fake_post(url: str, headers: dict[str, str], json: dict[str, Any], timeout: float):
|
|
captured["headers"] = headers
|
|
return MockHttpxResponse({"hits": []})
|
|
|
|
monkeypatch.setattr(
|
|
"integrations.openobserve.tools.openobserve_logs_tool.httpx.post", _fake_post
|
|
)
|
|
|
|
query_openobserve_logs(
|
|
base_url="https://oo.example.invalid",
|
|
username="alice",
|
|
password="secret",
|
|
)
|
|
expected = base64.b64encode(b"alice:secret").decode("ascii")
|
|
assert captured["headers"]["Authorization"] == f"Basic {expected}"
|
|
|
|
|
|
def test_bearer_takes_precedence_over_basic(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
captured: dict[str, Any] = {}
|
|
|
|
def _fake_post(url: str, headers: dict[str, str], json: dict[str, Any], timeout: float):
|
|
captured["headers"] = headers
|
|
return MockHttpxResponse({"hits": []})
|
|
|
|
monkeypatch.setattr(
|
|
"integrations.openobserve.tools.openobserve_logs_tool.httpx.post", _fake_post
|
|
)
|
|
|
|
query_openobserve_logs(
|
|
base_url="https://oo.example.invalid",
|
|
api_token="oo-token",
|
|
username="alice",
|
|
password="secret",
|
|
)
|
|
# When both are provided, bearer wins
|
|
assert captured["headers"]["Authorization"] == "Bearer oo-token"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Request shaping — endpoint, default query fallback, payload contents
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_endpoint_uses_default_org_when_blank(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
captured: dict[str, Any] = {}
|
|
|
|
def _fake_post(url: str, headers: dict[str, str], json: dict[str, Any], timeout: float):
|
|
captured["url"] = url
|
|
return MockHttpxResponse({"hits": []})
|
|
|
|
monkeypatch.setattr(
|
|
"integrations.openobserve.tools.openobserve_logs_tool.httpx.post", _fake_post
|
|
)
|
|
|
|
query_openobserve_logs(
|
|
base_url="https://oo.example.invalid/", # trailing slash should be stripped
|
|
org="",
|
|
api_token="oo-token",
|
|
)
|
|
assert captured["url"] == "https://oo.example.invalid/api/default/_search"
|
|
|
|
|
|
def test_endpoint_includes_provided_org(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
captured: dict[str, Any] = {}
|
|
|
|
def _fake_post(url: str, headers: dict[str, str], json: dict[str, Any], timeout: float):
|
|
captured["url"] = url
|
|
return MockHttpxResponse({"hits": []})
|
|
|
|
monkeypatch.setattr(
|
|
"integrations.openobserve.tools.openobserve_logs_tool.httpx.post", _fake_post
|
|
)
|
|
|
|
query_openobserve_logs(
|
|
base_url="https://oo.example.invalid",
|
|
org="acme",
|
|
api_token="oo-token",
|
|
)
|
|
assert captured["url"] == "https://oo.example.invalid/api/acme/_search"
|
|
|
|
|
|
def test_default_sql_used_when_query_blank(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
captured: dict[str, Any] = {}
|
|
|
|
def _fake_post(url: str, headers: dict[str, str], json: dict[str, Any], timeout: float):
|
|
captured["payload"] = json
|
|
return MockHttpxResponse({"hits": []})
|
|
|
|
monkeypatch.setattr(
|
|
"integrations.openobserve.tools.openobserve_logs_tool.httpx.post", _fake_post
|
|
)
|
|
|
|
query_openobserve_logs(
|
|
base_url="https://oo.example.invalid",
|
|
api_token="oo-token",
|
|
query="",
|
|
)
|
|
sql = captured["payload"]["query"]["sql"]
|
|
assert sql == "SELECT * FROM \"default\" WHERE level = 'error' ORDER BY _timestamp DESC"
|
|
|
|
|
|
def test_provided_query_is_passed_through(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
captured: dict[str, Any] = {}
|
|
|
|
def _fake_post(url: str, headers: dict[str, str], json: dict[str, Any], timeout: float):
|
|
captured["payload"] = json
|
|
return MockHttpxResponse({"hits": []})
|
|
|
|
monkeypatch.setattr(
|
|
"integrations.openobserve.tools.openobserve_logs_tool.httpx.post", _fake_post
|
|
)
|
|
|
|
query_openobserve_logs(
|
|
base_url="https://oo.example.invalid",
|
|
api_token="oo-token",
|
|
query="SELECT * FROM \"my_stream\" WHERE level = 'fatal'",
|
|
)
|
|
assert (
|
|
captured["payload"]["query"]["sql"] == "SELECT * FROM \"my_stream\" WHERE level = 'fatal'"
|
|
)
|
|
|
|
|
|
def test_payload_includes_size_and_time_window(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
captured: dict[str, Any] = {}
|
|
|
|
def _fake_post(url: str, headers: dict[str, str], json: dict[str, Any], timeout: float):
|
|
captured["payload"] = json
|
|
return MockHttpxResponse({"hits": []})
|
|
|
|
monkeypatch.setattr(
|
|
"integrations.openobserve.tools.openobserve_logs_tool.httpx.post", _fake_post
|
|
)
|
|
|
|
query_openobserve_logs(
|
|
base_url="https://oo.example.invalid",
|
|
api_token="oo-token",
|
|
time_range_minutes=15,
|
|
max_results=42,
|
|
)
|
|
payload = captured["payload"]
|
|
assert payload["size"] == 42
|
|
assert "start_time" in payload["query"]
|
|
assert "end_time" in payload["query"]
|
|
assert payload["query"]["end_time"] > payload["query"]["start_time"]
|
|
|
|
|
|
def test_stream_name_only_included_when_provided(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
captured: list[dict[str, Any]] = []
|
|
|
|
def _fake_post(url: str, headers: dict[str, str], json: dict[str, Any], timeout: float):
|
|
captured.append(json)
|
|
return MockHttpxResponse({"hits": []})
|
|
|
|
monkeypatch.setattr(
|
|
"integrations.openobserve.tools.openobserve_logs_tool.httpx.post", _fake_post
|
|
)
|
|
|
|
# Without stream
|
|
query_openobserve_logs(
|
|
base_url="https://oo.example.invalid",
|
|
api_token="oo-token",
|
|
stream="",
|
|
)
|
|
# With stream
|
|
query_openobserve_logs(
|
|
base_url="https://oo.example.invalid",
|
|
api_token="oo-token",
|
|
stream="app_logs",
|
|
)
|
|
|
|
assert "stream_name" not in captured[0]
|
|
assert captured[1]["stream_name"] == "app_logs"
|
|
|
|
|
|
def test_bounded_limit_caps_caller_request(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
captured: dict[str, Any] = {}
|
|
|
|
def _fake_post(url: str, headers: dict[str, str], json: dict[str, Any], timeout: float):
|
|
captured["size"] = json["size"]
|
|
return MockHttpxResponse({"hits": [{"message": f"m{idx}"} for idx in range(50)]})
|
|
|
|
monkeypatch.setattr(
|
|
"integrations.openobserve.tools.openobserve_logs_tool.httpx.post", _fake_post
|
|
)
|
|
|
|
result = query_openobserve_logs(
|
|
base_url="https://oo.example.invalid",
|
|
api_token="oo-token",
|
|
limit=1000,
|
|
max_results=4,
|
|
)
|
|
assert captured["size"] == 4
|
|
assert result["available"] is True
|
|
assert len(result["records"]) == 4
|
|
assert result["total_returned"] == 4
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Response normalization across supported shapes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_records_from_top_level_hits_list(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
def _fake_post(*_args: Any, **_kwargs: Any) -> MockHttpxResponse:
|
|
return MockHttpxResponse({"hits": [{"a": 1}, {"a": 2}]})
|
|
|
|
monkeypatch.setattr(
|
|
"integrations.openobserve.tools.openobserve_logs_tool.httpx.post", _fake_post
|
|
)
|
|
|
|
result = query_openobserve_logs(
|
|
base_url="https://oo.example.invalid",
|
|
api_token="oo-token",
|
|
)
|
|
assert result["records"] == [{"a": 1}, {"a": 2}]
|
|
|
|
|
|
def test_records_from_elastic_style_nested_hits(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
def _fake_post(*_args: Any, **_kwargs: Any) -> MockHttpxResponse:
|
|
return MockHttpxResponse(
|
|
{
|
|
"hits": {
|
|
"hits": [
|
|
{"_source": {"msg": "boom"}},
|
|
{"_source": {"msg": "kaboom"}},
|
|
]
|
|
}
|
|
}
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
"integrations.openobserve.tools.openobserve_logs_tool.httpx.post", _fake_post
|
|
)
|
|
|
|
result = query_openobserve_logs(
|
|
base_url="https://oo.example.invalid",
|
|
api_token="oo-token",
|
|
)
|
|
assert result["records"] == [{"msg": "boom"}, {"msg": "kaboom"}]
|
|
|
|
|
|
def test_records_from_records_field(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
def _fake_post(*_args: Any, **_kwargs: Any) -> MockHttpxResponse:
|
|
return MockHttpxResponse({"records": [{"x": 1}, "ignore-me", {"x": 2}]})
|
|
|
|
monkeypatch.setattr(
|
|
"integrations.openobserve.tools.openobserve_logs_tool.httpx.post", _fake_post
|
|
)
|
|
|
|
result = query_openobserve_logs(
|
|
base_url="https://oo.example.invalid",
|
|
api_token="oo-token",
|
|
)
|
|
# Only dict items are kept — strings are filtered out
|
|
assert result["records"] == [{"x": 1}, {"x": 2}]
|
|
|
|
|
|
def test_records_from_data_field(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
def _fake_post(*_args: Any, **_kwargs: Any) -> MockHttpxResponse:
|
|
return MockHttpxResponse({"data": [{"y": 9}]})
|
|
|
|
monkeypatch.setattr(
|
|
"integrations.openobserve.tools.openobserve_logs_tool.httpx.post", _fake_post
|
|
)
|
|
|
|
result = query_openobserve_logs(
|
|
base_url="https://oo.example.invalid",
|
|
api_token="oo-token",
|
|
)
|
|
assert result["records"] == [{"y": 9}]
|
|
|
|
|
|
def test_records_empty_on_unrecognized_shape(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
def _fake_post(*_args: Any, **_kwargs: Any) -> MockHttpxResponse:
|
|
return MockHttpxResponse({"unrelated": True})
|
|
|
|
monkeypatch.setattr(
|
|
"integrations.openobserve.tools.openobserve_logs_tool.httpx.post", _fake_post
|
|
)
|
|
|
|
result = query_openobserve_logs(
|
|
base_url="https://oo.example.invalid",
|
|
api_token="oo-token",
|
|
)
|
|
assert result["available"] is True
|
|
assert result["records"] == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HTTP failure handling
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_returns_unavailable_on_http_error_status(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
def _fake_post(*_args: Any, **_kwargs: Any) -> MockHttpxResponse:
|
|
return MockHttpxResponse(
|
|
{"err": "boom"},
|
|
raise_for_status_error=RuntimeError("HTTP 502 from OpenObserve"),
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
"integrations.openobserve.tools.openobserve_logs_tool.httpx.post", _fake_post
|
|
)
|
|
|
|
result = query_openobserve_logs(
|
|
base_url="https://oo.example.invalid",
|
|
api_token="oo-token",
|
|
)
|
|
assert result["available"] is False
|
|
assert "HTTP 502 from OpenObserve" in result["error"]
|
|
assert result["records"] == []
|
|
|
|
|
|
def test_returns_unavailable_on_request_exception(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
def _raise(*_args: Any, **_kwargs: Any) -> None:
|
|
raise ConnectionError("connection refused")
|
|
|
|
monkeypatch.setattr("integrations.openobserve.tools.openobserve_logs_tool.httpx.post", _raise)
|
|
|
|
result = query_openobserve_logs(
|
|
base_url="https://oo.example.invalid",
|
|
api_token="oo-token",
|
|
)
|
|
assert result["available"] is False
|
|
assert "connection refused" in result["error"]
|
|
assert result["records"] == []
|