Repository: CTFd/CTFd

Issue:
Mark wrong key as correct button



Discussion:
Could you explain this a bit more?
@Ret2lib sure! Basically CTFd has pages that show incorrect submissions for the entire CTF. I believe this issue was for admins to be able to mark an incorrect submission as correct. Does that make sense? 
Understood, I’ll take ownership of this and start working on it on downtime.

On Thu, May 2, 2019 at 4:30 PM Kevin Chung <notifications@github.com> wrote:

> @Ret2lib <https://github.com/Ret2lib> sure! Basically CTFd has pages that
> show incorrect submissions for the entire CTF. I believe this issue was for
> admins to be able to mark an incorrect submission as correct. Does that
> make sense?
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/CTFd/CTFd/issues/181#issuecomment-488867997>, or mute
> the thread
> <https://github.com/notifications/unsubscribe-auth/AE5VALR5NMBYW7Q3WND6LATPTN2QHANCNFSM4C3DAYFA>
> .
>

This is actually not so easy to do because CTFd relies on the ID to determine who submitted something first. Optimistically we would want to directly convert an incorrect flag into a correct one so that it retains its ID. This way the timing is correct. 

However, directly inserting IDs seems like a bad idea. I'll investigate what happens but seems like this is more trouble than its worth. It might be better to just handle these kinds of situations manually. 
I think after https://github.com/CTFd/CTFd/pull/2212 this is now possible 

Resolving patch:
diff --git a/CTFd/api/v1/statistics/challenges.py b/CTFd/api/v1/statistics/challenges.py
--- a/CTFd/api/v1/statistics/challenges.py
+++ b/CTFd/api/v1/statistics/challenges.py
@@ -80,7 +80,12 @@ class ChallengeSolvePercentages(Resource):
     @admins_only
     def get(self):
         challenges = (
-            Challenges.query.add_columns("id", "name", "state", "max_attempts")
+            Challenges.query.add_columns(
+                Challenges.id,
+                Challenges.name,
+                Challenges.state,
+                Challenges.max_attempts,
+            )
             .order_by(Challenges.value)
             .all()
         )
diff --git a/CTFd/api/v1/submissions.py b/CTFd/api/v1/submissions.py
--- a/CTFd/api/v1/submissions.py
+++ b/CTFd/api/v1/submissions.py
@@ -1,5 +1,6 @@
 from typing import List
 
+from flask import request
 from flask_restx import Namespace, Resource
 
 from CTFd.api.v1.helpers.request import validate_args
@@ -10,7 +11,7 @@
 )
 from CTFd.cache import clear_challenges, clear_standings
 from CTFd.constants import RawEnum
-from CTFd.models import Submissions, db
+from CTFd.models import Solves, Submissions, db
 from CTFd.schemas.submissions import SubmissionSchema
 from CTFd.utils.decorators import admins_only
 from CTFd.utils.helpers.models import build_model_filters
