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
This commit is contained in:
Kevin Chung
2022-12-05 00:10:30 -05:00
committed by GitHub
parent 800fb8260a
commit d89ac579f2
12 changed files with 356 additions and 132 deletions

View File

@@ -26,7 +26,13 @@ from CTFd.admin import statistics # noqa: F401
from CTFd.admin import submissions # noqa: F401 from CTFd.admin import submissions # noqa: F401
from CTFd.admin import teams # noqa: F401 from CTFd.admin import teams # noqa: F401
from CTFd.admin import users # 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 ( from CTFd.models import (
Awards, Awards,
Challenges, Challenges,
@@ -238,6 +244,7 @@ def reset():
clear_pages() clear_pages()
clear_standings() clear_standings()
clear_challenges()
clear_config() clear_config()
if logout is True: if logout is True:

View File

@@ -1,15 +1,13 @@
import datetime
from typing import List from typing import List
from flask import abort, render_template, request, url_for from flask import abort, render_template, request, url_for
from flask_restx import Namespace, Resource from flask_restx import Namespace, Resource
from sqlalchemy import func as sa_func from sqlalchemy.sql import and_
from sqlalchemy.sql import and_, false, true
from CTFd.api.v1.helpers.request import validate_args from CTFd.api.v1.helpers.request import validate_args
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse 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.constants import RawEnum
from CTFd.models import ChallengeFiles as ChallengeFilesModel from CTFd.models import ChallengeFiles as ChallengeFilesModel
from CTFd.models import Challenges from CTFd.models import Challenges
@@ -22,12 +20,18 @@ from CTFd.schemas.hints import HintSchema
from CTFd.schemas.tags import TagSchema from CTFd.schemas.tags import TagSchema
from CTFd.utils import config, get_config from CTFd.utils import config, get_config
from CTFd.utils import user as current_user 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 ( from CTFd.utils.config.visibility import (
accounts_visible, accounts_visible,
challenges_visible, challenges_visible,
scores_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 ( from CTFd.utils.decorators import (
admins_only, admins_only,
during_ctf_time_only, during_ctf_time_only,
@@ -37,9 +41,7 @@ from CTFd.utils.decorators.visibility import (
check_challenge_visibility, check_challenge_visibility,
check_score_visibility, check_score_visibility,
) )
from CTFd.utils.helpers.models import build_model_filters
from CTFd.utils.logging import log 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.security.signing import serialize
from CTFd.utils.user import ( from CTFd.utils.user import (
authed, 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("") @challenges_namespace.route("")
class ChallengeList(Resource): class ChallengeList(Resource):
@check_challenge_visibility @check_challenge_visibility
@@ -185,23 +133,22 @@ class ChallengeList(Resource):
# Build filtering queries # Build filtering queries
q = query_args.pop("q", None) q = query_args.pop("q", None)
field = str(query_args.pop("field", 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 # Admins get a shortcut to see all challenges despite pre-requisites
admin_view = is_admin() and request.args.get("view") == "admin" admin_view = is_admin() and request.args.get("view") == "admin"
solve_counts = {} # Get a cached mapping of challenge_id to solve_count
# Build a query for to show challenge solve information. We only solve_counts = get_solve_counts_for_challenges(admin=admin_view)
# give an admin view if the request argument has been provided.
# # Get list of solve_ids for current user
# NOTE: This is different behaviour to the challenge detail if authed():
# endpoint which only needs the current user to be an admin rather user = get_current_user()
# than also also having to provide `view=admin` as a query arg. user_solves = get_solve_ids_for_user_id(user_id=user.id)
solves_q, user_solves = _build_solves_query(admin_view=admin_view) else:
user_solves = set()
# Aggregate the query results into the hashes defined at the top of # Aggregate the query results into the hashes defined at the top of
# this block for later use # 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(): if scores_visible() and accounts_visible():
solve_count_dfl = 0 solve_count_dfl = 0
else: else:
@@ -211,18 +158,7 @@ class ChallengeList(Resource):
# `None` for the solve count if visiblity checks fail # `None` for the solve count if visiblity checks fail
solve_count_dfl = None solve_count_dfl = None
# Build the query for the challenges which may be listed chal_q = get_all_challenges(admin=admin_view, field=field, q=q, **query_args)
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)
)
# Iterate through the list of challenges, adding to the object which # Iterate through the list of challenges, adding to the object which
# will be JSONified back to the client # will be JSONified back to the client
@@ -308,6 +244,9 @@ class ChallengeList(Resource):
challenge_class = get_chal_class(challenge_type) challenge_class = get_chal_class(challenge_type)
challenge = challenge_class.create(request) challenge = challenge_class.create(request)
response = challenge_class.read(challenge) response = challenge_class.read(challenge)
clear_challenges()
return {"success": True, "data": response} return {"success": True, "data": response}
@@ -453,13 +392,17 @@ class Challenge(Resource):
response = chal_class.read(challenge=chal) response = chal_class.read(challenge=chal)
solves_q, user_solves = _build_solves_query( # Get list of solve_ids for current user
extra_filters=(Solves.challenge_id == chal.id,) if authed():
) user = get_current_user()
# If there are no solves for this challenge ID then we have 0 rows user_solves = get_solve_ids_for_user_id(user_id=user.id)
maybe_row = solves_q.first() else:
if maybe_row: user_solves = []
challenge_id, solve_count = maybe_row
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 solved_by_user = challenge_id in user_solves
else: else:
solve_count, solved_by_user = 0, False solve_count, solved_by_user = 0, False
@@ -522,6 +465,10 @@ class Challenge(Resource):
challenge_class = get_chal_class(challenge.type) challenge_class = get_chal_class(challenge.type)
challenge = challenge_class.update(challenge, request) challenge = challenge_class.update(challenge, request)
response = challenge_class.read(challenge) response = challenge_class.read(challenge)
clear_standings()
clear_challenges()
return {"success": True, "data": response} return {"success": True, "data": response}
@admins_only @admins_only
@@ -534,6 +481,9 @@ class Challenge(Resource):
chal_class = get_chal_class(challenge.type) chal_class = get_chal_class(challenge.type)
chal_class.delete(challenge) chal_class.delete(challenge)
clear_standings()
clear_challenges()
return {"success": True} return {"success": True}
@@ -675,6 +625,7 @@ class ChallengeAttempt(Resource):
user=user, team=team, challenge=challenge, request=request user=user, team=team, challenge=challenge, request=request
) )
clear_standings() clear_standings()
clear_challenges()
log( log(
"submissions", "submissions",
@@ -694,6 +645,7 @@ class ChallengeAttempt(Resource):
user=user, team=team, challenge=challenge, request=request user=user, team=team, challenge=challenge, request=request
) )
clear_standings() clear_standings()
clear_challenges()
log( log(
"submissions", "submissions",
@@ -762,41 +714,15 @@ class ChallengeSolves(Resource):
if challenge.state == "hidden" and is_admin() is False: if challenge.state == "hidden" and is_admin() is False:
abort(404) 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") freeze = get_config("freeze")
if freeze: if freeze:
preview = request.args.get("preview") preview = request.args.get("preview")
if (is_admin() is False) or (is_admin() is True and preview): if (is_admin() is False) or (is_admin() is True and preview):
dt = datetime.datetime.utcfromtimestamp(freeze) freeze = True
solves = solves.filter(Solves.date < dt) elif is_admin() is True:
freeze = False
for solve in solves: response = get_solves_for_challenge_id(challenge_id=challenge_id, freeze=freeze)
# 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),
}
)
return {"success": True, "data": response} return {"success": True, "data": response}

View File

@@ -6,7 +6,7 @@ from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.request import validate_args from CTFd.api.v1.helpers.request import validate_args
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse 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.constants import RawEnum
from CTFd.models import Configs, Fields, db from CTFd.models import Configs, Fields, db
from CTFd.schemas.config import ConfigSchema from CTFd.schemas.config import ConfigSchema
@@ -99,6 +99,7 @@ class ConfigList(Resource):
clear_config() clear_config()
clear_standings() clear_standings()
clear_challenges()
return {"success": True, "data": response.data} return {"success": True, "data": response.data}
@@ -119,6 +120,7 @@ class ConfigList(Resource):
clear_config() clear_config()
clear_standings() clear_standings()
clear_challenges()
return {"success": True} return {"success": True}
@@ -175,6 +177,7 @@ class Config(Resource):
clear_config() clear_config()
clear_standings() clear_standings()
clear_challenges()
return {"success": True, "data": response.data} return {"success": True, "data": response.data}
@@ -192,6 +195,7 @@ class Config(Resource):
clear_config() clear_config()
clear_standings() clear_standings()
clear_challenges()
return {"success": True} return {"success": True}

View File

@@ -8,7 +8,7 @@ from CTFd.api.v1.schemas import (
APIDetailedSuccessResponse, APIDetailedSuccessResponse,
PaginatedAPIListSuccessResponse, PaginatedAPIListSuccessResponse,
) )
from CTFd.cache import clear_standings from CTFd.cache import clear_challenges, clear_standings
from CTFd.constants import RawEnum from CTFd.constants import RawEnum
from CTFd.models import Submissions, db from CTFd.models import Submissions, db
from CTFd.schemas.submissions import SubmissionSchema from CTFd.schemas.submissions import SubmissionSchema
@@ -141,6 +141,8 @@ class SubmissionsList(Resource):
# Delete standings cache # Delete standings cache
clear_standings() clear_standings()
# Delete challenges cache
clear_challenges()
return {"success": True, "data": response.data} return {"success": True, "data": response.data}
@@ -188,5 +190,6 @@ class Submission(Resource):
# Delete standings cache # Delete standings cache
clear_standings() clear_standings()
clear_challenges()
return {"success": True} return {"success": True}

View File

@@ -10,7 +10,12 @@ from CTFd.api.v1.schemas import (
APIDetailedSuccessResponse, APIDetailedSuccessResponse,
PaginatedAPIListSuccessResponse, 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.constants import RawEnum
from CTFd.models import Awards, Submissions, Teams, Unlocks, Users, db from CTFd.models import Awards, Submissions, Teams, Unlocks, Users, db
from CTFd.schemas.awards import AwardSchema from CTFd.schemas.awards import AwardSchema
@@ -155,6 +160,7 @@ class TeamList(Resource):
db.session.close() db.session.close()
clear_standings() clear_standings()
clear_challenges()
return {"success": True, "data": response.data} return {"success": True, "data": response.data}
@@ -220,6 +226,7 @@ class TeamPublic(Resource):
clear_team_session(team_id=team.id) clear_team_session(team_id=team.id)
clear_standings() clear_standings()
clear_challenges()
db.session.close() db.session.close()
@@ -243,6 +250,7 @@ class TeamPublic(Resource):
clear_team_session(team_id=team_id) clear_team_session(team_id=team_id)
clear_standings() clear_standings()
clear_challenges()
db.session.close() db.session.close()
@@ -375,6 +383,7 @@ class TeamPrivate(Resource):
clear_team_session(team_id=team.id) clear_team_session(team_id=team.id)
clear_standings() clear_standings()
clear_challenges()
db.session.close() db.session.close()

View File

@@ -9,7 +9,7 @@ from CTFd.api.v1.schemas import (
APIDetailedSuccessResponse, APIDetailedSuccessResponse,
PaginatedAPIListSuccessResponse, 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.constants import RawEnum
from CTFd.models import ( from CTFd.models import (
Awards, Awards,
@@ -165,6 +165,7 @@ class UserList(Resource):
user_created_notification(addr=email, name=name, password=password) user_created_notification(addr=email, name=name, password=password)
clear_standings() clear_standings()
clear_challenges()
response = schema.dump(response.data) response = schema.dump(response.data)
@@ -242,6 +243,7 @@ class UserPublic(Resource):
clear_user_session(user_id=user_id) clear_user_session(user_id=user_id)
clear_standings() clear_standings()
clear_challenges()
return {"success": True, "data": response.data} return {"success": True, "data": response.data}
@@ -270,6 +272,7 @@ class UserPublic(Resource):
clear_user_session(user_id=user_id) clear_user_session(user_id=user_id)
clear_standings() clear_standings()
clear_challenges()
return {"success": True} return {"success": True}
@@ -322,6 +325,7 @@ class UserPrivate(Resource):
db.session.close() db.session.close()
clear_standings() clear_standings()
clear_challenges()
return {"success": True, "data": response.data} return {"success": True, "data": response.data}

View File

@@ -98,6 +98,18 @@ def clear_standings():
cache.delete(make_template_fragment_key(CacheKeys.PUBLIC_SCOREBOARD_TABLE)) 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(): def clear_pages():
from CTFd.utils.config.pages import get_page, get_pages from CTFd.utils.config.pages import get_page, get_pages

View File

@@ -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

View File

@@ -7,7 +7,7 @@ import random
import argparse import argparse
from CTFd import create_app 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 ( from CTFd.models import (
Users, Users,
Teams, Teams,
@@ -352,4 +352,5 @@ if __name__ == "__main__":
clear_config() clear_config()
clear_standings() clear_standings()
clear_challenges()
clear_pages() clear_pages()

View File

@@ -394,8 +394,9 @@ def test_api_challenges_get_solve_count_banned_user():
assert chal_data["solves"] == 1 assert chal_data["solves"] == 1
# Ban the user # Ban the user
Users.query.get(2).banned = True with login_as_user(app, name="admin") as client:
app.db.session.commit() r = client.patch("/api/v1/users/2", json={"banned": True})
assert Users.query.get(2).banned == True
with app.test_client() as client: with app.test_client() as client:
# Confirm solve count is `0` despite the banned user having solved # 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 assert chal_data["solves"] == 1
# Ban the user # Ban the user
Users.query.get(2).banned = True with login_as_user(app, name="admin") as client:
app.db.session.commit() 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 # Confirm solve count is `0` despite the banned user having solved
with app.test_client() as client: with app.test_client() as client:

128
tests/cache/test_challenges.py vendored Normal file
View File

@@ -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)

View File

@@ -15,7 +15,7 @@ from sqlalchemy_utils import drop_database
from werkzeug.datastructures import Headers from werkzeug.datastructures import Headers
from CTFd import create_app 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.config import TestingConfig
from CTFd.models import ( from CTFd.models import (
Awards, Awards,
@@ -336,6 +336,7 @@ def gen_challenge(
) )
db.session.add(chal) db.session.add(chal)
db.session.commit() db.session.commit()
clear_challenges()
return chal return chal
@@ -455,6 +456,7 @@ def gen_solve(
db.session.add(solve) db.session.add(solve)
db.session.commit() db.session.commit()
clear_standings() clear_standings()
clear_challenges()
return solve return solve