init CTFd source
Some checks are pending
Linting / Linting (3.11) (push) Waiting to run
Mirror core-theme / mirror (push) Waiting to run

This commit is contained in:
gkr
2025-12-25 09:39:21 +08:00
commit 2e06f92c64
1047 changed files with 150349 additions and 0 deletions

View File

View File

@@ -0,0 +1,61 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import pytest
from flask import request
from sqlalchemy.exc import IntegrityError
from CTFd.exceptions.challenges import ChallengeSolveException
from CTFd.models import Solves
from CTFd.plugins.challenges import BaseChallenge
from tests.helpers import (
create_ctfd,
destroy_ctfd,
gen_challenge,
gen_solve,
gen_team,
gen_user,
)
def test_base_challenge_solve_creates_solve_when_missing():
"""Test that BaseChallenge.solve() creates a solve if it doesn't exist"""
app = create_ctfd()
with app.app_context():
challenge = gen_challenge(app.db)
user = gen_user(app.db, name="solver", email="solver@example.com")
assert Solves.query.count() == 0
with app.test_request_context(
json={"submission": "flag"},
environ_base={"REMOTE_ADDR": "127.0.0.1"},
):
BaseChallenge.solve(user, None, challenge, request)
assert Solves.query.count() == 1
destroy_ctfd(app)
def test_base_challenge_solve_raises_on_duplicate_solve():
"""Test that BaseChallenge.solve() raises ChallengeSolveException on duplicate solve"""
app = create_ctfd(user_mode="teams")
with app.app_context():
challenge = gen_challenge(app.db)
team = gen_team(app.db, member_count=1)
user = team.members[0]
gen_solve(app.db, user_id=user.id, team_id=team.id, challenge_id=challenge.id)
assert Solves.query.count() == 1
with app.test_request_context(
json={"submission": "flag"},
environ_base={"REMOTE_ADDR": "127.0.0.1"},
):
with pytest.raises(ChallengeSolveException) as exc:
BaseChallenge.solve(user, team, challenge, request)
assert isinstance(exc.value.__cause__, IntegrityError)
assert Solves.query.count() == 1
destroy_ctfd(app)

View File

@@ -0,0 +1,275 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from CTFd.models import Challenges
from tests.helpers import (
create_ctfd,
destroy_ctfd,
gen_flag,
gen_team,
login_as_user,
register_user,
)
def test_all_flags_challenge_logic():
"""Test a challenge that requires all flags to be submitted for solving"""
app = create_ctfd()
with app.app_context():
register_user(app, name="user1")
admin = login_as_user(app, name="admin", password="password")
# Create a challenge
challenge_data = {
"name": "All Flags Challenge",
"category": "Logic",
"description": "Submit all flags to solve.",
"value": 100,
"state": "visible",
"type": "standard",
"logic": "all",
}
r = admin.post("/api/v1/challenges", json=challenge_data)
challenge_id = r.get_json()["data"]["id"]
client = login_as_user(app, name="user1", password="password")
# Add multiple flags to the challenge
flags = ["flag{one}", "flag{two}", "flag{three}"]
for content in flags:
gen_flag(app.db, challenge_id=challenge_id, content=content)
# Simulate "all flags" logic: user must submit all flags to solve
# Submit only one flag
submission = {"challenge_id": challenge_id, "submission": "flag{one}"}
r = client.post("/api/v1/challenges/attempt", json=submission)
assert r.get_json()["data"]["status"] == "partial"
# Submit two flags (simulate by submitting them one after another)
submission = {"challenge_id": challenge_id, "submission": "flag{two}"}
r = client.post("/api/v1/challenges/attempt", json=submission)
assert r.get_json()["data"]["status"] == "partial"
# Submit all three flags
submission = {"challenge_id": challenge_id, "submission": "flag{three}"}
r = client.post("/api/v1/challenges/attempt", json=submission)
assert r.get_json()["data"]["status"] == "correct"
# After all flags are submitted, the challenge should be marked as solved
r = client.get(f"/api/v1/challenges/{challenge_id}/solves")
solves = r.get_json()["data"]
assert len(solves) == 1
assert solves[0]["account_id"] == 2 # user1's ID (admin is 1)
destroy_ctfd(app)
def test_all_flags_challenge_logic_teams_mode():
"""Test a challenge that requires all flags to be submitted for solving in teams mode"""
app = create_ctfd(user_mode="teams")
with app.app_context():
# Create admin for challenge creation
admin = login_as_user(app, name="admin", password="password")
# Create a challenge with "all" logic
challenge_data = {
"name": "All Flags Team Challenge",
"category": "Logic",
"description": "Submit all flags to solve as a team.",
"value": 100,
"state": "visible",
"type": "standard",
"logic": "all",
}
r = admin.post("/api/v1/challenges", json=challenge_data)
challenge_id = r.get_json()["data"]["id"]
c = Challenges.query.filter_by(id=challenge_id).first()
assert c.logic == "all"
# Create a team with members using gen_team helper
team = gen_team(
app.db, name="test_team", email="team@examplectf.com", member_count=3
)
team_id = team.id
# Add multiple flags to the challenge
flags = ["flag{team_one}", "flag{team_two}", "flag{team_three}"]
for content in flags:
gen_flag(app.db, challenge_id=challenge_id, content=content)
# Get team members for testing
members = team.members
user1_name = members[0].name
user2_name = members[1].name
user3_name = members[2].name
# Test that different team members can submit flags and it counts towards team progress
# User1 submits first flag
client1 = login_as_user(app, name=user1_name, password="password")
submission = {"challenge_id": challenge_id, "submission": "flag{team_one}"}
r = client1.post("/api/v1/challenges/attempt", json=submission)
assert r.get_json()["data"]["status"] == "partial"
# User2 submits second flag
client2 = login_as_user(app, name=user2_name, password="password")
submission = {"challenge_id": challenge_id, "submission": "flag{team_two}"}
r = client2.post("/api/v1/challenges/attempt", json=submission)
assert r.get_json()["data"]["status"] == "partial"
# User3 submits third flag - should complete the challenge for the team
client3 = login_as_user(app, name=user3_name, password="password")
submission = {"challenge_id": challenge_id, "submission": "flag{team_three}"}
r = client3.post("/api/v1/challenges/attempt", json=submission)
assert r.get_json()["data"]["status"] == "correct"
# Check that the team is marked as having solved the challenge
r = admin.get(f"/api/v1/challenges/{challenge_id}/solves")
solves = r.get_json()["data"]
assert len(solves) == 1
assert solves[0]["account_id"] == team_id
# Verify team score reflects the solve
team_response = admin.get(f"/api/v1/teams/{team_id}")
team_data = team_response.get_json()["data"]
assert team_data["score"] == 100
# Test that another team member trying to submit the same flag gets "already_solved"
submission = {"challenge_id": challenge_id, "submission": "flag{team_one}"}
r = client1.post("/api/v1/challenges/attempt", json=submission)
assert r.get_json()["data"]["status"] == "already_solved"
destroy_ctfd(app)
def test_group_flags_challenge_logic_teams_mode():
"""Test a challenge that requires each team member to submit any flag for solving in teams mode"""
app = create_ctfd(user_mode="teams")
with app.app_context():
# Create admin for challenge creation
admin = login_as_user(app, name="admin", password="password")
# Create a challenge with "group" logic
challenge_data = {
"name": "Group Flags Team Challenge",
"category": "Logic",
"description": "Each team member must submit any flag to solve as a team.",
"value": 100,
"state": "visible",
"type": "standard",
"logic": "team",
}
r = admin.post("/api/v1/challenges", json=challenge_data)
challenge_id = r.get_json()["data"]["id"]
c = Challenges.query.filter_by(id=challenge_id).first()
assert c.logic == "team"
# Create a team with members using gen_team helper
team = gen_team(
app.db, name="test_team", email="team@examplectf.com", member_count=3
)
team_id = team.id
# Add multiple flags to the challenge
flags = ["flag{group_one}", "flag{group_two}", "flag{group_three}"]
for content in flags:
gen_flag(app.db, challenge_id=challenge_id, content=content)
# Get team members for testing
members = team.members
user1_name = members[0].name
user2_name = members[1].name
user3_name = members[2].name
# Test that each team member must submit any flag for the team to solve
# User1 submits first flag - should be partial since not all members have submitted
client1 = login_as_user(app, name=user1_name, password="password")
submission = {"challenge_id": challenge_id, "submission": "flag{group_one}"}
r = client1.post("/api/v1/challenges/attempt", json=submission)
assert r.get_json()["data"]["status"] == "partial"
# User2 submits any flag (can be different) - still partial since user3 hasn't submitted
client2 = login_as_user(app, name=user2_name, password="password")
submission = {"challenge_id": challenge_id, "submission": "flag{group_two}"}
r = client2.post("/api/v1/challenges/attempt", json=submission)
assert r.get_json()["data"]["status"] == "partial"
# User3 submits any flag - should complete the challenge since all members have now submitted
client3 = login_as_user(app, name=user3_name, password="password")
submission = {
"challenge_id": challenge_id,
"submission": "flag{group_one}",
} # Can reuse same flag
r = client3.post("/api/v1/challenges/attempt", json=submission)
assert r.get_json()["data"]["status"] == "correct"
# Check that the team is marked as having solved the challenge
r = admin.get(f"/api/v1/challenges/{challenge_id}/solves")
solves = r.get_json()["data"]
assert len(solves) == 1
assert solves[0]["account_id"] == team_id
# Verify team score reflects the solve
team_response = admin.get(f"/api/v1/teams/{team_id}")
team_data = team_response.get_json()["data"]
assert team_data["score"] == 100
# Test that team members trying to submit again get "already_solved"
submission = {"challenge_id": challenge_id, "submission": "flag{group_three}"}
r = client1.post("/api/v1/challenges/attempt", json=submission)
assert r.get_json()["data"]["status"] == "already_solved"
destroy_ctfd(app)
def test_challenge_default_logic_is_any():
"""Test that the default logic for a challenge is 'any'"""
app = create_ctfd()
with app.app_context():
register_user(app, name="user1")
admin = login_as_user(app, name="admin", password="password")
# Create a challenge without specifying logic (should default to "any")
challenge_data = {
"name": "Default Logic Challenge",
"category": "Test",
"description": "Test challenge with default logic.",
"value": 100,
"state": "visible",
"type": "standard",
# Note: no "logic" field specified - should default to "any"
}
r = admin.post("/api/v1/challenges", json=challenge_data)
challenge_id = r.get_json()["data"]["id"]
# Verify the challenge logic is set to "any" by default
challenge = Challenges.query.filter_by(id=challenge_id).first()
assert challenge.logic == "any"
# Test that "any" logic works correctly
client = login_as_user(app, name="user1", password="password")
# Add multiple flags to the challenge
flags = ["flag{first}", "flag{second}", "flag{third}"]
for content in flags:
gen_flag(app.db, challenge_id=challenge_id, content=content)
# With "any" logic, submitting any single correct flag should solve the challenge
submission = {"challenge_id": challenge_id, "submission": "flag{second}"}
r = client.post("/api/v1/challenges/attempt", json=submission)
assert r.get_json()["data"]["status"] == "correct"
# Verify the challenge is marked as solved
r = admin.get(f"/api/v1/challenges/{challenge_id}/solves")
solves = r.get_json()["data"]
assert len(solves) == 1
assert solves[0]["account_id"] == 2 # user1's ID (admin is 1)
# Test that submitting another flag returns "already_solved"
submission = {"challenge_id": challenge_id, "submission": "flag{first}"}
r = client.post("/api/v1/challenges/attempt", json=submission)
assert r.get_json()["data"]["status"] == "already_solved"
destroy_ctfd(app)

