Repository: ResonantGeoData/ResonantGeoData

Issue:
Client: API token saving bug on MacOS
@banesullivan was experiencing the following issue:

When calling `create_rgd_client`, if there isn't already a token stored locally (in `$HOME/.rgd/token`), the client is supposed to make a request to the server to create it, and save it in that file. On MacOS, it seems this might not be occurring. The file doesn't appear to ever be created (notably though, the `.rgd` folder _is_ present). Furthermore, if you try to manually populate that file with your token, it will correctly read it, but the file will then be gone afterwards.

This doesn't actually affect authorization, as it still just fetches the token from the API and stores it in memory, but the storage issue needs to be looked into.


Discussion:
Thanks for the summary @AlmightyYakob, this is exactly what I was experiencing.

@mvandenburgh, would you please implement a test for the Python client in `django-rgd/tests/test_client.py` that will create a client instance and check that the token is actually saved to the file it should be. 

If that test passes on CI, then we can look into adding a MacOS runner for the tests. If that still passes, then this is likely just an issue with my set up and I will debug from there. Either way, there are two things I'd like to get out of this:

1. A test to validate API tokens are saved to `~/.rgd/token`
2. A MacOS runner for our client's CI tests (unless this proves to be complicated / a maintenance burden). I'm thinking the django server will still run on linux as a docker service and we only run the Python client tests on MacOS.
Update: this seems to be working after resetting everything. @mvandenburgh, no need to look into a MacOS runner yet - we can do this later.

@mvandenburgh, can you implement something else as a part of this work though? Can you add a check to make sure the token is valid and try to get a new one if not? For example, I regularly use the Python client to log into different RGD deployments hosted on localhost:8000. I have to remember to delete the token or say `save=False` each time since I frequently switch. I'd like some sort of check when fetching the token from `~/.rgd/token` to make sure it works and if not attempt to get a new one.

Resolving patch:
diff --git a/django-rgd/client/rgd_client/client.py b/django-rgd/client/rgd_client/client.py
--- a/django-rgd/client/rgd_client/client.py
+++ b/django-rgd/client/rgd_client/client.py
@@ -1,4 +1,5 @@
 import getpass
+import logging
 import os
 from typing import List, Optional, Type
 
@@ -8,6 +9,8 @@
 from .session import RgdClientSession, clone_session
 from .utils import API_KEY_DIR_PATH, API_KEY_FILE_NAME, DEFAULT_RGD_API
 
