Swap to private solarweb access
Deploy Solar Relay / deploy (push) Successful in 56s

This commit is contained in:
franzz committed 2026-11-02 18:06:01 +01:00
1 parent 646ba3be81
commit aae5b73581
14 files changed
+883 -142

No files matched your search

+142 -30
View File
@@ -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
View File
@@ -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()
+5
View File
@@ -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)
+203
View File
@@ -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()