View File

@@ -0,0 +1,76 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from CTFd.models import Challenges
from tests.helpers import create_ctfd, destroy_ctfd, login_as_user, register_user
def test_missing_challenge_type():
"""Test that missing challenge types don't cause total challenge rendering failure"""
app = create_ctfd(enable_plugins=True)
with app.app_context():
register_user(app)
client = login_as_user(app, name="admin", password="password")
challenge_data = {
"name": "name",
"category": "category",
"description": "description",
"initial": 100,
"decay": 20,
"minimum": 1,
"state": "visible",
"type": "dynamic",
}
r = client.post("/api/v1/challenges", json=challenge_data)
assert r.get_json().get("data")["id"] == 1
assert r.get_json().get("data")["type"] == "dynamic"
chal_count = Challenges.query.count()
assert chal_count == 1
# Delete the dynamic challenge type
from CTFd.plugins.challenges import CHALLENGE_CLASSES
del CHALLENGE_CLASSES["dynamic"]
r = client.get("/admin/challenges")
assert r.status_code == 200
assert b"dynamic" in r.data
r = client.get("/admin/challenges/1")
assert r.status_code == 500
assert b"The underlying challenge type (dynamic) is not installed" in r.data
challenge_data = {
"name": "name",
"category": "category",
"description": "description",
"value": 100,
"state": "visible",
"type": "standard",
}
r = client.post("/api/v1/challenges", json=challenge_data)
r = client.get("/challenges")
assert r.status_code == 200
# We should still see the one visible standard challenge
r = client.get("/api/v1/challenges")
assert r.status_code == 200
assert len(r.json["data"]) == 1
assert r.json["data"][0]["type"] == "standard"
# We cannot load the broken challenge
r = client.get("/api/v1/challenges/1")
assert r.status_code == 500
assert (
"The underlying challenge type (dynamic) is not installed"
in r.json["message"]
)
# We can load other challenges
r = client.get("/api/v1/challenges/2")
assert r.status_code == 200
destroy_ctfd(app)

View File

