Files
wehub-resource-sync 4b6817381b
Benchmark image — build + push to ECR (any adapter) / build + push (push) Waiting to run
CI / quality (ubuntu-latest) (push) Waiting to run
CI / test (tools-runtime) (push) Waiting to run
CI / test (e2e-general) (push) Waiting to run
CI / test (cli-runtime) (push) Waiting to run
CI / test (e2e-provider-and-openclaw) (push) Waiting to run
CI / test (integrations-and-misc) (push) Waiting to run
CI / coverage-report (push) Blocked by required conditions
CI / test-kubernetes (push) Waiting to run
CI / should-run-thorough (push) Waiting to run
CI / test-thorough (cloudwatch-demo) (push) Blocked by required conditions
CI / test-thorough (flink-ecs) (push) Blocked by required conditions
CI / test-thorough (upstream-lambda) (push) Blocked by required conditions
CI / test-thorough (prefect-ecs-fargate) (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Blocked by required conditions
Release / publish-release (push) Blocked by required conditions
Release / publish-main-release (push) Blocked by required conditions
Release / prepare (push) Waiting to run
Release / verify (push) Blocked by required conditions
Release / build-python-dist (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Blocked by required conditions
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Waiting to run
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

548 lines
22 KiB
Python

"""Unit tests for the Better Stack integration module.
Mirrors the test_rabbitmq.py pattern: config layer + validation against
mocked ``httpx.MockTransport`` responses, no real Better Stack calls.
"""
from __future__ import annotations
from collections.abc import Callable
import httpx
import pytest
from pydantic import ValidationError
from integrations import betterstack as bs_module
from integrations.betterstack import (
DEFAULT_BETTERSTACK_MAX_ROWS,
DEFAULT_BETTERSTACK_TIMEOUT_S,
BetterStackConfig,
BetterStackValidationResult,
betterstack_config_from_env,
betterstack_extract_params,
betterstack_is_available,
build_betterstack_config,
query_logs,
validate_betterstack_config,
)
# ---------------------------------------------------------------------------
# Mock transport helper
# ---------------------------------------------------------------------------
Handler = Callable[[httpx.Request], httpx.Response]
@pytest.fixture
def patched_sql_client(monkeypatch: pytest.MonkeyPatch):
"""Monkeypatch ``_sql_client`` to route through an ``httpx.MockTransport``.
Returns a callable ``install(handler)`` that accepts a per-test request
handler and wires it into the module under test.
"""
def install(handler: Handler) -> None:
def _fake_client(config: BetterStackConfig) -> httpx.Client:
return httpx.Client(
auth=(config.username, config.password),
timeout=float(config.timeout_seconds),
transport=httpx.MockTransport(handler),
)
monkeypatch.setattr(bs_module, "_sql_client", _fake_client)
return install
def _configured() -> BetterStackConfig:
return BetterStackConfig(
query_endpoint="https://eu-nbg-2-connect.betterstackdata.com",
username="u",
password="p",
)
class TestBetterStackConfig:
def test_defaults(self) -> None:
c = BetterStackConfig()
assert c.query_endpoint == ""
assert c.username == ""
assert c.password == ""
assert c.sources == []
assert c.timeout_seconds == DEFAULT_BETTERSTACK_TIMEOUT_S
assert c.max_rows == DEFAULT_BETTERSTACK_MAX_ROWS
assert c.is_configured is False
def test_is_configured_requires_endpoint_and_username(self) -> None:
assert BetterStackConfig(query_endpoint="https://x", username="u").is_configured is True
assert BetterStackConfig(query_endpoint="https://x").is_configured is False
assert BetterStackConfig(username="u").is_configured is False
def test_normalize_endpoint_strips_trailing_slash_and_whitespace(self) -> None:
c = BetterStackConfig(query_endpoint=" https://eu-nbg-2-connect.betterstackdata.com/ ")
assert c.query_endpoint == "https://eu-nbg-2-connect.betterstackdata.com"
def test_normalize_username_strips_whitespace(self) -> None:
assert BetterStackConfig(username=" u ").username == "u"
def test_normalize_password_strips_via_parent_validator(self) -> None:
# StrictConfigModel's wildcard string validator strips all fields
# before the field-specific validator runs, including passwords.
assert BetterStackConfig(password=" p ").password == "p"
def test_normalize_password_coerces_none(self) -> None:
assert BetterStackConfig(password=None).password == "" # type: ignore[arg-type]
def test_sources_from_comma_string(self) -> None:
c = BetterStackConfig(sources="t1_myapp, t2_gateway")
assert c.sources == ["t1_myapp", "t2_gateway"]
def test_sources_from_list(self) -> None:
c = BetterStackConfig(sources=["t1_myapp", "t2_gateway"])
assert c.sources == ["t1_myapp", "t2_gateway"]
def test_sources_empty_string(self) -> None:
assert BetterStackConfig(sources="").sources == []
def test_sources_none(self) -> None:
assert BetterStackConfig(sources=None).sources == [] # type: ignore[arg-type]
def test_sources_strip_whitespace_and_drop_empty(self) -> None:
assert BetterStackConfig(sources="a, , b").sources == ["a", "b"]
def test_timeout_zero_raises(self) -> None:
with pytest.raises(ValidationError):
BetterStackConfig(timeout_seconds=0)
def test_max_rows_exceeds_cap_raises(self) -> None:
with pytest.raises(ValidationError):
BetterStackConfig(max_rows=99_999)
def test_unknown_field_rejected(self) -> None:
with pytest.raises(ValidationError):
BetterStackConfig(endpoint_url="https://x") # type: ignore[call-arg]
class TestBuildBetterStackConfig:
def test_empty_input(self) -> None:
c = build_betterstack_config(None)
assert c.query_endpoint == ""
assert c.is_configured is False
def test_dict_input(self) -> None:
c = build_betterstack_config(
{
"query_endpoint": "https://x",
"username": "u",
"password": "p",
"sources": "t1,t2",
}
)
assert c.query_endpoint == "https://x"
assert c.username == "u"
assert c.sources == ["t1", "t2"]
class TestBetterStackConfigFromEnv:
def test_returns_none_without_endpoint(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("BETTERSTACK_QUERY_ENDPOINT", raising=False)
monkeypatch.setenv("BETTERSTACK_USERNAME", "u")
assert betterstack_config_from_env() is None
def test_returns_none_without_username(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("BETTERSTACK_QUERY_ENDPOINT", "https://x")
monkeypatch.delenv("BETTERSTACK_USERNAME", raising=False)
assert betterstack_config_from_env() is None
def test_loads_from_env_full(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(
"BETTERSTACK_QUERY_ENDPOINT",
"https://eu-nbg-2-connect.betterstackdata.com",
)
monkeypatch.setenv("BETTERSTACK_USERNAME", "u")
monkeypatch.setenv("BETTERSTACK_PASSWORD", "p")
monkeypatch.setenv("BETTERSTACK_SOURCES", "t1_myapp,t2_gateway")
c = betterstack_config_from_env()
assert c is not None
assert c.query_endpoint == "https://eu-nbg-2-connect.betterstackdata.com"
assert c.username == "u"
assert c.password == "p"
assert c.sources == ["t1_myapp", "t2_gateway"]
def test_loads_without_optional_sources(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("BETTERSTACK_QUERY_ENDPOINT", "https://x")
monkeypatch.setenv("BETTERSTACK_USERNAME", "u")
monkeypatch.delenv("BETTERSTACK_SOURCES", raising=False)
c = betterstack_config_from_env()
assert c is not None
assert c.sources == []
class TestBetterStackHelpers:
def test_is_available_true_with_configured_sources(self) -> None:
assert (
betterstack_is_available(
{
"betterstack": {
"query_endpoint": "https://x",
"username": "u",
"sources": ["t1_myapp"],
}
}
)
is True
)
def test_is_available_true_with_source_hint(self) -> None:
# Alert-derived source_hint (from betterstack_source annotation) is
# sufficient to make the tool callable even without a configured
# sources list.
assert (
betterstack_is_available(
{
"betterstack": {
"query_endpoint": "https://x",
"username": "u",
"sources": [],
"source_hint": "t1_alert_inferred",
}
}
)
is True
)
def test_is_available_false_without_endpoint(self) -> None:
assert betterstack_is_available({"betterstack": {"username": "u"}}) is False
def test_is_available_false_without_username(self) -> None:
assert betterstack_is_available({"betterstack": {"query_endpoint": "https://x"}}) is False
def test_is_available_false_when_source_missing(self) -> None:
assert betterstack_is_available({}) is False
def test_is_available_false_when_no_way_to_derive_source(self) -> None:
# Credentials present but neither a configured sources list nor an
# alert-derived source_hint — the executor has no path to propagate
# a source to the tool, so availability must be False to prevent a
# deterministic empty-source failure at call time.
assert (
betterstack_is_available(
{
"betterstack": {
"query_endpoint": "https://x",
"username": "u",
"sources": [],
}
}
)
is False
)
def test_extract_params_full(self) -> None:
params = betterstack_extract_params(
{
"betterstack": {
"query_endpoint": "https://x",
"username": "u",
"password": "p",
"sources": ["t1"],
"source_hint": "t2_alert_inferred",
}
}
)
assert params == {
"query_endpoint": "https://x",
"username": "u",
"password": "p",
"sources": ["t1"],
"source": "t2_alert_inferred",
}
def test_extract_params_defaults_when_missing(self) -> None:
assert betterstack_extract_params({}) == {
"query_endpoint": "",
"username": "",
"password": "",
"sources": [],
"source": "",
}
def test_extract_params_source_from_hint(self) -> None:
# The alert-derived source_hint surfaces as the scalar ``source`` kwarg
# so the executor can propagate alert context into the tool.
params = betterstack_extract_params({"betterstack": {"source_hint": "t99_svc"}})
assert params["source"] == "t99_svc"
def test_extract_params_source_empty_when_no_hint(self) -> None:
# When no alert hint is present, ``source`` is empty and the tool's
# own fallback picks the first configured source (if any).
params = betterstack_extract_params({"betterstack": {"sources": ["t1", "t2"]}})
assert params["source"] == ""
assert params["sources"] == ["t1", "t2"]
def test_extract_params_sources_copy_not_alias(self) -> None:
original = ["t1"]
params = betterstack_extract_params({"betterstack": {"sources": original}})
params["sources"].append("t2")
assert original == ["t1"]
class TestValidateBetterStackConfig:
def test_returns_not_configured_when_missing_creds(self) -> None:
result = validate_betterstack_config(BetterStackConfig())
assert isinstance(result, BetterStackValidationResult)
assert result.ok is False
assert "required" in result.detail.lower()
def test_ok_on_200_probe(self, patched_sql_client) -> None:
patched_sql_client(lambda _req: httpx.Response(200, text='{"1":1}\n'))
result = validate_betterstack_config(_configured())
assert result.ok is True
def test_fails_on_empty_body(self, patched_sql_client) -> None:
patched_sql_client(lambda _req: httpx.Response(200, text=""))
result = validate_betterstack_config(_configured())
assert result.ok is False
assert "empty body" in result.detail.lower()
def test_fails_on_401_with_auth_hint(self, patched_sql_client) -> None:
patched_sql_client(lambda _req: httpx.Response(401, text="bad creds"))
result = validate_betterstack_config(_configured())
assert result.ok is False
assert "authentication" in result.detail.lower()
assert "BETTERSTACK_USERNAME" in result.detail
def test_fails_on_404_with_endpoint_hint(self, patched_sql_client) -> None:
patched_sql_client(lambda _req: httpx.Response(404, text="not found"))
result = validate_betterstack_config(_configured())
assert result.ok is False
assert "endpoint" in result.detail.lower()
assert "BETTERSTACK_QUERY_ENDPOINT" in result.detail
def test_fails_on_500_includes_status_and_body(self, patched_sql_client) -> None:
patched_sql_client(lambda _req: httpx.Response(500, text="boom"))
result = validate_betterstack_config(_configured())
assert result.ok is False
assert "500" in result.detail
assert "boom" in result.detail
def test_fails_on_request_error(self, patched_sql_client) -> None:
def _raiser(_req: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("dns failed")
patched_sql_client(_raiser)
result = validate_betterstack_config(_configured())
assert result.ok is False
assert "request failed" in result.detail.lower()
assert "dns failed" in result.detail
def test_probe_uses_required_wire_contract(self, patched_sql_client) -> None:
captured: dict[str, httpx.Request] = {}
def _capturing(req: httpx.Request) -> httpx.Response:
captured["req"] = req
return httpx.Response(200, text='{"1":1}\n')
patched_sql_client(_capturing)
validate_betterstack_config(_configured())
req = captured["req"]
assert req.method == "POST"
assert req.url.params.get("output_format_pretty_row_numbers") == "0"
assert req.headers.get("content-type") == "text/plain"
assert req.content == b"SELECT 1 FORMAT JSONEachRow"
# Basic auth header is populated by httpx from the client's ``auth`` tuple.
assert req.headers.get("authorization", "").lower().startswith("basic ")
class TestQueryLogs:
def test_not_configured(self) -> None:
result = query_logs(BetterStackConfig(), source="t1_x")
assert result["available"] is False
assert "not configured" in result["error"].lower()
assert result["rows"] == []
assert result["row_count"] == 0
def test_invalid_source_name_rejected(self, patched_sql_client) -> None:
called = {"n": 0}
def _never(_req: httpx.Request) -> httpx.Response:
called["n"] += 1
return httpx.Response(200, text="")
patched_sql_client(_never)
result = query_logs(_configured(), source="t1; DROP TABLE users;--")
assert result["available"] is False
assert "invalid" in result["error"].lower()
assert called["n"] == 0 # must not fire a request
def test_empty_source_rejected(self) -> None:
result = query_logs(_configured(), source="")
assert result["available"] is False
assert "invalid" in result["error"].lower()
@pytest.mark.parametrize(
"payload",
[
"t1; DROP TABLE users;--",
"'; DROP TABLE logs--",
"source OR 1=1",
"../../../etc/passwd",
"t1_logs UNION SELECT password FROM users",
"t1_logs/*comment*/",
"t1_logs -- trailing comment",
"t1_logs`",
"t1_logs\nDROP TABLE secrets",
"t1_logs'",
],
)
def test_source_name_rejects_sql_injection_patterns(self, payload: str) -> None:
assert bs_module._validate_source_name(payload) is None
def test_invalid_since_rejected(self, patched_sql_client) -> None:
patched_sql_client(lambda _r: httpx.Response(200, text=""))
result = query_logs(_configured(), source="t1_myapp", since="not-a-timestamp")
assert result["available"] is False
assert "since" in result["error"].lower()
def test_invalid_until_rejected(self, patched_sql_client) -> None:
patched_sql_client(lambda _r: httpx.Response(200, text=""))
result = query_logs(_configured(), source="t1_myapp", until="nope")
assert result["available"] is False
assert "until" in result["error"].lower()
def test_happy_path_parses_jsoneachrow(self, patched_sql_client) -> None:
body = (
'{"dt":"2026-04-20T00:00:00Z","raw":"hello"}\n'
'{"dt":"2026-04-20T00:00:01Z","raw":"world"}\n'
)
patched_sql_client(lambda _r: httpx.Response(200, text=body))
result = query_logs(_configured(), source="t1_myapp")
assert result["available"] is True
assert result["betterstack_source"] == "t1_myapp"
assert result["row_count"] == 2
assert result["rows"][0]["raw"] == "hello"
assert result["rows"][1]["raw"] == "world"
def test_empty_body_is_zero_rows(self, patched_sql_client) -> None:
patched_sql_client(lambda _r: httpx.Response(200, text=""))
result = query_logs(_configured(), source="t1_myapp")
assert result["available"] is True
assert result["rows"] == []
assert result["row_count"] == 0
def test_skips_malformed_rows(self, patched_sql_client) -> None:
body = '{"ok":1}\nnot-json\n{"ok":2}\n'
patched_sql_client(lambda _r: httpx.Response(200, text=body))
result = query_logs(_configured(), source="t1_myapp")
assert result["row_count"] == 2
assert result["rows"] == [{"ok": 1}, {"ok": 2}]
def test_401_reported_as_auth(self, patched_sql_client) -> None:
patched_sql_client(lambda _r: httpx.Response(401, text="nope"))
result = query_logs(_configured(), source="t1_myapp")
assert result["available"] is False
assert "authentication" in result["error"].lower()
def test_5xx_reports_status_and_body(self, patched_sql_client) -> None:
patched_sql_client(lambda _r: httpx.Response(500, text="boom"))
result = query_logs(_configured(), source="t1_myapp")
assert result["available"] is False
assert "500" in result["error"]
assert "boom" in result["error"]
def test_request_error_surfaced(self, patched_sql_client) -> None:
def _raiser(_r: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("dns failed")
patched_sql_client(_raiser)
result = query_logs(_configured(), source="t1_myapp")
assert result["available"] is False
assert "request failed" in result["error"].lower()
def test_limit_clamped_to_config_max_rows(self, patched_sql_client) -> None:
captured: dict[str, httpx.Request] = {}
def _capturing(req: httpx.Request) -> httpx.Response:
captured["req"] = req
return httpx.Response(200, text="")
patched_sql_client(_capturing)
config = BetterStackConfig(
query_endpoint="https://x",
username="u",
password="p",
max_rows=50,
)
result = query_logs(config, source="t1_myapp", limit=9999)
assert result["limit"] == 50
body = captured["req"].content.decode()
assert "LIMIT 50" in body
def test_negative_limit_clamped_to_one(self, patched_sql_client) -> None:
# A negative ``limit`` must never reach the SQL as ``LIMIT -1`` — ClickHouse
# rejects that with a 400 parse error. The integration clamps with max(1, ...).
captured: dict[str, httpx.Request] = {}
def _capturing(req: httpx.Request) -> httpx.Response:
captured["req"] = req
return httpx.Response(200, text="")
patched_sql_client(_capturing)
query_logs(_configured(), source="t1_myapp", limit=-1)
body = captured["req"].content.decode()
assert "LIMIT 1" in body
assert "LIMIT -1" not in body
def test_sql_unions_recent_and_historical(self, patched_sql_client) -> None:
captured: dict[str, httpx.Request] = {}
def _capturing(req: httpx.Request) -> httpx.Response:
captured["req"] = req
return httpx.Response(200, text="")
patched_sql_client(_capturing)
query_logs(_configured(), source="t1_myapp")
body = captured["req"].content.decode()
# Both branches of the UNION must be present, with the expected suffixes.
assert "remote(t1_myapp_logs)" in body
assert "s3Cluster(primary, t1_myapp_s3)" in body
assert "UNION ALL" in body
# _row_type=1 always filters the s3 branch down to log rows.
assert "_row_type = 1" in body
assert body.rstrip().endswith("FORMAT JSONEachRow")
def test_sql_contains_time_bounds_when_set(self, patched_sql_client) -> None:
captured: dict[str, httpx.Request] = {}
def _capturing(req: httpx.Request) -> httpx.Response:
captured["req"] = req
return httpx.Response(200, text="")
patched_sql_client(_capturing)
query_logs(
_configured(),
source="t1_myapp",
since="2026-04-20T00:00:00Z",
until="2026-04-20T01:00:00Z",
)
body = captured["req"].content.decode()
assert "dt >= parseDateTime64BestEffort('2026-04-20T00:00:00Z'" in body
assert "dt <= parseDateTime64BestEffort('2026-04-20T01:00:00Z'" in body
def test_sql_omits_time_bounds_when_unset(self, patched_sql_client) -> None:
captured: dict[str, httpx.Request] = {}
def _capturing(req: httpx.Request) -> httpx.Response:
captured["req"] = req
return httpx.Response(200, text="")
patched_sql_client(_capturing)
query_logs(_configured(), source="t1_myapp")
body = captured["req"].content.decode()
# No explicit time bounds in the WHERE clauses — the s3 branch still
# carries _row_type = 1 but there's no parseDateTime64BestEffort call.
assert "parseDateTime64BestEffort" not in body
assert "ORDER BY dt DESC" in body