+logger = logging.getLogger(__name__)
+
 
 class RgdClient:
     def __init__(
@@ -30,7 +33,7 @@ def __init__(
             A base RgdClient instance.
         """
         # Look for an API key in the environment. If it's not there, check username/password
-        api_key = _read_api_key()
+        api_key = _read_api_key(api_url=api_url, username=username, password=password)
         if api_key is None:
             if username is not None and password is None:
                 password = getpass.getpass()
@@ -38,6 +41,10 @@ def __init__(
             # Get an API key for this user and save it to disk
             if username and password:
                 api_key = _get_api_key(api_url, username, password, save)
+                if api_key is None:
+                    logger.error(
+                        'Failed to retrieve API key; are your username and password correct?'
+                    )
 
         auth_header = f'Token {api_key}'
 
@@ -49,11 +56,12 @@ def clear_token(self):
         (API_KEY_DIR_PATH / API_KEY_FILE_NAME).unlink(missing_ok=True)
 
 
-def _get_api_key(api_url: str, username: str, password: str, save: bool) -> str:
+def _get_api_key(api_url: str, username: str, password: str, save: bool) -> Optional[str]:
     """Get an RGD API Key for the given user from the server, and save it if requested."""
     resp = requests.post(f'{api_url}/api-token-auth', {'username': username, 'password': password})
-    resp.raise_for_status()
-    token = resp.json()['token']
+    token = resp.json().get('token')
+    if token is None:
+        return None
     if save:
         API_KEY_DIR_PATH.mkdir(parents=True, exist_ok=True)
         with open(API_KEY_DIR_PATH / API_KEY_FILE_NAME, 'w') as fd:
@@ -61,7 +69,7 @@ def _get_api_key(api_url: str, username: str, password: str, save: bool) -> str:
     return token
 
 
-def _read_api_key() -> Optional[str]:
+def _read_api_key(api_url: str, username: str = None, password: str = None) -> Optional[str]:
     """
     Retrieve an RGD API Key from the users environment.
 
@@ -75,10 +83,29 @@ def _read_api_key() -> Optional[str]:
     try:
         # read the first line of the text file at ~/.rgd/token
         with open(API_KEY_DIR_PATH / API_KEY_FILE_NAME, 'r') as fd:
-            return fd.readline().strip()
+            api_key = fd.readline().strip()
     except FileNotFoundError:
         return None
 
+    # Make sure API key works by hitting a protected endpoint
+    resp = requests.get(f'{api_url}/rgd/collection', headers={'Authorization': f'Token {api_key}'})
+
+    # If it doesn't, try to get a new one and save it to ~/.rgd/token, as the current one is corrupted
+    if resp.status_code == 401:
+        logger.error('API key is invalid.')
+        # If username + password were provided, try to get a new API key with them
+        if username is not None and password is not None:
+            logger.warning('Attempting to fetch a new API key...')
+            api_key = _get_api_key(api_url, username, password, save=True)
+            if api_key is not None:
+                logger.warning('Succeeded.')
+            return api_key
+        else:
+            logger.error('Provide your username and password next time to fetch a new one.')
+            return None
+
+    return api_key
+
 
 def create_rgd_client(
     api_url: str = DEFAULT_RGD_API,


Test patch:
diff --git a/django-rgd/tests/conftest.py b/django-rgd/tests/conftest.py
--- a/django-rgd/tests/conftest.py
+++ b/django-rgd/tests/conftest.py
@@ -30,3 +30,18 @@ def py_client(live_server):
     )
 
     return client
+
+
+@pytest.fixture
+def user_with_api_key(faker):
+    email = faker.email()
+    password = 'password'
+    params = {'username': email, 'email': email, 'password': password}
+
+    user = User.objects.create_user(is_staff=True, is_superuser=True, **params)
+    user.save()
+
+    api_token = 'topsecretkey'
+    Token.objects.create(user=user, key=api_token)
+
+    return email, password, api_token
diff --git a/django-rgd/tests/test_client.py b/django-rgd/tests/test_client.py
--- a/django-rgd/tests/test_client.py
+++ b/django-rgd/tests/test_client.py
@@ -1,8 +1,6 @@
 import json
 
-from django.contrib.auth.models import User
 import pytest
-from rest_framework.authtoken.models import Token
 from rgd.models import ChecksumFile
 from rgd_client import RgdClient, create_rgd_client
 from rgd_client.utils import API_KEY_DIR_PATH, API_KEY_FILE_NAME
@@ -36,22 +34,35 @@ def test_create_file_from_url(py_client: RgdClient, checksum_file_url: ChecksumF
 
 
 @pytest.mark.django_db(transaction=True)
-def test_save_api_key(live_server, faker):
+def test_save_api_key(live_server, user_with_api_key):
     """Test that saving an API key works correctly."""
-    # Create a fake user just for this test
-    email = faker.email()
-    params = {'username': email, 'email': email, 'password': 'password'}
-    user = User.objects.create_user(is_staff=True, is_superuser=True, **params)
-    user.save()
-    api_token = 'topsecretkey'
-    Token.objects.create(user=user, key=api_token)
+    username, password, api_token = user_with_api_key
 
     create_rgd_client(
-        username=params['username'],
-        password=params['password'],
+        username=username,
+        password=password,
         api_url=f'{live_server.url}/api',
         save=True,  # save the API key
     )
 
     # Ensure ~/.rgd/token exists and contains the user's API key
     assert (API_KEY_DIR_PATH / API_KEY_FILE_NAME).read_text() == api_token
+
+
+@pytest.mark.django_db(transaction=True)
+def test_invalid_local_api_key(live_server, user_with_api_key):
+    """Test that a new API key is fetched when the one on disk is invalid."""
+    username, password, api_token = user_with_api_key
+
+    # Save invalid API key to disk so `create_rgd_client` attempts to use it
+    (API_KEY_DIR_PATH / API_KEY_FILE_NAME).write_text(f'{api_token}_bad')
+
+    create_rgd_client(
+        username=username,
+        password=password,
+        api_url=f'{live_server.url}/api',
+        save=True,  # save the API key
+    )
+
+    # Ensure that `create_rgd_client` overwrote the invalid key with a fresh one
+    assert (API_KEY_DIR_PATH / API_KEY_FILE_NAME).read_text() == api_token