Skip to content

Commit 3311307

Browse files
authored
Add-GET-/books/{user_id}/reservations-(JWT-protected) (#16)
### Role-based Access Control (RBAC) - Added `require_admin` decorator for admin-only routes - Updated `login_user` route and DB logic to include a `role` field - Default role set to `user`; fixtures and sample data updated - Added tests for all RBAC cases (happy path, invalid JWT, non-admin access, etc.) ### Reservations Endpoint - Implemented `GET /books/{book_id}/reservations` (JWT-protected, admin-only) - Added tests for valid/invalid book IDs, missing/no auth headers, and non-admin roles - Log warning and skip reservations referencing non-existent users - Refactored response structure (`status → state`, nested `message` field, unified body construction) ### Fixtures & Test Infrastructure - Consolidated `users_db_setup` → `mongo_setup` to reset all collections - Added fixtures for seeding users/admins with JWTs, books, and reservations - Cleaned up/reorganized fixtures; fixed imports and test DB usage - Expanded coverage to 100% with edge case tests ### Refactors & Fixes - Fixed token return value, forbidden return handling, and test failures - Standardized response formatting across endpoints - Spelling/typo corrections, linter/formatter run
1 parent 1c89a26 commit 3311307

File tree

10 files changed

+497
-52
lines changed

10 files changed

+497
-52
lines changed

app/routes/auth_routes.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ def register_user():
5959
"email": email,
6060
# The hash is stored as a string in the DB
6161
"password": hashed_password,
62+
"role": "user", # all users assigned a default 'user' role
6263
}
6364
).inserted_id
6465

