Repository: CTFd/CTFd

Issue:
Add solve count to challenge list API
It would be nice if we could add the number of solves to the challenge list API so themes could render things like first blood or medal availability. We can currently request the solves for each challenge but that'd be extremely wasteful and since we already make queries for solves to manage challenge requirements, it doesn't seem unreasonable to also add a count on the challenge ID  in the solves table. Something like:

```
solve_counts = {
    chal_id: solves for (chal_id, solves) in db.session.query(
        Solves.challenge_id, sa_func.count(Solves.challenge_id),
    ).group_by(Solves.challenge_id).all()
}
```

We can also do some trickery to wrap up the check for if the current user/team has solved it like so:
```
solve_counts = {
    chal_id: (solves, user_solved) for (chal_id, solves, user_solved) in db.session.query(
        Solves.challenge_id, sa_func.count(Solves.challenge_id),
        sa_func.sum(Solves.account_id == user.account_id),
    ).group_by(Solves.challenge_id).all()
}
```

which would save us from doing more queries later on.

Would you be interested in a PR for this?


Discussion:

Resolving patch:
diff --git a/CTFd/api/v1/challenges.py b/CTFd/api/v1/challenges.py
--- a/CTFd/api/v1/challenges.py
+++ b/CTFd/api/v1/challenges.py
@@ -3,7 +3,9 @@
 
 from flask import abort, render_template, request, url_for
 from flask_restx import Namespace, Resource
-from sqlalchemy.sql import and_
+from sqlalchemy import func as sa_func
+from sqlalchemy import types as sa_types
+from sqlalchemy.sql import and_, cast, false, true
 
 from CTFd.api.v1.helpers.request import validate_args
 from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
@@ -48,7 +50,14 @@
 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, get_current_team, get_current_user, is_admin
+from CTFd.utils.user import (
+    authed,
+    get_current_team,
+    get_current_team_attrs,
+    get_current_user,
+    get_current_user_attrs,
+    is_admin,
+)
 
 challenges_namespace = Namespace(
     "challenges", description="Endpoint to retrieve Challenges"
@@ -75,6 +84,44 @@ class ChallengeListSuccessResponse(APIListSuccessResponse):
 )
 
 
+def _build_solves_query(extra_filters=(), admin_view=False):
+    # 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)
+    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.
+    AccountModel = get_model()
+    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),
+            sa_func.sum(cast(user_solved_cond, sa_types.Integer)),
+        )
+        .join(AccountModel)
+        .filter(*extra_filters, freeze_cond, exclude_solves_cond)
+        .group_by(Solves.challenge_id)
+    )
+    return solves_q
+
+
 @challenges_namespace.route("")
 class ChallengeList(Resource):
     @check_challenge_visibility
@@ -116,60 +163,68 @@ class ChallengeList(Resource):
         location="query",
     )
     def get(self, query_args):
+        # Require a team if in teams mode
+        # TODO: Convert this into a re-useable decorator
+        # TODO: The require_team decorator doesnt work because of no admin passthru
+        if get_current_user_attrs():
+            if is_admin():
+                pass
+            else:
+                if config.is_teams_mode() and get_current_team_attrs() is None:
+                    abort(403)
+
         # 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)
 
-        # This can return None (unauth) if visibility is set to public
-        user = get_current_user()
-
-        # Admins can request to see everything
-        if is_admin() and request.args.get("view") == "admin":
-            challenges = (
-                Challenges.query.filter_by(**query_args)
-                .filter(*filters)
-                .order_by(Challenges.value)
-                .all()
-            )
-            solve_ids = {challenge.id for challenge in challenges}
+        # Admins get a shortcut to see all challenges despite pre-requisites
+        admin_view = is_admin() and request.args.get("view") == "admin"
+
+        solve_counts, user_solves = {}, set()
+        # 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 = _build_solves_query(admin_view=admin_view)
+        # Aggregate the query results into the hashes defined at the top of
+        # this block for later use
+        for chal_id, solve_count, solved_by_user in solves_q:
+            solve_counts[chal_id] = solve_count
+            if solved_by_user:
+                user_solves.add(chal_id)
+        if scores_visible() and accounts_visible():
+            solve_count_dfl = 0
         else:
