diff --git a/mealie/app.py b/mealie/app.py index 1f90d916d..112c3f0f3 100644 --- a/mealie/app.py +++ b/mealie/app.py @@ -42,12 +42,16 @@ def api_routers(): app.include_router(meal_plan_router) # Settings Routes app.include_router(settings_router) - app.include_router(theme_routes.router) + app.include_router(theme_routes.public_router) + app.include_router(theme_routes.user_router) # Backups/Imports Routes app.include_router(backup_routes.router) # Migration Routes app.include_router(migration_routes.router) - app.include_router(debug_routes.router) + # Debug routes + app.include_router(debug_routes.public_router) + app.include_router(debug_routes.admin_router) + # Utility routes app.include_router(utility_routes.router) diff --git a/mealie/routes/about/events.py b/mealie/routes/about/events.py index 4f6405253..8adbaff87 100644 --- a/mealie/routes/about/events.py +++ b/mealie/routes/about/events.py @@ -1,41 +1,36 @@ from http.client import HTTPException -from fastapi import APIRouter, Depends, status +from fastapi import Depends, status from mealie.core.root_logger import get_logger from mealie.db.database import db from mealie.db.db_setup import generate_session -from mealie.routes.deps import get_current_user +from mealie.routes.routers import AdminAPIRouter from mealie.schema.event_notifications import EventNotificationIn, EventNotificationOut from mealie.schema.events import EventsOut, TestEvent -from mealie.schema.user import UserInDB from mealie.services.events import test_notification from sqlalchemy.orm.session import Session -router = APIRouter(prefix="/events", tags=["App Events"]) +router = AdminAPIRouter(prefix="/events", tags=["App Events"]) logger = get_logger() @router.get("", response_model=EventsOut) -async def get_events(session: Session = Depends(generate_session), current_user: UserInDB = Depends(get_current_user)): +async def get_events(session: Session = Depends(generate_session)): """ Get event from the Database """ # Get Item return EventsOut(total=db.events.count_all(session), events=db.events.get_all(session, order_by="time_stamp")) @router.delete("") -async def delete_events( - session: Session = Depends(generate_session), current_user: UserInDB = Depends(get_current_user) -): +async def delete_events(session: Session = Depends(generate_session)): """ Get event from the Database """ # Get Item return db.events.delete_all(session) @router.delete("/{id}") -async def delete_event( - id: int, session: Session = Depends(generate_session), current_user: UserInDB = Depends(get_current_user) -): +async def delete_event(id: int, session: Session = Depends(generate_session)): """ Delete event from the Database """ return db.events.delete(session, id) @@ -44,7 +39,6 @@ async def delete_event( async def create_event_notification( event_data: EventNotificationIn, session: Session = Depends(generate_session), - current_user: UserInDB = Depends(get_current_user), ): """ Create event_notification in the Database """ @@ -55,7 +49,6 @@ async def create_event_notification( async def test_notification_route( test_data: TestEvent, session: Session = Depends(generate_session), - current_user: UserInDB = Depends(get_current_user), ): """ Create event_notification in the Database """ @@ -71,27 +64,21 @@ async def test_notification_route( @router.get("/notifications", response_model=list[EventNotificationOut]) -async def get_all_event_notification( - session: Session = Depends(generate_session), current_user: UserInDB = Depends(get_current_user) -): +async def get_all_event_notification(session: Session = Depends(generate_session)): """ Get all event_notification from the Database """ # Get Item return db.event_notifications.get_all(session, override_schema=EventNotificationOut) @router.put("/notifications/{id}") -async def update_event_notification( - id: int, session: Session = Depends(generate_session), current_user: UserInDB = Depends(get_current_user) -): +async def update_event_notification(id: int, session: Session = Depends(generate_session)): """ Update event_notification in the Database """ - # Update Item - return {"details": "not yet implemented"} + # not yet implemented + raise HTTPException(status.HTTP_405_METHOD_NOT_ALLOWED) @router.delete("/notifications/{id}") -async def delete_event_notification( - id: int, session: Session = Depends(generate_session), current_user: UserInDB = Depends(get_current_user) -): +async def delete_event_notification(id: int, session: Session = Depends(generate_session)): """ Delete event_notification from the Database """ # Delete Item return db.event_notifications.delete(session, id) diff --git a/mealie/routes/backup_routes.py b/mealie/routes/backup_routes.py index 51eeb50d2..f1dc00494 100644 --- a/mealie/routes/backup_routes.py +++ b/mealie/routes/backup_routes.py @@ -2,19 +2,19 @@ import operator import shutil from pathlib import Path -from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, UploadFile, status +from fastapi import BackgroundTasks, Depends, File, HTTPException, UploadFile, status from mealie.core.config import app_dirs from mealie.core.root_logger import get_logger from mealie.core.security import create_file_token from mealie.db.db_setup import generate_session -from mealie.routes.deps import get_current_user +from mealie.routes.routers import AdminAPIRouter from mealie.schema.backup import BackupJob, ImportJob, Imports, LocalBackup from mealie.services.backups import imports from mealie.services.backups.exports import backup_all from mealie.services.events import create_backup_event from sqlalchemy.orm.session import Session -router = APIRouter(prefix="/api/backups", tags=["Backups"], dependencies=[Depends(get_current_user)]) +router = AdminAPIRouter(prefix="/api/backups", tags=["Backups"]) logger = get_logger() diff --git a/mealie/routes/debug_routes.py b/mealie/routes/debug_routes.py index 04f1516bb..a1fa7ec9b 100644 --- a/mealie/routes/debug_routes.py +++ b/mealie/routes/debug_routes.py @@ -1,18 +1,21 @@ -from fastapi import APIRouter, Depends +from fastapi import Depends +from fastapi.routing import APIRouter from mealie.core.config import APP_VERSION, app_dirs, settings from mealie.core.root_logger import LOGGER_FILE from mealie.core.security import create_file_token from mealie.db.database import db from mealie.db.db_setup import generate_session -from mealie.routes.deps import get_current_user +from mealie.routes.routers import AdminAPIRouter from mealie.schema.about import AppInfo, AppStatistics, DebugInfo from sqlalchemy.orm.session import Session -router = APIRouter(prefix="/api/debug", tags=["Debug"]) + +admin_router = AdminAPIRouter(prefix="/api/debug", tags=["Debug"]) +public_router = APIRouter(prefix="/api/debug", tags=["Debug"]) -@router.get("") -async def get_debug_info(current_user=Depends(get_current_user)): +@admin_router.get("") +async def get_debug_info(): """ Returns general information about the application for debugging """ return DebugInfo( @@ -27,7 +30,7 @@ async def get_debug_info(current_user=Depends(get_current_user)): ) -@router.get("/statistics") +@admin_router.get("/statistics") async def get_app_statistics(session: Session = Depends(generate_session)): return AppStatistics( total_recipes=db.recipes.count_all(session), @@ -38,7 +41,7 @@ async def get_app_statistics(session: Session = Depends(generate_session)): ) -@router.get("/version") +@public_router.get("/version") async def get_mealie_version(): """ Returns the current version of mealie""" return AppInfo( @@ -48,21 +51,21 @@ async def get_mealie_version(): ) -@router.get("/last-recipe-json") -async def get_last_recipe_json(current_user=Depends(get_current_user)): +@admin_router.get("/last-recipe-json") +async def get_last_recipe_json(): """ Returns a token to download a file """ return {"fileToken": create_file_token(app_dirs.DEBUG_DIR.joinpath("last_recipe.json"))} -@router.get("/log/{num}") -async def get_log(num: int, current_user=Depends(get_current_user)): +@admin_router.get("/log/{num}") +async def get_log(num: int): """ Doc Str """ with open(LOGGER_FILE, "rb") as f: log_text = tail(f, num) return log_text -@router.get("/log") +@admin_router.get("/log") async def get_log_file(): """ Returns a token to download a file """ return {"fileToken": create_file_token(LOGGER_FILE)} diff --git a/mealie/routes/deps.py b/mealie/routes/deps.py index 0999f9547..1e9d9f07b 100644 --- a/mealie/routes/deps.py +++ b/mealie/routes/deps.py @@ -75,6 +75,12 @@ async def get_current_user(token: str = Depends(oauth2_scheme), session=Depends( return user +async def get_admin_user(current_user=Depends(get_current_user)) -> UserInDB: + if not current_user.admin: + raise HTTPException(status.HTTP_403_FORBIDDEN) + return current_user + + def validate_long_live_token(session: Session, client_token: str, id: int) -> UserInDB: tokens: list[LongLiveTokenInDB] = db.api_tokens.get(session, id, "parent_id", limit=9999) diff --git a/mealie/routes/groups/__init__.py b/mealie/routes/groups/__init__.py index f8935bdb6..bd1a1fd98 100644 --- a/mealie/routes/groups/__init__.py +++ b/mealie/routes/groups/__init__.py @@ -1,8 +1,7 @@ from fastapi import APIRouter -from . import crud, groups +from . import groups groups_router = APIRouter() -groups_router.include_router(crud.router) groups_router.include_router(groups.router) diff --git a/mealie/routes/groups/crud.py b/mealie/routes/groups/crud.py index 0a19c1aec..305930b94 100644 --- a/mealie/routes/groups/crud.py +++ b/mealie/routes/groups/crud.py @@ -1,17 +1,18 @@ -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status +from fastapi import BackgroundTasks, Depends, HTTPException, status from mealie.db.database import db from mealie.db.db_setup import generate_session from mealie.routes.deps import get_current_user +from mealie.routes.routers import AdminAPIRouter, UserAPIRouter from mealie.schema.user import GroupBase, GroupInDB, UpdateGroup, UserInDB from mealie.services.events import create_group_event from sqlalchemy.orm.session import Session -router = APIRouter(prefix="/api/groups", tags=["Groups"]) +admin_router = AdminAPIRouter(prefix="/api/groups", tags=["Groups administration"]) +user_router = UserAPIRouter(prefix="/api/groups", tags=["Groups"]) -@router.get("", response_model=list[GroupInDB]) +@admin_router.get("", response_model=list[GroupInDB]) async def get_all_groups( - current_user=Depends(get_current_user), session: Session = Depends(generate_session), ): """ Returns a list of all groups in the database """ @@ -19,7 +20,7 @@ async def get_all_groups( return db.groups.get_all(session) -@router.get("/self", response_model=GroupInDB) +@user_router.get("/self", response_model=GroupInDB) async def get_current_user_group( current_user: UserInDB = Depends(get_current_user), session: Session = Depends(generate_session), @@ -30,11 +31,10 @@ async def get_current_user_group( return db.groups.get(session, current_user.group, "name") -@router.post("", status_code=status.HTTP_201_CREATED) +@admin_router.post("", status_code=status.HTTP_201_CREATED) async def create_group( background_tasks: BackgroundTasks, group_data: GroupBase, - current_user=Depends(get_current_user), session: Session = Depends(generate_session), ): """ Creates a Group in the Database """ @@ -46,18 +46,17 @@ async def create_group( raise HTTPException(status.HTTP_400_BAD_REQUEST) -@router.put("/{id}") +@admin_router.put("/{id}") async def update_group_data( id: int, group_data: UpdateGroup, - current_user=Depends(get_current_user), session: Session = Depends(generate_session), ): """ Updates a User Group """ db.groups.update(session, id, group_data.dict()) -@router.delete("/{id}") +@admin_router.delete("/{id}") async def delete_user_group( background_tasks: BackgroundTasks, id: int, diff --git a/mealie/routes/groups/groups.py b/mealie/routes/groups/groups.py index e7248b2f4..3c3e0f15f 100644 --- a/mealie/routes/groups/groups.py +++ b/mealie/routes/groups/groups.py @@ -3,4 +3,5 @@ from mealie.routes.groups import crud router = APIRouter() -router.include_router(crud.router) +router.include_router(crud.admin_router) +router.include_router(crud.user_router) diff --git a/mealie/routes/mealplans/crud.py b/mealie/routes/mealplans/crud.py index 219136c82..abd2fd516 100644 --- a/mealie/routes/mealplans/crud.py +++ b/mealie/routes/mealplans/crud.py @@ -1,7 +1,8 @@ -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status +from fastapi import BackgroundTasks, Depends, HTTPException, status from mealie.db.database import db from mealie.db.db_setup import generate_session from mealie.routes.deps import get_current_user +from mealie.routes.routers import UserAPIRouter from mealie.schema.meal import MealPlanIn, MealPlanOut from mealie.schema.user import GroupInDB, UserInDB from mealie.services.events import create_group_event @@ -10,7 +11,7 @@ from mealie.services.meal_services import get_todays_meal, set_mealplan_dates from sqlalchemy.orm.session import Session from starlette.responses import FileResponse -router = APIRouter(prefix="/api/meal-plans", tags=["Meal Plan"]) +router = UserAPIRouter(prefix="/api/meal-plans", tags=["Meal Plan"]) @router.get("/all", response_model=list[MealPlanOut]) diff --git a/mealie/routes/mealplans/helpers.py b/mealie/routes/mealplans/helpers.py index d5b90a551..389117ae6 100644 --- a/mealie/routes/mealplans/helpers.py +++ b/mealie/routes/mealplans/helpers.py @@ -1,8 +1,9 @@ -from fastapi import APIRouter, Depends +from fastapi import Depends from mealie.core.root_logger import get_logger from mealie.db.database import db from mealie.db.db_setup import generate_session from mealie.routes.deps import get_current_user +from mealie.routes.routers import UserAPIRouter from mealie.schema.meal import MealPlanOut from mealie.schema.recipe import Recipe from mealie.schema.shopping_list import ListItem, ShoppingListIn, ShoppingListOut @@ -11,7 +12,7 @@ from sqlalchemy.orm.session import Session logger = get_logger() -router = APIRouter(prefix="/api/meal-plans", tags=["Meal Plan"]) +router = UserAPIRouter(prefix="/api/meal-plans", tags=["Meal Plan"]) @router.get("/{id}/shopping-list") diff --git a/mealie/routes/migration_routes.py b/mealie/routes/migration_routes.py index 8fcfd7800..8fc9673c4 100644 --- a/mealie/routes/migration_routes.py +++ b/mealie/routes/migration_routes.py @@ -2,16 +2,15 @@ import operator import shutil from typing import List -from fastapi import APIRouter, Depends, File, UploadFile, status +from fastapi import Depends, File, HTTPException, UploadFile, status from mealie.core.config import app_dirs from mealie.db.db_setup import generate_session -from mealie.routes.deps import get_current_user +from mealie.routes.routers import AdminAPIRouter from mealie.schema.migration import MigrationFile, Migrations from mealie.services.migrations import migration from sqlalchemy.orm.session import Session -from fastapi import HTTPException -router = APIRouter(prefix="/api/migrations", tags=["Migration"], dependencies=[Depends(get_current_user)]) +router = AdminAPIRouter(prefix="/api/migrations", tags=["Migration"]) @router.get("", response_model=List[Migrations]) diff --git a/mealie/routes/recipe/__init__.py b/mealie/routes/recipe/__init__.py index cd78cf574..d2a6cd883 100644 --- a/mealie/routes/recipe/__init__.py +++ b/mealie/routes/recipe/__init__.py @@ -4,7 +4,12 @@ from mealie.routes.recipe import all_recipe_routes, category_routes, comments, r recipe_router = APIRouter() recipe_router.include_router(all_recipe_routes.router) -recipe_router.include_router(recipe_crud_routes.router) -recipe_router.include_router(category_routes.router) -recipe_router.include_router(tag_routes.router) +recipe_router.include_router(recipe_crud_routes.public_router) +recipe_router.include_router(recipe_crud_routes.user_router) +recipe_router.include_router(category_routes.public_router) +recipe_router.include_router(category_routes.user_router) +recipe_router.include_router(category_routes.admin_router) +recipe_router.include_router(tag_routes.admin_router) +recipe_router.include_router(tag_routes.user_router) +recipe_router.include_router(tag_routes.public_router) recipe_router.include_router(comments.router) diff --git a/mealie/routes/recipe/all_recipe_routes.py b/mealie/routes/recipe/all_recipe_routes.py index ca1ea6d6c..44d9e220a 100644 --- a/mealie/routes/recipe/all_recipe_routes.py +++ b/mealie/routes/recipe/all_recipe_routes.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends from mealie.db.database import db from mealie.db.db_setup import generate_session -from mealie.routes.deps import get_current_user, is_logged_in +from mealie.routes.deps import is_logged_in from mealie.schema.recipe import RecipeSummary from slugify import slugify from sqlalchemy.orm.session import Session @@ -36,21 +36,17 @@ async def get_recipe_summary( ) -@router.get( - "/api/recipes/summary/untagged", response_model=list[RecipeSummary], dependencies=[Depends(get_current_user)] -) +@router.get("/api/recipes/summary/untagged", response_model=list[RecipeSummary]) async def get_untagged_recipes(count: bool = False, session: Session = Depends(generate_session)): return db.recipes.count_untagged(session, count=count, override_schema=RecipeSummary) -@router.get( - "/api/recipes/summary/uncategorized", response_model=list[RecipeSummary], dependencies=[Depends(get_current_user)] -) +@router.get("/api/recipes/summary/uncategorized", response_model=list[RecipeSummary]) async def get_uncategorized_recipes(count: bool = False, session: Session = Depends(generate_session)): return db.recipes.count_uncategorized(session, count=count, override_schema=RecipeSummary) -@router.post("/api/recipes/category", deprecated=True, dependencies=[Depends(get_current_user)]) +@router.post("/api/recipes/category", deprecated=True) def filter_by_category(categories: list, session: Session = Depends(generate_session)): """ pass a list of categories and get a list of recipes associated with those categories """ # ! This should be refactored into a single database call, but I couldn't figure it out @@ -60,7 +56,7 @@ def filter_by_category(categories: list, session: Session = Depends(generate_ses return in_category -@router.post("/api/recipes/tag", deprecated=True, dependencies=[Depends(get_current_user)]) +@router.post("/api/recipes/tag", deprecated=True) async def filter_by_tags(tags: list, session: Session = Depends(generate_session)): """ pass a list of tags and get a list of recipes associated with those tags""" # ! This should be refactored into a single database call, but I couldn't figure it out diff --git a/mealie/routes/recipe/category_routes.py b/mealie/routes/recipe/category_routes.py index 0c82c14ec..a0e93cf0d 100644 --- a/mealie/routes/recipe/category_routes.py +++ b/mealie/routes/recipe/category_routes.py @@ -1,26 +1,29 @@ from fastapi import APIRouter, Depends, HTTPException, status from mealie.db.database import db from mealie.db.db_setup import generate_session -from mealie.routes.deps import get_current_user, is_logged_in +from mealie.routes.deps import is_logged_in +from mealie.routes.routers import AdminAPIRouter, UserAPIRouter from mealie.schema.category import CategoryIn, RecipeCategoryResponse from sqlalchemy.orm.session import Session -router = APIRouter(prefix="/api/categories", tags=["Recipe Categories"]) +public_router = APIRouter(prefix="/api/categories", tags=["Recipe Categories"]) +user_router = UserAPIRouter(prefix="/api/categories", tags=["Recipe Categories"]) +admin_router = AdminAPIRouter(prefix="/api/categories", tags=["Recipe Categories"]) -@router.get("") +@public_router.get("") async def get_all_recipe_categories(session: Session = Depends(generate_session)): """ Returns a list of available categories in the database """ return db.categories.get_all_limit_columns(session, ["slug", "name"]) -@router.get("/empty") +@public_router.get("/empty") def get_empty_categories(session: Session = Depends(generate_session)): """ Returns a list of categories that do not contain any recipes""" return db.categories.get_empty(session) -@router.get("/{category}", response_model=RecipeCategoryResponse) +@public_router.get("/{category}", response_model=RecipeCategoryResponse) def get_all_recipes_by_category( category: str, session: Session = Depends(generate_session), is_user: bool = Depends(is_logged_in) ): @@ -35,7 +38,7 @@ def get_all_recipes_by_category( return category_obj -@router.post("", dependencies=[Depends(get_current_user)]) +@user_router.post("") async def create_recipe_category(category: CategoryIn, session: Session = Depends(generate_session)): """ Creates a Category in the database """ @@ -45,7 +48,7 @@ async def create_recipe_category(category: CategoryIn, session: Session = Depend raise HTTPException(status.HTTP_400_BAD_REQUEST) -@router.put("/{category}", response_model=RecipeCategoryResponse, dependencies=[Depends(get_current_user)]) +@admin_router.put("/{category}", response_model=RecipeCategoryResponse) async def update_recipe_category(category: str, new_category: CategoryIn, session: Session = Depends(generate_session)): """ Updates an existing Tag in the database """ @@ -55,7 +58,7 @@ async def update_recipe_category(category: str, new_category: CategoryIn, sessio raise HTTPException(status.HTTP_400_BAD_REQUEST) -@router.delete("/{category}", dependencies=[Depends(get_current_user)]) +@admin_router.delete("/{category}") async def delete_recipe_category(category: str, session: Session = Depends(generate_session)): """ Removes a recipe category from the database. Deleting a diff --git a/mealie/routes/recipe/comments.py b/mealie/routes/recipe/comments.py index 2adbedf39..ce031fb3a 100644 --- a/mealie/routes/recipe/comments.py +++ b/mealie/routes/recipe/comments.py @@ -1,14 +1,15 @@ from http.client import HTTPException -from fastapi import APIRouter, Depends, status +from fastapi import Depends, status from mealie.db.database import db from mealie.db.db_setup import generate_session from mealie.routes.deps import get_current_user +from mealie.routes.routers import UserAPIRouter from mealie.schema.comments import CommentIn, CommentOut, CommentSaveToDB from mealie.schema.user import UserInDB from sqlalchemy.orm.session import Session -router = APIRouter(prefix="/api", tags=["Recipe Comments"]) +router = UserAPIRouter(prefix="/api", tags=["Recipe Comments"]) @router.post("/recipes/{slug}/comments") @@ -35,7 +36,7 @@ async def update_comment( old_comment: CommentOut = db.comments.get(session, id) if current_user.id != old_comment.user.id: - raise HTTPException(status.HTTP_401_UNAUTHORIZED) + raise HTTPException(status.HTTP_403_FORBIDDEN) return db.comments.update(session, id, new_comment) @@ -51,4 +52,4 @@ async def delete_comment( db.comments.delete(session, id) return - raise HTTPException(status.HTTP_401_UNAUTHORIZED) + raise HTTPException(status.HTTP_403_FORBIDDEN) diff --git a/mealie/routes/recipe/recipe_crud_routes.py b/mealie/routes/recipe/recipe_crud_routes.py index 742392bd5..34331e239 100644 --- a/mealie/routes/recipe/recipe_crud_routes.py +++ b/mealie/routes/recipe/recipe_crud_routes.py @@ -1,4 +1,5 @@ import json +from mealie.routes.routers import UserAPIRouter import shutil from shutil import copyfileobj from zipfile import ZipFile @@ -21,11 +22,12 @@ from slugify import slugify from sqlalchemy.orm.session import Session from starlette.responses import FileResponse -router = APIRouter(prefix="/api/recipes", tags=["Recipe CRUD"]) +user_router = UserAPIRouter(prefix="/api/recipes", tags=["Recipe CRUD"]) +public_router = APIRouter(prefix="/api/recipes", tags=["Recipe CRUD"]) logger = get_logger() -@router.post("/create", status_code=201, response_model=str) +@user_router.post("/create", status_code=201, response_model=str) def create_from_json( background_tasks: BackgroundTasks, data: Recipe, @@ -46,12 +48,12 @@ def create_from_json( return recipe.slug -@router.post("/test-scrape-url", dependencies=[Depends(get_current_user)]) +@user_router.post("/test-scrape-url") def test_parse_recipe_url(url: RecipeURLIn): return scrape_url(url.url) -@router.post("/create-url", status_code=201, response_model=str) +@user_router.post("/create-url", status_code=201, response_model=str) def parse_recipe_url( background_tasks: BackgroundTasks, url: RecipeURLIn, @@ -74,7 +76,7 @@ def parse_recipe_url( return recipe.slug -@router.get("/{recipe_slug}", response_model=Recipe) +@public_router.get("/{recipe_slug}", response_model=Recipe) def get_recipe(recipe_slug: str, session: Session = Depends(generate_session), is_user: bool = Depends(is_logged_in)): """ Takes in a recipe slug, returns all data for a recipe """ @@ -88,10 +90,10 @@ def get_recipe(recipe_slug: str, session: Session = Depends(generate_session), i return recipe else: - raise HTTPException(status.HTTP_401_UNAUTHORIZED, {"details": "unauthorized"}) + raise HTTPException(status.HTTP_403_FORBIDDEN) -@router.post("/create-from-zip", dependencies=[Depends(get_current_user)]) +@user_router.post("/create-from-zip") async def create_recipe_from_zip( session: Session = Depends(generate_session), temp_path=Depends(temporary_zip_path), @@ -121,7 +123,7 @@ async def create_recipe_from_zip( return recipe -@router.get("/{recipe_slug}/zip") +@public_router.get("/{recipe_slug}/zip") async def get_recipe_as_zip( recipe_slug: str, session: Session = Depends(generate_session), temp_path=Depends(temporary_zip_path) ): @@ -139,7 +141,7 @@ async def get_recipe_as_zip( return FileResponse(temp_path, filename=f"{recipe_slug}.zip") -@router.put("/{recipe_slug}", dependencies=[Depends(get_current_user)]) +@user_router.put("/{recipe_slug}") def update_recipe( recipe_slug: str, data: Recipe, @@ -154,7 +156,7 @@ def update_recipe( return recipe -@router.patch("/{recipe_slug}", dependencies=[Depends(get_current_user)]) +@user_router.patch("/{recipe_slug}") def patch_recipe( recipe_slug: str, data: Recipe, @@ -171,7 +173,7 @@ def patch_recipe( return recipe -@router.delete("/{recipe_slug}") +@user_router.delete("/{recipe_slug}") def delete_recipe( background_tasks: BackgroundTasks, recipe_slug: str, @@ -194,7 +196,7 @@ def delete_recipe( raise HTTPException(status.HTTP_400_BAD_REQUEST) -@router.put("/{recipe_slug}/image", dependencies=[Depends(get_current_user)]) +@user_router.put("/{recipe_slug}/image") def update_recipe_image( recipe_slug: str, image: bytes = File(...), @@ -208,7 +210,7 @@ def update_recipe_image( return {"image": new_version} -@router.post("/{recipe_slug}/image", dependencies=[Depends(get_current_user)]) +@user_router.post("/{recipe_slug}/image") def scrape_image_url( recipe_slug: str, url: RecipeURLIn, @@ -218,7 +220,7 @@ def scrape_image_url( scrape_image(url.url, recipe_slug) -@router.post("/{recipe_slug}/assets", response_model=RecipeAsset, dependencies=[Depends(get_current_user)]) +@user_router.post("/{recipe_slug}/assets", response_model=RecipeAsset) def upload_recipe_asset( recipe_slug: str, name: str = Form(...), diff --git a/mealie/routes/recipe/tag_routes.py b/mealie/routes/recipe/tag_routes.py index 9241d4a74..58962ac13 100644 --- a/mealie/routes/recipe/tag_routes.py +++ b/mealie/routes/recipe/tag_routes.py @@ -1,28 +1,29 @@ from fastapi import APIRouter, Depends, HTTPException, status from mealie.db.database import db from mealie.db.db_setup import generate_session -from mealie.routes.deps import get_current_user, is_logged_in +from mealie.routes.deps import is_logged_in +from mealie.routes.routers import AdminAPIRouter, UserAPIRouter from mealie.schema.category import RecipeTagResponse, TagIn from sqlalchemy.orm.session import Session -router = APIRouter(tags=["Recipes"]) - -router = APIRouter(prefix="/api/tags", tags=["Recipe Tags"]) +public_router = APIRouter(prefix="/api/tags", tags=["Recipe Tags"]) +user_router = UserAPIRouter(prefix="/api/tags", tags=["Recipe Tags"]) +admin_router = AdminAPIRouter(prefix="/api/tags", tags=["Recipe Tags"]) -@router.get("") +@public_router.get("") async def get_all_recipe_tags(session: Session = Depends(generate_session)): """ Returns a list of available tags in the database """ return db.tags.get_all_limit_columns(session, ["slug", "name"]) -@router.get("/empty") +@public_router.get("/empty") def get_empty_tags(session: Session = Depends(generate_session)): """ Returns a list of tags that do not contain any recipes""" return db.tags.get_empty(session) -@router.get("/{tag}", response_model=RecipeTagResponse) +@public_router.get("/{tag}", response_model=RecipeTagResponse) def get_all_recipes_by_tag( tag: str, session: Session = Depends(generate_session), is_user: bool = Depends(is_logged_in) ): @@ -36,21 +37,21 @@ def get_all_recipes_by_tag( return tag_obj -@router.post("", dependencies=[Depends(get_current_user)]) +@user_router.post("") async def create_recipe_tag(tag: TagIn, session: Session = Depends(generate_session)): """ Creates a Tag in the database """ return db.tags.create(session, tag.dict()) -@router.put("/{tag}", response_model=RecipeTagResponse, dependencies=[Depends(get_current_user)]) +@admin_router.put("/{tag}", response_model=RecipeTagResponse) async def update_recipe_tag(tag: str, new_tag: TagIn, session: Session = Depends(generate_session)): """ Updates an existing Tag in the database """ return db.tags.update(session, tag, new_tag.dict()) -@router.delete("/{tag}", dependencies=[Depends(get_current_user)]) +@admin_router.delete("/{tag}") async def delete_recipe_tag(tag: str, session: Session = Depends(generate_session)): """Removes a recipe tag from the database. Deleting a tag does not impact a recipe. The tag will be removed diff --git a/mealie/routes/routers.py b/mealie/routes/routers.py new file mode 100644 index 000000000..996f7bdca --- /dev/null +++ b/mealie/routes/routers.py @@ -0,0 +1,26 @@ +from fastapi import APIRouter, Depends +from mealie.routes.deps import get_admin_user, get_current_user + +from typing import List, Optional + + +class AdminAPIRouter(APIRouter): + """ Router for functions to be protected behind admin authentication """ + + def __init__( + self, + tags: Optional[List[str]] = None, + prefix: str = "", + ): + super().__init__(tags=tags, prefix=prefix, dependencies=[Depends(get_admin_user)]) + + +class UserAPIRouter(APIRouter): + """ Router for functions to be protected behind user authentication """ + + def __init__( + self, + tags: Optional[List[str]] = None, + prefix: str = "", + ): + super().__init__(tags=tags, prefix=prefix, dependencies=[Depends(get_current_user)]) diff --git a/mealie/routes/shopping_list.py b/mealie/routes/shopping_list.py index 83dfc8774..5b0ed8594 100644 --- a/mealie/routes/shopping_list.py +++ b/mealie/routes/shopping_list.py @@ -1,12 +1,13 @@ -from fastapi import APIRouter, Depends +from fastapi import Depends from mealie.db.database import db from mealie.db.db_setup import generate_session from mealie.routes.deps import get_current_user +from mealie.routes.routers import UserAPIRouter from mealie.schema.shopping_list import ShoppingListIn, ShoppingListOut from mealie.schema.user import UserInDB from sqlalchemy.orm.session import Session -shopping_list_router = APIRouter(prefix="/api/shopping-lists", tags=["Shopping Lists"]) +shopping_list_router = UserAPIRouter(prefix="/api/shopping-lists", tags=["Shopping Lists"]) @shopping_list_router.post("", response_model=ShoppingListOut) @@ -28,13 +29,13 @@ async def get_shopping_list(id: int, session: Session = Depends(generate_session return db.shopping_lists.get(session, id) -@shopping_list_router.put("/{id}", dependencies=[Depends(get_current_user)], response_model=ShoppingListOut) +@shopping_list_router.put("/{id}", response_model=ShoppingListOut) async def update_shopping_list(id: int, new_data: ShoppingListIn, session: Session = Depends(generate_session)): """ Update Shopping List in the Database """ return db.shopping_lists.update(session, id, new_data) -@shopping_list_router.delete("/{id}", dependencies=[Depends(get_current_user)]) +@shopping_list_router.delete("/{id}") async def delete_shopping_list(id: int, session: Session = Depends(generate_session)): """ Delete Shopping List from the Database """ return db.shopping_lists.delete(session, id) diff --git a/mealie/routes/site_settings/__init__.py b/mealie/routes/site_settings/__init__.py index ec356e63f..7eda166cd 100644 --- a/mealie/routes/site_settings/__init__.py +++ b/mealie/routes/site_settings/__init__.py @@ -1,9 +1,10 @@ from fastapi import APIRouter -from . import all_settings, custom_pages, site_settings +from . import custom_pages, site_settings settings_router = APIRouter() -settings_router.include_router(all_settings.router) -settings_router.include_router(custom_pages.router) -settings_router.include_router(site_settings.router) +settings_router.include_router(custom_pages.public_router) +settings_router.include_router(custom_pages.admin_router) +settings_router.include_router(site_settings.public_router) +settings_router.include_router(site_settings.admin_router) diff --git a/mealie/routes/site_settings/all_settings.py b/mealie/routes/site_settings/all_settings.py deleted file mode 100644 index 09da2eda2..000000000 --- a/mealie/routes/site_settings/all_settings.py +++ /dev/null @@ -1,7 +0,0 @@ -from fastapi import APIRouter -from mealie.routes.site_settings import custom_pages, site_settings - -router = APIRouter() - -router.include_router(custom_pages.router) -router.include_router(site_settings.router) diff --git a/mealie/routes/site_settings/custom_pages.py b/mealie/routes/site_settings/custom_pages.py index c3f382bfd..94e494a1f 100644 --- a/mealie/routes/site_settings/custom_pages.py +++ b/mealie/routes/site_settings/custom_pages.py @@ -1,46 +1,41 @@ +from mealie.routes.routers import AdminAPIRouter from typing import Union from fastapi import APIRouter, Depends from mealie.db.database import db from mealie.db.db_setup import generate_session -from mealie.routes.deps import get_current_user from mealie.schema.settings import CustomPageBase, CustomPageOut -from mealie.schema.user import UserInDB from sqlalchemy.orm.session import Session -router = APIRouter(prefix="/api/site-settings/custom-pages", tags=["Settings"]) +public_router = APIRouter(prefix="/api/site-settings/custom-pages", tags=["Settings"]) +admin_router = AdminAPIRouter(prefix="/api/site-settings/custom-pages", tags=["Settings"]) -@router.get("") +@public_router.get("") def get_custom_pages(session: Session = Depends(generate_session)): """ Returns the sites custom pages """ return db.custom_pages.get_all(session) -@router.post("") +@admin_router.post("") async def create_new_page( new_page: CustomPageBase, session: Session = Depends(generate_session), - current_user: UserInDB = Depends(get_current_user), ): """ Creates a new Custom Page """ db.custom_pages.create(session, new_page.dict()) -@router.put("") -async def update_multiple_pages( - pages: list[CustomPageOut], - session: Session = Depends(generate_session), - current_user: UserInDB = Depends(get_current_user), -): +@admin_router.put("") +async def update_multiple_pages(pages: list[CustomPageOut], session: Session = Depends(generate_session)): """ Update multiple custom pages """ for page in pages: db.custom_pages.update(session, page.id, page.dict()) -@router.get("/{id}") +@public_router.get("/{id}") async def get_single_page( id: Union[int, str], session: Session = Depends(generate_session), @@ -52,23 +47,21 @@ async def get_single_page( return db.custom_pages.get(session, id, "slug") -@router.put("/{id}") +@admin_router.put("/{id}") async def update_single_page( data: CustomPageOut, id: int, session: Session = Depends(generate_session), - current_user=Depends(get_current_user), ): """ Removes a custom page from the database """ return db.custom_pages.update(session, id, data.dict()) -@router.delete("/{id}") +@admin_router.delete("/{id}") async def delete_custom_page( id: int, session: Session = Depends(generate_session), - current_user: UserInDB = Depends(get_current_user), ): """ Removes a custom page from the database """ diff --git a/mealie/routes/site_settings/site_settings.py b/mealie/routes/site_settings/site_settings.py index f67c75fc2..53707d932 100644 --- a/mealie/routes/site_settings/site_settings.py +++ b/mealie/routes/site_settings/site_settings.py @@ -2,22 +2,24 @@ from fastapi import APIRouter, Depends, HTTPException, status from mealie.db.database import db from mealie.db.db_setup import generate_session from mealie.routes.deps import get_current_user +from mealie.routes.routers import AdminAPIRouter from mealie.schema.settings import SiteSettings from mealie.schema.user import GroupInDB, UserInDB from mealie.utils.post_webhooks import post_webhooks from sqlalchemy.orm.session import Session -router = APIRouter(prefix="/api/site-settings", tags=["Settings"]) +public_router = APIRouter(prefix="/api/site-settings", tags=["Settings"]) +admin_router = AdminAPIRouter(prefix="/api/site-settings", tags=["Settings"]) -@router.get("") +@public_router.get("") def get_main_settings(session: Session = Depends(generate_session)): """ Returns basic site settings """ return db.settings.get(session, 1) -@router.put("", dependencies=[Depends(get_current_user)]) +@admin_router.put("") def update_settings( data: SiteSettings, session: Session = Depends(generate_session), @@ -26,7 +28,7 @@ def update_settings( db.settings.update(session, 1, data.dict()) -@router.post("/webhooks/test") +@admin_router.post("/webhooks/test") def test_webhooks( current_user: UserInDB = Depends(get_current_user), session: Session = Depends(generate_session), diff --git a/mealie/routes/theme_routes.py b/mealie/routes/theme_routes.py index 6bbe5ef0c..2a0ff64b5 100644 --- a/mealie/routes/theme_routes.py +++ b/mealie/routes/theme_routes.py @@ -1,45 +1,46 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.routing import APIRouter +from fastapi import Depends, HTTPException, status from mealie.db.database import db from mealie.db.db_setup import generate_session -from mealie.routes.deps import get_current_user +from mealie.routes.routers import UserAPIRouter from mealie.schema.theme import SiteTheme from sqlalchemy.orm.session import Session -router = APIRouter(prefix="/api", tags=["Themes"]) +user_router = UserAPIRouter(prefix="/api", tags=["Themes"]) +public_router = APIRouter(prefix="/api", tags=["Themes"]) -@router.get("/themes") +@public_router.get("/themes") def get_all_themes(session: Session = Depends(generate_session)): """ Returns all site themes """ return db.themes.get_all(session) -@router.post("/themes/create", status_code=status.HTTP_201_CREATED) -def create_theme(data: SiteTheme, session: Session = Depends(generate_session), current_user=Depends(get_current_user)): +@user_router.post("/themes/create", status_code=status.HTTP_201_CREATED) +def create_theme(data: SiteTheme, session: Session = Depends(generate_session)): """ Creates a site color theme database entry """ db.themes.create(session, data.dict()) -@router.get("/themes/{id}") +@public_router.get("/themes/{id}") def get_single_theme(id: int, session: Session = Depends(generate_session)): """ Returns a named theme """ return db.themes.get(session, id) -@router.put("/themes/{id}", status_code=status.HTTP_200_OK) +@user_router.put("/themes/{id}", status_code=status.HTTP_200_OK) def update_theme( id: int, data: SiteTheme, session: Session = Depends(generate_session), - current_user=Depends(get_current_user), ): """ Update a theme database entry """ db.themes.update(session, id, data.dict()) -@router.delete("/themes/{id}", status_code=status.HTTP_200_OK) -def delete_theme(id: int, session: Session = Depends(generate_session), current_user=Depends(get_current_user)): +@user_router.delete("/themes/{id}", status_code=status.HTTP_200_OK) +def delete_theme(id: int, session: Session = Depends(generate_session)): """ Deletes theme from the database """ try: db.themes.delete(session, id) diff --git a/mealie/routes/unit_and_foods/food_routes.py b/mealie/routes/unit_and_foods/food_routes.py index 66c599c7d..7d1c9b6dd 100644 --- a/mealie/routes/unit_and_foods/food_routes.py +++ b/mealie/routes/unit_and_foods/food_routes.py @@ -1,8 +1,7 @@ -from fastapi import APIRouter, Depends +from mealie.routes.routers import UserAPIRouter from mealie.core.root_logger import get_logger -from mealie.routes.deps import get_current_user -router = APIRouter(prefix="/api/foods", dependencies=[Depends(get_current_user)]) +router = UserAPIRouter(prefix="/api/foods") logger = get_logger() diff --git a/mealie/routes/unit_and_foods/unit_routes.py b/mealie/routes/unit_and_foods/unit_routes.py index ae239ce7a..ff00807e6 100644 --- a/mealie/routes/unit_and_foods/unit_routes.py +++ b/mealie/routes/unit_and_foods/unit_routes.py @@ -1,8 +1,7 @@ -from fastapi import APIRouter, Depends +from mealie.routes.routers import UserAPIRouter from mealie.core.root_logger import get_logger -from mealie.routes.deps import get_current_user -router = APIRouter(prefix="/api/units", dependencies=[Depends(get_current_user)]) +router = UserAPIRouter(prefix="/api/units") logger = get_logger() diff --git a/mealie/routes/users/__init__.py b/mealie/routes/users/__init__.py index 727fa4138..a01219f87 100644 --- a/mealie/routes/users/__init__.py +++ b/mealie/routes/users/__init__.py @@ -4,7 +4,11 @@ from . import api_tokens, auth, crud, sign_up user_router = APIRouter() -user_router.include_router(auth.router) -user_router.include_router(sign_up.router) -user_router.include_router(crud.router) +user_router.include_router(auth.public_router) +user_router.include_router(auth.user_router) +user_router.include_router(sign_up.public_router) +user_router.include_router(sign_up.admin_router) +user_router.include_router(crud.public_router) +user_router.include_router(crud.user_router) +user_router.include_router(crud.admin_router) user_router.include_router(api_tokens.router) diff --git a/mealie/routes/users/api_tokens.py b/mealie/routes/users/api_tokens.py index f82fde44f..502d6648a 100644 --- a/mealie/routes/users/api_tokens.py +++ b/mealie/routes/users/api_tokens.py @@ -1,15 +1,16 @@ from datetime import timedelta -from fastapi import APIRouter, HTTPException, status +from fastapi import HTTPException, status from fastapi.param_functions import Depends from mealie.core.security import create_access_token from mealie.db.database import db from mealie.db.db_setup import generate_session from mealie.routes.deps import get_current_user +from mealie.routes.routers import UserAPIRouter from mealie.schema.user import CreateToken, LoingLiveTokenIn, LongLiveTokenInDB, UserInDB from sqlalchemy.orm.session import Session -router = APIRouter(prefix="/api/users", tags=["User API Tokens"]) +router = UserAPIRouter(prefix="/api/users", tags=["User API Tokens"]) @router.post("/api-tokens", status_code=status.HTTP_201_CREATED) @@ -53,4 +54,4 @@ async def delete_api_token( deleted_token = db.api_tokens.delete(session, token_id) return {"token_delete": deleted_token.name} else: - raise HTTPException(status.HTTP_401_UNAUTHORIZED) + raise HTTPException(status.HTTP_403_FORBIDDEN) diff --git a/mealie/routes/users/auth.py b/mealie/routes/users/auth.py index b5b5ddb9e..0d167562b 100644 --- a/mealie/routes/users/auth.py +++ b/mealie/routes/users/auth.py @@ -5,15 +5,17 @@ from mealie.core import security from mealie.core.security import authenticate_user from mealie.db.db_setup import generate_session from mealie.routes.deps import get_current_user +from mealie.routes.routers import UserAPIRouter from mealie.schema.user import UserInDB from mealie.services.events import create_user_event from sqlalchemy.orm.session import Session -router = APIRouter(prefix="/api/auth", tags=["Authentication"]) +public_router = APIRouter(prefix="/api/auth", tags=["Authentication"]) +user_router = UserAPIRouter(prefix="/api/auth", tags=["Authentication"]) -@router.post("/token/long") -@router.post("/token") +@public_router.post("/token/long") +@public_router.post("/token") def get_token( background_tasks: BackgroundTasks, request: Request, @@ -38,7 +40,7 @@ def get_token( return {"access_token": access_token, "token_type": "bearer"} -@router.get("/refresh") +@user_router.get("/refresh") async def refresh_token(current_user: UserInDB = Depends(get_current_user)): """ Use a valid token to get another token""" access_token = security.create_access_token(data=dict(sub=current_user.email)) diff --git a/mealie/routes/users/crud.py b/mealie/routes/users/crud.py index 49d68cc3f..c7447003c 100644 --- a/mealie/routes/users/crud.py +++ b/mealie/routes/users/crud.py @@ -1,21 +1,34 @@ import shutil -from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, UploadFile, status +from fastapi import BackgroundTasks, Depends, File, HTTPException, UploadFile, status from fastapi.responses import FileResponse +from fastapi.routing import APIRouter from mealie.core import security from mealie.core.config import app_dirs, settings from mealie.core.security import get_password_hash, verify_password from mealie.db.database import db from mealie.db.db_setup import generate_session from mealie.routes.deps import get_current_user +from mealie.routes.routers import AdminAPIRouter, UserAPIRouter from mealie.schema.user import ChangePassword, UserBase, UserFavorites, UserIn, UserInDB, UserOut from mealie.services.events import create_user_event from sqlalchemy.orm.session import Session -router = APIRouter(prefix="/api/users", tags=["Users"]) +public_router = APIRouter(prefix="/api/users", tags=["Users"]) +user_router = UserAPIRouter(prefix="/api/users", tags=["Users"]) +admin_router = AdminAPIRouter(prefix="/api/users", tags=["Users"]) -@router.post("", response_model=UserOut, status_code=201) +async def assert_user_change_allowed( + id: int, + current_user: UserInDB = Depends(get_current_user), +): + if current_user.id != id and not current_user.admin: + # only admins can edit other users + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="NOT_AN_ADMIN") + + +@admin_router.post("", response_model=UserOut, status_code=201) async def create_user( background_tasks: BackgroundTasks, new_user: UserIn, @@ -30,26 +43,19 @@ async def create_user( return db.users.create(session, new_user.dict()) -@router.get("", response_model=list[UserOut]) -async def get_all_users( - current_user: UserInDB = Depends(get_current_user), - session: Session = Depends(generate_session), -): - - if not current_user.admin: - raise HTTPException(status.HTTP_403_FORBIDDEN) - +@admin_router.get("", response_model=list[UserOut]) +async def get_all_users(session: Session = Depends(generate_session)): return db.users.get_all(session) -@router.get("/self", response_model=UserOut) +@user_router.get("/self", response_model=UserOut) async def get_logged_in_user( current_user: UserInDB = Depends(get_current_user), ): return current_user.dict() -@router.get("/{id}", response_model=UserOut, dependencies=[Depends(get_current_user)]) +@admin_router.get("/{id}", response_model=UserOut) async def get_user_by_id( id: int, session: Session = Depends(generate_session), @@ -57,7 +63,7 @@ async def get_user_by_id( return db.users.get(session, id) -@router.put("/{id}/reset-password", dependencies=[Depends(get_current_user)]) +@user_router.put("/{id}/reset-password") async def reset_user_password( id: int, session: Session = Depends(generate_session), @@ -67,7 +73,7 @@ async def reset_user_password( db.users.update_password(session, id, new_password) -@router.put("/{id}") +@user_router.put("/{id}") async def update_user( id: int, new_data: UserBase, @@ -75,16 +81,24 @@ async def update_user( session: Session = Depends(generate_session), ): - token = None - if current_user.id == id or current_user.admin: - db.users.update(session, id, new_data.dict()) + assert_user_change_allowed(id) + + if not current_user.admin and (new_data.admin or current_user.group != new_data.group): + # prevent a regular user from doing admin tasks on themself + raise HTTPException(status.HTTP_403_FORBIDDEN) + + if current_user.id == id and current_user.admin and not new_data.admin: + # prevent an admin from demoting themself + raise HTTPException(status.HTTP_403_FORBIDDEN) + + db.users.update(session, id, new_data.dict()) if current_user.id == id: access_token = security.create_access_token(data=dict(sub=new_data.email)) token = {"access_token": access_token, "token_type": "bearer"} return token -@router.get("/{id}/image") +@public_router.get("/{id}/image") async def get_user_image(id: str): """ Returns a users profile picture """ user_dir = app_dirs.USER_DIR.joinpath(id) @@ -94,13 +108,15 @@ async def get_user_image(id: str): raise HTTPException(status.HTTP_404_NOT_FOUND) -@router.post("/{id}/image", dependencies=[Depends(get_current_user)]) +@user_router.post("/{id}/image") async def update_user_image( id: str, profile_image: UploadFile = File(...), ): """ Updates a User Image """ + assert_user_change_allowed(id) + extension = profile_image.filename.split(".")[-1] app_dirs.USER_DIR.joinpath(id).mkdir(parents=True, exist_ok=True) @@ -116,7 +132,7 @@ async def update_user_image( raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR) -@router.put("/{id}/password") +@user_router.put("/{id}/password") async def update_password( id: int, password_change: ChangePassword, @@ -125,24 +141,24 @@ async def update_password( ): """ Resets the User Password""" + assert_user_change_allowed(id) match_passwords = verify_password(password_change.current_password, current_user.password) - match_id = current_user.id == id - if not (match_passwords and match_id): - raise HTTPException(status.HTTP_401_UNAUTHORIZED) + if not (match_passwords): + raise HTTPException(status.HTTP_400_BAD_REQUEST) new_password = get_password_hash(password_change.new_password) db.users.update_password(session, id, new_password) -@router.get("/{id}/favorites", response_model=UserFavorites) +@user_router.get("/{id}/favorites", response_model=UserFavorites) async def get_favorites(id: str, session: Session = Depends(generate_session)): - """ Adds a Recipe to the users favorites """ + """ Get user's favorite recipes """ return db.users.get(session, id, override_schema=UserFavorites) -@router.post("/{id}/favorites/{slug}") +@user_router.post("/{id}/favorites/{slug}") async def add_favorite( slug: str, current_user: UserInDB = Depends(get_current_user), @@ -150,12 +166,13 @@ async def add_favorite( ): """ Adds a Recipe to the users favorites """ + assert_user_change_allowed(id) current_user.favorite_recipes.append(slug) db.users.update(session, current_user.id, current_user) -@router.delete("/{id}/favorites/{slug}") +@user_router.delete("/{id}/favorites/{slug}") async def remove_favorite( slug: str, current_user: UserInDB = Depends(get_current_user), @@ -163,6 +180,7 @@ async def remove_favorite( ): """ Adds a Recipe to the users favorites """ + assert_user_change_allowed(id) current_user.favorite_recipes = [x for x in current_user.favorite_recipes if x != slug] db.users.update(session, current_user.id, current_user) @@ -170,21 +188,21 @@ async def remove_favorite( return -@router.delete("/{id}") +@admin_router.delete("/{id}") async def delete_user( background_tasks: BackgroundTasks, id: int, - current_user: UserInDB = Depends(get_current_user), session: Session = Depends(generate_session), ): """ Removes a user from the database. Must be the current user or a super user""" + assert_user_change_allowed(id) + if id == 1: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="SUPER_USER") - if current_user.id == id or current_user.admin: - try: - db.users.delete(session, id) - background_tasks.add_task(create_user_event, "User Deleted", f"User ID: {id}", session=session) - except Exception: - raise HTTPException(status.HTTP_400_BAD_REQUEST) + try: + db.users.delete(session, id) + background_tasks.add_task(create_user_event, "User Deleted", f"User ID: {id}", session=session) + except Exception: + raise HTTPException(status.HTTP_400_BAD_REQUEST) diff --git a/mealie/routes/users/sign_up.py b/mealie/routes/users/sign_up.py index ef81b4df0..77f9f141e 100644 --- a/mealie/routes/users/sign_up.py +++ b/mealie/routes/users/sign_up.py @@ -4,18 +4,19 @@ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status from mealie.core.security import get_password_hash from mealie.db.database import db from mealie.db.db_setup import generate_session -from mealie.routes.deps import get_current_user +from mealie.routes.deps import get_admin_user +from mealie.routes.routers import AdminAPIRouter from mealie.schema.sign_up import SignUpIn, SignUpOut, SignUpToken from mealie.schema.user import UserIn, UserInDB from mealie.services.events import create_user_event from sqlalchemy.orm.session import Session -router = APIRouter(prefix="/api/users/sign-ups", tags=["User Signup"]) +public_router = APIRouter(prefix="/api/users/sign-ups", tags=["User Signup"]) +admin_router = AdminAPIRouter(prefix="/api/users/sign-ups", tags=["User Signup"]) -@router.get("", response_model=list[SignUpOut]) +@admin_router.get("", response_model=list[SignUpOut]) async def get_all_open_sign_ups( - current_user=Depends(get_current_user), session: Session = Depends(generate_session), ): """ Returns a list of open sign up links """ @@ -23,18 +24,15 @@ async def get_all_open_sign_ups( return db.sign_ups.get_all(session) -@router.post("", response_model=SignUpToken) +@admin_router.post("", response_model=SignUpToken) async def create_user_sign_up_key( background_tasks: BackgroundTasks, key_data: SignUpIn, - current_user: UserInDB = Depends(get_current_user), + current_user: UserInDB = Depends(get_admin_user), session: Session = Depends(generate_session), ): """ Generates a Random Token that a new user can sign up with """ - if not current_user.admin: - raise HTTPException(status.HTTP_403_FORBIDDEN) - sign_up = { "token": str(uuid.uuid1().hex), "name": key_data.name, @@ -47,7 +45,7 @@ async def create_user_sign_up_key( return db.sign_ups.create(session, sign_up) -@router.post("/{token}") +@public_router.post("/{token}") async def create_user_with_token( background_tasks: BackgroundTasks, token: str, @@ -59,7 +57,7 @@ async def create_user_with_token( # Validate Token db_entry: SignUpOut = db.sign_ups.get(session, token, limit=1) if not db_entry: - raise HTTPException(status.HTTP_401_UNAUTHORIZED) + raise HTTPException(status.HTTP_400_BAD_REQUEST) # Create User new_user.admin = db_entry.admin @@ -73,14 +71,10 @@ async def create_user_with_token( db.sign_ups.delete(session, token) -@router.delete("/{token}") +@admin_router.delete("/{token}") async def delete_token( token: str, - current_user: UserInDB = Depends(get_current_user), session: Session = Depends(generate_session), ): """ Removed a token from the database """ - if not current_user.admin: - raise HTTPException(status.HTTP_403_FORBIDDEN) - db.sign_ups.delete(session, token) diff --git a/tests/conftest.py b/tests/conftest.py index cace40945..87ad6d2e0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -49,16 +49,39 @@ def test_image_png(): return TEST_DATA.joinpath("images", "test_image.png") -@fixture(scope="session") -def token(api_client: requests, api_routes: AppRoutes): - form_data = {"username": "changeme@email.com", "password": settings.DEFAULT_PASSWORD} +def login(form_data, api_client: requests, api_routes: AppRoutes): response = api_client.post(api_routes.auth_token, form_data) - + assert response.status_code == 200 token = json.loads(response.text).get("access_token") - return {"Authorization": f"Bearer {token}"} +@fixture(scope="session") +def admin_token(api_client: requests, api_routes: AppRoutes): + form_data = {"username": "changeme@email.com", "password": settings.DEFAULT_PASSWORD} + return login(form_data, api_client, api_routes) + + +@fixture(scope="session") +def user_token(admin_token, api_client: requests, api_routes: AppRoutes): + # Create the user + create_data = { + "fullName": "User", + "email": "user@email.com", + "password": "useruser", + "group": "Home", + "admin": False, + "tokens": [], + } + + response = api_client.post(api_routes.users, json=create_data, headers=admin_token) + assert response.status_code == 201 + + # Log in as this user + form_data = {"username": "user@email.com", "password": "useruser"} + return login(form_data, api_client, api_routes) + + @fixture(scope="session") def raw_recipe(): return get_raw_recipe() diff --git a/tests/integration_tests/recipe_tests/test_recipe_crud.py b/tests/integration_tests/recipe_tests/test_recipe_crud.py index 0cbaa7740..acf3e0eed 100644 --- a/tests/integration_tests/recipe_tests/test_recipe_crud.py +++ b/tests/integration_tests/recipe_tests/test_recipe_crud.py @@ -10,35 +10,35 @@ recipe_test_data = get_recipe_test_cases() @pytest.mark.parametrize("recipe_data", recipe_test_data) -def test_create_by_url(api_client: TestClient, api_routes: AppRoutes, recipe_data: RecipeSiteTestCase, token): - api_client.delete(api_routes.recipes_recipe_slug(recipe_data.expected_slug), headers=token) +def test_create_by_url(api_client: TestClient, api_routes: AppRoutes, recipe_data: RecipeSiteTestCase, user_token): + api_client.delete(api_routes.recipes_recipe_slug(recipe_data.expected_slug), headers=user_token) - response = api_client.post(api_routes.recipes_create_url, json={"url": recipe_data.url}, headers=token) + response = api_client.post(api_routes.recipes_create_url, json={"url": recipe_data.url}, headers=user_token) assert response.status_code == 201 assert json.loads(response.text) == recipe_data.expected_slug -def test_create_by_json(api_client: TestClient, api_routes: AppRoutes, token, raw_recipe): +def test_create_by_json(api_client: TestClient, api_routes: AppRoutes, user_token, raw_recipe): recipe_url = api_routes.recipes_recipe_slug("banana-bread") - api_client.delete(recipe_url, headers=token) - response = api_client.post(api_routes.recipes_create, json=raw_recipe, headers=token) + api_client.delete(recipe_url, headers=user_token) + response = api_client.post(api_routes.recipes_create, json=raw_recipe, headers=user_token) assert response.status_code == 201 assert json.loads(response.text) == "banana-bread" -def test_create_no_image(api_client: TestClient, api_routes: AppRoutes, token, raw_recipe_no_image): - response = api_client.post(api_routes.recipes_create, json=raw_recipe_no_image, headers=token) +def test_create_no_image(api_client: TestClient, api_routes: AppRoutes, user_token, raw_recipe_no_image): + response = api_client.post(api_routes.recipes_create, json=raw_recipe_no_image, headers=user_token) assert response.status_code == 201 assert json.loads(response.text) == "banana-bread-no-image" @pytest.mark.parametrize("recipe_data", recipe_test_data) -def test_read_update(api_client: TestClient, api_routes: AppRoutes, recipe_data: RecipeSiteTestCase, token): +def test_read_update(api_client: TestClient, api_routes: AppRoutes, recipe_data: RecipeSiteTestCase, user_token): recipe_url = api_routes.recipes_recipe_slug(recipe_data.expected_slug) - response = api_client.get(recipe_url, headers=token) + response = api_client.get(recipe_url, headers=user_token) assert response.status_code == 200 recipe = json.loads(response.text) @@ -54,7 +54,7 @@ def test_read_update(api_client: TestClient, api_routes: AppRoutes, recipe_data: test_categories = ["one", "two", "three"] recipe["recipeCategory"] = test_categories - response = api_client.put(recipe_url, json=recipe, headers=token) + response = api_client.put(recipe_url, json=recipe, headers=user_token) assert response.status_code == 200 assert json.loads(response.text).get("slug") == recipe_data.expected_slug @@ -69,9 +69,9 @@ def test_read_update(api_client: TestClient, api_routes: AppRoutes, recipe_data: @pytest.mark.parametrize("recipe_data", recipe_test_data) -def test_rename(api_client: TestClient, api_routes: AppRoutes, recipe_data: RecipeSiteTestCase, token): +def test_rename(api_client: TestClient, api_routes: AppRoutes, recipe_data: RecipeSiteTestCase, user_token): recipe_url = api_routes.recipes_recipe_slug(recipe_data.expected_slug) - response = api_client.get(recipe_url, headers=token) + response = api_client.get(recipe_url, headers=user_token) assert response.status_code == 200 recipe = json.loads(response.text) @@ -79,7 +79,7 @@ def test_rename(api_client: TestClient, api_routes: AppRoutes, recipe_data: Reci new_slug = slugify(new_name) recipe["name"] = new_name - response = api_client.put(recipe_url, json=recipe, headers=token) + response = api_client.put(recipe_url, json=recipe, headers=user_token) assert response.status_code == 200 assert json.loads(response.text).get("slug") == new_slug @@ -88,7 +88,7 @@ def test_rename(api_client: TestClient, api_routes: AppRoutes, recipe_data: Reci @pytest.mark.parametrize("recipe_data", recipe_test_data) -def test_delete(api_client: TestClient, api_routes: AppRoutes, recipe_data: RecipeSiteTestCase, token): +def test_delete(api_client: TestClient, api_routes: AppRoutes, recipe_data: RecipeSiteTestCase, user_token): recipe_url = api_routes.recipes_recipe_slug(recipe_data.expected_slug) - response = api_client.delete(recipe_url, headers=token) + response = api_client.delete(recipe_url, headers=user_token) assert response.status_code == 200 diff --git a/tests/integration_tests/test_custom_page_routes.py b/tests/integration_tests/test_custom_page_routes.py index f89e46ee8..fc6c76a85 100644 --- a/tests/integration_tests/test_custom_page_routes.py +++ b/tests/integration_tests/test_custom_page_routes.py @@ -10,8 +10,8 @@ def page_data(): return {"name": "My New Page", "position": 0, "categories": []} -def test_create_page(api_client: TestClient, api_routes: AppRoutes, token, page_data): - response = api_client.post(api_routes.site_settings_custom_pages, json=page_data, headers=token) +def test_create_page(api_client: TestClient, api_routes: AppRoutes, admin_token, page_data): + response = api_client.post(api_routes.site_settings_custom_pages, json=page_data, headers=admin_token) assert response.status_code == 200 @@ -25,16 +25,16 @@ def test_read_page(api_client: TestClient, api_routes: AppRoutes, page_data): assert json.loads(response.text) == page_data -def test_update_page(api_client: TestClient, api_routes: AppRoutes, page_data, token): +def test_update_page(api_client: TestClient, api_routes: AppRoutes, page_data, admin_token): page_data["id"] = 1 page_data["name"] = "My New Name" - response = api_client.put(api_routes.site_settings_custom_pages_id(1), json=page_data, headers=token) + response = api_client.put(api_routes.site_settings_custom_pages_id(1), json=page_data, headers=admin_token) assert response.status_code == 200 -def test_delete_page(api_client: TestClient, api_routes: AppRoutes, token): - response = api_client.delete(api_routes.site_settings_custom_pages_id(1), headers=token) +def test_delete_page(api_client: TestClient, api_routes: AppRoutes, admin_token): + response = api_client.delete(api_routes.site_settings_custom_pages_id(1), headers=admin_token) assert response.status_code == 200 diff --git a/tests/integration_tests/test_group_routes.py b/tests/integration_tests/test_group_routes.py index 2c8bab53f..3c01c65ad 100644 --- a/tests/integration_tests/test_group_routes.py +++ b/tests/integration_tests/test_group_routes.py @@ -10,20 +10,20 @@ def group_data(): return {"name": "Test Group"} -def test_create_group(api_client: TestClient, api_routes: AppRoutes, token): - response = api_client.post(api_routes.groups, json={"name": "Test Group"}, headers=token) +def test_create_group(api_client: TestClient, api_routes: AppRoutes, admin_token): + response = api_client.post(api_routes.groups, json={"name": "Test Group"}, headers=admin_token) assert response.status_code == 201 -def test_get_self_group(api_client: TestClient, api_routes: AppRoutes, token): - response = api_client.get(api_routes.groups, headers=token) +def test_get_self_group(api_client: TestClient, api_routes: AppRoutes, admin_token): + response = api_client.get(api_routes.groups, headers=admin_token) assert response.status_code == 200 assert len(json.loads(response.text)) >= 2 -def test_update_group(api_client: TestClient, api_routes: AppRoutes, token): +def test_update_group(api_client: TestClient, api_routes: AppRoutes, admin_token): new_data = { "name": "New Group Name", "id": 2, @@ -36,23 +36,23 @@ def test_update_group(api_client: TestClient, api_routes: AppRoutes, token): "shoppingLists": [], } # Test Update - response = api_client.put(api_routes.groups_id(2), json=new_data, headers=token) + response = api_client.put(api_routes.groups_id(2), json=new_data, headers=admin_token) assert response.status_code == 200 # Validate Changes - response = api_client.get(api_routes.groups, headers=token) + response = api_client.get(api_routes.groups, headers=admin_token) all_groups = json.loads(response.text) id_2 = filter(lambda x: x["id"] == 2, all_groups) assert next(id_2) == new_data -def test_home_group_not_deletable(api_client: TestClient, api_routes: AppRoutes, token): - response = api_client.delete(api_routes.groups_id(1), headers=token) +def test_home_group_not_deletable(api_client: TestClient, api_routes: AppRoutes, admin_token): + response = api_client.delete(api_routes.groups_id(1), headers=admin_token) assert response.status_code == 400 -def test_delete_group(api_client: TestClient, api_routes: AppRoutes, token): - response = api_client.delete(api_routes.groups_id(2), headers=token) +def test_delete_group(api_client: TestClient, api_routes: AppRoutes, admin_token): + response = api_client.delete(api_routes.groups_id(2), headers=admin_token) assert response.status_code == 200 diff --git a/tests/integration_tests/test_import_routes.py b/tests/integration_tests/test_import_routes.py index f683a2791..214e91239 100644 --- a/tests/integration_tests/test_import_routes.py +++ b/tests/integration_tests/test_import_routes.py @@ -19,9 +19,9 @@ def backup_data(): } -def test_import(api_client: TestClient, api_routes: AppRoutes, backup_data, token): +def test_import(api_client: TestClient, api_routes: AppRoutes, backup_data, admin_token): import_route = api_routes.backups_file_name_import("test_backup_2021-Apr-27.zip") - response = api_client.post(import_route, json=backup_data, headers=token) + response = api_client.post(import_route, json=backup_data, headers=admin_token) assert response.status_code == 200 for _, value in json.loads(response.content).items(): for v in value: diff --git a/tests/integration_tests/test_long_live_tokens.py b/tests/integration_tests/test_long_live_tokens.py index aff8dc6c6..634f3d7ca 100644 --- a/tests/integration_tests/test_long_live_tokens.py +++ b/tests/integration_tests/test_long_live_tokens.py @@ -6,15 +6,15 @@ from tests.app_routes import AppRoutes @fixture -def long_live_token(api_client: TestClient, api_routes: AppRoutes, token): - response = api_client.post(api_routes.users_api_tokens, json={"name": "Test Fixture Token"}, headers=token) +def long_live_token(api_client: TestClient, api_routes: AppRoutes, admin_token): + response = api_client.post(api_routes.users_api_tokens, json={"name": "Test Fixture Token"}, headers=admin_token) assert response.status_code == 201 return {"Authorization": f"Bearer {json.loads(response.text).get('token')}"} -def test_api_token_creation(api_client: TestClient, api_routes: AppRoutes, token): - response = api_client.post(api_routes.users_api_tokens, json={"name": "Test API Token"}, headers=token) +def test_api_token_creation(api_client: TestClient, api_routes: AppRoutes, admin_token): + response = api_client.post(api_routes.users_api_tokens, json={"name": "Test API Token"}, headers=admin_token) assert response.status_code == 201 @@ -24,9 +24,9 @@ def test_use_token(api_client: TestClient, api_routes: AppRoutes, long_live_toke assert response.status_code == 200 -def test_delete_token(api_client: TestClient, api_routes: AppRoutes, token): - response = api_client.delete(api_routes.users_api_tokens_token_id(1), headers=token) +def test_delete_token(api_client: TestClient, api_routes: AppRoutes, admin_token): + response = api_client.delete(api_routes.users_api_tokens_token_id(1), headers=admin_token) assert response.status_code == 200 - response = api_client.delete(api_routes.users_api_tokens_token_id(2), headers=token) + response = api_client.delete(api_routes.users_api_tokens_token_id(2), headers=admin_token) assert response.status_code == 200 diff --git a/tests/integration_tests/test_meal_routes.py b/tests/integration_tests/test_meal_routes.py index a98f15910..563f7b431 100644 --- a/tests/integration_tests/test_meal_routes.py +++ b/tests/integration_tests/test_meal_routes.py @@ -25,8 +25,8 @@ def get_meal_plan_template(first=None, second=None): @pytest.fixture(scope="session") -def slug_1(api_client: TestClient, api_routes: AppRoutes, token, recipe_store: list[RecipeSiteTestCase]): - slug_1 = api_client.post(api_routes.recipes_create_url, json={"url": recipe_store[0].url}, headers=token) +def slug_1(api_client: TestClient, api_routes: AppRoutes, admin_token, recipe_store: list[RecipeSiteTestCase]): + slug_1 = api_client.post(api_routes.recipes_create_url, json={"url": recipe_store[0].url}, headers=admin_token) slug_1 = json.loads(slug_1.content) yield slug_1 @@ -35,8 +35,8 @@ def slug_1(api_client: TestClient, api_routes: AppRoutes, token, recipe_store: l @pytest.fixture(scope="session") -def slug_2(api_client: TestClient, api_routes: AppRoutes, token, recipe_store: list[RecipeSiteTestCase]): - slug_2 = api_client.post(api_routes.recipes_create_url, json={"url": recipe_store[1].url}, headers=token) +def slug_2(api_client: TestClient, api_routes: AppRoutes, admin_token, recipe_store: list[RecipeSiteTestCase]): + slug_2 = api_client.post(api_routes.recipes_create_url, json={"url": recipe_store[1].url}, headers=admin_token) slug_2 = json.loads(slug_2.content) yield slug_2 @@ -44,15 +44,15 @@ def slug_2(api_client: TestClient, api_routes: AppRoutes, token, recipe_store: l api_client.delete(api_routes.recipes_recipe_slug(slug_2)) -def test_create_mealplan(api_client: TestClient, api_routes: AppRoutes, slug_1, slug_2, token): +def test_create_mealplan(api_client: TestClient, api_routes: AppRoutes, slug_1, slug_2, admin_token): meal_plan = get_meal_plan_template(slug_1, slug_2) - response = api_client.post(api_routes.meal_plans_create, json=meal_plan, headers=token) + response = api_client.post(api_routes.meal_plans_create, json=meal_plan, headers=admin_token) assert response.status_code == 201 -def test_read_mealplan(api_client: TestClient, api_routes: AppRoutes, slug_1, slug_2, token): - response = api_client.get(api_routes.meal_plans_all, headers=token) +def test_read_mealplan(api_client: TestClient, api_routes: AppRoutes, slug_1, slug_2, admin_token): + response = api_client.get(api_routes.meal_plans_all, headers=admin_token) assert response.status_code == 200 @@ -65,9 +65,9 @@ def test_read_mealplan(api_client: TestClient, api_routes: AppRoutes, slug_1, sl assert meals[1]["meals"][0]["slug"] == meal_plan_template["planDays"][1]["meals"][0]["slug"] -def test_update_mealplan(api_client: TestClient, api_routes: AppRoutes, slug_1, slug_2, token): +def test_update_mealplan(api_client: TestClient, api_routes: AppRoutes, slug_1, slug_2, admin_token): - response = api_client.get(api_routes.meal_plans_all, headers=token) + response = api_client.get(api_routes.meal_plans_all, headers=admin_token) existing_mealplan = json.loads(response.text) existing_mealplan = existing_mealplan[0] @@ -77,11 +77,11 @@ def test_update_mealplan(api_client: TestClient, api_routes: AppRoutes, slug_1, existing_mealplan["planDays"][0]["meals"][0]["slug"] = slug_2 existing_mealplan["planDays"][1]["meals"][0]["slug"] = slug_1 - response = api_client.put(api_routes.meal_plans_plan_id(plan_uid), json=existing_mealplan, headers=token) + response = api_client.put(api_routes.meal_plans_plan_id(plan_uid), json=existing_mealplan, headers=admin_token) assert response.status_code == 200 - response = api_client.get(api_routes.meal_plans_all, headers=token) + response = api_client.get(api_routes.meal_plans_all, headers=admin_token) existing_mealplan = json.loads(response.text) existing_mealplan = existing_mealplan[0] @@ -89,14 +89,14 @@ def test_update_mealplan(api_client: TestClient, api_routes: AppRoutes, slug_1, assert existing_mealplan["planDays"][1]["meals"][0]["slug"] == slug_1 -def test_delete_mealplan(api_client: TestClient, api_routes: AppRoutes, token): - response = api_client.get(api_routes.meal_plans_all, headers=token) +def test_delete_mealplan(api_client: TestClient, api_routes: AppRoutes, admin_token): + response = api_client.get(api_routes.meal_plans_all, headers=admin_token) assert response.status_code == 200 existing_mealplan = json.loads(response.text) existing_mealplan = existing_mealplan[0] plan_uid = existing_mealplan.get("uid") - response = api_client.delete(api_routes.meal_plans_plan_id(plan_uid), headers=token) + response = api_client.delete(api_routes.meal_plans_plan_id(plan_uid), headers=admin_token) assert response.status_code == 200 diff --git a/tests/integration_tests/test_migration_routes.py b/tests/integration_tests/test_migration_routes.py index b9c9abecb..21e4f9cfc 100644 --- a/tests/integration_tests/test_migration_routes.py +++ b/tests/integration_tests/test_migration_routes.py @@ -22,22 +22,22 @@ def chowdown_zip(): zip_copy.unlink() -def test_upload_chowdown_zip(api_client: TestClient, api_routes: AppRoutes, chowdown_zip: Path, token): +def test_upload_chowdown_zip(api_client: TestClient, api_routes: AppRoutes, chowdown_zip: Path, admin_token): upload_url = api_routes.migrations_import_type_upload("chowdown") - response = api_client.post(upload_url, files={"archive": chowdown_zip.open("rb")}, headers=token) + response = api_client.post(upload_url, files={"archive": chowdown_zip.open("rb")}, headers=admin_token) assert response.status_code == 200 assert app_dirs.MIGRATION_DIR.joinpath("chowdown", chowdown_zip.name).is_file() -def test_import_chowdown_directory(api_client: TestClient, api_routes: AppRoutes, chowdown_zip: Path, token): +def test_import_chowdown_directory(api_client: TestClient, api_routes: AppRoutes, chowdown_zip: Path, admin_token): delete_url = api_routes.recipes_recipe_slug("roasted-okra") - api_client.delete(delete_url, headers=token) # TODO: Manage Test Data better + api_client.delete(delete_url, headers=admin_token) # TODO: Manage Test Data better selection = chowdown_zip.name import_url = api_routes.migrations_import_type_file_name_import("chowdown", selection) - response = api_client.post(import_url, headers=token) + response = api_client.post(import_url, headers=admin_token) assert response.status_code == 200 @@ -47,10 +47,10 @@ def test_import_chowdown_directory(api_client: TestClient, api_routes: AppRoutes assert report.get("status") is True -def test_delete_chowdown_migration_data(api_client: TestClient, api_routes: AppRoutes, chowdown_zip: Path, token): +def test_delete_chowdown_migration_data(api_client: TestClient, api_routes: AppRoutes, chowdown_zip: Path, admin_token): selection = chowdown_zip.name delete_url = api_routes.migrations_import_type_file_name_delete("chowdown", selection) - response = api_client.delete(delete_url, headers=token) + response = api_client.delete(delete_url, headers=admin_token) assert response.status_code == 200 assert not app_dirs.MIGRATION_DIR.joinpath(chowdown_zip.name).is_file() @@ -70,19 +70,19 @@ def nextcloud_zip(): zip_copy.unlink() -def test_upload_nextcloud_zip(api_client: TestClient, api_routes: AppRoutes, nextcloud_zip, token): +def test_upload_nextcloud_zip(api_client: TestClient, api_routes: AppRoutes, nextcloud_zip, admin_token): upload_url = api_routes.migrations_import_type_upload("nextcloud") - response = api_client.post(upload_url, files={"archive": nextcloud_zip.open("rb")}, headers=token) + response = api_client.post(upload_url, files={"archive": nextcloud_zip.open("rb")}, headers=admin_token) assert response.status_code == 200 assert app_dirs.MIGRATION_DIR.joinpath("nextcloud", nextcloud_zip.name).is_file() -def test_import_nextcloud_directory(api_client: TestClient, api_routes: AppRoutes, nextcloud_zip, token): +def test_import_nextcloud_directory(api_client: TestClient, api_routes: AppRoutes, nextcloud_zip, admin_token): selection = nextcloud_zip.name import_url = api_routes.migrations_import_type_file_name_import("nextcloud", selection) - response = api_client.post(import_url, headers=token) + response = api_client.post(import_url, headers=admin_token) assert response.status_code == 200 @@ -91,10 +91,12 @@ def test_import_nextcloud_directory(api_client: TestClient, api_routes: AppRoute assert report.get("status") is True -def test_delete__nextcloud_migration_data(api_client: TestClient, api_routes: AppRoutes, nextcloud_zip: Path, token): +def test_delete__nextcloud_migration_data( + api_client: TestClient, api_routes: AppRoutes, nextcloud_zip: Path, admin_token +): selection = nextcloud_zip.name delete_url = api_routes.migrations_import_type_file_name_delete("nextcloud", selection) - response = api_client.delete(delete_url, headers=token) + response = api_client.delete(delete_url, headers=admin_token) assert response.status_code == 200 assert not app_dirs.MIGRATION_DIR.joinpath(nextcloud_zip.name).is_file() diff --git a/tests/integration_tests/test_settings_routes.py b/tests/integration_tests/test_settings_routes.py index f0b9df16e..03a2cb08a 100644 --- a/tests/integration_tests/test_settings_routes.py +++ b/tests/integration_tests/test_settings_routes.py @@ -19,11 +19,11 @@ def test_default_settings(api_client: TestClient, api_routes: AppRoutes, default assert json.loads(response.content) == default_settings -def test_update_settings(api_client: TestClient, api_routes: AppRoutes, default_settings, token): +def test_update_settings(api_client: TestClient, api_routes: AppRoutes, default_settings, admin_token): default_settings["language"] = "fr" default_settings["showRecent"] = False - response = api_client.put(api_routes.site_settings, json=default_settings, headers=token) + response = api_client.put(api_routes.site_settings, json=default_settings, headers=admin_token) assert response.status_code == 200 diff --git a/tests/integration_tests/test_signup_routes.py b/tests/integration_tests/test_signup_routes.py index dce2b1f19..333066a4d 100644 --- a/tests/integration_tests/test_signup_routes.py +++ b/tests/integration_tests/test_signup_routes.py @@ -7,10 +7,10 @@ from tests.app_routes import AppRoutes @pytest.fixture() -def active_link(api_client: TestClient, api_routes: AppRoutes, token): +def active_link(api_client: TestClient, api_routes: AppRoutes, admin_token): data = {"name": "Fixture Token", "admin": True} - response = api_client.post(api_routes.users_sign_ups, json=data, headers=token) + response = api_client.post(api_routes.users_sign_ups, json=data, headers=admin_token) return SignUpToken(**json.loads(response.text)) @@ -26,10 +26,10 @@ def sign_up_user(): } -def test_create_sign_up_link(api_client: TestClient, api_routes: AppRoutes, token): +def test_create_sign_up_link(api_client: TestClient, api_routes: AppRoutes, admin_token): data = {"name": "Test Token", "admin": False} - response = api_client.post(api_routes.users_sign_ups, json=data, headers=token) + response = api_client.post(api_routes.users_sign_ups, json=data, headers=admin_token) assert response.status_code == 200 @@ -47,11 +47,11 @@ def test_new_user_signup(api_client: TestClient, api_routes: AppRoutes, active_l def test_delete_sign_up_link( - api_client: TestClient, api_routes: AppRoutes, token, active_link: SignUpToken, sign_up_user + api_client: TestClient, api_routes: AppRoutes, admin_token, active_link: SignUpToken, sign_up_user ): - response = api_client.delete(api_routes.users_sign_ups_token(active_link.token), headers=token) + response = api_client.delete(api_routes.users_sign_ups_token(active_link.token), headers=admin_token) assert response.status_code == 200 - # Validate Token is Gone - response = api_client.get(api_routes.users_sign_ups, headers=token) + # Validate admin_token is Gone + response = api_client.get(api_routes.users_sign_ups, headers=admin_token) assert sign_up_user not in json.loads(response.content) diff --git a/tests/integration_tests/test_theme_routes.py b/tests/integration_tests/test_theme_routes.py index 043e7a69a..e0dcabcd0 100644 --- a/tests/integration_tests/test_theme_routes.py +++ b/tests/integration_tests/test_theme_routes.py @@ -28,38 +28,38 @@ def new_theme(): } -def test_default_theme(api_client: TestClient, api_routes: AppRoutes, default_theme): - response = api_client.get(api_routes.themes_id(1)) +def test_default_theme(api_client: TestClient, api_routes: AppRoutes, default_theme, user_token): + response = api_client.get(api_routes.themes_id(1), headers=user_token) assert response.status_code == 200 assert json.loads(response.content) == default_theme -def test_create_theme(api_client: TestClient, api_routes: AppRoutes, new_theme, token): +def test_create_theme(api_client: TestClient, api_routes: AppRoutes, new_theme, user_token): - response = api_client.post(api_routes.themes_create, json=new_theme, headers=token) + response = api_client.post(api_routes.themes_create, json=new_theme, headers=user_token) assert response.status_code == 201 - response = api_client.get(api_routes.themes_id(new_theme.get("id")), headers=token) + response = api_client.get(api_routes.themes_id(new_theme.get("id")), headers=user_token) assert response.status_code == 200 assert json.loads(response.content) == new_theme -def test_read_all_themes(api_client: TestClient, api_routes: AppRoutes, default_theme, new_theme): - response = api_client.get(api_routes.themes) +def test_read_all_themes(api_client: TestClient, api_routes: AppRoutes, default_theme, new_theme, user_token): + response = api_client.get(api_routes.themes, headers=user_token) assert response.status_code == 200 response_dict = json.loads(response.content) assert default_theme in response_dict assert new_theme in response_dict -def test_read_theme(api_client: TestClient, api_routes: AppRoutes, default_theme, new_theme): +def test_read_theme(api_client: TestClient, api_routes: AppRoutes, default_theme, new_theme, user_token): for theme in [default_theme, new_theme]: - response = api_client.get(api_routes.themes_id(theme.get("id"))) + response = api_client.get(api_routes.themes_id(theme.get("id")), headers=user_token) assert response.status_code == 200 assert json.loads(response.content) == theme -def test_update_theme(api_client: TestClient, api_routes: AppRoutes, token, new_theme): +def test_update_theme(api_client: TestClient, api_routes: AppRoutes, user_token, new_theme): theme_colors = { "primary": "#E12345", "accent": "#012345", @@ -72,14 +72,14 @@ def test_update_theme(api_client: TestClient, api_routes: AppRoutes, token, new_ new_theme["colors"] = theme_colors new_theme["name"] = "New Theme Name" - response = api_client.put(api_routes.themes_id(new_theme.get("id")), json=new_theme, headers=token) + response = api_client.put(api_routes.themes_id(new_theme.get("id")), json=new_theme, headers=user_token) assert response.status_code == 200 - response = api_client.get(api_routes.themes_id(new_theme.get("id"))) + response = api_client.get(api_routes.themes_id(new_theme.get("id")), headers=user_token) assert json.loads(response.content) == new_theme -def test_delete_theme(api_client: TestClient, api_routes: AppRoutes, default_theme, new_theme, token): +def test_delete_theme(api_client: TestClient, api_routes: AppRoutes, default_theme, new_theme, user_token): for theme in [default_theme, new_theme]: - response = api_client.delete(api_routes.themes_id(theme.get("id")), headers=token) + response = api_client.delete(api_routes.themes_id(theme.get("id")), headers=user_token) assert response.status_code == 200 diff --git a/tests/integration_tests/test_user_routes.py b/tests/integration_tests/test_user_routes.py index b0cd7e4fd..408048469 100644 --- a/tests/integration_tests/test_user_routes.py +++ b/tests/integration_tests/test_user_routes.py @@ -9,7 +9,7 @@ from tests.app_routes import AppRoutes @fixture(scope="session") -def default_user(): +def admin_user(): return UserOut( id=1, fullName="Change Me", @@ -24,7 +24,7 @@ def default_user(): @fixture(scope="session") def new_user(): return UserOut( - id=3, + id=4, fullName="My New User", username="My New User", email="newuser@email.com", @@ -41,27 +41,27 @@ def test_failed_login(api_client: TestClient, api_routes: AppRoutes): assert response.status_code == 401 -def test_superuser_login(api_client: TestClient, api_routes: AppRoutes, token): +def test_superuser_login(api_client: TestClient, api_routes: AppRoutes, admin_token): form_data = {"username": "changeme@email.com", "password": "MyPassword"} response = api_client.post(api_routes.auth_token, form_data) assert response.status_code == 200 new_token = json.loads(response.text).get("access_token") - response = api_client.get(api_routes.users_self, headers=token) + response = api_client.get(api_routes.users_self, headers=admin_token) assert response.status_code == 200 return {"Authorization": f"Bearer {new_token}"} -def test_init_superuser(api_client: TestClient, api_routes: AppRoutes, token, default_user: UserOut): - response = api_client.get(api_routes.users_id(1), headers=token) +def test_init_superuser(api_client: TestClient, api_routes: AppRoutes, admin_token, admin_user: UserOut): + response = api_client.get(api_routes.users_id(1), headers=admin_token) assert response.status_code == 200 - assert json.loads(response.text) == default_user.dict(by_alias=True) + assert json.loads(response.text) == admin_user.dict(by_alias=True) -def test_create_user(api_client: TestClient, api_routes: AppRoutes, token, new_user): +def test_create_user(api_client: TestClient, api_routes: AppRoutes, admin_token, new_user): create_data = { "fullName": "My New User", "email": "newuser@email.com", @@ -71,32 +71,74 @@ def test_create_user(api_client: TestClient, api_routes: AppRoutes, token, new_u "tokens": [], } - response = api_client.post(api_routes.users, json=create_data, headers=token) + response = api_client.post(api_routes.users, json=create_data, headers=admin_token) assert response.status_code == 201 assert json.loads(response.text) == new_user.dict(by_alias=True) - assert True -def test_get_all_users(api_client: TestClient, api_routes: AppRoutes, token, new_user, default_user): - response = api_client.get(api_routes.users, headers=token) +def test_create_user_as_non_admin(api_client: TestClient, api_routes: AppRoutes, user_token): + create_data = { + "fullName": "My New User", + "email": "newuser@email.com", + "password": "MyStrongPassword", + "group": "Home", + "admin": False, + "tokens": [], + } + + response = api_client.post(api_routes.users, json=create_data, headers=user_token) + + assert response.status_code == 403 + + +def test_get_all_users(api_client: TestClient, api_routes: AppRoutes, admin_token, new_user, admin_user): + response = api_client.get(api_routes.users, headers=admin_token) assert response.status_code == 200 all_users = json.loads(response.text) - assert default_user.dict(by_alias=True) in all_users + assert admin_user.dict(by_alias=True) in all_users assert new_user.dict(by_alias=True) in all_users -def test_update_user(api_client: TestClient, api_routes: AppRoutes, token): +def test_update_user(api_client: TestClient, api_routes: AppRoutes, admin_token): update_data = {"id": 1, "fullName": "Updated Name", "email": "changeme@email.com", "group": "Home", "admin": True} - response = api_client.put(api_routes.users_id(1), headers=token, json=update_data) + response = api_client.put(api_routes.users_id(1), headers=admin_token, json=update_data) assert response.status_code == 200 assert json.loads(response.text).get("access_token") -def test_reset_user_password(api_client: TestClient, api_routes: AppRoutes, token): - response = api_client.put(api_routes.users_id_reset_password(3), headers=token) +def test_update_other_user_as_not_admin(api_client: TestClient, api_routes: AppRoutes, user_token): + update_data = {"id": 1, "fullName": "Updated Name", "email": "changeme@email.com", "group": "Home", "admin": True} + response = api_client.put(api_routes.users_id(1), headers=user_token, json=update_data) + + assert response.status_code == 403 + + +def test_update_self_as_not_admin(api_client: TestClient, api_routes: AppRoutes, user_token): + update_data = {"id": 3, "fullName": "User fullname", "email": "user@email.com", "group": "Home", "admin": False} + response = api_client.put(api_routes.users_id(3), headers=user_token, json=update_data) + + assert response.status_code == 200 + + +def test_self_demote_admin(api_client: TestClient, api_routes: AppRoutes, admin_token): + update_data = {"id": 1, "fullName": "Updated Name", "email": "changeme@email.com", "group": "Home", "admin": False} + response = api_client.put(api_routes.users_id(1), headers=admin_token, json=update_data) + + assert response.status_code == 403 + + +def test_self_promote_admin(api_client: TestClient, api_routes: AppRoutes, user_token): + update_data = {"id": 3, "fullName": "Updated Name", "email": "user@email.com", "group": "Home", "admin": True} + response = api_client.put(api_routes.users_id(3), headers=user_token, json=update_data) + + assert response.status_code == 403 + + +def test_reset_user_password(api_client: TestClient, api_routes: AppRoutes, admin_token): + response = api_client.put(api_routes.users_id_reset_password(4), headers=admin_token) assert response.status_code == 200 @@ -106,23 +148,23 @@ def test_reset_user_password(api_client: TestClient, api_routes: AppRoutes, toke assert response.status_code == 200 -def test_delete_user(api_client: TestClient, api_routes: AppRoutes, token): - response = api_client.delete(api_routes.users_id(2), headers=token) +def test_delete_user(api_client: TestClient, api_routes: AppRoutes, admin_token): + response = api_client.delete(api_routes.users_id(2), headers=admin_token) assert response.status_code == 200 def test_update_user_image( - api_client: TestClient, api_routes: AppRoutes, test_image_jpg: Path, test_image_png: Path, token + api_client: TestClient, api_routes: AppRoutes, test_image_jpg: Path, test_image_png: Path, admin_token ): response = api_client.post( - api_routes.users_id_image(2), files={"profile_image": test_image_jpg.open("rb")}, headers=token + api_routes.users_id_image(2), files={"profile_image": test_image_jpg.open("rb")}, headers=admin_token ) assert response.status_code == 200 response = api_client.post( - api_routes.users_id_image(2), files={"profile_image": test_image_png.open("rb")}, headers=token + api_routes.users_id_image(2), files={"profile_image": test_image_png.open("rb")}, headers=admin_token ) assert response.status_code == 200