diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 27ff3119..175e6fbf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -121,8 +121,19 @@ export ROBOLIST_SMTP_SENDER_NAME= export ROBOLIST_SMTP_USERNAME= export ROBOLIST_REDIS_HOST= export ROBOLIST_REDIS_PASSWORD= +export GITHUB_CLIENT_ID= +export GITHUB_CLIENT_SECRET= ``` +### Github OAuth Configuration + +To run Github OAuth locally, you must follow these steps: +1. Create an OAuth App on [Github Developer Settings](https://github.com/settings/developers) +2. Set both Homepage URL and Authorization callback URL to `http://127.0.0.1:3000` before you `Update application` on Github Oauth App configuration +3. Copy the Client ID and Client Secret from Github OAuth App configuration and set them in `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` respectively +4. Run `source env.sh` in your Fast API terminal window to ensure it has access to the github environment variables + + ## React To install the React dependencies, use [nvm](https://github.com/nvm-sh/nvm) and [npm](https://www.npmjs.com/): diff --git a/frontend/src/components/nav/Sidebar.tsx b/frontend/src/components/nav/Sidebar.tsx index 55f760ea..8378d769 100644 --- a/frontend/src/components/nav/Sidebar.tsx +++ b/frontend/src/components/nav/Sidebar.tsx @@ -2,7 +2,7 @@ import TCButton from "components/files/TCButton"; import { useAlertQueue } from "hooks/alerts"; import { api } from "hooks/api"; import { useAuthentication } from "hooks/auth"; -import { FormEvent, useEffect, useState } from "react"; +import { FormEvent, useState } from "react"; import { Col, Form, Offcanvas, Row } from "react-bootstrap"; import { Link } from "react-router-dom"; @@ -14,8 +14,6 @@ interface Props { const Sidebar = ({ show, onHide }: Props) => { const { addAlert } = useAlertQueue(); - const [needToCall, setNeedToCall] = useState(true); - const [email, setEmail] = useState(""); const auth = useAuthentication(); const auth_api = new api(auth.api); @@ -54,19 +52,6 @@ const Sidebar = ({ show, onHide }: Props) => { } }; - useEffect(() => { - (async () => { - if (needToCall) { - setNeedToCall(false); - try { - const res = await auth_api.me(); - setEmail(res.email); - } catch (error) { - console.error(error); - } - } - })(); - }, []); return ( @@ -84,7 +69,14 @@ const Sidebar = ({ show, onHide }: Props) => {

Change Email

-

Current email: {email}

