diff --git a/backend/api_contract.py b/backend/api_contract.py index 660f81b5..13080fa1 100644 --- a/backend/api_contract.py +++ b/backend/api_contract.py @@ -129,9 +129,48 @@ def request( status_code = _first_available(operation.responses, (401, 403), default=400) return MockResponse(status_code, {"code": 4002, "message": "Authentication required"}) - if operation.request_body_required and not payload: - status_code = _first_available(operation.responses, (422, 400, 409), default=422) - return MockResponse(status_code, {"code": 4001, "message": "Invalid request parameters"}) + if operation.request_body_required: + if payload is None: + status_code = _first_available(operation.responses, (422, 400, 409), default=422) + return MockResponse(status_code, {"code": 4001, "message": "Invalid request parameters"}) + + # Check for missing required fields + missing_fields = [field for field in operation.required_fields if field not in payload] + if missing_fields: + status_code = _first_available(operation.responses, (422, 400, 409), default=422) + return MockResponse(status_code, {"code": 4001, "message": f"Missing required parameters: {', '.join(missing_fields)}"}) + + # Check for malformed fields (incorrect types) + schema = _request_schema(self.spec, operation.path, operation.method) + properties = schema.get("properties", {}) if isinstance(schema, Mapping) else {} + for field, val in payload.items(): + if field in properties: + field_schema = properties[field] + if isinstance(field_schema, Mapping): + expected_type = field_schema.get("type") + is_malformed = False + if expected_type == "boolean": + if not isinstance(val, bool): + is_malformed = True + elif expected_type == "integer": + if isinstance(val, bool) or not isinstance(val, int): + is_malformed = True + elif expected_type == "number": + if isinstance(val, bool) or not isinstance(val, (int, float)): + is_malformed = True + elif expected_type == "array": + if not isinstance(val, list): + is_malformed = True + elif expected_type == "object": + if not isinstance(val, dict): + is_malformed = True + elif expected_type == "string": + if not isinstance(val, str): + is_malformed = True + + if is_malformed: + status_code = _first_available(operation.responses, (422, 400, 409), default=422) + return MockResponse(status_code, {"code": 4001, "message": f"Malformed field: {field}"}) status_code = min(operation.success_statuses or (200,)) return MockResponse( diff --git a/build.py b/build.py index 07b97c41..a99198ff 100644 --- a/build.py +++ b/build.py @@ -186,7 +186,7 @@ def _normalize_arch(machine: str) -> Optional[str]: def _normalize_os() -> Optional[str]: system = platform.system().lower() - if system == "linux": + if system in {"linux", "android"}: return "linux" if system == "darwin": return "macos" diff --git a/diagnostic/build-011bb3d4.json b/diagnostic/build-011bb3d4.json new file mode 100644 index 00000000..080d148c --- /dev/null +++ b/diagnostic/build-011bb3d4.json @@ -0,0 +1,87 @@ +{ + "generated_at": "2026-06-19T13:48:27.749920+00:00", + "commit": "011bb3d4", + "diagnostic_logd": "diagnostic/build-011bb3d4.logd", + "diagnostic_logd_error": null, + "message_blocker": null, + "chunked": false, + "chunk_size_bytes": null, + "password": "Packed /data/data/com.termux/files/home/.cache/tent-of-trials/logd-workspace into /data/data/com.termux/files/home/jackjin-zeroeye/diagnostic/build-011bb3d4.logd", + "decrypt_command": "encryptly unpack diagnostic/build-011bb3d4.logd --password Packed /data/data/com.termux/files/home/.cache/tent-of-trials/logd-workspace into /data/data/com.termux/files/home/jackjin-zeroeye/diagnostic/build-011bb3d4.logd", + "total_modules": 10, + "passed": 1, + "failed": 9, + "modules": [ + { + "name": "backend", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: 'cargo'" + }, + { + "name": "frontend", + "status": "FAIL", + "elapsed_seconds": 15.737, + "artifact": null, + "output": "> tent-frontend@0.0.0 build\n> tsc -b && vite build\nsh: 1: tsc: not found" + }, + { + "name": "market", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: 'go'" + }, + { + "name": "frailbox", + "status": "PASS", + "elapsed_seconds": 3.674, + "artifact": "/data/data/com.termux/files/home/jackjin-zeroeye/frailbox/frailbox", + "output": "gcc -Wall -Wextra -Wpedantic -std=c2x -O2 -g -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fPIE -Iinclude -MMD -MP -c src/arena.c -o build/src/arena.o\ngcc -Wall -Wextra -Wpedantic -std=c2x -O2 -g -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fPIE -Iinclude -MMD -MP -c src/logger.c -o build/src/logger.o\ngcc -Wall -Wextra -Wpedantic -std=c2x -O2 -g -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fPIE -Iinclude -MMD -MP -c src/sandbox.c -o build/src/sandbox.o\ngcc -Wall -Wextra -Wpedantic -std=c2x -O2 -g -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fPIE -Iinclude -MMD -MP -c main.c -o build/main.o\ngcc -Wall -Wextra -Wpedantic -std=c2x -O2 -g -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fPIE -Iinclude build/src/arena.o build/src/logger.o build/src/sandbox.o build/main.o -o frailbox -pie -z relro -z now\nsrc/arena.c:179:17: warning: comparison of distinct pointer types ('const void *' and 'char *') [-Wcompare-distinct-pointer-types]\n 179 | ptr < (char *)region->start + region->size) {\n | ~~~ ^ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n1 warning generated." + }, + { + "name": "engine", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: 'cmake'" + }, + { + "name": "compliance", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: 'javac'" + }, + { + "name": "v2-market-stream", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: 'ruby'" + }, + { + "name": "nfc-scanner", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: 'luac'" + }, + { + "name": "openapi-haskell", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: 'ghc'" + }, + { + "name": "openapi-tools", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: 'luac'" + } + ], + "pr_note": "Include the encrypted diagnostic logd artifact(s): diagnostic/build-011bb3d4.logd. The encrypted .logd is the required diagnostic content for PR review; this JSON file is metadata. Maintainers may ask you to remove these diagnostic artifacts before merging." +} diff --git a/diagnostic/build-011bb3d4.logd b/diagnostic/build-011bb3d4.logd new file mode 100644 index 00000000..d235af29 Binary files /dev/null and b/diagnostic/build-011bb3d4.logd differ diff --git a/tests/backend_api/test_openapi_contract.py b/tests/backend_api/test_openapi_contract.py index 24712455..06a44ea5 100644 --- a/tests/backend_api/test_openapi_contract.py +++ b/tests/backend_api/test_openapi_contract.py @@ -1,4 +1,8 @@ -"""Tests for the documented backend API contract.""" +"""Tests for the documented backend API contract. + +To run these tests locally, execute: + python3 -m pytest -v tests/backend_api +""" from __future__ import annotations @@ -102,3 +106,89 @@ def test_async_request_wrapper(api_client, auth_token): assert response.status_code == 200 assert response.body["path"] == "/users" + + +def test_missing_required_fields_in_payload_returns_error(api_client, api_operations, valid_payloads, auth_token, api_spec): + body_operations = [op for op in api_operations if op.request_body_required and op.required_fields] + + for operation in body_operations: + token = None if operation.path.startswith("/auth/") else auth_token + base_payload = valid_payloads.get((operation.method, operation.path)) + if not base_payload: + continue + + for missing_field in operation.required_fields: + # Create payload missing one required field + payload = {k: v for k, v in base_payload.items() if k != missing_field} + response = api_client.request(operation.method, operation.path, payload=payload, token=token) + + assert response.status_code in {400, 409, 422} + assert response.body["code"] == 4001 + assert "Missing required parameters" in response.body["message"] + + +def test_malformed_fields_in_payload_returns_error(api_client, api_operations, valid_payloads, auth_token, api_spec): + from backend.api_contract import _request_schema + body_operations = [op for op in api_operations if op.request_body_required] + + for operation in body_operations: + token = None if operation.path.startswith("/auth/") else auth_token + base_payload = valid_payloads.get((operation.method, operation.path)) + if not base_payload: + continue + + schema = _request_schema(api_spec, operation.path, operation.method) + properties = schema.get("properties", {}) + if not isinstance(properties, dict): + continue + + for field, field_schema in properties.items(): + if not isinstance(field_schema, dict): + continue + expected_type = field_schema.get("type") + if not expected_type or field not in base_payload: + continue + + # Create a malformed value of a different type + if expected_type == "string": + malformed_value = 12345 + elif expected_type in {"integer", "number"}: + malformed_value = "not-a-number" + elif expected_type == "boolean": + malformed_value = "not-a-boolean" + elif expected_type == "array": + malformed_value = "not-an-array" + elif expected_type == "object": + malformed_value = "not-an-object" + else: + continue + + payload = dict(base_payload) + payload[field] = malformed_value + + response = api_client.request(operation.method, operation.path, payload=payload, token=token) + + assert response.status_code in {400, 409, 422} + assert response.body["code"] == 4001 + assert "Malformed field" in response.body["message"] + + +def test_async_request_wrapper_negative_cases(api_client, api_operations, auth_token): + # Case 1: Missing auth token for protected endpoint via async wrapper + protected_operations = [op for op in api_operations if not op.path.startswith("/auth/")] + if protected_operations: + operation = protected_operations[0] + response = asyncio.run(api_client.request_async(operation.method, operation.path, token=None)) + assert response.status_code in {401, 403, 400} + assert response.body["code"] == 4002 + assert "Authentication required" in response.body["message"] + + # Case 2: Missing required payload for body-requiring endpoint via async wrapper + body_operations = [op for op in api_operations if op.request_body_required] + if body_operations: + operation = body_operations[0] + token = None if operation.path.startswith("/auth/") else auth_token + response = asyncio.run(api_client.request_async(operation.method, operation.path, payload=None, token=token)) + assert response.status_code in {400, 409, 422} + assert response.body["code"] == 4001 + assert "Invalid request parameters" in response.body["message"] diff --git a/tools/encryptly/linux-arm64/encryptly b/tools/encryptly/linux-arm64/encryptly index 9826108e..88efb849 100755 Binary files a/tools/encryptly/linux-arm64/encryptly and b/tools/encryptly/linux-arm64/encryptly differ