-            challenges = (
-                Challenges.query.filter(
-                    and_(Challenges.state != "hidden", Challenges.state != "locked")
-                )
-                .filter_by(**query_args)
-                .filter(*filters)
-                .order_by(Challenges.value)
-                .all()
+            # Empty out the solves_count if we're hiding scores/accounts
+            solve_counts = {}
+            # This is necessary to match the challenge detail API which returns
+            # `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)
+        )
 
-            if user:
-                solve_ids = (
-                    Solves.query.with_entities(Solves.challenge_id)
-                    .filter_by(account_id=user.account_id)
-                    .order_by(Solves.challenge_id.asc())
-                    .all()
-                )
-                solve_ids = {value for value, in solve_ids}
-
-                # TODO: Convert this into a re-useable decorator
-                if is_admin():
-                    pass
-                else:
-                    if config.is_teams_mode() and get_current_team() is None:
-                        abort(403)
-            else:
-                solve_ids = set()
-
+        # Iterate through the list of challenges, adding to the object which
+        # will be JSONified back to the client
         response = []
         tag_schema = TagSchema(view="user", many=True)
-        for challenge in challenges:
+        for challenge in chal_q:
             if challenge.requirements:
                 requirements = challenge.requirements.get("prerequisites", [])
                 anonymize = challenge.requirements.get("anonymize")
                 prereqs = set(requirements)
-                if solve_ids >= prereqs:
+                if user_solves >= prereqs:
                     pass
                 else:
                     if anonymize:
@@ -179,6 +234,8 @@ def get(self, query_args):
                                 "type": "hidden",
                                 "name": "???",
                                 "value": 0,
+                                "solves": None,
+                                "solved_by_me": False,
                                 "category": "???",
                                 "tags": [],
                                 "template": "",
@@ -201,6 +258,8 @@ def get(self, query_args):
                     "type": challenge_type.name,
                     "name": challenge.name,
                     "value": challenge.value,
+                    "solves": solve_counts.get(challenge.id, solve_count_dfl),
+                    "solved_by_me": challenge.id in user_solves,
                     "category": challenge.category,
                     "tags": tag_schema.dump(challenge.tags).data,
                     "template": challenge_type.templates["view"],
@@ -318,6 +377,8 @@ def get(self, challenge_id):
                                 "type": "hidden",
                                 "name": "???",
                                 "value": 0,
+                                "solves": None,
+                                "solved_by_me": False,
                                 "category": "???",
                                 "tags": [],
                                 "template": "",
@@ -374,25 +435,20 @@ def get(self, challenge_id):
 
         response = chal_class.read(challenge=chal)
 
