diff --git a/server/src/ledgrab/api/__init__.py b/server/src/ledgrab/api/__init__.py index 6ef367c..70737b8 100644 --- a/server/src/ledgrab/api/__init__.py +++ b/server/src/ledgrab/api/__init__.py @@ -27,6 +27,7 @@ from .routes.update import router as update_router from .routes.assets import router as assets_router from .routes.home_assistant import router as home_assistant_router from .routes.mqtt import router as mqtt_router +from .routes.http_endpoints import router as http_endpoints_router from .routes.game_integration import router as game_integration_router from .routes.audio_processing_templates import router as audio_processing_templates_router from .routes.audio_filters import router as audio_filters_router @@ -59,6 +60,7 @@ router.include_router(update_router) router.include_router(assets_router) router.include_router(home_assistant_router) router.include_router(mqtt_router) +router.include_router(http_endpoints_router) router.include_router(game_integration_router) router.include_router(audio_processing_templates_router) router.include_router(audio_filters_router) diff --git a/server/src/ledgrab/api/routes/http_endpoints.py b/server/src/ledgrab/api/routes/http_endpoints.py new file mode 100644 index 0000000..223d241 --- /dev/null +++ b/server/src/ledgrab/api/routes/http_endpoints.py @@ -0,0 +1,270 @@ +"""HTTP endpoint routes: CRUD + one-shot test.""" + +import json + +from fastapi import APIRouter, Depends, HTTPException + +from ledgrab.api.auth import AuthRequired +from ledgrab.api.dependencies import ( + fire_entity_event, + get_http_endpoint_store, +) +from ledgrab.api.schemas.http_endpoints import ( + HTTPEndpointCreate, + HTTPEndpointListResponse, + HTTPEndpointResponse, + HTTPEndpointUpdate, + HTTPTestRequest, + HTTPTestResponse, +) +from ledgrab.storage.base_store import EntityNotFoundError +from ledgrab.storage.http_endpoint import HTTPEndpoint +from ledgrab.storage.http_endpoint_store import HTTPEndpointStore +from ledgrab.utils import get_logger +from ledgrab.utils.safe_source import safe_request_bounded, validate_polling_url + +logger = get_logger(__name__) + +router = APIRouter() + + +def _warn_if_plaintext_token(url: str, auth_token: str, *, action: str) -> None: + """Log a warning when an auth token would be sent over plaintext http://.""" + if auth_token and url.lower().startswith("http://"): + logger.warning( + "HTTP endpoint %s: auth_token will be sent over plaintext http:// to %s. " + "Anyone on the network path can read it. Consider https:// if the " + "target supports TLS.", + action, + url, + ) + + +def _to_response(endpoint: HTTPEndpoint) -> HTTPEndpointResponse: + return HTTPEndpointResponse( + id=endpoint.id, + name=endpoint.name, + url=endpoint.url, + method=endpoint.method, + auth_token_set=bool(endpoint.auth_token), + headers=dict(endpoint.headers), + timeout_s=endpoint.timeout_s, + description=endpoint.description, + tags=endpoint.tags, + icon=getattr(endpoint, "icon", "") or "", + icon_color=getattr(endpoint, "icon_color", "") or "", + created_at=endpoint.created_at, + updated_at=endpoint.updated_at, + ) + + +@router.get( + "/api/v1/http/endpoints", + response_model=HTTPEndpointListResponse, + tags=["HTTP"], +) +async def list_http_endpoints( + _auth: AuthRequired, + store: HTTPEndpointStore = Depends(get_http_endpoint_store), +): + endpoints = store.get_all_endpoints() + return HTTPEndpointListResponse( + endpoints=[_to_response(e) for e in endpoints], + count=len(endpoints), + ) + + +@router.post( + "/api/v1/http/endpoints", + response_model=HTTPEndpointResponse, + status_code=201, + tags=["HTTP"], +) +async def create_http_endpoint( + data: HTTPEndpointCreate, + _auth: AuthRequired, + store: HTTPEndpointStore = Depends(get_http_endpoint_store), +): + validate_polling_url(data.url) + _warn_if_plaintext_token(data.url, data.auth_token, action="create") + try: + endpoint = store.create_endpoint( + name=data.name, + url=data.url, + method=data.method, + auth_token=data.auth_token, + headers=data.headers, + timeout_s=data.timeout_s, + description=data.description, + tags=data.tags, + icon=data.icon, + icon_color=data.icon_color, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + fire_entity_event("http_endpoint", "created", endpoint.id) + return _to_response(endpoint) + + +@router.get( + "/api/v1/http/endpoints/{endpoint_id}", + response_model=HTTPEndpointResponse, + tags=["HTTP"], +) +async def get_http_endpoint( + endpoint_id: str, + _auth: AuthRequired, + store: HTTPEndpointStore = Depends(get_http_endpoint_store), +): + try: + endpoint = store.get_endpoint(endpoint_id) + except EntityNotFoundError: + raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found") + return _to_response(endpoint) + + +@router.put( + "/api/v1/http/endpoints/{endpoint_id}", + response_model=HTTPEndpointResponse, + tags=["HTTP"], +) +async def update_http_endpoint( + endpoint_id: str, + data: HTTPEndpointUpdate, + _auth: AuthRequired, + store: HTTPEndpointStore = Depends(get_http_endpoint_store), +): + if data.url is not None: + validate_polling_url(data.url) + final_url = data.url + final_token = data.auth_token + if final_url is None or final_token is None: + try: + existing = store.get_endpoint(endpoint_id) + except EntityNotFoundError: + raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found") + if final_url is None: + final_url = existing.url + if final_token is None: + final_token = existing.auth_token + _warn_if_plaintext_token(final_url, final_token, action="update") + try: + endpoint = store.update_endpoint( + endpoint_id, + name=data.name, + url=data.url, + method=data.method, + auth_token=data.auth_token, + headers=data.headers, + timeout_s=data.timeout_s, + description=data.description, + tags=data.tags, + icon=data.icon, + icon_color=data.icon_color, + ) + except EntityNotFoundError: + raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + fire_entity_event("http_endpoint", "updated", endpoint.id) + return _to_response(endpoint) + + +@router.delete( + "/api/v1/http/endpoints/{endpoint_id}", + status_code=204, + tags=["HTTP"], +) +async def delete_http_endpoint( + endpoint_id: str, + _auth: AuthRequired, + store: HTTPEndpointStore = Depends(get_http_endpoint_store), +): + try: + store.delete_endpoint(endpoint_id) + except EntityNotFoundError: + raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found") + fire_entity_event("http_endpoint", "deleted", endpoint_id) + + +async def _run_http_test( + method: str, + url: str, + headers: dict[str, str], + timeout_s: float, +) -> HTTPTestResponse: + """Shared one-shot fetch + response shaping for both test endpoints.""" + try: + status, body_bytes, error = await safe_request_bounded( + method, url, headers=headers, timeout=timeout_s + ) + except HTTPException: + raise + except Exception as exc: + return HTTPTestResponse(success=False, error=f"Unexpected error: {type(exc).__name__}") + + if error and status == 0: + return HTTPTestResponse(success=False, error=error) + + try: + body_text = body_bytes.decode("utf-8") + except UnicodeDecodeError: + body_text = body_bytes.decode("utf-8", errors="replace") + try: + body_json = json.loads(body_text) if body_text else None + except (json.JSONDecodeError, ValueError): + body_json = None + + preview = body_text[:500] if body_text else None + is_success = 200 <= status < 300 + return HTTPTestResponse( + success=is_success, + status_code=status, + body_preview=preview, + body_json=body_json, + error=None if is_success else f"HTTP {status}", + ) + + +@router.post( + "/api/v1/http/endpoints/test", + response_model=HTTPTestResponse, + tags=["HTTP"], +) +async def test_http_endpoint( + data: HTTPTestRequest, + _auth: AuthRequired, +): + """One-shot fetch to validate URL + auth before saving.""" + headers = dict(data.headers) + if data.auth_token and not any(k.lower() == "authorization" for k in headers): + headers["Authorization"] = f"Bearer {data.auth_token}" + return await _run_http_test(data.method, data.url, headers, data.timeout_s) + + +@router.post( + "/api/v1/http/endpoints/{endpoint_id}/test", + response_model=HTTPTestResponse, + tags=["HTTP"], +) +async def test_saved_http_endpoint( + endpoint_id: str, + _auth: AuthRequired, + store: HTTPEndpointStore = Depends(get_http_endpoint_store), +): + """Run the stored endpoint configuration (URL + auth + headers + timeout). + + Useful for the "test" button on the endpoint card: avoids the user + having to open the editor and re-enter the auth token (which is + never returned to the client). + """ + try: + endpoint = store.get_endpoint(endpoint_id) + except EntityNotFoundError: + raise HTTPException(status_code=404, detail=f"HTTP endpoint {endpoint_id} not found") + return await _run_http_test( + endpoint.method, + endpoint.url, + endpoint.build_request_headers(), + endpoint.timeout_s, + ) diff --git a/server/src/ledgrab/api/schemas/http_endpoints.py b/server/src/ledgrab/api/schemas/http_endpoints.py new file mode 100644 index 0000000..38ae1f4 --- /dev/null +++ b/server/src/ledgrab/api/schemas/http_endpoints.py @@ -0,0 +1,166 @@ +"""HTTP endpoint schemas (CRUD + one-shot test).""" + +import re +from datetime import datetime +from typing import Any, Dict, List, Literal, Optional +from urllib.parse import urlparse + +from pydantic import BaseModel, Field, field_validator + + +# RFC 7230 token chars for header names + reject any control character in values. +_HEADER_NAME_RE = re.compile(r"^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$") +_HEADER_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f]") + + +def _validate_headers(value: Dict[str, str]) -> Dict[str, str]: + """Reject header names/values that could enable CRLF injection.""" + if value is None: + return {} + cleaned: Dict[str, str] = {} + for name, raw in value.items(): + if not isinstance(name, str) or not isinstance(raw, str): + raise ValueError(f"Header name/value must be strings: {name!r}") + if not _HEADER_NAME_RE.match(name): + raise ValueError(f"Invalid HTTP header name: {name!r}") + if _HEADER_CONTROL_CHARS_RE.search(raw): + raise ValueError( + f"Invalid HTTP header value for {name!r} (contains control characters)" + ) + cleaned[name] = raw + return cleaned + + +def _validate_url(value: str) -> str: + """Reject URLs that embed ``user:pass@`` so credentials can't leak + into server logs (e.g. via the plaintext-token warning helper). + + Schemes + IP-block checks are enforced later by + :func:`ledgrab.utils.safe_source.validate_polling_url`. + """ + if not value: + return value + parsed = urlparse(value) + if parsed.username is not None or parsed.password is not None: + raise ValueError( + "URL must not embed credentials (user:pass@). " + "Use the auth_token field or a custom Authorization header instead." + ) + return value + + +class HTTPEndpointCreate(BaseModel): + """Request to create an HTTP endpoint.""" + + name: str = Field(min_length=1, max_length=100) + url: str = Field(min_length=1, description="http or https URL") + method: Literal["GET", "HEAD"] = "GET" + auth_token: str = Field( + default="", + description=( + "Optional bearer token — sent as 'Authorization: Bearer '. " + "Add a custom Authorization entry in `headers` to override." + ), + ) + headers: Dict[str, str] = Field(default_factory=dict) + timeout_s: float = Field(default=10.0, gt=0) + description: Optional[str] = Field(None, max_length=500) + tags: List[str] = Field(default_factory=list) + icon: Optional[str] = Field(None, max_length=64) + icon_color: Optional[str] = Field(None, max_length=32) + + @field_validator("headers") + @classmethod + def _check_headers(cls, value): + return _validate_headers(value) + + @field_validator("url") + @classmethod + def _check_url(cls, value): + return _validate_url(value) + + +class HTTPEndpointUpdate(BaseModel): + """Request to update an HTTP endpoint. + + All fields optional — ``None`` keeps the existing value. Sending an + empty string for ``auth_token`` CLEARS the stored token; omit the + field (or send ``null``) to keep it. + """ + + name: Optional[str] = Field(None, min_length=1, max_length=100) + url: Optional[str] = Field(None, min_length=1) + method: Optional[Literal["GET", "HEAD"]] = None + auth_token: Optional[str] = Field(None, description="null = keep existing; '' = clear.") + headers: Optional[Dict[str, str]] = None + timeout_s: Optional[float] = Field(None, gt=0) + description: Optional[str] = Field(None, max_length=500) + tags: Optional[List[str]] = None + icon: Optional[str] = Field(None, max_length=64) + icon_color: Optional[str] = Field(None, max_length=32) + + @field_validator("headers") + @classmethod + def _check_headers(cls, value): + if value is None: + return None + return _validate_headers(value) + + @field_validator("url") + @classmethod + def _check_url(cls, value): + if value is None: + return None + return _validate_url(value) + + +class HTTPEndpointResponse(BaseModel): + """HTTP endpoint response. Note: ``auth_token`` is NEVER returned — + use ``auth_token_set`` to know whether one is configured.""" + + id: str + name: str + url: str + method: str + auth_token_set: bool = False + headers: Dict[str, str] = Field(default_factory=dict) + timeout_s: float + description: Optional[str] = None + tags: List[str] = Field(default_factory=list) + icon: Optional[str] = Field(None, max_length=64) + icon_color: Optional[str] = Field(None, max_length=32) + created_at: datetime + updated_at: datetime + + +class HTTPEndpointListResponse(BaseModel): + endpoints: List[HTTPEndpointResponse] + count: int + + +class HTTPTestRequest(BaseModel): + """One-shot test request to validate URL + auth before saving.""" + + url: str + method: Literal["GET", "HEAD"] = "GET" + auth_token: str = "" + headers: Dict[str, str] = Field(default_factory=dict) + timeout_s: float = Field(default=10.0, gt=0) + + @field_validator("headers") + @classmethod + def _check_headers(cls, value): + return _validate_headers(value) + + @field_validator("url") + @classmethod + def _check_url(cls, value): + return _validate_url(value) + + +class HTTPTestResponse(BaseModel): + success: bool + status_code: Optional[int] = None + body_preview: Optional[str] = Field(None, description="First 500 chars of the body") + body_json: Any = None + error: Optional[str] = None diff --git a/server/src/ledgrab/static/js/features/http-endpoints.ts b/server/src/ledgrab/static/js/features/http-endpoints.ts new file mode 100644 index 0000000..f452d7b --- /dev/null +++ b/server/src/ledgrab/static/js/features/http-endpoints.ts @@ -0,0 +1,618 @@ +/** + * HTTP Endpoints — CRUD, test, cards. + * + * An HTTP endpoint is a connection definition only (URL + auth + + * headers + timeout). Polling cadence is owned by HTTPValueSource — + * one endpoint can back many value sources at different intervals. + * + * Structurally mirrors `home-assistant-sources.ts` (HA-source CRUD UI): + * register icon adapter → modal subclass with dirty-check → save/edit/ + * clone/delete handlers → card builder → event delegation. + */ + +import { + _cachedHTTPEndpoints, httpEndpointsCache, +} from '../core/state.ts'; +import { fetchWithAuth, escapeHtml } from '../core/api.ts'; +import { t } from '../core/i18n.ts'; +import { Modal } from '../core/modal.ts'; +import { showToast, showConfirm } from '../core/ui.ts'; +import { + ICON_EDIT, ICON_TRASH, ICON_EYE, ICON_EYE_OFF, + ICON_TEST, ICON_OK, ICON_WARNING, +} from '../core/icons.ts'; +import * as P from '../core/icon-paths.ts'; +import { wrapCard } from '../core/card-colors.ts'; +import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts'; +import { TagInput, renderTagChips } from '../core/tag-input.ts'; +import { makeCardIconFields } from '../core/card-icon.ts'; +import { registerIconEntityType, makeSimpleIconAdapter } from './icon-picker.ts'; +import { IconSelect } from '../core/icon-select.ts'; +import type { HTTPEndpoint, HTTPEndpointWritePayload, HTTPMethod, HTTPTestResponse } from '../types.ts'; + +registerIconEntityType('http_endpoint', makeSimpleIconAdapter({ + cache: httpEndpointsCache, + endpointPrefix: '/http/endpoints', + reload: async () => { + if (typeof (window as any).loadIntegrations === 'function') { + await (window as any).loadIntegrations(); + } + }, + typeLabelKey: 'device.icon.entity.http_endpoint', + typeLabelFallback: 'HTTP endpoint', + cardSelectors: (id) => [ + `[data-card-section="http-endpoints"] [data-id="${CSS.escape(id)}"]`, + ], +})); + +const ICON_HTTP = `${P.globe}`; + +// ── Modal ───────────────────────────────────────────────────── + +let _httpTagsInput: TagInput | null = null; +let _httpMethodIconSelect: IconSelect | null = null; +/** In-memory headers; mirrored into hidden snapshot field. */ +let _httpHeaders: Array<{ name: string; value: string }> = []; + +class HTTPEndpointModal extends Modal { + constructor() { super('http-endpoint-modal'); } + + onForceClose() { + if (_httpTagsInput) { _httpTagsInput.destroy(); _httpTagsInput = null; } + if (_httpMethodIconSelect) { _httpMethodIconSelect.destroy(); _httpMethodIconSelect = null; } + _httpHeaders = []; + const out = document.getElementById('http-endpoint-test-output'); + if (out) { out.innerHTML = ''; out.style.display = 'none'; } + } + + snapshotValues() { + // Headers are compared as a sorted snapshot so the dirty-check is + // immune to a backend serialization that emits keys in a different + // order than the user originally entered them (false-positive + // "discard changes?" on reopen of multi-header endpoints). + const headersSnapshot = [..._httpHeaders] + .sort((a, b) => a.name.localeCompare(b.name)); + return { + name: (document.getElementById('http-endpoint-name') as HTMLInputElement).value, + url: (document.getElementById('http-endpoint-url') as HTMLInputElement).value, + method: (document.getElementById('http-endpoint-method') as HTMLSelectElement).value, + auth_token: (document.getElementById('http-endpoint-auth-token') as HTMLInputElement).value, + timeout_s: (document.getElementById('http-endpoint-timeout') as HTMLInputElement).value, + description: (document.getElementById('http-endpoint-description') as HTMLInputElement).value, + headers: JSON.stringify(headersSnapshot), + tags: JSON.stringify(_httpTagsInput ? _httpTagsInput.getValue() : []), + }; + } +} + +const httpEndpointModal = new HTTPEndpointModal(); + +// ── Method IconSelect (GET / HEAD) ──────────────────────────── + +function _ensureMethodIconSelect() { + const sel = document.getElementById('http-endpoint-method') as HTMLSelectElement | null; + if (!sel) return; + const items = [ + { + value: 'GET', + icon: `${P.download}`, + label: 'GET', + desc: t('http_endpoint.method.get.desc'), + }, + { + value: 'HEAD', + icon: `${P.listChecks}`, + label: 'HEAD', + desc: t('http_endpoint.method.head.desc'), + }, + ]; + if (_httpMethodIconSelect) { _httpMethodIconSelect.updateItems(items); return; } + _httpMethodIconSelect = new IconSelect({ target: sel, items, columns: 2 }); +} + +// ── Headers list (key/value rows) ───────────────────────────── +// Visual model mirrors `.group-child-row` (cards.css): bordered rows on +// `--bg-color` with a subtle hover ring, inputs share the same height +// and rounded corners, trash button on the right. Empty state matches +// the `.pp-filter-empty` dashed-border pattern. + +function _renderHeaderRows() { + const list = document.getElementById('http-endpoint-headers-list'); + if (!list) return; + if (_httpHeaders.length === 0) { + list.innerHTML = `
${escapeHtml(t('http_endpoint.headers.empty'))}
`; + return; + } + list.innerHTML = _httpHeaders.map((h, idx) => ` +
+ +
+ + +
+ +
+ `).join(''); + + list.querySelectorAll('.http-header-name').forEach((inp) => { + inp.addEventListener('input', () => { + const row = inp.closest('.http-header-row')!; + const idx = parseInt(row.dataset.idx || '0', 10); + if (_httpHeaders[idx]) _httpHeaders[idx].name = inp.value; + }); + }); + list.querySelectorAll('.http-header-value').forEach((inp) => { + inp.addEventListener('input', () => { + const row = inp.closest('.http-header-row')!; + const idx = parseInt(row.dataset.idx || '0', 10); + if (_httpHeaders[idx]) _httpHeaders[idx].value = inp.value; + }); + }); + list.querySelectorAll('.http-header-remove').forEach((btn) => { + btn.addEventListener('click', () => { + const row = btn.closest('.http-header-row')!; + const idx = parseInt(row.dataset.idx || '0', 10); + _httpHeaders.splice(idx, 1); + _renderHeaderRows(); + }); + }); +} + +export function addHTTPEndpointHeader() { + _httpHeaders.push({ name: '', value: '' }); + _renderHeaderRows(); + // Focus the newest row so the user can immediately start typing. + const list = document.getElementById('http-endpoint-headers-list'); + if (list) { + const names = list.querySelectorAll('.http-header-name'); + names[names.length - 1]?.focus(); + } +} + +// ── Show / Close ────────────────────────────────────────────── + +export async function showHTTPEndpointModal(editData: HTTPEndpoint | null = null): Promise { + const isEdit = !!editData; + const titleKey = isEdit ? 'http_endpoint.edit' : 'http_endpoint.add'; + document.getElementById('http-endpoint-modal-title')!.innerHTML = `${ICON_HTTP} ${t(titleKey)}`; + (document.getElementById('http-endpoint-id') as HTMLInputElement).value = editData?.id || ''; + (document.getElementById('http-endpoint-error') as HTMLElement).style.display = 'none'; + + const nameInput = document.getElementById('http-endpoint-name') as HTMLInputElement; + const urlInput = document.getElementById('http-endpoint-url') as HTMLInputElement; + const methodSel = document.getElementById('http-endpoint-method') as HTMLSelectElement; + const tokenInput = document.getElementById('http-endpoint-auth-token') as HTMLInputElement; + const timeoutInput = document.getElementById('http-endpoint-timeout') as HTMLInputElement; + const descInput = document.getElementById('http-endpoint-description') as HTMLInputElement; + + nameInput.value = editData?.name || ''; + urlInput.value = editData?.url || ''; + methodSel.value = editData?.method || 'GET'; + tokenInput.value = ''; // never expose stored token + tokenInput.type = 'password'; + timeoutInput.value = String(editData?.timeout_s ?? 10); + descInput.value = editData?.description || ''; + + // Headers: snapshot from data into our editable buffer + _httpHeaders = editData?.headers + ? Object.entries(editData.headers).map(([name, value]) => ({ name, value })) + : []; + _renderHeaderRows(); + + _ensureMethodIconSelect(); + if (_httpMethodIconSelect) _httpMethodIconSelect.setValue(editData?.method || 'GET'); + + // Show "leave blank to keep" hint only when editing an endpoint + // that already has a token configured. + const tokenHint = document.getElementById('http-endpoint-token-hint'); + if (tokenHint) tokenHint.style.display = (isEdit && editData?.auth_token_set) ? '' : 'none'; + + // Reveal password toggle + const revealBtn = document.getElementById('http-endpoint-token-reveal'); + if (revealBtn) revealBtn.innerHTML = ICON_EYE; + + // Inject icon into the inline Test button + const testBtnIcon = document.querySelector('#http-endpoint-test-btn .http-endpoint-test-btn-icon'); + if (testBtnIcon) testBtnIcon.innerHTML = ICON_TEST; + + // Reset test output + const out = document.getElementById('http-endpoint-test-output'); + if (out) { out.innerHTML = ''; out.style.display = 'none'; } + + // Tags + if (_httpTagsInput) { _httpTagsInput.destroy(); _httpTagsInput = null; } + _httpTagsInput = new TagInput( + document.getElementById('http-endpoint-tags-container'), + { placeholder: t('tags.placeholder') } + ); + _httpTagsInput.setValue(isEdit ? (editData?.tags || []) : []); + + httpEndpointModal.open(); + httpEndpointModal.snapshot(); +} + +export async function closeHTTPEndpointModal(): Promise { + await httpEndpointModal.close(); +} + +export function toggleHTTPEndpointTokenVisibility() { + const inp = document.getElementById('http-endpoint-auth-token') as HTMLInputElement; + const btn = document.getElementById('http-endpoint-token-reveal'); + if (!inp || !btn) return; + if (inp.type === 'password') { + inp.type = 'text'; + btn.innerHTML = ICON_EYE_OFF; + } else { + inp.type = 'password'; + btn.innerHTML = ICON_EYE; + } +} + +// ── Header collection (commits live values to buffer) ─ + +function _collectHeaders(): Record { + // Pull current input values to be safe (in case of paste / IME). + const list = document.getElementById('http-endpoint-headers-list'); + if (list) { + list.querySelectorAll('.http-header-row').forEach((row) => { + const idx = parseInt(row.dataset.idx || '0', 10); + const name = (row.querySelector('.http-header-name') as HTMLInputElement)?.value || ''; + const value = (row.querySelector('.http-header-value') as HTMLInputElement)?.value || ''; + if (_httpHeaders[idx]) { _httpHeaders[idx].name = name; _httpHeaders[idx].value = value; } + }); + } + const out: Record = {}; + for (const h of _httpHeaders) { + const name = h.name.trim(); + if (!name) continue; + out[name] = h.value; + } + return out; +} + +// ── Save ────────────────────────────────────────────────────── + +export async function saveHTTPEndpoint(): Promise { + const id = (document.getElementById('http-endpoint-id') as HTMLInputElement).value; + if (httpEndpointModal.closeIfPristine(id)) return; + + const name = (document.getElementById('http-endpoint-name') as HTMLInputElement).value.trim(); + const url = (document.getElementById('http-endpoint-url') as HTMLInputElement).value.trim(); + const method = (document.getElementById('http-endpoint-method') as HTMLSelectElement).value as HTTPMethod; + const token = (document.getElementById('http-endpoint-auth-token') as HTMLInputElement).value; + const timeoutRaw = (document.getElementById('http-endpoint-timeout') as HTMLInputElement).value; + const description = (document.getElementById('http-endpoint-description') as HTMLInputElement).value.trim() || undefined; + const headers = _collectHeaders(); + const timeout_s = parseFloat(timeoutRaw); + + if (!name) { + httpEndpointModal.showError(t('http_endpoint.error.name_required')); + return; + } + if (!url) { + httpEndpointModal.showError(t('http_endpoint.error.url_required')); + return; + } + if (isNaN(timeout_s) || timeout_s <= 0) { + httpEndpointModal.showError(t('http_endpoint.error.timeout_invalid')); + return; + } + + const payload: HTTPEndpointWritePayload = { + name, url, method, headers, timeout_s, description, + tags: _httpTagsInput ? _httpTagsInput.getValue() : [], + }; + + // Auth token semantics (per backend schema): + // POST — empty string means "no token" + // PUT new — non-empty replaces; empty string CLEARS; omit to KEEP existing. + // For PUT we omit the field unless the user typed something so we don't + // accidentally clear a previously-configured token. + if (!id) { + payload.auth_token = token; + } else if (token) { + payload.auth_token = token; + } + + try { + const method = id ? 'PUT' : 'POST'; + const url = id ? `/http/endpoints/${id}` : '/http/endpoints'; + const resp = await fetchWithAuth(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.detail || `HTTP ${resp.status}`); + } + showToast(t(id ? 'http_endpoint.updated' : 'http_endpoint.created'), 'success'); + httpEndpointModal.forceClose(); + httpEndpointsCache.invalidate(); + if (typeof (window as any).loadIntegrations === 'function') await (window as any).loadIntegrations(); + } catch (e: any) { + if (e.isAuth) return; + httpEndpointModal.showError(e.message); + } +} + +// ── Edit / Clone / Delete ───────────────────────────────────── + +export async function editHTTPEndpoint(endpointId: string): Promise { + try { + const resp = await fetchWithAuth(`/http/endpoints/${endpointId}`); + if (!resp.ok) throw new Error(t('http_endpoint.error.load')); + const data: HTTPEndpoint = await resp.json(); + await showHTTPEndpointModal(data); + } catch (e: any) { + if (e.isAuth) return; + showToast(e.message, 'error'); + } +} + +export async function cloneHTTPEndpoint(endpointId: string): Promise { + try { + const resp = await fetchWithAuth(`/http/endpoints/${endpointId}`); + if (!resp.ok) throw new Error(t('http_endpoint.error.load')); + const data = await resp.json(); + delete data.id; + data.name = data.name + ' (copy)'; + // Cloning never reveals the token — user must re-enter if needed. + data.auth_token_set = false; + await showHTTPEndpointModal(data); + } catch (e: any) { + if (e.isAuth) return; + showToast(e.message, 'error'); + } +} + +export async function deleteHTTPEndpoint(endpointId: string): Promise { + const confirmed = await showConfirm(t('http_endpoint.delete.confirm')); + if (!confirmed) return; + try { + const resp = await fetchWithAuth(`/http/endpoints/${endpointId}`, { method: 'DELETE' }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.detail || `HTTP ${resp.status}`); + } + showToast(t('http_endpoint.deleted'), 'success'); + httpEndpointsCache.invalidate(); + if (typeof (window as any).loadIntegrations === 'function') await (window as any).loadIntegrations(); + } catch (e: any) { + if (e.isAuth) return; + showToast(e.message, 'error'); + } +} + +// ── Test (in-form, pre-save) ────────────────────────────────── + +/** Builds a `HTTPTestRequest` from the live form values and renders + * the response inline. Works for both new and edit modes. */ +export async function testHTTPEndpoint(): Promise { + const url = (document.getElementById('http-endpoint-url') as HTMLInputElement).value.trim(); + const method = (document.getElementById('http-endpoint-method') as HTMLSelectElement).value as HTTPMethod; + const token = (document.getElementById('http-endpoint-auth-token') as HTMLInputElement).value; + const timeoutRaw = (document.getElementById('http-endpoint-timeout') as HTMLInputElement).value; + const headers = _collectHeaders(); + const timeout_s = parseFloat(timeoutRaw); + + const out = document.getElementById('http-endpoint-test-output'); + if (!out) return; + + // Render validation errors inline next to the Test button — the + // modal-level banner at the top of the form is invisible from here. + const renderValidationFail = (msg: string): void => { + out.style.display = ''; + out.innerHTML = `
+ ${escapeHtml(t('http_endpoint.test.failed'))} + ${escapeHtml(msg)} +
`; + }; + + if (!url) { + renderValidationFail(t('http_endpoint.error.url_required')); + return; + } + if (isNaN(timeout_s) || timeout_s <= 0) { + renderValidationFail(t('http_endpoint.error.timeout_invalid')); + return; + } + + const testBtn = document.getElementById('http-endpoint-test-btn'); + if (testBtn) testBtn.classList.add('loading'); + out.style.display = ''; + out.innerHTML = `
+ + ${escapeHtml(t('http_endpoint.test.pending'))} +
`; + + try { + const resp = await fetchWithAuth('/http/endpoints/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url, method, auth_token: token, headers, timeout_s }), + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const data: HTTPTestResponse = await resp.json(); + _renderTestResult(out, data); + } catch (e: any) { + if (e.isAuth) return; + out.innerHTML = _renderTestErrorHtml(e.message); + } finally { + if (testBtn) testBtn.classList.remove('loading'); + } +} + +function _renderTestErrorHtml(message: string): string { + return `
+
+ ${ICON_WARNING}${escapeHtml(t('http_endpoint.test.failed'))} +
+ ${escapeHtml(message)} +
`; +} + +function _renderTestResult(out: HTMLElement, data: HTTPTestResponse) { + const statusClass = data.success ? 'http-test-ok' : 'http-test-fail'; + const badgeClass = data.success ? 'http-test-badge-ok' : 'http-test-badge-fail'; + const badgeIcon = data.success ? ICON_OK : ICON_WARNING; + const badgeText = data.success + ? t('http_endpoint.test.success') + : t('http_endpoint.test.failed'); + const statusLine = data.status_code != null + ? `${data.status_code}` + : ''; + const errLine = data.error && !data.success + ? `${escapeHtml(data.error)}` + : ''; + // Show JSON body when available — easier to copy a json_path from than raw text. + let bodyHtml = ''; + let bodyLabel = ''; + if (data.body_json != null) { + let pretty: string; + try { pretty = JSON.stringify(data.body_json, null, 2); } + catch { pretty = String(data.body_json); } + bodyLabel = `
${escapeHtml(t('http_endpoint.test.body.json'))}
`; + bodyHtml = `
${escapeHtml(pretty)}
`; + } else if (data.body_preview) { + bodyLabel = `
${escapeHtml(t('http_endpoint.test.body.text'))}
`; + bodyHtml = `
${escapeHtml(data.body_preview)}
`; + } + out.innerHTML = `
+
+ ${badgeIcon}${escapeHtml(badgeText)} + ${statusLine} +
+ ${errLine} + ${bodyLabel} + ${bodyHtml} +
`; +} + +// ── Card-level test (uses stored config, no form needed) ────── + +async function _testHTTPEndpointFromCard(endpointId: string): Promise { + try { + const resp = await fetchWithAuth(`/http/endpoints/${endpointId}/test`, { method: 'POST' }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.detail || `HTTP ${resp.status}`); + } + const data: HTTPTestResponse = await resp.json(); + if (data.success) { + const status = data.status_code != null ? ` (${data.status_code})` : ''; + showToast(`${t('http_endpoint.test.success')}${status}`, 'success'); + } else { + const detail = data.error || `HTTP ${data.status_code ?? '?'}`; + showToast(`${t('http_endpoint.test.failed')}: ${detail}`, 'error'); + } + } catch (e: any) { + if (e.isAuth) return; + showToast(`${t('http_endpoint.test.failed')}: ${e.message}`, 'error'); + } +} + +// ── Card rendering ──────────────────────────────────────────── + +export function createHTTPEndpointCard(endpoint: HTTPEndpoint) { + const hasAuth = !!endpoint.auth_token_set; + const headerCount = Object.keys(endpoint.headers || {}).length; + const leds: LedState[] = ['on']; + + const chips: ModChipOpts[] = [ + { + icon: `${P.globe}`, + text: endpoint.url, + title: endpoint.url, + }, + { + icon: `${P.refreshCw}`, + text: endpoint.method, + }, + ]; + if (hasAuth) { + chips.push({ + icon: `${P.lock}`, + text: t('http_endpoint.auth.set'), + }); + } + if (headerCount > 0) { + chips.push({ + icon: `${P.listChecks}`, + text: t('http_endpoint.headers.count').replace('{n}', String(headerCount)), + }); + } + + const mod: ModCardOpts = { + head: { + badge: { text: 'HTTP · ENDPOINT' }, + name: endpoint.name, + metaHtml: escapeHtml(endpoint.url), + leds, + ...makeCardIconFields('http_endpoint', endpoint.id, endpoint), + menu: { + duplicateOnclick: `cloneHTTPEndpoint('${endpoint.id}')`, + hideOnclick: `toggleCardHidden('http-endpoints','${endpoint.id}')`, + deleteOnclick: `deleteHTTPEndpoint('${endpoint.id}')`, + }, + }, + body: { + desc: endpoint.description || undefined, + chips, + }, + foot: { + patchState: 'idle', + patchLabel: `${endpoint.method} · ${Math.round(endpoint.timeout_s)}s`, + iconActions: [ + { icon: ICON_TEST, onclick: '', title: t('http_endpoint.test'), dataAttrs: { 'data-action': 'test' } }, + { icon: ICON_EDIT, onclick: '', title: t('common.edit'), dataAttrs: { 'data-action': 'edit' } }, + ], + }, + running: false, + }; + const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-id', id: endpoint.id, mod }); + const tagsHtml = renderTagChips(endpoint.tags); + return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}`) : cardHtml; +} + +// ── Event delegation ────────────────────────────────────────── + +const _httpEndpointActions: Record void> = { + clone: cloneHTTPEndpoint, + edit: editHTTPEndpoint, + test: _testHTTPEndpointFromCard, +}; + +export function initHTTPEndpointDelegation(container: HTMLElement): void { + container.addEventListener('click', (e: MouseEvent) => { + const btn = (e.target as HTMLElement).closest('[data-action]'); + if (!btn) return; + + const section = btn.closest('[data-card-section="http-endpoints"]'); + if (!section) return; + const card = btn.closest('[data-id]'); + if (!card) return; + + const action = btn.dataset.action; + const id = card.getAttribute('data-id'); + if (!action || !id) return; + + const handler = _httpEndpointActions[action]; + if (handler) { + e.stopPropagation(); + handler(id); + } + }); +} + +// ── Expose to global scope for HTML onclick handlers ────────── + +window.showHTTPEndpointModal = showHTTPEndpointModal; +window.closeHTTPEndpointModal = closeHTTPEndpointModal; +window.saveHTTPEndpoint = saveHTTPEndpoint; +window.editHTTPEndpoint = editHTTPEndpoint; +window.cloneHTTPEndpoint = cloneHTTPEndpoint; +window.deleteHTTPEndpoint = deleteHTTPEndpoint; +window.testHTTPEndpoint = testHTTPEndpoint; +window.addHTTPEndpointHeader = addHTTPEndpointHeader; +window.toggleHTTPEndpointTokenVisibility = toggleHTTPEndpointTokenVisibility; diff --git a/server/src/ledgrab/storage/http_endpoint.py b/server/src/ledgrab/storage/http_endpoint.py new file mode 100644 index 0000000..797e6fa --- /dev/null +++ b/server/src/ledgrab/storage/http_endpoint.py @@ -0,0 +1,126 @@ +"""HTTP endpoint data model. + +An ``HTTPEndpoint`` is a *connection definition*: where to fetch (URL), +how to authenticate (bearer token), what headers to send, and how long +to wait. It owns nothing about *what* to extract or *how often* to poll +— those concerns live on consumers (HTTPValueSource for periodic polling ++ extraction; potentially other consumers in future). + +Mirrors the MQTT/HA source pattern (storage/mqtt_source.py, +storage/home_assistant_source.py). +""" + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Dict, List, Optional + +from ledgrab.utils import secret_box + + +def _parse_common(data: dict) -> dict: + """Extract common fields from a dict, parsing timestamps.""" + created = data.get("created_at", "") + updated = data.get("updated_at", "") + return { + "id": data["id"], + "name": data["name"], + "created_at": ( + datetime.fromisoformat(created) + if isinstance(created, str) and created + else datetime.now(timezone.utc) + ), + "updated_at": ( + datetime.fromisoformat(updated) + if isinstance(updated, str) and updated + else datetime.now(timezone.utc) + ), + "description": data.get("description"), + "tags": data.get("tags") or [], + } + + +@dataclass +class HTTPEndpoint: + """HTTP endpoint connection configuration.""" + + id: str + name: str + created_at: datetime + updated_at: datetime + url: str = "" + method: str = "GET" # "GET" | "HEAD" + auth_token: str = "" # convenience: becomes Authorization: Bearer + headers: Dict[str, str] = field(default_factory=dict) + timeout_s: float = 10.0 + description: Optional[str] = None + tags: List[str] = field(default_factory=list) + icon: str = "" + icon_color: str = "" + + def __post_init__(self) -> None: + # Invariant: ``self.auth_token`` is always plaintext at runtime. + # If a caller constructed this from a raw dict that still holds the + # encrypted envelope, decrypt now so ``build_request_headers`` + # doesn't accidentally send ``Authorization: Bearer ``. + if self.auth_token and secret_box.is_encrypted(self.auth_token): + try: + self.auth_token = secret_box.decrypt(self.auth_token) + except Exception: + self.auth_token = "" + + @property + def plaintext_token(self) -> str: + return secret_box.decrypt(self.auth_token) if self.auth_token else "" + + def build_request_headers(self) -> Dict[str, str]: + """Compose the headers actually sent on a fetch. + + Order: explicit ``headers`` first, ``Authorization`` derived from + ``auth_token`` only if the caller did not already supply one. + The check is case-insensitive so a user-supplied ``authorization`` + (lower) wins over the bearer-token shortcut. + """ + result: Dict[str, str] = dict(self.headers) + already_has_auth = any(k.lower() == "authorization" for k in result) + if self.auth_token and not already_has_auth: + result["Authorization"] = f"Bearer {self.auth_token}" + return result + + def to_dict(self) -> dict: + # Always persist auth_token in encrypted envelope form. If the field + # already contains an envelope, encrypt() is a no-op. + stored_token = secret_box.encrypt(self.auth_token) if self.auth_token else "" + d = { + "id": self.id, + "name": self.name, + "url": self.url, + "method": self.method, + "auth_token": stored_token, + "headers": dict(self.headers), + "timeout_s": self.timeout_s, + "description": self.description, + "tags": self.tags, + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat(), + } + if self.icon: + d["icon"] = self.icon + if self.icon_color: + d["icon_color"] = self.icon_color + return d + + @staticmethod + def from_dict(data: dict) -> "HTTPEndpoint": + common = _parse_common(data) + raw_token = data.get("auth_token", "") + token = secret_box.decrypt(raw_token) if raw_token else "" + return HTTPEndpoint( + **common, + url=data.get("url", ""), + method=data.get("method", "GET"), + auth_token=token, + headers=dict(data.get("headers") or {}), + timeout_s=float(data.get("timeout_s", 10.0)), + icon=data.get("icon", ""), + icon_color=data.get("icon_color", ""), + ) diff --git a/server/src/ledgrab/storage/http_endpoint_store.py b/server/src/ledgrab/storage/http_endpoint_store.py new file mode 100644 index 0000000..8611ac9 --- /dev/null +++ b/server/src/ledgrab/storage/http_endpoint_store.py @@ -0,0 +1,147 @@ +"""HTTP endpoint storage using SQLite. Mirrors MQTTSourceStore.""" + +import uuid +from datetime import datetime, timezone +from typing import Dict, List, Optional + +from ledgrab.storage.base_sqlite_store import BaseSqliteStore +from ledgrab.storage.database import Database +from ledgrab.storage.http_endpoint import HTTPEndpoint +from ledgrab.utils import get_logger, secret_box + +logger = get_logger(__name__) + + +class HTTPEndpointStore(BaseSqliteStore[HTTPEndpoint]): + """Persistent storage for HTTP endpoint connection definitions.""" + + _table_name = "http_endpoints" + _entity_name = "HTTP endpoint" + + def __init__(self, db: Database): + super().__init__(db, HTTPEndpoint.from_dict) + self._migrate_plaintext_tokens() + + def _migrate_plaintext_tokens(self) -> None: + """Encrypt any stored auth tokens still in plaintext at rest.""" + migrated = 0 + try: + rows = self._db.load_all(self._table_name) + except Exception as exc: + logger.error("Could not inspect rows for HTTP token migration: %s", exc) + return + for row in rows: + sid = row.get("id") + raw = row.get("auth_token", "") or "" + if not sid or not raw: + continue + if secret_box.is_encrypted(raw): + continue + endpoint = self._items.get(sid) + if endpoint is None: + continue + try: + self._save_item(sid, endpoint) + migrated += 1 + except Exception as exc: + logger.error("Failed to migrate HTTP token for %s: %s", sid, exc) + if migrated: + logger.warning( + "MIGRATION: encrypted %d plaintext HTTP auth token(s) at rest.", + migrated, + ) + + # Backward-compatible aliases (mirror MQTT store) + get_all_endpoints = BaseSqliteStore.get_all + get_endpoint = BaseSqliteStore.get + delete_endpoint = BaseSqliteStore.delete + + def create_endpoint( + self, + name: str, + url: str, + method: str = "GET", + auth_token: str = "", + headers: Optional[Dict[str, str]] = None, + timeout_s: float = 10.0, + description: Optional[str] = None, + tags: Optional[List[str]] = None, + icon: Optional[str] = None, + icon_color: Optional[str] = None, + ) -> HTTPEndpoint: + if not url: + raise ValueError("url is required") + if method not in ("GET", "HEAD"): + raise ValueError(f"Unsupported method: {method!r}. Use GET or HEAD.") + if timeout_s <= 0: + raise ValueError("timeout_s must be > 0") + + self._check_name_unique(name) + + eid = f"htep_{uuid.uuid4().hex[:8]}" + now = datetime.now(timezone.utc) + + endpoint = HTTPEndpoint( + id=eid, + name=name, + created_at=now, + updated_at=now, + url=url, + method=method, + auth_token=auth_token, + headers=dict(headers or {}), + timeout_s=timeout_s, + description=description, + tags=tags or [], + icon=icon or "", + icon_color=icon_color or "", + ) + + self._items[eid] = endpoint + self._save_item(eid, endpoint) + logger.info(f"Created HTTP endpoint: {name} ({eid})") + return endpoint + + def update_endpoint( + self, + endpoint_id: str, + name: Optional[str] = None, + url: Optional[str] = None, + method: Optional[str] = None, + auth_token: Optional[str] = None, + headers: Optional[Dict[str, str]] = None, + timeout_s: Optional[float] = None, + description: Optional[str] = None, + tags: Optional[List[str]] = None, + icon: Optional[str] = None, + icon_color: Optional[str] = None, + ) -> HTTPEndpoint: + existing = self.get(endpoint_id) + + if name is not None and name != existing.name: + self._check_name_unique(name) + if method is not None and method not in ("GET", "HEAD"): + raise ValueError(f"Unsupported method: {method!r}. Use GET or HEAD.") + if timeout_s is not None and timeout_s <= 0: + raise ValueError("timeout_s must be > 0") + + updated = HTTPEndpoint( + id=existing.id, + name=name if name is not None else existing.name, + created_at=existing.created_at, + updated_at=datetime.now(timezone.utc), + url=url if url is not None else existing.url, + method=method if method is not None else existing.method, + auth_token=auth_token if auth_token is not None else existing.auth_token, + headers=dict(headers) if headers is not None else dict(existing.headers), + timeout_s=timeout_s if timeout_s is not None else existing.timeout_s, + description=description if description is not None else existing.description, + tags=tags if tags is not None else existing.tags, + icon=icon if icon is not None else existing.icon, + icon_color=icon_color if icon_color is not None else existing.icon_color, + ) + + self._items[endpoint_id] = updated + self._save_item(endpoint_id, updated) + logger.info(f"Updated HTTP endpoint: {updated.name} ({endpoint_id})") + return updated diff --git a/server/src/ledgrab/templates/index.html b/server/src/ledgrab/templates/index.html index d968bd6..2b30a47 100644 --- a/server/src/ledgrab/templates/index.html +++ b/server/src/ledgrab/templates/index.html @@ -256,6 +256,7 @@ {% include 'modals/weather-source-editor.html' %} {% include 'modals/ha-source-editor.html' %} {% include 'modals/mqtt-source-editor.html' %} + {% include 'modals/http-endpoint-editor.html' %} {% include 'modals/ha-light-editor.html' %} {% include 'modals/z2m-light-editor.html' %} {% include 'modals/asset-upload.html' %} diff --git a/server/src/ledgrab/templates/modals/http-endpoint-editor.html b/server/src/ledgrab/templates/modals/http-endpoint-editor.html new file mode 100644 index 0000000..fc83045 --- /dev/null +++ b/server/src/ledgrab/templates/modals/http-endpoint-editor.html @@ -0,0 +1,142 @@ + + diff --git a/server/tests/api/routes/test_http_endpoints_routes.py b/server/tests/api/routes/test_http_endpoints_routes.py new file mode 100644 index 0000000..94c44ed --- /dev/null +++ b/server/tests/api/routes/test_http_endpoints_routes.py @@ -0,0 +1,185 @@ +"""Tests for HTTP endpoint routes — CRUD, no-leak, /test, LAN policy.""" + +from unittest.mock import MagicMock + +import httpx +import pytest +import respx +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from ledgrab.api import dependencies as deps +from ledgrab.api.routes.http_endpoints import router +from ledgrab.storage.http_endpoint_store import HTTPEndpointStore + + +@pytest.fixture +def _route_db(tmp_path): + from ledgrab.storage.database import Database + + db = Database(tmp_path / "test.db") + yield db + db.close() + + +@pytest.fixture +def http_store(_route_db): + return HTTPEndpointStore(_route_db) + + +@pytest.fixture +def client(http_store): + app = FastAPI() + app.include_router(router) + + from ledgrab.api.auth import verify_api_key + + app.dependency_overrides[verify_api_key] = lambda: "test-user" + app.dependency_overrides[deps.get_http_endpoint_store] = lambda: http_store + + # Stub fire_entity_event's processor_manager + deps._deps["processor_manager"] = MagicMock() + + return TestClient(app, raise_server_exceptions=False) + + +# --------------------------------------------------------------------------- +# CRUD shape + no-leak guarantee +# --------------------------------------------------------------------------- + + +class TestCRUDRoutes: + def test_create_and_list(self, client): + r = client.post( + "/api/v1/http/endpoints", + json={ + "name": "Plex", + "url": "https://example.com/status/sessions", + "auth_token": "very-secret", + "headers": {"Accept": "application/json"}, + }, + ) + assert r.status_code == 201 + body = r.json() + assert body["id"].startswith("htep_") + assert body["auth_token_set"] is True + assert "very-secret" not in r.text + + lst = client.get("/api/v1/http/endpoints").json() + assert lst["count"] == 1 + + def test_response_never_exposes_auth_token(self, client): + """Defence-in-depth: scan every CRUD response for the token string.""" + token = "uniq-token-12345-no-collisions" + post = client.post( + "/api/v1/http/endpoints", + json={ + "name": "x", + "url": "https://example.com", + "auth_token": token, + }, + ) + assert post.status_code == 201 + endpoint_id = post.json()["id"] + + get = client.get(f"/api/v1/http/endpoints/{endpoint_id}") + put = client.put(f"/api/v1/http/endpoints/{endpoint_id}", json={"name": "renamed"}) + lst = client.get("/api/v1/http/endpoints") + + for resp in (post, get, put, lst): + assert token not in resp.text, f"token leaked in {resp.url}" + + def test_method_allowlist_at_schema_layer(self, client): + r = client.post( + "/api/v1/http/endpoints", + json={"name": "x", "url": "https://example.com", "method": "POST"}, + ) + assert r.status_code == 422 + + def test_crlf_in_header_rejected(self, client): + r = client.post( + "/api/v1/http/endpoints", + json={ + "name": "x", + "url": "https://example.com", + "headers": {"X-Inject": "value\r\nX-Smuggle: yes"}, + }, + ) + assert r.status_code == 422 + + def test_invalid_header_name_rejected(self, client): + r = client.post( + "/api/v1/http/endpoints", + json={ + "name": "x", + "url": "https://example.com", + "headers": {"Bad Name With Space": "v"}, + }, + ) + assert r.status_code == 422 + + +# --------------------------------------------------------------------------- +# /test one-shot endpoint + LAN policy +# --------------------------------------------------------------------------- + + +class TestOneShotEndpoint: + @respx.mock + def test_test_success(self, client): + respx.get("https://example.com/status").mock( + return_value=httpx.Response(200, json={"ok": True}) + ) + r = client.post( + "/api/v1/http/endpoints/test", + json={"url": "https://example.com/status"}, + ) + assert r.status_code == 200 + body = r.json() + assert body["success"] is True + assert body["status_code"] == 200 + assert body["body_json"] == {"ok": True} + + @respx.mock + def test_test_sends_auth_header(self, client): + route = respx.get("https://example.com/secure").mock( + return_value=httpx.Response(200, json={}) + ) + client.post( + "/api/v1/http/endpoints/test", + json={ + "url": "https://example.com/secure", + "auth_token": "tok-abc", + }, + ) + sent = route.calls.last.request + assert sent.headers.get("authorization") == "Bearer tok-abc" + + def test_test_rejects_loopback_url(self, client): + # Loopback is always blocked, even with the relaxed polling policy. + r = client.post( + "/api/v1/http/endpoints/test", + json={"url": "http://127.0.0.1:9000/"}, + ) + assert r.status_code == 400 + + def test_test_rejects_metadata_link_local(self, client): + # AWS/GCP metadata at 169.254.169.254 stays blocked. + r = client.post( + "/api/v1/http/endpoints/test", + json={"url": "http://169.254.169.254/latest/meta-data/"}, + ) + assert r.status_code == 400 + + @respx.mock + def test_test_allows_private_lan_ip(self, client): + """Relaxed polling policy MUST permit private IPs — primary use case.""" + respx.get("http://192.168.1.100/status").mock( + return_value=httpx.Response(200, json={"ok": True}) + ) + r = client.post( + "/api/v1/http/endpoints/test", + json={"url": "http://192.168.1.100/status"}, + ) + assert r.status_code == 200 + assert r.json()["success"] is True diff --git a/server/tests/storage/test_http_endpoint_store.py b/server/tests/storage/test_http_endpoint_store.py new file mode 100644 index 0000000..5953128 --- /dev/null +++ b/server/tests/storage/test_http_endpoint_store.py @@ -0,0 +1,168 @@ +"""Tests for HTTPEndpointStore — CRUD + auth_token encryption + header policy.""" + +import pytest + +from ledgrab.storage.database import Database +from ledgrab.storage.http_endpoint_store import HTTPEndpointStore +from ledgrab.utils import secret_box + + +@pytest.fixture +def http_store(tmp_path): + db = Database(tmp_path / "test.db") + store = HTTPEndpointStore(db) + yield store + db.close() + + +class TestCRUD: + def test_create_basic(self, http_store): + e = http_store.create_endpoint( + name="Plex", + url="https://plex.example.com/status/sessions", + ) + assert e.id.startswith("htep_") + assert e.url == "https://plex.example.com/status/sessions" + assert e.method == "GET" + assert e.timeout_s == 10.0 + assert e.headers == {} + assert e.auth_token == "" + + def test_create_with_auth_and_headers(self, http_store): + e = http_store.create_endpoint( + name="Plex", + url="https://plex.example.com/status/sessions", + auth_token="my-secret-token", + headers={"Accept": "application/json"}, + timeout_s=5.0, + ) + assert e.auth_token == "my-secret-token" + assert e.headers == {"Accept": "application/json"} + assert e.timeout_s == 5.0 + + def test_create_requires_url(self, http_store): + with pytest.raises(ValueError, match="url is required"): + http_store.create_endpoint(name="x", url="") + + def test_create_rejects_unsupported_method(self, http_store): + with pytest.raises(ValueError, match="Unsupported method"): + http_store.create_endpoint(name="x", url="https://example.com", method="POST") + + def test_create_rejects_zero_timeout(self, http_store): + with pytest.raises(ValueError, match="timeout_s"): + http_store.create_endpoint(name="x", url="https://example.com", timeout_s=0) + + def test_unique_name(self, http_store): + http_store.create_endpoint(name="dup", url="https://a") + with pytest.raises(ValueError, match="already exists"): + http_store.create_endpoint(name="dup", url="https://b") + + def test_update(self, http_store): + e = http_store.create_endpoint(name="Plex", url="https://a") + updated = http_store.update_endpoint( + e.id, + url="https://b", + auth_token="new-token", + ) + assert updated.url == "https://b" + assert updated.auth_token == "new-token" + assert updated.created_at == e.created_at + assert updated.updated_at > e.updated_at + + def test_update_with_empty_token_clears(self, tmp_path): + """Sending ``auth_token=""`` MUST clear the stored token (the + endpoint route distinguishes None=keep from ""=clear; the store + layer is the source of truth for the on-disk state).""" + db = Database(tmp_path / "test.db") + store = HTTPEndpointStore(db) + e = store.create_endpoint(name="x", url="https://a", auth_token="initial-secret") + assert e.auth_token == "initial-secret" + + cleared = store.update_endpoint(e.id, auth_token="") + assert cleared.auth_token == "" + + # Re-open the DB to confirm the change is persisted, not just in + # the in-memory cache. + db.close() + db2 = Database(tmp_path / "test.db") + store2 = HTTPEndpointStore(db2) + reloaded = store2.get_endpoint(e.id) + assert reloaded.auth_token == "" + db2.close() + + def test_delete(self, http_store): + e = http_store.create_endpoint(name="x", url="https://a") + http_store.delete_endpoint(e.id) + from ledgrab.storage.base_store import EntityNotFoundError + + with pytest.raises(EntityNotFoundError): + http_store.get_endpoint(e.id) + + +class TestTokenEncryption: + """auth_token must be encrypted at rest, decrypted on load.""" + + def test_token_encrypted_at_rest(self, tmp_path): + db = Database(tmp_path / "test.db") + store = HTTPEndpointStore(db) + store.create_endpoint(name="x", url="https://a", auth_token="plaintext-secret") + + rows = db.load_all("http_endpoints") + assert len(rows) == 1 + raw = rows[0]["auth_token"] + assert raw != "plaintext-secret" + assert secret_box.is_encrypted(raw) + db.close() + + def test_token_decrypted_on_load(self, tmp_path): + db_path = tmp_path / "test.db" + db = Database(db_path) + store = HTTPEndpointStore(db) + eid = store.create_endpoint(name="x", url="https://a", auth_token="round-trip").id + db.close() + + db2 = Database(db_path) + store2 = HTTPEndpointStore(db2) + loaded = store2.get_endpoint(eid) + assert loaded.auth_token == "round-trip" + db2.close() + + +class TestHeadersAndAuth: + def test_build_request_headers_with_token(self, http_store): + e = http_store.create_endpoint( + name="x", + url="https://a", + auth_token="abc", + headers={"Accept": "application/json"}, + ) + h = e.build_request_headers() + assert h["Authorization"] == "Bearer abc" + assert h["Accept"] == "application/json" + + def test_custom_authorization_header_wins(self, http_store): + e = http_store.create_endpoint( + name="x", + url="https://a", + auth_token="bearer-token", + headers={"Authorization": "Basic dXNlcjpwYXNz"}, + ) + h = e.build_request_headers() + assert h["Authorization"] == "Basic dXNlcjpwYXNz" + + def test_case_insensitive_authorization_check(self, http_store): + """Lowercase 'authorization' supplied by the user must win too.""" + e = http_store.create_endpoint( + name="x", + url="https://a", + auth_token="should-not-be-sent", + headers={"authorization": "Basic xyz"}, + ) + h = e.build_request_headers() + # No second Authorization key — user's lowercase variant is preserved as-is + assert h.get("authorization") == "Basic xyz" + assert "Authorization" not in h + + def test_no_auth_no_header(self, http_store): + e = http_store.create_endpoint(name="x", url="https://a") + assert "Authorization" not in e.build_request_headers()