aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.github/CODEOWNERS4
-rw-r--r--README.md2
-rw-r--r--SCHEMA.md7
-rw-r--r--backend/constants.py9
-rw-r--r--backend/models/form.py2
-rw-r--r--backend/routes/forms/form.py4
-rw-r--r--backend/routes/forms/submit.py23
-rw-r--r--backend/routes/forms/unittesting.py127
-rw-r--r--docker-compose.yml9
-rw-r--r--resources/unittest_template.py90
-rw-r--r--tox.ini4
11 files changed, 271 insertions, 10 deletions
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 483fdc7..3fa665c 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1,2 +1,2 @@
-# Request joe and ks123 for all PRs.
-* @jb3 @ks129
+# Request joe, ks123, HassanAbouelela for all PRs.
+* @jb3 @ks129 @HassanAbouelela
diff --git a/README.md b/README.md
index ea20937..59bdf17 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,7 @@ To start working on forms-backend, you'll need few things:
2. Poetry
3. Docker and docker-compose (optional)
4. Running MongoDB instance (when not using Docker)
+5. Running [Snekbox](https://git.pydis.com/snekbox) instance (when not using Docker, optional)
### Running with Docker
The easiest way to run forms-backend is using Docker (and docker-compose).
@@ -30,6 +31,7 @@ Create a `.env` file with the same contents as the Docker section above and the
- `FRONTEND_URL`: Forms frontend URL.
- `DATABASE_URL`: MongoDB instance URI, in format `mongodb://(username):(password)@(database IP or domain):(port)`.
- `MONGO_DB`: MongoDB database name, defaults to `pydis_forms`.
+- `SNEKBOX_URL`: Snekbox evaluation endpoint.
#### Running
Simply run: `$ uvicorn --reload --host 0.0.0.0 --debug backend:app`.
diff --git a/SCHEMA.md b/SCHEMA.md
index 6aae7d6..ada828e 100644
--- a/SCHEMA.md
+++ b/SCHEMA.md
@@ -123,7 +123,12 @@ Textareas require no additional configuration.
```js
{
// A supported language from https://prismjs.com/#supported-languages
- "language": "python"
+ "language": "python",
+ // An optinal mapping of unit tests
+ "unittests": {
+ "unit_1": "unit_code()",
+ ...
+ }
}
```
diff --git a/backend/constants.py b/backend/constants.py
index af25d84..e1f4a5b 100644
--- a/backend/constants.py
+++ b/backend/constants.py
@@ -1,14 +1,15 @@
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")
DATABASE_URL = os.getenv("DATABASE_URL")
MONGO_DATABASE = os.getenv("MONGO_DATABASE", "pydis_forms")
+SNEKBOX_URL = os.getenv("SNEKBOX_URL", "http://snekbox.default.svc.cluster.local/eval")
PRODUCTION = os.getenv("PRODUCTION", "True").lower() != "false"
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 e3360b1..1c6e44a 100644
--- a/backend/routes/forms/form.py
+++ b/backend/routes/forms/form.py
@@ -10,6 +10,7 @@ from starlette.responses import JSONResponse
from backend.models import Form
from backend.route import Route
+from backend.routes.forms.unittesting import filter_unittests
from backend.validation import ErrorMessage, OkayResponse, api
@@ -37,6 +38,9 @@ class SingleForm(Route):
if raw_form := await request.state.db.forms.find_one(filters):
form = Form(**raw_form)
+ if not admin:
+ form = filter_unittests(form)
+
return JSONResponse(form.dict(admin=admin))
return JSONResponse({"error": "not_found"}, status_code=404)
diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py
index 8627a29..4224586 100644
--- a/backend/routes/forms/submit.py
+++ b/backend/routes/forms/submit.py
@@ -20,6 +20,7 @@ from backend import constants
from backend.authentication.user import User
from backend.models import Form, FormResponse
from backend.route import Route
+from backend.routes.forms.unittesting import execute_unittest
from backend.validation import ErrorMessage, api
HCAPTCHA_VERIFY_URL = "https://hcaptcha.com/siteverify"
@@ -129,7 +130,10 @@ class SubmitForm(Route):
response["user"] = request.user.payload
response["user"]["admin"] = request.user.admin
- if constants.FormFeatures.COLLECT_EMAIL.value in form.features and "email" not in response["user"]: # noqa
+ if (
+ constants.FormFeatures.COLLECT_EMAIL.value in form.features
+ and "email" not in response["user"]
+ ):
return JSONResponse({
"error": "email_required"
}, status_code=400)
@@ -157,6 +161,23 @@ class SubmitForm(Route):
except ValidationError as e:
return JSONResponse(e.errors(), status_code=422)
+ # Run unittests if needed
+ if any("unittests" in question.data for question in form.questions):
+ unittest_results = await execute_unittest(response_obj, form)
+
+ 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
+ ) else 403
+
+ 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)
)
diff --git a/backend/routes/forms/unittesting.py b/backend/routes/forms/unittesting.py
new file mode 100644
index 0000000..3854314
--- /dev/null
+++ b/backend/routes/forms/unittesting.py
@@ -0,0 +1,127 @@
+import base64
+from collections import namedtuple
+from itertools import count
+from textwrap import indent
+
+import httpx
+from httpx import HTTPStatusError
+
+from backend.constants import SNEKBOX_URL
+from backend.models import FormResponse, Form
+
+with open("resources/unittest_template.py") as file:
+ TEST_TEMPLATE = file.read()
+
+
+UnittestResult = namedtuple("UnittestResult", "question_id return_code passed result")
+
+
+def filter_unittests(form: Form) -> Form:
+ """
+ Replace the unittest data section of code questions with the number of test cases.
+
+ This is used to redact the exact tests when sending the form back to the frontend.
+ """
+ for question in form.questions:
+ if question.type == "code" and "unittests" in question.data:
+ question.data["unittests"] = len(question.data["unittests"])
+
+ return 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():
+ result += (
+ f"\ndef test_{unit_name.lstrip('#')}(unit):" # Function definition
+ f"\n{indent(unit_code, ' ')}" # Unit code
+ )
+
+ return indent(result, " ")
+
+
+def _make_user_code(code: str) -> str:
+ """Compose the user code into an actual base64-encoded string variable."""
+ code = base64.b64encode(code.encode("utf8")).decode("utf8")
+ return f'USER_CODE = b"{code}"'
+
+
+async def _post_eval(code: str) -> dict[str, str]:
+ """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, timeout=10)
+
+ response.raise_for_status()
+ return response.json()
+
+
+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("#").lstrip("test_"): next(hidden_test_counter)
+ for test in question.data["unittests"].keys()
+ 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)
+
+ try:
+ response = await _post_eval(code)
+ except HTTPStatusError:
+ return_code = 99
+ result = "Unable to contact code runner."
+ else:
+ return_code = int(response["returncode"])
+
+ # Parse the stdout if the tests ran successfully
+ if return_code == 0:
+ 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(";")
+
+ # Redact failed hidden tests
+ for i, failed_test in enumerate(failed_tests.copy()):
+ if failed_test in hidden_tests:
+ failed_tests[i] = f"hidden_test_{hidden_tests[failed_test]}"
+
+ result = ";".join(failed_tests)
+ else:
+ result = ""
+ elif return_code in (5, 6, 99):
+ result = response["stdout"]
+ # Killed by NsJail
+ elif return_code == 137:
+ return_code = 7
+ result = "Timed out or ran out of memory."
+ # Another code has been returned by CPython because of another failure.
+ else:
+ return_code = 99
+ result = "Internal error."
+
+ unittest_results.append(UnittestResult(
+ question_id=question.id,
+ return_code=return_code,
+ passed=passed,
+ result=result
+ ))
+
+ return unittest_results
diff --git a/docker-compose.yml b/docker-compose.yml
index d44b4e0..4e58ef7 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -10,6 +10,13 @@ services:
MONGO_INITDB_ROOT_PASSWORD: forms-backend
MONGO_INITDB_DATABASE: pydis_forms
+ snekbox:
+ image: ghcr.io/python-discord/snekbox:latest
+ ipc: none
+ ports:
+ - "127.0.0.1:8060:8060"
+ privileged: true
+
backend:
build:
context: .
@@ -19,11 +26,13 @@ services:
- "127.0.0.1:8000:8000"
depends_on:
- mongo
+ - snekbox
tty: true
volumes:
- .:/app:ro
environment:
- DATABASE_URL=mongodb://forms-backend:forms-backend@mongo:27017
+ - SNEKBOX_URL=http://snekbox:8060/eval
- OAUTH2_CLIENT_ID
- OAUTH2_CLIENT_SECRET
- ALLOWED_URL
diff --git a/resources/unittest_template.py b/resources/unittest_template.py
new file mode 100644
index 0000000..2410278
--- /dev/null
+++ b/resources/unittest_template.py
@@ -0,0 +1,90 @@
+# flake8: noqa
+"""This template is used inside snekbox to evaluate and test user code."""
+import ast
+import base64
+import io
+import os
+import sys
+import traceback
+import unittest
+from itertools import chain
+from types import ModuleType, SimpleNamespace
+from typing import NoReturn
+from unittest import mock
+
+### USER CODE
+
+
+class RunnerTestCase(unittest.TestCase):
+### UNIT CODE
+
+
+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
+ - 6: Uncaught exception while loading user code
+ - 99: Internal error
+
+ 137 can also be generated by NsJail when killing the process.
+ """
+ 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."""
+ code = base64.b64decode(USER_CODE).decode("utf8")
+ try:
+ ast.parse(code, "<input>")
+ except SyntaxError:
+ RESULT.write("".join(traceback.format_exception(*sys.exc_info(), limit=0)))
+ _exit_sandbox(5)
+
+ _module = ModuleType("module")
+ exec(code, _module.__dict__)
+
+ return _module
+
+
+def _main() -> None:
+ suite = unittest.defaultTestLoader.loadTestsFromTestCase(RunnerTestCase)
+ result = suite.run(unittest.TestResult())
+
+ RESULT.write(str(int(result.wasSuccessful())))
+
+ if not result.wasSuccessful():
+ RESULT.write(
+ ";".join(chain(
+ (error[0]._testMethodName.lstrip("test_") for error in result.errors),
+ (failure[0]._testMethodName.lstrip("test_") for failure in result.failures)
+ ))
+ )
+
+ _exit_sandbox(0)
+
+
+try:
+ # Fake file object not writing anything
+ 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
+
+ # Load the user code as a global module variable
+ try:
+ module = _load_user_module()
+ except Exception:
+ RESULT.write("Uncaught exception while loading user code.")
+ _exit_sandbox(6)
+ _main()
+except Exception:
+ RESULT.write("Uncaught exception inside runner.")
+ _exit_sandbox(99)
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