Repository: bvanelli/actualpy
Pull request #44 (2024-07-19 21:08:11)

feat: Support header login as a fallback in case server returns invalid-header
Closes https://github.com/bvanelli/actualpy/issues/43

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/bvanelli/actualpy/pull/44?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli) Report
Attention: Patch coverage is `92.30769%` with `1 line` in your changes missing coverage. Please review.
> Project coverage is 96.72%. Comparing base [(`86aac7f`)](https://app.codecov.io/gh/bvanelli/actualpy/commit/86aac7fafb5fa95e42634a1870baca1686b074fe?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli) to head [(`47bd783`)](https://app.codecov.io/gh/bvanelli/actualpy/commit/47bd783120bf0506b285c1de79b9877f4c4a010f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli).

| [Files](https://app.codecov.io/gh/bvanelli/actualpy/pull/44?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli) | Patch % | Lines |
|---|---|---|
| [actual/api/\_\_init\_\_.py](https://app.codecov.io/gh/bvanelli/actualpy/pull/44?src=pr&el=tree&filepath=actual%2Fapi%2F__init__.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli#diff-YWN0dWFsL2FwaS9fX2luaXRfXy5weQ==) | 88.88% | [1 Missing :warning: ](https://app.codecov.io/gh/bvanelli/actualpy/pull/44?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main      #44      +/-   ##
==========================================
- Coverage   96.75%   96.72%   -0.04%     
==========================================
  Files          13       13              
  Lines        1728     1738      +10     
==========================================
+ Hits         1672     1681       +9     
- Misses         56       57       +1     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/bvanelli/actualpy/pull/44?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli).


Patch:
diff --git a/actual/api/__init__.py b/actual/api/__init__.py
index 6fb9034..13138f2 100644
--- a/actual/api/__init__.py
+++ b/actual/api/__init__.py
@@ -50,15 +50,34 @@ class ActualServer:
         # finally call validate
         self.validate()
 
-    def login(self, password: str) -> LoginDTO:
-        """Logs in on the Actual server using the password provided. Raises `AuthorizationError` if it fails to
-        authenticate the user."""
+    def login(self, password: str, method: Literal["password", "header"] = "password") -> LoginDTO:
+        """
+        Logs in on the Actual server using the password provided. Raises `AuthorizationError` if it fails to
+        authenticate the user.
+
+        :param password: password of the Actual server.
+        :param method: the method used to authenticate with the server. Check
+        https://actualbudget.org/docs/advanced/http-header-auth/ for information.
+        """
         if not password:
             raise AuthorizationError("Trying to login but not password was provided.")
-        response = requests.post(f"{self.api_url}/{Endpoints.LOGIN}", json={"password": password})
+        if method == "password":
+            response = requests.post(f"{self.api_url}/{Endpoints.LOGIN}", json={"password": password})
+        else:
+            response = requests.post(
+                f"{self.api_url}/{Endpoints.LOGIN}",
+                json={"loginMethod": method},
+                headers={"X-ACTUAL-PASSWORD": password},
+            )
+        response_dict = response.json()
         if response.status_code == 400 and "invalid-password" in response.text:
             raise AuthorizationError("Could not validate password on login.")
-        response.raise_for_status()
+        elif response.status_code == 200 and "invalid-header" in response.text:
+            # try the same login with the header
+            return self.login(password, "header")
+        elif response_dict["status"] == "error":
+            # for example, when not trusting the proxy
+            raise AuthorizationError(f"Something went wrong on login: {response_dict['reason']}")
         login_response = LoginDTO.model_validate(response.json())
         # older versions do not return 400 but rather return empty tokens
         if login_response.data.token is None:
@@ -84,7 +103,7 @@ class ActualServer:
         return InfoDTO.model_validate(response.json())
 
     def validate(self) -> ValidateDTO:
-        """Validates"""
+        """Validates if the user is valid and logged in, and if the token is also valid and bound to a session."""
         response = requests.get(f"{self.api_url}/{Endpoints.ACCOUNT_VALIDATE}", headers=self.headers())
         response.raise_for_status()
         return ValidateDTO.model_validate(response.json())
diff --git a/actual/api/models.py b/actual/api/models.py
index cc856d4..d7d4227 100644
--- a/actual/api/models.py
+++ b/actual/api/models.py
@@ -46,12 +46,18 @@ class BankSyncs(enum.Enum):
 
 class StatusCode(enum.Enum):
     OK = "ok"
+    ERROR = "error"
 
 
 class StatusDTO(BaseModel):
     status: StatusCode
 
 
+class ErrorStatusDTO(BaseModel):
+    status: StatusCode
+    reason: Optional[str] = None
+
+
 class TokenDTO(BaseModel):
     token: Optional[str]
 


Test patch:
diff --git a/tests/test_api.py b/tests/test_api.py
index 77fc452..e16805e 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -1,8 +1,11 @@
+from unittest.mock import patch
+
 import pytest
 
 from actual import Actual
-from actual.exceptions import ActualError, UnknownFileId
+from actual.exceptions import ActualError, AuthorizationError, UnknownFileId
 from actual.protobuf_models import Message
+from tests.conftest import RequestsMock
 
 
 def test_api_apply(mocker):
@@ -25,3 +28,11 @@ def test_rename_delete_budget_without_file():
         actual.delete_budget()
     with pytest.raises(UnknownFileId, match="No current file loaded"):
         actual.rename_budget("foo")
+
+
+@patch("requests.post", return_value=RequestsMock({"status": "error", "reason": "proxy-not-trusted"}))
+def test_api_login_unknown_error(_post):
+    actual = Actual.__new__(Actual)
+    actual.api_url = "localhost"
+    with pytest.raises(AuthorizationError, match="Something went wrong on login"):
+        actual.login("foo")
diff --git a/tests/test_integration.py b/tests/test_integration.py
index 2df04b6..340ae5c 100644
--- a/tests/test_integration.py
+++ b/tests/test_integration.py
@@ -142,3 +142,18 @@ def test_models(actual_server):
                 assert (
                     column_name in __TABLE_COLUMNS_MAP__[table_name]["columns"]
                 ), f"Missing column '{column_name}' at table '{table_name}'."
+
+
+def test_header_login():
+    with DockerContainer("actualbudget/actual-server:24.7.0").with_env(
+        "ACTUAL_LOGIN_METHOD", "header"
+    ).with_exposed_ports(5006) as container:
+        port = container.get_exposed_port(5006)
+        wait_for_logs(container, "Listening on :::5006...")
+        with Actual(f"http://localhost:{port}", password="mypass", bootstrap=True):
+            pass
+        # make sure we can log in
+        actual = Actual(f"http://localhost:{port}", password="mypass")
+        response_login = actual.login("mypass")
+        response_header_login = actual.login("mypass", "header")
+        assert response_login.data.token == response_header_login.data.token