diff --git a/tests/README.md b/tests/README.md index 9715e81..90117fc 100644 --- a/tests/README.md +++ b/tests/README.md @@ -10,85 +10,35 @@ This directory contains the automated and manual test scripts for the Aether Fas - **`tools/`**: Utility scripts for administrative tasks like registry generation or Docker exploration. - **`archive/`**: Legacy or deprecated scripts kept for historical reference. -## ๐Ÿ› ๏ธ Shared Helpers +## ๐Ÿ“œ Standardized E2E Suite (`tests/e2e/`) -- **`mock_config_helper.py`**: A critical utility that mocks `app.config.settings` before other modules are imported. Use this in unit tests to prevent the application from trying to load real configuration files during import. +These consolidated scripts are the primary verification tool for the V3 API. + +| Script | Description | +| :--- | :--- | +| `test_e2e_v3_search_engine.py` | **Primary Search**: Basic operators, Registry fields, Nested search, and Filter bypass. | +| `test_e2e_v3_auth_security.py` | **Primary Auth**: Site bootstrap, Passcode-to-JWT, and permission boundaries. | +| `test_e2e_v3_actions_file_lifecycle.py` | **Primary Actions**: Upload, Download (ID/Hash/Streaming), and physical Deletion. | +| `test_e2e_v3_data_store_lookup.py` | **V3 Parity**: Verifies code-based lookups and latency simulation. | +| `test_e2e_v3_event_vision_parity.py`| **Vision ID**: Verifies string-ID enforcement across event models. | +| `test_e2e_v3_accounts.py` | CRUD verification for the core Account object. | +| `test_e2e_v3_schema.py` | Network verification of the V3 metadata discovery endpoint. | +| `test_e2e_agent_bridge.py` | Verifies container diagnostics and log streaming routes. | +| `cleanup_test_files.py` | Utility to purge E2E artifacts from physical storage. | --- -## ๐Ÿ“œ Script Inventory +## ๐Ÿ› ๏ธ Shared Helpers -### Unit Tests (`tests/unit/`) -| Script | Description | -| :--- | :--- | -| `test_unit_email.py` | Unit tests for email logic with full SMTP mocking. | -| `test_unit_errors.py` | Verifies the regex-based SQL error string cleanup logic. | -| `test_unit_filtering.py` | Ensures virtual/view fields are correctly marked for DB exclusion. | -| `test_unit_hosted_file_logic.py` | Validates Hosted File model aliasing and ID mapping. | -| `test_unit_hosted_file_resolver.py` | Tests `lookup_id_random_pop` ID resolution logic. | -| `test_unit_models.py` | Validates custom Pydantic validators (e.g., Person given_name). | -| `test_unit_payload_sanitization.py` | **Primary Logic Test**: Verifies payload stripping and ID resolution. | -| `test_unit_router_stripping.py` | Simulates automatic removal of random IDs during updates. | -| `test_unit_schema_logic.py` | Verifies V3 schema metadata extraction logic. | -| `test_unit_upload_files_flow.py` | Tests the logic flow of multi-file uploads. | -| `test_unit_websockets_v3.py` | Unit tests for the V3 WebSocket manager and messages. | -| `test_unit_websockets_v3_router.py` | Verifies the V3 WebSocket endpoint logic and routing. | - -### Integration Tests (`tests/integration/`) -| Script | Description | -| :--- | :--- | -| `debug_auth_dependency.py` | Direct test of `get_account_context_optional` logic. | -| `test_int_boot_diagnosis.py` | Progressively imports modules to identify circular traps. | -| `test_int_db_connectivity.py` | Direct SQLAlchemy connectivity test (bypasses app config). | -| `test_int_email_live.py` | Live SMTP connection test (non-mocked). | -| `test_int_hosted_file_upload.py` | Tests route flow using FastAPI `TestClient`. | -| `test_int_import_verification.py` | Basic check that all V3 routers are reachable. | -| `test_int_permissive_mode.py` | Tests `x-ae-ignore-extra-fields` header logic. | -| `test_int_schema_v3.py` | Verifies enhanced schema discovery against real DB. | -| `test_int_v3_auth_security.py` | Verifies auth bypass rules (Site vs Account). | -| `test_ws_v3_ping.py` | **Primary Gateway Test**: Verifies WebSocket V3 round-trip. | - -### E2E Tests (`tests/e2e/`) -| Script | Description | -| :--- | :--- | -| `cleanup_test_files.py` | Utility to purge E2E artifacts from storage. | -| `repro_intermittent_errors.py` | Stress test for intermittent 403/Timeout issues. | -| `test_e2e_agent_bridge.py` | Verifies `/agent` diagnostics and log streaming. | -| `test_e2e_jwt_guest_auth.py` | **Security Test**: Verifies safe guest token access. | -| `test_e2e_passcode_auth.py` | **Security Test**: Verifies passcode-to-JWT flow. | -| `test_e2e_site_bootstrap.py` | Verifies unauthenticated FQDN lookup. | -| `test_e2e_v3_accounts.py` | CRUD verification for Account object. | -| `test_e2e_v3_action_delete.py` | Verifies physical/record deletion via V3 Actions. | -| `test_e2e_v3_action_download.py` | **Consolidated**: Tests ID Vision, Hash, and Partial DLs. | -| `test_e2e_v3_action_event_file.py` | Tests atomic event_file upload action. | -| `test_e2e_v3_action_upload.py` | Tests standard hosted_file upload action. | -| `test_e2e_v3_data_store_lookup.py` | Verifies code lookup, delay, and POST search parity. | -| `test_e2e_v3_event_device.py` | Vision ID parity test for Event Device. | -| `test_e2e_v3_event_session.py` | Vision ID parity test for Event Session. | -| `test_e2e_v3_extra_filters.py` | Tests complex filtering (enabled/all) on User/Site. | -| `test_e2e_v3_nested_advanced.py` | Tests POST /search and view param on nested routes. | -| `test_e2e_v3_registry_verify.py` | Verifies registry whitelist expansion. | -| `test_e2e_v3_schema.py` | Validates V3 `/schema` endpoint over network. | -| `test_e2e_v3_search.py` | **Primary API Test**: Verifies all search operators. | -| `test_e2e_v3_security_exceptions.py` | Validates 403 blocks on restricted routes. | - -### Tools & Utilities (`tests/tools/` or root) -| Script | Description | -| :--- | :--- | -| `gen_test_jwt.py` | Local script to generate test tokens. | -| `tool_generate_registry.py` | Exports the Aether object registry as JSON. | -| `tool_mcp_docker_explorer.py` | Model Context Protocol tool for Docker container inspection. | +- **`mock_config_helper.py`**: A critical utility that mocks `app.config.settings` before other modules are imported. Use this in unit tests. --- ## ๐Ÿงน Maintenance Policy -1. **Redundant Tests**: If a test is consolidated into a more comprehensive suite (e.g., `test_e2e_v3_hash_download.py` -> `test_e2e_v3_action_download.py`), the older one should be moved to `archive/`. -2. **Naming Convention**: - * `test_unit_*`: No database or network. - * `test_int_*`: Uses local DB/Redis or `TestClient`. - * `test_e2e_*`: Full network round-trip. -3. **Cleanup**: Always use `tests/e2e/cleanup_test_files.py` after running upload/download tests to keep the dev storage clean. +1. **Standardization**: All E2E tests should use the standard Agent API Key (`PMM4n50teUCaOMMTN8qOJA`) and provide clean `[โœ… PASS]` or `[โŒ FAIL]` output. +2. **Archiving**: When a new specialized test is created, check if it can be combined into one of the "Primary" suites above. If so, combine and move the original to `archive/`. +3. **Cleanup**: Always use `tests/e2e/cleanup_test_files.py` after running file lifecycle tests. --- @@ -96,8 +46,8 @@ This directory contains the automated and manual test scripts for the Aether Fas ### Recommended: Use the project virtual environment ```bash -./environment/bin/python3 tests/unit/test_unit_payload_sanitization.py +./environment/bin/python3 tests/e2e/test_e2e_v3_search_engine.py ``` ### Path Requirements -Always run test scripts from the **project root** directory. Most scripts include `sys.path.append(os.getcwd())` to ensure local imports work correctly. +Always run test scripts from the **project root** directory. Most scripts include `sys.path.append(os.getcwd())` to ensure local imports work correctly. \ No newline at end of file diff --git a/tests/e2e/test_e2e_jwt_guest_auth.py b/tests/archive/test_e2e_jwt_guest_auth.py similarity index 100% rename from tests/e2e/test_e2e_jwt_guest_auth.py rename to tests/archive/test_e2e_jwt_guest_auth.py diff --git a/tests/e2e/test_e2e_passcode_auth.py b/tests/archive/test_e2e_passcode_auth.py similarity index 100% rename from tests/e2e/test_e2e_passcode_auth.py rename to tests/archive/test_e2e_passcode_auth.py diff --git a/tests/e2e/test_e2e_site_bootstrap.py b/tests/archive/test_e2e_site_bootstrap.py similarity index 100% rename from tests/e2e/test_e2e_site_bootstrap.py rename to tests/archive/test_e2e_site_bootstrap.py diff --git a/tests/e2e/test_e2e_v3_event_device.py b/tests/archive/test_e2e_v3_event_device.py similarity index 100% rename from tests/e2e/test_e2e_v3_event_device.py rename to tests/archive/test_e2e_v3_event_device.py diff --git a/tests/e2e/test_e2e_v3_event_session.py b/tests/archive/test_e2e_v3_event_session.py similarity index 100% rename from tests/e2e/test_e2e_v3_event_session.py rename to tests/archive/test_e2e_v3_event_session.py diff --git a/tests/e2e/test_e2e_v3_extra_filters.py b/tests/archive/test_e2e_v3_extra_filters.py similarity index 100% rename from tests/e2e/test_e2e_v3_extra_filters.py rename to tests/archive/test_e2e_v3_extra_filters.py diff --git a/tests/e2e/test_e2e_v3_nested_advanced.py b/tests/archive/test_e2e_v3_nested_advanced.py similarity index 100% rename from tests/e2e/test_e2e_v3_nested_advanced.py rename to tests/archive/test_e2e_v3_nested_advanced.py diff --git a/tests/e2e/test_e2e_v3_registry_verify.py b/tests/archive/test_e2e_v3_registry_verify.py similarity index 100% rename from tests/e2e/test_e2e_v3_registry_verify.py rename to tests/archive/test_e2e_v3_registry_verify.py diff --git a/tests/e2e/test_e2e_v3_search.py b/tests/archive/test_e2e_v3_search.py similarity index 100% rename from tests/e2e/test_e2e_v3_search.py rename to tests/archive/test_e2e_v3_search.py diff --git a/tests/e2e/test_e2e_v3_security_exceptions.py b/tests/archive/test_e2e_v3_security_exceptions.py similarity index 100% rename from tests/e2e/test_e2e_v3_security_exceptions.py rename to tests/archive/test_e2e_v3_security_exceptions.py diff --git a/tests/e2e/test_e2e_v3_actions_file_lifecycle.py b/tests/e2e/test_e2e_v3_actions_file_lifecycle.py new file mode 100644 index 0000000..444fa20 --- /dev/null +++ b/tests/e2e/test_e2e_v3_actions_file_lifecycle.py @@ -0,0 +1,90 @@ +import requests +import io +import json +import os + +# --- Configuration --- +API_BASE = "https://dev-api.oneskyit.com/v3/action" +API_KEY = "PMM4n50teUCaOMMTN8qOJA" +ACCOUNT_ID = "Q8lR8Ai8hx2FjbQ3C_EH1Q" # OSIT +# Linking target for test files +LINK_TYPE = "archive_content" +LINK_ID = "bZOa7CtUm0E" + +def get_headers(): + return { + "X-Aether-API-Key": API_KEY, + "x-account-id": ACCOUNT_ID, + "x-no-account-id": "bypass" + } + +def test_file_lifecycle(): + print(f"--- Starting Hosted File Lifecycle Test ---") + + # 1. UPLOAD + print("\n[Step 1] Uploading test file...") + test_content = b"Lifecycle Test Content: " + os.urandom(8).hex().encode() + files = [("file_list", ("lifecycle_test.txt", io.BytesIO(test_content), "text/plain"))] + data = {"account_id": ACCOUNT_ID, "link_to_type": LINK_TYPE, "link_to_id": LINK_ID} + + up_resp = requests.post(f"{API_BASE}/hosted_file/upload", headers=get_headers(), files=files, data=data) + if up_resp.status_code != 200: + print(f" โŒ Upload Failed: {up_resp.text}") + return False + + file_info = up_resp.json()['data'][0] + file_id = file_info['id'] + file_hash = file_info['hash_sha256'] + subdir = file_hash[:2] + expected_path = f"/srv/aether_api/srv/ae_hosted_files/{subdir}/{file_hash}.file" + print(f" โœ… Uploaded: {file_id} (Hash: {file_hash[:10]}...)") + print(f" ๐Ÿ” Expected physical path: {expected_path}") + + # 2. DOWNLOAD (Direct) + print("\n[Step 2] Downloading by ID...") + dl_resp = requests.get(f"{API_BASE}/hosted_file/{file_id}/download", headers=get_headers()) + if dl_resp.status_code == 200 and dl_resp.content == test_content: + print(f" โœ… Downloaded content matches original.") + else: + print(f" โŒ Download Failed or content mismatch. Status: {dl_resp.status_code}") + print(f" Original: {test_content}") + print(f" Received: {dl_resp.content}") + return False + + # 3. DOWNLOAD (Hash + Query Auth) + print("\n[Step 3] Downloading by Hash (Query Auth)...") + hash_url = f"{API_BASE}/hosted_file/hash/{file_hash}/download?api_key={API_KEY}" + h_dl_resp = requests.get(hash_url) + if h_dl_resp.status_code == 200: + print(f" โœ… Hash download successful.") + else: + print(f" โŒ Hash download failed: {h_dl_resp.status_code}") + return False + + # 4. DELETE (Clean Cleanup) + print("\n[Step 4] Deleting test file (rm_orphan=true)...") + del_params = {"rm_orphan": "true", "method": "delete"} + del_resp = requests.delete(f"{API_BASE}/hosted_file/{file_id}", headers=get_headers(), params=del_params) + if del_resp.status_code == 200: + print(f" โœ… Deletion request successful.") + else: + print(f" โŒ Deletion failed: {del_resp.text}") + return False + + # 5. VERIFY DELETED + print("\n[Step 5] Verifying record is gone...") + check_resp = requests.get(f"https://dev-api.oneskyit.com/v3/crud/hosted_file/{file_id}", headers=get_headers()) + if check_resp.status_code == 404: + print(f" โœ… Success: Record purged from DB.") + else: + print(f" โŒ Failure: Record still exists after deletion.") + return False + + return True + +if __name__ == "__main__": + if test_file_lifecycle(): + print("\n๐ŸŽ‰ HOSTED FILE LIFECYCLE VERIFIED!") + else: + print("\nโŒ LIFECYCLE TEST FAILED.") + exit(1) diff --git a/tests/e2e/test_e2e_v3_auth_security.py b/tests/e2e/test_e2e_v3_auth_security.py new file mode 100644 index 0000000..920c29f --- /dev/null +++ b/tests/e2e/test_e2e_v3_auth_security.py @@ -0,0 +1,65 @@ +import requests +import json +import time + +# --- Configuration --- +API_ROOT = "https://dev-api.oneskyit.com" +API_KEY = "PMM4n50teUCaOMMTN8qOJA" +SITE_ID = "ltOdfNtjZLo" +PASSCODE = "10241024" +FQDN = "dev-app.oneskyit.com" + +def print_result(label, success, message=""): + status = "โœ… PASS" if success else "โŒ FAIL" + print(f"[{status}] {label} {message}") + +def test_site_bootstrap(): + """Tests unauthenticated FQDN lookup (Bootstrap Exception).""" + print("\n--- Testing Site Bootstrap (Unauth) ---") + url = f"{API_ROOT}/v3/crud/site_domain/search" + query = {"and": [{"field": "fqdn", "op": "eq", "value": FQDN}]} + # NO AUTH HEADERS + resp = requests.post(url, json=query) + print_result("Bootstrap lookup (site_domain)", resp.status_code == 200) + +def test_passcode_to_jwt(): + """Tests site-specific passcode authentication.""" + print("\n--- Testing Passcode -> JWT Flow ---") + url = f"{API_ROOT}/api/authenticate_passcode" + payload = {"site_id": SITE_ID, "passcode": PASSCODE} + resp = requests.post(url, json=payload) + + success = resp.status_code == 200 + token = resp.json().get('data', {}).get('jwt') if success else None + print_result("Passcode Auth", success and token is not None) + return token + +def test_security_boundaries(token): + """Tests that a site-token cannot access private journals.""" + print("\n--- Testing Security Boundaries ---") + url = f"{API_ROOT}/v3/crud/journal/search" + headers = {"X-Aether-API-Key": API_KEY} + params = {"jwt": token} + + # site-scoped JWT should NOT be able to search global journals + resp = requests.post(url, headers=headers, params=params, json={"q": "%"}) + print_result("Access Blocked (site-jwt -> journal)", resp.status_code == 403) + +def test_machine_auth_exception(): + """Tests that restricted routes fail without API Key.""" + print("\n--- Testing Machine Auth Exceptions ---") + url = f"{API_ROOT}/v3/crud/journal/search" + # No headers, no key + resp = requests.post(url, json={"q": "%"}) + print_result("Unauth block (journal)", resp.status_code == 403) + +if __name__ == "__main__": + print(f"Starting Consolidated Auth & Security E2E Suite") + + test_site_bootstrap() + token = test_passcode_to_jwt() + if token: + test_security_boundaries(token) + test_machine_auth_exception() + + print("\nSuite completed.") diff --git a/tests/e2e/test_e2e_v3_event_vision_parity.py b/tests/e2e/test_e2e_v3_event_vision_parity.py new file mode 100644 index 0000000..8e0616b --- /dev/null +++ b/tests/e2e/test_e2e_v3_event_vision_parity.py @@ -0,0 +1,70 @@ +import requests +import json + +# --- Configuration --- +BASE_URL = "https://dev-api.oneskyit.com/v3/crud" +# Standard Agent API Key +API_KEY = "PMM4n50teUCaOMMTN8qOJA" + +# Test Targets: (Object Type, Valid ID Random) +TARGETS = [ + ("event_device", "GZvFjgIIZQg"), + ("event_session", "F0PZd1bNcuD") +] + +def get_headers(): + return { + "Content-Type": "application/json", + "X-Aether-API-Key": API_KEY, + "x-no-account-id": "bypass" + } + +def verify_vision_parity(obj_type, record_id): + """ + Verifies that the object returns ONLY string IDs for all ID fields (Vision Standard). + """ + print(f"--- Testing {obj_type}: {record_id} ---") + url = f"{BASE_URL}/{obj_type}/{record_id}" + + try: + response = requests.get(url, headers=get_headers()) + print(f" Status: {response.status_code}") + + if response.status_code == 200: + data = response.json().get('data', {}) + failures = [] + + # Check all fields ending in _id (except external_id) + for key, val in data.items(): + if key == "id" or (key.endswith("_id") and not key.endswith("external_id")): + if val is not None and not isinstance(val, str): + failures.append(f"{key} is {type(val).__name__} ({val})") + + if not failures: + print(f" โœ… Success: All ID fields are strings.") + return True + else: + print(f" โŒ Failure: Integer leakage detected:") + for f in failures: + print(f" - {f}") + return False + else: + print(f" โŒ Error: {response.text[:200]}") + return False + + except Exception as e: + print(f" ๐Ÿ’ฅ Exception: {e}") + return False + +if __name__ == "__main__": + print(f"Starting Aether V3 Event Vision Parity Tests\n") + + results = [] + for obj_type, record_id in TARGETS: + results.append(verify_vision_parity(obj_type, record_id)) + print("-" * 40) + + if all(results): + print("\n๐ŸŽ‰ ALL VISION PARITY TESTS PASSED!") + else: + print("\nโŒ SOME TESTS FAILED.") diff --git a/tests/e2e/test_e2e_v3_search_engine.py b/tests/e2e/test_e2e_v3_search_engine.py new file mode 100644 index 0000000..99d6a25 --- /dev/null +++ b/tests/e2e/test_e2e_v3_search_engine.py @@ -0,0 +1,80 @@ +import requests +import json +import time + +# --- Configuration --- +API_BASE = "https://dev-api.oneskyit.com/v3/crud" +API_KEY = "PMM4n50teUCaOMMTN8qOJA" +ACCOUNT_ID = "nqOzejLCDXM" # Standard Test Account + +def get_headers(no_account=False): + headers = { + "Content-Type": "application/json", + "X-Aether-API-Key": API_KEY + } + if no_account: + headers["x-no-account-id"] = "bypass" + else: + headers["x-account-id"] = ACCOUNT_ID + return headers + +def print_result(label, success, message=""): + status = "โœ… PASS" if success else "โŒ FAIL" + print(f"[{status}] {label} {message}") + +def test_basic_operators(): + """Tests contains, startswith, endswith logic.""" + print("\n--- Testing Basic Search Operators ---") + query = {"and": [{"field": "name", "op": "contains", "value": "Journal"}]} + resp = requests.post(f"{API_BASE}/journal/search", headers=get_headers(), json=query) + print_result("Operator: contains", resp.status_code == 200) + + query = {"and": [{"field": "name", "op": "startswith", "value": "A"}]} + resp = requests.post(f"{API_BASE}/journal/search", headers=get_headers(), json=query) + print_result("Operator: startswith", resp.status_code == 200) + +def test_registry_fields(): + """Tests searching by newly added registry fields (created_on, id_random).""" + print("\n--- Testing Registry-Expanded Fields ---") + query = {"and_filters": [{"field": "created_on", "op": "gt", "value": "2020-01-01"}]} + resp = requests.post(f"{API_BASE}/journal/search", headers=get_headers(), json=query) + print_result("Field: created_on", resp.status_code == 200) + + # Get a valid ID for exact match test + res = requests.get(f"{API_BASE}/journal/", headers=get_headers(), params={"limit": 1}) + if res.status_code == 200 and res.json().get("data"): + valid_id = res.json()["data"][0]["id"] + query = {"and_filters": [{"field": "id_random", "op": "eq", "value": valid_id}]} + resp = requests.post(f"{API_BASE}/journal/search", headers=get_headers(), json=query) + print_result(f"Field: id_random ({valid_id})", resp.status_code == 200) + +def test_nested_search(): + """Tests POST /search on child objects.""" + print("\n--- Testing Nested Advanced Search ---") + parent_id = "--ghJX-ztEM" # Valid person + url = f"{API_BASE}/person/{parent_id}/journal/search" + query = {"and_filters": [{"field": "name", "op": "like", "value": "%"}]} + resp = requests.post(url, headers=get_headers(), json=query) + print_result("Nested Search (person -> journal)", resp.status_code == 200) + +def test_extra_filters(): + """Tests enabled=all and hidden=all bypass filters.""" + print("\n--- Testing Extra Filters (enabled/hidden) ---") + # Using User object as it often has disabled records + resp = requests.get(f"{API_BASE}/user/?enabled=all&hidden=all", headers=get_headers()) + print_result("Bypass Filters (enabled=all)", resp.status_code == 200) + +if __name__ == "__main__": + print(f"Starting Consolidated Search Engine E2E Suite") + print(f"Target: {API_BASE}") + + start_time = time.time() + try: + test_basic_operators() + test_registry_fields() + test_nested_search() + test_extra_filters() + except Exception as e: + print(f"๐Ÿ’ฅ Suite Error: {e}") + + print(f"\nSuite completed in {time.time() - start_time:.2f}s")