@@ -152,7 +153,7 @@ def post(self, json_args):
 class Submission(Resource):
     @admins_only
     @submissions_namespace.doc(
-        description="Endpoint to get submission objects in bulk",
+        description="Endpoint to get a submission object",
         responses={
             200: ("Success", "SubmissionDetailedSuccessResponse"),
             400: (
@@ -173,7 +174,51 @@ def get(self, submission_id):
 
     @admins_only
     @submissions_namespace.doc(
-        description="Endpoint to get submission objects in bulk",
+        description="Endpoint to edit a submission object",
+        responses={
+            200: ("Success", "SubmissionDetailedSuccessResponse"),
+            400: (
+                "An error occured processing the provided or stored data",
+                "APISimpleErrorResponse",
+            ),
+        },
+    )
+    def patch(self, submission_id):
+        submission = Submissions.query.filter_by(id=submission_id).first_or_404()
+
+        req = request.get_json()
+        submission_type = req.get("type")
+
+        if submission_type == "correct":
+            solve = Solves(
+                user_id=submission.user_id,
+                challenge_id=submission.challenge_id,
+                team_id=submission.team_id,
+                ip=submission.ip,
+                provided=submission.provided,
+                date=submission.date,
+            )
+            db.session.add(solve)
+            submission.type = "discard"
+            db.session.commit()
+
+            # Delete standings cache
+            clear_standings()
+            clear_challenges()
+
+            submission = solve
+
+        schema = SubmissionSchema()
+        response = schema.dump(submission)
+
+        if response.errors:
+            return {"success": False, "errors": response.errors}, 400
+
+        return {"success": True, "data": response.data}
+
+    @admins_only
+    @submissions_namespace.doc(
+        description="Endpoint to delete a submission object",
         responses={
             200: ("Success", "APISimpleSuccessResponse"),
             400: (
diff --git a/CTFd/models/__init__.py b/CTFd/models/__init__.py
--- a/CTFd/models/__init__.py
+++ b/CTFd/models/__init__.py
@@ -849,6 +849,10 @@ class Fails(Submissions):
     __mapper_args__ = {"polymorphic_identity": "incorrect"}
 
 
+class Discards(Submissions):
+    __mapper_args__ = {"polymorphic_identity": "discard"}
+
+
 class Unlocks(db.Model):
     __tablename__ = "unlocks"
     id = db.Column(db.Integer, primary_key=True)


Test patch:
diff --git a/tests/api/v1/test_submissions.py b/tests/api/v1/test_submissions.py
--- a/tests/api/v1/test_submissions.py
+++ b/tests/api/v1/test_submissions.py
@@ -1,12 +1,16 @@
 #!/usr/bin/env python
 # -*- coding: utf-8 -*-
 
+from CTFd.models import Discards, Fails, Solves
 from tests.helpers import (
     create_ctfd,
     destroy_ctfd,
     gen_challenge,
+    gen_fail,
     gen_solve,
+    gen_team,
     login_as_user,
+    register_user,
 )
 
 
@@ -91,3 +95,116 @@ def test_api_submission_delete_admin():
             assert r.status_code == 200
             assert r.get_json().get("data") is None
     destroy_ctfd(app)
+
+
+def test_api_submission_patch_correct():
+    """Test that patching a submission to correct creates a solve"""
+    app = create_ctfd()
+    with app.app_context():
+        register_user(app)
+        gen_challenge(app.db)
+        gen_fail(app.db, challenge_id=1, user_id=2)
+        assert Solves.query.count() == 0
+        with login_as_user(app, "admin") as client:
+            r = client.patch("/api/v1/submissions/1", json={"type": "correct"})
+            assert r.status_code == 200
+            assert Fails.query.count() == 0
+            assert Solves.query.count() == 1
+            assert Discards.query.count() == 1
+    destroy_ctfd(app)
+
+
+def test_api_submission_patch_correct_scoreboard():
+    "If we adjust a submission for someone the scoreboard should be correct accounting for the time of the adjusted submission"
+    app = create_ctfd()
+    with app.app_context():
+        # Create 2 test users
+        register_user(app, name="user1", email="user1@examplectf.com")
+        register_user(app, name="user2", email="user2@examplectf.com")
+
+        # Create 2 test challenges
+        gen_challenge(app.db, name="chal1")
+        gen_challenge(app.db, name="chal2")
+
+        # Give the first test user only fails
+        gen_fail(app.db, challenge_id=1, user_id=2)
+        gen_fail(app.db, challenge_id=2, user_id=2)
+        # Give the second test user only solves
+        gen_solve(app.db, challenge_id=1, user_id=3)
+        gen_solve(app.db, challenge_id=2, user_id=3)
+        with login_as_user(app, "admin") as client:
+            # user2 who has both solves should be considered on top
+            scoreboard = client.get("/api/v1/scoreboard").get_json()["data"]
+            assert len(scoreboard) == 1
+            assert scoreboard[0]["name"] == "user2"
+
+            # We mark user1's first solve as correct
+            # This should give them 100 points
+            # It should not place them above user2 who has 200 points
+            client.patch("/api/v1/submissions/1", json={"type": "correct"})
+            scoreboard = client.get("/api/v1/scoreboard").get_json()["data"]
+            assert len(scoreboard) == 2
+            assert scoreboard[0]["name"] == "user2"
+            assert scoreboard[1]["name"] == "user1"
+            assert scoreboard[1]["score"] == 100
+
+            # We mark user1's second solve as correct
+            # This should give them 200 points
+            # It should place them above user2 who has 200 points but was not the first to solve the challenge
+            # Based on time user1's attempts should be considered correct and first
+            client.patch("/api/v1/submissions/2", json={"type": "correct"})
+            scoreboard = client.get("/api/v1/scoreboard").get_json()["data"]
+            assert len(scoreboard) == 2
+            assert scoreboard[0]["name"] == "user1"
+            assert scoreboard[0]["score"] == 200
+            assert scoreboard[1]["name"] == "user2"
+            assert scoreboard[1]["score"] == 200
+    destroy_ctfd(app)
+
+
+def test_api_submission_patch_correct_scoreboard_teams():
+    "If we adjust a submission for a team the scoreboard should be correct after the adjustment"
+    app = create_ctfd(user_mode="teams")
+    with app.app_context():
+        # Create 2 test teams each with only 1 user
+        gen_team(app.db, name="team1", email="team1@examplectf.com", member_count=1)
+        gen_team(app.db, name="team2", email="team2@examplectf.com", member_count=1)
+
+        # Create 2 test challenges
+        gen_challenge(app.db, name="chal1")
+        gen_challenge(app.db, name="chal2")
+
+        # Assign only fails to the user in team 1
+        gen_fail(app.db, challenge_id=1, user_id=2, team_id=1)
+        gen_fail(app.db, challenge_id=2, user_id=2, team_id=1)
+        # Assign only solves to the user in team 2
+        gen_solve(app.db, challenge_id=1, user_id=3, team_id=2)
+        gen_solve(app.db, challenge_id=2, user_id=3, team_id=2)
+
+        with login_as_user(app, "admin") as client:
+            # team2 who has both solves should be considered on top
+            scoreboard = client.get("/api/v1/scoreboard").get_json()["data"]
+            assert len(scoreboard) == 1
+            assert scoreboard[0]["name"] == "team2"
+
+            # We then convert the first submission which should give team1 100 points
+            # team1 should also now appear on the scoreboard in 2nd place
+            client.patch("/api/v1/submissions/1", json={"type": "correct"})
+            scoreboard = client.get("/api/v1/scoreboard").get_json()["data"]
+            assert len(scoreboard) == 2
+            assert scoreboard[0]["name"] == "team2"
+            assert scoreboard[1]["name"] == "team1"
+            assert scoreboard[1]["score"] == 100
+
+            # We mark team1's second solve as correct
+            # This should give them 200 points
+            # It should place them above team2 who has 200 points but was not the first to solve the challenge
+            # Based on time team1's attempts should be considered correct and first
+            client.patch("/api/v1/submissions/2", json={"type": "correct"})
+            scoreboard = client.get("/api/v1/scoreboard").get_json()["data"]
+            assert len(scoreboard) == 2
+            assert scoreboard[0]["name"] == "team1"
+            assert scoreboard[0]["score"] == 200
+            assert scoreboard[1]["name"] == "team2"
+            assert scoreboard[1]["score"] == 200
+    destroy_ctfd(app)