-        Model = get_model()
-
-        if scores_visible() is True and accounts_visible() is True:
-            solves = Solves.query.join(Model, Solves.account_id == Model.id).filter(
-                Solves.challenge_id == chal.id,
-                Model.banned == False,
-                Model.hidden == False,
-            )
-
-            # Only show solves that happened before freeze time if configured
-            freeze = get_config("freeze")
-            if not is_admin() and freeze:
-                solves = solves.filter(Solves.date < unix_time_to_utc(freeze))
-
-            solves = solves.count()
-            response["solves"] = solves
+        solves_q = _build_solves_query(
+            admin_view=is_admin(), 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:
+            _, solve_count, solved_by_user = maybe_row
+            solved_by_user = bool(solved_by_user)
         else:
-            response["solves"] = None
-            solves = None
+            solve_count, solved_by_user = 0, False
+
+        # Hide solve counts if we are hiding solves/accounts
+        if scores_visible() is False or accounts_visible() is False:
+            solve_count = None
 
         if authed():
             # Get current attempts for the user
@@ -402,6 +458,8 @@ def get(self, challenge_id):
         else:
             attempts = 0
 
+        response["solves"] = solve_count
+        response["solved_by_me"] = solved_by_user
         response["attempts"] = attempts
         response["files"] = files
         response["tags"] = tags
@@ -409,7 +467,8 @@ def get(self, challenge_id):
 
         response["view"] = render_template(
             chal_class.templates["view"].lstrip("/"),
-            solves=solves,
+            solves=solve_count,
+            solved_by_me=solved_by_user,
             files=files,
             tags=tags,
             hints=[Hints(**h) for h in hints],


Test patch:
diff --git a/tests/api/v1/test_challenges.py b/tests/api/v1/test_challenges.py
--- a/tests/api/v1/test_challenges.py
+++ b/tests/api/v1/test_challenges.py
@@ -154,6 +154,250 @@ def test_api_challenges_get_hidden_admin():
     destroy_ctfd(app)
 
 
+def test_api_challenges_get_solve_status():
+    """Does the challenge list API show the current user's solve status?"""
+    app = create_ctfd()
+    with app.app_context():
+        chal_id = gen_challenge(app.db).id
+        register_user(app)
+        client = login_as_user(app)
+        # First request - unsolved
+        r = client.get("/api/v1/challenges")
+        assert r.status_code == 200
+        chal_data = r.get_json()["data"].pop()
+        assert chal_data["solved_by_me"] is False
+        # Solve and re-request
+        gen_solve(app.db, user_id=2, challenge_id=chal_id)
+        r = client.get("/api/v1/challenges")
+        assert r.status_code == 200
+        chal_data = r.get_json()["data"].pop()
+        assert chal_data["solved_by_me"] is True
+    destroy_ctfd(app)
+
+
+def test_api_challenges_get_solve_count():
+    """Does the challenge list API show the solve count?"""
+    # This is checked with public requests against the API after each generated
+    # user makes a solve
+    app = create_ctfd()
+    with app.app_context():
+        set_config("challenge_visibility", "public")
+        chal_id = gen_challenge(app.db).id
+        with app.test_client() as client:
+            _USER_BASE = 2  # First user we create will have this ID
+            _MAX = 3  # arbitrarily selected
+            for i in range(_MAX):
+                # Confirm solve count against `i` first
+                r = client.get("/api/v1/challenges")
+                assert r.status_code == 200
+                chal_data = r.get_json()["data"].pop()
+                assert chal_data["solves"] == i
+                # Generate a new user and solve for the challenge
+                uname = "user{}".format(i)
+                uemail = uname + "@examplectf.com"
+                register_user(app, name=uname, email=uemail)
+                gen_solve(app.db, user_id=_USER_BASE + i, challenge_id=chal_id)
+            # Confirm solve count one final time against `_MAX`
+            r = client.get("/api/v1/challenges")
+            assert r.status_code == 200
+            chal_data = r.get_json()["data"].pop()
+            assert chal_data["solves"] == _MAX
+    destroy_ctfd(app)
+
+
+def test_api_challenges_get_solve_info_score_visibility():
+    """Does the challenge list API show solve info if scores are hidden?"""
+    app = create_ctfd()
+    with app.app_context(), app.test_client() as pub_client:
+        set_config("challenge_visibility", "public")
+
+        # Generate a challenge, user and solve to test the API with
+        chal_id = gen_challenge(app.db).id
+        register_user(app)
+        gen_solve(app.db, user_id=2, challenge_id=chal_id)
+
+        #  With the public setting any unauthed user should see the solve
+        set_config("score_visibility", "public")
+        r = pub_client.get("/api/v1/challenges")
+        assert r.status_code == 200
+        chal_data = r.get_json()["data"].pop()
+        assert chal_data["solves"] == 1
+        assert chal_data["solved_by_me"] == False
+
+        # With the private setting only an authed user should see the solve
+        set_config("score_visibility", "private")
+        # Test public user
+        r = pub_client.get("/api/v1/challenges")
+        assert r.status_code == 200
+        chal_data = r.get_json()["data"].pop()
+        assert chal_data["solves"] is None
+        assert chal_data["solved_by_me"] is False
+        # Test authed user
+        user_client = login_as_user(app)
+        r = user_client.get("/api/v1/challenges")
+        assert r.status_code == 200
+        chal_data = r.get_json()["data"].pop()
+        assert chal_data["solves"] == 1
+        assert chal_data["solved_by_me"] is True
+
+        # With the admins setting only admins should see the solve
+        set_config("score_visibility", "admins")
+        # Test authed user
+        r = user_client.get("/api/v1/challenges")
+        assert r.status_code == 200
+        chal_data = r.get_json()["data"].pop()
+        assert chal_data["solves"] is None
+        assert chal_data["solved_by_me"] is True
+        # Test admin
+        admin_client = login_as_user(app, "admin", "password")
+        r = admin_client.get("/api/v1/challenges")
+        assert r.status_code == 200
+        chal_data = r.get_json()["data"].pop()
+        assert chal_data["solves"] == 1
+        assert chal_data["solved_by_me"] is False
+
+        # With the hidden setting nobody should see the solve
+        set_config("score_visibility", "hidden")
+        r = admin_client.get("/api/v1/challenges")
+        assert r.status_code == 200
+        chal_data = r.get_json()["data"].pop()
+        assert chal_data["solves"] is None
+    destroy_ctfd(app)
+
+
+def test_api_challenges_get_solve_info_account_visibility():
+    """Does the challenge list API show solve info if accounts are hidden?"""
+    app = create_ctfd()
+    with app.app_context(), app.test_client() as pub_client:
+        set_config("challenge_visibility", "public")
+
+        # Generate a challenge, user and solve to test the API with
+        chal_id = gen_challenge(app.db).id
+        register_user(app)
+        gen_solve(app.db, user_id=2, challenge_id=chal_id)
+
+        #  With the public setting any unauthed user should see the solve
+        set_config("account_visibility", "public")
+        r = pub_client.get("/api/v1/challenges")
+        assert r.status_code == 200
+        chal_data = r.get_json()["data"].pop()
+        assert chal_data["solves"] == 1
+        assert chal_data["solved_by_me"] is False
+
+        # With the private setting only an authed user should see the solve
+        set_config("account_visibility", "private")
+        # Test public user
+        r = pub_client.get("/api/v1/challenges")
+        assert r.status_code == 200
+        chal_data = r.get_json()["data"].pop()
+        assert chal_data["solves"] is None
+        assert chal_data["solved_by_me"] is False
+        # Test user
+        user_client = login_as_user(app)
+        r = user_client.get("/api/v1/challenges")
+        assert r.status_code == 200
+        chal_data = r.get_json()["data"].pop()
+        assert chal_data["solves"] == 1
+        assert chal_data["solved_by_me"] is True
+
+        # With the admins setting only admins should see the solve
+        set_config("account_visibility", "admins")
+        # Test user
+        r = user_client.get("/api/v1/challenges")
+        assert r.status_code == 200
+        chal_data = r.get_json()["data"].pop()
+        assert chal_data["solves"] is None
+        assert chal_data["solved_by_me"] is True
+        # Test admin user
+        admin_client = login_as_user(app, "admin", "password")
+        r = admin_client.get("/api/v1/challenges")
+        assert r.status_code == 200
+        chal_data = r.get_json()["data"].pop()
+        assert chal_data["solves"] == 1
+        assert chal_data["solved_by_me"] is False
+    destroy_ctfd(app)
+
+
+def test_api_challenges_get_solve_count_frozen():
+    """Does the challenge list API count solves made during a freeze?"""
+    app = create_ctfd()
+    with app.app_context(), app.test_client() as client:
+        set_config("challenge_visibility", "public")
+        set_config("freeze", "1507262400")
+        chal_id = gen_challenge(app.db).id
+
+        with freeze_time("2017-10-4"):
+            # Create a user and generate a solve from before the freeze time
+            register_user(app, name="user1", email="user1@examplectf.com")
+            gen_solve(app.db, user_id=2, challenge_id=chal_id)
+
+        # Confirm solve count is now `1`
+        r = client.get("/api/v1/challenges")
+        assert r.status_code == 200
+        chal_data = r.get_json()["data"].pop()
+        assert chal_data["solves"] == 1
+
+        with freeze_time("2017-10-8"):
+            # Create a user and generate a solve from after the freeze time
+            register_user(app, name="user2", email="user2@examplectf.com")
+            gen_solve(app.db, user_id=3, challenge_id=chal_id)
+
+        # Confirm solve count is still `1` despite the new solve
+        r = client.get("/api/v1/challenges")
+        assert r.status_code == 200
+        chal_data = r.get_json()["data"].pop()
+        assert chal_data["solves"] == 1
+    destroy_ctfd(app)
+
+
+def test_api_challenges_get_solve_count_hidden_user():
+    """Does the challenge list API show solve counts for hidden users?"""
+    app = create_ctfd()
+    with app.app_context():
+        set_config("challenge_visibility", "public")
+        chal_id = gen_challenge(app.db).id
+        # The admin is expected to be hidden by default
+        gen_solve(app.db, user_id=1, challenge_id=chal_id)
+        with app.test_client() as client:
+            # Confirm solve count is `0` despite the hidden admin having solved
+            r = client.get("/api/v1/challenges")
+            assert r.status_code == 200
+            chal_data = r.get_json()["data"].pop()
+            assert chal_data["solves"] == 0
+    destroy_ctfd(app)
+
+
+def test_api_challenges_get_solve_count_banned_user():
+    """Does the challenge list API show solve counts for banned users?"""
+    app = create_ctfd()
+    with app.app_context():
+        set_config("challenge_visibility", "public")
+        chal_id = gen_challenge(app.db).id
+
+        # Create a banned user and generate a solve for the challenge
+        register_user(app)
+        gen_solve(app.db, user_id=2, challenge_id=chal_id)
+
+        # Confirm that the solve is there
+        with app.test_client() as client:
+            r = client.get("/api/v1/challenges")
+            assert r.status_code == 200
+            chal_data = r.get_json()["data"].pop()
+            assert chal_data["solves"] == 1
+
+        # Ban the user
+        Users.query.get(2).banned = True
+        app.db.session.commit()
+
+        with app.test_client() as client:
+            # Confirm solve count is `0` despite the banned user having solved
+            r = client.get("/api/v1/challenges")
+            assert r.status_code == 200
+            chal_data = r.get_json()["data"].pop()
+            assert chal_data["solves"] == 0
+    destroy_ctfd(app)
+
+
 def test_api_challenges_post_admin():
     """Can a user post /api/v1/challenges if admin"""
     app = create_ctfd()
@@ -325,6 +569,264 @@ def test_api_challenge_get_non_existing():
     destroy_ctfd(app)
 
 
+def test_api_challenge_get_solve_status():
+    """Does the challenge detail API show the current user's solve status?"""
+    app = create_ctfd()
+    with app.app_context():
+        chal_id = gen_challenge(app.db).id
+        chal_uri = "/api/v1/challenges/{}".format(chal_id)
+        register_user(app)
+        client = login_as_user(app)
+        # First request - unsolved
+        r = client.get(chal_uri)
+        assert r.status_code == 200
+        chal_data = r.get_json()["data"]
+        assert chal_data["solved_by_me"] is False
+        # Solve and re-request
+        gen_solve(app.db, user_id=2, challenge_id=chal_id)
+        r = client.get(chal_uri)
+        assert r.status_code == 200
+        chal_data = r.get_json()["data"]
+        assert chal_data["solved_by_me"] is True
+    destroy_ctfd(app)
+
+
+def test_api_challenge_get_solve_info_score_visibility():
+    """Does the challenge detail API show solve info if scores are hidden?"""
+    app = create_ctfd()
+    with app.app_context(), app.test_client() as pub_client:
+        set_config("challenge_visibility", "public")
+        # Generate a challenge, user and solve to test the API with
+        chal_id = gen_challenge(app.db).id
+        chal_uri = "/api/v1/challenges/{}".format(chal_id)
+        register_user(app)
+        gen_solve(app.db, user_id=2, challenge_id=chal_id)
+
+        #  With the public setting any unauthed user should see the solve
+        set_config("score_visibility", "public")
+        r = pub_client.get(chal_uri)
+        assert r.status_code == 200
+        chal_data = r.get_json()["data"]
+        assert chal_data["solves"] == 1
+        assert chal_data["solved_by_me"] is False
+
+        # With the private setting only an authed user should see the solve
+        set_config("score_visibility", "private")
+        # Test public user
+        r = pub_client.get(chal_uri)
+        assert r.status_code == 200
+        chal_data = r.get_json()["data"]
+        assert chal_data["solves"] is None
+        assert chal_data["solved_by_me"] is False
+        # Test user
+        user_client = login_as_user(app)
+        r = user_client.get(chal_uri)
+        assert r.status_code == 200
+        chal_data = r.get_json()["data"]
+        assert chal_data["solves"] == 1
+        assert chal_data["solved_by_me"] is True
+
+        # With the admins setting only admins should see the solve
+        set_config("score_visibility", "admins")
+        # Test user
+        r = user_client.get(chal_uri)
+        assert r.status_code == 200
+        chal_data = r.get_json()["data"]
+        assert chal_data["solves"] is None
+        assert chal_data["solved_by_me"] is True
+        # Test admin user
+        admin_client = login_as_user(app, "admin", "password")
+        r = admin_client.get(chal_uri)
+        assert r.status_code == 200
+        chal_data = r.get_json()["data"]
+        assert chal_data["solves"] == 1
+        assert chal_data["solved_by_me"] is False
+
+        # With the hidden setting nobody should see the solve
+        set_config("score_visibility", "hidden")
+        r = admin_client.get(chal_uri)
+        assert r.status_code == 200
+        chal_data = r.get_json()["data"]
+        assert chal_data["solves"] is None
+    destroy_ctfd(app)
+
+
+def test_api_challenge_get_solve_info_account_visibility():
+    """Does the challenge detail API show solve info if accounts are hidden?"""
+    app = create_ctfd()
+    with app.app_context(), app.test_client() as pub_client:
+        set_config("challenge_visibility", "public")
+        # Generate a challenge, user and solve to test the API with
+        chal_id = gen_challenge(app.db).id
+        chal_uri = "/api/v1/challenges/{}".format(chal_id)
+        register_user(app)
+        gen_solve(app.db, user_id=2, challenge_id=chal_id)
+
+        #  With the public setting any unauthed user should see the solve
+        set_config("account_visibility", "public")
+        r = pub_client.get(chal_uri)
+        assert r.status_code == 200
+        chal_data = r.get_json()["data"]
+        assert chal_data["solves"] == 1
+        assert chal_data["solved_by_me"] is False
+
+        # With the private setting only an authed user should see the solve
+        set_config("account_visibility", "private")
+        # Test public user
+        r = pub_client.get(chal_uri)
+        assert r.status_code == 200
+        chal_data = r.get_json()["data"]
+        assert chal_data["solves"] is None
+        assert chal_data["solved_by_me"] is False
+        # Test user
+        user_client = login_as_user(app)
+        r = user_client.get(chal_uri)
+        assert r.status_code == 200
+        chal_data = r.get_json()["data"]
+        assert chal_data["solves"] == 1
+        assert chal_data["solved_by_me"] is True
+
+        # With the admins setting only admins should see the solve
+        set_config("account_visibility", "admins")
+        # Test user
+        r = user_client.get(chal_uri)
+        assert r.status_code == 200
+        chal_data = r.get_json()["data"]
+        assert chal_data["solves"] is None
+        assert chal_data["solved_by_me"] is True
+        # Test admin user
+        admin_client = login_as_user(app, "admin", "password")
+        r = admin_client.get(chal_uri)
+        assert r.status_code == 200
+        chal_data = r.get_json()["data"]
+        assert chal_data["solves"] == 1
+        assert chal_data["solved_by_me"] is False
+
+        # With the hidden setting admins can still see the solve
+        # because the challenge detail endpoint doesn't have an admin specific view
+        set_config("account_visibility", "hidden")
+        r = admin_client.get(chal_uri)
+        assert r.status_code == 200
+        chal_data = r.get_json()["data"]
+        assert chal_data["solves"] == 1
+    destroy_ctfd(app)
+
+
+def test_api_challenge_get_solve_count():
+    """Does the challenge detail API show the solve count?"""
+    # This is checked with public requests against the API after each generated
+    # user makes a solve
+    app = create_ctfd()
+    with app.app_context():
+        set_config("challenge_visibility", "public")
+        chal_id = gen_challenge(app.db).id
+        chal_uri = "/api/v1/challenges/{}".format(chal_id)
+        with app.test_client() as client:
+            _USER_BASE = 2  # First user we create will have this ID
+            _MAX = 3  # arbitrarily selected
+            for i in range(_MAX):
+                # Confirm solve count against `i` first
+                r = client.get(chal_uri)
+                assert r.status_code == 200
+                chal_data = r.get_json()["data"]
+                assert chal_data["solves"] == i
+                # Generate a new user and solve for the challenge
+                uname = "user{}".format(i)
+                uemail = uname + "@examplectf.com"
+                register_user(app, name=uname, email=uemail)
+                gen_solve(app.db, user_id=_USER_BASE + i, challenge_id=chal_id)
+            # Confirm solve count one final time against `_MAX`
+            r = client.get(chal_uri)
+            assert r.status_code == 200
+            chal_data = r.get_json()["data"]
+            assert chal_data["solves"] == _MAX
+    destroy_ctfd(app)
+
+
+def test_api_challenge_get_solve_count_frozen():
+    """Does the challenge detail API count solves made during a freeze?"""
+    app = create_ctfd()
+    with app.app_context(), app.test_client() as client:
+        set_config("challenge_visibility", "public")
+        # Friday, October 6, 2017 4:00:00 AM
+        set_config("freeze", "1507262400")
+        chal_id = gen_challenge(app.db).id
+        chal_uri = "/api/v1/challenges/{}".format(chal_id)
+
+        with freeze_time("2017-10-4"):
+            # Create a user and generate a solve from before the freeze time
+            register_user(app, name="user1", email="user1@examplectf.com")
+            gen_solve(app.db, user_id=2, challenge_id=chal_id)
+
+        # Confirm solve count is now `1`
+        r = client.get(chal_uri)
+        assert r.status_code == 200
+        chal_data = r.get_json()["data"]
+        assert chal_data["solves"] == 1
+
+        with freeze_time("2017-10-8"):
+            # Create a user and generate a solve from after the freeze time
+            register_user(app, name="user2", email="user2@examplectf.com")
+            gen_solve(app.db, user_id=3, challenge_id=chal_id)
+
+        # Confirm solve count is still `1` despite the new solve
+        r = client.get(chal_uri)
+        assert r.status_code == 200
+        chal_data = r.get_json()["data"]
+        assert chal_data["solves"] == 1
+    destroy_ctfd(app)
+
+
+def test_api_challenge_get_solve_count_hidden_user():
+    """Does the challenge detail API show solve counts for hidden users?"""
+    app = create_ctfd()
+    with app.app_context():
+        set_config("challenge_visibility", "public")
+        chal_id = gen_challenge(app.db).id
+        chal_uri = "/api/v1/challenges/{}".format(chal_id)
+        # The admin is expected to be hidden by default
+        gen_solve(app.db, user_id=1, challenge_id=chal_id)
+        with app.test_client() as client:
+            # Confirm solve count is `0` despite the hidden admin having solved
+            r = client.get(chal_uri)
+            assert r.status_code == 200
+            chal_data = r.get_json()["data"]
+            assert chal_data["solves"] == 0
+    destroy_ctfd(app)
+
+
+def test_api_challenge_get_solve_count_banned_user():
+    """Does the challenge detail API show solve counts for banned users?"""
+    app = create_ctfd()
+    with app.app_context():
+        set_config("challenge_visibility", "public")
+        chal_id = gen_challenge(app.db).id
+        chal_uri = "/api/v1/challenges/{}".format(chal_id)
+
+        # Create a user and generate a solve for the challenge
+        register_user(app)
+        gen_solve(app.db, user_id=2, challenge_id=chal_id)
+
+        # Confirm that the solve is there
+        with app.test_client() as client:
+            r = client.get(chal_uri)
+            assert r.status_code == 200
+            chal_data = r.get_json()["data"]
+            assert chal_data["solves"] == 1
+
+        # Ban the user
+        Users.query.get(2).banned = True
+        app.db.session.commit()
+
+        # Confirm solve count is `0` despite the banned user having solved
+        with app.test_client() as client:
+            r = client.get(chal_uri)
+            assert r.status_code == 200
+            chal_data = r.get_json()["data"]
+            assert chal_data["solves"] == 0
+    destroy_ctfd(app)
+
+
 def test_api_challenge_patch_non_admin():
     """Can a user patch /api/v1/challenges/<challenge_id> if not admin"""
     app = create_ctfd()