init CTFd source
This commit is contained in:
0
tests/challenges/__init__.py
Normal file
0
tests/challenges/__init__.py
Normal file
61
tests/challenges/test_base_challenge.py
Normal file
61
tests/challenges/test_base_challenge.py
Normal 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)
|
||||
275
tests/challenges/test_challenge_logic.py
Normal file
275
tests/challenges/test_challenge_logic.py
Normal 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)
|
||||
76
tests/challenges/test_challenge_types.py
Normal file
76
tests/challenges/test_challenge_types.py
Normal 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)
|
||||
402
tests/challenges/test_dynamic.py
Normal file
402
tests/challenges/test_dynamic.py
Normal 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)
|
||||
598
tests/challenges/test_ratings.py
Normal file
598
tests/challenges/test_ratings.py
Normal 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)
|
||||
836
tests/challenges/test_standard_dynamic.py
Normal file
836
tests/challenges/test_standard_dynamic.py
Normal 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)
|
||||
Reference in New Issue
Block a user