@@ -0,0 +1,402 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from CTFd.models import Challenges
from CTFd.plugins.dynamic_challenges import DynamicChallenge, DynamicValueChallenge
from CTFd.utils.security.signing import hmac
from tests.helpers import (
FakeRequest,
create_ctfd,
destroy_ctfd,
gen_flag,
gen_user,
login_as_user,
register_user,
)
def test_can_create_dynamic_challenge():
"""Test that dynamic challenges can be made from the API/admin panel"""
app = create_ctfd(enable_plugins=True)
with app.app_context():
register_user(app)
client = login_as_user(app, name="admin", password="password")
challenge_data = {
"name": "name",
"category": "category",
"description": "description",
"initial": 100,
"decay": 20,
"minimum": 1,
"state": "hidden",
"type": "dynamic",
}
r = client.post("/api/v1/challenges", json=challenge_data)
assert r.get_json().get("data")["id"] == 1
challenges = DynamicChallenge.query.all()
assert len(challenges) == 1
challenge = challenges[0]
assert challenge.value == 100
assert challenge.initial == 100
assert challenge.decay == 20
assert challenge.minimum == 1
destroy_ctfd(app)
def test_can_update_dynamic_challenge():
app = create_ctfd(enable_plugins=True)
with app.app_context():
challenge_data = {
"name": "name",
"category": "category",
"description": "description",
"initial": 100,
"decay": 20,
"minimum": 1,
"state": "hidden",
"type": "dynamic",
}
req = FakeRequest(form=challenge_data)
challenge = DynamicValueChallenge.create(req)
assert challenge.value == 100
assert challenge.initial == 100
assert challenge.decay == 20
assert challenge.minimum == 1
challenge_data = {
"name": "new_name",
"category": "category",
"description": "new_description",
"value": "200",
"initial": "200",
"decay": "40",
"minimum": "5",
"max_attempts": "0",
"state": "visible",
}
req = FakeRequest(form=challenge_data)
challenge = DynamicValueChallenge.update(challenge, req)
assert challenge.name == "new_name"
assert challenge.description == "new_description"
assert challenge.value == 200
assert challenge.initial == 200
assert challenge.decay == 40
assert challenge.minimum == 5
assert challenge.state == "visible"
destroy_ctfd(app)
def test_can_add_requirement_dynamic_challenge():
"""Test that requirements can be added to dynamic challenges"""
app = create_ctfd(enable_plugins=True)
with app.app_context():
challenge_data = {
"name": "name",
"category": "category",
"description": "description",
"initial": 100,
"decay": 20,
"minimum": 1,
"state": "hidden",
"type": "dynamic",
}
req = FakeRequest(form=challenge_data)
challenge = DynamicValueChallenge.create(req)
assert challenge.value == 100
assert challenge.initial == 100
assert challenge.decay == 20
assert challenge.minimum == 1
challenge_data = {
"name": "second_name",
"category": "category",
"description": "new_description",
"value": "200",
"initial": "200",
"decay": "40",
"minimum": "5",
"max_attempts": "0",
"state": "visible",
}
req = FakeRequest(form=challenge_data)
challenge = DynamicValueChallenge.create(req)
assert challenge.name == "second_name"
assert challenge.description == "new_description"
assert challenge.value == 200
assert challenge.initial == 200
assert challenge.decay == 40
assert challenge.minimum == 5
assert challenge.state == "visible"
challenge_data = {"requirements": [1]}
req = FakeRequest(form=challenge_data)
challenge = DynamicValueChallenge.update(challenge, req)
assert challenge.requirements == [1]
destroy_ctfd(app)
def test_can_delete_dynamic_challenge():
"""Test that dynamic challenges can be deleted"""
app = create_ctfd(enable_plugins=True)
with app.app_context():
register_user(app)
client = login_as_user(app, name="admin", password="password")
challenge_data = {
"name": "name",
"category": "category",
"description": "description",
"initial": 100,
"decay": 20,
"minimum": 1,
"state": "hidden",
"type": "dynamic",
}
r = client.post("/api/v1/challenges", json=challenge_data)
assert r.get_json().get("data")["id"] == 1
challenges = DynamicChallenge.query.all()
assert len(challenges) == 1
challenge = challenges[0]
DynamicValueChallenge.delete(challenge)
challenges = DynamicChallenge.query.all()
assert len(challenges) == 0
destroy_ctfd(app)
def test_dynamic_challenge_loses_value_properly():
app = create_ctfd(enable_plugins=True)
with app.app_context():
register_user(app)
client = login_as_user(app, name="admin", password="password")
challenge_data = {
"name": "name",
"category": "category",
"description": "description",
"initial": 100,
"decay": 20,
"minimum": 1,
"state": "visible",
"type": "dynamic",
}
r = client.post("/api/v1/challenges", json=challenge_data)
assert r.get_json().get("data")["id"] == 1
gen_flag(app.db, challenge_id=1, content="flag")
for i, team_id in enumerate(range(2, 26)):
name = "user{}".format(team_id)
email = "user{}@examplectf.com".format(team_id)
# We need to bypass rate-limiting so gen_user instead of register_user
user = gen_user(app.db, name=name, email=email)
user_id = user.id
with app.test_client() as client:
# We need to bypass rate-limiting so creating a fake user instead of logging in
with client.session_transaction() as sess:
sess["id"] = user_id
sess["nonce"] = "fake-nonce"
sess["hash"] = hmac(user.password)
data = {"submission": "flag", "challenge_id": 1}
r = client.post("/api/v1/challenges/attempt", json=data)
resp = r.get_json()["data"]
assert resp["status"] == "correct"
chal = DynamicChallenge.query.filter_by(id=1).first()
if i >= 20:
assert chal.value == chal.minimum
else:
assert chal.initial >= chal.value
assert chal.value > chal.minimum
destroy_ctfd(app)
def test_dynamic_challenge_doesnt_lose_value_on_update():
"""Dynamic challenge updates without changing any values or solves shouldn't change the current value. See #1043"""
app = create_ctfd(enable_plugins=True)
with app.app_context():
challenge_data = {
"name": "name",
"category": "category",
"description": "description",
"initial": 10000,
"decay": 4,
"minimum": 10,
"state": "visible",
"type": "dynamic",
}
req = FakeRequest(form=challenge_data)
challenge = DynamicValueChallenge.create(req)
challenge_id = challenge.id
gen_flag(app.db, challenge_id=challenge.id, content="flag")
register_user(app)
with login_as_user(app) as client:
data = {"submission": "flag", "challenge_id": challenge_id}
r = client.post("/api/v1/challenges/attempt", json=data)
assert r.status_code == 200
assert r.get_json()["data"]["status"] == "correct"
chal = Challenges.query.filter_by(id=challenge_id).first()
prev_chal_value = chal.value
chal = DynamicValueChallenge.update(chal, req)
assert prev_chal_value == chal.value
destroy_ctfd(app)
def test_dynamic_challenge_value_isnt_affected_by_hidden_users():
app = create_ctfd(enable_plugins=True)
with app.app_context():
register_user(app)
client = login_as_user(app, name="admin", password="password")
challenge_data = {
"name": "name",
"category": "category",
"description": "description",
"initial": 100,
"decay": 20,
"minimum": 1,
"state": "visible",
"type": "dynamic",
}
r = client.post("/api/v1/challenges", json=challenge_data)
assert r.get_json().get("data")["id"] == 1
gen_flag(app.db, challenge_id=1, content="flag")
# Make a solve as a regular user. This should not affect the value.
data = {"submission": "flag", "challenge_id": 1}
r = client.post("/api/v1/challenges/attempt", json=data)
resp = r.get_json()["data"]
assert resp["status"] == "correct"
# Make solves as hidden users. Also should not affect value
for _, team_id in enumerate(range(2, 26)):
name = "user{}".format(team_id)
email = "user{}@examplectf.com".format(team_id)
# We need to bypass rate-limiting so gen_user instead of register_user
user = gen_user(app.db, name=name, email=email)
user.hidden = True
app.db.session.commit()
user_id = user.id
with app.test_client() as client:
# We need to bypass rate-limiting so creating a fake user instead of logging in
with client.session_transaction() as sess:
sess["id"] = user_id
sess["nonce"] = "fake-nonce"
sess["hash"] = hmac(user.password)
data = {"submission": "flag", "challenge_id": 1}
r = client.post("/api/v1/challenges/attempt", json=data)
assert r.status_code == 200
resp = r.get_json()["data"]
assert resp["status"] == "correct"
chal = DynamicChallenge.query.filter_by(id=1).first()
assert chal.value == chal.initial
destroy_ctfd(app)
def test_dynamic_challenges_reset():
app = create_ctfd(enable_plugins=True)
with app.app_context():
client = login_as_user(app, name="admin", password="password")
challenge_data = {
"name": "name",
"category": "category",
"description": "description",
"initial": 100,
"decay": 20,
"minimum": 1,
"state": "hidden",
"type": "dynamic",
}
r = client.post("/api/v1/challenges", json=challenge_data)
assert Challenges.query.count() == 1
assert DynamicChallenge.query.count() == 1
with client.session_transaction() as sess:
data = {"nonce": sess.get("nonce"), "challenges": "on"}
r = client.post("/admin/reset", data=data)
assert r.location.endswith("/admin/statistics")
assert Challenges.query.count() == 0
assert DynamicChallenge.query.count() == 0
destroy_ctfd(app)
def test_dynamic_challenge_linear_loses_value_properly():
app = create_ctfd(enable_plugins=True)
with app.app_context():
register_user(app)
client = login_as_user(app, name="admin", password="password")
challenge_data = {
"name": "name",
"category": "category",
"description": "description",
"function": "linear",
"initial": 100,
"decay": 5,
"minimum": 1,
"state": "visible",
"type": "dynamic",
}
r = client.post("/api/v1/challenges", json=challenge_data)
assert r.get_json().get("data")["id"] == 1
gen_flag(app.db, challenge_id=1, content="flag")
for i, team_id in enumerate(range(2, 26)):
name = "user{}".format(team_id)
email = "user{}@examplectf.com".format(team_id)
# We need to bypass rate-limiting so gen_user instead of register_user
user = gen_user(app.db, name=name, email=email)
user_id = user.id
with app.test_client() as client:
# We need to bypass rate-limiting so creating a fake user instead of logging in
with client.session_transaction() as sess:
sess["id"] = user_id
sess["nonce"] = "fake-nonce"
sess["hash"] = hmac(user.password)
data = {"submission": "flag", "challenge_id": 1}
r = client.post("/api/v1/challenges/attempt", json=data)
resp = r.get_json()["data"]
assert resp["status"] == "correct"
chal = DynamicChallenge.query.filter_by(id=1).first()
if i >= 20:
assert chal.value == chal.minimum
else:
assert chal.value == (chal.initial - (i * 5))
destroy_ctfd(app)

View File

