From 109d91058aa2aa9bc3db3f8634ca90151af0b23f Mon Sep 17 00:00:00 2001 From: Marc Cataford Date: Sat, 19 Aug 2023 23:52:40 -0400 Subject: [PATCH 01/13] feat(backend): create user table --- .../migrations/migration_1_user_table.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 backend/rotini/migrations/migration_1_user_table.py diff --git a/backend/rotini/migrations/migration_1_user_table.py b/backend/rotini/migrations/migration_1_user_table.py new file mode 100644 index 0000000..ccc78eb --- /dev/null +++ b/backend/rotini/migrations/migration_1_user_table.py @@ -0,0 +1,24 @@ +""" +Generated: 2023-08-19T23:04:28.163820 + +Message: None +""" +UID = "141faa0b-6868-4d07-a24b-b45f98d2809d" + +PARENT = "06f02980-864d-4832-a894-2e9d2543a79a" + +MESSAGE = "Creates the user table." + +UP_SQL = """CREATE TABLE + users +( + id bigserial PRIMARY KEY, + username varchar(64) NOT NULL, + password_hash varchar(128) NOT NULL, + created_at timestamp DEFAULT now(), + updated_at timestamp DEFAULT now(), + password_updated_at timestamp DEFAULT now() +) +""" + +DOWN_SQL = """DROP TABLE users;""" -- 2.45.2 From 941fe3b6dcaf56538781dd6e7cb9629cbba2f36b Mon Sep 17 00:00:00 2001 From: Marc Cataford Date: Sat, 19 Aug 2023 23:53:10 -0400 Subject: [PATCH 02/13] build(backend): add argon2-cffi dependency --- backend/requirements.in | 2 ++ backend/requirements.txt | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/backend/requirements.in b/backend/requirements.in index c6503c3..c25f286 100644 --- a/backend/requirements.in +++ b/backend/requirements.in @@ -3,3 +3,5 @@ uvicorn[standard] python-multipart psycopg2 typing_extensions + +argon2-cffi~=23.1 diff --git a/backend/requirements.txt b/backend/requirements.txt index 2a0d9cc..947d399 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,6 +4,12 @@ anyio==3.7.1 # via # starlette # watchfiles +argon2-cffi==23.1.0 + # via -r requirements.in +argon2-cffi-bindings==21.2.0 + # via argon2-cffi +cffi==1.15.1 + # via argon2-cffi-bindings click==8.1.6 # via uvicorn exceptiongroup==1.1.2 @@ -18,6 +24,8 @@ idna==3.4 # via anyio psycopg2==2.9.7 # via -r requirements.in +pycparser==2.21 + # via cffi pydantic==2.1.1 # via fastapi pydantic-core==2.4.0 -- 2.45.2 From 78c2375bead500238bf736baad42bad0ee02a023 Mon Sep 17 00:00:00 2001 From: Marc Cataford Date: Sat, 19 Aug 2023 23:53:57 -0400 Subject: [PATCH 03/13] feat(backend): basic create user / login implementation --- backend/rotini/api/users.py | 50 ++++++++++++ backend/rotini/main.py | 2 + backend/rotini/use_cases/__init__.py | 0 backend/rotini/use_cases/exceptions.py | 4 + backend/rotini/use_cases/files.py | 7 +- backend/rotini/use_cases/users.py | 103 +++++++++++++++++++++++++ 6 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 backend/rotini/api/users.py create mode 100644 backend/rotini/use_cases/__init__.py create mode 100644 backend/rotini/use_cases/exceptions.py create mode 100644 backend/rotini/use_cases/users.py diff --git a/backend/rotini/api/users.py b/backend/rotini/api/users.py new file mode 100644 index 0000000..2a1a329 --- /dev/null +++ b/backend/rotini/api/users.py @@ -0,0 +1,50 @@ +from fastapi import APIRouter, Request, HTTPException + +import use_cases.users as users_use_cases + +router = APIRouter(prefix="/auth") + + +@router.post("/users/") +async def create_user(request: Request): + """ + POST /auth/users/ + + { + username: string + password: string + } + + 201 { } + + If the user is created successfully, the user object is returned. + + 400 {} + + If the username already exists, or the password is not adequate, + 400 is returned. + """ + body = await request.json() + + user = users_use_cases.create_new_user( + username=body["username"], raw_password=body["password"] + ) + + return user + + +@router.post("/session/") +async def log_in(request: Request): + """ """ + + body = await request.json() + + username = body["username"] + password = body["password"] + + user = users_use_cases.get_user(username=username) + + if not users_use_cases.validate_password_for_user(user["id"], password): + raise HTTPException(status_code=401) + + return user diff --git a/backend/rotini/main.py b/backend/rotini/main.py index 4587548..12bb801 100644 --- a/backend/rotini/main.py +++ b/backend/rotini/main.py @@ -4,6 +4,7 @@ Rotini: a self-hosted cloud storage & productivity app. from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +import api.users import api.files app = FastAPI() @@ -19,6 +20,7 @@ app.add_middleware( ) app.include_router(api.files.router) +app.include_router(api.users.router) @app.get("/", status_code=204) diff --git a/backend/rotini/use_cases/__init__.py b/backend/rotini/use_cases/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/rotini/use_cases/exceptions.py b/backend/rotini/use_cases/exceptions.py new file mode 100644 index 0000000..7e4079f --- /dev/null +++ b/backend/rotini/use_cases/exceptions.py @@ -0,0 +1,4 @@ +class DoesNotExist(Exception): + """ + General purpose exception signalling a failure to find a database record. + """ diff --git a/backend/rotini/use_cases/files.py b/backend/rotini/use_cases/files.py index e413d24..09785f9 100644 --- a/backend/rotini/use_cases/files.py +++ b/backend/rotini/use_cases/files.py @@ -11,12 +11,7 @@ import typing_extensions as typing from db import get_connection from settings import settings - - -class DoesNotExist(Exception): - """ - General purpose exception signalling a failure to find a database record. - """ +from use_cases.exceptions import DoesNotExist class FileRecord(typing.TypedDict): diff --git a/backend/rotini/use_cases/users.py b/backend/rotini/use_cases/users.py new file mode 100644 index 0000000..2d47997 --- /dev/null +++ b/backend/rotini/use_cases/users.py @@ -0,0 +1,103 @@ +""" +User-related use cases. + +Functions in this file are focused on users and passwords. +""" +import argon2 + +import datetime +import typing_extensions as typing + +from db import get_connection +from use_cases.exceptions import DoesNotExist + +password_hasher = argon2.PasswordHasher() + + +class User(typing.TypedDict): + """ + User representation. + + The password hash is never included in these records and should + not leave the database. + """ + + id: int + username: str + created_at: datetime.datetime + updated_at: datetime.datetime + password_updated_at: datetime.datetime + + +def create_new_user(*, username: str, raw_password: str) -> User: + password_hash = _hash_secret(raw_password) + + with get_connection() as connection, connection.cursor() as cursor: + cursor.execute( + "INSERT INTO users (username, password_hash) VALUES (%s, %s) RETURNING id, username", + (username, password_hash), + ) + returned = cursor.fetchone() + + if returned is None: + raise RuntimeError("Uh") + + inserted_id = returned[0] + created_username = returned[1] + + return User( + id=inserted_id, + username=created_username, + created_at=datetime.datetime.now(), + updated_at=datetime.datetime.now(), + password_updated_at=datetime.datetime.now(), + ) + + +def _hash_secret(secret: str) -> str: + return password_hasher.hash(secret) + + +def get_user( + *, username: str = None, user_id: int = None +) -> typing.Union[typing.NoReturn, User]: + with get_connection() as connection, connection.cursor() as cursor: + if username is not None: + cursor.execute( + "SELECT id, username, created_at, updated_at, password_updated_at FROM users WHERE username = %s;", + (username,), + ) + elif user_id is not None: + cursor.execute( + "SELECT id, username, created_at, updated_at, password_updated_at FROM users WHERE id = %s", + (user_id,), + ) + + fetched = cursor.fetchone() + + if fetched is None: + raise RuntimeError("ho") + + return User( + id=fetched[0], + username=fetched[1], + created_at=fetched[2], + updated_at=fetched[3], + password_updated_at=fetched[4], + ) + + +def validate_password_for_user(user_id: int, raw_password: str) -> bool: + try: + current_secret_hash = _get_password_hash_for_user(user_id) + return password_hasher.verify(current_secret_hash, raw_password) + except: + return False + + +def _get_password_hash_for_user(user_id: int) -> str: + with get_connection() as connection, connection.cursor() as cursor: + cursor.execute("SELECT password_hash FROM users WHERE id = %s", (user_id,)) + fetched = cursor.fetchone() + + return fetched[0] -- 2.45.2 From 3d015c6c653f3a7bc992358726670d618f90a6bd Mon Sep 17 00:00:00 2001 From: Marc Cataford Date: Sun, 20 Aug 2023 10:36:18 -0400 Subject: [PATCH 04/13] chore(backend): ignore needless lintrules --- backend/.pylintrc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/.pylintrc b/backend/.pylintrc index 932095a..d4d2b07 100644 --- a/backend/.pylintrc +++ b/backend/.pylintrc @@ -432,7 +432,8 @@ disable=raw-checker-failed, invalid-name, missing-function-docstring, missing-module-docstring, - too-many-locals + too-many-locals, + line-too-long # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option -- 2.45.2 From 342f6479b1c18845d0db2f1e3e003304bb29acd1 Mon Sep 17 00:00:00 2001 From: Marc Cataford Date: Sun, 20 Aug 2023 10:36:42 -0400 Subject: [PATCH 05/13] refactor(backend): user api+use cases clean up and docs --- backend/rotini/api/users.py | 12 ++++++++- backend/rotini/use_cases/users.py | 43 +++++++++++++++++++++---------- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/backend/rotini/api/users.py b/backend/rotini/api/users.py index 2a1a329..4170ddd 100644 --- a/backend/rotini/api/users.py +++ b/backend/rotini/api/users.py @@ -35,7 +35,17 @@ async def create_user(request: Request): @router.post("/session/") async def log_in(request: Request): - """ """ + """ + Attempts to log a user in. + + 200 { } + + If the supplied credentials are correct, the user is returned. + + 401 {} + + If the credentials are incorrect, immediate failure. + """ body = await request.json() diff --git a/backend/rotini/use_cases/users.py b/backend/rotini/use_cases/users.py index 2d47997..92e3ef5 100644 --- a/backend/rotini/use_cases/users.py +++ b/backend/rotini/use_cases/users.py @@ -3,11 +3,11 @@ User-related use cases. Functions in this file are focused on users and passwords. """ -import argon2 - import datetime import typing_extensions as typing +import argon2 + from db import get_connection from use_cases.exceptions import DoesNotExist @@ -30,6 +30,13 @@ class User(typing.TypedDict): def create_new_user(*, username: str, raw_password: str) -> User: + """ + Creates a new user record given a username and password. + + The password is hashed (see `_hash_secret`) and the hash is stored. + + If successful, returns a dictionary representing the user. + """ password_hash = _hash_secret(raw_password) with get_connection() as connection, connection.cursor() as cursor: @@ -55,12 +62,21 @@ def create_new_user(*, username: str, raw_password: str) -> User: def _hash_secret(secret: str) -> str: + """ + Produces a hash of the given secret. + """ return password_hasher.hash(secret) def get_user( *, username: str = None, user_id: int = None ) -> typing.Union[typing.NoReturn, User]: + """ + Retrieves a user record, if one exists, for the given user. + + Querying can be done via username or user ID. The first one supplied, in this + order, is used and any other values are ignored. + """ with get_connection() as connection, connection.cursor() as cursor: if username is not None: cursor.execute( @@ -76,7 +92,7 @@ def get_user( fetched = cursor.fetchone() if fetched is None: - raise RuntimeError("ho") + raise DoesNotExist() return User( id=fetched[0], @@ -88,16 +104,17 @@ def get_user( def validate_password_for_user(user_id: int, raw_password: str) -> bool: + """ + Validates whether a password is correct for the given user. + + Always returns a boolean representing whether it was a match or not. + """ try: - current_secret_hash = _get_password_hash_for_user(user_id) + with get_connection() as connection, connection.cursor() as cursor: + cursor.execute("SELECT password_hash FROM users WHERE id = %s", (user_id,)) + fetched = cursor.fetchone() + + current_secret_hash = fetched[0] return password_hasher.verify(current_secret_hash, raw_password) - except: + except Exception: # pylint: disable=broad-exception-caught return False - - -def _get_password_hash_for_user(user_id: int) -> str: - with get_connection() as connection, connection.cursor() as cursor: - cursor.execute("SELECT password_hash FROM users WHERE id = %s", (user_id,)) - fetched = cursor.fetchone() - - return fetched[0] -- 2.45.2 From 88e84fa1e05c7f991e5d07c218714c8347eda3e0 Mon Sep 17 00:00:00 2001 From: Marc Cataford Date: Sun, 20 Aug 2023 11:06:55 -0400 Subject: [PATCH 06/13] refactor(backend): reorganize into module --- backend/requirements.in | 1 + backend/rotini/auth/base.py | 8 +++++++ .../rotini/{api/users.py => auth/routes.py} | 24 ++++++++++--------- .../{use_cases/users.py => auth/use_cases.py} | 0 backend/rotini/main.py | 10 ++++---- 5 files changed, 28 insertions(+), 15 deletions(-) create mode 100644 backend/rotini/auth/base.py rename backend/rotini/{api/users.py => auth/routes.py} (64%) rename backend/rotini/{use_cases/users.py => auth/use_cases.py} (100%) diff --git a/backend/requirements.in b/backend/requirements.in index c25f286..62d7343 100644 --- a/backend/requirements.in +++ b/backend/requirements.in @@ -3,5 +3,6 @@ uvicorn[standard] python-multipart psycopg2 typing_extensions +pydantic ~= 2.0 argon2-cffi~=23.1 diff --git a/backend/rotini/auth/base.py b/backend/rotini/auth/base.py new file mode 100644 index 0000000..c7a4958 --- /dev/null +++ b/backend/rotini/auth/base.py @@ -0,0 +1,8 @@ +import pydantic + + +class LoginRequestData(pydantic.BaseModel): + """Payload for login requests""" + + username: str + password: str diff --git a/backend/rotini/api/users.py b/backend/rotini/auth/routes.py similarity index 64% rename from backend/rotini/api/users.py rename to backend/rotini/auth/routes.py index 4170ddd..c7a345d 100644 --- a/backend/rotini/api/users.py +++ b/backend/rotini/auth/routes.py @@ -1,6 +1,10 @@ from fastapi import APIRouter, Request, HTTPException -import use_cases.users as users_use_cases +from use_cases.exceptions import DoesNotExist + +import auth.use_cases as auth_use_cases + +from .base import LoginRequestData router = APIRouter(prefix="/auth") @@ -26,15 +30,15 @@ async def create_user(request: Request): """ body = await request.json() - user = users_use_cases.create_new_user( + user = auth_use_cases.create_new_user( username=body["username"], raw_password=body["password"] ) return user -@router.post("/session/") -async def log_in(request: Request): +@router.post("/sessions/") +async def log_in(payload: LoginRequestData): """ Attempts to log a user in. @@ -47,14 +51,12 @@ async def log_in(request: Request): If the credentials are incorrect, immediate failure. """ - body = await request.json() + try: + user = auth_use_cases.get_user(username=payload.username) + except DoesNotExist as exc: + raise HTTPException(status_code=401) from exc - username = body["username"] - password = body["password"] - - user = users_use_cases.get_user(username=username) - - if not users_use_cases.validate_password_for_user(user["id"], password): + if not auth_use_cases.validate_password_for_user(user["id"], payload.password): raise HTTPException(status_code=401) return user diff --git a/backend/rotini/use_cases/users.py b/backend/rotini/auth/use_cases.py similarity index 100% rename from backend/rotini/use_cases/users.py rename to backend/rotini/auth/use_cases.py diff --git a/backend/rotini/main.py b/backend/rotini/main.py index 12bb801..d81ef0d 100644 --- a/backend/rotini/main.py +++ b/backend/rotini/main.py @@ -4,8 +4,8 @@ Rotini: a self-hosted cloud storage & productivity app. from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -import api.users -import api.files +import auth.routes as auth_routes +import api.files as files_routes app = FastAPI() @@ -19,8 +19,10 @@ app.add_middleware( allow_headers=["*"], ) -app.include_router(api.files.router) -app.include_router(api.users.router) +routers = [files_routes.router, auth_routes.router] + +for router in routers: + app.include_router(router) @app.get("/", status_code=204) -- 2.45.2 From bee2cc20119c2a79b7999c54d627ec55a5e9d455 Mon Sep 17 00:00:00 2001 From: Marc Cataford Date: Sun, 20 Aug 2023 11:07:13 -0400 Subject: [PATCH 07/13] test(backend): login route coverage --- backend/tests/test_auth_routes.py | 73 +++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 backend/tests/test_auth_routes.py diff --git a/backend/tests/test_auth_routes.py b/backend/tests/test_auth_routes.py new file mode 100644 index 0000000..815ba18 --- /dev/null +++ b/backend/tests/test_auth_routes.py @@ -0,0 +1,73 @@ +import pytest + + +@pytest.fixture(name="test_user_credentials") +def fixture_test_user_creds(): + """ + Test user credentials. + """ + return {"username": "testuser", "password": "testpassword"} + + +@pytest.fixture(name="test_user", autouse=True) +def fixture_test_user(client, test_user_credentials): + """ + Sets up a test user using the `test_user_credentials` data. + """ + response = client.post("/auth/users/", json=test_user_credentials) + yield response + + +def test_create_user_returns_200_on_success(): + pass + + +def test_create_user_with_nonunique_username_fails(): + pass + + +def test_create_user_requires_username_and_password_supplied(): + pass + + +def test_log_in_returns_200_and_user_on_success(client, test_user_credentials): + # The `test_user` fixture creates a user. + + response = client.post("/auth/sessions/", json=test_user_credentials) + + assert response.status_code == 200 + + returned = response.json() + + assert returned["username"] == test_user_credentials["username"] + + +def test_log_in_returns_401_on_wrong_password(client, test_user_credentials): + response = client.post( + "/auth/sessions/", + json={"username": test_user_credentials["username"], "password": "sillystring"}, + ) + + assert response.status_code == 401 + + +def test_log_in_returns_401_on_nonexistent_user(client): + response = client.post( + "/auth/sessions/", json={"username": "notauser", "password": "sillystring"} + ) + + assert response.status_code == 401 + + +@pytest.mark.parametrize( + "credentials", + [ + pytest.param({"username": "test"}, id="username_only"), + pytest.param({"password": "test"}, id="password_only"), + pytest.param({}, id="no_data"), + ], +) +def test_log_in_returns_422_on_invalid_input(client, credentials): + response = client.post("/auth/sessions", json=credentials) + + assert response.status_code == 422 -- 2.45.2 From 9c3cf13f77290e549e36136536a8f5e95f139233 Mon Sep 17 00:00:00 2001 From: Marc Cataford Date: Sun, 20 Aug 2023 11:10:00 -0400 Subject: [PATCH 08/13] refactor(backend): add request data schemas --- backend/rotini/auth/base.py | 7 +++++++ backend/rotini/auth/routes.py | 11 ++++------- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/backend/rotini/auth/base.py b/backend/rotini/auth/base.py index c7a4958..eb1d2ca 100644 --- a/backend/rotini/auth/base.py +++ b/backend/rotini/auth/base.py @@ -6,3 +6,10 @@ class LoginRequestData(pydantic.BaseModel): username: str password: str + + +class CreateUserRequestData(pydantic.BaseModel): + """Payload for user creation""" + + username: str + password: str diff --git a/backend/rotini/auth/routes.py b/backend/rotini/auth/routes.py index c7a345d..e57cfd3 100644 --- a/backend/rotini/auth/routes.py +++ b/backend/rotini/auth/routes.py @@ -3,14 +3,13 @@ from fastapi import APIRouter, Request, HTTPException from use_cases.exceptions import DoesNotExist import auth.use_cases as auth_use_cases - -from .base import LoginRequestData +import auth.base as auth_base router = APIRouter(prefix="/auth") @router.post("/users/") -async def create_user(request: Request): +async def create_user(payload: auth_base.CreateUserRequestData): """ POST /auth/users/ @@ -28,17 +27,15 @@ async def create_user(request: Request): If the username already exists, or the password is not adequate, 400 is returned. """ - body = await request.json() - user = auth_use_cases.create_new_user( - username=body["username"], raw_password=body["password"] + username=payload.username, raw_password=payload.password ) return user @router.post("/sessions/") -async def log_in(payload: LoginRequestData): +async def log_in(payload: auth_base.LoginRequestData): """ Attempts to log a user in. -- 2.45.2 From cd91881762336c762678038c6cf55c1061660b40 Mon Sep 17 00:00:00 2001 From: Marc Cataford Date: Sun, 20 Aug 2023 11:15:56 -0400 Subject: [PATCH 09/13] test(backend): refactor client call fixtures --- backend/tests/conftest.py | 16 ++++++++++++++++ backend/tests/test_auth_routes.py | 28 +++++++++++++--------------- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 649e779..7f36e24 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -33,3 +33,19 @@ def set_storage_path(tmp_path, monkeypatch): files_dir.mkdir() monkeypatch.setattr(settings, "STORAGE_ROOT", str(files_dir)) + + +@pytest.fixture(name="client_log_in") +def fixture_client_log_in(client): + def _client_log_in(credentials): + return client.post("/auth/sessions/", json=credentials) + + return _client_log_in + + +@pytest.fixture(name="client_create_user") +def fixture_client_create_user(client): + def _client_create_user(credentials): + return client.post("/auth/users/", json=credentials) + + return _client_create_user diff --git a/backend/tests/test_auth_routes.py b/backend/tests/test_auth_routes.py index 815ba18..baa237c 100644 --- a/backend/tests/test_auth_routes.py +++ b/backend/tests/test_auth_routes.py @@ -10,12 +10,11 @@ def fixture_test_user_creds(): @pytest.fixture(name="test_user", autouse=True) -def fixture_test_user(client, test_user_credentials): +def fixture_test_user(client_create_user, test_user_credentials): """ Sets up a test user using the `test_user_credentials` data. """ - response = client.post("/auth/users/", json=test_user_credentials) - yield response + yield client_create_user(test_user_credentials) def test_create_user_returns_200_on_success(): @@ -30,10 +29,12 @@ def test_create_user_requires_username_and_password_supplied(): pass -def test_log_in_returns_200_and_user_on_success(client, test_user_credentials): +def test_log_in_returns_200_and_user_on_success( + client_create_user, test_user_credentials +): # The `test_user` fixture creates a user. - response = client.post("/auth/sessions/", json=test_user_credentials) + response = client_create_user(test_user_credentials) assert response.status_code == 200 @@ -42,19 +43,16 @@ def test_log_in_returns_200_and_user_on_success(client, test_user_credentials): assert returned["username"] == test_user_credentials["username"] -def test_log_in_returns_401_on_wrong_password(client, test_user_credentials): - response = client.post( - "/auth/sessions/", - json={"username": test_user_credentials["username"], "password": "sillystring"}, +def test_log_in_returns_401_on_wrong_password(client_log_in, test_user_credentials): + response = client_log_in( + {"username": test_user_credentials["username"], "password": "sillystring"} ) assert response.status_code == 401 -def test_log_in_returns_401_on_nonexistent_user(client): - response = client.post( - "/auth/sessions/", json={"username": "notauser", "password": "sillystring"} - ) +def test_log_in_returns_401_on_nonexistent_user(client_log_in): + response = client_log_in({"username": "notauser", "password": "sillystring"}) assert response.status_code == 401 @@ -67,7 +65,7 @@ def test_log_in_returns_401_on_nonexistent_user(client): pytest.param({}, id="no_data"), ], ) -def test_log_in_returns_422_on_invalid_input(client, credentials): - response = client.post("/auth/sessions", json=credentials) +def test_log_in_returns_422_on_invalid_input(client_log_in, credentials): + response = client_log_in(credentials) assert response.status_code == 422 -- 2.45.2 From f945bb08f940efd4f8ce5497f90cb3e3bc302393 Mon Sep 17 00:00:00 2001 From: Marc Cataford Date: Sun, 20 Aug 2023 11:31:10 -0400 Subject: [PATCH 10/13] feat(backend): set up username uniqueness constraint --- backend/rotini/auth/base.py | 4 ++++ backend/rotini/auth/routes.py | 13 ++++++++----- backend/rotini/auth/use_cases.py | 18 ++++++++++-------- .../migrations/migration_1_user_table.py | 3 ++- 4 files changed, 24 insertions(+), 14 deletions(-) diff --git a/backend/rotini/auth/base.py b/backend/rotini/auth/base.py index eb1d2ca..c5de3b8 100644 --- a/backend/rotini/auth/base.py +++ b/backend/rotini/auth/base.py @@ -13,3 +13,7 @@ class CreateUserRequestData(pydantic.BaseModel): username: str password: str + + +class UsernameAlreadyExists(Exception): + pass diff --git a/backend/rotini/auth/routes.py b/backend/rotini/auth/routes.py index e57cfd3..26d63ff 100644 --- a/backend/rotini/auth/routes.py +++ b/backend/rotini/auth/routes.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Request, HTTPException +from fastapi import APIRouter, HTTPException from use_cases.exceptions import DoesNotExist @@ -8,7 +8,7 @@ import auth.base as auth_base router = APIRouter(prefix="/auth") -@router.post("/users/") +@router.post("/users/", status_code=201) async def create_user(payload: auth_base.CreateUserRequestData): """ POST /auth/users/ @@ -27,9 +27,12 @@ async def create_user(payload: auth_base.CreateUserRequestData): If the username already exists, or the password is not adequate, 400 is returned. """ - user = auth_use_cases.create_new_user( - username=payload.username, raw_password=payload.password - ) + try: + user = auth_use_cases.create_new_user( + username=payload.username, raw_password=payload.password + ) + except auth_base.UsernameAlreadyExists as exc: + raise HTTPException(status_code=400) from exc return user diff --git a/backend/rotini/auth/use_cases.py b/backend/rotini/auth/use_cases.py index 92e3ef5..70dc1a8 100644 --- a/backend/rotini/auth/use_cases.py +++ b/backend/rotini/auth/use_cases.py @@ -11,6 +11,8 @@ import argon2 from db import get_connection from use_cases.exceptions import DoesNotExist +import auth.base as auth_base + password_hasher = argon2.PasswordHasher() @@ -40,14 +42,14 @@ def create_new_user(*, username: str, raw_password: str) -> User: password_hash = _hash_secret(raw_password) with get_connection() as connection, connection.cursor() as cursor: - cursor.execute( - "INSERT INTO users (username, password_hash) VALUES (%s, %s) RETURNING id, username", - (username, password_hash), - ) - returned = cursor.fetchone() - - if returned is None: - raise RuntimeError("Uh") + try: + cursor.execute( + "INSERT INTO users (username, password_hash) VALUES (%s, %s) RETURNING id, username", + (username, password_hash), + ) + returned = cursor.fetchone() + except Exception as exc: + raise auth_base.UsernameAlreadyExists() inserted_id = returned[0] created_username = returned[1] diff --git a/backend/rotini/migrations/migration_1_user_table.py b/backend/rotini/migrations/migration_1_user_table.py index ccc78eb..7daf79d 100644 --- a/backend/rotini/migrations/migration_1_user_table.py +++ b/backend/rotini/migrations/migration_1_user_table.py @@ -17,7 +17,8 @@ UP_SQL = """CREATE TABLE password_hash varchar(128) NOT NULL, created_at timestamp DEFAULT now(), updated_at timestamp DEFAULT now(), - password_updated_at timestamp DEFAULT now() + password_updated_at timestamp DEFAULT now(), + CONSTRAINT unique_username UNIQUE(username) ) """ -- 2.45.2 From 19d8e90ce0ad7e1520079b4165c74f5185796978 Mon Sep 17 00:00:00 2001 From: Marc Cataford Date: Sun, 20 Aug 2023 11:31:24 -0400 Subject: [PATCH 11/13] test(backend): update coverage for username uniqueness --- backend/tests/conftest.py | 5 +++- backend/tests/test_auth_routes.py | 41 ++++++++++++++++++++++--------- 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 7f36e24..252f160 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -18,8 +18,11 @@ def reset_database(): """ Empties all user tables between tests. """ + tables = ["files", "users"] + with get_connection() as conn, conn.cursor() as cursor: - cursor.execute("DELETE FROM files;") + for table in tables: + cursor.execute("DELETE FROM " + table + ";") @pytest.fixture(autouse=True) diff --git a/backend/tests/test_auth_routes.py b/backend/tests/test_auth_routes.py index baa237c..2e01632 100644 --- a/backend/tests/test_auth_routes.py +++ b/backend/tests/test_auth_routes.py @@ -17,24 +17,43 @@ def fixture_test_user(client_create_user, test_user_credentials): yield client_create_user(test_user_credentials) -def test_create_user_returns_200_on_success(): - pass +def test_create_user_returns_201_on_success(client_create_user): + credentials = {"username": "newuser", "password": "test"} + response = client_create_user(credentials) + + assert response.status_code == 201 -def test_create_user_with_nonunique_username_fails(): - pass +def test_create_user_with_nonunique_username_fails(client_create_user): + credentials = {"username": "newuser", "password": "test"} + client_create_user(credentials) + + # Recreate the same user, name collision. + response = client_create_user(credentials) + + assert response.status_code == 400 -def test_create_user_requires_username_and_password_supplied(): - pass - - -def test_log_in_returns_200_and_user_on_success( - client_create_user, test_user_credentials +@pytest.mark.parametrize( + "credentials", + [ + pytest.param({"username": "test"}, id="username_only"), + pytest.param({"password": "test"}, id="password_only"), + pytest.param({}, id="no_data"), + ], +) +def test_create_user_requires_username_and_password_supplied( + client_create_user, credentials ): + response = client_create_user(credentials) + + assert response.status_code == 422 + + +def test_log_in_returns_200_and_user_on_success(client_log_in, test_user_credentials): # The `test_user` fixture creates a user. - response = client_create_user(test_user_credentials) + response = client_log_in(test_user_credentials) assert response.status_code == 200 -- 2.45.2 From c6ff529934eb8ad1708219541a2946ecaef376bb Mon Sep 17 00:00:00 2001 From: Marc Cataford Date: Sun, 20 Aug 2023 11:32:43 -0400 Subject: [PATCH 12/13] chore(backend): missing dunderinit --- backend/rotini/auth/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 backend/rotini/auth/__init__.py diff --git a/backend/rotini/auth/__init__.py b/backend/rotini/auth/__init__.py new file mode 100644 index 0000000..e69de29 -- 2.45.2 From c8e8fc063d8039de926909f5ea1ce915ecf62274 Mon Sep 17 00:00:00 2001 From: Marc Cataford Date: Sun, 20 Aug 2023 11:36:19 -0400 Subject: [PATCH 13/13] chore(backend): linting --- backend/rotini/auth/base.py | 5 ++++- backend/rotini/auth/use_cases.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/rotini/auth/base.py b/backend/rotini/auth/base.py index c5de3b8..28bc230 100644 --- a/backend/rotini/auth/base.py +++ b/backend/rotini/auth/base.py @@ -1,3 +1,6 @@ +""" +Class declarations and constants for the auth module. +""" import pydantic @@ -16,4 +19,4 @@ class CreateUserRequestData(pydantic.BaseModel): class UsernameAlreadyExists(Exception): - pass + """Signals a unique constraint violation on username values""" diff --git a/backend/rotini/auth/use_cases.py b/backend/rotini/auth/use_cases.py index 70dc1a8..a6402fa 100644 --- a/backend/rotini/auth/use_cases.py +++ b/backend/rotini/auth/use_cases.py @@ -49,7 +49,7 @@ def create_new_user(*, username: str, raw_password: str) -> User: ) returned = cursor.fetchone() except Exception as exc: - raise auth_base.UsernameAlreadyExists() + raise auth_base.UsernameAlreadyExists() from exc inserted_id = returned[0] created_username = returned[1] -- 2.45.2