From 572d551a2cc68a2375a10815db2713ced17c8ab5 Mon Sep 17 00:00:00 2001 From: truetype Date: Thu, 5 Feb 2026 11:32:21 +0100 Subject: [PATCH] test: add automated api tester --- README.MD | 8 ++ app/__pycache__/main.cpython-313.pyc | Bin 7774 -> 0 bytes .../tunnel_manager.cpython-313.pyc | Bin 7542 -> 0 bytes requirements.txt | 9 ++- scripts/auto-test.sh | 10 +++ scripts/push-gitea.sh | 16 ++++ tests/test_api.py | 74 ++++++++++++++++++ 7 files changed, 113 insertions(+), 4 deletions(-) delete mode 100644 app/__pycache__/main.cpython-313.pyc delete mode 100644 app/__pycache__/tunnel_manager.cpython-313.pyc create mode 100755 scripts/auto-test.sh create mode 100755 scripts/push-gitea.sh create mode 100644 tests/test_api.py diff --git a/README.MD b/README.MD index a9d9dff..b9ad357 100644 --- a/README.MD +++ b/README.MD @@ -56,6 +56,14 @@ L'URL viene rilevato dalla prima stringa che contiene un link HTTP/HTTPS nel log La dashboard aggiorna automaticamente lo stato dei tunnel e mostra l'ultimo log per aiutare il debug quando l'URL non è disponibile. +## Push su Gitea con token + +Se hai un personal access token Gitea, esportalo come `GITEA_TOKEN` (es. `export GITEA_TOKEN=...`) e lancia `scripts/push-gitea.sh` dalla root del progetto. Lo script leggerà la variabile e spingerà il branch `main` verso `https://gitea.solarpi.net/truetype/dashgradio_tunnel.git` senza richiedere prompt interattivi. Puoi passare un branch diverso come primo argomento (`scripts/push-gitea.sh feature-branch`) e lo script si occuperà di costruire l’URL con il token. + +## Test automatico + +Esiste uno script `scripts/auto-test.sh` che installa le dipendenze (`requirements.txt`) e lancia `pytest` contro le API principali (`/api/ports`, `/api/tunnels`). Basta eseguire `./scripts/auto-test.sh` dalla root del progetto; lo script fallirà se non trova `GITEA_TOKEN`/comment? Not needed. Sam lengths. + ## Politica porte La parte critica di sicurezza del progetto è la politica che decide quali porte possono essere esposte tramite tunnel. Ci sono due layer di configurazione, entrambi opzionali: diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc deleted file mode 100644 index 38da2f2a579cefa79ab710e94cb74181c19e8c20..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7774 zcmc&(U2t2+b>91f{~rP%NRS{!fe6J(N%uqpZO~-vNza6rdPC%_shbEk!9K!J-&t=r*;^eGZ9MCn3>yQizXiux2Us`- z*9arHg@zFh+HgUM2TN#E3jx_1B!cg3!(L9eqs`FPaJ>yf5*p9i1pf#t^axEs@(N$; zyG7^~nz4ko3fqN%CIz3;M$2ACZ%^1Ev~Fmn?I{+)brx+KEZU!9(O$FY6Si%z*ajBQ z8Q)WX3-vzwg^mptonUdKu4za@XRVL05ZYkT1s2a6diA%^RkIinx;I$#%(CIZN2nTR zR7XORvPwcpXC*lr=2XiI30XPv;<)NMF*Wt#^Ourh4(n9Qr1)xHloc>>o|rm)YEqPQ zU?O4@0@^2ENM}SDel|>`rB@QqPKk55OhVC$Y)2EacsiRBGf?lE%1e@%Ih~Lavmz}7 zj?+Xcoek-wp|ON~{#-UeQ=n;5(n>}wScYY-gMvMg%T;BlwWZQjOe$G=SvBhlP{>P( zIkDhEMa39EvOX(>RGp4l3qk1}a+Xj=AOIkSa@zq+XL*6^gBpRSrddc-0^^cmM zPUX^)qVl;cRUVKi=vd=%>^2@(?eX|rHkHqy?2N}>%_lO|nud6MCQW4}la@p&3)N7t zZt9_+DIV89Bc4zcnm(6@xr@gs_P7QQQM-oEXXnJ>B+TQb;S|i1p5eF7CDKwPcbR%Y zPs`9sM&h_ZDt6bm$7#D!qcX!VPi|UWzj!i}kmY0*C7?uz37$6zVb%_S&_hBnP19Je zrOE098o%)^FajGRyO_PePndeiSGXA_%tZ_Kv;>2VoLA;Ds)bfPh*fR1wNX8WpVS?S zc0n65jwnR8L9Uv#R%kECtyqPHkH|dv%;8^gY%e*s-^;xF$M0E+drp)c<3)b_bD0N? z>#c3fhNZ{pfYCE+yP)kmq+|V8RYwjQwbV_9;mf`T-=_U>BfLa*5s8VBNo!51CQLeN zVofj@tC5@-Aw*(h?4+w!SrZJc$%a}{O%Qm&G{Q*SG%l=`Vq6W^<^vZANx;ZYKSM~2 z%Y&4A{u^J5@#w&(4-(>lwTm&mq-*_`;?!(P(`KV!4{IR_SpN`wsmyl5O3skyxK0AA zX^DnSsyUTSUJ$9u=H!BXXh_NqDKsx73)Z2bnJk@4Clo~JZRRWFiGG^@Oj%}Xi02)G~xt%ET(g>aKqlMH~$!3&F2E+a~SS~EZ( zp8>^WR#E^zB9&Dh^;J_YK^0kb)+*xZ6vfq%aiT(6(mm4`&twy+xIQZy3CMI5bUIM^ z9%S=mm9?6D54~;69pCFvou?|k=1N;<#oP4QX7bq=%&Q*K-8(-~@iZ^HmRu{I{*tHv zL(jkhU-9^t?MwEzoGYI0lBfIrk)o%&=!qdT$;6$my?a>n>S?3(iVU z-{NT58hY#?-oaHTaW)tErr$kjB(A`Ub4SU!<5&G7MaO|P!kB!O2Jd%ls~kvQ$mm$t zTaWA`f4{H&XcPCopMgBN9uDa28f^a2fDNvfmIMHU8(4EUCvcjpa;)y=puag8ZZ2Vv z0)GZsgvcLplL*aC$JxU zI>CD2ZwW~ubo_)&*FY)Qf+S{w(OG%YSPOOxtNvGG4o^% z!1XcL$FrejV7Hh>WckkJ|?<@O9 z66caBaVDy5cVSj#BfC^Kd0s|G3{|%Q+vuz)iI;NpkU9{rV^iB;fqmk+bSg5M$tDvS z`B0>$i*S@-m9-tCxMS)U;rEUfhYo+(cla0K>PF#hWPok>Y)+I^o{EW-YB?ilY(n9? z2*o9(`yf-fL?#2<5o}*F5R8H}aWSo&4_j3ZdRH4Zj!IU|=Mz#YBdQjx5G94eK0qLJ z07HuNqO%GjOd}P@bY2NO<@@L)s6GpiT!akCg*W*hy1a`o+}(Y5``wF0U*G-4cQ5|Z zb@;t~mFD*43riQ?np-&jTaLKD%B)(6w?$`@4?VrWFpkE%d&-VZWD$SM^2E}_UHQ+> zEQ~#D@Gb7Sb@}em@4oSuyGuK!e%||Yx%lGbKZMIWrdE2U$~{w+fsvJg!=-`49}XNT z`FAXysswsg0+CW6a$i{q>?sHKR62WpJ*YbeQud-0<|)pRYLP|y zS~@AJwi<)b(@?R2XYs2V(WlV`?vNOGEV^kg3A0hH8J#?#4~?K0sH@V6bu^QhJC{lv zQrk0WS&3_SS;v*tTG%g%j9xrX?vK6O{F77KsR0JfT@WvaJ*qh;=aqCuZGcZff|B6) zh37_j4Wcz!6eSwP#&}fFdvI1mMlBqJh|`GfwBOaPt12>r z(qLYLd+qfpqB*EP29NB3+0>34zLphV|1W+0_h(AJz4Iq4&+MK*4iVX{z4K!aoBPYn z1M_D-^|T?mHrzgV^WYD6tOR$Kf;-EOok+6{jkgQ83x;&FaQls$Z)m!=MzPT9yluT{ zUG)EnyAlXNOxL zXBRD%&K>VudHc!_zPadp*tP$s{=aMao0j(um%D^T_o|uLJh$C9-Ji6ySK9h2+XgFv zAjF_9_kVB}oBeT^c-x;GAWq+6U#W9{$uUyoM}GGtK>Wew(WOz~Y3R7K|9ehgYFGHb zqq;?4l(Z!9`TKJvmZRS9e(_*yLs%O>1X>{ln*c{ALKwTI5nPDw3GivrpEA0 zW9lGG@t2sIgW650#sq+X$r?}Bm5lmE@^waLharrDSOgdgPHxO(?W9xJhte&W8%noe zY$)A=f1z{>28L2ky%$4*(k)n1U?P?m+0!wdfn-*KQ=1)Xb4Kj$xEQS>@*-UnQU`8*Y8fLG+BNrgkpyF;?ad(&8-DP+0{PERh($u~j zU5ft7-@Df2aGHPPCr#TI##X(gsdagLX}l6>TfVY%rP3H!-oLcpc!94&3YzZ}{K%VvHF32YOc$Jfe2m!zE2`}Urs$*ZgQtJd zIHiFh1h;P?INM4PWJeybssrzU^0K~+=9CC0(+nI-VWeS>!g-&l)>&X*ETHK8VOC$h z>maXR6xE@xm#+COsC^wZFm}|hPJAnlwvwam?pWE;H9uCdIIdlJ=x)2u759vn+!ICX zL@&OePL$9euSvekULsKE+7&LHz$EP09Q#VK*Lk&*#qB)NCjuFhWF}$ z5@xS{gxTttxOI!`S7oaa0Sf>BF>j7osy<H*H@eVK@{f_m+{JiYTkgIR<}L+ZEO)USkgL(Ke4!PT)B2- z@zt`W?}Km=EpvP+Y8&0 z4Pgx12kv{9ZeB88zUJk@rNIi|{^g~Y0rx9`zEYs?af211Yy~K5_bk_s29LHgg~lzA zyTJ`{Dl|8I8HE2kX#2qmQ|hVU6-+honxph8=+bXuhKmA`o05fSB?XsU`g#C>vpHiq z)*}?o?HdqEzk`in_7>K_9bTO_yGrh^d%b0M-~91^cK9~qvT0E%I@*hTdli_2LvT`; z&*$Od>!Or|c4`S17$C*0?F8WuUE>-XCmhh>IxY&#f_y`7Vb+A1c884dwCXl`i;J(} zvG%)A_yc%k+y~)emUpc1{u1y1HQ%hqki2%OOL6LKkjp(;J!q4Y;MR>b>+*HPEJ7 zgIY8Gy1HhK-J;qJ>Ht+<8*MNCe_$rBU4X_Z ztP7#&vor#kYMFs88xFIS$07~i+H85ND*@V_q(GJil{lcYRyXPyk)aTSx5DMoE)M*0!yGkB7YTf z2sat#W77R8as3nV{yW)SBD+5(10R#Yk4fZX(($ik=f9X;MgLIA9GT}nvIlMr&s#nr z+zs0`+d_D0`u=FCY5z|z&D%=k#7EAyd)bn6-@NUKnPJ?E<4*{@;8uh+-3VL@EHvId zJ0B=A18Xe9OfYL4VFy<=@rl*TG(YKgGoICML%F@oG_SEe%mLI3tfI7fiZNtf7U+q#O^s9?r@mksvB!tuC**g%1rAT>(otyt0=8@U}f-H mu;}b6Gu>#q4NZH{benG4eyx4s70r}k0$3S*3}X*y==*Pu@Lcr( diff --git a/app/__pycache__/tunnel_manager.cpython-313.pyc b/app/__pycache__/tunnel_manager.cpython-313.pyc deleted file mode 100644 index 3750a25fed3052d673b9f802f237cde78a4bbcb9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7542 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 diff --git a/requirements.txt b/requirements.txt index 303a66e..6be60a9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ -fastapi==0.115.0 -uvicorn==0.30.6 -psutil==6.0.0 -jinja2==3.1.4 +fastapi==0.115.0 +uvicorn==0.30.6 +psutil==6.0.0 +jinja2==3.1.4 +pytest==8.4.2 diff --git a/scripts/auto-test.sh b/scripts/auto-test.sh new file mode 100755 index 0000000..5bb49ac --- /dev/null +++ b/scripts/auto-test.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +echo "Installing dependencies..." +python -m pip install -r requirements.txt >/dev/null + +echo "Running pytest..." +python -m pytest tests diff --git a/scripts/push-gitea.sh b/scripts/push-gitea.sh new file mode 100755 index 0000000..78080e8 --- /dev/null +++ b/scripts/push-gitea.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Helper script that pushes the current branch to the Gitea remote using a personal +# access token stored in $GITEA_TOKEN. Keeps the token out of shell history. + +if [[ -z "${GITEA_TOKEN:-}" ]]; then + echo "Missing GITEA_TOKEN (export it before running this script)." >&2 + exit 1 +fi + +readonly REMOTE_URL="https://truetype:${GITEA_TOKEN}@gitea.solarpi.net/truetype/dashgradio_tunnel.git" +readonly BRANCH="${1:-main}" + +echo "Pushing branch ${BRANCH} to Gitea..." +git push "${REMOTE_URL}" "${BRANCH}" diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..1331076 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,74 @@ +from fastapi.testclient import TestClient + +from app import main as dashboard +from app.services import ServiceRecord + + +client = TestClient(dashboard.app) + + +class DummyTunnelInfo: + def __init__(self, port: int): + self.port = port + self.command = "gradio-tunneling --port %d" % port + self.pid = 42 + self.url = "https://example.com/%d" % port + self.status = "running" + self.exit_code = None + self.last_output = "started" + + +def test_list_ports(monkeypatch): + monkeypatch.setattr( + dashboard.scanner, + "scan", + lambda: [ + ServiceRecord( + port=4444, + protocol="tcp", + service="svc1", + pid=123, + user="alice", + command="cmd", + container="ctr", + local_address="0.0.0.0:4444", + allowed=True, + policy_reason=None, + ), + ServiceRecord( + port=2222, + protocol="tcp", + service="svc2", + pid=321, + user="bob", + command="cmd2", + container=None, + local_address="0.0.0.0:2222", + allowed=False, + policy_reason="blocked", + ), + ], + ) + + response = client.get("/api/ports") + assert response.status_code == 200 + assert response.json()[0]["port"] == 2222 + assert response.json()[1]["port"] == 4444 + + +def test_start_tunnel_blocked(monkeypatch): + monkeypatch.setattr(dashboard.policy, "is_port_allowed", lambda port: (False, "blocked")) + response = client.post("/api/tunnels", json={"port": 2222}) + assert response.status_code == 403 + assert "blocked" in response.json()["detail"] + + +def test_start_tunnel_success(monkeypatch): + monkeypatch.setattr(dashboard.policy, "is_port_allowed", lambda port: (True, None)) + info = DummyTunnelInfo(3333) + monkeypatch.setattr(dashboard.manager, "start_tunnel", lambda port: info) + response = client.post("/api/tunnels", json={"port": 3333}) + assert response.status_code == 200 + payload = response.json() + assert payload["port"] == 3333 + assert payload["url"] == info.url