Initial production analytics foundation
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from production_analytics.cli.__main__ import main
|
||||
from production_analytics.enlyze.exploration import ExplorationResponse
|
||||
|
||||
|
||||
class CliTests(unittest.TestCase):
|
||||
@patch.dict(os.environ, {"ENLYZE_BASE_URL": "https://enlyze.example"}, clear=True)
|
||||
@patch("production_analytics.cli.__main__.ExplorationClient.get")
|
||||
def test_raw_prints_and_saves_sanitized_response(self, get_mock: object) -> None:
|
||||
get_mock.return_value = ExplorationResponse( # type: ignore[attr-defined]
|
||||
status_code=200,
|
||||
path="/verified/path",
|
||||
headers={"Content-Type": "application/json"},
|
||||
body={"api_token": "secret", "authorization": "Bearer secret", "value": 4.2},
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
output = io.StringIO()
|
||||
with redirect_stdout(output):
|
||||
result = main(
|
||||
[
|
||||
"enlyze",
|
||||
"raw",
|
||||
"/verified/path",
|
||||
"--save-fixture",
|
||||
"example.json",
|
||||
"--fixture-dir",
|
||||
temporary_directory,
|
||||
"--save-raw",
|
||||
"raw-example.json",
|
||||
"--raw-dir",
|
||||
temporary_directory,
|
||||
]
|
||||
)
|
||||
fixture = json.loads((Path(temporary_directory) / "example.json").read_text())
|
||||
raw_capture = json.loads((Path(temporary_directory) / "raw-example.json").read_text())
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(fixture["response"]["body"]["api_token"], "<redacted-secret>")
|
||||
self.assertEqual(fixture["response"]["body"]["authorization"], "<redacted-secret>")
|
||||
self.assertNotIn("Bearer secret", output.getvalue())
|
||||
self.assertEqual(raw_capture["response"]["body"]["api_token"], "secret")
|
||||
|
||||
def test_raw_rejects_unsafe_fixture_name(self) -> None:
|
||||
with patch.dict(os.environ, {"ENLYZE_BASE_URL": "https://enlyze.example"}, clear=True):
|
||||
with patch("production_analytics.cli.__main__.ExplorationClient.get") as get_mock:
|
||||
get_mock.return_value = ExplorationResponse(200, "/path", {}, {})
|
||||
output = io.StringIO()
|
||||
with redirect_stdout(output):
|
||||
result = main(["enlyze", "raw", "/path", "--save-fixture", "../unsafe.json"])
|
||||
|
||||
self.assertEqual(result, 1)
|
||||
self.assertIn("plain filename", output.getvalue())
|
||||
|
||||
@patch.dict(os.environ, {"ENLYZE_BASE_URL": "https://enlyze.example"}, clear=True)
|
||||
@patch("production_analytics.cli.__main__.ExplorationClient.post_json")
|
||||
def test_timeseries_builds_schema_supported_read_only_post(self, post_mock: object) -> None:
|
||||
post_mock.return_value = ExplorationResponse( # type: ignore[attr-defined]
|
||||
200, "/v2/timeseries", {}, {"metadata": {"next_cursor": None}, "data": {"columns": [], "records": []}}
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
result = main(
|
||||
[
|
||||
"enlyze", "timeseries", "--machine", "machine-id", "--start", "2026-01-01T00:00:00+00:00",
|
||||
"--end", "2026-01-01T00:10:00+00:00", "--variable", "variable-id", "--resampling-interval", "10",
|
||||
"--resampling-method", "avg", "--save-fixture", "timeseries.json", "--fixture-dir", temporary_directory,
|
||||
]
|
||||
)
|
||||
fixture = json.loads((Path(temporary_directory) / "timeseries.json").read_text())
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(post_mock.call_args.args[0], "/v2/timeseries") # type: ignore[attr-defined]
|
||||
self.assertEqual(post_mock.call_args.args[1]["resampling_interval"], 10) # type: ignore[attr-defined]
|
||||
self.assertEqual(fixture["request"]["method"], "POST")
|
||||
@@ -0,0 +1,19 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import unittest
|
||||
|
||||
from production_analytics.domain import SourceRange
|
||||
|
||||
|
||||
class SourceRangeTests(unittest.TestCase):
|
||||
def test_accepts_ordered_timestamps(self) -> None:
|
||||
start = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
source_range = SourceRange(start=start, end=start + timedelta(minutes=1))
|
||||
|
||||
self.assertEqual(source_range.start, start)
|
||||
|
||||
|
||||
def test_rejects_reverse_interval(self) -> None:
|
||||
end = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "must not precede"):
|
||||
SourceRange(start=end + timedelta(seconds=1), end=end)
|
||||
@@ -0,0 +1,121 @@
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from urllib.error import HTTPError
|
||||
|
||||
from production_analytics.enlyze.exploration import (
|
||||
AuthenticationError,
|
||||
ExplorationClient,
|
||||
ExplorationSettings,
|
||||
NonJsonResponseError,
|
||||
load_secret_file,
|
||||
)
|
||||
from production_analytics.enlyze.sanitize import sanitize
|
||||
|
||||
|
||||
class _Response:
|
||||
status = 200
|
||||
headers = {"Content-Type": "application/json", "X-Request-Id": "request-123"}
|
||||
|
||||
def __init__(self, body: bytes) -> None:
|
||||
self._body = body
|
||||
|
||||
def read(self) -> bytes:
|
||||
return self._body
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class ExplorationClientTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.settings = ExplorationSettings.from_environment(
|
||||
{
|
||||
"ENLYZE_BASE_URL": "https://enlyze.example/",
|
||||
"ENLYZE_API_KEY": "secret",
|
||||
}
|
||||
)
|
||||
|
||||
@patch("production_analytics.enlyze.exploration.urlopen")
|
||||
def test_get_uses_explicit_header_and_decodes_json(self, urlopen_mock: object) -> None:
|
||||
urlopen_mock.return_value = _Response(b'{"items": [1]}') # type: ignore[attr-defined]
|
||||
|
||||
response = ExplorationClient(self.settings).get("/observed/path", {"limit": "1"})
|
||||
|
||||
request = urlopen_mock.call_args.args[0] # type: ignore[attr-defined]
|
||||
self.assertEqual(request.get_method(), "GET")
|
||||
self.assertEqual(request.get_header("Authorization"), "Bearer secret")
|
||||
self.assertEqual(response.body, {"items": [1]})
|
||||
|
||||
@patch("production_analytics.enlyze.exploration.urlopen")
|
||||
def test_non_json_response_is_clear_error(self, urlopen_mock: object) -> None:
|
||||
urlopen_mock.return_value = _Response(b"not json") # type: ignore[attr-defined]
|
||||
|
||||
with self.assertRaises(NonJsonResponseError):
|
||||
ExplorationClient(self.settings).get("/observed/path")
|
||||
|
||||
@patch("production_analytics.enlyze.exploration.urlopen")
|
||||
def test_post_json_sends_json_body_and_bearer_header(self, urlopen_mock: object) -> None:
|
||||
urlopen_mock.return_value = _Response(b'{"data": {}}') # type: ignore[attr-defined]
|
||||
|
||||
ExplorationClient(self.settings).post_json("/v2/timeseries", {"machine": "machine-id"})
|
||||
|
||||
request = urlopen_mock.call_args.args[0] # type: ignore[attr-defined]
|
||||
self.assertEqual(request.get_method(), "POST")
|
||||
self.assertEqual(request.get_header("Content-type"), "application/json")
|
||||
self.assertEqual(request.data, b'{"machine": "machine-id"}')
|
||||
self.assertEqual(request.get_header("Authorization"), "Bearer secret")
|
||||
|
||||
@patch("production_analytics.enlyze.exploration.urlopen")
|
||||
def test_authentication_error_does_not_expose_response_body(self, urlopen_mock: object) -> None:
|
||||
urlopen_mock.side_effect = HTTPError("https://example.invalid", 401, "Unauthorized", {}, io.BytesIO(b"secret")) # type: ignore[attr-defined]
|
||||
|
||||
with self.assertRaisesRegex(AuthenticationError, "HTTP 401"):
|
||||
ExplorationClient(self.settings).get("/observed/path")
|
||||
|
||||
|
||||
class SanitizationTests(unittest.TestCase):
|
||||
def test_sanitization_preserves_shape_and_redacts_sensitive_values(self) -> None:
|
||||
response = sanitize(
|
||||
{
|
||||
"machine_id": "machine-47",
|
||||
"token": "very-secret",
|
||||
"Authorization": "Bearer very-secret",
|
||||
"site_name": "Sensitive Site",
|
||||
"api_url": "https://internal.example/v1/items",
|
||||
"timestamp": "2026-01-01T00:00:00Z",
|
||||
"unit": "kW",
|
||||
"value": 12.5,
|
||||
"quality": "good",
|
||||
"items": [{"machine_id": "machine-47"}],
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(response["token"], "<redacted-secret>")
|
||||
self.assertEqual(response["Authorization"], "<redacted-secret>")
|
||||
self.assertEqual(response["site_name"], "<redacted-site_name>")
|
||||
self.assertEqual(response["api_url"], "https://redacted-host.invalid/v1/items")
|
||||
self.assertEqual(response["timestamp"], "2026-01-01T00:00:00Z")
|
||||
self.assertEqual(response["unit"], "kW")
|
||||
self.assertEqual(response["value"], 12.5)
|
||||
self.assertEqual(response["machine_id"], response["items"][0]["machine_id"])
|
||||
|
||||
|
||||
class SecretFileTests(unittest.TestCase):
|
||||
def test_secret_file_is_parsed_without_execution(self) -> None:
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8") as secret_file:
|
||||
secret_file.write("ENLYZE_API_KEY='key value'\nUNRELATED=plain # comment\n")
|
||||
secret_file.flush()
|
||||
|
||||
values = load_secret_file(secret_file.name)
|
||||
|
||||
self.assertEqual(values, {"ENLYZE_API_KEY": "key value", "UNRELATED": "plain"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user