From d89ac579f226c747b7de1e4657a21da64b58839f Mon Sep 17 00:00:00 2001 From: Kevin Chung Date: Mon, 5 Dec 2022 00:10:30 -0500 Subject: [PATCH] Cache challenge data for faster loading of /api/v1/challenges (#2232) * Improve response time of `/api/v1/challenges` and `/api/v1/challenges/[challenge_id]/solves` * Rewrite and remove _build_solves_query to make it cacheable * Closes #2209 --- CTFd/admin/__init__.py | 9 +- CTFd/api/v1/challenges.py | 168 +++++++++--------------------- CTFd/api/v1/config.py | 6 +- CTFd/api/v1/submissions.py | 5 +- CTFd/api/v1/teams.py | 11 +- CTFd/api/v1/users.py | 6 +- CTFd/cache/__init__.py | 12 +++ CTFd/utils/challenges/__init__.py | 126 ++++++++++++++++++++++ populate.py | 3 +- tests/api/v1/test_challenges.py | 10 +- tests/cache/test_challenges.py | 128 +++++++++++++++++++++++ tests/helpers.py | 4 +- 12 files changed, 356 insertions(+), 132 deletions(-) create mode 100644 CTFd/utils/challenges/__init__.py create mode 100644 tests/cache/test_challenges.py diff --git a/CTFd/admin/__init__.py b/CTFd/admin/__init__.py index 20f96ef6..dedb2ea1 100644 --- a/CTFd/admin/__init__.py +++ b/CTFd/admin/__init__.py @@ -26,7 +26,13 @@ from CTFd.admin import statistics # noqa: F401 from CTFd.admin import submissions # noqa: F401 from CTFd.admin import teams # noqa: F401 from CTFd.admin import users # noqa: F401 -from CTFd.cache import cache, clear_config, clear_pages, clear_standings +from CTFd.cache import ( + cache, + clear_challenges, + clear_config, + clear_pages, + clear_standings, +) from CTFd.models import ( Awards, Challenges, @@ -238,6 +244,7 @@ def reset(): clear_pages() clear_standings() + clear_challenges() clear_config() if logout is True: diff --git a/CTFd/api/v1/challenges.py b/CTFd/api/v1/challenges.py index 8d141a1c..7cf734c8 100644 --- a/CTFd/api/v1/challenges.py +++ b/CTFd/api/v1/challenges.py @@ -1,15 +1,13 @@ -import datetime from typing import List from flask import abort, render_template, request, url_for from flask_restx import Namespace, Resource -from sqlalchemy import func as sa_func -from sqlalchemy.sql import and_, false, true +from sqlalchemy.sql import and_ from CTFd.api.v1.helpers.request import validate_args from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse -from CTFd.cache import clear_standings +from CTFd.cache import clear_challenges, clear_standings from CTFd.constants import RawEnum from CTFd.models import ChallengeFiles as ChallengeFilesModel from CTFd.models import Challenges @@ -22,12 +20,18 @@ from CTFd.schemas.hints import HintSchema from CTFd.schemas.tags import TagSchema from CTFd.utils import config, get_config from CTFd.utils import user as current_user +from CTFd.utils.challenges import ( + get_all_challenges, + get_solve_counts_for_challenges, + get_solve_ids_for_user_id, + get_solves_for_challenge_id, +) from CTFd.utils.config.visibility import ( accounts_visible, challenges_visible, scores_visible, ) -from CTFd.utils.dates import ctf_ended, ctf_paused, ctftime, isoformat, unix_time_to_utc +from CTFd.utils.dates import ctf_ended, ctf_paused, ctftime from CTFd.utils.decorators import ( admins_only, during_ctf_time_only, @@ -37,9 +41,7 @@ from CTFd.utils.decorators.visibility import ( check_challenge_visibility, check_score_visibility, ) -from CTFd.utils.helpers.models import build_model_filters from CTFd.utils.logging import log -from CTFd.utils.modes import generate_account_url, get_model from CTFd.utils.security.signing import serialize from CTFd.utils.user import ( authed, @@ -77,60 +79,6 @@ challenges_namespace.schema_model( ) -def _build_solves_query(extra_filters=(), admin_view=False): - """Returns queries and data that that are used for showing an account's solves. - It returns a tuple of - - SQLAlchemy query with (challenge_id, solve_count_for_challenge_id) - - Current user's solved challenge IDs - """ - # This can return None (unauth) if visibility is set to public - user = get_current_user() - # We only set a condition for matching user solves if there is a user and - # they have an account ID (user mode or in a team in teams mode) - AccountModel = get_model() - if user is not None and user.account_id is not None: - user_solved_cond = Solves.account_id == user.account_id - else: - user_solved_cond = false() - # We have to filter solves to exclude any made after the current freeze - # time unless we're in an admin view as determined by the caller. - freeze = get_config("freeze") - if freeze and not admin_view: - freeze_cond = Solves.date < unix_time_to_utc(freeze) - else: - freeze_cond = true() - # Finally, we never count solves made by hidden or banned users/teams, even - # if we are an admin. This is to match the challenge detail API. - exclude_solves_cond = and_( - AccountModel.banned == false(), AccountModel.hidden == false(), - ) - # This query counts the number of solves per challenge, as well as the sum - # of correct solves made by the current user per the condition above (which - # should probably only be 0 or 1!) - solves_q = ( - db.session.query(Solves.challenge_id, sa_func.count(Solves.challenge_id),) - .join(AccountModel) - .filter(*extra_filters, freeze_cond, exclude_solves_cond) - .group_by(Solves.challenge_id) - ) - # Also gather the user's solve items which can be different from above query - # For example, even if we are a hidden user, we should see that we have solved a challenge - # however as a hidden user we are not included in the count of the above query - if admin_view: - # If we're an admin we should show all challenges as solved to break through any requirements - challenges = Challenges.query.all() - solve_ids = {challenge.id for challenge in challenges} - else: - # If not an admin we calculate solves as normal - solve_ids = ( - Solves.query.with_entities(Solves.challenge_id) - .filter(user_solved_cond) - .all() - ) - solve_ids = {value for value, in solve_ids} - return solves_q, solve_ids - - @challenges_namespace.route("") class ChallengeList(Resource): @check_challenge_visibility @@ -185,23 +133,22 @@ class ChallengeList(Resource): # Build filtering queries q = query_args.pop("q", None) field = str(query_args.pop("field", None)) - filters = build_model_filters(model=Challenges, query=q, field=field) # Admins get a shortcut to see all challenges despite pre-requisites admin_view = is_admin() and request.args.get("view") == "admin" - solve_counts = {} - # Build a query for to show challenge solve information. We only - # give an admin view if the request argument has been provided. - # - # NOTE: This is different behaviour to the challenge detail - # endpoint which only needs the current user to be an admin rather - # than also also having to provide `view=admin` as a query arg. - solves_q, user_solves = _build_solves_query(admin_view=admin_view) + # Get a cached mapping of challenge_id to solve_count + solve_counts = get_solve_counts_for_challenges(admin=admin_view) + + # Get list of solve_ids for current user + if authed(): + user = get_current_user() + user_solves = get_solve_ids_for_user_id(user_id=user.id) + else: + user_solves = set() + # Aggregate the query results into the hashes defined at the top of # this block for later use - for chal_id, solve_count in solves_q: - solve_counts[chal_id] = solve_count if scores_visible() and accounts_visible(): solve_count_dfl = 0 else: @@ -211,18 +158,7 @@ class ChallengeList(Resource): # `None` for the solve count if visiblity checks fail solve_count_dfl = None - # Build the query for the challenges which may be listed - chal_q = Challenges.query - # Admins can see hidden and locked challenges in the admin view - if admin_view is False: - chal_q = chal_q.filter( - and_(Challenges.state != "hidden", Challenges.state != "locked") - ) - chal_q = ( - chal_q.filter_by(**query_args) - .filter(*filters) - .order_by(Challenges.value, Challenges.id) - ) + chal_q = get_all_challenges(admin=admin_view, field=field, q=q, **query_args) # Iterate through the list of challenges, adding to the object which # will be JSONified back to the client @@ -308,6 +244,9 @@ class ChallengeList(Resource): challenge_class = get_chal_class(challenge_type) challenge = challenge_class.create(request) response = challenge_class.read(challenge) + + clear_challenges() + return {"success": True, "data": response} @@ -453,13 +392,17 @@ class Challenge(Resource): response = chal_class.read(challenge=chal) - solves_q, user_solves = _build_solves_query( - extra_filters=(Solves.challenge_id == chal.id,) - ) - # If there are no solves for this challenge ID then we have 0 rows - maybe_row = solves_q.first() - if maybe_row: - challenge_id, solve_count = maybe_row + # Get list of solve_ids for current user + if authed(): + user = get_current_user() + user_solves = get_solve_ids_for_user_id(user_id=user.id) + else: + user_solves = [] + + solves_count = get_solve_counts_for_challenges(challenge_id=chal.id) + if solves_count: + challenge_id = chal.id + solve_count = solves_count.get(chal.id) solved_by_user = challenge_id in user_solves else: solve_count, solved_by_user = 0, False @@ -522,6 +465,10 @@ class Challenge(Resource): challenge_class = get_chal_class(challenge.type) challenge = challenge_class.update(challenge, request) response = challenge_class.read(challenge) + + clear_standings() + clear_challenges() + return {"success": True, "data": response} @admins_only @@ -534,6 +481,9 @@ class Challenge(Resource): chal_class = get_chal_class(challenge.type) chal_class.delete(challenge) + clear_standings() + clear_challenges() + return {"success": True} @@ -675,6 +625,7 @@ class ChallengeAttempt(Resource): user=user, team=team, challenge=challenge, request=request ) clear_standings() + clear_challenges() log( "submissions", @@ -694,6 +645,7 @@ class ChallengeAttempt(Resource): user=user, team=team, challenge=challenge, request=request ) clear_standings() + clear_challenges() log( "submissions", @@ -762,41 +714,15 @@ class ChallengeSolves(Resource): if challenge.state == "hidden" and is_admin() is False: abort(404) - Model = get_model() - - # Note that we specifically query for the Solves.account.name - # attribute here because it is faster than having SQLAlchemy - # query for the attribute directly and it's unknown what the - # affects of changing the relationship lazy attribute would be - solves = ( - Solves.query.add_columns(Model.name.label("account_name")) - .join(Model, Solves.account_id == Model.id) - .filter( - Solves.challenge_id == challenge_id, - Model.banned == False, - Model.hidden == False, - ) - .order_by(Solves.date.asc()) - ) - freeze = get_config("freeze") if freeze: preview = request.args.get("preview") if (is_admin() is False) or (is_admin() is True and preview): - dt = datetime.datetime.utcfromtimestamp(freeze) - solves = solves.filter(Solves.date < dt) + freeze = True + elif is_admin() is True: + freeze = False - for solve in solves: - # Seperate out the account name and the Solve object from the SQLAlchemy tuple - solve, account_name = solve - response.append( - { - "account_id": solve.account_id, - "name": account_name, - "date": isoformat(solve.date), - "account_url": generate_account_url(account_id=solve.account_id), - } - ) + response = get_solves_for_challenge_id(challenge_id=challenge_id, freeze=freeze) return {"success": True, "data": response} diff --git a/CTFd/api/v1/config.py b/CTFd/api/v1/config.py index 9a734717..6401508a 100644 --- a/CTFd/api/v1/config.py +++ b/CTFd/api/v1/config.py @@ -6,7 +6,7 @@ from flask_restx import Namespace, Resource from CTFd.api.v1.helpers.request import validate_args from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse -from CTFd.cache import clear_config, clear_standings +from CTFd.cache import clear_challenges, clear_config, clear_standings from CTFd.constants import RawEnum from CTFd.models import Configs, Fields, db from CTFd.schemas.config import ConfigSchema @@ -99,6 +99,7 @@ class ConfigList(Resource): clear_config() clear_standings() + clear_challenges() return {"success": True, "data": response.data} @@ -119,6 +120,7 @@ class ConfigList(Resource): clear_config() clear_standings() + clear_challenges() return {"success": True} @@ -175,6 +177,7 @@ class Config(Resource): clear_config() clear_standings() + clear_challenges() return {"success": True, "data": response.data} @@ -192,6 +195,7 @@ class Config(Resource): clear_config() clear_standings() + clear_challenges() return {"success": True} diff --git a/CTFd/api/v1/submissions.py b/CTFd/api/v1/submissions.py index d2b9b051..a5494d30 100644 --- a/CTFd/api/v1/submissions.py +++ b/CTFd/api/v1/submissions.py @@ -8,7 +8,7 @@ from CTFd.api.v1.schemas import ( APIDetailedSuccessResponse, PaginatedAPIListSuccessResponse, ) -from CTFd.cache import clear_standings +from CTFd.cache import clear_challenges, clear_standings from CTFd.constants import RawEnum from CTFd.models import Submissions, db from CTFd.schemas.submissions import SubmissionSchema @@ -141,6 +141,8 @@ class SubmissionsList(Resource): # Delete standings cache clear_standings() + # Delete challenges cache + clear_challenges() return {"success": True, "data": response.data} @@ -188,5 +190,6 @@ class Submission(Resource): # Delete standings cache clear_standings() + clear_challenges() return {"success": True} diff --git a/CTFd/api/v1/teams.py b/CTFd/api/v1/teams.py index 487220ad..a3a2ea37 100644 --- a/CTFd/api/v1/teams.py +++ b/CTFd/api/v1/teams.py @@ -10,7 +10,12 @@ from CTFd.api.v1.schemas import ( APIDetailedSuccessResponse, PaginatedAPIListSuccessResponse, ) -from CTFd.cache import clear_standings, clear_team_session, clear_user_session +from CTFd.cache import ( + clear_challenges, + clear_standings, + clear_team_session, + clear_user_session, +) from CTFd.constants import RawEnum from CTFd.models import Awards, Submissions, Teams, Unlocks, Users, db from CTFd.schemas.awards import AwardSchema @@ -155,6 +160,7 @@ class TeamList(Resource): db.session.close() clear_standings() + clear_challenges() return {"success": True, "data": response.data} @@ -220,6 +226,7 @@ class TeamPublic(Resource): clear_team_session(team_id=team.id) clear_standings() + clear_challenges() db.session.close() @@ -243,6 +250,7 @@ class TeamPublic(Resource): clear_team_session(team_id=team_id) clear_standings() + clear_challenges() db.session.close() @@ -375,6 +383,7 @@ class TeamPrivate(Resource): clear_team_session(team_id=team.id) clear_standings() + clear_challenges() db.session.close() diff --git a/CTFd/api/v1/users.py b/CTFd/api/v1/users.py index 85065d56..954c8ee9 100644 --- a/CTFd/api/v1/users.py +++ b/CTFd/api/v1/users.py @@ -9,7 +9,7 @@ from CTFd.api.v1.schemas import ( APIDetailedSuccessResponse, PaginatedAPIListSuccessResponse, ) -from CTFd.cache import clear_standings, clear_user_session +from CTFd.cache import clear_challenges, clear_standings, clear_user_session from CTFd.constants import RawEnum from CTFd.models import ( Awards, @@ -165,6 +165,7 @@ class UserList(Resource): user_created_notification(addr=email, name=name, password=password) clear_standings() + clear_challenges() response = schema.dump(response.data) @@ -242,6 +243,7 @@ class UserPublic(Resource): clear_user_session(user_id=user_id) clear_standings() + clear_challenges() return {"success": True, "data": response.data} @@ -270,6 +272,7 @@ class UserPublic(Resource): clear_user_session(user_id=user_id) clear_standings() + clear_challenges() return {"success": True} @@ -322,6 +325,7 @@ class UserPrivate(Resource): db.session.close() clear_standings() + clear_challenges() return {"success": True, "data": response.data} diff --git a/CTFd/cache/__init__.py b/CTFd/cache/__init__.py index 366ad764..94d7bcf0 100644 --- a/CTFd/cache/__init__.py +++ b/CTFd/cache/__init__.py @@ -98,6 +98,18 @@ def clear_standings(): cache.delete(make_template_fragment_key(CacheKeys.PUBLIC_SCOREBOARD_TABLE)) +def clear_challenges(): + from CTFd.utils.challenges import get_all_challenges + from CTFd.utils.challenges import get_solves_for_challenge_id + from CTFd.utils.challenges import get_solve_ids_for_user_id + from CTFd.utils.challenges import get_solve_counts_for_challenges + + cache.delete_memoized(get_all_challenges) + cache.delete_memoized(get_solves_for_challenge_id) + cache.delete_memoized(get_solve_ids_for_user_id) + cache.delete_memoized(get_solve_counts_for_challenges) + + def clear_pages(): from CTFd.utils.config.pages import get_page, get_pages diff --git a/CTFd/utils/challenges/__init__.py b/CTFd/utils/challenges/__init__.py new file mode 100644 index 00000000..83a8b0a9 --- /dev/null +++ b/CTFd/utils/challenges/__init__.py @@ -0,0 +1,126 @@ +import datetime +from collections import namedtuple + +from sqlalchemy import func as sa_func +from sqlalchemy.sql import and_, false, true + +from CTFd.cache import cache +from CTFd.models import Challenges, Solves, Users, db +from CTFd.schemas.tags import TagSchema +from CTFd.utils import get_config +from CTFd.utils.dates import isoformat, unix_time_to_utc +from CTFd.utils.helpers.models import build_model_filters +from CTFd.utils.modes import generate_account_url, get_model + +Challenge = namedtuple( + "Challenge", ["id", "type", "name", "value", "category", "tags", "requirements"] +) + + +@cache.memoize(timeout=60) +def get_all_challenges(admin=False, field=None, q=None, **query_args): + filters = build_model_filters(model=Challenges, query=q, field=field) + chal_q = Challenges.query + # Admins can see hidden and locked challenges in the admin view + if admin is False: + chal_q = chal_q.filter( + and_(Challenges.state != "hidden", Challenges.state != "locked") + ) + chal_q = ( + chal_q.filter_by(**query_args) + .filter(*filters) + .order_by(Challenges.value, Challenges.id) + ) + tag_schema = TagSchema(view="user", many=True) + + results = [] + for c in chal_q: + ct = Challenge( + id=c.id, + type=c.type, + name=c.name, + value=c.value, + category=c.category, + requirements=c.requirements, + tags=tag_schema.dump(c.tags).data, + ) + results.append(ct) + return results + + +@cache.memoize(timeout=60) +def get_solves_for_challenge_id(challenge_id, freeze=False): + Model = get_model() + # Note that we specifically query for the Solves.account.name + # attribute here because it is faster than having SQLAlchemy + # query for the attribute directly and it's unknown what the + # affects of changing the relationship lazy attribute would be + solves = ( + Solves.query.add_columns(Model.name.label("account_name")) + .join(Model, Solves.account_id == Model.id) + .filter( + Solves.challenge_id == challenge_id, + Model.banned == False, + Model.hidden == False, + ) + .order_by(Solves.date.asc()) + ) + if freeze: + freeze_time = get_config("freeze") + if freeze_time: + dt = datetime.datetime.utcfromtimestamp(freeze_time) + solves = solves.filter(Solves.date < dt) + results = [] + + for solve in solves: + # Seperate out the account name and the Solve object from the SQLAlchemy tuple + solve, account_name = solve + results.append( + { + "account_id": solve.account_id, + "name": account_name, + "date": isoformat(solve.date), + "account_url": generate_account_url(account_id=solve.account_id), + } + ) + return results + + +@cache.memoize(timeout=60) +def get_solve_ids_for_user_id(user_id): + user = Users.query.filter_by(id=user_id).first() + solve_ids = ( + Solves.query.with_entities(Solves.challenge_id) + .filter(Solves.account_id == user.account_id) + .all() + ) + solve_ids = {value for value, in solve_ids} + return solve_ids + + +@cache.memoize(timeout=60) +def get_solve_counts_for_challenges(challenge_id=None, admin=False): + if challenge_id is None: + challenge_id_filter = () + else: + challenge_id_filter = (Solves.challenge_id == challenge_id,) + AccountModel = get_model() + freeze = get_config("freeze") + if freeze and not admin: + freeze_cond = Solves.date < unix_time_to_utc(freeze) + else: + freeze_cond = true() + exclude_solves_cond = and_( + AccountModel.banned == false(), AccountModel.hidden == false(), + ) + solves_q = ( + db.session.query(Solves.challenge_id, sa_func.count(Solves.challenge_id),) + .join(AccountModel) + .filter(*challenge_id_filter, freeze_cond, exclude_solves_cond) + .group_by(Solves.challenge_id) + ) + + solve_counts = {} + for chal_id, solve_count in solves_q: + solve_counts[chal_id] = solve_count + return solve_counts diff --git a/populate.py b/populate.py index af669a65..c54450d9 100644 --- a/populate.py +++ b/populate.py @@ -7,7 +7,7 @@ import random import argparse from CTFd import create_app -from CTFd.cache import clear_config, clear_standings, clear_pages +from CTFd.cache import clear_challenges, clear_config, clear_standings, clear_pages from CTFd.models import ( Users, Teams, @@ -352,4 +352,5 @@ if __name__ == "__main__": clear_config() clear_standings() + clear_challenges() clear_pages() diff --git a/tests/api/v1/test_challenges.py b/tests/api/v1/test_challenges.py index 39ecd49a..4eb25d8f 100644 --- a/tests/api/v1/test_challenges.py +++ b/tests/api/v1/test_challenges.py @@ -394,8 +394,9 @@ def test_api_challenges_get_solve_count_banned_user(): assert chal_data["solves"] == 1 # Ban the user - Users.query.get(2).banned = True - app.db.session.commit() + with login_as_user(app, name="admin") as client: + r = client.patch("/api/v1/users/2", json={"banned": True}) + assert Users.query.get(2).banned == True with app.test_client() as client: # Confirm solve count is `0` despite the banned user having solved @@ -823,8 +824,9 @@ def test_api_challenge_get_solve_count_banned_user(): assert chal_data["solves"] == 1 # Ban the user - Users.query.get(2).banned = True - app.db.session.commit() + with login_as_user(app, name="admin") as client: + r = client.patch("/api/v1/users/2", json={"banned": True}) + assert Users.query.get(2).banned == True # Confirm solve count is `0` despite the banned user having solved with app.test_client() as client: diff --git a/tests/cache/test_challenges.py b/tests/cache/test_challenges.py new file mode 100644 index 00000000..73093d5c --- /dev/null +++ b/tests/cache/test_challenges.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from CTFd.models import Users +from tests.helpers import ( + create_ctfd, + destroy_ctfd, + login_as_user, + register_user, + simulate_user_activity, +) + + +def test_adding_challenge_clears_cache(): + """ + Test that when we add a challenge, it appears in our challenge list + """ + app = create_ctfd() + with app.app_context(): + register_user(app) + + with login_as_user(app) as client, login_as_user( + app, name="admin", password="password" + ) as admin: + req = client.get("/api/v1/challenges") + data = req.get_json() + assert data["data"] == [] + + challenge_data = { + "name": "name", + "category": "category", + "description": "description", + "value": 100, + "state": "visible", + "type": "standard", + } + + r = admin.post("/api/v1/challenges", json=challenge_data) + assert r.get_json().get("data")["id"] == 1 + + req = client.get("/api/v1/challenges") + data = req.get_json() + assert data["data"] != [] + destroy_ctfd(app) + + +def test_deleting_challenge_clears_cache_solves(): + """ + Test that deleting a challenge clears the cached solves for the challenge + """ + app = create_ctfd() + with app.app_context(): + register_user(app) + user = Users.query.filter_by(id=2).first() + simulate_user_activity(app.db, user) + with login_as_user(app) as client, login_as_user( + app, name="admin", password="password" + ) as admin: + req = client.get("/api/v1/challenges") + data = req.get_json()["data"] + challenge = data[0] + assert challenge["solves"] == 1 + from CTFd.utils.challenges import ( + get_solves_for_challenge_id, + get_solve_counts_for_challenges, + ) + + solves = get_solves_for_challenge_id(1) + solve_counts = get_solve_counts_for_challenges() + solves_req = client.get("/api/v1/challenges/1/solves").get_json()["data"] + assert len(solves_req) == 1 + assert len(solves) == 1 + assert solve_counts[1] == 1 + + r = admin.delete("/api/v1/challenges/1", json="") + assert r.status_code == 200 + + solve_counts = get_solve_counts_for_challenges() + solves = get_solves_for_challenge_id(1) + r = client.get("/api/v1/challenges/1/solves") + assert r.status_code == 404 + assert len(solves) == 0 + assert solve_counts.get(1) is None + destroy_ctfd(app) + + +def test_deleting_solve_clears_cache(): + """ + Test that deleting a solve clears out the solve count cache + """ + app = create_ctfd() + with app.app_context(): + register_user(app) + user = Users.query.filter_by(id=2).first() + simulate_user_activity(app.db, user) + with login_as_user(app) as client, login_as_user( + app, name="admin", password="password" + ) as admin: + req = client.get("/api/v1/challenges") + data = req.get_json()["data"] + challenge = data[0] + assert challenge["solves"] == 1 + from CTFd.utils.challenges import ( + get_solves_for_challenge_id, + get_solve_counts_for_challenges, + ) + + solves = get_solves_for_challenge_id(1) + solve_counts = get_solve_counts_for_challenges() + solves_req = client.get("/api/v1/challenges/1/solves").get_json()["data"] + assert len(solves_req) == 1 + assert len(solves) == 1 + assert solve_counts[1] == 1 + + r = admin.get("/api/v1/submissions/6", json="") + assert r.get_json()["data"]["type"] == "correct" + r = admin.delete("/api/v1/submissions/6", json="") + assert r.status_code == 200 + r = admin.get("/api/v1/submissions/6", json="") + assert r.status_code == 404 + + solve_counts = get_solve_counts_for_challenges() + solves = get_solves_for_challenge_id(1) + solves_req = client.get("/api/v1/challenges/1/solves").get_json()["data"] + assert len(solves_req) == 0 + assert len(solves) == 0 + assert solve_counts.get(1) is None + destroy_ctfd(app) diff --git a/tests/helpers.py b/tests/helpers.py index 2037dfd4..665f321d 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -15,7 +15,7 @@ from sqlalchemy_utils import drop_database from werkzeug.datastructures import Headers from CTFd import create_app -from CTFd.cache import cache, clear_standings +from CTFd.cache import cache, clear_challenges, clear_standings from CTFd.config import TestingConfig from CTFd.models import ( Awards, @@ -336,6 +336,7 @@ def gen_challenge( ) db.session.add(chal) db.session.commit() + clear_challenges() return chal @@ -455,6 +456,7 @@ def gen_solve( db.session.add(solve) db.session.commit() clear_standings() + clear_challenges() return solve