Conversation
Add a comprehensive set of end-to-end tests and supporting utilities, and harden post/comment handling in the app. - app/routes/post.py: require login for POST actions, add permission checks for deleting posts/comments, validate comment existence, and log unauthorized attempts; ensure proper redirects. - tests/: add many E2E test modules (account, admin, home, post, search, pages) covering create/edit/delete posts, comments, admin flows, dashboard, static pages, and auth flows. - tests/e2e/pages/: add page objects CreatePostPage and PostPage and export them from package init. - tests/e2e/helpers/database_helpers.py: add helpers to create/query test posts and comments (create_test_post, create_test_comment, get_post_by_title/url_id, get_comment_by_id, etc.) and small utilities used by tests. - tests/e2e/helpers/__init__.py: export new helper functions. - tests/README.md: update run instructions, suite overview, fixtures, and CI notes. These changes provide test coverage and developer tooling for E2E scenarios and fix authorization/validation gaps in post/comment handling.
|
Warning Rate limit exceeded
⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. 📝 WalkthroughWalkthroughAdds route guard decorators enforcing login/admin checks and tightens post/comment delete permissions; introduces app/utils/route_guards.py. Large E2E test expansion: new tests, page objects, and DB helpers across account, admin, home, post, and search domains; updates test docs and translation keys. Changes
Sequence Diagram(s)sequenceDiagram
participant Client as Client (Browser/Test)
participant Route as Flask Route (e.g., /post/delete)
participant Guard as route_guards.login_required / admin_required
participant DB as Database / User store
participant App as Route Handler
Client->>Route: HTTP POST /post/<id>/delete
Route->>Guard: invoke decorator
Guard->>DB: lookup session["username"] → get_user_by_username
alt user not found / not logged in
Guard-->>Client: redirect to /login (with flash)
else user found
Guard->>App: call wrapped handler (user in session)
App->>DB: fetch post/comment, verify owner or admin
alt authorized
App->>DB: delete post/comment
App-->>Client: 302/301 redirect (success)
else unauthorized
App-->>Client: 301 redirect (unauthorized), log attempt
end
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 3❌ Failed checks (2 warnings, 1 inconclusive)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Fix all issues with AI agents
In `@app/routes/post.py`:
- Around line 52-59: The redirect responses in app/routes/post.py currently
force a 301 status (e.g., the redirect(...) , 301 returned after the permission
check and other POST actions), which is incorrect for POST form actions; remove
the explicit ", 301" status overrides on the redirect(...) calls (leave the
default 302 or use 303 for POST→GET if you prefer) for the permission-denied and
post/comment actions referenced by redirect(url_for("post.post", url_id=url_id))
and the other three identical redirects so they no longer return 301.
- Around line 64-84: Guard against missing form fields and validate comment
submission: replace direct index access to request.form["comment_id"] and
request.form["comment"] with safe access (request.form.get(...)) and ensure the
branch only proceeds when the form indicates a comment submission and the
CommentForm (or equivalent) validates. Specifically, in the block handling
"comment_delete_button" use comment_id = request.form.get("comment_id") and bail
out (redirect) if it's None or invalid before calling Comment.query.get; and for
comment creation ensure you check for the comment-submit indicator (or validate
CommentForm.is_submitted()/validate()) before reading
request.form.get("comment"), validate/escape the text, and only then call
create_comment (or the existing create path), keeping permission checks and
delete_comment(comment_id) unchanged.
In `@tests/e2e/account/test_dashboard.py`:
- Around line 33-35: Two tests that require an authenticated session are missing
the `@pytest.mark.auth` marker: add `@pytest.mark.auth` above the test functions
test_dashboard_shows_user_posts_and_comments and test_dashboard_can_delete_post
so they are included in auth-filtered runs; ensure pytest is imported in the
file if not already and place the decorator directly above each def (keeping the
existing _login usage and test parameters intact).
In `@tests/e2e/post/test_post.py`:
- Around line 211-216: Replace the unnecessary BasePage instantiation by calling
the inherited method on PostPage directly: remove creation of BasePage(page,
flask_server["base_url"]) and the variable base_page, then call
post_page.expect_success_flash() instead of base_page.expect_success_flash();
ensure you keep the subsequent page.wait_for_url and
post_page.expect_comment_visible(comment_text) calls unchanged so the flow and
assertions remain intact.
🧹 Nitpick comments (6)
tests/e2e/helpers/database_helpers.py (1)
160-167: Optional: extract a context-manager to reduce connection boilerplate.Every helper repeats the same
conn = get_db_connection(...)/try/finally: conn.close()pattern. A small context manager would remove ~4 lines per function and make the pattern less error-prone if new helpers are added.Example context manager
from contextlib import contextmanager `@contextmanager` def db_connection(db_path: str, row_factory=None): conn = get_db_connection(db_path) if row_factory: conn.row_factory = row_factory try: yield conn, conn.cursor() finally: conn.close()Usage:
def get_post_by_title(db_path: str, title: str) -> dict | None: with db_connection(db_path, row_factory=sqlite3.Row) as (conn, cursor): cursor.execute("SELECT * FROM posts WHERE title = ?", (title,)) row = cursor.fetchone() return dict(row) if row else NoneAlso applies to: 266-268, 299-300
tests/e2e/home/test_home.py (1)
13-14:_suffix()is duplicated across multiple test files.This same helper appears in
test_home.py,test_search.py,test_category.py, andtest_admin.py. Consider extracting it totests/e2e/helpers/(e.g., alongsidetest_data.py) to avoid repetition.tests/e2e/pages/post_page.py (1)
35-37:get_views_countwill raise an unhandledValueErroron non-numeric text.If the rendered view count includes formatting like commas (
"1,234") or abbreviations ("1.2K"),int(views_text)will throw. For a test page object this is likely fine in practice, but worth noting if the app ever changes its display format.tests/e2e/post/test_post.py (2)
39-43:_loginhelper is duplicated across three test files.This identical helper appears in
test_post.py,test_dashboard.py, andtest_account_settings.py. Consider extracting it to a shared conftest fixture or a common test helper module (e.g.,tests/e2e/helpers/auth_helpers.py) to reduce duplication.
145-162:@pytest.mark.authon tests that don't require user authentication may be misleading.
test_post_url_redirects_to_canonical_slugandtest_post_views_increment_when_post_is_opened(lines 145–184) create posts via DB helpers and access them without logging in. If theauthmarker is used to filter authentication-related tests, these may not belong under it. If the post route itself requires authentication, then the tests would fail for a different reason (redirect to login rather than testing routing/views). Consider whether a different marker (or no marker) is more appropriate.tests/e2e/account/test_static_pages.py (1)
17-18: CSS framework class selectors (.btn-primary,.badge-ghost) may be fragile.These are DaisyUI/Tailwind utility classes that could change across UI framework versions. Consider using
data-testidattributes or more semantic selectors if the project adopts that pattern. Not critical for smoke tests, just a resilience consideration.
| if ( | ||
| post.author != session["username"] | ||
| and session.get("user_role") != "admin" | ||
| ): | ||
| Log.error( | ||
| f'User: "{session["username"]}" tried to delete post "{url_id}" without permission', | ||
| ) | ||
| return redirect(url_for("post.post", url_id=url_id)), 301 |
There was a problem hiding this comment.
HTTP 301 (Moved Permanently) is semantically incorrect for action responses.
return redirect(url), 301 overrides the redirect status to 301. These are all responses to POST form submissions (delete post, delete comment, add comment), not permanent URL relocations. Browsers and proxies cache 301 redirects, meaning a browser might skip hitting the server entirely on subsequent requests to the same URL, leading to stale or broken behavior.
Use the default 302 (or 303 for POST→GET) instead:
Proposed fix — drop the 301 overrides
- return redirect(url_for("post.post", url_id=url_id)), 301
+ return redirect(url_for("post.post", url_id=url_id))Apply the same change to all four occurrences (Lines 59, 70, 79, 82, and 108). For example:
- return redirect(url_for("post.post", url_id=url_id)), 301
+ return redirect(url_for("post.post", url_id=url_id))
- delete_comment(comment_id)
- return redirect(url_for("post.post", url_id=url_id)), 301
+ delete_comment(comment_id)
+ return redirect(url_for("post.post", url_id=url_id))
- return redirect(url_for("post.post", url_id=url_id)), 301
+ return redirect(url_for("post.post", url_id=url_id))Also applies to: 68-70, 79-82, 108-108
🤖 Prompt for AI Agents
In `@app/routes/post.py` around lines 52 - 59, The redirect responses in
app/routes/post.py currently force a 301 status (e.g., the redirect(...) , 301
returned after the permission check and other POST actions), which is incorrect
for POST form actions; remove the explicit ", 301" status overrides on the
redirect(...) calls (leave the default 302 or use 303 for POST→GET if you
prefer) for the permission-denied and post/comment actions referenced by
redirect(url_for("post.post", url_id=url_id)) and the other three identical
redirects so they no longer return 301.
| if "comment_delete_button" in request.form: | ||
| delete_comment(request.form["comment_id"]) | ||
| comment_id = request.form["comment_id"] | ||
| comment = Comment.query.get(comment_id) | ||
|
|
||
| if not comment: | ||
| Log.error(f'Comment: "{comment_id}" not found for post "{url_id}"') | ||
| return redirect(url_for("post.post", url_id=url_id)), 301 | ||
|
|
||
| if ( | ||
| comment.username != session["username"] | ||
| and session.get("user_role") != "admin" | ||
| ): | ||
| Log.error( | ||
| f'User: "{session["username"]}" tried to delete comment "{comment_id}" without permission', | ||
| ) | ||
| return redirect(url_for("post.post", url_id=url_id)), 301 | ||
|
|
||
| delete_comment(comment_id) | ||
| return redirect(url_for("post.post", url_id=url_id)), 301 | ||
|
|
||
| comment_text = escape(request.form["comment"]) |
There was a problem hiding this comment.
Missing guard for the comment creation fallthrough and missing KeyError handling.
Two concerns in this block:
-
Line 65:
request.form["comment_id"]will raise aKeyErrorif a malicious or malformed request omits the field. Userequest.form.get("comment_id")with a guard, or catch the exception. -
Lines 84+: If a POST request reaches this point without any recognized button and without a
"comment"key in the form, line 84 (request.form["comment"]) will raise a 500. There's no explicitelifor guard ensuring this is actually a comment submission. ACommentFormis instantiated (line 29) but never validated.
Proposed fix — use safe form access and validate before creating a comment
if "comment_delete_button" in request.form:
- comment_id = request.form["comment_id"]
+ comment_id = request.form.get("comment_id")
+ if not comment_id:
+ return redirect(url_for("post.post", url_id=url_id))
comment = Comment.query.get(comment_id)For the comment creation block, consider validating the form:
- comment_text = escape(request.form["comment"])
+ if not form.validate():
+ return redirect(url_for("post.post", url_id=url_id))
+ comment_text = escape(form.comment.data)📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if "comment_delete_button" in request.form: | |
| delete_comment(request.form["comment_id"]) | |
| comment_id = request.form["comment_id"] | |
| comment = Comment.query.get(comment_id) | |
| if not comment: | |
| Log.error(f'Comment: "{comment_id}" not found for post "{url_id}"') | |
| return redirect(url_for("post.post", url_id=url_id)), 301 | |
| if ( | |
| comment.username != session["username"] | |
| and session.get("user_role") != "admin" | |
| ): | |
| Log.error( | |
| f'User: "{session["username"]}" tried to delete comment "{comment_id}" without permission', | |
| ) | |
| return redirect(url_for("post.post", url_id=url_id)), 301 | |
| delete_comment(comment_id) | |
| return redirect(url_for("post.post", url_id=url_id)), 301 | |
| comment_text = escape(request.form["comment"]) | |
| if "comment_delete_button" in request.form: | |
| comment_id = request.form.get("comment_id") | |
| if not comment_id: | |
| return redirect(url_for("post.post", url_id=url_id)) | |
| comment = Comment.query.get(comment_id) | |
| if not comment: | |
| Log.error(f'Comment: "{comment_id}" not found for post "{url_id}"') | |
| return redirect(url_for("post.post", url_id=url_id)), 301 | |
| if ( | |
| comment.username != session["username"] | |
| and session.get("user_role") != "admin" | |
| ): | |
| Log.error( | |
| f'User: "{session["username"]}" tried to delete comment "{comment_id}" without permission', | |
| ) | |
| return redirect(url_for("post.post", url_id=url_id)), 301 | |
| delete_comment(comment_id) | |
| return redirect(url_for("post.post", url_id=url_id)), 301 | |
| if not form.validate(): | |
| return redirect(url_for("post.post", url_id=url_id)) | |
| comment_text = escape(form.comment.data) |
🤖 Prompt for AI Agents
In `@app/routes/post.py` around lines 64 - 84, Guard against missing form fields
and validate comment submission: replace direct index access to
request.form["comment_id"] and request.form["comment"] with safe access
(request.form.get(...)) and ensure the branch only proceeds when the form
indicates a comment submission and the CommentForm (or equivalent) validates.
Specifically, in the block handling "comment_delete_button" use comment_id =
request.form.get("comment_id") and bail out (redirect) if it's None or invalid
before calling Comment.query.get; and for comment creation ensure you check for
the comment-submit indicator (or validate CommentForm.is_submitted()/validate())
before reading request.form.get("comment"), validate/escape the text, and only
then call create_comment (or the existing create path), keeping permission
checks and delete_comment(comment_id) unchanged.
| def test_dashboard_shows_user_posts_and_comments( | ||
| self, page, flask_server, test_user, db_path | ||
| ): |
There was a problem hiding this comment.
Inconsistent @pytest.mark.auth usage — two tests that require login are missing the marker.
test_dashboard_shows_user_posts_and_comments (line 33) and test_dashboard_can_delete_post (line 96) both log in via _login and exercise authenticated flows, but lack the @pytest.mark.auth marker that other auth-dependent tests in this PR consistently use. If the marker is used for filtering or CI gating, these tests would be excluded from auth test runs.
♻️ Suggested fix
+ `@pytest.mark.auth`
def test_dashboard_shows_user_posts_and_comments(
self, page, flask_server, test_user, db_path
):+ `@pytest.mark.auth`
def test_dashboard_can_delete_post(self, page, flask_server, test_user, db_path):Also applies to: 96-97
🤖 Prompt for AI Agents
In `@tests/e2e/account/test_dashboard.py` around lines 33 - 35, Two tests that
require an authenticated session are missing the `@pytest.mark.auth` marker: add
`@pytest.mark.auth` above the test functions
test_dashboard_shows_user_posts_and_comments and test_dashboard_can_delete_post
so they are included in auth-filtered runs; ensure pytest is imported in the
file if not already and place the decorator directly above each def (keeping the
existing _login usage and test parameters intact).
| post_page.add_comment(comment_text) | ||
|
|
||
| base_page = BasePage(page, flask_server["base_url"]) | ||
| base_page.expect_success_flash() | ||
| page.wait_for_url("**/post/**", timeout=5000) | ||
| post_page.expect_comment_visible(comment_text) |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
Unnecessary BasePage instantiation — PostPage inherits expect_success_flash.
PostPage extends BasePage, so post_page.expect_success_flash() works directly. No need to create a separate BasePage instance.
♻️ Suggested fix
post_page.add_comment(comment_text)
- base_page = BasePage(page, flask_server["base_url"])
- base_page.expect_success_flash()
+ post_page.expect_success_flash()
page.wait_for_url("**/post/**", timeout=5000)
post_page.expect_comment_visible(comment_text)🤖 Prompt for AI Agents
In `@tests/e2e/post/test_post.py` around lines 211 - 216, Replace the unnecessary
BasePage instantiation by calling the inherited method on PostPage directly:
remove creation of BasePage(page, flask_server["base_url"]) and the variable
base_page, then call post_page.expect_success_flash() instead of
base_page.expect_success_flash(); ensure you keep the subsequent
page.wait_for_url and post_page.expect_comment_visible(comment_text) calls
unchanged so the flow and assertions remain intact.
Capture the user's language before clearing the session to avoid reading from a cleared session in change_password route. Pass that language into the flash_message after session.clear to preserve localization. Add a new end-to-end test (tests/e2e/account/test_change_password_flow.py) that verifies credential rotation: after changing password the old password is rejected and the new password authenticates. Update tests/README.md to reflect the added test and adjust suite counts and file listings.
Standardize the change-username key/name across the app: rename "change_user_name" to "change_username" in all translation files, update the change_username template to use the new keys, adjust route flash_page and flash_message calls, and fix the form import path (utils.forms.change_username_form) in change_username route. This unifies naming for the change-username feature and fixes related template/import references.
There was a problem hiding this comment.
Actionable comments posted: 11
🤖 Fix all issues with AI agents
In `@app/routes/admin_panel_users.py`:
- Around line 23-36: The POST handlers call delete_user(...) and
change_user_role(...) but ignore their return values (which may be Flask
redirect responses); update the POST branch in the request.method == "POST"
block to capture the result of delete_user(request.form["username"]) and
change_user_role(request.form["username"]) and, if the result is a
Response/redirect, immediately return it (otherwise continue). Ensure you still
log with Log.info(...) before calling those functions and propagate any redirect
returned by those functions so redirects from delete_user and change_user_role
are not lost.
- Around line 41-54: Remove the password field from the tuple built from
users_objects in the users list comprehension (currently includes u.password) so
templates do not receive password hashes; then update the corresponding index
references in admin_panel_users.html to account for the shifted tuple positions
(change user[4]→user[3], user[5]→user[4], user[6]→user[5], user[7]→user[6]).
Locate the list construction around the users = [ (... ) for u in users_objects
] in admin_panel_users.py and the index uses in admin_panel_users.html and
adjust them accordingly.
In `@app/routes/change_password.py`:
- Around line 56-97: The three independent conditionals after
encryption.verify(old_password, user.password) can trigger multiple flash
messages; change them to an exclusive control flow (use if / elif / elif or
return early) so only one branch runs: check old_password == password first
(flash "same" and return/redirect), else if password != password_confirm (flash
"match" and return/redirect), else (passwords different from old and match each
other) set user.password via encryption.hash(password), call
db.session.commit(), call Log.success(...), clear session, flash "success" using
session.get("language", "en") and then return redirect("/login/redirect=&").
Ensure you update the block containing encryption.verify, flash_message,
user.password, db.session.commit, Log.success, session, and redirect
accordingly.
In `@app/routes/change_username.py`:
- Around line 49-65: Post and Comment rows (Post.query.filter_by(...) and
Comment.query.filter_by(...)) and db.session.commit() run even when the User
lookup fails; move the Post and Comment update calls and the db.session.commit()
inside the same if user: guard that wraps user.username = new_username (or
replace the guard so the entire update block is conditional on user being
truthy), and add handling for the case when
User.query.filter_by(username=old_username).first() returns none (e.g.,
return/abort or skip updates) so Post, Comment and commit only execute when a
corresponding User exists.
In `@app/routes/create_post.py`:
- Around line 42-50: CreatePostForm is instantiated but its validators are
bypassed because code reads values directly from request.form; call
form.validate() (or use form.validate_on_submit()) and, on success, use
form.post_title.data, form.post_tags.data, form.post_abstract.data,
form.post_content.data, form.post_category.data instead of request.form[...] so
the min/max validators on post_abstract and post_content run server-side; if
validate() fails, return/flash form.errors and re-render the form. Keep using
request.files["post_banner"] for the file but only process it after form
validation.
In `@app/routes/dashboard.py`:
- Around line 34-37: The redirect after a POST in app/routes/dashboard.py
currently returns redirect(url_for("dashboard.dashboard", username=username)),
301 which is incorrect; change it to use a non-permanent redirect (either return
redirect(url_for("dashboard.dashboard", username=username)) to get a 302 or
explicitly return redirect(url_for("dashboard.dashboard", username=username),
code=303) to enforce a POST→GET See Other). Update the return in the handler
that issues this redirect (the call site using
redirect(url_for("dashboard.dashboard", username=username))) accordingly.
- Around line 76-80: session.get("language") can be None causing
translation_file to become "./translations/None.json" and raise
FileNotFoundError; update the code around session.get("language") and
translation_file so you use a safe default (e.g., "en") when
session.get("language") returns None or an empty value. Concretely, replace the
raw session.get("language") use with something like language =
session.get("language") or "en" (or session.get("language", "en")), then build
translation_file and proceed to open/load; ensure the change touches the
language variable, translation_file, and the with open(...) load(...) sequence
so it never attempts to open "None.json".
In `@app/routes/edit_post.py`:
- Line 60: The current code accesses request.files["post_banner"] directly which
can raise KeyError if the field is missing; change it to use
request.files.get("post_banner") and guard before calling .read(): retrieve the
file object into a variable (e.g., post_banner_file =
request.files.get("post_banner")), check if post_banner_file is truthy (and
optionally has a filename) and only then call post_banner_file.read() to assign
post_banner; otherwise set post_banner to None or handle the missing-file branch
(validation error/flash/redirect) inside the same function handling the form
submission.
In `@app/routes/verify_user.py`:
- Line 52: The module-level variable verification_code is a concurrency hazard
because it allows concurrent flows to clobber each other's codes; replace this
global by persisting per-user codes (e.g., attach the code to the user's session
or to your user verification DB record) instead of using verification_code.
Update any functions or routes that read/write verification_code (e.g., the
route handler that generates/sends the code and the handler that validates it)
to read from and write to the chosen per-user store (session key or a
Verification table keyed by user_id/email), ensure proper expiration timestamps
and single-use semantics, and remove the global declaration so all reads/writes
are scoped to the user context.
In `@app/utils/route_guards.py`:
- Around line 10-44: The decorator uses session["username"] which conflicts with
the coding guideline's session["userName"] but the rest of the codebase
consistently uses the lowercase "username"; resolve the mismatch by choosing one
canonical key and making code + docs consistent: either (A) change this
decorator to use session.get("userName") and perform a project-wide replace of
session["username"] -> session["userName"] (update any tests and auth code that
read/write the session), or (B) accept the existing lowercase key by updating
the coding guideline and any docs to declare "username" as the canonical session
key; locate the decorator function login_required and the other auth read/write
sites to apply the chosen consistent change.
In `@tests/README.md`:
- Around line 186-190: In the CI section of tests/README.md update the product
name to use correct capitalization: change any occurrence of "github actions" or
"github" to "GitHub Actions" (and "GitHub" where used alone) so the CI heading
and the sentence "E2E tests run in GitHub Actions
(`.github/workflows/e2e-tests.yaml`) on:" use the official product casing.
🧹 Nitpick comments (7)
app/routes/admin_panel_posts.py (1)
15-15: Consider removing"POST"from allowed methods.This route only queries and renders data — there's no
request.method == "POST"handling. If the template doesn't submit forms to this endpoint, the method list can be narrowed to["GET"].app/routes/change_profile_picture.py (1)
41-62: Form validation is bypassed — rawrequest.formaccess can raiseKeyError.
request.form["new_profile_picture_seed"]on line 42 is accessed directly without callingform.validate(). If the field is missing from the POST body, this raises a400 Bad Request. Consider validating via the form object first:♻️ Suggested improvement
if request.method == "POST": - new_profile_picture_seed = request.form["new_profile_picture_seed"] + if not form.validate(): + return render_template("change_profile_picture.html", form=form) + + new_profile_picture_seed = form.new_profile_picture_seed.dataapp/routes/account_settings.py (1)
18-20: Return value fromdelete_useris discarded.
delete_user()(inapp/utils/delete.py) returns aredirect(...)response, but it's ignored here — the route always returns its ownredirect("/"). The side effects (DB deletion,session.clear(), flash) still fire, so this works for the self-deletion case. However, silently discarding the response is fragile ifdelete_user's contract changes.Consider using the return value:
♻️ Suggested improvement
if request.method == "POST": - delete_user(user.username) - return redirect("/") + return delete_user(user.username)app/routes/verify_user.py (1)
85-87: Redundantif user:check — user is guaranteed to exist.Line 44–47 already verifies the user exists and returns early if not. The
if user:guard on Line 87 is dead code.♻️ Suggested cleanup
elif code_sent == "false": if request.method == "POST": - if user: - context = ssl.create_default_context() + context = ssl.create_default_context()(and dedent the entire block below accordingly)
tests/e2e/account/test_change_password_flow.py (1)
14-18:_loginhelper is duplicated across multiple E2E test files.The AI summary notes identical
_loginhelpers intest_account_settings.pyandtest_profile_and_preferences.py. Consider extracting this into a shared fixture or utility in a conftest/helpers module to reduce duplication as the test suite grows.app/routes/dashboard.py (1)
44-60: Fragile positional list representation for posts with magic index access.Posts are stored as plain lists and accessed by magic indices (e.g.,
post[9]for category on Line 83). This is error-prone—any change to the field order silently breaks the category translation and any template code that consumes these lists. Consider using dictionaries or named tuples instead.Also applies to: 82-83
tests/e2e/admin/test_admin.py (1)
19-28:_suffix()and_login()are duplicated across test files.These helpers appear identically in
test_profile_and_preferences.pyas well. Consider extracting them into a shared test utility (e.g.,tests/e2e/helpers/) to reduce duplication.
| if request.method == "POST": | ||
| if "user_delete_button" in request.form: | ||
| Log.info( | ||
| f"Admin: {session['username']} deleted user: {request.form['username']}" | ||
| ) | ||
|
|
||
| Log.info(f"Rendering admin_panel_users.html: params: users={len(users)}") | ||
| delete_user(request.form["username"]) | ||
|
|
||
| return render_template( | ||
| "admin_panel_users.html", | ||
| users=users, | ||
| page=page, | ||
| total_pages=total_pages, | ||
| ) | ||
| else: | ||
| Log.error( | ||
| f"{request.remote_addr} tried to reach user admin panel without being admin" | ||
| if "user_role_change_button" in request.form: | ||
| Log.info( | ||
| f"Admin: {session['username']} changed {request.form['username']}'s role" | ||
| ) | ||
|
|
||
| return redirect("/") | ||
| else: | ||
| Log.error( | ||
| f"{request.remote_addr} tried to reach user admin panel being logged in" | ||
| change_user_role(request.form["username"]) |
There was a problem hiding this comment.
Return values from delete_user and change_user_role are silently discarded — redirects won't fire.
Both delete_user() and change_user_role() can return redirect(...) responses (see app/utils/delete.py lines 67–86 and app/utils/change_user_role.py lines 25–27). Since their return values are not propagated here, the code falls through to the query/render block on lines 38–63. This means:
- After deleting a user, the flash message fires but the page re-renders (potentially showing the deleted user if the DB commit already happened, or crashing if it didn't).
- If an admin demotes themselves, the
redirect("/")fromchange_user_roleis lost — the page re-renders, and subsequent requests will fail because the@admin_requiredguard will reject the now-non-admin user.
🐛 Proposed fix — propagate redirect responses
if request.method == "POST":
if "user_delete_button" in request.form:
Log.info(
f"Admin: {session['username']} deleted user: {request.form['username']}"
)
- delete_user(request.form["username"])
+ result = delete_user(request.form["username"])
+ if result:
+ return result
- if "user_role_change_button" in request.form:
+ elif "user_role_change_button" in request.form:
Log.info(
f"Admin: {session['username']} changed {request.form['username']}'s role"
)
- change_user_role(request.form["username"])
+ result = change_user_role(request.form["username"])
+ if result:
+ return result📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if request.method == "POST": | |
| if "user_delete_button" in request.form: | |
| Log.info( | |
| f"Admin: {session['username']} deleted user: {request.form['username']}" | |
| ) | |
| Log.info(f"Rendering admin_panel_users.html: params: users={len(users)}") | |
| delete_user(request.form["username"]) | |
| return render_template( | |
| "admin_panel_users.html", | |
| users=users, | |
| page=page, | |
| total_pages=total_pages, | |
| ) | |
| else: | |
| Log.error( | |
| f"{request.remote_addr} tried to reach user admin panel without being admin" | |
| if "user_role_change_button" in request.form: | |
| Log.info( | |
| f"Admin: {session['username']} changed {request.form['username']}'s role" | |
| ) | |
| return redirect("/") | |
| else: | |
| Log.error( | |
| f"{request.remote_addr} tried to reach user admin panel being logged in" | |
| change_user_role(request.form["username"]) | |
| if request.method == "POST": | |
| if "user_delete_button" in request.form: | |
| Log.info( | |
| f"Admin: {session['username']} deleted user: {request.form['username']}" | |
| ) | |
| result = delete_user(request.form["username"]) | |
| if result: | |
| return result | |
| elif "user_role_change_button" in request.form: | |
| Log.info( | |
| f"Admin: {session['username']} changed {request.form['username']}'s role" | |
| ) | |
| result = change_user_role(request.form["username"]) | |
| if result: | |
| return result |
🤖 Prompt for AI Agents
In `@app/routes/admin_panel_users.py` around lines 23 - 36, The POST handlers call
delete_user(...) and change_user_role(...) but ignore their return values (which
may be Flask redirect responses); update the POST branch in the request.method
== "POST" block to capture the result of delete_user(request.form["username"])
and change_user_role(request.form["username"]) and, if the result is a
Response/redirect, immediately return it (otherwise continue). Ensure you still
log with Log.info(...) before calling those functions and propagate any redirect
returned by those functions so redirects from delete_user and change_user_role
are not lost.
| users = [ | ||
| ( | ||
| u.user_id, | ||
| u.username, | ||
| u.email, | ||
| u.password, | ||
| u.profile_picture, | ||
| u.role, | ||
| u.points, | ||
| u.time_stamp, | ||
| u.is_verified, | ||
| ) | ||
| for u in users_objects | ||
| ] |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
git ls-files | grep -E "(admin_panel|routes)" | head -20Repository: DogukanUrker/FlaskBlog
Length of output: 603
🏁 Script executed:
fd 'admin_panel_users' --type fRepository: DogukanUrker/FlaskBlog
Length of output: 134
🏁 Script executed:
cat -n app/routes/admin_panel_users.py 2>/dev/null | head -60Repository: DogukanUrker/FlaskBlog
Length of output: 2117
🏁 Script executed:
cat -n app/templates/admin_panel_users.htmlRepository: DogukanUrker/FlaskBlog
Length of output: 5791
Remove password from the user tuple passed to template — it's never used and exposes unnecessary sensitive data.
Line 46 includes u.password in the tuple passed to the template, but the template never accesses this field. Even though passwords are hashed, passing password hashes to the template violates the principle of least privilege and risks unnecessary exposure in HTML source, caches, or logs.
🔒 Proposed fix
users = [
(
u.user_id,
u.username,
u.email,
- u.password,
u.profile_picture,
u.role,
u.points,
u.time_stamp,
u.is_verified,
)
for u in users_objects
]Update the template indices: after removing u.password, the tuple indices shift by 1. In admin_panel_users.html, update:
user[4]→user[3](profile_picture)user[5]→user[4](role)user[6]→user[5](points)user[7]→user[6](timestamp)
🤖 Prompt for AI Agents
In `@app/routes/admin_panel_users.py` around lines 41 - 54, Remove the password
field from the tuple built from users_objects in the users list comprehension
(currently includes u.password) so templates do not receive password hashes;
then update the corresponding index references in admin_panel_users.html to
account for the shifted tuple positions (change user[4]→user[3],
user[5]→user[4], user[6]→user[5], user[7]→user[6]). Locate the list construction
around the users = [ (... ) for u in users_objects ] in admin_panel_users.py and
the index uses in admin_panel_users.html and adjust them accordingly.
| if encryption.verify(old_password, user.password): | ||
| if old_password == password: | ||
| flash_message( | ||
| page="change_password", | ||
| message="login", | ||
| message="same", | ||
| category="error", | ||
| language=session["language"], | ||
| ) | ||
| return redirect("/login/redirect=change-password") | ||
|
|
||
| if encryption.verify(old_password, user.password): | ||
| if old_password == password: | ||
| flash_message( | ||
| page="change_password", | ||
| message="same", | ||
| category="error", | ||
| language=session["language"], | ||
| ) | ||
|
|
||
| if password != password_confirm: | ||
| flash_message( | ||
| page="change_password", | ||
| message="match", | ||
| category="error", | ||
| language=session["language"], | ||
| ) | ||
|
|
||
| if old_password != password and password == password_confirm: | ||
| user.password = encryption.hash(password) | ||
| db.session.commit() | ||
|
|
||
| Log.success( | ||
| f'User: "{session["username"]}" changed his password', | ||
| ) | ||
|
|
||
| session.clear() | ||
| flash_message( | ||
| page="change_password", | ||
| message="success", | ||
| category="success", | ||
| language=session["language"], | ||
| ) | ||
|
|
||
| return redirect("/login/redirect=&") | ||
| else: | ||
|
|
||
| if password != password_confirm: | ||
| flash_message( | ||
| page="change_password", | ||
| message="old", | ||
| message="match", | ||
| category="error", | ||
| language=session["language"], | ||
| ) | ||
|
|
||
| return render_template( | ||
| "change_password.html", | ||
| form=form, | ||
| ) | ||
| else: | ||
| Log.error( | ||
| f"{request.remote_addr} tried to change his password without logging in" | ||
| ) | ||
| flash_message( | ||
| page="change_password", | ||
| message="login", | ||
| category="error", | ||
| language=session["language"], | ||
| ) | ||
|
|
||
| return redirect("/login/redirect=change-password") | ||
| if old_password != password and password == password_confirm: | ||
| user.password = encryption.hash(password) | ||
| db.session.commit() | ||
|
|
||
| Log.success( | ||
| f'User: "{session["username"]}" changed his password', | ||
| ) | ||
|
|
||
| language = session.get("language", "en") | ||
| session.clear() | ||
| flash_message( | ||
| page="change_password", | ||
| message="success", | ||
| category="success", | ||
| language=language, | ||
| ) | ||
|
|
||
| return redirect("/login/redirect=&") | ||
| else: | ||
| flash_message( | ||
| page="change_password", | ||
| message="old", | ||
| category="error", | ||
| language=session["language"], | ||
| ) |
There was a problem hiding this comment.
Non-exclusive if blocks allow double flash messages.
Lines 57, 65, and 73 are independent if statements rather than if/elif/elif. When old_password == password and password != password_confirm, both the "same password" and "passwords don't match" flash messages fire in the same request. Use elif chains or early returns to ensure mutual exclusivity.
🐛 Proposed fix
if encryption.verify(old_password, user.password):
- if old_password == password:
+ if old_password == password:
flash_message(
page="change_password",
message="same",
category="error",
language=session["language"],
)
-
- if password != password_confirm:
+ elif password != password_confirm:
flash_message(
page="change_password",
message="match",
category="error",
language=session["language"],
)
-
- if old_password != password and password == password_confirm:
+ else:
user.password = encryption.hash(password)
db.session.commit()🤖 Prompt for AI Agents
In `@app/routes/change_password.py` around lines 56 - 97, The three independent
conditionals after encryption.verify(old_password, user.password) can trigger
multiple flash messages; change them to an exclusive control flow (use if / elif
/ elif or return early) so only one branch runs: check old_password == password
first (flash "same" and return/redirect), else if password != password_confirm
(flash "match" and return/redirect), else (passwords different from old and
match each other) set user.password via encryption.hash(password), call
db.session.commit(), call Log.success(...), clear session, flash "success" using
session.get("language", "en") and then return redirect("/login/redirect=&").
Ensure you update the block containing encryption.verify, flash_message,
user.password, db.session.commit, Log.success, session, and redirect
accordingly.
| if not existing_user: | ||
| old_username = session["username"] | ||
|
|
||
| # Update username in users table | ||
| user = User.query.filter_by(username=old_username).first() | ||
| if user: | ||
| user.username = new_username | ||
|
|
||
| # Update author in posts table | ||
| Post.query.filter_by(author=old_username).update({"author": new_username}) | ||
|
|
||
| # Update username in comments table | ||
| Comment.query.filter_by(username=old_username).update( | ||
| {"username": new_username} | ||
| ) | ||
|
|
||
| db.session.commit() |
There was a problem hiding this comment.
Post and Comment updates execute even if the User record isn't found.
The if user: guard on Line 54 only protects the User.username assignment, but Post.query.filter_by(...) and Comment.query.filter_by(...) updates on Lines 58–63 run unconditionally, and the commit on Line 65 persists them. If the user lookup fails (defensive edge case), posts and comments get reassigned to new_username while no User record is updated.
🐛 Proposed fix — guard the entire update block
if not existing_user:
old_username = session["username"]
# Update username in users table
user = User.query.filter_by(username=old_username).first()
- if user:
- user.username = new_username
+ if not user:
+ return redirect("/")
- # Update author in posts table
- Post.query.filter_by(author=old_username).update({"author": new_username})
+ user.username = new_username
- # Update username in comments table
- Comment.query.filter_by(username=old_username).update(
- {"username": new_username}
- )
+ # Update author in posts table
+ Post.query.filter_by(author=old_username).update({"author": new_username})
- db.session.commit()
+ # Update username in comments table
+ Comment.query.filter_by(username=old_username).update(
+ {"username": new_username}
+ )
+
+ db.session.commit()🤖 Prompt for AI Agents
In `@app/routes/change_username.py` around lines 49 - 65, Post and Comment rows
(Post.query.filter_by(...) and Comment.query.filter_by(...)) and
db.session.commit() run even when the User lookup fails; move the Post and
Comment update calls and the db.session.commit() inside the same if user: guard
that wraps user.username = new_username (or replace the guard so the entire
update block is conditional on user being truthy), and add handling for the case
when User.query.filter_by(username=old_username).first() returns none (e.g.,
return/abort or skip updates) so Post, Comment and commit only execute when a
corresponding User exists.
| form = CreatePostForm(request.form) | ||
|
|
||
| if request.method == "POST": | ||
| post_title = request.form["post_title"] | ||
| post_tags = request.form["post_tags"] | ||
| post_abstract = request.form["post_abstract"] | ||
| post_content = request.form["post_content"] | ||
| post_banner = request.files["post_banner"].read() | ||
| post_category = request.form["post_category"] | ||
| if request.method == "POST": | ||
| post_title = request.form["post_title"] | ||
| post_tags = request.form["post_tags"] | ||
| post_abstract = request.form["post_abstract"] | ||
| post_content = request.form["post_content"] | ||
| post_banner = request.files["post_banner"].read() | ||
| post_category = request.form["post_category"] |
There was a problem hiding this comment.
WTForms validation is bypassed — form values are read directly from request.form.
CreatePostForm is instantiated on Line 42 but form.validate() is never called. Fields are pulled directly from request.form, so the min/max length validators on post_abstract (150–200 chars) and post_content (≥ 50 chars) are never enforced server-side — only the empty-string check on Line 52 gates submission.
This is pre-existing, but worth noting since it means the form validators serve only as client-side hints (if rendered), and the server accepts arbitrarily short or long content.
🤖 Prompt for AI Agents
In `@app/routes/create_post.py` around lines 42 - 50, CreatePostForm is
instantiated but its validators are bypassed because code reads values directly
from request.form; call form.validate() (or use form.validate_on_submit()) and,
on success, use form.post_title.data, form.post_tags.data,
form.post_abstract.data, form.post_content.data, form.post_category.data instead
of request.form[...] so the min/max validators on post_abstract and post_content
run server-side; if validate() fails, return/flash form.errors and re-render the
form. Keep using request.files["post_banner"] for the file but only process it
after form validation.
| language = session.get("language") | ||
| translation_file = f"./translations/{language}.json" | ||
|
|
||
| with open(translation_file, "r", encoding="utf-8") as file: | ||
| translations = load(file) |
There was a problem hiding this comment.
session.get("language") can be None, leading to a FileNotFoundError.
If the session has no "language" key, language will be None, and the file path becomes ./translations/None.json, which will raise a FileNotFoundError on Line 79. Provide a default fallback.
Proposed fix
- language = session.get("language")
+ language = session.get("language", "en")📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| language = session.get("language") | |
| translation_file = f"./translations/{language}.json" | |
| with open(translation_file, "r", encoding="utf-8") as file: | |
| translations = load(file) | |
| language = session.get("language", "en") | |
| translation_file = f"./translations/{language}.json" | |
| with open(translation_file, "r", encoding="utf-8") as file: | |
| translations = load(file) |
🤖 Prompt for AI Agents
In `@app/routes/dashboard.py` around lines 76 - 80, session.get("language") can be
None causing translation_file to become "./translations/None.json" and raise
FileNotFoundError; update the code around session.get("language") and
translation_file so you use a safe default (e.g., "en") when
session.get("language") returns None or an empty value. Concretely, replace the
raw session.get("language") use with something like language =
session.get("language") or "en" (or session.get("language", "en")), then build
translation_file and proceed to open/load; ensure the change touches the
language variable, translation_file, and the with open(...) load(...) sequence
so it never attempts to open "None.json".
| post_content = request.form["post_content"] | ||
| post_abstract = request.form["post_abstract"] | ||
| post_category = request.form["post_category"] | ||
| post_banner = request.files["post_banner"].read() |
There was a problem hiding this comment.
request.files["post_banner"] can raise KeyError if the file field is absent.
If the form is submitted without a file input named post_banner, this will raise an unhandled KeyError/400. Use .get() with a fallback:
🛡️ Proposed fix
- post_banner = request.files["post_banner"].read()
+ post_banner_file = request.files.get("post_banner")
+ post_banner = post_banner_file.read() if post_banner_file else b""📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| post_banner = request.files["post_banner"].read() | |
| post_banner_file = request.files.get("post_banner") | |
| post_banner = post_banner_file.read() if post_banner_file else b"" |
🤖 Prompt for AI Agents
In `@app/routes/edit_post.py` at line 60, The current code accesses
request.files["post_banner"] directly which can raise KeyError if the field is
missing; change it to use request.files.get("post_banner") and guard before
calling .read(): retrieve the file object into a variable (e.g.,
post_banner_file = request.files.get("post_banner")), check if post_banner_file
is truthy (and optionally has a filename) and only then call
post_banner_file.read() to assign post_banner; otherwise set post_banner to None
or handle the missing-file branch (validation error/flash/redirect) inside the
same function handling the form submission.
| if user.is_verified == "True": | ||
| return redirect("/") | ||
| elif user.is_verified == "False": | ||
| global verification_code |
There was a problem hiding this comment.
global verification_code is a shared-state concurrency hazard.
This module-level global means concurrent verification flows for different users will overwrite each other's codes. If User A requests a code, then User B requests one before A submits, A's code is lost. This is pre-existing, but worth flagging as the guard refactor is a good opportunity to address it — store the code in the user's session or in the database instead.
🤖 Prompt for AI Agents
In `@app/routes/verify_user.py` at line 52, The module-level variable
verification_code is a concurrency hazard because it allows concurrent flows to
clobber each other's codes; replace this global by persisting per-user codes
(e.g., attach the code to the user's session or to your user verification DB
record) instead of using verification_code. Update any functions or routes that
read/write verification_code (e.g., the route handler that generates/sends the
code and the handler that validates it) to read from and write to the chosen
per-user store (session key or a Verification table keyed by user_id/email),
ensure proper expiration timestamps and single-use semantics, and remove the
global declaration so all reads/writes are scoped to the user context.
| def login_required( | ||
| route_name: str, | ||
| redirect_to="/", | ||
| flash_page: str | None = None, | ||
| flash_message_key: str = "login", | ||
| flash_category: str = "error", | ||
| ): | ||
| """Ensure the current session has a logged in user.""" | ||
|
|
||
| def decorator(view_func): | ||
| @wraps(view_func) | ||
| def wrapped_view(*args, **kwargs): | ||
| if "username" in session: | ||
| return view_func(*args, **kwargs) | ||
|
|
||
| Log.error( | ||
| f"{request.remote_addr} tried to reach {route_name} without being logged in" | ||
| ) | ||
|
|
||
| if flash_page: | ||
| flash_message( | ||
| page=flash_page, | ||
| message=flash_message_key, | ||
| category=flash_category, | ||
| language=session.get("language", "en"), | ||
| ) | ||
|
|
||
| if callable(redirect_to): | ||
| return redirect(redirect_to(*args, **kwargs)) | ||
|
|
||
| return redirect(redirect_to) | ||
|
|
||
| return wrapped_view | ||
|
|
||
| return decorator |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify which session key is used across the codebase for authentication
echo "=== Occurrences of session[\"userName\"] ==="
rg -n 'session\["userName"\]' --type=py || echo "No matches found"
echo ""
echo "=== Occurrences of session[\"username\"] ==="
rg -n 'session\["username"\]' --type=py || echo "No matches found"
echo ""
echo "=== Occurrences of session.get\(\"userName\"\) ==="
rg -n "session\.get\(\"userName\"" --type=py || echo "No matches found"
echo ""
echo "=== Occurrences of session.get\(\"username\"\) ==="
rg -n "session\.get\(\"username\"" --type=py || echo "No matches found"
echo ""
echo "=== All session key references (for context) ==="
rg -n 'session\[' --type=py | head -40Repository: DogukanUrker/FlaskBlog
Length of output: 7209
Session key usage violates coding guideline but is systemic across codebase.
The code uses session["username"] (line 54), which conflicts with the coding guideline requiring session["userName"]. However, verification reveals this discrepancy is systemic: the entire codebase (40+ locations across all authentication files) consistently uses the lowercase variant. No instances of the guideline-compliant session["userName"] exist anywhere.
The new code correctly follows the established codebase convention, but this represents a guideline-to-implementation mismatch that requires clarification from maintainers: either update the coding guideline to match actual usage, or commit to a codebase-wide migration.
The decorator design itself is sound: clean separation of concerns, proper @wraps usage for Flask endpoint registration, and sensible defaults (language fallback via session.get("language", "en")).
🤖 Prompt for AI Agents
In `@app/utils/route_guards.py` around lines 10 - 44, The decorator uses
session["username"] which conflicts with the coding guideline's
session["userName"] but the rest of the codebase consistently uses the lowercase
"username"; resolve the mismatch by choosing one canonical key and making code +
docs consistent: either (A) change this decorator to use session.get("userName")
and perform a project-wide replace of session["username"] -> session["userName"]
(update any tests and auth code that read/write the session), or (B) accept the
existing lowercase key by updating the coding guideline and any docs to declare
"username" as the canonical session key; locate the decorator function
login_required and the other auth read/write sites to apply the chosen
consistent change.
| ## CI | ||
|
|
||
| - Push to `main` | ||
| - Pull requests | ||
| E2E tests run in GitHub Actions (`.github/workflows/e2e-tests.yaml`) on: | ||
|
|
||
| See `.github/workflows/e2e-tests.yml` for configuration. | ||
| - Push to `main` (when `app/**`, `tests/**`, or workflow file changes) |
There was a problem hiding this comment.
Capitalize “GitHub” in the CI section.
Use “GitHub Actions” to match the official product name.
🧰 Tools
🪛 LanguageTool
[uncategorized] ~188-~188: The official name of this software platform is spelled with a capital “H”.
Context: ...## CI E2E tests run in GitHub Actions (.github/workflows/e2e-tests.yaml) on: - Push ...
(GITHUB)
🤖 Prompt for AI Agents
In `@tests/README.md` around lines 186 - 190, In the CI section of tests/README.md
update the product name to use correct capitalization: change any occurrence of
"github actions" or "github" to "GitHub Actions" (and "GitHub" where used alone)
so the CI heading and the sentence "E2E tests run in GitHub Actions
(`.github/workflows/e2e-tests.yaml`) on:" use the official product casing.
Rename app/utils/forms/change_user_name_form.py to app/utils/forms/change_username_form.py for naming consistency. No code changes were made; update any imports or references to the old filename as needed.
fixes #222
Summary by CodeRabbit
Bug Fixes
Tests
Documentation