diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fbadb078..952a15c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,10 +14,8 @@ jobs: matrix: os: [ubuntu-latest] python-version: [3.6, 3.7, 3.8, 3.9] - - runs-on: ${{ matrix.os }} - + # TODO(fedden): We need to discuss these steps: We could just use a test-supabase instance or we could update the docker image and use that for the tests. steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} @@ -45,4 +43,5 @@ jobs: time: "5s" - name: Test with pytest run: | - pytest \ No newline at end of file + pytest -sx + diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..960ddc32 --- /dev/null +++ b/.gitignore @@ -0,0 +1,162 @@ +.mypy_cache/ +__pycache__/ +tags + +# Swap +[._]*.s[a-v][a-z] +!*.svg # comment out if you don't need vector files +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Session +Session.vim +Sessionx.vim + +# Temporary +.netrwhist +*~ +# Auto-generated tag files +tags +# Persistent undo +[._]*.un~ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ diff --git a/README.md b/README.md index b371b504..9f3c76c8 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,84 @@ - # Gotrue-py - -## Status: POC -This is a hacky `gotrue-py` client conceived during a very draggy class. It was developed against the [supabase](https://github.com/supabase/gotrue) fork of netlify's gotrue. The design mirrors that of [GoTrue-elixir](https://github.com/joshnuss/gotrue-elixir) +This is a Python port of the [supabase js gotrue client](https://github.com/supabase/gotrue-js/). The current status is that there is not complete feature pairity when compared with the js-client, but this something we are working on. ## Installation +We are still working on making the go-true python library more user-friendly. For now here are some sparse notes on how to install the module + +### Poetry +```bash +poetry add gotrue +``` -Here's how you'd install the library with gotrue -### With Poetry +### Pip +```bash +pip install gotrue +``` -`poetry add gotrue` +## Differences to the JS client +It should be noted there are differences to the [JS client](https://github.com/supabase/gotrue-js/). If you feel particulaly strongly about them and want to motivate a change, feel free to make a GitHub issue and we can discuss it there. -### With pip -`pip3 install gotrue` +Firstly, feature pairity is not 100% with the [JS client](https://github.com/supabase/gotrue-js/). In most cases we match the methods and attributes of the [JS client](https://github.com/supabase/gotrue-js/) and api classes, but is some places (e.g for browser specific code) it didn't make sense to port the code line for line. +There is also a divergence in terms of how errors are raised. In the [JS client](https://github.com/supabase/gotrue-js/), the errors are returned as part of the object, which the user can choose to process in whatever way they see fit. In this Python client, we raise the errors directly where they originate, as it was felt this was more Pythonic and adhered to the idioms of the language more directly. -### Usage +In JS we return the error, but in Python we just raise it. +```js +const { data, error } = client.sign_up(...) ``` -import gotrue -client = gotrue.Client("www.genericauthwebsite.com") -credentials = {"email": "anemail@gmail.com", "password": "gmebbnok"} -client.sign_up(credentials) -client.sign_in(credentials) +The other key difference is we do not use pascalCase to encode variable and method names. Instead we use the snake_case convention adopted in the Python language. + +## Usage +To instanciate the client, you'll need the URL and any request headers at a minimum. +```python +from gotrue import Client + +headers = { + "apiKey": "my-mega-awesome-api-key", + # ... any other headers you might need. +} +client: Client = Client(url="www.genericauthwebsite.com", headers=headers) +``` +To send a magic email link to the user, just provide the email kwarg to the `sign_in` method: +```python +user: Dict[str, Any] = client.sign_up(email="example@gmail.com") ``` +To login with email and password, provide both to the `sign_in` method: +```python +user: Dict[str, Any] = client.sign_up(email="example@gmail.com", password="*********") +``` + +To sign out of the logged in user, call the `sign_out` method. We can then assert that the session and user are null values. +```python +client.sign_out() +assert client.user() is None +assert client.session() is None +``` + +We can refesh a users session. +```python +# The user should already be signed in at this stage. +user = client.refresh_session() +assert client.user() is not None +assert client.session() is not None +``` + +## Tests +At the moment we use a pre-defined supabase instance to test the functionality. This may change over time. You can run the tests like so: +```bash +SUPABASE_TEST_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlhdCI6MTYxMjYwOTMyMiwiZXhwIjoxOTI4MTg1MzIyfQ.XL9W5I_VRQ4iyQHVQmjG0BkwRfx6eVyYB3uAKcesukg" \ +SUPABASE_TEST_URL="https://tfsatoopsijgjhrqplra.supabase.co" \ +pytest -sx +``` -### Development/TODOs -- Figure out to use either Sessions to manage headers or allow passing in of headers -- [] Add Documentation +## Contributions +We would be immensely grateful for any contributions to this project. In particular are the following items: +- [x] Figure out to use either Sessions to manage headers or allow passing in of headers +- [ ] Add documentation. +- [ ] Add more tests. +- [ ] Ensuring feature-parity with the js-client. +- [ ] Supporting 3rd party provider authentication. +- [ ] Implement a js port of setTimeout for the refresh session code. diff --git a/gotrue/__init__.py b/gotrue/__init__.py index b00597a1..db4869e2 100644 --- a/gotrue/__init__.py +++ b/gotrue/__init__.py @@ -1,3 +1,6 @@ -__version__ = '0.1.0' +__version__ = '0.2.0' +from . import lib +from . import api +from . import client from .client import Client diff --git a/gotrue/api.py b/gotrue/api.py new file mode 100644 index 00000000..d591c27a --- /dev/null +++ b/gotrue/api.py @@ -0,0 +1,208 @@ +import json +from typing import Any, Dict + +import requests + +from gotrue.lib.constants import COOKIE_OPTIONS + + +def to_dict(request_response) -> Dict[str, Any]: + """Wrap up request_response to user-friendly dict.""" + return {**request_response.json(), "status_code": request_response.status_code} + + +class GoTrueApi: + def __init__( + self, url: str, headers: Dict[str, Any], cookie_options: Dict[str, Any] + ): + """Initialise API class.""" + self.url = url + self.headers = headers + self.cookie_options = {**COOKIE_OPTIONS, **cookie_options} + + def sign_up_with_email(self, email: str, password: str) -> Dict[str, Any]: + """Creates a new user using their email address + + Parameters + ---------- + email : str + The user's email address. + password : str + The user's password. + + Returns + ------- + request : dict of any + The user or error message returned by the supabase backend. + """ + credentials = {"email": email, "password": password} + request = requests.post( + f"{self.url}/signup", json.dumps(credentials), headers=self.headers + ) + return to_dict(request) + + def sign_in_with_email(self, email: str, password: str) -> Dict[str, Any]: + """Logs in an existing user using their email address. + + Parameters + ---------- + email : str + The user's email address. + password : str + The user's password. + + Returns + ------- + request : dict of any + The user or error message returned by the supabase backend. + """ + credentials = {"email": email, "password": password} + request = requests.post( + f"{self.url}/token?grant_type=password", + json.dumps(credentials), + headers=self.headers, + ) + return to_dict(request) + + def send_magic_link_email(self, email: str) -> Dict[str, Any]: + """Sends a magic login link to an email address. + + Parameters + ---------- + email : str + The user's email address. + + Returns + ------- + request : dict of any + The user or error message returned by the supabase backend. + """ + credentials = {"email": email} + request = requests.post( + f"{self.url}/magiclink", json.dumps(credentials), headers=self.headers + ) + return to_dict(request) + + def invite_user_by_email(self, email: str) -> Dict[str, Any]: + """Sends an invite link to an email address. + + Parameters + ---------- + email : str + The user's email address. + + Returns + ------- + request : dict of any + The invite or error message returned by the supabase backend. + """ + credentials = {"email": email} + request = requests.post( + f"{self.url}/invite", json.dumps(credentials), headers=self.headers + ) + return to_dict(request) + + def reset_password_for_email(self, email: str) -> Dict[str, Any]: + """Sends a reset request to an email address. + + Parameters + ---------- + email : str + The user's email address. + + Returns + ------- + request : dict of any + The password reset status or error message returned by the supabase + backend. + """ + credentials = {"email": email} + request = requests.post( + f"{self.url}/recover", json.dumps(credentials), headers=self.headers + ) + return to_dict(request) + + def _create_request_headers(self, jwt: str) -> Dict[str, str]: + """Create temporary object. + + Create a temporary object with all configured headers and adds the + Authorization token to be used on request methods. + + Parameters + ---------- + jwt : str + A valid, logged-in JWT. + + Returns + ------- + headers : dict of str + The headers required for a successful request statement with the + supabase backend. + """ + headers = {**self.headers} + headers["Authorization"] = f"Bearer {jwt}" + return headers + + def sign_out(self, jwt: str): + """Removes a logged-in session. + + Parameters + ---------- + jwt : str + A valid, logged-in JWT. + """ + requests.post(f"{self.url}/logout", headers=self._create_request_headers(jwt)) + + def get_url_for_provider(self, provider: str) -> str: + """Generates the relevant login URL for a third-party provider.""" + return f"{self.url}/authorize?provider={provider}" + + def get_user(self, jwt: str) -> Dict[str, Any]: + """Gets the user details + + Parameters + ---------- + jwt : str + A valid, logged-in JWT. + + Returns + ------- + request : dict of any + The user or error message returned by the supabase backend. + """ + request = requests.get( + f"{self.url}/user", headers=self._create_request_headers(jwt) + ) + return to_dict(request) + + def update_user(self, jwt: str, **attributes) -> Dict[str, Any]: + """Updates the user data through the attributes kwargs.""" + request = requests.put( + f"{self.url}/user", + json.dumps(attributes), + headers=self._create_request_headers(jwt), + ) + return to_dict(request) + + def refresh_access_token(self, refresh_token: str) -> Dict[str, Any]: + """Generates a new JWT. + + Parameters + ---------- + refresh_token : str + A valid refresh token that was returned on login. + """ + request = requests.post( + f"{self.url}/token?grant_type=refresh_token", + json.dumps({"refresh_token": refresh_token}), + headers=self.headers, + ) + return to_dict(request) + + def set_auth_cookie(req, res): + """Stub for parity with JS api.""" + raise NotImplementedError("set_auth_cookie not implemented.") + + def get_user_by_cookie(req): + """Stub for parity with JS api.""" + raise NotImplementedError("get_user_by_cookie not implemented.") diff --git a/gotrue/client.py b/gotrue/client.py index 5a33dfce..ab135b75 100644 --- a/gotrue/client.py +++ b/gotrue/client.py @@ -3,75 +3,238 @@ ==================================== core module of the project """ -import requests -import re +import datetime +import functools import json -from urllib.parse import quote +import re +import uuid +from typing import Any, Callable, Dict, Optional -HTTPRegexp = "/^http://" -defaultApiURL = "/.netlify/identity" +from gotrue.api import GoTrueApi +from gotrue.lib.constants import GOTRUE_URL, STORAGE_KEY -def jsonify(dictionary: dict): - return json.dumps(dictionary) +HTTPRegexp = "/^http://" +defaultApiURL = "/.netlify/identity" class Client: - def __init__(self, url, audience="", setCookie=False, headers={}): + def __init__( + self, + headers: Dict[str, str], + url: str = GOTRUE_URL, + detect_session_in_url: bool = True, + auto_refresh_token: bool = True, + persist_session: bool = True, + local_storage: Dict[str, Any] = {}, + cookie_options: Dict[str, Any] = {}, + ): + """Create a new client for use in the browser. + + url + The URL of the GoTrue server. + headers + Any additional headers to send to the GoTrue server. + detectSessionInUrl + Set to "true" if you want to automatically detects OAuth grants in + the URL and signs in the user. + autoRefreshToken + Set to "true" if you want to automatically refresh the token before + expiring. + persistSession + Set to "true" if you want to automatically save the user session + into local storage. + localStorage + """ if re.match(HTTPRegexp, url): - # TODO: Decide whether to convert this to a logging statement print( - "Warning:\n\nDO NOT USE HTTP IN PRODUCTION FOR GOTRUE EVER!\nGoTrue REQUIRES HTTPS to work securely." + "Warning:\n\nDO NOT USE HTTP IN PRODUCTION FOR GOTRUE EVER!\n" + "GoTrue REQUIRES HTTPS to work securely." ) - self.BASE_URL = url - self.headers = headers - - def settings(self): - """Get environment settings for the server""" - return requests.get(f"{self.BASE_URL}/settings", headers=self.headers) - - def sign_up(self, credentials: dict): - return requests.post( - f"{self.BASE_URL}/signup", jsonify(credentials), headers=self.headers + self.state_change_emitters: Dict[str, Any] = {} + self.current_user = None + self.current_session = None + self.auto_refresh_token = auto_refresh_token + self.persist_session = persist_session + self.local_storage: Dict[str, Any] = {} + self.api = GoTrueApi(url=url, headers=headers, cookie_options=cookie_options) + self._recover_session() + + def sign_up(self, email: str, password: str): + """Creates a new user. + + Parameters + --------- + email : str + The user's email address. + password : str + The user's password. + """ + self._remove_session() + data = self.api.sign_up_with_email(email, password) + if "expires_in" in data and "user" in data: + # The user has confirmed their email or the underlying DB doesn't + # require email confirmation. + self._save_session(data) + self._notify_all_subscribers("SIGNED_IN") + return data + + def sign_in( + self, + email: Optional[str] = None, + password: Optional[str] = None, + provider: Optional[str] = None, + ) -> Dict[str, Any]: + """Log in an exisiting user, or login via a third-party provider.""" + self._remove_session() + if email is not None and password is None: + data = self.api.send_magic_link_email(email) + elif email is not None and password is not None: + data = self._handle_email_sign_in(email, password) + elif provider is not None: + data = self._handle_provider_sign_in(provider) + else: + raise ValueError("Email or provider must be defined, both can't be None.") + return data + + def user(self) -> Optional[Dict[str, Any]]: + """Returns the user data, if there is a logged in user.""" + return self.current_user + + def session(self) -> Optional[Dict[str, Any]]: + """Returns the session data, if there is an active session.""" + return self.current_session + + def refresh_session(self) -> Dict[str, Any]: + """Force refreshes the session. + + Force refreshes the session including the user data incase it was + updated in a different session. + """ + if self.current_session is None or "access_token" not in self.current_session: + raise ValueError("Not logged in.") + self._call_refresh_token() + data = self.api.get_user(self.current_session["access_token"]) + self.current_user = data + return data + + def update(self, **attributes) -> Dict[str, Any]: + """Updates user data, if there is a logged in user.""" + if self.current_session is None or not self.current_session.get("access_token"): + raise ValueError("Not logged in.") + data = self.api.update_user(self.current_session["access_token"], **attributes) + self.current_user = data + self._notify_all_subscribers("USER_UPDATED") + return data + + def get_session_from_url(self, store_session: bool): + """Gets the session data from a URL string.""" + raise NotImplementedError( + "This method is a stub and is only required by the JS client." ) - def sign_in(self, credentials: dict): - """Sign in with email and password""" - return self.grant_token("password", credentials, headers=self.headers) - - def refresh_access_token(self, refresh_token: str): - return grant_token("refresh_token", {"refresh_token": refresh_token}) - - def grant_token(self, type: str, data: dict): - return requests.post( - f"{self.BASE_URL}/token?grant_type=#{type}/", jsonify(data) - ) - - def sign_out(jwt: str): - """Sign out user using a valid JWT""" - return requests.post(f"{self.BASE_URL}/logout", auth=jwt) - - def recover(self, email: str): - """ Send a recovery email """ - data = {"email": email} - return requests.post(f"{self.BASE_URL}/recover", jsonify(data)) - - def get_user(self, jwt: str): - """Get user info using a valid JWT""" - return requests.get(f"{self.BASE_URL}/user", auth=jwt) - - def update_user(self, jwt: str, info: dict): - """Update user info using a valid JWT""" - return requests.put(f"{self.BASE_URL}/user", auth=jwt, data=info) - - def send_magic_link(self, email: str): - """Send a magic link for passwordless login""" - data = json.dumps({"email": email}) - return requests.post(f"{self.BASE_URL}/magiclink", data=data) - - def url_for_provider(self, provider: str) -> str: - return f"{self.BASE_URL}/authorize?provider=#{urllib.parse.quote(provider)}" - - def invite(self, invitation: dict): - """Invite a new user to join""" - return requests.post(f"{self.BASE_URL}/invite", jsonify(invitation)) + def sign_out(self): + """Log the user out.""" + if self.current_session is not None and "access_token" in self.current_session: + self.api.sign_out(self.current_session["access_token"]) + self._remove_session() + self._notify_all_subscribers("SIGNED_OUT") + + def on_auth_state_change( + self, callback: Callable[[str, Optional[Dict[str, Any]]], Any], + ): + """""" + unique_id: str = str(uuid.uuid4()) + subscription: Dict[str, Any] = { + "id": unique_id, + "callback": callback, + "unsubscribe": functools.partial( + self.state_change_emitters.pop, id=unique_id + ), + } + self.state_change_emitters[unique_id] = subscription + return subscription + + def _handle_email_sign_in(self, email: str, password: str) -> Dict[str, Any]: + """Sign in with email and password.""" + data = self.api.sign_in_with_email(email, password) + if ( + data is not None + and data.get("user") is not None + and "confirmed_at" in data["user"] + ): + self._save_session(data) + self._notify_all_subscribers("SIGNED_IN") + return data + + def _handle_provider_sign_in(self, provider): + """Sign in with provider.""" + raise NotImplementedError("Not implemeted for Python client.") + + def _save_session(self, session): + """Save session to client.""" + required_keys = ["user", "expires_in"] + if any(key not in session for key in required_keys): + raise ValueError( + f"Session not defined as expected, one of {required_keys} not " + f"present in session dict..") + self.current_session = session + self.current_user = session["user"] + token_expiry_seconds = session["expires_in"] + if self.auto_refresh_token and token_expiry_seconds is not None: + self._set_timeout( + self._call_refresh_token, (token_expiry_seconds - 60) * 1000 + ) + if self.persist_session: + self._persist_session(self.current_session, token_expiry_seconds) + + def _persist_session(self, current_session, seconds_to_expiry: int): + timenow_seconds: int = int(round(datetime.datetime.now().timestamp())) + expires_at: int = timenow_seconds + seconds_to_expiry + data = { + "current_session": current_session, + "expires_at": expires_at, + } + self.local_storage[STORAGE_KEY] = json.dumps(data) + + def _remove_session(self): + """Remove the session.""" + self.current_session = None + self.current_user = None + self.local_storage.pop(STORAGE_KEY, None) + + def _recover_session(self): + """Kept as a stub for pairity with the JS client. Only required for + React Native. + """ + pass + + def _call_refresh_token(self, refresh_token: Optional[str] = None): + logged_in: bool = self.current_session is not None and "access_token" in self.current_session + if refresh_token is None and logged_in: + refresh_token = self.current_session["refresh_token"] + elif refresh_token is None: + raise ValueError("No current session and refresh_token not supplied.") + data = self.api.refresh_access_token(refresh_token) + if "access_token" in data: + self.current_session = data + self.current_user = data["user"] + self._notify_all_subscribers("SIGNED_IN") + token_expiry_seconds: int = data["expires_in"] + if self.auto_refresh_token and token_expiry_seconds is not None: + self._set_timeout( + self._call_refresh_token, (token_expiry_seconds - 60) * 1000 + ) + if self.persist_session: + self._persist_session(self.current_session, token_expiry_seconds) + return data + + def _notify_all_subscribers(self, event: str): + """Notify all subscribers that auth event happened.""" + for value in self.state_change_emitters.values(): + value["callback"](event, self.current_session) + + def _set_timeout(*args, **kwargs): + """""" + # TODO(fedden): Implement JS equivalent of setTimeout method. + pass diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index ba6f3e7e..00000000 --- a/pyproject.toml +++ /dev/null @@ -1,18 +0,0 @@ -[tool.poetry] -name = "gotrue" -version = "0.1.1" -description = "Python Client Library for GoTrue" -authors = ["Joel Lee "] - -[tool.poetry.dependencies] -python = "^3.7.1" - -[tool.poetry.dev-dependencies] -pytest = "^4.6" -requests = "^2.25" -sphinx = "*" -recommonmark = "*" - -[build-system] -requires = ["poetry-core>=1.0.0"] -build-backend = "poetry.core.masonry.api" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..a790c7d1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +pytest==^4.6 +requests==^2.25 +sphinx +recommonmark diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..7341dbbe --- /dev/null +++ b/setup.py @@ -0,0 +1,37 @@ +import glob +import setuptools +from typing import List + +import gotrue + + +def get_scripts_from_bin() -> List[str]: + """Get all local scripts from bin so they are included in the package.""" + return glob.glob("bin/*") + + +def get_package_description() -> str: + """Returns a description of this package from the markdown files.""" + with open("README.md", "r") as stream: + readme: str = stream.read() + return readme + + +setuptools.setup( + name="gotrue", + version=gotrue.__version__, + author="Joel Lee", + author_email="joel@joellee.org", + description="Python Client Library for GoTrue", + long_description=get_package_description(), + long_description_content_type="text/markdown", + url="https://github.com/supabase/gotrue-py", + packages=setuptools.find_packages(), + classifiers=[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + ], + scripts=get_scripts_from_bin(), + python_requires=">=3.7", +) diff --git a/tests/__pycache__/__init__.cpython-39.pyc b/tests/__pycache__/__init__.cpython-39.pyc deleted file mode 100644 index ce43f3b1..00000000 Binary files a/tests/__pycache__/__init__.cpython-39.pyc and /dev/null differ diff --git a/tests/__pycache__/test_gotrue.cpython-39-pytest-6.2.1.pyc b/tests/__pycache__/test_gotrue.cpython-39-pytest-6.2.1.pyc deleted file mode 100644 index a80af2fa..00000000 Binary files a/tests/__pycache__/test_gotrue.cpython-39-pytest-6.2.1.pyc and /dev/null differ diff --git a/tests/test_gotrue.py b/tests/test_gotrue.py index e75fb6a7..cdb85d95 100644 --- a/tests/test_gotrue.py +++ b/tests/test_gotrue.py @@ -1,42 +1,89 @@ -from gotrue import __version__ +import os +import random +import string +from typing import Any, Dict + import pytest -@pytest.fixture -def client(): - from gotrue import client - return client.Client("http://localhost:9999") +def _random_string(length: int = 10) -> str: + """Generate random string.""" + return "".join(random.choices(string.ascii_uppercase + string.digits, k=length)) -def test_settings(client): - res = client.settings() - assert (res.status_code == 200) +def _assert_authenticated_user(data: Dict[str, Any]): + """Raise assertion error if user is not logged in correctly.""" + assert "access_token" in data + assert "refresh_token" in data + assert data.get("status_code") == 200 + user = data.get("user") + assert user is not None + assert user.get("id") is not None + assert user.get("aud") == "authenticated" -def test_refresh_access_token(): - pass +@pytest.fixture +def client(): + from gotrue import Client + supabase_url: str = os.environ.get("SUPABASE_TEST_URL") + supabase_key: str = os.environ.get("SUPABASE_TEST_KEY") + url: str = f"{supabase_url}/auth/v1" + return Client( + url=url, + headers={"apiKey": supabase_key, "Authorization": f"Bearer {supabase_key}"}, + ) -@pytest.mark.incremental -class TestUserHandling: - def test_signup(client): - pass - def test_login(client): - pass +def test_user_auth_flow(client): + """Ensures user can sign up, log out and log into their account.""" + random_email: str = f"{_random_string(10)}@supamail.com" + random_password: str = _random_string(20) + user = client.sign_up(email=random_email, password=random_password) + _assert_authenticated_user(user) + assert client.current_user is not None + assert client.current_session is not None + # Sign user out. + client.sign_out() + assert client.current_user is None + assert client.current_session is None + user = client.sign_in(email=random_email, password=random_password) + _assert_authenticated_user(user) + assert client.current_user is not None + assert client.current_session is not None - def test_verify(client): - pass - def test_logout(self): - pass +def test_get_user_and_session_methods(client): + """Ensure we can get the current user and session via the getters.""" + # Create a random user. + random_email: str = f"{_random_string(10)}@supamail.com" + random_password: str = _random_string(20) + user = client.sign_up(email=random_email, password=random_password) + _assert_authenticated_user(user) + # Test that we get not null users and sessions. + assert client.user() is not None + assert client.session() is not None -def test_send_magic_link(client): - res = client.send_magic_link("someemail@gmail.com") - assert (res.status_code == 200 or res.status_code == 429) +def test_refresh_session(client): + """Test user can signup/in and refresh their session.""" + # Create a random user. + random_email: str = f"{_random_string(10)}@supamail.com" + random_password: str = _random_string(20) + user = client.sign_up(email=random_email, password=random_password) + _assert_authenticated_user(user) + assert client.current_user is not None + assert client.current_session is not None + # Refresh users session + data = client.refresh_session() + assert data["status_code"] == 200 + assert client.current_user is not None + assert client.current_session is not None -def test_recover_email(client): - res = client.recover("someemail@gmail.com") - assert (res.status_code == 200 or res.status_code == 429) +def test_send_magic_link(client): + """Tests client can send a magic link to email address.""" + random_email: str = f"{_random_string(10)}@supamail.com" + # We send a magic link if no password is supplied with the email. + data = client.sign_in(email=random_email) + assert data.get("status_code") == 200