diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1297d36..bd1c5ab 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -44,6 +44,14 @@ repos: # Test suite gate - repo: local hooks: + - id: openapi-contract-check + name: OpenAPI contract check + entry: python3 scripts/check_openapi_contract.py + language: system + pass_filenames: false + always_run: true + files: ^(repeater/web/.*\.py|repeater/web/openapi\.yaml)$ + - id: pytest name: pytest entry: ./scripts/precommit-pytest.sh diff --git a/repeater/main.py b/repeater/main.py index 8646a1b..7fc87a5 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -438,9 +438,9 @@ async def _load_additional_identities(self): elif isinstance(identity_key, str): try: identity_key_bytes = bytes.fromhex(identity_key) - if len(identity_key_bytes) != 32: + if len(identity_key_bytes) not in (32, 64): logger.error( - f"Identity key for '{name}' is invalid length: {len(identity_key_bytes)} bytes (expected 32)" + f"Identity key for '{name}' is invalid length: {len(identity_key_bytes)} bytes (expected 32 or 64)" ) continue except ValueError as e: diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index 09442a0..200ae65 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -95,6 +95,7 @@ # GET /api/transport_keys - List all transport keys # POST /api/transport_keys - Create new transport key # GET /api/transport_key?key_id=X - Get specific transport key +# PUT /api/transport_key?key_id=X - Update transport key # DELETE /api/transport_key?key_id=X - Delete transport key # Network Policy diff --git a/repeater/web/openapi.yaml b/repeater/web/openapi.yaml index f738fe0..f08d1ab 100644 --- a/repeater/web/openapi.yaml +++ b/repeater/web/openapi.yaml @@ -434,6 +434,7 @@ paths: type: object raw_attributes: type: object + additionalProperties: true /gps_stream: get: @@ -475,6 +476,23 @@ paths: application/json: schema: type: object + required: [logs] + properties: + logs: + type: array + items: + type: object + required: [message, timestamp, level] + properties: + message: + type: string + timestamp: + type: string + format: date-time + level: + type: string + error: + type: string /hardware_stats: get: @@ -1304,20 +1322,20 @@ paths: type: object /advert: - get: + delete: tags: [Adverts] - summary: Get specific advert - description: Retrieve details of a specific advertisement + summary: Delete specific advert + description: Delete a specific advertisement by ID parameters: - name: advert_id in: query required: true schema: type: integer - description: Advert ID to retrieve + description: Advert ID to delete responses: '200': - description: Advert details + description: Advert deleted content: application/json: schema: @@ -1395,6 +1413,52 @@ paths: application/json: schema: type: object + put: + tags: [Transport Keys] + summary: Update transport key + description: Update an existing transport encryption key + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - name: key_id + in: query + required: true + schema: + type: string + description: Key ID to update + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: Updated key name + flood_policy: + type: string + enum: [allow, deny] + description: Updated flood policy + transport_key: + type: string + description: Updated transport key hex + parent_id: + type: integer + nullable: true + description: Updated parent transport key ID + last_used: + type: string + format: date-time + description: Updated last-used timestamp in ISO-8601 format + responses: + '200': + description: Key updated + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' delete: tags: [Transport Keys] summary: Delete transport key @@ -1421,20 +1485,6 @@ paths: # Network Policy # ============================================================================ /unscoped_flood_policy: - get: - tags: [Network Policy] - summary: Get unscoped flood policy - description: Retrieve current network flood policy configuration - security: - - BearerAuth: [] - - ApiKeyAuth: [] - responses: - '200': - description: Current policy - content: - application/json: - schema: - type: object post: tags: [Network Policy] summary: Update unscoped flood policy @@ -1470,17 +1520,50 @@ paths: application/json: schema: type: object + required: [target_id] properties: - node_id: + target_id: type: string description: Target node identifier + timeout: + type: integer + description: Ping timeout in seconds + default: 10 responses: '200': description: Ping sent content: application/json: schema: - $ref: '#/components/schemas/SuccessResponse' + type: object + required: [success, data] + properties: + success: + type: boolean + data: + type: object + required: [target_id, rtt_ms, snr_db, rssi, path, tag] + properties: + target_id: + type: string + rtt_ms: + type: number + snr_db: + type: number + rssi: + type: number + path: + type: array + items: + type: string + tag: + type: integer + path_hash_mode: + type: integer + message: + type: string + error: + type: string # ============================================================================ # Identity Management @@ -2457,6 +2540,1066 @@ paths: deleted_count: type: integer + /needs_setup: + get: + tags: [System] + summary: Check setup wizard status + responses: + '200': + description: Setup status + content: + application/json: + schema: + type: object + + /site_info: + get: + tags: [System] + summary: Get site and host info + responses: + '200': + description: Site info + content: + application/json: + schema: + type: object + + /hardware_options: + get: + tags: [System] + summary: Get supported hardware options + responses: + '200': + description: Hardware options + content: + application/json: + schema: + type: object + + /radio_presets: + get: + tags: [System] + summary: Get radio presets + responses: + '200': + description: Preset list + content: + application/json: + schema: + type: object + + /serial_ports: + get: + tags: [System] + summary: List available serial ports + responses: + '200': + description: Serial ports + content: + application/json: + schema: + type: object + + /setup_wizard: + post: + tags: [System] + summary: Submit setup wizard payload + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: Setup applied + content: + application/json: + schema: + type: object + + /check_pymc_console: + get: + tags: [System] + summary: Check pyMC console availability + responses: + '200': + description: Console status + content: + application/json: + schema: + type: object + + /mqtt_status: + get: + tags: [System] + summary: Get MQTT runtime status + responses: + '200': + description: MQTT status + content: + application/json: + schema: + type: object + + /broker_presets: + get: + tags: [System] + summary: List MQTT broker presets + responses: + '200': + description: Broker presets + content: + application/json: + schema: + type: object + + /update_web_config: + post: + tags: [System] + summary: Update web configuration + security: + - BearerAuth: [] + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: Web config updated + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + + /update_mqtt_config: + post: + tags: [System] + summary: Update MQTT configuration + security: + - BearerAuth: [] + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: MQTT config updated + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + + /update_advert_rate_limit_config: + post: + tags: [Adverts] + summary: Update advert rate limit configuration + security: + - BearerAuth: [] + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: Advert rate limit config updated + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + + /bulk_packets: + get: + tags: [Packets] + summary: Fetch packets in bulk + parameters: + - name: limit + in: query + schema: + type: integer + default: 1000 + - name: offset + in: query + schema: + type: integer + default: 0 + - name: start_timestamp + in: query + schema: + type: number + - name: end_timestamp + in: query + schema: + type: number + responses: + '200': + description: Bulk packet result + content: + application/json: + schema: + type: object + + /airtime_data: + get: + tags: [Packets] + summary: Get lightweight airtime packet rows + parameters: + - name: start_timestamp + in: query + schema: + type: number + - name: end_timestamp + in: query + schema: + type: number + - name: limit + in: query + schema: + type: integer + default: 50000 + responses: + '200': + description: Airtime data rows + content: + application/json: + schema: + type: object + + /airtime_chart_data: + get: + tags: [Charts] + summary: Get server-aggregated airtime chart buckets + parameters: + - name: start_timestamp + in: query + schema: + type: number + - name: end_timestamp + in: query + schema: + type: number + - name: bucket_seconds + in: query + schema: + type: integer + default: 60 + - name: sf + in: query + schema: + type: integer + default: 9 + - name: bw_hz + in: query + schema: + type: integer + default: 62500 + - name: cr + in: query + schema: + type: integer + default: 5 + - name: preamble + in: query + schema: + type: integer + default: 17 + responses: + '200': + description: Airtime buckets + content: + application/json: + schema: + type: object + + /adverts_count_by_contact_type: + get: + tags: [Adverts] + summary: Get advert count for contact type + parameters: + - name: contact_type + in: query + required: true + schema: + type: string + - name: hours + in: query + schema: + type: integer + responses: + '200': + description: Advert count + content: + application/json: + schema: + type: object + + /advert_rate_limit_stats: + get: + tags: [Adverts] + summary: Get advert rate-limit runtime stats + responses: + '200': + description: Rate-limit stats + content: + application/json: + schema: + type: object + + /crc_error_count: + get: + tags: [System] + summary: Get CRC error count + parameters: + - name: hours + in: query + schema: + type: integer + default: 24 + responses: + '200': + description: CRC error count + content: + application/json: + schema: + type: object + + /crc_error_history: + get: + tags: [System] + summary: Get CRC error history + parameters: + - name: hours + in: query + schema: + type: integer + default: 24 + - name: limit + in: query + schema: + type: integer + responses: + '200': + description: CRC error records + content: + application/json: + schema: + type: object + + /memory_debug: + get: + tags: [System] + summary: Get memory diagnostics + responses: + '200': + description: Memory diagnostics + content: + application/json: + schema: + type: object + post: + tags: [System] + summary: Start/stop memory diagnostics tracing + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + action: + type: string + enum: [start, stop] + responses: + '200': + description: Tracing state changed + content: + application/json: + schema: + type: object + + /config_export: + get: + tags: [System] + summary: Export configuration + parameters: + - name: include_secrets + in: query + schema: + type: boolean + responses: + '200': + description: Exported config payload + content: + application/json: + schema: + type: object + required: [success, data] + properties: + success: + type: boolean + data: + type: object + required: [meta, config] + properties: + meta: + type: object + required: [exported_at, version, config_path, includes_secrets] + properties: + exported_at: + type: string + format: date-time + version: + type: string + config_path: + type: string + includes_secrets: + type: boolean + config: + type: object + error: + type: string + + /config_import: + post: + tags: [System] + summary: Import configuration + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: Import result + content: + application/json: + schema: + type: object + + /identity_export: + get: + tags: [Identities] + summary: Export repeater identity key + responses: + '200': + description: Identity export + content: + application/json: + schema: + type: object + required: [success, data] + properties: + success: + type: boolean + data: + type: object + required: [identity_key_hex, key_length_bytes] + properties: + identity_key_hex: + type: string + key_length_bytes: + type: integer + public_key_hex: + type: string + node_address: + type: string + error: + type: string + + /generate_vanity_key: + post: + tags: [Identities] + summary: Generate vanity identity key + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [prefix] + properties: + prefix: + type: string + minLength: 1 + maxLength: 8 + apply: + type: boolean + responses: + '200': + description: Vanity key generation result + content: + application/json: + schema: + type: object + required: [success, data] + properties: + success: + type: boolean + data: + type: object + required: [public_hex, private_hex, attempts] + properties: + public_hex: + type: string + private_hex: + type: string + attempts: + type: integer + applied: + type: boolean + error: + type: string + + /db_stats: + get: + tags: [System] + summary: Get database statistics + responses: + '200': + description: Database stats + content: + application/json: + schema: + type: object + required: [success, data] + properties: + success: + type: boolean + data: + type: object + required: [database_size_bytes, rrd_size_bytes, tables] + properties: + database_size_bytes: + type: integer + rrd_size_bytes: + type: integer + tables: + type: array + items: + type: object + required: [name, row_count, has_timestamp] + properties: + name: + type: string + row_count: + type: integer + oldest_timestamp: + type: number + newest_timestamp: + type: number + has_timestamp: + type: boolean + error: + type: string + + /db_purge: + post: + tags: [System] + summary: Purge database tables + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: Table purge result + content: + application/json: + schema: + type: object + required: [success, data, message] + properties: + success: + type: boolean + data: + type: object + additionalProperties: + type: object + properties: + deleted: + type: integer + error: + type: string + message: + type: string + error: + type: string + + /db_vacuum: + post: + tags: [System] + summary: Vacuum SQLite database + responses: + '200': + description: Vacuum result + content: + application/json: + schema: + type: object + required: [success, data] + properties: + success: + type: boolean + data: + type: object + required: [size_before, size_after, freed_bytes] + properties: + size_before: + type: integer + size_after: + type: integer + freed_bytes: + type: integer + error: + type: string + + /docs: + get: + tags: [System] + summary: Serve Swagger UI docs page + responses: + '200': + description: HTML docs page + + /api/auth/tokens: + get: + tags: [Authentication] + summary: List API tokens (alias path) + responses: + '200': + description: Token list + content: + application/json: + schema: + type: object + post: + tags: [Authentication] + summary: Create API token (alias path) + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: Token created + content: + application/json: + schema: + type: object + + /api/auth/tokens/{token_id}: + delete: + tags: [Authentication] + summary: Revoke API token (alias path) + parameters: + - name: token_id + in: path + required: true + schema: + type: integer + responses: + '200': + description: Token revoked + content: + application/json: + schema: + type: object + + /companion: + get: + tags: [System] + summary: List companion bridge instances + responses: + '200': + description: Companion instances + content: + application/json: + schema: + type: object + + /companion/self_info: + get: + tags: [System] + summary: Get local companion identity info + responses: + '200': + description: Companion identity + content: + application/json: + schema: + type: object + + /companion/contacts: + get: + tags: [System] + summary: List companion contacts + responses: + '200': + description: Contacts + content: + application/json: + schema: + type: object + + /companion/contact: + get: + tags: [System] + summary: Get one companion contact + parameters: + - name: pub_key + in: query + required: true + schema: + type: string + responses: + '200': + description: Contact detail + content: + application/json: + schema: + type: object + + /companion/import_repeater_contacts: + post: + tags: [System] + summary: Import repeater adverts into companion contacts + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: Import result + content: + application/json: + schema: + type: object + + /companion/channels: + get: + tags: [System] + summary: List companion channels + responses: + '200': + description: Channels + content: + application/json: + schema: + type: object + + /companion/stats: + get: + tags: [System] + summary: Get companion stats + responses: + '200': + description: Companion stats + content: + application/json: + schema: + type: object + + /companion/send_text: + post: + tags: [System] + summary: Send direct text message via companion + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: Send result + content: + application/json: + schema: + type: object + + /companion/send_channel_message: + post: + tags: [System] + summary: Send channel message via companion + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: Send result + content: + application/json: + schema: + type: object + + /companion/login: + post: + tags: [System] + summary: Initiate companion login flow + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: Login result + content: + application/json: + schema: + type: object + + /companion/request_status: + post: + tags: [System] + summary: Request companion status frame + responses: + '200': + description: Request accepted + content: + application/json: + schema: + type: object + + /companion/request_telemetry: + post: + tags: [System] + summary: Request companion telemetry frame + responses: + '200': + description: Request accepted + content: + application/json: + schema: + type: object + + /companion/send_command: + post: + tags: [System] + summary: Send command to companion + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: Command result + content: + application/json: + schema: + type: object + + /companion/reset_path: + post: + tags: [System] + summary: Reset companion route/path state + responses: + '200': + description: Path reset + content: + application/json: + schema: + type: object + + /companion/set_advert_name: + post: + tags: [System] + summary: Set companion advert name + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: Name updated + content: + application/json: + schema: + type: object + + /companion/set_advert_location: + post: + tags: [System] + summary: Set companion advert location + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: Location updated + content: + application/json: + schema: + type: object + + /companion/events: + get: + tags: [System] + summary: Stream companion events (SSE) + responses: + '200': + description: Event stream + + /update/status: + get: + tags: [System] + summary: Get update service status + responses: + '200': + description: Update status + content: + application/json: + schema: + type: object + + /update/check: + get: + tags: [System] + summary: Trigger or fetch update check + responses: + '200': + description: Check result + content: + application/json: + schema: + type: object + post: + tags: [System] + summary: Trigger update check + requestBody: + required: false + content: + application/json: + schema: + type: object + responses: + '200': + description: Check started/result + content: + application/json: + schema: + type: object + + /update/install: + post: + tags: [System] + summary: Install available update + requestBody: + required: false + content: + application/json: + schema: + type: object + responses: + '200': + description: Install started + content: + application/json: + schema: + type: object + + /update/progress: + get: + tags: [System] + summary: Stream update progress (SSE) + responses: + '200': + description: Progress event stream + + /update/channels: + get: + tags: [System] + summary: List update channels + responses: + '200': + description: Available channels + content: + application/json: + schema: + type: object + + /update/set_channel: + post: + tags: [System] + summary: Set update channel + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: Channel changed + content: + application/json: + schema: + type: object + + /update/changelog: + get: + tags: [System] + summary: Get update changelog + responses: + '200': + description: Changelog content + content: + application/json: + schema: + type: object + + /cli: + post: + tags: [System] + summary: Execute repeater CLI command + security: + - BearerAuth: [] + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [command] + properties: + command: + type: string + responses: + '200': + description: CLI command result + content: + application/json: + schema: + type: object + components: schemas: SuccessResponse: diff --git a/scripts/check_openapi_contract.py b/scripts/check_openapi_contract.py new file mode 100644 index 0000000..4626b4d --- /dev/null +++ b/scripts/check_openapi_contract.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python3 +"""Check OpenAPI contract coverage against CherryPy exposed endpoints. + +This check enforces both directions: +- every OpenAPI path must exist in code +- every API endpoint in code must exist in OpenAPI OR be explicitly allowlisted + +Method checks are warning-only by default and can be made strict. +""" + +from __future__ import annotations + +import argparse +import ast +import re +from dataclasses import dataclass +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +WEB_DIR = ROOT / "repeater" / "web" +OPENAPI_PATH = WEB_DIR / "openapi.yaml" +ALLOWLIST_PATH = ROOT / "scripts" / "openapi_contract_allowlist.yaml" + + +@dataclass +class RouteInfo: + methods: set[str] + confident: bool + + +@dataclass +class Allowlist: + exact: set[str] + prefixes: tuple[str, ...] + + +def _normalize_path(path: str) -> str: + path = path.strip() + if not path: + return "/" + if not path.startswith("/"): + path = "/" + path + path = re.sub(r"/{2,}", "/", path) + path = re.sub(r"\{[^/}]+\}", "{}", path) + if len(path) > 1 and path.endswith("/"): + path = path[:-1] + return path + + +def _load_openapi() -> dict[str, set[str]]: + with OPENAPI_PATH.open("r", encoding="utf-8") as f: + doc = yaml.safe_load(f) or {} + + paths = doc.get("paths", {}) + out: dict[str, set[str]] = {} + for raw_path, ops in paths.items(): + if not isinstance(ops, dict): + continue + methods = { + m.lower() + for m in ops.keys() + if m.lower() in {"get", "post", "put", "delete", "patch", "options", "head"} + } + # We do not enforce OPTIONS/HEAD in this checker. + methods.discard("options") + methods.discard("head") + out[_normalize_path(str(raw_path))] = methods + return out + + +def _load_allowlist() -> Allowlist: + if not ALLOWLIST_PATH.exists(): + return Allowlist(exact=set(), prefixes=tuple()) + + with ALLOWLIST_PATH.open("r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + + exact_raw = data.get("exact_paths", []) + prefix_raw = data.get("path_prefixes", []) + + exact = {_normalize_path(str(p)) for p in exact_raw if str(p).strip()} + prefixes = tuple(_normalize_path(str(p)) for p in prefix_raw if str(p).strip()) + return Allowlist(exact=exact, prefixes=prefixes) + + +def _is_allowlisted(path: str, allowlist: Allowlist) -> bool: + if path in allowlist.exact: + return True + for prefix in allowlist.prefixes: + if path == prefix or path.startswith(prefix + "/"): + return True + return False + + +def _is_cherrypy_request_method_expr(node: ast.AST) -> bool: + # cherrypy.request.method + return ( + isinstance(node, ast.Attribute) + and node.attr == "method" + and isinstance(node.value, ast.Attribute) + and node.value.attr == "request" + and isinstance(node.value.value, ast.Name) + and node.value.value.id == "cherrypy" + ) + + +def _extract_method_strings(node: ast.AST) -> set[str]: + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return {node.value.upper()} + if isinstance(node, (ast.Tuple, ast.List, ast.Set)): + vals: set[str] = set() + for e in node.elts: + vals |= _extract_method_strings(e) + return vals + return set() + + +def _infer_methods(fn: ast.FunctionDef) -> tuple[set[str], bool]: + methods: set[str] = set() + confidence = False + has_require_post = False + saw_method_compare = False + + for node in ast.walk(fn): + if isinstance(node, ast.Call): + # self._require_post() + if ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "_require_post" + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "self" + ): + has_require_post = True + methods.add("POST") + confidence = True + + if not isinstance(node, ast.Compare): + continue + if not _is_cherrypy_request_method_expr(node.left): + continue + saw_method_compare = True + if not node.ops or not node.comparators: + continue + + op = node.ops[0] + rhs_vals = _extract_method_strings(node.comparators[0]) + if not rhs_vals: + continue + + # Treat equality and inequality guards as declared allowed methods. + if isinstance(op, (ast.Eq, ast.In, ast.NotEq, ast.NotIn)): + methods |= rhs_vals + confidence = True + + methods.discard("OPTIONS") + methods.discard("HEAD") + + # If a handler branches on request.method but is not explicitly POST-only, + # CherryPy's default method for uncovered branches is typically GET. + if saw_method_compare and not has_require_post and methods and "POST" in methods: + methods.add("GET") + + if not methods: + return {"GET"}, False + return methods, confidence + + +def _has_expose_decorator(fn: ast.FunctionDef) -> bool: + for d in fn.decorator_list: + # @cherrypy.expose + if isinstance(d, ast.Attribute): + if d.attr == "expose" and isinstance(d.value, ast.Name) and d.value.id == "cherrypy": + return True + return False + + +def _fn_params(fn: ast.FunctionDef) -> list[str]: + params = [a.arg for a in fn.args.args if a.arg != "self"] + return [p for p in params if p not in {"kwargs", "args"}] + + +def _candidate_suffixes(fn: ast.FunctionDef) -> list[str]: + name = fn.name + params = _fn_params(fn) + + if name == "index": + return [""] + + if name == "default": + if params: + return ["/{}"] + return ["/{path}"] + + base = f"/{name}" + # Keep named endpoints canonical. Parameters are often query parameters, + # so adding path-segment variants here produces false positives. + return [base] + + +def _collect_class_routes( + module_path: Path, class_name: str, prefixes: list[str] +) -> dict[str, RouteInfo]: + tree = ast.parse(module_path.read_text(encoding="utf-8"), filename=str(module_path)) + cls = next( + (n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == class_name), + None, + ) + if cls is None: + return {} + + routes: dict[str, RouteInfo] = {} + for node in cls.body: + if not isinstance(node, ast.FunctionDef): + continue + if class_name == "APIEndpoints" and node.name == "default": + # Catch-all handlers are not part of API contract surface. + continue + if not _has_expose_decorator(node): + continue + + methods, confident = _infer_methods(node) + suffixes = _candidate_suffixes(node) + + for prefix in prefixes: + for suffix in suffixes: + path = _normalize_path(prefix + suffix) + cur = routes.get(path) + if cur is None: + routes[path] = RouteInfo(methods=set(methods), confident=confident) + else: + cur.methods |= methods + cur.confident = cur.confident or confident + return routes + + +def _collect_routes() -> dict[str, RouteInfo]: + route_map: dict[str, RouteInfo] = {} + + class_specs = [ + # /api/* methods are described in OpenAPI as / + (WEB_DIR / "api_endpoints.py", "APIEndpoints", [""]), + # Nested /api/companion/* endpoints are described as /companion/*. + (WEB_DIR / "companion_endpoints.py", "CompanionAPIEndpoints", ["/companion"]), + # Nested /api/update/* endpoints are described as /update/* when documented. + (WEB_DIR / "update_endpoints.py", "UpdateAPIEndpoints", ["/update"]), + # Auth top-level endpoints are mounted at /auth/* + (WEB_DIR / "auth_endpoints.py", "AuthEndpoints", ["/auth"]), + # Token sub-resource is exposed both under /auth and /api/auth in current routing. + (WEB_DIR / "auth_endpoints.py", "TokensAPIEndpoint", ["/auth/tokens", "/api/auth/tokens"]), + ] + + for file_path, class_name, prefixes in class_specs: + class_routes = _collect_class_routes(file_path, class_name, prefixes) + for path, info in class_routes.items(): + cur = route_map.get(path) + if cur is None: + route_map[path] = info + else: + cur.methods |= info.methods + cur.confident = cur.confident or info.confident + + return route_map + + +def main() -> int: + parser = argparse.ArgumentParser(description="Check OpenAPI contract coverage.") + parser.add_argument( + "--strict-methods", + action="store_true", + help="Fail when inferred HTTP methods differ from OpenAPI methods.", + ) + args = parser.parse_args() + + if not OPENAPI_PATH.exists(): + print(f"ERROR: OpenAPI spec not found at {OPENAPI_PATH}") + return 1 + + spec = _load_openapi() + allowlist = _load_allowlist() + code_routes = _collect_routes() + + errors: list[str] = [] + warnings: list[str] = [] + + for path, spec_methods in sorted(spec.items()): + code = code_routes.get(path) + if code is None: + errors.append(f"Missing endpoint in code for OpenAPI path: {path}") + continue + + if spec_methods and code.confident: + code_methods = {m.lower() for m in code.methods} + missing_methods = sorted(m for m in spec_methods if m not in code_methods) + if missing_methods: + msg = ( + f"Method mismatch for {path}: OpenAPI has {sorted(spec_methods)}, " + f"code inference has {sorted(code_methods)}" + ) + if args.strict_methods: + errors.append(msg) + else: + warnings.append(msg) + + # Enforce code -> OpenAPI (unless allowlisted) + for path in sorted(code_routes.keys()): + if path in spec: + continue + if _is_allowlisted(path, allowlist): + continue + errors.append(f"Undocumented endpoint in code (not in OpenAPI and not allowlisted): {path}") + + if warnings: + print("OpenAPI contract warnings:") + for w in warnings: + print(f"- {w}") + + if errors: + print("OpenAPI contract check failed:") + for e in errors: + print(f"- {e}") + return 1 + + print("OpenAPI contract check passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_main_py_more.py b/tests/test_main_py_more.py index 6f46c13..e7b0fdb 100644 --- a/tests/test_main_py_more.py +++ b/tests/test_main_py_more.py @@ -38,6 +38,7 @@ async def test_load_additional_identities_valid_and_invalid_entries(): {"name": "bad-type", "identity_key": 12345}, {"name": "good-bytes", "identity_key": b"\x10" * 32}, {"name": "good-hex", "identity_key": ("11" * 32)}, + {"name": "good-hex-64", "identity_key": ("22" * 64)}, ] } @@ -48,10 +49,10 @@ async def test_load_additional_identities_valid_and_invalid_entries(): with patch("pymc_core.LocalIdentity", _FakeLocalIdentity): await daemon._load_additional_identities() - # Only the two valid entries should be registered. - assert daemon._register_identity_everywhere.call_count == 2 + # Only valid entries should be registered (including 64-byte firmware keys). + assert daemon._register_identity_everywhere.call_count == 3 names = [c.kwargs["name"] for c in daemon._register_identity_everywhere.call_args_list] - assert names == ["good-bytes", "good-hex"] + assert names == ["good-bytes", "good-hex", "good-hex-64"] @pytest.mark.asyncio