Skip to content

Commit 8d49699

Browse files
committed
Handle optional and unsupported challenge tests
1 parent 25334e8 commit 8d49699

3 files changed

Lines changed: 173 additions & 7 deletions

File tree

tools/dojo/parse-dojo-yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_valida
2222
ID_REGEX = re.compile(r"^[a-z0-9-]{1,32}$")
2323

2424
INHERIT_KEYS = ["privileged", "interfaces"]
25+
LOCAL_CHALLENGE_CONFIG_KEYS = {"allow_unsupported_tests"}
2526

2627

2728
class Visibility(BaseModel):
@@ -278,6 +279,11 @@ def main() -> int:
278279
if resource.get("type") == "challenge":
279280
challenge_id = resource.get("id")
280281
challenge_data, challenge_description_md = challenge_reads.get(challenge_id, ({}, None))
282+
challenge_data = {
283+
key: value
284+
for key, value in challenge_data.items()
285+
if key not in LOCAL_CHALLENGE_CONFIG_KEYS
286+
}
281287

282288
resource = {**challenge_data, **resource, "type": "challenge", "id": challenge_id}
283289
resource.setdefault(
@@ -300,6 +306,9 @@ def main() -> int:
300306

301307
challenge_id = challenge.get("id")
302308
challenge_data, challenge_description_md = challenge_reads.get(challenge_id, ({}, None))
309+
challenge_data = {
310+
key: value for key, value in challenge_data.items() if key not in LOCAL_CHALLENGE_CONFIG_KEYS
311+
}
303312

304313
resource = {**challenge_data, **challenge, "type": "challenge", "id": challenge_id}
305314
resource.setdefault(

tools/pwnshop/src/pwnshop/commands/test.py

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import click
1111
import requests
12+
import yaml
1213
from rich.progress import (
1314
BarColumn,
1415
MofNCompleteColumn,
@@ -23,6 +24,27 @@
2324

2425
logger = logging.getLogger(__name__)
2526

27+
UNSUPPORTED_TEST_EXIT_CODE = 77
28+
29+
30+
def _read_metadata(path: pathlib.Path) -> dict:
31+
if not path.is_file():
32+
return {}
33+
metadata = yaml.safe_load(path.read_text()) or {}
34+
return metadata if isinstance(metadata, dict) else {}
35+
36+
37+
def _allows_unsupported_tests(challenge_path: pathlib.Path) -> bool:
38+
return _read_metadata(challenge_path / "challenge.yml").get("allow_unsupported_tests") is True
39+
40+
41+
def _challenge_requires_solve(challenge_path: pathlib.Path) -> bool:
42+
module = _read_metadata(challenge_path.parent / "module.yml")
43+
for resource in module.get("resources", []):
44+
if resource.get("type") == "challenge" and resource.get("id") == challenge_path.name:
45+
return resource.get("required", True) is not False
46+
return True
47+
2648

2749
def run_workspace_command(
2850
workspace_url: str,
@@ -73,7 +95,7 @@ def run_workspace_command(
7395
default=None,
7496
help="Timeout in seconds for each individual test.",
7597
)
76-
@click.option("--require-solved", is_flag=True, help="Fail if any challenge is unsolved.")
98+
@click.option("--require-solved", is_flag=True, help="Fail if any required challenge is unsolved.")
7799
@click.option(
78100
"--log-failures",
79101
metavar="DIR",
@@ -110,22 +132,25 @@ def test_challenge(challenge_path):
110132
challenge_path = pathlib.Path(challenge_path)
111133
rendered = None
112134
try:
135+
allow_unsupported_tests = _allows_unsupported_tests(challenge_path)
136+
requires_solve = _challenge_requires_solve(challenge_path)
113137
rendered = lib.render_challenge(challenge_path)
114138
image_id = lib.build_challenge(challenge_path)
115139
tests = sorted(rendered.rglob("test*/test_*"))
116140
if not tests:
117141
logger.warning("no tests found for %s", challenge_path)
118-
return {"path": challenge_path, "tests": [], "solved": False}
142+
return {"path": challenge_path, "tests": [], "solved": not requires_solve}
119143
logger.info("running %d test(s) for %s", len(tests), challenge_path)
120144
results = []
121-
solved = False
145+
solved = not requires_solve
122146
for test in tests:
123147
test_name = test.relative_to(rendered)
124148
logger.debug("running test %s in %s", test_name, challenge_path)
125149
passed = False
126150
last_output = ""
127151
failed_attempt_outputs = []
128152
for attempt in range(1, attempts + 1):
153+
test_unsupported = False
129154
logger.debug("running test %s in %s (attempt %d/%d)", test_name, challenge_path, attempt, attempts)
130155
with lib.run_challenge(challenge_path, image_id, volumes=[test]) as (
131156
_container,
@@ -148,17 +173,18 @@ def test_challenge(challenge_path):
148173
last_output += e.stderr
149174
passed = False
150175
else:
151-
passed = run.returncode == 0
176+
test_unsupported = allow_unsupported_tests and run.returncode == UNSUPPORTED_TEST_EXIT_CODE
152177
last_output = (run.stdout or "") + (run.stderr or "")
178+
passed = run.returncode == 0 or test_unsupported
153179
logger.debug(
154180
"test %s %s (rc=%d, attempt %d/%d)",
155181
test_name,
156-
"PASSED" if passed else "FAILED",
182+
"UNSUPPORTED" if test_unsupported else "PASSED" if passed else "FAILED",
157183
run.returncode,
158184
attempt,
159185
attempts,
160186
)
161-
solved = solved or flag in last_output
187+
solved = solved or test_unsupported or flag in last_output
162188
if passed:
163189
if attempt > 1:
164190
logger.info(
@@ -217,7 +243,8 @@ def test_challenge(challenge_path):
217243
else:
218244
console.print(f"[red]FAIL[/] {challenge}: {error}")
219245
elif not tests:
220-
unsolved.add(challenge)
246+
if not solved:
247+
unsolved.add(challenge)
221248
passed_count += 1
222249
else:
223250
for test_path, passed, output in tests:
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import contextlib
2+
import pathlib
3+
import subprocess
4+
import sys
5+
import tempfile
6+
import unittest
7+
from unittest import mock
8+
9+
from click.testing import CliRunner
10+
11+
12+
PWNSHOP_ROOT = pathlib.Path(__file__).resolve().parents[1]
13+
sys.path.insert(0, str(PWNSHOP_ROOT / "src"))
14+
15+
from pwnshop.commands import test as test_command # noqa: E402
16+
17+
18+
class TestCommandMetadataTests(unittest.TestCase):
19+
@staticmethod
20+
def challenge_context(flag):
21+
@contextlib.contextmanager
22+
def manager():
23+
yield "container-id", "http://workspace/", flag
24+
25+
return manager()
26+
27+
def invoke_test_command(
28+
self,
29+
returncode,
30+
*,
31+
allow_unsupported_tests=False,
32+
attempts=1,
33+
required=None,
34+
):
35+
with tempfile.TemporaryDirectory() as directory:
36+
module = pathlib.Path(directory)
37+
challenge = module / "challenge"
38+
challenge.mkdir()
39+
if allow_unsupported_tests:
40+
(challenge / "challenge.yml").write_text("allow_unsupported_tests: true\n")
41+
if required is not None:
42+
(module / "module.yml").write_text(
43+
f"resources:\n - type: challenge\n id: challenge\n required: {str(required).lower()}\n"
44+
)
45+
46+
rendered = module / "rendered"
47+
test = rendered / "tests_private" / "test_solve.py"
48+
test.parent.mkdir(parents=True)
49+
test.write_text("#!/usr/bin/python3\n")
50+
51+
arguments = [
52+
"--jobs",
53+
"1",
54+
"--attempts",
55+
str(attempts),
56+
"--require-solved",
57+
str(challenge),
58+
]
59+
outcome = subprocess.CompletedProcess(
60+
[str(test)],
61+
returncode,
62+
stdout="test output without the generated flag\n",
63+
stderr="",
64+
)
65+
66+
with (
67+
mock.patch.object(test_command.lib, "resolve_targets", return_value=[challenge]),
68+
mock.patch.object(test_command.lib, "render_challenge", return_value=rendered),
69+
mock.patch.object(test_command.lib, "build_challenge", return_value="image-id"),
70+
mock.patch.object(
71+
test_command.lib,
72+
"run_challenge",
73+
side_effect=lambda *_args, **_kwargs: self.challenge_context("generated-flag"),
74+
) as run_challenge,
75+
mock.patch.object(test_command, "run_workspace_command", return_value=outcome) as run_test,
76+
):
77+
result = CliRunner().invoke(test_command.test_command, arguments)
78+
79+
return result, run_challenge.call_count, run_test.call_count
80+
81+
def test_optional_challenge_does_not_need_to_emit_the_flag(self):
82+
result, container_calls, test_calls = self.invoke_test_command(0, required=False)
83+
84+
self.assertEqual(result.exit_code, 0, result.output)
85+
self.assertEqual((container_calls, test_calls), (1, 1))
86+
self.assertNotIn("Unsolved challenges", result.output)
87+
88+
def test_optional_challenge_test_failure_still_fails(self):
89+
result, container_calls, test_calls = self.invoke_test_command(1, required=False)
90+
91+
self.assertEqual(result.exit_code, 1)
92+
self.assertEqual((container_calls, test_calls), (1, 1))
93+
self.assertIn("Some tests failed", result.output)
94+
self.assertNotIn("Unsolved challenges", result.output)
95+
96+
def test_unsupported_test_is_exempt_and_not_retried(self):
97+
result, container_calls, test_calls = self.invoke_test_command(
98+
test_command.UNSUPPORTED_TEST_EXIT_CODE,
99+
allow_unsupported_tests=True,
100+
attempts=4,
101+
)
102+
103+
self.assertEqual(result.exit_code, 0, result.output)
104+
self.assertEqual((container_calls, test_calls), (1, 1))
105+
self.assertNotIn("Unsolved challenges", result.output)
106+
107+
def test_unsupported_exit_requires_opt_in(self):
108+
result, container_calls, test_calls = self.invoke_test_command(test_command.UNSUPPORTED_TEST_EXIT_CODE)
109+
110+
self.assertEqual(result.exit_code, 1)
111+
self.assertEqual((container_calls, test_calls), (1, 1))
112+
self.assertIn("Some tests failed", result.output)
113+
114+
def test_other_nonzero_exit_is_not_unsupported(self):
115+
result, container_calls, test_calls = self.invoke_test_command(1, allow_unsupported_tests=True)
116+
117+
self.assertEqual(result.exit_code, 1)
118+
self.assertEqual((container_calls, test_calls), (1, 1))
119+
self.assertIn("Some tests failed", result.output)
120+
121+
def test_required_challenge_without_flag_is_still_unsolved(self):
122+
result, container_calls, test_calls = self.invoke_test_command(0)
123+
124+
self.assertEqual(result.exit_code, 1)
125+
self.assertEqual((container_calls, test_calls), (1, 1))
126+
self.assertIn("Unsolved challenges", result.output)
127+
128+
129+
if __name__ == "__main__":
130+
unittest.main()

0 commit comments

Comments
 (0)