@@ -0,0 +1,598 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from CTFd.cache import clear_ratings
from CTFd.models import Ratings
from CTFd.utils import set_config
from tests.helpers import (
create_ctfd,
destroy_ctfd,
gen_challenge,
gen_flag,
gen_rating,
gen_solve,
login_as_user,
register_user,
)
def test_ratings_public_config():
"""Test that users can see and leave ratings when configuration is set to public"""
app = create_ctfd()
with app.app_context():
set_config("challenge_ratings", "public")
# Create users and challenge
register_user(app, name="user1", email="user1@example.com")
user1_id = 2
register_user(app, name="user2", email="user2@example.com")
user2_id = 3
register_user(app, name="user3", email="user3@example.com")
user3_id = 4
challenge = gen_challenge(app.db, name="Test Challenge", value=100)
challenge_id = challenge.id
gen_flag(app.db, challenge_id=challenge_id, content="flag{test}")
# Solve the challenge for both users (required to rate)
gen_solve(
app.db, user_id=user1_id, challenge_id=challenge_id, provided="flag{test}"
)
gen_solve(
app.db, user_id=user2_id, challenge_id=challenge_id, provided="flag{test}"
)
gen_solve(
app.db, user_id=user3_id, challenge_id=challenge_id, provided="flag{test}"
)
client1 = login_as_user(app, name="user1", password="password")
client2 = login_as_user(app, name="user2", password="password")
client3 = login_as_user(app, name="user3", password="password")
# Test that users can leave ratings (PUT request)
rating_data1 = {
"value": 1,
"review": "Great challenge! Really enjoyed solving it.",
}
r = client1.put(f"/api/v1/challenges/{challenge_id}/ratings", json=rating_data1)
assert r.status_code == 200
data = r.get_json()
assert data["success"] is True
assert data["data"]["value"] == 1
assert data["data"]["review"] == "Great challenge! Really enjoyed solving it."
assert data["data"]["challenge_id"] == challenge_id
# Second user can also rate
rating_data2 = {"value": 1, "review": "Excellent challenge!"}
r = client2.put(f"/api/v1/challenges/{challenge_id}/ratings", json=rating_data2)
assert r.status_code == 200
data = r.get_json()
assert data["success"] is True
assert data["data"]["value"] == 1
assert data["data"]["review"] == "Excellent challenge!"
# Second user can also rate
rating_data3 = {"value": -1, "review": "Hated it!"}
r = client3.put(f"/api/v1/challenges/{challenge_id}/ratings", json=rating_data3)
assert r.status_code == 200
data = r.get_json()
assert data["success"] is True
assert data["data"]["value"] == -1
assert data["data"]["review"] == "Hated it!"
# Test that challenge detail includes rating info when public
r = client1.get(f"/api/v1/challenges/{challenge_id}")
assert r.status_code == 200
data = r.get_json()
assert data["success"] is True
assert "ratings" in data["data"]
assert data["data"]["ratings"]["up"] == 2
assert data["data"]["ratings"]["down"] == 1
assert data["data"]["ratings"]["count"] == 3
assert "rating" in data["data"] # User's own rating
assert data["data"]["rating"]["value"] == 1
assert (
data["data"]["rating"]["review"]
== "Great challenge! Really enjoyed solving it."
)
assert len(Ratings.query.all()) == 3
destroy_ctfd(app)
def test_ratings_private_config():
"""Test that users can only leave ratings but cannot see aggregated ratings when set to private"""
app = create_ctfd()
with app.app_context():
set_config("challenge_ratings", "private")
# Create users and challenge
register_user(app, name="user1", email="user1@example.com")
user1_id = 2
register_user(app, name="user2", email="user2@example.com")
user2_id = 3
challenge = gen_challenge(app.db, name="Test Challenge", value=100)
challenge_id = challenge.id
gen_flag(app.db, challenge_id=challenge_id, content="flag{test}")
# Solve the challenge for both users (required to rate)
gen_solve(
app.db, user_id=user1_id, challenge_id=challenge_id, provided="flag{test}"
)
gen_solve(
app.db, user_id=user2_id, challenge_id=challenge_id, provided="flag{test}"
)
client1 = login_as_user(app, name="user1", password="password")
client2 = login_as_user(app, name="user2", password="password")
admin_client = login_as_user(app, name="admin", password="password")
# Test that non-admin users cannot see aggregated ratings (GET request should fail)
r = client1.get(f"/api/v1/challenges/{challenge_id}/ratings", json=True)
assert r.status_code == 403 # Forbidden
# Test that users can still leave ratings (PUT request)
rating_data1 = {"value": 1, "review": "Good challenge with private rating"}
r = client1.put(f"/api/v1/challenges/{challenge_id}/ratings", json=rating_data1)
assert r.status_code == 200
data = r.get_json()
assert data["success"] is True
assert data["data"]["value"] == 1
assert data["data"]["review"] == "Good challenge with private rating"
# Second user can also rate
rating_data2 = {"value": 1, "review": "Excellent private challenge!"}
r = client2.put(f"/api/v1/challenges/{challenge_id}/ratings", json=rating_data2)
assert r.status_code == 200
data = r.get_json()
assert data["success"] is True
assert data["data"]["value"] == 1
assert data["data"]["review"] == "Excellent private challenge!"
# Test that challenge detail doesn't include aggregated ratings for regular users
r = client1.get(f"/api/v1/challenges/{challenge_id}")
assert r.status_code == 200
data = r.get_json()
assert data["success"] is True
assert data["data"]["ratings"] is None # No aggregated ratings shown
assert "rating" in data["data"] # But user can see their own rating
assert data["data"]["rating"]["value"] == 1
assert data["data"]["rating"]["review"] == "Good challenge with private rating"
# Test that admin can still see aggregated ratings
r = admin_client.get(f"/api/v1/challenges/{challenge_id}/ratings")
assert r.status_code == 200
data = r.get_json()
assert data["success"] is True
assert data["meta"]["summary"]["up"] == 2
assert data["meta"]["summary"]["down"] == 0
assert data["meta"]["summary"]["count"] == 2
assert len(Ratings.query.all()) == 2
destroy_ctfd(app)
def test_ratings_disabled_config():
"""Test that users cannot see or leave ratings when ratings are disabled"""
app = create_ctfd()
with app.app_context():
set_config("challenge_ratings", "disabled")
# Create users and challenge
register_user(app, name="user1", email="user1@example.com")
user1_id = 2
challenge = gen_challenge(app.db, name="Test Challenge", value=100)
challenge_id = challenge.id
gen_flag(app.db, challenge_id=challenge_id, content="flag{test}")
# Solve the challenge (required to rate)
gen_solve(
app.db, user_id=user1_id, challenge_id=challenge_id, provided="flag{test}"
)
client1 = login_as_user(app, name="user1", password="password")
admin_client = login_as_user(app, name="admin", password="password")
# Test that users cannot see ratings (GET request should fail)
r = client1.get(f"/api/v1/challenges/{challenge_id}/ratings", json=True)
assert r.status_code == 403 # Forbidden
# Test that users cannot leave ratings (PUT request should fail)
rating_data = {"value": 2, "review": "Trying to rate when disabled"}
r = client1.put(f"/api/v1/challenges/{challenge_id}/ratings", json=rating_data)
assert r.status_code == 403 # Forbidden
assert len(Ratings.query.all()) == 0
# Test that challenge detail doesn't include any rating info
r = client1.get(f"/api/v1/challenges/{challenge_id}")
assert r.status_code == 200
data = r.get_json()
assert data["success"] is True
assert data["data"]["ratings"] is None
assert data["data"]["rating"] is None
# Test that even admin cannot leave ratings when disabled
r = admin_client.put(
f"/api/v1/challenges/{challenge_id}/ratings", json=rating_data
)
assert r.status_code == 403 # Forbidden
# But admin can still see the endpoint (though it will be empty)
r = admin_client.get(f"/api/v1/challenges/{challenge_id}/ratings", json=True)
data = r.get_json()
assert len(data["data"]) == 0
assert len(Ratings.query.all()) == 0
destroy_ctfd(app)
def test_rating_requires_solve():
"""Test that users can only rate challenges they have solved"""
app = create_ctfd()
with app.app_context():
set_config("challenge_ratings", "public")
# Create a user and challenge
register_user(app, name="user1", email="user1@example.com")
user_id = 2
challenge = gen_challenge(app.db, name="Test Challenge", value=100)
chal_id = challenge.id
gen_flag(app.db, challenge_id=chal_id, content="flag{test}")
client = login_as_user(app, name="user1", password="password")
# Try to rate without solving first
rating_data = {"value": 1, "review": "Great challenge!"}
r = client.put(f"/api/v1/challenges/{chal_id}/ratings", json=rating_data)
assert r.status_code == 403
data = r.get_json()
assert data["success"] is False
assert "You must solve this challenge before rating it" in data["errors"][""][0]
assert len(Ratings.query.all()) == 0
# Now solve the challenge
gen_solve(app.db, user_id=user_id, challenge_id=chal_id, provided="flag{test}")
# Now rating should work
r = client.put(f"/api/v1/challenges/{chal_id}/ratings", json=rating_data)
assert r.status_code == 200
data = r.get_json()
assert data["success"] is True
assert data["data"]["value"] == 1
assert data["data"]["review"] == "Great challenge!"
assert len(Ratings.query.all()) == 1
destroy_ctfd(app)
def test_rating_validation():
"""Test rating input validation"""
app = create_ctfd()
with app.app_context():
set_config("challenge_ratings", "public")
# Create a user and challenge
register_user(app, name="user1", email="user1@example.com")
user_id = 2
challenge = gen_challenge(app.db, name="Test Challenge", value=100)
chal_id = challenge.id
gen_flag(app.db, challenge_id=chal_id, content="flag{test}")
# Solve the challenge
gen_solve(app.db, user_id=user_id, challenge_id=chal_id, provided="flag{test}")
client = login_as_user(app, name="user1", password="password")
# Test missing rating value
r = client.put(f"/api/v1/challenges/{chal_id}/ratings", json={})
assert r.status_code == 400
data = r.get_json()
assert data["success"] is False
assert "Rating value is required" in data["errors"]["value"][0]
# Test invalid rating value (not -1 or 1)
rating_data = {"value": 0}
r = client.put(f"/api/v1/challenges/{chal_id}/ratings", json=rating_data)
assert r.status_code == 400
data = r.get_json()
assert data["success"] is False
assert "Rating value must be either 1 or -1" in data["errors"]["value"][0]
# Test invalid rating value (too high)
rating_data = {"value": 2}
r = client.put(f"/api/v1/challenges/{chal_id}/ratings", json=rating_data)
assert r.status_code == 400
data = r.get_json()
assert data["success"] is False
assert "Rating value must be either 1 or -1" in data["errors"]["value"][0]
# Test invalid rating value (non-integer)
rating_data = {"value": "invalid"}
r = client.put(f"/api/v1/challenges/{chal_id}/ratings", json=rating_data)
assert r.status_code == 400
data = r.get_json()
assert data["success"] is False
assert "Rating value must be an integer" in data["errors"]["value"][0]
# Test review text too long
long_review = "x" * 2001 # Exceeds 2000 character limit
rating_data = {"value": 1, "review": long_review}
r = client.put(f"/api/v1/challenges/{chal_id}/ratings", json=rating_data)
assert r.status_code == 400
data = r.get_json()
assert data["success"] is False
assert (
"Review text cannot exceed 2000 characters" in data["errors"]["review"][0]
)
assert len(Ratings.query.all()) == 0
# Test valid rating with review
rating_data = {
"value": -1,
"review": "This is a valid review within the character limit.",
}
r = client.put(f"/api/v1/challenges/{chal_id}/ratings", json=rating_data)
assert r.status_code == 200
data = r.get_json()
assert data["success"] is True
assert data["data"]["value"] == -1
assert (
data["data"]["review"]
== "This is a valid review within the character limit."
)
assert len(Ratings.query.all()) == 1
destroy_ctfd(app)
def test_rating_update():
"""Test that users can update their existing ratings"""
app = create_ctfd()
with app.app_context():
set_config("challenge_ratings", "public")
# Create a user and challenge
register_user(app, name="user1", email="user1@example.com")
user_id = 2
challenge = gen_challenge(app.db, name="Test Challenge", value=100)
chal_id = challenge.id
gen_flag(app.db, challenge_id=challenge.id, content="flag{test}")
# Solve the challenge
gen_solve(app.db, user_id=user_id, challenge_id=chal_id, provided="flag{test}")
client = login_as_user(app, name="user1", password="password")
# Create initial rating
initial_rating = {"value": 1, "review": "Initial review"}
r = client.put(f"/api/v1/challenges/{chal_id}/ratings", json=initial_rating)
assert r.status_code == 200
data = r.get_json()
assert data["success"] is True
assert data["data"]["value"] == 1
assert data["data"]["review"] == "Initial review"
initial_id = data["data"]["id"]
assert len(Ratings.query.all()) == 1
r = Ratings.query.get(1)
assert r.value == 1
assert r.review == "Initial review"
# Update the rating
updated_rating = {
"value": -1,
"review": "Updated review after thinking more about it",
}
r = client.put(f"/api/v1/challenges/{chal_id}/ratings", json=updated_rating)
assert r.status_code == 200
data = r.get_json()
assert data["success"] is True
assert data["data"]["value"] == -1
assert data["data"]["review"] == "Updated review after thinking more about it"
assert data["data"]["id"] == initial_id # Same rating record, just updated
assert len(Ratings.query.all()) == 1
r = Ratings.query.get(1)
assert r.value == -1
assert r.review == "Updated review after thinking more about it"
# Verify only one rating exists in database
ratings = Ratings.query.filter_by(user_id=user_id, challenge_id=chal_id).all()
assert len(ratings) == 1
assert ratings[0].value == -1
assert ratings[0].review == "Updated review after thinking more about it"
destroy_ctfd(app)
def test_rating_without_authentication():
"""Test that unauthenticated users cannot rate challenges"""
app = create_ctfd()
with app.app_context():
set_config("challenge_ratings", "public")
challenge = gen_challenge(app.db, name="Test Challenge", value=100)
gen_flag(app.db, challenge_id=challenge.id, content="flag{test}")
# Try to rate without being logged in
with app.test_client() as client:
rating_data = {"value": 4, "review": "Great challenge!"}
r = client.put(
f"/api/v1/challenges/{challenge.id}/ratings", json=rating_data
)
assert r.status_code == 403
assert len(Ratings.query.all()) == 0
destroy_ctfd(app)
def test_ratings_upvote_downvote_count():
"""Test that upvote and downvote calculations are correct with multiple users and ratings"""
app = create_ctfd()
with app.app_context():
set_config("challenge_ratings", "public")
# Define test data for upvote/downvote ratings
user_ratings = [
{
"name": "user1",
"email": "user1@example.com",
"value": 1, # upvote
"review": "Excellent challenge!",
},
{
"name": "user2",
"email": "user2@example.com",
"value": -1, # downvote
"review": "Not great",
},
{
"name": "user3",
"email": "user3@example.com",
"value": 1, # upvote
"review": "Pretty good!",
},
]
# Expected upvotes/downvotes after each rating submission
expected_counts = [
{"up": 1, "down": 0, "count": 1}, # After user1
{"up": 1, "down": 1, "count": 2}, # After user2
{"up": 2, "down": 1, "count": 3}, # After user3
]
# Create users and challenge
users = []
user_ids = []
user_id_start = 2
for rating_data in user_ratings:
user = register_user(
app, name=rating_data["name"], email=rating_data["email"]
)
users.append(user)
user_ids.append(user_id_start) # Store ID immediately
user_id_start += 1
# Create admin client for accessing ratings endpoint
admin_client = login_as_user(app, name="admin", password="password")
challenge = gen_challenge(app.db, name="Test Challenge", value=100)
challenge_id = challenge.id # Store ID immediately
gen_flag(app.db, challenge_id=challenge_id, content="flag{test}")
# Solve the challenge for all users (required to rate)
for user_id in user_ids:
gen_solve(
app.db,
user_id=user_id,
challenge_id=challenge_id,
provided="flag{test}",
)
# Initially no ratings - check admin can see empty ratings endpoint
r = admin_client.get(f"/api/v1/challenges/{challenge_id}/ratings")
assert r.status_code == 200
data = r.get_json()
assert data["success"] is True
assert data["meta"]["summary"]["up"] == 0
assert data["meta"]["summary"]["down"] == 0
assert data["meta"]["summary"]["count"] == 0
# Submit ratings progressively and verify calculations using database creation
for i, rating_data in enumerate(user_ratings):
# Create rating directly in database using helper
gen_rating(
app.db,
user_id=user_ids[i],
challenge_id=challenge_id,
value=rating_data["value"],
review=rating_data["review"],
)
assert len(Ratings.query.all()) == i + 1
# Check admin ratings endpoint has correct upvote/downvote counts
r = admin_client.get(f"/api/v1/challenges/{challenge_id}/ratings")
assert r.status_code == 200
data = r.get_json()
assert data["success"] is True
assert data["meta"]["summary"]["up"] == expected_counts[i]["up"]
assert data["meta"]["summary"]["down"] == expected_counts[i]["down"]
assert data["meta"]["summary"]["count"] == expected_counts[i]["count"]
# Verify each user can see their own correct rating in challenge detail
for rating_data in user_ratings:
user_client = login_as_user(
app, name=rating_data["name"], password="password"
)
r = user_client.get(f"/api/v1/challenges/{challenge_id}")
data = r.get_json()
assert data["data"]["rating"]["value"] == rating_data["value"]
assert data["data"]["rating"]["review"] == rating_data["review"]
# Test rating update - user 1 changes their rating from upvote (1) to downvote (-1)
# Update rating directly in database
first_rating = Ratings.query.filter_by(
user_id=user_ids[0], challenge_id=challenge_id
).first()
first_rating.value = -1 # Change from upvote to downvote
first_rating.review = "Actually, not so great after reconsidering"
app.db.session.commit()
clear_ratings()
assert len(Ratings.query.all()) == len(
user_ratings
) # Still same number of ratings
# Update the expected values list for verification
user_ratings[0]["value"] = -1
user_ratings[0]["review"] = "Actually, not so great after reconsidering"
# Expected counts after update: user1 changed from upvote to downvote
# So now we have: user1(-1), user2(-1), user3(1) = 1 upvote, 2 downvotes
expected_updated_counts = {"up": 1, "down": 2, "count": 3}
# Check counts after update using admin ratings endpoint
r = admin_client.get(f"/api/v1/challenges/{challenge_id}/ratings")
assert r.status_code == 200
data = r.get_json()
print(data)
assert data["success"] is True
assert data["meta"]["summary"]["up"] == expected_updated_counts["up"]
assert data["meta"]["summary"]["down"] == expected_updated_counts["down"]
assert data["meta"]["summary"]["count"] == expected_updated_counts["count"]
# Verify updated user sees their new rating in challenge detail
user1_client = login_as_user(app, name="user1", password="password")
r = user1_client.get(f"/api/v1/challenges/{challenge_id}")
data = r.get_json()
assert data["data"]["rating"]["value"] == -1
assert (
data["data"]["rating"]["review"]
== "Actually, not so great after reconsidering"
)
# Verify all other users still see their original ratings in challenge detail
for user in user_ratings:
user_client = login_as_user(
app, name=user["name"], password="password", raise_for_error=False
)
r = user_client.get(f"/api/v1/challenges/{challenge_id}")
data = r.get_json()
assert data["data"]["rating"]["value"] == user["value"]
assert data["data"]["rating"]["review"] == user["review"]
# Verify database consistency
all_ratings = Ratings.query.filter_by(challenge_id=challenge_id).all()
assert len(all_ratings) == len(user_ratings)
rating_values = [r.value for r in all_ratings]
expected_values = sorted([rating["value"] for rating in user_ratings])
assert sorted(rating_values) == expected_values
# Verify counts match database reality
upvotes = sum(1 for value in rating_values if value == 1)
downvotes = sum(1 for value in rating_values if value == -1)
assert upvotes == expected_updated_counts["up"]
assert downvotes == expected_updated_counts["down"]
destroy_ctfd(app)