+ {auth.email == "dummy@kscale.dev" ? ( +

+ No email address associated with this account. (This is because + you registered via OAuth.) +

+ ) : ( +

Current email: {auth.email}

+ )} {changeEmailSuccess ? (

An email has been sent to your new email address.

) : ( @@ -109,6 +101,10 @@ const Sidebar = ({ show, onHide }: Props) => {

Change Password

+

+ You may only change your password if you have a previous password. + If not, log out and reset your password. +

{changePasswordSuccess ? (

Your password has been changed.

) : ( diff --git a/frontend/src/components/nav/TopNavbar.tsx b/frontend/src/components/nav/TopNavbar.tsx index e28f9ad5..76719185 100644 --- a/frontend/src/components/nav/TopNavbar.tsx +++ b/frontend/src/components/nav/TopNavbar.tsx @@ -1,8 +1,9 @@ import Boop from "components/nav/Boop"; import Sidebar from "components/nav/Sidebar"; -import { useAuthentication } from "hooks/auth"; +import { api } from "hooks/api"; +import { setLocalStorageAuth, useAuthentication } from "hooks/auth"; import { useTheme } from "hooks/theme"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Container, Nav, Navbar } from "react-bootstrap"; import { GearFill, MoonFill, SunFill } from "react-bootstrap-icons"; import { Link } from "react-router-dom"; @@ -10,7 +11,29 @@ import { Link } from "react-router-dom"; const TopNavbar = () => { const [showSidebar, setShowSidebar] = useState(false); const { theme, setTheme } = useTheme(); - const { isAuthenticated } = useAuthentication(); + const auth = useAuthentication(); + const auth_api = new api(auth.api); + + useEffect(() => { + (async () => { + try { + // get code from query string to carry out oauth login + const search = window.location.search; + const params = new URLSearchParams(search); + const code = params.get("code"); + if (auth.isAuthenticated) { + const { email } = await auth_api.me(); + auth.setEmail(email); + } else if (code) { + const res = await auth_api.login_github(code as string); + setLocalStorageAuth(res.username); + auth.setIsAuthenticated(true); + } + } catch (error) { + console.error(error); + } + })(); + }, []); return ( <> @@ -27,7 +50,7 @@ const TopNavbar = () => { {theme === "dark" ? : } - {isAuthenticated ? ( + {auth.isAuthenticated ? ( <> setShowSidebar(true)}> diff --git a/frontend/src/hooks/api.tsx b/frontend/src/hooks/api.tsx index 244aef61..3e9ff158 100644 --- a/frontend/src/hooks/api.tsx +++ b/frontend/src/hooks/api.tsx @@ -90,6 +90,23 @@ export class api { } } + public async send_register_github(): Promise { + try { + const res = await this.api.get("/users/github-login"); + return res.data; + } catch (error) { + if (axios.isAxiosError(error)) { + console.error("Error redirecting to github:", error.response?.data); + throw new Error( + error.response?.data?.detail || "Error redirecting to github", + ); + } else { + console.error("Unexpected error:", error); + throw new Error("Unexpected error"); + } + } + } + public async register( token: string, username: string, @@ -182,6 +199,24 @@ export class api { } } } + + public async login_github(code: string): Promise { + try { + const res = await this.api.get(`/users/github-code/${code}`); + return res.data; + } catch (error) { + if (axios.isAxiosError(error)) { + console.error("Error logging in:", error.response?.data); + throw new Error( + error.response?.data?.detail || "Error logging in with github", + ); + } else { + console.error("Unexpected error:", error); + throw new Error("Unexpected error"); + } + } + } + public async logout(): Promise { try { await this.api.delete("/users/logout/"); diff --git a/frontend/src/hooks/auth.tsx b/frontend/src/hooks/auth.tsx index de79f5e2..81680cf5 100644 --- a/frontend/src/hooks/auth.tsx +++ b/frontend/src/hooks/auth.tsx @@ -1,6 +1,12 @@ import axios, { AxiosInstance } from "axios"; import { BACKEND_URL } from "constants/backend"; -import { createContext, ReactNode, useCallback, useContext } from "react"; +import { + createContext, + ReactNode, + useCallback, + useContext, + useState, +} from "react"; import { useNavigate } from "react-router-dom"; const AUTH_KEY_ID = "AUTH"; @@ -9,8 +15,9 @@ const getLocalStorageAuth = (): string | null => { return localStorage.getItem(AUTH_KEY_ID); }; -export const setLocalStorageAuth = (email: string) => { - localStorage.setItem(AUTH_KEY_ID, email); +// changed from email to id to accommodate oauth logins that don't use email +export const setLocalStorageAuth = (id: string) => { + localStorage.setItem(AUTH_KEY_ID, id); }; export const deleteLocalStorageAuth = () => { @@ -20,8 +27,11 @@ export const deleteLocalStorageAuth = () => { interface AuthenticationContextProps { logout: () => void; isAuthenticated: boolean; - email: string | null; + setIsAuthenticated: React.Dispatch>; + id: string | null; api: AxiosInstance; + email: string; + setEmail: React.Dispatch>; } const AuthenticationContext = createContext< @@ -37,8 +47,11 @@ export const AuthenticationProvider = (props: AuthenticationProviderProps) => { const navigate = useNavigate(); - const isAuthenticated = getLocalStorageAuth() !== null; - const email = getLocalStorageAuth(); + const [isAuthenticated, setIsAuthenticated] = useState( + getLocalStorageAuth() !== null, + ); + const [email, setEmail] = useState("dummy@kscale.dev"); + const id = getLocalStorageAuth(); const api = axios.create({ baseURL: BACKEND_URL, @@ -62,8 +75,11 @@ export const AuthenticationProvider = (props: AuthenticationProviderProps) => { value={{ logout, isAuthenticated, - email, + setIsAuthenticated, + id, api, + email, + setEmail, }} > {children} diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index c69da574..75c38a58 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -1,4 +1,4 @@ -import { StrictMode } from "react"; +// import { StrictMode } from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; @@ -7,8 +7,4 @@ const root = ReactDOM.createRoot( document.getElementById("root") as HTMLElement, ); -root.render( - - - , -); +root.render(); diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 41b12779..33295c74 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,3 +1,5 @@ +import { faGithub } from "@fortawesome/free-brands-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import TCButton from "components/files/TCButton"; import { useAlertQueue } from "hooks/alerts"; import { api } from "hooks/api"; @@ -32,6 +34,21 @@ const Login = () => { } }; + const handleGithubSubmit = async (event: FormEvent) => { + event.preventDefault(); + try { + const redirectUrl = await auth_api.send_register_github(); + window.location.href = redirectUrl; + // setSuccess(true); + } catch (err) { + if (err instanceof Error) { + addAlert(err.message, "error"); + } else { + addAlert("Unexpected error.", "error"); + } + } + }; + return (

Login

@@ -65,6 +82,13 @@ const Login = () => {
Login +
+
OR
+ + + Login with Github + +
); }; diff --git a/frontend/src/pages/Logout.tsx b/frontend/src/pages/Logout.tsx index 319aa5d1..5a2eb5cf 100644 --- a/frontend/src/pages/Logout.tsx +++ b/frontend/src/pages/Logout.tsx @@ -12,6 +12,7 @@ const Logout = () => { deleteLocalStorageAuth(); try { await auth_api.logout(); + auth.setIsAuthenticated(false); } catch (err) { console.error(err); } diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx index 8b111efc..a69f5eb8 100644 --- a/frontend/src/pages/Register.tsx +++ b/frontend/src/pages/Register.tsx @@ -1,7 +1,7 @@ import TCButton from "components/files/TCButton"; import { useAlertQueue } from "hooks/alerts"; import { api } from "hooks/api"; -import { useAuthentication } from "hooks/auth"; +import { setLocalStorageAuth, useAuthentication } from "hooks/auth"; import { FormEvent, useEffect, useState } from "react"; import { Col, Container, Form, Row, Spinner } from "react-bootstrap"; import { Link, useNavigate, useParams } from "react-router-dom"; @@ -23,6 +23,7 @@ const Register = () => { event.preventDefault(); try { await auth_api.register(token || "", username, password); + setLocalStorageAuth(email); navigate("/"); } catch (err) { if (err instanceof Error) { diff --git a/frontend/src/pages/RegistrationEmail.tsx b/frontend/src/pages/RegistrationEmail.tsx index ba4d7bb9..9f0d8519 100644 --- a/frontend/src/pages/RegistrationEmail.tsx +++ b/frontend/src/pages/RegistrationEmail.tsx @@ -1,3 +1,5 @@ +import { faGithub } from "@fortawesome/free-brands-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import TCButton from "components/files/TCButton"; import { useAlertQueue } from "hooks/alerts"; import { api } from "hooks/api"; @@ -28,6 +30,24 @@ const RegistrationEmail = () => { } } }; + + const handleGithubSubmit = async (event: FormEvent) => { + event.preventDefault(); + try { + setSubmitted(true); + const redirectUrl = await auth_api.send_register_github(); + window.location.href = redirectUrl; + // setSuccess(true); + } catch (err) { + setSubmitted(false); + if (err instanceof Error) { + addAlert(err.message, "error"); + } else { + addAlert("Unexpected error.", "error"); + } + } + }; + if (success) { return (
@@ -72,6 +92,13 @@ const RegistrationEmail = () => { /> Send Code +
+
OR
+ + + Register with Github + +
); }; diff --git a/store/app/crud/users.py b/store/app/crud/users.py index 92dd401a..dd05d275 100644 --- a/store/app/crud/users.py +++ b/store/app/crud/users.py @@ -91,7 +91,9 @@ async def add_user(self, user: User) -> None: # Then, add the user object to the Users table. table = await self.db.Table("Users") await table.put_item( - Item=user.model_dump(), ConditionExpression="attribute_not_exists(email) AND attribute_not_exists(username)" + Item=user.model_dump(), + ConditionExpression="attribute_not_exists(oauth_id) AND attribute_not_exists(email) AND \ + attribute_not_exists(username)", ) async def get_user(self, user_id: str) -> User | None: @@ -124,6 +126,19 @@ async def get_user_from_email(self, email: str) -> User | None: raise ValueError(f"Multiple users found with email {email}") return User.model_validate(items[0]) + async def get_user_from_oauth_id(self, oauth_id: str) -> User | None: + table = await self.db.Table("Users") + user_dict = await table.query( + IndexName="oauthIdIndex", + KeyConditionExpression=Key("oauth_id").eq(oauth_id), + ) + items = user_dict["Items"] + if len(items) == 0: + return None + if len(items) > 1: + raise ValueError(f"Multiple users found with oauth id {oauth_id}") + return User.model_validate(items[0]) + async def get_user_id_from_session_token(self, session_token: str) -> str | None: user_id = await self.session_kv.get(hash_token(session_token)) if user_id is None: diff --git a/store/app/db.py b/store/app/db.py index 210cedf5..ce41a3cb 100644 --- a/store/app/db.py +++ b/store/app/db.py @@ -33,8 +33,8 @@ async def create_tables(crud: Crud | None = None, deletion_protection: bool = Fa logging.basicConfig(level=logging.INFO) if crud is None: - async with Crud() as crud: - await create_tables(crud) + async with Crud() as new_crud: + await create_tables(new_crud) else: await asyncio.gather( @@ -46,6 +46,7 @@ async def create_tables(crud: Crud | None = None, deletion_protection: bool = Fa gsis=[ ("emailIndex", "email", "S", "HASH"), ("usernameIndex", "username", "S", "HASH"), + ("oauthIdIndex", "oauth_id", "S", "HASH"), ], deletion_protection=deletion_protection, ), @@ -85,8 +86,8 @@ async def delete_tables(crud: Crud | None = None) -> None: logging.basicConfig(level=logging.INFO) if crud is None: - async with Crud() as crud: - await delete_tables(crud) + async with Crud() as new_crud: + await delete_tables(new_crud) else: await asyncio.gather( @@ -103,8 +104,8 @@ async def populate_with_dummy_data(crud: Crud | None = None) -> None: crud: The top-level CRUD class. """ if crud is None: - async with Crud() as crud: - await populate_with_dummy_data(crud) + async with Crud() as new_crud: + await populate_with_dummy_data(new_crud) else: raise NotImplementedError("This function is not yet implemented.") diff --git a/store/app/model.py b/store/app/model.py index 9c963093..1252dd11 100644 --- a/store/app/model.py +++ b/store/app/model.py @@ -18,6 +18,7 @@ class User(BaseModel): username: str email: str password_hash: str + oauth_id: str admin: bool @classmethod @@ -27,9 +28,21 @@ def create(cls, email: str, username: str, password: str) -> "User": email=email, username=username, password_hash=hash_password(password), + oauth_id="dummy_oauth", admin=False, ) + @classmethod + def create_oauth(cls, username: str, oauth_id: str) -> "User": + return cls( + user_id=str(uuid.uuid4()), + username=username, + email="dummy@kscale.dev", + oauth_id=oauth_id, + admin=False, + password_hash="", + ) + class Bom(BaseModel): part_id: str diff --git a/store/app/routers/users.py b/store/app/routers/users.py index a54084f4..51b740af 100644 --- a/store/app/routers/users.py +++ b/store/app/routers/users.py @@ -6,12 +6,14 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status from fastapi.security.utils import get_authorization_scheme_param -from pydantic.main import BaseModel +from httpx import AsyncClient +from pydantic.main import BaseModel as PydanticBaseModel from store.app.crypto import check_password, new_token from store.app.db import Crud from store.app.model import User from store.app.utils.email import send_change_email, send_delete_email, send_register_email, send_reset_password_email +from store.settings import settings logger = logging.getLogger(__name__) @@ -20,6 +22,11 @@ TOKEN_TYPE = "Bearer" +class BaseModel(PydanticBaseModel): + class Config: + arbitrary_types_allowed = True + + def set_token_cookie(response: Response, token: str, key: str) -> None: response.set_cookie( key=key, @@ -255,9 +262,11 @@ async def get_user_info_endpoint( ) -> UserInfoResponse: user_id = await crud.get_user_id_from_session_token(token) if user_id is None: + print("executed 1") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") user_obj = await crud.get_user(user_id) if user_obj is None: + print("executed 2") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") return UserInfoResponse( email=user_obj.email, @@ -314,6 +323,88 @@ async def get_users_batch_endpoint( ] +class SessionData(BaseModel): + username: str + + +@users_router.get("/github-login") +async def github_login() -> str: + """Gives the user a redirect url to login with github. + + Returns: + Github oauth redirect url. + """ + return f"https://github.com/login/oauth/authorize?client_id={settings.oauth.github_client_id}" + + +@users_router.get("/github-code/{code}", response_model=UserInfoResponse) +async def github_code( + code: str, + crud: Annotated[Crud, Depends(Crud.get)], + response: Response, +) -> UserInfoResponse: + """Gives the user a session token upon successful github authentication and creation of user. + + Args: + code: Github code returned from the successful authentication. + crud: The CRUD object. + response: The response object. + + Returns: + UserInfoResponse. + """ + params = { + "client_id": settings.oauth.github_client_id, + "client_secret": settings.oauth.github_client_secret, + "code": code, + } + headers = {"Accept": "application/json"} + async with AsyncClient() as client: + oauth_response = await client.post( + url="https://github.com/login/oauth/access_token", params=params, headers=headers + ) + response_json = oauth_response.json() + print("\n\n", response_json, "\n\n") + + # access token is used to retrieve user oauth details + access_token = response_json["access_token"] + async with AsyncClient() as client: + headers.update({"Authorization": f"Bearer {access_token}"}) + oauth_response = await client.get("https://api.github.com/user", headers=headers) + + github_id = oauth_response.json()["html_url"] + github_username = oauth_response.json()["login"] + + user = await crud.get_user_from_oauth_id(github_id) + + # create a user if it doesn't exist, with dummy email since email is required for secondary indexing + if user is None: + user = User.create_oauth(username=github_username, oauth_id=github_id) + await crud.add_user(user) + + token = new_token() + + await crud.add_session_token(token, user.user_id, 60 * 60 * 24 * 7) + + response.set_cookie( + key="session_token", + value=token, + httponly=True, + ) + + user_obj = await crud.get_user(user.user_id) + + if user_obj is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + + return UserInfoResponse( + email=user_obj.email, + username=user_obj.username, + user_id=user_obj.user_id, + admin=user_obj.admin, + ) + + @users_router.get("/{user_id}", response_model=PublicUserInfoResponse) async def get_user_info_by_id_endpoint( user_id: str, crud: Annotated[Crud, Depends(Crud.get)] diff --git a/store/requirements.txt b/store/requirements.txt index 5f41e97b..deb4dcc4 100644 --- a/store/requirements.txt +++ b/store/requirements.txt @@ -23,4 +23,4 @@ python-multipart uvicorn[standard] # Types -types-aioboto3[dynamodb, s3] +types-aioboto3[dynamodb, s3] \ No newline at end of file diff --git a/store/settings/environment.py b/store/settings/environment.py index e81aada3..62a7e628 100644 --- a/store/settings/environment.py +++ b/store/settings/environment.py @@ -5,6 +5,12 @@ from omegaconf import II, MISSING +@dataclass +class OauthSettings: + github_client_id: str = field(default=II("oc.env:GITHUB_CLIENT_ID")) + github_client_secret: str = field(default=II("oc.env:GITHUB_CLIENT_SECRET")) + + @dataclass class RedisSettings: host: str = field(default=II("oc.env:ROBOLIST_REDIS_HOST,127.0.0.1")) @@ -48,6 +54,7 @@ class SiteSettings: @dataclass class EnvironmentSettings: + oauth: OauthSettings = field(default_factory=OauthSettings) redis: RedisSettings = field(default_factory=RedisSettings) user: UserSettings = field(default_factory=UserSettings) crypto: CryptoSettings = field(default_factory=CryptoSettings) diff --git a/tests/conftest.py b/tests/conftest.py index 2a115d59..522d2267 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,7 +6,7 @@ import fakeredis import pytest from _pytest.python import Function -from httpx import AsyncClient, ASGITransport +from httpx import ASGITransport, AsyncClient from moto.dynamodb import mock_dynamodb from moto.server import ThreadedMotoServer from pytest_mock.plugin import MockerFixture, MockType @@ -68,7 +68,7 @@ def mock_redis(mocker: MockerFixture) -> None: @pytest.fixture() -async def app_client(): +async def app_client() -> AsyncClient: from store.app.main import app transport = ASGITransport(app) diff --git a/tests/test_robots.py b/tests/test_robots.py index e1fcf4ab..18884bdc 100644 --- a/tests/test_robots.py +++ b/tests/test_robots.py @@ -1,7 +1,8 @@ """Runs tests on the robot APIs.""" from httpx import AsyncClient -from store.app.db import create_tables + from store.app.crud.users import UserCrud +from store.app.db import create_tables async def test_robots(app_client: AsyncClient) -> None: @@ -15,19 +16,41 @@ async def test_robots(app_client: AsyncClient) -> None: test_token = "test_token" await crud.add_register_token(test_token, test_email, 3600) - + # Register. - response = await app_client.post("/users/register", json={"username": test_username, "token": test_token, "password": test_password}) + response = await app_client.post("/users/register", json={ + "username": test_username, + "token": test_token, + "password": test_password, + }) assert response.status_code == 200 # Log in. - response = await app_client.post("/users/login", json={"email": test_email, "password": test_password}) + response = await app_client.post("/users/login", json={ + "email": test_email, + "password": test_password, + }) assert response.status_code == 200 assert "session_token" in response.cookies # Create a part. - response = await app_client.post("/parts/add", json={"name": "test part", "description": "test description", "images": [{"url": "", "caption": ""}]}) + response = await app_client.post("/parts/add", json={ + "name": "test part", + "description": "test description", + "images": [{ + "url": "", + "caption": "", + }], + }) # Create a robot. - response = await app_client.post("/robots/add", json={"name": "test robot", "description": "test description", "bom": [], "images": [{"url": "", "caption": ""}]}) + response = await app_client.post("/robots/add", json={ + "name": "test robot", + "description": "test description", + "bom": [], + "images": [{ + "url": "", + "caption": "" + }], + }) diff --git a/tests/test_users.py b/tests/test_users.py index 826636c9..77b051ae 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -3,12 +3,10 @@ from httpx import AsyncClient - from pytest_mock.plugin import MockType -from store.app.db import create_tables from store.app.crud.users import UserCrud - +from store.app.db import create_tables async def test_user_auth_functions(app_client: AsyncClient, mock_send_email: MockType) -> None: @@ -29,7 +27,11 @@ async def test_user_auth_functions(app_client: AsyncClient, mock_send_email: Moc assert mock_send_email.call_count == 1 # Register. - response = await app_client.post("/users/register", json={"username": test_username, "token": test_token, "password": test_password}) + response = await app_client.post("/users/register", json={ + "username": test_username, + "token": test_token, + "password": test_password + }) assert response.status_code == 200 # Checks that without the session token we get a 401 response. @@ -42,7 +44,10 @@ async def test_user_auth_functions(app_client: AsyncClient, mock_send_email: Moc assert response.status_code == 401, response.json() # Log in. - response = await app_client.post("/users/login", json={"email": test_email, "password": test_password}) + response = await app_client.post("/users/login", json={ + "email": test_email, + "password": test_password, + }) assert response.status_code == 200 assert "session_token" in response.cookies token = response.cookies["session_token"] @@ -53,7 +58,11 @@ async def test_user_auth_functions(app_client: AsyncClient, mock_send_email: Moc assert response.json()["email"] == test_email # Use the Authorization header instead of the cookie. - response = await app_client.get("/users/me", cookies={"session_token": ""}, headers={"Authorization": f"Bearer {token}"}) + response = await app_client.get( + "/users/me", + cookies={"session_token": ""}, + headers={"Authorization": f"Bearer {token}"} + ) assert response.status_code == 200, response.json() assert response.json()["email"] == test_email @@ -68,7 +77,10 @@ async def test_user_auth_functions(app_client: AsyncClient, mock_send_email: Moc assert response.json()["detail"] == "Not authenticated" # Log the user back in, getting new session token. - response = await app_client.post("/users/login", json={"email": test_email, "password": test_password}) + response = await app_client.post("/users/login", json={ + "email": test_email, + "password": test_password, + }) assert response.status_code == 200 assert "session_token" in response.cookies token = response.cookies["session_token"]