"""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'''
''' CALLBACK_FORM = b'''''' 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"Account", "")] 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"", b''), b'']: 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()