View File

@@ -0,0 +1,836 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from CTFd.models import Challenges
from CTFd.plugins.challenges import CTFdStandardChallenge
from CTFd.utils.security.signing import hmac
from tests.helpers import (
FakeRequest,
create_ctfd,
destroy_ctfd,
gen_flag,
gen_user,
login_as_user,
register_user,
)
def test_can_create_standard_dynamic_challenge():
"""Test that standard dynamic challenges can be made from the API/admin panel"""
app = create_ctfd()
with app.app_context():
register_user(app)
client = login_as_user(app, name="admin", password="password")
challenge_data = {
"name": "name",
"category": "category",
"description": "description",
"value": 100,
"function": "linear",
"initial": 100,
"decay": 20,
"minimum": 1,
"state": "hidden",
"type": "standard",
}
r = client.post("/api/v1/challenges", json=challenge_data)
assert r.get_json().get("data")["id"] == 1
challenges = Challenges.query.all()
assert len(challenges) == 1
challenge = challenges[0]
assert challenge.value == 100
assert challenge.initial == 100
assert challenge.decay == 20
assert challenge.minimum == 1
assert challenge.function == "linear"
destroy_ctfd(app)
def test_can_update_standard_dynamic_challenge():
app = create_ctfd()
with app.app_context():
challenge_data = {
"name": "name",
"category": "category",
"description": "description",
"value": 100,
"state": "hidden",
"type": "standard",
}
req = FakeRequest(form=challenge_data)
challenge = CTFdStandardChallenge.create(req)
assert challenge.value == 100
assert challenge.initial is None
assert challenge.decay is None
assert challenge.minimum is None
assert challenge.function == "static"
challenge_data = {
"name": "new_name",
"category": "category",
"description": "new_description",
"value": "200",
"function": "linear",
"initial": "200",
"decay": "40",
"minimum": "5",
"max_attempts": "0",
"state": "visible",
}
req = FakeRequest(form=challenge_data)
challenge = CTFdStandardChallenge.update(challenge, req)
assert challenge.name == "new_name"
assert challenge.description == "new_description"
assert challenge.value == 200
assert challenge.initial == 200
assert challenge.decay == 40
assert challenge.minimum == 5
assert challenge.function == "linear"
assert challenge.state == "visible"
destroy_ctfd(app)
def test_can_add_requirement_standard_dynamic_challenge():
"""Test that requirements can be added to dynamic challenges"""
app = create_ctfd()
with app.app_context():
challenge_data = {
"name": "name",
"category": "category",
"description": "description",
"function": "linear",
"value": 100,
"decay": 20,
"minimum": 1,
"state": "hidden",
"type": "standard",
}
req = FakeRequest(form=challenge_data)
challenge = CTFdStandardChallenge.create(req)
assert challenge.value == 100
assert challenge.initial == 100
assert challenge.decay == 20
assert challenge.minimum == 1
challenge_data = {
"name": "second_name",
"category": "category",
"description": "new_description",
"value": "200",
"initial": "200",
"decay": "40",
"minimum": "5",
"function": "logarithmic",
"max_attempts": "0",
"state": "visible",
}
req = FakeRequest(form=challenge_data)
challenge = CTFdStandardChallenge.create(req)
assert challenge.name == "second_name"
assert challenge.description == "new_description"
assert challenge.function == "logarithmic"
assert challenge.value == 200
assert challenge.initial == 200
assert challenge.decay == 40
assert challenge.minimum == 5
assert challenge.state == "visible"
challenge_data = {"requirements": [1]}
req = FakeRequest(form=challenge_data)
challenge = CTFdStandardChallenge.update(challenge, req)
assert challenge.requirements == [1]
destroy_ctfd(app)
def test_can_delete_standard_dynamic_challenge():
"""Test that dynamic challenges can be deleted"""
app = create_ctfd()
with app.app_context():
register_user(app)
client = login_as_user(app, name="admin", password="password")
challenge_data = {
"name": "name",
"category": "category",
"description": "description",
"value": 100,
"initial": 100,
"decay": 20,
"minimum": 1,
"function": "linear",
"state": "hidden",
"type": "standard",
}
r = client.post("/api/v1/challenges", json=challenge_data)
assert r.get_json().get("data")["id"] == 1
challenges = Challenges.query.all()
assert len(challenges) == 1
challenge = challenges[0]
CTFdStandardChallenge.delete(challenge)
challenges = Challenges.query.all()
assert len(challenges) == 0
destroy_ctfd(app)
def test_standard_dynamic_challenge_loses_value_properly():
app = create_ctfd()
with app.app_context():
register_user(app)
client = login_as_user(app, name="admin", password="password")
challenge_data = {
"name": "name",
"category": "category",
"description": "description",
"value": 100,
"initial": 100,
"decay": 20,
"minimum": 1,
"function": "logarithmic",
"state": "visible",
"type": "standard",
}
r = client.post("/api/v1/challenges", json=challenge_data)
assert r.get_json().get("data")["id"] == 1
gen_flag(app.db, challenge_id=1, content="flag")
for i, team_id in enumerate(range(2, 26)):
name = "user{}".format(team_id)
email = "user{}@examplectf.com".format(team_id)
# We need to bypass rate-limiting so gen_user instead of register_user
user = gen_user(app.db, name=name, email=email)
user_id = user.id
with app.test_client() as client:
# We need to bypass rate-limiting so creating a fake user instead of logging in
with client.session_transaction() as sess:
sess["id"] = user_id
sess["nonce"] = "fake-nonce"
sess["hash"] = hmac(user.password)
data = {"submission": "flag", "challenge_id": 1}
r = client.post("/api/v1/challenges/attempt", json=data)
resp = r.get_json()["data"]
assert resp["status"] == "correct"
chal = Challenges.query.filter_by(id=1).first()
if i >= 20:
assert chal.value == chal.minimum
else:
assert chal.initial >= chal.value
assert chal.value > chal.minimum
destroy_ctfd(app)
def test_standard_dynamic_challenge_doesnt_lose_value_on_update():
"""Standard Dynamic challenge updates without changing any values or solves shouldn't change the current value. See #1043"""
app = create_ctfd()
with app.app_context():
challenge_data = {
"name": "name",
"category": "category",
"description": "description",
"value": 10000,
"initial": 10000,
"decay": 4,
"minimum": 10,
"function": "logarithmic",
"state": "visible",
"type": "standard",
}
req = FakeRequest(form=challenge_data)
challenge = CTFdStandardChallenge.create(req)
challenge_id = challenge.id
gen_flag(app.db, challenge_id=challenge.id, content="flag")
register_user(app)
with login_as_user(app) as client:
data = {"submission": "flag", "challenge_id": challenge_id}
r = client.post("/api/v1/challenges/attempt", json=data)
assert r.status_code == 200
assert r.get_json()["data"]["status"] == "correct"
chal = Challenges.query.filter_by(id=challenge_id).first()
prev_chal_value = chal.value
chal = CTFdStandardChallenge.update(chal, req)
assert prev_chal_value == chal.value
destroy_ctfd(app)
def test_standard_dynamic_challenge_value_isnt_affected_by_hidden_users():
app = create_ctfd()
with app.app_context():
register_user(app)
client = login_as_user(app, name="admin", password="password")
challenge_data = {
"name": "name",
"category": "category",
"description": "description",
"value": 100,
"initial": 100,
"decay": 20,
"minimum": 1,
"function": "logarithmic",
"state": "visible",
"type": "standard",
}
r = client.post("/api/v1/challenges", json=challenge_data)
assert r.get_json().get("data")["id"] == 1
gen_flag(app.db, challenge_id=1, content="flag")
# Make a solve as a regular user. This should not affect the value.
data = {"submission": "flag", "challenge_id": 1}
r = client.post("/api/v1/challenges/attempt", json=data)
resp = r.get_json()["data"]
assert resp["status"] == "correct"
# Make solves as hidden users. Also should not affect value
for _, team_id in enumerate(range(2, 26)):
name = "user{}".format(team_id)
email = "user{}@examplectf.com".format(team_id)
# We need to bypass rate-limiting so gen_user instead of register_user
user = gen_user(app.db, name=name, email=email)
user.hidden = True
app.db.session.commit()
user_id = user.id
with app.test_client() as client:
# We need to bypass rate-limiting so creating a fake user instead of logging in
with client.session_transaction() as sess:
sess["id"] = user_id
sess["nonce"] = "fake-nonce"
sess["hash"] = hmac(user.password)
data = {"submission": "flag", "challenge_id": 1}
r = client.post("/api/v1/challenges/attempt", json=data)
assert r.status_code == 200
resp = r.get_json()["data"]
assert resp["status"] == "correct"
chal = Challenges.query.filter_by(id=1).first()
assert chal.value == chal.initial
destroy_ctfd(app)
def test_standard_dynamic_challenges_reset():
app = create_ctfd()
with app.app_context():
client = login_as_user(app, name="admin", password="password")
challenge_data = {
"name": "name",
"category": "category",
"description": "description",
"value": 100,
"initial": 100,
"decay": 20,
"minimum": 1,
"function": "linear",
"state": "hidden",
"type": "standard",
}
r = client.post("/api/v1/challenges", json=challenge_data)
assert Challenges.query.count() == 1
with client.session_transaction() as sess:
data = {"nonce": sess.get("nonce"), "challenges": "on"}
r = client.post("/admin/reset", data=data)
assert r.location.endswith("/admin/statistics")
assert Challenges.query.count() == 0
destroy_ctfd(app)
def test_standard_dynamic_challenge_linear_loses_value_properly():
app = create_ctfd()
with app.app_context():
register_user(app)
client = login_as_user(app, name="admin", password="password")
challenge_data = {
"name": "name",
"category": "category",
"description": "description",
"function": "linear",
"value": 100,
"initial": 100,
"decay": 5,
"minimum": 1,
"state": "visible",
"type": "standard",
}
r = client.post("/api/v1/challenges", json=challenge_data)
assert r.get_json().get("data")["id"] == 1
gen_flag(app.db, challenge_id=1, content="flag")
for i, team_id in enumerate(range(2, 26)):
name = "user{}".format(team_id)
email = "user{}@examplectf.com".format(team_id)
# We need to bypass rate-limiting so gen_user instead of register_user
user = gen_user(app.db, name=name, email=email)
user_id = user.id
with app.test_client() as client:
# We need to bypass rate-limiting so creating a fake user instead of logging in
with client.session_transaction() as sess:
sess["id"] = user_id
sess["nonce"] = "fake-nonce"
sess["hash"] = hmac(user.password)
data = {"submission": "flag", "challenge_id": 1}
r = client.post("/api/v1/challenges/attempt", json=data)
resp = r.get_json()["data"]
assert resp["status"] == "correct"
chal = Challenges.query.filter_by(id=1).first()
if i >= 20:
assert chal.value == chal.minimum
else:
assert chal.value == (chal.initial - (i * 5))
destroy_ctfd(app)
def test_standard_challenges_default_to_static_function():
"""Test that standard challenges by default are created with static function"""
app = create_ctfd()
with app.app_context():
register_user(app)
client = login_as_user(app, name="admin", password="password")
# Test creating challenge without specifying function - should default to static
challenge_data = {
"name": "default_challenge",
"category": "category",
"description": "description",
"value": 100,
"state": "visible",
"type": "standard",
}
r = client.post("/api/v1/challenges", json=challenge_data)
assert r.get_json().get("data")["id"] == 1
challenge = Challenges.query.filter_by(id=1).first()
# Assert that function defaults to "static"
assert challenge.function == "static"
assert challenge.value == 100
assert challenge.initial is None
assert challenge.decay is None
assert challenge.minimum is None
# Test with direct model creation (via CTFdStandardChallenge.create)
challenge_data2 = {
"name": "direct_challenge",
"category": "category",
"description": "description",
"value": 200,
"state": "visible",
"type": "standard",
# Note: no function specified
}
req = FakeRequest(form=challenge_data2)
challenge2 = CTFdStandardChallenge.create(req)
# Assert that function defaults to "static"
assert challenge2.function == "static"
assert challenge2.value == 200
assert challenge2.initial is None
assert challenge2.decay is None
assert challenge2.minimum is None
# Test that static challenges maintain their value after solves
gen_flag(app.db, challenge_id=challenge2.id, content="flag")
# Create a user and solve the challenge
user = gen_user(app.db, name="solver", email="solver@example.com")
with app.test_client() as test_client:
with test_client.session_transaction() as sess:
sess["id"] = user.id
sess["nonce"] = "fake-nonce"
sess["hash"] = hmac(user.password)
data = {"submission": "flag", "challenge_id": challenge2.id}
r = test_client.post("/api/v1/challenges/attempt", json=data)
resp = r.get_json()["data"]
assert resp["status"] == "correct"
# Challenge value should remain unchanged for static challenges
challenge2_after = Challenges.query.filter_by(id=challenge2.id).first()
assert challenge2_after.value == 200
assert challenge2_after.function == "static"
destroy_ctfd(app)
def test_standard_dynamic_challenge_update_overrides_manual_value():
"""Test that updating a dynamic challenge recalculates value even when a manual value is provided"""
app = create_ctfd()
with app.app_context():
# Create initial dynamic challenge
challenge_data = {
"name": "dynamic_challenge",
"category": "category",
"description": "description",
"function": "linear",
"initial": 100,
"decay": 10,
"minimum": 10,
"state": "visible",
"type": "standard",
}
req = FakeRequest(form=challenge_data)
challenge = CTFdStandardChallenge.create(req)
challenge_id = challenge.id
# Verify initial state
assert challenge.function == "linear"
assert challenge.value == 100 # Should match initial value
assert challenge.initial == 100
assert challenge.decay == 10
assert challenge.minimum == 10
# Add a flag and create some solves to change the dynamic value
gen_flag(app.db, challenge_id=challenge_id, content="flag")
# Create some users and solves to trigger value decay
for i in range(3):
user = gen_user(app.db, name=f"user{i}", email=f"user{i}@example.com")
with app.test_client() as client:
with client.session_transaction() as sess:
sess["id"] = user.id
sess["nonce"] = "fake-nonce"
sess["hash"] = hmac(user.password)
data = {"submission": "flag", "challenge_id": challenge_id}
r = client.post("/api/v1/challenges/attempt", json=data)
assert r.get_json()["data"]["status"] == "correct"
# Check that value has decreased due to linear decay
challenge_after_solves = Challenges.query.filter_by(id=challenge_id).first()
# Linear formula: initial - (decay * solve_count), with solve_count adjusted by -1
# So with 3 solves: 100 - (10 * 2) = 80
expected_value_after_solves = 80
assert challenge_after_solves.value == expected_value_after_solves
# Now update the challenge with a manual override value but keep same parameters
update_data = {
"name": "updated_dynamic_challenge",
"description": "updated description",
"value": 100, # This should be ignored since it's a dynamic challenge and value should be calculated on the fly
"function": "linear",
"initial": 100, # Keep same initial value
"decay": 10, # Keep same decay value
"minimum": 10, # Keep same minimum value
}
req = FakeRequest(form=update_data)
updated_challenge = CTFdStandardChallenge.update(challenge_after_solves, req)
# Verify that the manual value (100) was ignored and dynamic calculation maintained current value
# Since parameters didn't change and we have 3 solves, value should remain 80
assert (
updated_challenge.value == 80
) # Should stay at dynamically calculated value, not the sent value of 100
assert updated_challenge.initial == 100 # Parameters should remain the same
assert updated_challenge.decay == 10
assert updated_challenge.minimum == 10
assert updated_challenge.function == "linear"
# The name and description should be updated
assert updated_challenge.name == "updated_dynamic_challenge"
assert updated_challenge.description == "updated description"
# Test that subsequent solves continue to use the same parameters correctly
user4 = gen_user(app.db, name="user4", email="user4@example.com")
with app.test_client() as client:
with client.session_transaction() as sess:
sess["id"] = user4.id
sess["nonce"] = "fake-nonce"
sess["hash"] = hmac(user4.password)
data = {"submission": "flag", "challenge_id": challenge_id}
r = client.post("/api/v1/challenges/attempt", json=data)
assert r.get_json()["data"]["status"] == "correct"
# After 4 total solves: 100 - (10 * 3) = 70
final_challenge = Challenges.query.filter_by(id=challenge_id).first()
expected_final_value = 70
assert final_challenge.value == expected_final_value
destroy_ctfd(app)
def test_standard_dynamic_challenge_creation_requires_all_parameters():
"""Test that creating a dynamic challenge without initial, minimum, and decay raises an error"""
app = create_ctfd()
with app.app_context():
from CTFd.exceptions.challenges import ChallengeCreateException
# Test missing initial parameter for linear function
challenge_data_missing_initial = {
"name": "missing_initial",
"category": "category",
"description": "description",
"function": "linear",
"decay": 10,
"minimum": 5,
"state": "visible",
"type": "standard",
}
req = FakeRequest(form=challenge_data_missing_initial)
try:
CTFdStandardChallenge.create(req)
raise AssertionError(
"Should have raised ChallengeCreateException for missing initial"
)
except ChallengeCreateException as e:
assert "Missing 'initial'" in str(e)
assert "function is linear" in str(e)
# Test missing decay parameter for logarithmic function
challenge_data_missing_decay = {
"name": "missing_decay",
"category": "category",
"description": "description",
"function": "logarithmic",
"initial": 100,
"minimum": 5,
"state": "visible",
"type": "standard",
}
req = FakeRequest(form=challenge_data_missing_decay)
try:
CTFdStandardChallenge.create(req)
raise AssertionError(
"Should have raised ChallengeCreateException for missing decay"
)
except ChallengeCreateException as e:
assert "Missing 'decay'" in str(e)
assert "function is logarithmic" in str(e)
# Test missing minimum parameter for linear function
challenge_data_missing_minimum = {
"name": "missing_minimum",
"category": "category",
"description": "description",
"function": "linear",
"initial": 100,
"decay": 10,
"state": "visible",
"type": "standard",
}
req = FakeRequest(form=challenge_data_missing_minimum)
try:
CTFdStandardChallenge.create(req)
raise AssertionError(
"Should have raised ChallengeCreateException for missing minimum"
)
except ChallengeCreateException as e:
assert "Missing 'minimum'" in str(e)
assert "function is linear" in str(e)
# Test missing all dynamic parameters
challenge_data_missing_all = {
"name": "missing_all",
"category": "category",
"description": "description",
"function": "logarithmic",
"value": 100, # Only static value provided
"state": "visible",
"type": "standard",
}
req = FakeRequest(form=challenge_data_missing_all)
try:
CTFdStandardChallenge.create(req)
raise AssertionError(
"Should have raised ChallengeCreateException for missing all dynamic parameters"
)
except ChallengeCreateException as e:
# Should catch the first missing parameter (likely 'initial')
assert "Missing" in str(e)
assert "function is logarithmic" in str(e)
# Test that static challenges don't require dynamic parameters
static_challenge_data = {
"name": "static_challenge",
"category": "category",
"description": "description",
"function": "static",
"value": 100,
"state": "visible",
"type": "standard",
}
req = FakeRequest(form=static_challenge_data)
# This should NOT raise an exception
static_challenge = CTFdStandardChallenge.create(req)
assert static_challenge.function == "static"
assert static_challenge.value == 100
assert static_challenge.initial is None
assert static_challenge.decay is None
assert static_challenge.minimum is None
# Test that providing all required parameters works
valid_dynamic_challenge_data = {
"name": "valid_dynamic",
"category": "category",
"description": "description",
"function": "linear",
"initial": 200,
"decay": 15,
"minimum": 10,
"state": "visible",
"type": "standard",
}
req = FakeRequest(form=valid_dynamic_challenge_data)
# This should NOT raise an exception
valid_challenge = CTFdStandardChallenge.create(req)
assert valid_challenge.function == "linear"
assert valid_challenge.value == 200
assert valid_challenge.initial == 200
assert valid_challenge.decay == 15
assert valid_challenge.minimum == 10
destroy_ctfd(app)
def test_dynamic_challenge_update_requires_all_parameters():
"""Test that updating a challenge to dynamic function without initial, minimum, and decay raises an error"""
app = create_ctfd()
with app.app_context():
register_user(app)
client = login_as_user(app, name="admin", password="password")
# First create a static challenge via API
initial_challenge_data = {
"name": "static_challenge",
"category": "category",
"description": "description",
"value": 100,
"state": "visible",
"type": "standard",
}
r = client.post("/api/v1/challenges", json=initial_challenge_data)
assert r.status_code == 200
challenge_id = r.get_json().get("data")["id"]
# Verify it's initially static
challenge = Challenges.query.get(1)
assert challenge.function == "static"
assert challenge.value == 100
assert challenge.initial is None
assert challenge.decay is None
assert challenge.minimum is None
# Test updating to linear function missing initial parameter
update_data_missing_initial = {
"function": "linear",
"decay": 10,
"minimum": 5,
}
r = client.patch(
f"/api/v1/challenges/{challenge_id}", json=update_data_missing_initial
)
assert "Missing 'initial'" in r.get_json()["errors"][""][0]
assert r.status_code == 500 # Should fail validation
challenge = Challenges.query.get(challenge_id)
assert challenge.function == "static"
assert challenge.initial is None
assert challenge.decay is None
assert challenge.minimum is None
# Test updating to logarithmic function missing decay parameter
update_data_missing_decay = {
"function": "logarithmic",
"initial": 100,
"minimum": 5,
}
r = client.patch(
f"/api/v1/challenges/{challenge_id}", json=update_data_missing_decay
)
assert "Missing 'decay'" in r.get_json()["errors"][""][0]
assert r.status_code == 500 # Should fail validation
challenge = Challenges.query.get(challenge_id)
assert challenge.function == "static"
assert challenge.initial is None
assert challenge.decay is None
assert challenge.minimum is None
# Test updating to logarithmic function missing minimum parameter
update_data_missing_minimum = {
"function": "logarithmic",
"initial": 100,
"decay": 10,
}
r = client.patch(
f"/api/v1/challenges/{challenge_id}", json=update_data_missing_minimum
)
assert "Missing 'minimum'" in r.get_json()["errors"][""][0]
assert r.status_code == 500 # Should fail validation
challenge = Challenges.query.get(challenge_id)
assert challenge.function == "static"
assert challenge.initial is None
assert challenge.decay is None
assert challenge.minimum is None
# Test that updating with all valid parameters works
valid_update_data = {
"function": "linear",
"initial": 200,
"decay": 15,
"minimum": 10,
}
r = client.patch(f"/api/v1/challenges/{challenge_id}", json=valid_update_data)
assert r.status_code == 200
# Verify the challenge was updated correctly
challenge = Challenges.query.get(challenge_id)
assert challenge.function == "linear"
assert challenge.initial == 200
assert challenge.decay == 15
assert challenge.minimum == 10
destroy_ctfd(app)