This commit is contained in:
1 parent
646ba3be81
commit
aae5b73581
14 files changed
+883
-142
No files matched your search
+142
-30
@@ -1,14 +1,16 @@
|
||||
from copy import deepcopy
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
import pyarrow.parquet as pq
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from solar_relay.config import HistoryConfig, utc_text
|
||||
from solar_relay.history import CHANNELS, HistoryRecovery, history_sample
|
||||
from solar_relay.history import CHANNELS, HistoryRecovery, chart_entries, history_sample
|
||||
from solar_relay.http import TransportError
|
||||
from solar_relay.solarweb import ChartDataError
|
||||
from solar_relay.storage import Store
|
||||
from tests.test_status import observed
|
||||
|
||||
@@ -22,13 +24,16 @@ def entry(when):
|
||||
for field, channel in CHANNELS.items()]}
|
||||
|
||||
|
||||
class FakeHistoryHttp:
|
||||
def __init__(self, entries):
|
||||
self.entries, self.requests = entries, []
|
||||
class FakeCharts:
|
||||
def __init__(self, when):
|
||||
self.points = {dataset: {int((when + timedelta(minutes=offset)).timestamp() * 1000): watts
|
||||
for offset in [0, 5, 10]}
|
||||
for dataset, watts in [("production", 1200), ("consumption", 1440)]}
|
||||
self.requests = []
|
||||
|
||||
def request(self, url, **kwargs):
|
||||
self.requests.append((url, kwargs))
|
||||
return {"pvSystemId": SYSTEM, "data": deepcopy(self.entries)}
|
||||
def get_chart(self, system_id, dataset, date):
|
||||
self.requests.append((system_id, dataset, date))
|
||||
return dict(self.points[dataset])
|
||||
|
||||
|
||||
class HistoryTests(unittest.TestCase):
|
||||
@@ -38,9 +43,9 @@ class HistoryTests(unittest.TestCase):
|
||||
self.store = Store(Path(self.directory.name) / "archive")
|
||||
self.when = datetime(2026, 10, 30, 10, tzinfo=timezone.utc)
|
||||
self.source = "test-house"
|
||||
self.http = FakeHistoryHttp([entry(self.when + timedelta(minutes=offset)) for offset in [0, 5, 10]])
|
||||
self.client = FakeCharts(self.when)
|
||||
self.config = HistoryConfig(enabled=True, pv_system_id=SYSTEM)
|
||||
self.recovery = HistoryRecovery(self.config, self.source, self.store, http=self.http, credentials=("id-test", "value-test"))
|
||||
self.recovery = HistoryRecovery(self.config, self.source, self.store, client=self.client)
|
||||
|
||||
def recover(self):
|
||||
return self.recovery.recover(utc_text(self.when), utc_text(self.when + timedelta(minutes=15)), now=self.when + timedelta(hours=4))
|
||||
@@ -56,12 +61,12 @@ class HistoryTests(unittest.TestCase):
|
||||
self.assertIsNone(recovered[0]["readings"]["boiler_enabled"])
|
||||
self.assertNotIn("boiler", recovered[0]["source_timestamps"])
|
||||
self.assertIn("boiler", recovered[0]["errors"])
|
||||
url, kwargs = self.http.requests[0]
|
||||
self.assertEqual(urlsplit(url).hostname, "swqapi.solarweb.com")
|
||||
self.assertEqual(kwargs["extra_headers"], {"AccessKeyId": "id-test", "AccessKeyValue": "value-test"})
|
||||
self.assertNotIn("value-test", url)
|
||||
self.assertEqual(self.client.requests, [(SYSTEM, dataset, self.when.date())
|
||||
for dataset in ("production", "consumption")])
|
||||
self.assertEqual(recovered[0]["raw"]["solarweb_history"]["chart_power_w"],
|
||||
{"production": 1200, "consumption": 1440})
|
||||
self.assertEqual(self.recover(), 0)
|
||||
self.assertEqual(len(self.http.requests), 1)
|
||||
self.assertEqual(len(self.client.requests), 2)
|
||||
|
||||
def test_cloud_energy_does_not_mark_boiler_or_meter_history_complete(self):
|
||||
self.assertEqual(self.recover(), 3)
|
||||
@@ -76,10 +81,11 @@ class HistoryTests(unittest.TestCase):
|
||||
self.assertEqual(row["interval_energy_wh"]["production"], 100)
|
||||
|
||||
def test_partial_cloud_records_remain_missing_for_retry(self):
|
||||
self.http.entries[1]["channels"][0]["value"] = None
|
||||
stamp = int((self.when + timedelta(minutes=5)).timestamp() * 1000)
|
||||
del self.client.points["production"][stamp]
|
||||
self.assertEqual(self.recover(), 2)
|
||||
self.assertEqual(self.recover(), 0)
|
||||
self.http.entries[1] = entry(self.when + timedelta(minutes=5))
|
||||
self.client.points["production"][stamp] = 1200
|
||||
self.assertEqual(self.recover(), 1)
|
||||
|
||||
def test_recovered_identifiers_are_stable_for_duplicate_delivery(self):
|
||||
@@ -99,23 +105,129 @@ class HistoryTests(unittest.TestCase):
|
||||
history_sample(value, self.source, SYSTEM)
|
||||
|
||||
def test_missing_private_credentials_fail_before_requests(self):
|
||||
with self.assertRaisesRegex(ValueError, "SOLARWEB_ACCESS_KEY"):
|
||||
with self.assertRaisesRegex(ValueError, "SOLARWEB_USERNAME and SOLARWEB_PASSWORD"):
|
||||
HistoryRecovery(self.config, self.source, self.store, credentials=("", ""))
|
||||
|
||||
def test_requests_are_split_at_utc_midnight_even_across_dst(self):
|
||||
self.http.entries = []
|
||||
def test_config_credentials_take_precedence_over_environment(self):
|
||||
config = replace(self.config, username="private@example.test", password="synthetic password é &")
|
||||
with patch("solar_relay.history.os.environ", {"SOLARWEB_USERNAME": "env@example.test", "SOLARWEB_PASSWORD": "env-password"}), \
|
||||
patch("solar_relay.history.SolarWebClient", return_value=self.client) as client:
|
||||
recovery = HistoryRecovery(config, self.source, self.store)
|
||||
self.assertEqual(recovery.recover(utc_text(self.when), utc_text(self.when + timedelta(minutes=15)),
|
||||
now=self.when + timedelta(hours=4)), 3)
|
||||
client.assert_called_once_with("private@example.test", "synthetic password é &", timeout=config.request_timeout_seconds)
|
||||
|
||||
def test_empty_config_credentials_use_environment_fallback(self):
|
||||
config = replace(self.config, username_env="BACKUP_USER", password_env="BACKUP_PASSWORD")
|
||||
with patch("solar_relay.history.os.environ", {"BACKUP_USER": "env@example.test", "BACKUP_PASSWORD": "env-password"}), \
|
||||
patch("solar_relay.history.SolarWebClient", return_value=self.client) as client:
|
||||
HistoryRecovery(config, self.source, self.store)
|
||||
client.assert_called_once_with("env@example.test", "env-password", timeout=config.request_timeout_seconds)
|
||||
|
||||
def test_partial_config_credentials_never_mix_with_environment(self):
|
||||
for changes in [{"username": "private@example.test"}, {"password": "private-password"}]:
|
||||
with self.subTest(changes=changes), \
|
||||
patch("solar_relay.history.os.environ", {"SOLARWEB_USERNAME": "env@example.test", "SOLARWEB_PASSWORD": "env-password"}), \
|
||||
patch("solar_relay.history.SolarWebClient") as client:
|
||||
with self.assertRaisesRegex(ValueError, "history.username and history.password"):
|
||||
HistoryRecovery(replace(self.config, **changes), self.source, self.store)
|
||||
client.assert_not_called()
|
||||
|
||||
def test_requests_use_local_dates_across_dst(self):
|
||||
self.recovery.recover("2026-10-24T22:00:00Z", "2026-10-26T00:00:00Z", now=datetime(2026, 10, 27, tzinfo=timezone.utc))
|
||||
self.assertEqual(len(self.http.requests), 2)
|
||||
for url, _ in self.http.requests:
|
||||
query = parse_qs(urlsplit(url).query)
|
||||
start, end = [datetime.fromisoformat(query[name][0]) for name in ("from", "to")]
|
||||
self.assertLessEqual((end - start).total_seconds(), 86400)
|
||||
self.assertEqual([date.isoformat() for _, dataset, date in self.client.requests if dataset == "production"],
|
||||
["2026-10-25", "2026-10-26"])
|
||||
|
||||
def test_recent_intervals_wait_for_the_hourly_cloud_upload(self):
|
||||
self.http.entries = []
|
||||
self.recovery.recover(utc_text(self.when), utc_text(self.when + timedelta(hours=4)), now=self.when + timedelta(hours=4))
|
||||
query = parse_qs(urlsplit(self.http.requests[0][0]).query)
|
||||
self.assertEqual(query["to"], [utc_text(self.when + timedelta(hours=2))])
|
||||
for points in self.client.points.values():
|
||||
points.clear()
|
||||
for offset in [0, 115, 120, 235]:
|
||||
points[int((self.when + timedelta(minutes=offset)).timestamp() * 1000)] = 1200
|
||||
self.assertEqual(self.recovery.recover(utc_text(self.when), utc_text(self.when + timedelta(hours=4)),
|
||||
now=self.when + timedelta(hours=4)), 2)
|
||||
|
||||
def test_automatic_recovery_checks_retention_before_first_observation_and_respects_cutoff(self):
|
||||
now = datetime(2026, 11, 2, 12, 2, 17, tzinfo=timezone.utc)
|
||||
cutoff = datetime(2026, 10, 3, 12, 5, tzinfo=timezone.utc)
|
||||
latest = datetime(2026, 11, 2, 10, tzinfo=timezone.utc)
|
||||
recoverable = [cutoff, now.replace(minute=0, second=0) - timedelta(days=25), latest - timedelta(minutes=5)]
|
||||
points = {int(when.timestamp() * 1000): 1200 for when in
|
||||
[cutoff - timedelta(minutes=5), *recoverable, latest]}
|
||||
self.client.points = {"production": points, "consumption": points}
|
||||
self.store.add(observed(now.replace(minute=0, second=0)))
|
||||
self.assertEqual(self.recovery.recover(now=now), 3)
|
||||
recovered = [record for record in self.store.records() if "history_interval" in record]
|
||||
self.assertEqual([record["observed_at"] for record in recovered], [utc_text(when) for when in recoverable])
|
||||
self.assertEqual(min(day for _, _, day in self.client.requests), cutoff.date())
|
||||
|
||||
def test_automatic_recovery_checks_entirely_empty_days_and_archives(self):
|
||||
self.store.retention_days = 2
|
||||
self.assertEqual(self.recovery.recover(now=self.when + timedelta(days=2)), 3)
|
||||
self.assertEqual({day for _, _, day in self.client.requests},
|
||||
{(self.when + timedelta(days=offset)).date() for offset in range(3)})
|
||||
|
||||
def test_automatic_recovery_supports_retention_longer_than_manual_limit(self):
|
||||
self.store.retention_days = 45
|
||||
now = self.when + timedelta(days=40)
|
||||
self.assertEqual(self.recovery.recover(now=now), 3)
|
||||
self.assertEqual(min(day for _, _, day in self.client.requests), (now - timedelta(days=45)).date())
|
||||
with self.assertRaisesRegex(ValueError, "at most 30 days"):
|
||||
self.recovery.recover(utc_text(now - timedelta(days=45)), utc_text(now), now=now)
|
||||
|
||||
def test_complete_retention_window_does_not_request_solarweb(self):
|
||||
now = self.when + timedelta(days=1)
|
||||
lower = int(self.when.timestamp())
|
||||
upper = int((now - timedelta(seconds=self.config.lag_seconds)).timestamp())
|
||||
store = Mock(retention_days=1)
|
||||
store.energy_slots.return_value = set(range(lower, upper, 300))
|
||||
recovery = HistoryRecovery(self.config, self.source, store, client=self.client)
|
||||
self.assertEqual(recovery.recover(now=now), 0)
|
||||
self.assertEqual(self.client.requests, [])
|
||||
store.add.assert_not_called()
|
||||
|
||||
def test_unavailable_cloud_day_does_not_block_newer_missing_days(self):
|
||||
self.store.retention_days = 2
|
||||
available = self.when + timedelta(days=1)
|
||||
self.client.points = {dataset: {int(available.timestamp() * 1000): 1200}
|
||||
for dataset in ("production", "consumption")}
|
||||
get_chart = self.client.get_chart
|
||||
|
||||
def charts(system, dataset, day):
|
||||
if day == self.when.date():
|
||||
raise ChartDataError("synthetic secret response must not be logged")
|
||||
return get_chart(system, dataset, day)
|
||||
|
||||
with patch.object(self.client, "get_chart", side_effect=charts), self.assertLogs("solar_relay.history", level="WARNING") as logs:
|
||||
self.assertEqual(self.recovery.recover(now=self.when + timedelta(days=2)), 1)
|
||||
self.assertNotIn("synthetic secret response", " ".join(logs.output))
|
||||
self.assertEqual(next(self.store.records())["observed_at"], utc_text(available))
|
||||
|
||||
def test_login_or_transport_failure_stops_the_scan_without_repeated_attempts(self):
|
||||
with patch.object(self.client, "get_chart", side_effect=TransportError("Login failed")) as client:
|
||||
with self.assertRaisesRegex(TransportError, "Login failed"):
|
||||
self.recovery.recover(now=self.when + timedelta(days=2))
|
||||
self.assertEqual(client.call_count, 1)
|
||||
|
||||
def test_chart_conversion_matches_solar_and_retains_provenance(self):
|
||||
for generated, consumed in [(1200, 1440), (2400, 1200), (0, 0)]:
|
||||
with self.subTest(generated=generated, consumed=consumed):
|
||||
stamp = int(self.when.timestamp() * 1000)
|
||||
record = next(chart_entries({stamp: generated}, {stamp: consumed}))
|
||||
values = history_sample(record, self.source, SYSTEM)["history_interval"]["energy_wh"]
|
||||
self.assertEqual(values, {"production": generated / 12, "consumption": consumed / 12,
|
||||
"self_consumption": min(generated, consumed) / 12,
|
||||
"bought": max(0, consumed - generated) / 12,
|
||||
"sold": max(0, generated - consumed) / 12})
|
||||
self.assertEqual(record["energy_method"], "constant_power_over_five_minutes")
|
||||
|
||||
def test_dst_days_recover_every_actual_five_minute_slot(self):
|
||||
for day, expected in [("2026-03-29", 276), ("2026-10-25", 300)]:
|
||||
with self.subTest(day=day):
|
||||
start = datetime.fromisoformat(day).replace(tzinfo=ZoneInfo("Europe/Zurich"))
|
||||
end = start + timedelta(days=1)
|
||||
points = {stamp: 1200 for stamp in range(int(start.timestamp() * 1000), int(end.timestamp() * 1000), 300_000)}
|
||||
self.client.points = {"production": points, "consumption": points}
|
||||
self.assertEqual(self.recovery.recover(utc_text(start), utc_text(end), now=end + timedelta(days=1)), expected)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+68
-2
@@ -10,7 +10,7 @@ import threading
|
||||
import unittest
|
||||
import subprocess
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import Mock, patch
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from solar_relay.config import InverterConfig, UploadConfig, date_bound, load_config
|
||||
@@ -244,7 +244,7 @@ class DeliveryTests(unittest.TestCase):
|
||||
reading = client.collect("test")
|
||||
self.assertEqual(reading["errors"], {})
|
||||
self.store.add(reading)
|
||||
uploader = Uploader(UploadConfig(url=base + "/ingest"), self.store, token="test-token")
|
||||
uploader = Uploader(UploadConfig(url=base + "/ingest", token="test-token"), self.store)
|
||||
httpd.fail_once = True
|
||||
with self.assertRaises(UploadError):
|
||||
uploader.upload_once()
|
||||
@@ -323,6 +323,27 @@ class DeliveryTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ValueError, "SOLAR_RELAY_TOKEN"):
|
||||
Uploader(UploadConfig(url="https://example.test/ingest"), self.store)
|
||||
|
||||
def test_config_upload_token_takes_precedence_over_environment(self):
|
||||
reading = sample()
|
||||
self.store.add(reading)
|
||||
http = Mock()
|
||||
http.request.return_value = {"accepted_ids": [reading["id"]]}
|
||||
with patch("solar_relay.upload.os.environ", {"SOLAR_RELAY_TOKEN": "env-token"}):
|
||||
uploader = Uploader(UploadConfig(url="https://example.test/ingest", token="config-token"), self.store, http=http)
|
||||
self.assertEqual(uploader.upload_once(), 1)
|
||||
self.assertEqual(http.request.call_args.kwargs["token"], "config-token")
|
||||
|
||||
def test_empty_config_token_uses_environment_fallback(self):
|
||||
with patch("solar_relay.upload.os.environ", {"BACKUP_TOKEN": "env-token"}):
|
||||
uploader = Uploader(UploadConfig(url="https://example.test/ingest", token_env="BACKUP_TOKEN"), self.store)
|
||||
self.assertEqual(uploader.token, "env-token")
|
||||
|
||||
def test_invalid_config_token_does_not_fall_back_or_leak_its_value(self):
|
||||
with patch("solar_relay.upload.os.environ", {"SOLAR_RELAY_TOKEN": "env-token"}):
|
||||
with self.assertRaises(ValueError) as raised:
|
||||
Uploader(UploadConfig(url="https://example.test/ingest", token="invalid secret token"), self.store)
|
||||
self.assertNotIn("invalid secret token", str(raised.exception))
|
||||
|
||||
def test_cli_sample_and_export_use_the_persistent_archive(self):
|
||||
with server() as (_, base):
|
||||
config = Path(self.directory.name) / "config.toml"
|
||||
@@ -381,6 +402,51 @@ class ConfigTests(unittest.TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
self.load('\n[http]\nbase_path="/../config"\n')
|
||||
|
||||
def test_history_account_settings_and_migration_from_query_api_keys(self):
|
||||
config, _ = self.load('\n[history]\nusername_env="BACKUP_USER"\npassword_env="BACKUP_PASSWORD"\n')
|
||||
self.assertEqual(config.history.username_env, "BACKUP_USER")
|
||||
self.assertEqual(config.history.password_env, "BACKUP_PASSWORD")
|
||||
self.assertFalse(config.history.enabled)
|
||||
config, _ = self.load('\n[history]\naccess_key_id_env="OLD_ID"\naccess_key_value_env="OLD_KEY"\n')
|
||||
self.assertEqual(config.history.username_env, "SOLARWEB_USERNAME")
|
||||
self.assertEqual(config.history.password_env, "SOLARWEB_PASSWORD")
|
||||
for name in ("username_env", "password_env"):
|
||||
with self.subTest(name=name), self.assertRaisesRegex(ValueError, "environment variable"):
|
||||
self.load(f'\n[history]\n{name}="invalid name"\n')
|
||||
|
||||
def test_history_uses_retention_instead_of_legacy_lookback_setting(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "synthetic-test.toml"
|
||||
path.write_text('[collector]\nretention_days=2\n[history]\nenabled=true\n'
|
||||
'pv_system_id="73733052-6f10-47a5-9746-7f7e76ebcb8c"\nlookback_days=7\n')
|
||||
config = load_config(path)
|
||||
self.assertEqual(config.collector.retention_days, 2)
|
||||
self.assertTrue(config.history.enabled)
|
||||
self.assertFalse(hasattr(config.history, "lookback_days"))
|
||||
|
||||
def test_private_config_accepts_credentials_and_redacts_them_from_repr(self):
|
||||
config, _ = self.load('\n[upload]\ntoken="synthetic-shared-token"\n'
|
||||
'\n[history]\nusername="synthetic@example.test"\npassword="synthetic password é &"\n')
|
||||
self.assertEqual(config.upload.token, "synthetic-shared-token")
|
||||
self.assertEqual(config.history.username, "synthetic@example.test")
|
||||
self.assertEqual(config.history.password, "synthetic password é &")
|
||||
for secret in (config.upload.token, config.history.username, config.history.password):
|
||||
self.assertNotIn(secret, repr(config))
|
||||
|
||||
def test_partial_or_mistyped_private_credentials_are_rejected_without_values(self):
|
||||
for fields in ['username="synthetic@example.test"', 'password="synthetic-secret"',
|
||||
'username=" "\npassword="synthetic-secret"', 'username=42', 'password=42']:
|
||||
with self.subTest(fields=fields):
|
||||
with self.assertRaises(ValueError) as raised:
|
||||
self.load(f'\n[history]\n{fields}\n')
|
||||
self.assertNotIn("synthetic-secret", str(raised.exception))
|
||||
self.assertNotIn("synthetic@example.test", str(raised.exception))
|
||||
for value in ['42', '"invalid secret token"']:
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaisesRegex(ValueError, "upload.token") as raised:
|
||||
self.load(f'\n[upload]\ntoken={value}\n')
|
||||
self.assertNotIn("invalid secret token", str(raised.exception))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -63,6 +63,11 @@ class ScheduleTests(unittest.TestCase):
|
||||
self.assertEqual(clock.waits[0], (301, 299))
|
||||
self.assertTrue(all(stamp % 300 == 0 for stamp in recorded))
|
||||
|
||||
def test_default_live_collection_never_initializes_cloud_recovery(self):
|
||||
with patch("solar_relay.cli.HistoryRecovery", side_effect=AssertionError("Unexpected cloud recovery")):
|
||||
recorded, _ = self.run_collector(301)
|
||||
self.assertEqual(recorded, [600, 900, 1200])
|
||||
|
||||
def test_restart_in_the_same_interval_adds_no_extra_observation(self):
|
||||
before, _ = self.run_collector(590, count=1)
|
||||
after, _ = self.run_collector(615, count=2)
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Exercise browser authentication with synthetic credentials and responses only."""
|
||||
|
||||
from datetime import date
|
||||
from io import BytesIO
|
||||
import json
|
||||
import unittest
|
||||
from urllib.error import URLError
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
from unittest.mock import Mock
|
||||
|
||||
from solar_relay.http import TransportError
|
||||
from solar_relay.solarweb import API_SERVER, LOGIN_SERVER, Response, SolarWebClient, decode_chart, validate_url
|
||||
|
||||
SYSTEM = "73733052-6f10-47a5-9746-7f7e76ebcb8c"
|
||||
DAY = date(2026, 10, 30)
|
||||
STAMP = 1793354400000
|
||||
PASSWORD = "synthetic password & Unicode é"
|
||||
LOGIN_FORM = b'''<form action="/commonauth" method="post">
|
||||
<input type="hidden" name="sessionDataKey" value="synthetic-session">
|
||||
<input name="usernameUserInput"><input type="password" name="password">
|
||||
<input type="hidden" name="disabled" value="unused" disabled></form>'''
|
||||
CALLBACK_FORM = b'''<form action="https://www.solarweb.com/Account/ExternalLoginCallback" method="post">
|
||||
<input type="hidden" name="state" value="synthetic-state">
|
||||
<input type="hidden" name="id_token" value="synthetic-token"></form>'''
|
||||
|
||||
|
||||
def chart(series_id="FromGen", points=None, suffix=" W"):
|
||||
return json.dumps({"settings": {"series": [{"id": series_id, "name": "Localized display name",
|
||||
"tooltip": {"valueSuffix": suffix},
|
||||
"data": points if points is not None else [[STAMP, 1200], [STAMP + 300_000, None]]}]}}).encode()
|
||||
|
||||
|
||||
def login_steps():
|
||||
return [(302, b"", LOGIN_SERVER + "/authenticationendpoint/login.do?sessionDataKey=synthetic-session"),
|
||||
(200, LOGIN_FORM, ""), (200, CALLBACK_FORM, ""),
|
||||
(302, b"", API_SERVER + "/"), (200, b"<html>Account</html>", "")]
|
||||
|
||||
|
||||
class ScriptedClient(SolarWebClient):
|
||||
def __init__(self, steps):
|
||||
super().__init__("account@example.test", PASSWORD)
|
||||
self.steps, self.requests = list(steps), []
|
||||
|
||||
def _send(self, url, form, referer, as_json):
|
||||
self.requests.append((url, form, referer, as_json))
|
||||
if not self.steps:
|
||||
raise AssertionError("Unexpected request")
|
||||
status, body, location = self.steps.pop(0)
|
||||
return Response(status, url, body, location)
|
||||
|
||||
|
||||
class LoginTests(unittest.TestCase):
|
||||
def test_real_account_flow_and_authenticated_charts_reuse_session(self):
|
||||
client = ScriptedClient(login_steps() + [(200, b"Chart", ""), (200, chart(), ""),
|
||||
(200, chart("ToConsumer"), "")])
|
||||
self.assertEqual(client.get_chart(SYSTEM, "production", DAY), {STAMP: 1200})
|
||||
self.assertEqual(client.get_chart(SYSTEM, "consumption", DAY), {STAMP: 1200})
|
||||
self.assertTrue(client.authenticated)
|
||||
self.assertFalse(client.steps)
|
||||
posts = [(url, form) for url, form, _, _ in client.requests if form is not None]
|
||||
self.assertEqual(posts, [(LOGIN_SERVER + "/commonauth", {"sessionDataKey": "synthetic-session",
|
||||
"username": "account@example.test", "usernameUserInput": "account@example.test",
|
||||
"password": PASSWORD}),
|
||||
(API_SERVER + "/Account/ExternalLoginCallback",
|
||||
{"state": "synthetic-state", "id_token": "synthetic-token"})])
|
||||
for request, view in zip(client.requests[-2:], ["consumption", "production"]):
|
||||
url, form, referer, as_json = request
|
||||
query = parse_qs(urlsplit(url).query)
|
||||
self.assertEqual(query, {"pvSystemId": [SYSTEM], "year": ["2026"], "month": ["10"],
|
||||
"day": ["30"], "interval": ["day"], "view": [view]})
|
||||
self.assertIsNone(form)
|
||||
self.assertTrue(as_json)
|
||||
self.assertEqual(urlsplit(referer).path, "/Chart/Chart")
|
||||
for url, _, _, _ in client.requests:
|
||||
self.assertNotIn(PASSWORD, url)
|
||||
self.assertNotIn("GuestLogOn", url)
|
||||
self.assertNotIn("swqapi", url)
|
||||
|
||||
def test_expired_chart_session_is_renewed_once(self):
|
||||
client = ScriptedClient(login_steps() + [(200, b"Chart", ""), (401, b"", "")]
|
||||
+ login_steps() + [(200, b"Chart", ""), (200, chart(), "")])
|
||||
self.assertEqual(client.get_chart(SYSTEM, "production", DAY), {STAMP: 1200})
|
||||
self.assertEqual(sum(form is not None and "password" in form for _, form, _, _ in client.requests), 2)
|
||||
|
||||
def test_persistent_denial_stops_after_one_session_renewal(self):
|
||||
client = ScriptedClient(login_steps() + [(403, b"", "")] + login_steps() + [(403, b"", "")])
|
||||
with self.assertRaisesRegex(TransportError, "No guest access"):
|
||||
client.get_chart(SYSTEM, "production", DAY)
|
||||
self.assertFalse(client.steps)
|
||||
|
||||
def test_rejected_credentials_are_submitted_only_once(self):
|
||||
client = ScriptedClient(login_steps()[:2] + [(200, LOGIN_FORM, "")])
|
||||
with self.assertRaisesRegex(TransportError, "login failed"):
|
||||
client.get_chart(SYSTEM, "production", DAY)
|
||||
self.assertEqual(sum(form is not None for _, form, _, _ in client.requests), 1)
|
||||
|
||||
def test_captcha_or_mfa_does_not_trigger_password_retries(self):
|
||||
for body in [LOGIN_FORM.replace(b"</form>", b'<div data-sitekey="captcha"></div></form>'),
|
||||
b'<form method="post"><input name="otp"></form>']:
|
||||
with self.subTest(body=body):
|
||||
client = ScriptedClient([login_steps()[0], (200, body, "")])
|
||||
with self.assertRaisesRegex(TransportError, "login failed"):
|
||||
client.get_chart(SYSTEM, "production", DAY)
|
||||
self.assertFalse(any(form is not None for _, form, _, _ in client.requests))
|
||||
|
||||
def test_changed_or_untrusted_password_form_is_not_submitted(self):
|
||||
bodies = [LOGIN_FORM.replace(b"/commonauth", b"https://evil.example/commonauth"),
|
||||
LOGIN_FORM.replace(b"/commonauth", b"/other"), LOGIN_FORM.replace(b'"post"', b'"get"'),
|
||||
LOGIN_FORM.replace(b"sessionDataKey", b"differentKey")]
|
||||
for body in bodies:
|
||||
with self.subTest(body=body):
|
||||
client = ScriptedClient([login_steps()[0], (200, body, "")])
|
||||
with self.assertRaises(TransportError):
|
||||
client.get_chart(SYSTEM, "production", DAY)
|
||||
self.assertFalse(any(form is not None for _, form, _, _ in client.requests))
|
||||
|
||||
def test_untrusted_callback_is_never_sent_tokens(self):
|
||||
for target in [b"https://evil.example/callback", b"https://login.fronius.com/commonauth",
|
||||
b"https://www.solarweb.com/other"]:
|
||||
with self.subTest(target=target):
|
||||
body = CALLBACK_FORM.replace(b"https://www.solarweb.com/Account/ExternalLoginCallback", target)
|
||||
client = ScriptedClient(login_steps()[:2] + [(200, body, "")])
|
||||
with self.assertRaises(TransportError):
|
||||
client.get_chart(SYSTEM, "production", DAY)
|
||||
self.assertEqual(sum(form is not None for _, form, _, _ in client.requests), 1)
|
||||
|
||||
def test_guest_and_untrusted_redirects_are_blocked_before_network(self):
|
||||
for target in [API_SERVER + "/Account/GuestLogOn?pvSystemId=x", API_SERVER + "/Account/DemoLogOn",
|
||||
"https://evil.example/", "http://www.solarweb.com/"]:
|
||||
with self.subTest(target=target):
|
||||
client = ScriptedClient([(302, b"", target)])
|
||||
with self.assertRaisesRegex(TransportError, "Request blocked"):
|
||||
client.get_chart(SYSTEM, "production", DAY)
|
||||
self.assertEqual(len(client.requests), 1)
|
||||
|
||||
def test_post_redirect_cannot_forward_credentials(self):
|
||||
for status in [307, 308]:
|
||||
with self.subTest(status=status):
|
||||
client = ScriptedClient(login_steps()[:2] + [(status, b"", API_SERVER + "/")])
|
||||
with self.assertRaisesRegex(TransportError, "sensitive POST"):
|
||||
client.get_chart(SYSTEM, "production", DAY)
|
||||
self.assertEqual(len(client.requests), 3)
|
||||
|
||||
|
||||
class TransportTests(unittest.TestCase):
|
||||
def test_url_allowlist(self):
|
||||
for url in [API_SERVER + "/", LOGIN_SERVER + "/commonauth", API_SERVER + ":443/Chart/Chart"]:
|
||||
validate_url(url)
|
||||
for url in ["https://www.solarweb.com.evil.example/", "https://user:pass@www.solarweb.com/",
|
||||
API_SERVER + ":444/", "http://www.solarweb.com/", API_SERVER + "/%47uestLogOn",
|
||||
API_SERVER + "/DemoLogOn", API_SERVER + "/bad\npath", API_SERVER + "\\@evil.example/"]:
|
||||
with self.subTest(url=url), self.assertRaises(TransportError):
|
||||
validate_url(url)
|
||||
|
||||
def test_transport_redacts_network_errors_and_response_repr(self):
|
||||
client = SolarWebClient("account@example.test", PASSWORD)
|
||||
client.opener = Mock()
|
||||
client.opener.open.side_effect = URLError("synthetic-token " + PASSWORD)
|
||||
with self.assertRaises(TransportError) as raised:
|
||||
client._request(API_SERVER + "/")
|
||||
self.assertNotIn(PASSWORD, str(raised.exception))
|
||||
self.assertNotIn("synthetic-token", str(raised.exception))
|
||||
self.assertEqual(repr(Response(200, API_SERVER + "/?token=synthetic-token", b"secret", "secret")), "Response(status=200)")
|
||||
|
||||
def test_transport_keeps_sso_queries_out_of_referer_and_encodes_password(self):
|
||||
client = SolarWebClient("account@example.test", PASSWORD)
|
||||
response = BytesIO(b"success")
|
||||
response.code, response.headers = 200, {}
|
||||
client.opener = Mock()
|
||||
client.opener.open.return_value = response
|
||||
client._request(LOGIN_SERVER + "/commonauth", {"password": PASSWORD}, LOGIN_SERVER + "/?state=synthetic-token")
|
||||
request = client.opener.open.call_args.args[0]
|
||||
self.assertEqual(request.get_header("Referer"), LOGIN_SERVER + "/")
|
||||
self.assertEqual(request.get_header("Origin"), LOGIN_SERVER)
|
||||
self.assertEqual(parse_qs(request.data.decode())["password"], [PASSWORD])
|
||||
|
||||
def test_responses_are_bounded(self):
|
||||
client = SolarWebClient("account@example.test", PASSWORD)
|
||||
response = BytesIO(b"x" * (client.MAX_RESPONSE_BYTES + 1))
|
||||
response.code, response.headers = 200, {}
|
||||
client.opener = Mock()
|
||||
client.opener.open.return_value = response
|
||||
with self.assertRaisesRegex(TransportError, "exceeds 8 MiB"):
|
||||
client._request(API_SERVER + "/")
|
||||
|
||||
|
||||
class ChartTests(unittest.TestCase):
|
||||
def test_series_uses_id_and_skips_null_instead_of_zeroing(self):
|
||||
self.assertEqual(decode_chart(chart(), "FromGen"), {STAMP: 1200})
|
||||
|
||||
def test_bad_units_missing_series_and_invalid_points_stop_import(self):
|
||||
bodies = [b"not JSON", b'{"settings":{"series":[]}}', chart("Other"), chart(suffix="kW"),
|
||||
chart(points=[[STAMP + 1, 1200]]), chart(points=[[STAMP, True]]),
|
||||
chart(points=[[STAMP, -1]]), chart(points=[[STAMP, float("nan")]]),
|
||||
chart(points=[[STAMP, 1], [STAMP, 2]]), chart(points=[[True, 1]]),
|
||||
chart(points=[[STAMP, "1200"]]), chart(points=[[STAMP]])]
|
||||
for body in bodies:
|
||||
with self.subTest(body=body), self.assertRaises(TransportError):
|
||||
decode_chart(body, "FromGen")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user