"""cryptpad — recipe-specific functional test (Phase 2 P3, ≥2 beyond parity). CryptPad serves a `/api/config` JSON endpoint that the SPA bootstraps from. It carries the server's public configuration (apps enabled, admin emails, supported features). Asserting it returns parseable JSON with known CryptPad-specific keys proves: 1. The cryptpad-server JS process is up (not just nginx returning a static page). 2. The /api/* routing is wired correctly through the recipe's compose proxy. 3. The recipe's bundled JS config is valid (an invalid /api/config returns 500 or non-JSON). Non-vacuous: a wedged cryptpad-server would still let nginx serve the SPA on `/` (status 200, the parity test would pass), but `/api/config` would 502/500 — this test catches that class of half-up state. Runs in the custom tier against the shared post-install deployment. """ from __future__ import annotations import json import os import ssl import sys import urllib.request sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner")) from harness import http as harness_http # noqa: E402 def test_api_config_returns_json(live_app): """GET /api/config; assert JSON with cryptpad-server config keys.""" url = f"https://{live_app}/api/config" # CryptPad's /api/config returns a JS file (Content-Type: text/javascript) on some versions, # OR JSON. Tolerate both; what we assert is the body is parseable as JSON (CryptPad emits # `var x = { ... };` wrapped JSON in JS-form). Strip the prefix if present. ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE def _fetch_and_parse(): req = urllib.request.Request(url, method="GET") try: with urllib.request.urlopen(req, timeout=15, context=ctx) as resp: if resp.status != 200: return None body = resp.read().decode(errors="replace") except Exception: # noqa: BLE001 return None # CryptPad's /api/config may be JSON directly OR JS-wrapped: # 1. raw JSON: `{ ... }` # 2. JS: `define([], function () { return { ... }; });` — strip define wrapper body_stripped = body.strip() # try direct JSON first try: return json.loads(body_stripped) except (json.JSONDecodeError, ValueError): pass # try the JS define-wrapped form: find the first { and last } start = body_stripped.find("{") end = body_stripped.rfind("}") if start == -1 or end == -1 or end <= start: return None try: return json.loads(body_stripped[start : end + 1]) except (json.JSONDecodeError, ValueError): return None config = harness_http.assert_converges( _fetch_and_parse, f"GET {url} returns parseable JSON config", max_wait=60, interval=5, ) assert isinstance(config, dict), f"/api/config returned non-dict: {type(config).__name__}" # CryptPad's bootstrap config carries (across versions) at least one of these keys — # apps/applications list, admin contact email, websocketURL, fileHost, or httpUnsafeOrigin. # The exact key set evolves; assert any of the well-known ones is present. expected_any = ( "websocketURL", "fileHost", "httpUnsafeOrigin", "httpSafeOrigin", "applications", "removedApplications", "adminEmail", "adminKeys", ) present = [k for k in expected_any if k in config] assert present, ( f"/api/config missing all of {expected_any}; got keys: {sorted(config.keys())[:20]}" )