@@ -97,6 +98,7 @@ def login_user():
9798
# 4. Generate the JWT payload
9899
payload = {
99100
"sub": str(user["_id"]), # sub (subject)- standard claim for user ID
101+
"role": user.get("role", "user"), # embed the role in token
100102
"iat": datetime.datetime.now(
101103
datetime.UTC
102104
), # iat (issued at)- when token was created

app/routes/reservation_routes.py

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
"""Routes for /books/<id>/reservations endpoint"""
22

33
import datetime
4+
import logging
45

56
from bson import ObjectId
67
from bson.errors import InvalidId
78
from flask import Blueprint, g, jsonify, url_for
89

910
from app.extensions import mongo
10-
from app.utils.decorators import require_jwt
11+
from app.utils.decorators import require_admin, require_jwt
1112

1213
reservations_bp = Blueprint(
1314
"reservations_bp", __name__, url_prefix="/books/<book_id_str>"
@@ -19,8 +20,8 @@
1920
def create_reservation(book_id_str):
2021
"""
2122
This POST endpoint lets an authenticated user reserve a book by its ID.
22-
It validates the ID, checks book availability,
23-
prevents duplicate reservations,
23+
It validates the ID, checks book availability,
24+
prevents duplicate reservations,
2425
creates the reservation, and returns its details.
2526
"""
2627

@@ -76,3 +77,65 @@ def create_reservation(book_id_str):
7677
}
7778

7879
return jsonify(response_data), 201
80+
81+
82+
@reservations_bp.route("/reservations", methods=["GET"])
83+
@require_admin
84+
def get_reservations_for_book_id(book_id_str):
85+
"""
86+
Retrieves all reservations for a specific book.
87+
Accessible only by users with the 'admin' role.
88+
"""
89+
# Validate the book_id format
90+
try:
91+
oid = ObjectId(book_id_str)
92+
except (InvalidId, TypeError):
93+
return jsonify({"error": "Invalid Book ID"}), 400
94+
95+
# Check if book exists
96+
book = mongo.db.books.find_one({"_id": oid})
97+
if not book:
98+
return "book not found", 404, {"Content-Type": "text/plain"}
99+
100+
# Fetch reservations for the given book_id
101+
reservations_cursor = mongo.db.reservations.find({"book_id": oid})
102+
103+
items = []
104+
for r in reservations_cursor:
105+
# For each reservation, fetch the associated user's details
106+
user = mongo.db.users.find_one({"_id": r["user_id"]})
107+
108+
# Skip if the user for a reservation is not found, to avoid errors
109+
if not user:
110+
logging.warning(
111+
"Data integrity issue: Reservation '%s' references a non-existent user_id '%s'. Skipping.", # pylint: disable=line-too-long
112+
r["_id"],
113+
r["user_id"],
114+
)
115+
continue
116+
117+
# Build the item object according to spec
118+
reservation_item = {
119+
"id": str(r["_id"]),
120+
"state": r.get("state", "UNKNOWN"), # Use .get() for safety
121+
"user": {
122+
"forenames": user.get("forenames"),
123+
"surname": user.get("surname"),
124+
},
125+
"book_id": str(r["book_id"]),
126+
"links": {
127+
"self": url_for(
128+
".get_reservations_for_book_id",
129+
reservation_id=str(r["_id"]),
130+
_external=True,
131+
),
132+
"book": url_for("get_book", book_id=str(r["book_id"]), _external=True),
133+
},
134+
}
135+
136+
items.append(reservation_item)
137+
138+
# Construct the final response body
139+
response_body = {"total_count": len(items), "items": items}
140+
141+
return jsonify(response_body), 200

app/utils/decorators.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import jwt
88
from bson.errors import InvalidId
99
from bson.objectid import ObjectId
10-
from flask import current_app, g, jsonify, request
10+
from flask import abort, current_app, g, jsonify, request
1111

1212
from app.extensions import mongo
1313

@@ -68,3 +68,19 @@ def decorated_function(*args, **kwargs):
6868
return f(*args, **kwargs)
6969

7070
return decorated_function
71+
72+
73+
def require_admin(f):
74+
"""A decorator that requires a user to be admin"""
75+
76+
@functools.wraps(f)
77+
@require_jwt # ensure the user is logged in with a valid JWT
78+
def decorated_function(*args, **kwargs):
79+
user_role = g.current_user.get("role")
80+
81+
if user_role != "admin":
82+
abort(403, description="Admin privileges required.")
83+
84+
return f(*args, **kwargs)
85+
86+
return decorated_function

scripts/seed_users.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ def seed_users(users_to_seed: list) -> str:
2626

2727
for user_data in users_to_seed:
2828
email = user_data["email"]
29+
role = user_data["role"]
2930

3031
# Check if data already exists
3132
if mongo.db.users.find_one({"email": email}):
@@ -39,7 +40,7 @@ def seed_users(users_to_seed: list) -> str:
3940

4041
# insert to new user
4142
mongo.db.users.insert_one(
42-
{"email": email, "password": hashed_password.decode("utf-8")}
43+
{"email": email, "password": hashed_password.decode("utf-8"), "role": role}
4344
)
4445
count += 1
4546
print(f"Created user: {email}")
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[
2-
{"email": "[email protected]", "password": "AdminPassword123"},
3-
{"email": "[email protected]", "password": "UserPassword456"},
4-
{"email": "[email protected]", "password": "DAYSend1991"}
2+
{"email": "[email protected]", "password": "AdminPassword123", "role": "admin"},
3+
{"email": "[email protected]", "password": "UserPassword456", "role": "user"},
4+
{"email": "[email protected]", "password": "DAYSend1991", "role": "user"}
55
]

tests/conftest.py

Lines changed: 71 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -125,20 +125,25 @@ def db_setup(test_app): # pylint: disable=redefined-outer-name
125125

126126
# Fixture for tests/test_auth.py
127127
@pytest.fixture(scope="function")
128-
def users_db_setup(test_app): # pylint: disable=redefined-outer-name
128+
def mongo_setup(test_app): # pylint: disable=redefined-outer-name
129129
"""
130-
Sets up and tears down the 'users' collection for a test.
130+
Updated to a more robust setup fixture.
131+
Ensures all relevant collections ('users', 'books', 'reservations')
132+
are clean before each test function runs.
131133
"""
132134
with test_app.app_context():
133-
# Now, the 'mongo' variable is defined and linked to the test_app
134-
users_collection = mongo.db.users
135-
users_collection.delete_many({})
135+
# Clear all collections before using in tests
136+
mongo.db.users.delete_many({})
137+
mongo.db.books.delete_many({})
138+
mongo.db.reservations.delete_many({})
136139

137140
yield
138141

139142
with test_app.app_context():
140-
users_collection = mongo.db.users
141-
users_collection.delete_many({})
143+
# Clear after tests have run
144+
mongo.db.users.delete_many({})
145+
mongo.db.books.delete_many({})
146+
mongo.db.reservations.delete_many({})
142147

143148

144149
TEST_USER_ID = "6154b3a3e4a5b6c7d8e9f0a1"
@@ -155,29 +160,82 @@ def mock_user_data():
155160
"_id": ObjectId(TEST_USER_ID),
156161
"email": "[email protected]",
157162
"password": hashed_password,
163+
"role": "user",
164+
}
165+
166+
167+
@pytest.fixture(scope="session")
168+
def mock_admin_data():
169+
"""
170+
PROVIDES a dictionary of a test admin's data,
171+
WITH a hashed password
172+
"""
173+
hashed_password = bcrypt.generate_password_hash("admin-password").decode("utf-8")
174+
175+
return {
176+
"email": "[email protected]",
177+
"password": hashed_password,
178+
"role": "admin", # Role explicitly set to 'admin'
158179
}
159180

