-
-
Notifications
You must be signed in to change notification settings - Fork 9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Access (auth) refactor & database transactions #46
Changes from all commits
7aef69d
6c19302
5130624
fa52620
4384407
5453380
27f239d
ed0968c
cfa3fc0
1ebb949
12a01e7
d178fa2
78613ce
48b692b
db2cc2b
8247ac0
a2a66fd
6298622
9b27c1e
5b6bdaa
66cb5a1
60e940e
cc70003
2ad2126
a0b5856
ad4d0fb
686a15b
6fb9b21
f3b8f1b
25e21b1
5d33242
7a5154e
c39dfd9
b6ac23b
0b7637f
b5bfac1
1c81ea9
105f608
5c4280d
1521dc7
1bfff57
a76f531
ab8104c
3b6127c
a73533c
2b279a8
6f10141
1476822
edc8a13
3d14b75
be8b88d
23348ed
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
from .base import * |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
from typing import Dict, Any | ||
from fastapi import HTTPException | ||
from app.api import crud, security | ||
from app.db import accesses | ||
from app.api.schemas import AccessRead, AccessCreation, Cred, AccessBase, CredHash | ||
|
||
|
||
async def post_access(login: str, password: str, scopes: str) -> AccessRead: | ||
"""Assert login does not exist, hash password, post entry into accesses table""" | ||
if await crud.fetch_one(accesses, [('login', login)]) is not None: | ||
raise HTTPException( | ||
status_code=400, | ||
detail=f"An entry with login='{login}' already exists.", | ||
) | ||
|
||
pwd = await security.hash_password(password) | ||
access = AccessCreation(login=login, hashed_password=pwd, scopes=scopes) | ||
entry = await crud.create_entry(accesses, access) | ||
return AccessRead(**entry) | ||
|
||
|
||
async def update_access_login(payload: AccessBase, entry_id: int) -> Dict[str, Any]: | ||
# TODO check that works | ||
entry = await crud.update_entry(accesses, payload, entry_id) | ||
# Return non-sensitive information | ||
return {"login": entry["login"]} | ||
|
||
|
||
async def update_access_pwd(payload: Cred, entry_id: int) -> Dict[str, Any]: | ||
"""hash new password & update entry""" | ||
pwd = await security.hash_password(payload.password) | ||
|
||
updated_payload = CredHash(hashed_password=pwd) | ||
entry = await crud.update_entry(accesses, updated_payload, entry_id) | ||
|
||
# Return non-sensitive information | ||
return {"login": entry["login"]} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,17 +3,20 @@ | |
from fastapi import APIRouter, Path, Security, HTTPException | ||
|
||
from app.api import crud | ||
from app.db import devices | ||
from app.db import database, devices | ||
from app.api.schemas import DeviceOut, DeviceAuth, DeviceCreation, DeviceIn, UserRead, DefaultPosition, Cred | ||
from app.api.deps import get_current_device, get_current_user | ||
|
||
from app.api.routes.accesses import post_access, update_access_pwd | ||
from app.api.crud.accesses import post_access, update_access_pwd | ||
|
||
|
||
router = APIRouter() | ||
|
||
|
||
@router.post("/", response_model=DeviceOut, status_code=201) | ||
@database.transaction() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why add this decorator only for route that create objects? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I planned to add the decorator when transaction is needed (writing in several tables with atomicity), user/device creation was the first use case, did I miss other ones ? I realised that route decorator was not the best practice: it makes it impossible to test. We may need to find a way to mock a database transaction or we'll have to rewrite operations into pytest mocks. @frgfm do you see what I mean ? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am no where close to be knowledgeable about database transactions since I just discovered that thanks to you @jeanpasquier75. What is the expected behaviour difference after decorating the route? reducing execution time? output type change? If it's beneficial to the API, even if we can't test it per se, I would argue that we should still put it. If it doesn't bring much and we can't test it, then perhaps let's leave it out |
||
async def create_device(payload: DeviceAuth, _=Security(get_current_user, scopes=["admin"])): | ||
"""Use transaction to insert access/device.""" | ||
access_entry = await post_access(payload.login, payload.password, scopes=payload.scopes) | ||
return await crud.create_entry(devices, DeviceCreation(**payload.dict(), access_id=access_entry.id)) | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,12 +3,10 @@ | |
from fastapi import APIRouter, Path, Security | ||
|
||
from app.api import crud | ||
from app.db import users | ||
from app.db import database, users | ||
from app.api.schemas import UserInfo, UserCreation, Cred, UserRead, UserAuth | ||
from app.api.deps import get_current_user | ||
|
||
from app.api.routes.accesses import post_access, update_access_pwd | ||
|
||
|
||
router = APIRouter() | ||
|
||
|
@@ -26,14 +24,15 @@ async def update_my_info(payload: UserInfo, me: UserRead = Security(get_current_ | |
@router.put("/update-pwd", response_model=UserInfo) | ||
async def update_my_password(payload: Cred, me: UserRead = Security(get_current_user, scopes=["me"])): | ||
entry = await crud.get_entry(users, me.id) | ||
await update_access_pwd(payload, entry["access_id"]) | ||
await crud.update_access_pwd(payload, entry["access_id"]) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You forgot to do the same for the admin version further down below ;) |
||
return entry | ||
|
||
|
||
@router.post("/", response_model=UserRead, status_code=201) | ||
@database.transaction() | ||
frgfm marked this conversation as resolved.
Show resolved
Hide resolved
|
||
async def create_user(payload: UserAuth, _=Security(get_current_user, scopes=["admin"])): | ||
# Create a new access | ||
access_entry = await post_access(payload.login, payload.password, payload.scopes) | ||
"""Use transaction to insert access/user.""" | ||
access_entry = await crud.accesses.post_access(payload.login, payload.password, payload.scopes) | ||
return await crud.create_entry(users, UserCreation(login=payload.login, access_id=access_entry.id)) | ||
|
||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -28,7 +28,7 @@ | |
"accesses", | ||
metadata, | ||
Column("id", Integer, primary_key=True), | ||
Column("login", String(50)), | ||
Column("login", String(50), index=True, unique=True), # index for fast lookup | ||
frgfm marked this conversation as resolved.
Show resolved
Hide resolved
|
||
Column("hashed_password", String(70), nullable=False), | ||
Column("scopes", String(30), default="me", nullable=False), | ||
) | ||
|
@@ -75,7 +75,7 @@ class EventType(str, enum.Enum): | |
Column("id", Integer, primary_key=True), | ||
Column("login", String(50)), | ||
Column("owner_id", Integer, ForeignKey("users.id")), | ||
Column("access_id", Integer, ForeignKey("accesses.id"), unique=True), | ||
Column("access_id", Integer, ForeignKey("accesses.id", ondelete="CASCADE"), unique=True), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So deleting the device will delete the access? If so, shouldn't we do the same for the user? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. And shall we do it also for the owner_id ? If we delete the users, the devices will disappear? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes I thought that I also had added the on delete cascade on user but I forgot. I figured out that it is the opposite: deleting the access deletes the device/user. I am working on fixing the device/user deletion. I will add the mechanism owner id as well ! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Another issue when we use the on cascade mechanism -> as it perform at db level we have to find a way to mock it in our unit tests |
||
Column("specs", String(50)), | ||
Column("elevation", Float(1, asdecimal=True), default=None, nullable=True), | ||
Column("lat", Float(4, asdecimal=True), default=None, nullable=True), | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,8 +1,7 @@ | ||
import json | ||
import pytest | ||
from copy import deepcopy | ||
|
||
from app.api import crud | ||
from app.api.crud import base | ||
from app.api.routes import accesses | ||
|
||
|
||
|
@@ -16,12 +15,9 @@ def _patch_session(monkeypatch, mock_table): | |
# DB patching | ||
monkeypatch.setattr(accesses, "accesses", mock_table) | ||
# Sterilize all DB interactions through CRUD override | ||
monkeypatch.setattr(crud, "get", pytest.mock_get) | ||
monkeypatch.setattr(crud, "fetch_all", pytest.mock_fetch_all) | ||
monkeypatch.setattr(crud, "fetch_one", pytest.mock_fetch_one) | ||
monkeypatch.setattr(crud, "post", pytest.mock_post) | ||
monkeypatch.setattr(crud, "put", pytest.mock_put) | ||
monkeypatch.setattr(crud, "delete", pytest.mock_delete) | ||
monkeypatch.setattr(base, "get", pytest.mock_get) | ||
monkeypatch.setattr(base, "fetch_all", pytest.mock_fetch_all) | ||
monkeypatch.setattr(base, "fetch_one", pytest.mock_fetch_one) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since you made the crud init, you can keep the previous version (you have to patch the thin imported in the route, and does not import base, but crud) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actually it is not that simple (I think, I am not masting import mechanisms) because it seams that mocking crud.get will keep untouched the original crud.base.get function and if it imported afterward the original is used instead of the mocked version. So far the only way I could mock all crud function was to mock both crud._ and crud.base._ (_ beeing each function). Do you see ? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mmmh, what error did you get with my suggestion? Because the import for the accesses is over here: https://github.com/pyronear/pyro-api/blob/master/src/app/api/routes/accesses.py#L3 So if we patch this very import, it should be working without any problem 🤔 |
||
|
||
|
||
def test_get_access(test_app, monkeypatch): | ||
|
@@ -62,100 +58,3 @@ def test_fetch_accesses(test_app, monkeypatch): | |
assert response.status_code == 200 | ||
assert response.json() == [{k: v for k, v in entry.items() if k != "hashed_password"} | ||
for entry in mock_access_table] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we remove the unittests for the routes, we need to adapt them to test the crud.accesses functions. I don't think it would be wise to decrease coverage by that much |
||
|
||
|
||
def test_create_access(test_app, monkeypatch): | ||
|
||
# Sterilize DB interactions | ||
mock_access_table = deepcopy(ACCESS_TABLE) | ||
_patch_session(monkeypatch, mock_access_table) | ||
|
||
test_payload = {"login": "third_login", "scopes": "me", "password": "PickARobustOne"} | ||
test_response = {"id": len(mock_access_table) + 1, **test_payload} | ||
|
||
response = test_app.post("/accesses/", data=json.dumps(test_payload)) | ||
|
||
assert response.status_code == 201 | ||
assert response.json() == {k: v for k, v in test_response.items() if k != "password"} | ||
|
||
|
||
@pytest.mark.parametrize( | ||
"payload, status_code, status_details", | ||
[ | ||
[{"login": "first_login", "password": "PickARobustOne", "scopes": "me"}, 400, | ||
"An entry with login='first_login' already exists."], | ||
[{"login": "third_login", "scopes": "me", "hashed_password": "PickARobustOne"}, 422, None], | ||
[{"login": 1, "scopes": "me", "password": "PickARobustOne"}, 422, None], | ||
[{"login": "third_login", "scopes": "me"}, 422, None], | ||
], | ||
) | ||
def test_create_access_invalid(test_app, monkeypatch, payload, status_code, status_details): | ||
# Sterilize DB interactions | ||
mock_access_table = deepcopy(ACCESS_TABLE) | ||
_patch_session(monkeypatch, mock_access_table) | ||
|
||
response = test_app.post("/accesses/", data=json.dumps(payload)) | ||
assert response.status_code == status_code, print(payload) | ||
if isinstance(status_details, str): | ||
assert response.json()["detail"] == status_details, print(payload) | ||
|
||
|
||
def test_update_access(test_app, monkeypatch): | ||
# Sterilize DB interactions | ||
mock_access_table = deepcopy(ACCESS_TABLE) | ||
_patch_session(monkeypatch, mock_access_table) | ||
|
||
test_payload = {"login": "first_login", "scopes": "me", "password": "PickAnotherRobustOne"} | ||
response = test_app.put("/accesses/1/", data=json.dumps(test_payload)) | ||
assert response.status_code == 200 | ||
for k, v in mock_access_table[0].items(): | ||
assert v == test_payload.get(k, ACCESS_TABLE[0][k]) | ||
|
||
|
||
@pytest.mark.parametrize( | ||
"access_id, payload, status_code", | ||
[ | ||
[1, {}, 422], | ||
[1, {"login": "first_login"}, 422], | ||
[999, {"login": "first_login", "scopes": "me", "password": "PickAnotherRobustOne"}, 404], | ||
[1, {"login": 1, "scopes": "me", "password": "PickAnotherRobustOne"}, 422], | ||
[0, {"login": "first_login", "scopes": "me", "password": "PickAnotherRobustOne"}, 422], | ||
], | ||
) | ||
def test_update_access_invalid(test_app, monkeypatch, access_id, payload, status_code): | ||
# Sterilize DB interactions | ||
mock_access_table = deepcopy(ACCESS_TABLE) | ||
_patch_session(monkeypatch, mock_access_table) | ||
|
||
response = test_app.put(f"/accesses/{access_id}/", data=json.dumps(payload)) | ||
assert response.status_code == status_code, print(payload) | ||
|
||
|
||
def test_delete_access(test_app, monkeypatch): | ||
# Sterilize DB interactions | ||
mock_access_table = deepcopy(ACCESS_TABLE) | ||
_patch_session(monkeypatch, mock_access_table) | ||
|
||
response = test_app.delete("/accesses/1/") | ||
assert response.status_code == 200 | ||
assert response.json() == {k: v for k, v in ACCESS_TABLE[0].items() if k != "hashed_password"} | ||
for entry in mock_access_table: | ||
assert entry['id'] != 1 | ||
|
||
|
||
@pytest.mark.parametrize( | ||
"access_id, status_code, status_details", | ||
[ | ||
[999, 404, "Entry not found"], | ||
[0, 422, None], | ||
], | ||
) | ||
def test_delete_access_invalid(test_app, monkeypatch, access_id, status_code, status_details): | ||
# Sterilize DB interactions | ||
mock_access_table = deepcopy(ACCESS_TABLE) | ||
_patch_session(monkeypatch, mock_access_table) | ||
|
||
response = test_app.delete(f"/accesses/{access_id}/") | ||
assert response.status_code == status_code, print(access_id) | ||
if isinstance(status_details, str): | ||
assert response.json()["detail"] == status_details, print(access_id) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,7 +3,7 @@ | |
from copy import deepcopy | ||
from datetime import datetime | ||
|
||
from app.api import crud | ||
from app.api.crud import base | ||
from app.api.routes import alerts | ||
|
||
|
||
|
@@ -21,11 +21,11 @@ def _patch_session(monkeypatch, mock_table): | |
# DB patching | ||
monkeypatch.setattr(alerts, "alerts", mock_table) | ||
# Sterilize all DB interactions through CRUD override | ||
monkeypatch.setattr(crud, "get", pytest.mock_get) | ||
monkeypatch.setattr(crud, "fetch_all", pytest.mock_fetch_all) | ||
monkeypatch.setattr(crud, "post", pytest.mock_post) | ||
monkeypatch.setattr(crud, "put", pytest.mock_put) | ||
monkeypatch.setattr(crud, "delete", pytest.mock_delete) | ||
monkeypatch.setattr(base, "get", pytest.mock_get) | ||
monkeypatch.setattr(base, "fetch_all", pytest.mock_fetch_all) | ||
monkeypatch.setattr(base, "post", pytest.mock_post) | ||
monkeypatch.setattr(base, "put", pytest.mock_put) | ||
monkeypatch.setattr(base, "delete", pytest.mock_delete) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same here |
||
|
||
|
||
def test_get_alert(test_app, monkeypatch): | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we want to remove the access routes (it is fine by me if we think we don't need it anymore for debugging purpose), why leave those routes?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think he mentioned that we don't want to expose creation/modification of accesses but reading is fine
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes reading is fine while it does not break database consistency