diff --git a/app/api/v1/integrare_router.py b/app/api/v1/integrare_router.py new file mode 100644 index 0000000..42f83fc --- /dev/null +++ b/app/api/v1/integrare_router.py @@ -0,0 +1,182 @@ +"""Router integrare US-001 — endpoint-uri de integrare externe. + +Endpointuri: + GET /v1/ping — readiness check per cont (autentificat sau dev fallback) + GET /v1/integrare/postman.json — export colectie Postman v2.1.0 + +Ruta /v1/ping foloseste `resolve_account_id` (dependinta standard) pentru 401 +pe cheie invalida / prod fara cheie. Flag-ul `autentificat_cu_cheie` e derivat +separat citind header-ele brute si verificand cheia real-time (fara sa dubleze +logica de 401 — aceea ramane in `resolve_account_id`). +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, Header +from fastapi.responses import JSONResponse + +from ...auth import _extract_key, account_for_key, resolve_account_id +from ...config import get_settings +from ...db import get_connection +from ...mapping import account_or_default + +router = APIRouter(prefix="/v1", tags=["integrare"]) + + +@router.get("/ping") +def ping( + account_id: int = Depends(resolve_account_id), + x_api_key: str | None = Header(default=None, alias="X-API-Key"), + authorization: str | None = Header(default=None), +) -> JSONResponse: + """Readiness check per cont. + + Intoarce: + account_id — contul rezolvat din cheie (sau 1 in dev fara cheie) + mediu — "test" / "prod" (settings.rar_env) + autentificat_cu_cheie — True daca cererea a venit cu o cheie API reala valida + are_creds_rar — True daca contul are rar_creds_enc stocat + ts — timestamp ISO UTC al cererii + """ + settings = get_settings() + + # Detectam daca s-a folosit o cheie reala (nu fallback dev). + # `resolve_account_id` a garantat deja ca nu e cheie invalida (ar fi dat 401). + # Acum verificam doar daca exista o cheie extrasa si daca e valida pentru cont. + cheie_bruta = _extract_key(x_api_key, authorization) + autentificat_cu_cheie = False + if cheie_bruta: + conn = get_connection() + try: + acct = account_for_key(conn, cheie_bruta) + finally: + conn.close() + autentificat_cu_cheie = acct is not None + + # Verificam daca contul are creds RAR stocate. + aid = account_or_default(account_id) + conn = get_connection() + try: + row = conn.execute( + "SELECT rar_creds_enc FROM accounts WHERE id=?", (aid,) + ).fetchone() + finally: + conn.close() + + are_creds_rar = bool(row and row["rar_creds_enc"]) + + return JSONResponse({ + "account_id": aid, + "mediu": settings.rar_env, + "autentificat_cu_cheie": autentificat_cu_cheie, + "are_creds_rar": are_creds_rar, + "ts": datetime.now(timezone.utc).isoformat(), + }) + + +# Allowlist hardcodat (NU derivat din app.routes) — cele 3 rute de integrare expuse extern. +_POSTMAN_ITEMS = [ + { + "name": "Trimite prezentari", + "request": { + "method": "POST", + "header": [ + {"key": "X-API-Key", "value": "{{api_key}}"}, + {"key": "Content-Type", "value": "application/json"}, + ], + "url": { + "raw": "{{base_url}}/v1/prezentari", + "host": ["{{base_url}}"], + "path": ["v1", "prezentari"], + }, + "body": { + "mode": "raw", + "options": {"raw": {"language": "json"}}, + "raw": ( + '{\n' + ' "rar_credentials": {\n' + ' "email": "user@exemplu.ro",\n' + ' "password": "parola_rar"\n' + ' },\n' + ' "prezentari": [\n' + ' {\n' + ' "vin": "WVWZZZ1KZAW000123",\n' + ' "nr_inmatriculare": "B999TST",\n' + ' "data_prestatie": "2026-06-15",\n' + ' "odometru_final": "123456",\n' + ' "prestatii": [\n' + ' {"cod_prestatie": "OE-1"}\n' + ' ]\n' + ' }\n' + ' ]\n' + '}' + ), + }, + }, + }, + { + "name": "Import fisier (xlsx/csv)", + "request": { + "method": "POST", + "header": [ + {"key": "X-API-Key", "value": "{{api_key}}"}, + ], + "url": { + "raw": "{{base_url}}/v1/import", + "host": ["{{base_url}}"], + "path": ["v1", "import"], + }, + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "file", + "type": "file", + "description": "Fisier xlsx sau csv cu prezentarile de importat", + } + ], + }, + }, + }, + { + "name": "Ping (readiness check)", + "request": { + "method": "GET", + "header": [ + {"key": "X-API-Key", "value": "{{api_key}}"}, + ], + "url": { + "raw": "{{base_url}}/v1/ping", + "host": ["{{base_url}}"], + "path": ["v1", "ping"], + }, + }, + }, +] + + +@router.get("/integrare/postman.json") +def postman_export() -> JSONResponse: + """Export colectie Postman v2.1.0 cu cele 3 rute de integrare. + + Allowlist hardcodat — NU deriva din app.routes pentru a nu expune + rute interne (ex. /v1/conturi/rar-creds, rutele web etc.). + """ + colectie = { + "info": { + "name": "RAR AUTOPASS Gateway", + "description": ( + "Colectie de integrare pentru gateway-ul RAR AUTOPASS (Legea 142/2023, OM 210/2024). " + "Seteaza variabilele `base_url` si `api_key` inainte de utilizare." + ), + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + }, + "variable": [ + {"key": "base_url", "value": "http://localhost:8010", "type": "string"}, + {"key": "api_key", "value": "", "type": "string"}, + ], + "item": _POSTMAN_ITEMS, + } + return JSONResponse(content=colectie, media_type="application/json") diff --git a/app/main.py b/app/main.py index f43cdce..4068f59 100644 --- a/app/main.py +++ b/app/main.py @@ -22,6 +22,7 @@ from starlette.responses import RedirectResponse from . import __version__ from .api.v1.import_router import router as import_v1_router +from .api.v1.integrare_router import router as integrare_v1_router from .api.v1.router import router as api_v1_router from .config import get_settings from .db import get_connection, init_db, queue_depth, read_heartbeat @@ -90,6 +91,7 @@ app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static") app.include_router(api_v1_router) app.include_router(import_v1_router) +app.include_router(integrare_v1_router) app.include_router(web_router) app.include_router(auth_router) app.include_router(admin_router) diff --git a/app/web/integrare_examples.py b/app/web/integrare_examples.py new file mode 100644 index 0000000..c133c49 --- /dev/null +++ b/app/web/integrare_examples.py @@ -0,0 +1,377 @@ +"""Generator de exemple de cod multi-limbaj pentru integrarea cu AutoPass. + +Modul PUR: fara I/O, fara DB, fara stare globala. +Folosit de pagina de documentatie a hub-ului de integrare (Etapa 5). + +Campurile obligatorii din payload-ul JSON sunt derivate dinamic din +PrezentareIn.model_fields pentru rezistenta la drift de schema. +""" + +from __future__ import annotations + +import json + +from app.models import PrezentareIn + +# Placeholder pentru cheia API — niciodata o cheie reala +_CHEIE_PLACEHOLDER = "rfak_..." + + +def _campuri_obligatorii() -> list[str]: + """Intoarce lista campurilor obligatorii din PrezentareIn (is_required()).""" + return [camp for camp, field in PrezentareIn.model_fields.items() if field.is_required()] + + +def _payload_prezentari_dict(account_id: int) -> dict: + """Construieste un payload JSON exemplu cu toate campurile obligatorii. + + Campurile cu default (odometru_initial, obs, b64_image, sistem_reparat) sunt + omise pentru concizie — nu sunt obligatorii. + """ + # Construim un dict cu toate campurile obligatorii + campuri = _campuri_obligatorii() + prezentare: dict = {} + + # Valori exemplu pentru campuri obligatorii cunoscute + valori_exemplu: dict = { + "vin": "WVWZZZ1JZXW000001", + "nr_inmatriculare": "B123ABC", + "data_prestatie": "2026-06-22", + "odometru_final": "150000", + "prestatii": [{"cod_prestatie": "OE-1"}], + } + + for camp in campuri: + if camp in valori_exemplu: + prezentare[camp] = valori_exemplu[camp] + else: + # Fallback generic pentru campuri neasteptate adaugate ulterior + prezentare[camp] = f"<{camp}>" + + return { + "rar_credentials": { + "email": "utilizator@service.ro", + "password": "parola_rar", + }, + "prezentari": [prezentare], + } + + +def _payload_json_str(account_id: int, indent: int = 2) -> str: + """Payload JSON formatat ca string pentru includere in snippet-uri.""" + return json.dumps(_payload_prezentari_dict(account_id), indent=indent, ensure_ascii=False) + + +def _payload_json_compact(account_id: int) -> str: + """Payload JSON pe o singura linie (fara newline) pentru string literal C#/VFP. + + Foloseste separators=(',', ':') pentru a elimina spatiile si newline-urile. + Rezultatul e un JSON valid pe o singura linie, fara newline in interior. + """ + return json.dumps(_payload_prezentari_dict(account_id), separators=(",", ":"), ensure_ascii=False) + + +def _snippet_curl_prezentari(base_url: str, account_id: int) -> str: + payload = _payload_json_str(account_id) + return f"""curl -X POST "{base_url}/v1/prezentari" \\ + -H "Content-Type: application/json" \\ + -H "X-API-Key: {_CHEIE_PLACEHOLDER}" \\ + -d '{payload}'""" + + +def _snippet_curl_import(base_url: str, account_id: int) -> str: + fisier = '"file=@prezentari.xlsx"' + return ( + f'curl -X POST "{base_url}/v1/import" \\\n' + f' -H "X-API-Key: {_CHEIE_PLACEHOLDER}" \\\n' + f" -F {fisier}" + ) + + +def _snippet_python_prezentari(base_url: str, account_id: int) -> str: + payload = _payload_json_str(account_id) + return f"""import requests + +url = "{base_url}/v1/prezentari" +headers = {{ + "X-API-Key": "{_CHEIE_PLACEHOLDER}", + "Content-Type": "application/json", +}} +payload = {payload} + +response = requests.post(url, json=payload, headers=headers) +print(response.json())""" + + +def _snippet_python_import(base_url: str, account_id: int) -> str: + return f"""import requests + +url = "{base_url}/v1/import" +headers = {{ + "X-API-Key": "{_CHEIE_PLACEHOLDER}", +}} + +with open("prezentari.xlsx", "rb") as f: + files = {{"file": ("prezentari.xlsx", f, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}} + response = requests.post(url, headers=headers, files=files) + +print(response.json())""" + + +def _snippet_php_prezentari(base_url: str, account_id: int) -> str: + payload = _payload_json_str(account_id) + # Escapeaza apostrof-urile pentru PHP heredoc + payload_php = payload.replace("'", "\\'") + return f""" str: + return f""" $fisier]); +curl_setopt($ch, CURLOPT_HTTPHEADER, [ + "X-API-Key: {_CHEIE_PLACEHOLDER}", +]); + +$response = curl_exec($ch); +curl_close($ch); +echo $response;""" + + +def _snippet_csharp_prezentari(base_url: str, account_id: int) -> str: + payload = _payload_json_compact(account_id) + # Escape ghilimele duble pentru string C# (literal pe o singura linie) + payload_cs = payload.replace('"', '\\"') + return f"""using System.Net.Http; +using System.Text; +using System.Threading.Tasks; + +var client = new HttpClient(); +client.DefaultRequestHeaders.Add("X-API-Key", "{_CHEIE_PLACEHOLDER}"); + +var json = "{payload_cs}"; +var content = new StringContent(json, Encoding.UTF8, "application/json"); + +var response = await client.PostAsync("{base_url}/v1/prezentari", content); +var body = await response.Content.ReadAsStringAsync(); +Console.WriteLine(body);""" + + +def _snippet_csharp_import(base_url: str, account_id: int) -> str: + return f"""using System.Net.Http; +using System.Threading.Tasks; + +var client = new HttpClient(); +client.DefaultRequestHeaders.Add("X-API-Key", "{_CHEIE_PLACEHOLDER}"); + +using var form = new MultipartFormDataContent(); +var fileBytes = File.ReadAllBytes("prezentari.xlsx"); +var fileContent = new ByteArrayContent(fileBytes); +fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); +form.Add(fileContent, "file", "prezentari.xlsx"); + +var response = await client.PostAsync("{base_url}/v1/import", form); +var body = await response.Content.ReadAsStringAsync(); +Console.WriteLine(body);""" + + +def _snippet_node_prezentari(base_url: str, account_id: int) -> str: + payload = _payload_json_str(account_id) + return f"""const payload = {payload}; + +const response = await fetch("{base_url}/v1/prezentari", {{ + method: "POST", + headers: {{ + "Content-Type": "application/json", + "X-API-Key": "{_CHEIE_PLACEHOLDER}", + }}, + body: JSON.stringify(payload), +}}); + +const data = await response.json(); +console.log(data);""" + + +def _snippet_node_import(base_url: str, account_id: int) -> str: + # FormData si Blob sunt globale in Node 18+ — nu necesita import din node:buffer + return f"""import {{ readFileSync }} from "fs"; + +const form = new FormData(); +const continut = readFileSync("prezentari.xlsx"); +form.append("file", new Blob([continut], {{ + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", +}}), "prezentari.xlsx"); + +const response = await fetch("{base_url}/v1/import", {{ + method: "POST", + headers: {{ "X-API-Key": "{_CHEIE_PLACEHOLDER}" }}, + body: form, +}}); + +const data = await response.json(); +console.log(data);""" + + +def _snippet_vfp_msxml_prezentari(base_url: str, account_id: int) -> str: + payload = _payload_json_compact(account_id) + # VFP: ghilimele in string se dubleaza; payload compact = o singura linie + payload_vfp = payload.replace('"', '""') + return f"""* Visual FoxPro — MSXML2.ServerXMLHTTP.6.0 +LOCAL oHTTP, cURL, cPayload, cRaspuns +cURL = "{base_url}/v1/prezentari" +cPayload = "{payload_vfp}" + +oHTTP = CREATEOBJECT("MSXML2.ServerXMLHTTP.6.0") +oHTTP.open("POST", cURL, .F.) +oHTTP.setRequestHeader("Content-Type", "application/json") +oHTTP.setRequestHeader("X-API-Key", "{_CHEIE_PLACEHOLDER}") +oHTTP.send(cPayload) + +cRaspuns = oHTTP.responseText +? cRaspuns""" + + +def _snippet_vfp_msxml_import(base_url: str, account_id: int) -> str: + return f"""* Visual FoxPro — MSXML2.ServerXMLHTTP.6.0 — upload fisier +* Necesita ADODB.Stream pentru a citi fisierul binar +LOCAL oHTTP, oStream, oBody +LOCAL cURL, cGranita, cCRLF, cDisp, cType + +cURL = "{base_url}/v1/import" +cGranita = "----AutoPassBoundary" +cCRLF = CHR(13) + CHR(10) + +* Citire fisier in ADODB.Stream +oStream = CREATEOBJECT("ADODB.Stream") +oStream.Type = 1 && adTypeBinary +oStream.Open() +oStream.LoadFromFile("prezentari.xlsx") + +* Construire body multipart (simplificat — pentru fisiere mici) +oBody = CREATEOBJECT("ADODB.Stream") +oBody.Type = 1 +oBody.Open() + +oHTTP = CREATEOBJECT("MSXML2.ServerXMLHTTP.6.0") +oHTTP.open("POST", cURL, .F.) +oHTTP.setRequestHeader("Content-Type", "multipart/form-data; boundary=" + cGranita) +oHTTP.setRequestHeader("X-API-Key", "{_CHEIE_PLACEHOLDER}") +* Nota: pentru upload binar complet folositi un helper COM sau ADODB. +oHTTP.send(oBody) +? oHTTP.responseText""" + + +def _snippet_vfp_winhttp_prezentari(base_url: str, account_id: int) -> str: + payload = _payload_json_compact(account_id) + # VFP: ghilimele in string se dubleaza; payload compact = o singura linie + payload_vfp = payload.replace('"', '""') + return f"""* Visual FoxPro — WinHttp.WinHttpRequest.5.1 +LOCAL oHTTP, cURL, cPayload, cRaspuns +cURL = "{base_url}/v1/prezentari" +cPayload = "{payload_vfp}" + +oHTTP = CREATEOBJECT("WinHttp.WinHttpRequest.5.1") +oHTTP.Open("POST", cURL, .F.) +oHTTP.SetRequestHeader("Content-Type", "application/json") +oHTTP.SetRequestHeader("X-API-Key", "{_CHEIE_PLACEHOLDER}") +oHTTP.Send(cPayload) + +cRaspuns = oHTTP.ResponseText +? cRaspuns""" + + +def _snippet_vfp_winhttp_import(base_url: str, account_id: int) -> str: + return f"""* Visual FoxPro — WinHttp.WinHttpRequest.5.1 — upload fisier +* Necesita ADODB.Stream pentru a citi fisierul binar +LOCAL oHTTP, oStream +LOCAL cURL, cGranita, cCRLF + +cURL = "{base_url}/v1/import" +cGranita = "----AutoPassBoundary" +cCRLF = CHR(13) + CHR(10) + +oStream = CREATEOBJECT("ADODB.Stream") +oStream.Type = 1 && adTypeBinary +oStream.Open() +oStream.LoadFromFile("prezentari.xlsx") + +oHTTP = CREATEOBJECT("WinHttp.WinHttpRequest.5.1") +oHTTP.Open("POST", cURL, .F.) +oHTTP.SetRequestHeader("Content-Type", "multipart/form-data; boundary=" + cGranita) +oHTTP.SetRequestHeader("X-API-Key", "{_CHEIE_PLACEHOLDER}") +* Nota: pentru upload binar complet folositi un helper COM sau ADODB. +oHTTP.Send() +? oHTTP.ResponseText""" + + +def exemple(base_url: str, account_id: int) -> dict: + """Genereaza snippet-uri de cod multi-limbaj pentru integrarea cu AutoPass. + + Parametri: + base_url: URL-ul de baza al gateway-ului (ex. "https://autopass.example.com") + account_id: ID-ul contului (inclus in context, nu in snippet-uri) + + Intoarce un dict structurat astfel: + { + "": { + "prezentari": "", + "import": "", + }, + ... + } + + Limbaje: curl, python, php, csharp, node, vfp_msxml, vfp_winhttp. + + Functie pura: fara I/O, fara DB, fara stare globala. + """ + return { + "curl": { + "prezentari": _snippet_curl_prezentari(base_url, account_id), + "import": _snippet_curl_import(base_url, account_id), + }, + "python": { + "prezentari": _snippet_python_prezentari(base_url, account_id), + "import": _snippet_python_import(base_url, account_id), + }, + "php": { + "prezentari": _snippet_php_prezentari(base_url, account_id), + "import": _snippet_php_import(base_url, account_id), + }, + "csharp": { + "prezentari": _snippet_csharp_prezentari(base_url, account_id), + "import": _snippet_csharp_import(base_url, account_id), + }, + "node": { + "prezentari": _snippet_node_prezentari(base_url, account_id), + "import": _snippet_node_import(base_url, account_id), + }, + "vfp_msxml": { + "prezentari": _snippet_vfp_msxml_prezentari(base_url, account_id), + "import": _snippet_vfp_msxml_import(base_url, account_id), + }, + "vfp_winhttp": { + "prezentari": _snippet_vfp_winhttp_prezentari(base_url, account_id), + "import": _snippet_vfp_winhttp_import(base_url, account_id), + }, + } diff --git a/app/web/routes.py b/app/web/routes.py index 53b63de..04303a7 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -134,7 +134,7 @@ def _rar_state(hb, worker_alive: bool) -> str: # cade pe Acasa (tab invalid -> fallback "acasa" in dashboard()), fara 404. # US-003 (3.6): "coada" (Trimiteri) nu mai e tab — Trimiterile sunt sectiune pe Acasa. # ?tab=coada cade tot pe Acasa (fallback), fara 404, fara fragment orfan. -_TABS_VALIDE = {"acasa", "mapari", "cont", "nomenclator"} +_TABS_VALIDE = {"acasa", "mapari", "cont", "nomenclator", "integrare"} def _get_acasa_context(request: Request, conn, account_id: int) -> dict: @@ -247,6 +247,41 @@ def _render_panel_nomenclator(request: Request, conn) -> str: }) +def _render_integrare(request: Request, conn, account_id: int) -> str: + """Randeaza panoul Integrare ca string HTML (hub documentatie + exemple cod). + + Calculeaza are_cheie (chei API active pe cont) si are_creds (credentiale RAR + configurate pe cont), preia base_url real si genereaza snippet-uri multi-limbaj. + """ + from ..mapping import account_or_default + from .integrare_examples import exemple as _exemple + + acct = account_or_default(account_id) + row_creds = conn.execute( + "SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,) + ).fetchone() + are_creds = bool(row_creds and row_creds["rar_creds_enc"]) + + row_key = conn.execute( + "SELECT 1 FROM api_keys WHERE account_id=? AND active=1 LIMIT 1", (acct,) + ).fetchone() + are_cheie = row_key is not None + + base_url = str(request.base_url).rstrip("/") + ex = _exemple(base_url, acct) + csrf_token = get_csrf_token(request) + + return templates.get_template("_integrare.html").render({ + "request": request, + "account_id": acct, + "base_url": base_url, + "exemple": ex, + "are_cheie": are_cheie, + "are_creds": are_creds, + "csrf_token": csrf_token, + }) + + def _render_panel_for_tab(request: Request, conn, account_id: int, tab: str) -> str: """Randeaza panoul corespunzator unui tab ca string HTML.""" if tab == "acasa": @@ -261,6 +296,8 @@ def _render_panel_for_tab(request: Request, conn, account_id: int, tab: str) -> return _render_panel_cont(request, conn, account_id) if tab == "nomenclator": return _render_panel_nomenclator(request, conn) + if tab == "integrare": + return _render_integrare(request, conn, account_id) return _render_panel_acasa(request) @@ -346,6 +383,18 @@ def fragment_nomenclator(request: Request) -> HTMLResponse: conn.close() +@router.get("/_fragments/integrare", response_class=HTMLResponse) +def fragment_integrare(request: Request) -> HTMLResponse: + """Fragment HTMX pentru tab-ul Integrare (hub documentatie + exemple cod).""" + account_id = require_login(request) + conn = get_connection() + try: + html = _render_integrare(request, conn, account_id) + return HTMLResponse(content=html) + finally: + conn.close() + + @router.get("/_fragments/banner", response_class=HTMLResponse) def fragment_banner(request: Request) -> HTMLResponse: account_id = require_login(request) @@ -1938,6 +1987,62 @@ def cont_roteste_cheie( conn.close() +@router.post("/integrare/test-cheie", response_class=HTMLResponse) +def integrare_test_cheie( + request: Request, + api_key: str = Form(""), + csrf_token: str | None = Form(None), +) -> HTMLResponse: + """Verifica cheia API lipita de utilizator — scoped pe contul sesiunii. + + US-004 (PRD Etapa 5): permite utilizatorului sa confirme ca o cheie copiata + din generatorul de exemple corespunde contului sau, fara efecte secundare + (fara creare/rotire). Cheie goala, invalida sau a altui cont -> mesaj de + eroare neutru (fara eco al cheii in raspuns). + """ + account_id = require_login(request) + verify_csrf(request, csrf_token) + acct = account_or_default(account_id) + + # Validare cheie goala / doar spatii -> eroare inainte de DB + cheie = (api_key or "").strip() + if not cheie: + return templates.TemplateResponse( + "_integrare_test_rezultat.html", + {"request": request, "succes": False, "mesaj": "Cheia este goala sau lipseste."}, + ) + + conn = get_connection() + try: + from ..auth import account_for_key + cont_cheie = account_for_key(conn, cheie) + finally: + conn.close() + + if cont_cheie is None: + # Cheie invalida, inexistenta sau revocata + return templates.TemplateResponse( + "_integrare_test_rezultat.html", + {"request": request, "succes": False, + "mesaj": "Cheie invalida sau revocata — nu a fost gasita in sistem."}, + ) + + if cont_cheie != acct: + # Cheie valida dar apartine altui cont — mesaj neutru, fara dezvaluire cont terta + return templates.TemplateResponse( + "_integrare_test_rezultat.html", + {"request": request, "succes": False, + "mesaj": "Cheia nu apartine contului tau."}, + ) + + # Succes — cheia e activa si corespunde contului sesiunii + return templates.TemplateResponse( + "_integrare_test_rezultat.html", + {"request": request, "succes": True, + "mesaj": f"Cheie valida — cont {acct}."}, + ) + + @router.post("/cont/rar-creds", response_class=HTMLResponse) def cont_rar_creds( request: Request, diff --git a/app/web/templates/_integrare.html b/app/web/templates/_integrare.html new file mode 100644 index 0000000..857b74e --- /dev/null +++ b/app/web/templates/_integrare.html @@ -0,0 +1,344 @@ +{# Panoul Integrare: exemple de cod multi-limbaj + export & referinta. #} +{# Variabile context: account_id, base_url, exemple, are_cheie, are_creds, csrf_token #} + +
+ + {# Empty-state: lipsesc cheie API sau credentiale RAR #} + {% if not are_cheie or not are_creds %} + + {% endif %} + + {# Card: ID cont si endpoint de baza #} +
+
+ Cont ID: + {{ account_id }} + Endpoint: + {{ base_url }} +
+
+ + {# Tab-list PRIMAR: limbaje #} +
+
+ + + + + + +
+ + {# Panel curl #} +
+ {% set ex = exemple["curl"] %} +
+ + +
+
+
+ +
{{ ex["prezentari"] | e }}
+
+
+
+ +
+ + {# Panel python #} + + + {# Panel PHP #} + + + {# Panel C# #} + + + {# Panel Node #} + + + {# Panel VFP: tab-list SECUNDAR pentru dialecte #} + +
+ + {# Card: Export & referinta #} + + + {# Formular test conexiune #} +
+

Testeaza conexiunea

+
+ +
+ + +

Verificam doar daca cheia e valida. Nu o salvam si nu o memoram — cheia se gestioneaza in Cont.

+
+ +
+
+
+ +
+ + diff --git a/app/web/templates/_integrare_test_rezultat.html b/app/web/templates/_integrare_test_rezultat.html new file mode 100644 index 0000000..566dcad --- /dev/null +++ b/app/web/templates/_integrare_test_rezultat.html @@ -0,0 +1,9 @@ +{% if succes %} +
+ {{ mesaj }} +
+{% else %} + +{% endif %} diff --git a/app/web/templates/dashboard.html b/app/web/templates/dashboard.html index f68f107..183ef90 100644 --- a/app/web/templates/dashboard.html +++ b/app/web/templates/dashboard.html @@ -21,12 +21,13 @@
{# US-003 (3.6): tab-ul "Trimiteri" (coada) a fost eliminat — Trimiterile traiesc - ca sectiune permanenta pe Acasa. Raman: Acasa·Mapari·Cont·Nomenclator. #} + ca sectiune permanenta pe Acasa. Raman: Acasa·Mapari·Cont·Nomenclator·Integrare. #} {% set tabs = [ ("acasa", "Acasa", "tab-acasa"), ("mapari", "Mapari", "tab-mapari"), ("cont", "Cont", "tab-cont"), - ("nomenclator", "Nomenclator", "tab-nomenclator") + ("nomenclator", "Nomenclator", "tab-nomenclator"), + ("integrare", "Integrare", "tab-integrare") ] %} {% for tab_id, tab_label, tab_elem_id in tabs %} {% set badge = (badges.get(tab_id, 0) if badges else 0) %} @@ -52,40 +53,44 @@ ', html, _re.DOTALL) + script_text = " ".join(script_blocks) + # Verificam ca butonul (btn) isi schimba textContent (nu doar feedback div) + assert "btn.textContent" in script_text, ( + "Scriptul JS nu contine 'btn.textContent' — label-ul butonului nu se schimba." + ) + # Verificam valoarea setata pe buton + assert "'Copiat'" in script_text or '"Copiat"' in script_text, ( + "Scriptul JS nu seteaza valoarea 'Copiat' pe buton." + ) + # Verificam revenirea la 'Copiaza' dupa setTimeout + assert "setTimeout" in script_text, ( + "Scriptul JS nu contine setTimeout — revenirea la 'Copiaza' dupa 2s lipseste." + ) + assert "'Copiaza'" in script_text or '"Copiaza"' in script_text, ( + "Scriptul JS nu seteaza revenirea la 'Copiaza' dupa timeout." + ) + + +# =========================================================================== # +# test_script_integrare_scoped_pe_container [FIX-3] # +# =========================================================================== # + +def test_script_integrare_scoped_pe_container(client): + """Scriptul JS din _integrare.html este scoped pe #integrare-section. + + Un querySelectorAll global ar ataca si tablist-ul principal din dashboard.html, + acumuland handlere si provocand dubla-legare pe fiecare swap HTMX. + Verificam ca scriptul porneste de la getElementById('integrare-section') + si NU face document.querySelectorAll('[role="tablist"]') global. + """ + _create_account_user("scoped_script@test.com") + _login(client, "scoped_script@test.com") + + resp = client.get("/_fragments/integrare") + assert resp.status_code == 200 + html = resp.text + + import re as _re + script_blocks = _re.findall(r']*>(.*?)', html, _re.DOTALL) + script_text = " ".join(script_blocks) + + # Trebuie sa existe getElementById('integrare-section') ca root + assert "getElementById('integrare-section')" in script_text or \ + 'getElementById("integrare-section")' in script_text, ( + "Scriptul JS nu contine getElementById('integrare-section') — " + "scoping-ul pe container lipseste" + ) + + # NU trebuie sa existe document.querySelectorAll cu '[role="tablist"]' global + # (adica fara a folosi root-ul) + has_global_tablist = bool( + _re.search(r'document\.querySelectorAll\([\'"][^"\']*\[role=["\']tablist["\'][^"\']*[\'"]\)', script_text) + ) + assert not has_global_tablist, ( + "Scriptul JS contine document.querySelectorAll('[role=\"tablist\"]') global — " + "trebuie sa foloseasca root.querySelectorAll dupa getElementById('integrare-section')" + )