160181

161182
@pytest.fixture
162183
def seeded_user_in_db(
163-
test_app, mock_user_data, users_db_setup
184+
test_app, mock_user_data, mongo_setup
164185
): # pylint: disable=redefined-outer-name
165186
"""
166187
Ensures the test database is clean and contains exactly one predefined user.
167188
Depends on:
168189
- test_app: To get the application context and correct mongo.db object
169190
- mock_user_data: To get the user data to insert.
170-
- users_db_Setup: To ensure the users collection is empty before seeding.
191+
- mongo_setup: To ensure the users collection is empty before seeding.
171192
"""
172-
_ = users_db_setup
193+
_ = mongo_setup
173194

174195
with test_app.app_context():
175196
mongo.db.users.insert_one(mock_user_data)
176197

177-
# When yielding the mock data back to the test,
178-
# we must convert it back the ObjectId back to the string,
179-
# because that's what 'auth_token' fixture expects to put into the JWT 'sub' claim
180198
yield_data = mock_user_data.copy()
181199
yield_data["_id"] = str(yield_data["_id"])
182200

183201
yield yield_data
202+
203+
204+
@pytest.fixture
205+
def seeded_admin_in_db(
206+
test_app, mock_admin_data, mongo_setup
207+
): # pylint: disable=redefined-outer-name
208+
"""
209+
Ensures the test database is clean and
210+
Contains exactly one predefined admin
211+
"""
212+
_ = mongo_setup
213+
214+
with test_app.app_context():
215+
result = mongo.db.users.insert_one(mock_admin_data)
216+
217+
yield_data = mock_admin_data.copy()
218+
yield_data["_id"] = str(result.inserted_id)
219+
yield yield_data
220+
221+
222+
@pytest.fixture
223+
def user_token(client, seeded_user_in_db): # pylint: disable=redefined-outer-name
224+
"""Logs in the seeded user and returns a valid JWT"""
225+
_ = seeded_user_in_db
226+
login_payload = {"email": "[email protected]", "password": PLAIN_PASSWORD}
227+
228+
response = client.post("/auth/login", json=login_payload)
229+
assert response.status_code == 200, "Failed to log in test user"
230+
return response.get_json()["token"]
231+
232+
233+
@pytest.fixture
234+
def admin_token(client, seeded_admin_in_db): # pylint: disable=redefined-outer-name
235+
"""Logs in the seeded admin and returns a valid JWT."""
236+
_ = seeded_admin_in_db
237+
login_payload = {"email": "[email protected]", "password": "admin-password"}
238+
239+
response = client.post("/auth/login", json=login_payload)
240+
assert response.status_code == 200, "Failed to log in test admin"
241+
return response.get_json()["token"]

tests/scripts/test_seed_users.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,22 @@ def test_seed_users_successfully(test_app):
1414
"""
1515
GIVEN an empty database and a list of user data
1616
WHEN the seed_users function is called
17-
THEN the users should be created in the database with hashed passwords.
17+
THEN the users should be created in the database with hashed passwords
18+
AND the correct roles.
1819
"""
1920
# Arrange
2021
# define the user data we want to seed the database with
2122
sample_users = [
22-
{"email": "[email protected]", "password": "AdminPassword123"},
23-
{"email": "[email protected]", "password": "UserPassword456"},
23+
{
24+
"email": "[email protected]",
25+
"password": "AdminPassword123",
26+
"role": "admin",
27+
},
28+
{
29+
"email": "[email protected]",
30+
"password": "UserPassword456",
31+
"role": "user",
32+
},
2433
]
2534

2635
# Enter application context and
@@ -41,6 +50,11 @@ def test_seed_users_successfully(test_app):
4150
assert bcrypt.checkpw(
4251
b"AdminPassword123", admin_user["password"].encode("utf-8")
4352
)
53+
54+
# verify roles
55+
assert admin_user["role"] == "admin"
56+
test_user = mongo.db.users.find_one({"email": "[email protected]"})
57+
assert test_user["role"] == "user"
4458
assert "Successfully seeded 2 users" in result_message
4559

4660

@@ -52,8 +66,12 @@ def test_seed_users_skips_if_user_already_exists(test_app, capsys):
5266
"""
5367
# Arrange
5468
users_to_attempt_seeding = [
55-
{"email": "[email protected]", "password": "Password123"},
56-
{"email": "[email protected]", "password": "Password456"},
69+
{
70+
"email": "[email protected]",
71+
"password": "Password123",
72+
"role": "user",
73+
},
74+
{"email": "[email protected]", "password": "Password456", "role": "user"},
5775
]
5876

5977
with test_app.app_context():

0 commit comments

Comments
 (0)