From 96c659fce17a5aca17fb913cf587765bac90481f Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Wed, 24 Feb 2021 12:08:11 +0100 Subject: Hook up unittesting in the submit protocol --- backend/routes/forms/submit.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) (limited to 'backend/routes/forms/submit.py') diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index d8e6d35..85a4226 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -18,6 +18,7 @@ from starlette.responses import JSONResponse from backend.constants import FRONTEND_URL, FormFeatures, HCAPTCHA_API_SECRET from backend.models import Form, FormResponse from backend.route import Route +from backend.routes.forms.unittesting import execute_unittest from backend.validation import AuthorizationHeaders, ErrorMessage, api HCAPTCHA_VERIFY_URL = "https://hcaptcha.com/siteverify" @@ -127,6 +128,19 @@ class SubmitForm(Route): except ValidationError as e: return JSONResponse(e.errors(), status_code=422) + has_unittests = any("unittests" in question.data for question in form.questions) + if has_unittests: + unittest_results = await execute_unittest(response_obj, form) + + was_successful = all(test.passed for test in unittest_results) + if not was_successful: + status_code = 500 if any(test.return_code == 99 for test in unittest_results) else 200 + + return JSONResponse({ + "error": "failed_tests", + "test_results": [test._asdict() for test in unittest_results if not test.passed] + }, status_code=status_code) + await request.state.db.responses.insert_one( response_obj.dict(by_alias=True) ) -- cgit v1.2.3 From da6b581185e8bbe37e561a05827c8517824c7d2c Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Wed, 24 Feb 2021 13:53:08 +0100 Subject: Switch to 100 chars line length and get rid of the noqas --- backend/constants.py | 8 ++++---- backend/models/form.py | 2 +- backend/routes/forms/form.py | 2 +- backend/routes/forms/submit.py | 15 +++++++++++---- backend/routes/forms/unittesting.py | 3 ++- tox.ini | 4 +++- 6 files changed, 22 insertions(+), 12 deletions(-) (limited to 'backend/routes/forms/submit.py') diff --git a/backend/constants.py b/backend/constants.py index cccf437..59b56e0 100644 --- a/backend/constants.py +++ b/backend/constants.py @@ -1,9 +1,9 @@ from dotenv import load_dotenv -load_dotenv() +import os +import binascii +from enum import Enum -import os # noqa -import binascii # noqa -from enum import Enum # noqa +load_dotenv() FRONTEND_URL = os.getenv("FRONTEND_URL", "https://forms.pythondiscord.com") diff --git a/backend/models/form.py b/backend/models/form.py index 8e59905..eac0b63 100644 --- a/backend/models/form.py +++ b/backend/models/form.py @@ -47,7 +47,7 @@ class Form(BaseModel): if any(v not in allowed_values for v in value): raise ValueError("Form features list contains one or more invalid values.") - if FormFeatures.COLLECT_EMAIL in value and FormFeatures.REQUIRES_LOGIN not in value: # noqa + if FormFeatures.COLLECT_EMAIL in value and FormFeatures.REQUIRES_LOGIN not in value: raise ValueError("COLLECT_EMAIL feature require REQUIRES_LOGIN feature.") return value diff --git a/backend/routes/forms/form.py b/backend/routes/forms/form.py index b6b722e..e5f7ec6 100644 --- a/backend/routes/forms/form.py +++ b/backend/routes/forms/form.py @@ -26,7 +26,7 @@ class SingleForm(Route): @api.validate(resp=Response(HTTP_200=Form, HTTP_404=ErrorMessage), tags=["forms"]) async def get(self, request: Request) -> JSONResponse: """Returns single form information by ID.""" - admin = request.user.payload["admin"] if request.user.is_authenticated else False # noqa + admin = request.user.payload["admin"] if request.user.is_authenticated else False filters = { "_id": request.path_params["form_id"] diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index 85a4226..c19fc2d 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -100,7 +100,10 @@ class SubmitForm(Route): if request.user.is_authenticated: response["user"] = request.user.payload - if FormFeatures.COLLECT_EMAIL.value in form.features and "email" not in response["user"]: # noqa + if ( + FormFeatures.COLLECT_EMAIL.value in form.features + and "email" not in response["user"] + ): return JSONResponse({ "error": "email_required" }, status_code=400) @@ -134,11 +137,15 @@ class SubmitForm(Route): was_successful = all(test.passed for test in unittest_results) if not was_successful: - status_code = 500 if any(test.return_code == 99 for test in unittest_results) else 200 + status_code = 500 if any( + test.return_code == 99 for test in unittest_results + ) else 200 return JSONResponse({ "error": "failed_tests", - "test_results": [test._asdict() for test in unittest_results if not test.passed] + "test_results": [ + test._asdict() for test in unittest_results if not test.passed + ] }, status_code=status_code) await request.state.db.responses.insert_one( @@ -186,7 +193,7 @@ class SubmitForm(Route): embed = { "title": "New Form Response", "description": f"{mention} submitted a response to `{form.name}`.", - "url": f"{FRONTEND_URL}/path_to_view_form/{response.id}", # noqa # TODO: Enter Form View URL + "url": f"{FRONTEND_URL}/path_to_view_form/{response.id}", # TODO: Enter Form View URL "timestamp": response.timestamp, "color": 7506394, } diff --git a/backend/routes/forms/unittesting.py b/backend/routes/forms/unittesting.py index 3e1d280..fe8320f 100644 --- a/backend/routes/forms/unittesting.py +++ b/backend/routes/forms/unittesting.py @@ -51,7 +51,8 @@ async def execute_unittest(form_response: FormResponse, form: Form) -> list[Unit unit_code = _make_unit_code(question.data["unittests"]) user_code = _make_user_code(form_response.response[question.id]) - code = TEST_TEMPLATE.replace("### USER CODE", user_code).replace("### UNIT CODE", unit_code) + code = TEST_TEMPLATE.replace("### USER CODE", user_code) + code = code.replace("### UNIT CODE", unit_code) # Make sure that the code is well formatted (we don't check for the user code) try: diff --git a/tox.ini b/tox.ini index 48a3da6..afb3b34 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,10 @@ [flake8] -max-line-length=88 +max-line-length=100 exclude=.cache,.venv,.git docstring-convention=all import-order-style=pycharm ignore= # Type annotations ANN101,ANN102 + # Line breaks + W503 -- cgit v1.2.3 From d69f80e083ed1b9d91716609c7c063968aef22fa Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Wed, 24 Feb 2021 14:30:39 +0100 Subject: Return 403 on failed tests --- backend/routes/forms/submit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'backend/routes/forms/submit.py') diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index c19fc2d..7618a33 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -139,7 +139,7 @@ class SubmitForm(Route): if not was_successful: status_code = 500 if any( test.return_code == 99 for test in unittest_results - ) else 200 + ) else 403 return JSONResponse({ "error": "failed_tests", -- cgit v1.2.3 From 0a9026dcdd23eaf7c48256eb7da5af774892673b Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Wed, 24 Feb 2021 15:16:13 +0100 Subject: Document unittest code --- backend/routes/forms/submit.py | 2 ++ backend/routes/forms/unittesting.py | 20 ++++++++++++++------ resources/unittest_template.py | 13 +++++++------ 3 files changed, 23 insertions(+), 12 deletions(-) (limited to 'backend/routes/forms/submit.py') diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index 7618a33..d6b549e 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -131,12 +131,14 @@ class SubmitForm(Route): except ValidationError as e: return JSONResponse(e.errors(), status_code=422) + # Run unittests if needed has_unittests = any("unittests" in question.data for question in form.questions) if has_unittests: unittest_results = await execute_unittest(response_obj, form) was_successful = all(test.passed for test in unittest_results) if not was_successful: + # Return 500 if we encountered an internal error (code 99). status_code = 500 if any( test.return_code == 99 for test in unittest_results ) else 403 diff --git a/backend/routes/forms/unittesting.py b/backend/routes/forms/unittesting.py index 0cb7d8d..e038f3a 100644 --- a/backend/routes/forms/unittesting.py +++ b/backend/routes/forms/unittesting.py @@ -30,6 +30,7 @@ def filter_unittests(form: Form) -> Form: def _make_unit_code(units: dict[str, str]) -> str: + """Compose a dict mapping unit names to their code into an actual class body.""" result = "" for unit_name, unit_code in units.items(): @@ -39,14 +40,16 @@ def _make_unit_code(units: dict[str, str]) -> str: def _make_user_code(code: str) -> str: - # Make sure that we we escape triple quotes and backslashes in the user code - code = code.replace('"""', '\\"""').replace("\\", "\\\\") - return f'USER_CODE = """{code}"""' + """Compose the user code into an actual string variable.""" + # Make sure that we we escape triple quotes in the user code + code = code.replace('"""', '\\"""') + return f'USER_CODE = r"""{code}"""' async def _post_eval(code: str) -> Optional[dict[str, str]]: - data = {"input": code} + """Post the eval to snekbox and return the response.""" async with httpx.AsyncClient() as client: + data = {"input": code} response = await client.post(SNEKBOX_URL, json=data) if not response.status_code == 200: @@ -56,12 +59,14 @@ async def _post_eval(code: str) -> Optional[dict[str, str]]: async def execute_unittest(form_response: FormResponse, form: Form) -> list[UnittestResult]: + """Execute all the unittests in this form and return the results.""" unittest_results = [] for question in form.questions: if question.type == "code" and "unittests" in question.data: passed = False + # Tests starting with an hashtag should have censored names. hidden_test_counter = count(1) hidden_tests = { test.lstrip("#"): next(hidden_test_counter) @@ -69,19 +74,20 @@ async def execute_unittest(form_response: FormResponse, form: Form) -> list[Unit if test.startswith("#") } + # Compose runner code unit_code = _make_unit_code(question.data["unittests"]) user_code = _make_user_code(form_response.response[question.id]) code = TEST_TEMPLATE.replace("### USER CODE", user_code) code = code.replace("### UNIT CODE", unit_code) - # Make sure that the code is well formatted (we don't check for the user code) + # Make sure that the code is well formatted (we don't check for the user code). try: ast.parse(code) except SyntaxError: return_code = 99 result = "Invalid generated unit code." - + # The runner is correctly formatted, we can run it. else: response = await _post_eval(code) @@ -91,6 +97,7 @@ async def execute_unittest(form_response: FormResponse, form: Form) -> list[Unit else: return_code = int(response["returncode"]) + # Another code has been returned by CPython because of another failure. if return_code not in (0, 5, 99): return_code = 99 result = "Internal error." @@ -98,6 +105,7 @@ async def execute_unittest(form_response: FormResponse, form: Form) -> list[Unit stdout = response["stdout"] passed = bool(int(stdout[0])) + # If the test failed, we have to populate the result string. if not passed: failed_tests = stdout[1:].strip().split(";") diff --git a/resources/unittest_template.py b/resources/unittest_template.py index c792944..4c9b0bb 100644 --- a/resources/unittest_template.py +++ b/resources/unittest_template.py @@ -1,4 +1,5 @@ # flake8: noqa +"""This template is used inside snekbox to evaluate and test user code.""" import ast import io import os @@ -23,27 +24,26 @@ DEVNULL = SimpleNamespace(write=lambda *_: None, flush=lambda *_: None) RESULT = io.StringIO() ORIGINAL_STDOUT = sys.stdout +# stdout/err is patched in order to control what is outputted by the runner sys.stdout = DEVNULL sys.stderr = DEVNULL def _exit_sandbox(code: int) -> NoReturn: """ + Exit the sandbox by printing the result to the actual stdout and exit with the provided code. + Codes: - 0: Executed with success - 5: Syntax error while parsing user code - 99: Internal error """ - result_content = RESULT.getvalue() - - print( - f"{result_content}", - file=ORIGINAL_STDOUT - ) + print(RESULT.getvalue(), file=ORIGINAL_STDOUT, end="") sys.exit(code) def _load_user_module() -> ModuleType: + """Load the user code into a new module and return it.""" try: ast.parse(USER_CODE, "") except SyntaxError: @@ -74,6 +74,7 @@ def _main() -> None: try: + # Load the user code as a global module variable module = _load_user_module() _main() except Exception: -- cgit v1.2.3 From 06c01e78abcb0ab8713a3ad375218e98aab2882f Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 25 Feb 2021 14:44:26 +0100 Subject: Remove unneeded temp variable --- backend/routes/forms/submit.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) (limited to 'backend/routes/forms/submit.py') diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index d6b549e..b3a6afd 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -132,12 +132,10 @@ class SubmitForm(Route): return JSONResponse(e.errors(), status_code=422) # Run unittests if needed - has_unittests = any("unittests" in question.data for question in form.questions) - if has_unittests: + if any("unittests" in question.data for question in form.questions): unittest_results = await execute_unittest(response_obj, form) - was_successful = all(test.passed for test in unittest_results) - if not was_successful: + if not all(test.passed for test in unittest_results): # Return 500 if we encountered an internal error (code 99). status_code = 500 if any( test.return_code == 99 for test in unittest_results -- cgit v1.2.3