Terminal / tests /test_security.py
Baida07's picture
sync: 181 file da Baida98/AI@80e22d8c (2026-08-25 12:01 UTC) [deploy-all]
22bf795 verified
Raw
History Blame
27.1 kB
"""test_security.py — Regression test sicurezza P19-SEC2 (audit 2026-07-08, rev 2026-07-09).
Esegui con: python3 -m pytest backend/tests/test_security.py -v
"""
from __future__ import annotations
import asyncio, os, sys, time, unittest
import unittest.mock
from datetime import datetime, timezone, timedelta
from fastapi import HTTPException
_B = os.path.join(os.path.dirname(__file__), "..")
if _B not in sys.path:
sys.path.insert(0, _B)
# Helper per eseguire coroutine
def _run(coro):
return asyncio.run(coro)
class TestShellValidate(unittest.TestCase):
"""SEC-1: validate_shell_command blocca tutti i vettori di injection."""
def setUp(self):
try:
from tools._shell_safety import validate_shell_command, run_shell_safe
self.v = validate_shell_command
self.rs = run_shell_safe
except ImportError as e:
self.skipTest(str(e))
# Comandi validi
def test_ok_ls(self): self.assertIsNone(self.v("ls"))
def test_ok_ls_args(self): self.assertIsNone(self.v("ls -la"))
def test_ok_git_status(self): self.assertIsNone(self.v("git status"))
def test_ok_curl_https(self): self.assertIsNone(self.v("curl https://example.com"))
def test_ok_echo(self): self.assertIsNone(self.v("echo hello"))
# Metacaratteri shell (GAP-11 / GAP-12)
def test_block_semicolon(self):
self.assertIsNotNone(self.v("ls; rm -rf /"), "semicolon bypass non rilevato")
def test_block_ampersand(self):
self.assertIsNotNone(self.v("echo ok && curl x.com"), "ampersand bypass")
def test_block_pipe(self):
self.assertIsNotNone(self.v("ls | curl x.com"), "pipe bypass")
def test_block_dollar_paren(self):
self.assertIsNotNone(self.v("echo $(rm /)"), "$(cmd) bypass")
def test_block_newline(self):
self.assertIsNotNone(self.v("ls\nrm /"), "newline bypass")
# Comandi non in allowlist
def test_block_rm(self):
self.assertIsNotNone(self.v("rm -rf /tmp"), "rm non bloccato")
def test_block_git_push(self):
self.assertIsNotNone(self.v("git push origin main"), "git push non bloccato")
def test_block_curl_http(self):
self.assertIsNotNone(self.v("curl http://attacker.com"), "curl http non bloccato")
# run_shell_safe
def test_run_blocks_injection(self):
r = _run(self.rs("ls; whoami"))
self.assertFalse(r["ok"])
self.assertIsNotNone(r["error"])
self.assertEqual(r["stdout"], "")
def test_run_executes_valid(self):
r = _run(self.rs("echo sec_test_ok"))
self.assertTrue(r["ok"])
self.assertIn("sec_test_ok", r["stdout"])
class TestSafeEnv(unittest.TestCase):
"""SEC-2: safe_shell_env non espone secret del processo padre."""
def setUp(self):
try:
from tools._shell_safety import safe_shell_env
self.fn = safe_shell_env
except ImportError as e:
self.skipTest(str(e))
def _get(self, **kw):
with unittest.mock.patch.dict(os.environ, kw):
return self.fn()
def test_no_internal_token(self):
self.assertNotIn("INTERNAL_TOKEN", self._get(INTERNAL_TOKEN="secret"))
def test_no_supabase_key(self):
self.assertNotIn("SUPABASE_KEY", self._get(SUPABASE_KEY="srv_role"))
def test_no_hf_token_a(self):
self.assertNotIn("HF_TOKEN_A", self._get(HF_TOKEN_A="hf_fake"))
def test_no_vault_key(self):
self.assertNotIn("VAULT_KEY", self._get(VAULT_KEY="fernet_key"))
class TestRequireRole(unittest.TestCase):
"""SEC-3: require_role(MACHINE) non fa fail-open con INTERNAL_TOKEN vuoto."""
def setUp(self):
try:
from api.auth_guard import require_role, AuthRole
self.rr = require_role
self.AR = AuthRole
except ImportError as e:
self.skipTest(str(e))
def test_machine_rejects_empty_token(self):
dep = self.rr(self.AR.MACHINE)
req_mock = unittest.mock.MagicMock()
req_mock.headers = {}
with unittest.mock.patch.dict(os.environ, {"INTERNAL_TOKEN": ""}):
with self.assertRaises(HTTPException) as ctx:
# Passiamo resolved=AuthRole.USER per simulare la dependency non risolta
# o testare il comportamento di require_role quando chiamata direttamente
_run(dep(req_mock, resolved=self.AR.USER))
self.assertIn(ctx.exception.status_code, (401, 403, 503),
"require_role fail-open con INTERNAL_TOKEN vuoto")
def test_machine_rejects_wrong_token(self):
dep = self.rr(self.AR.MACHINE)
req_mock = unittest.mock.MagicMock()
req_mock.headers = {"authorization": "Bearer wrongtoken"}
with unittest.mock.patch.dict(os.environ, {"INTERNAL_TOKEN": "correcttoken"}):
with self.assertRaises(HTTPException):
_run(dep(req_mock, resolved=self.AR.USER))
class TestMachineRateLimitClientBuckets(unittest.TestCase):
"""SEC-3B: il token proxy MACHINE non deve condividere un unico bucket pubblico."""
def setUp(self):
try:
from api.auth_guard import _rate_key
self.rate_key = _rate_key
except ImportError as e:
self.skipTest(str(e))
def test_machine_uses_distinct_attested_client_ips(self):
first = self.rate_key(1, "shared-internal-token", "198.51.100.10")
second = self.rate_key(1, "shared-internal-token", "198.51.100.11")
self.assertNotEqual(first, second)
def test_machine_without_attested_ip_keeps_token_bucket(self):
first = self.rate_key(1, "shared-internal-token", None)
second = self.rate_key(1, "shared-internal-token", None)
self.assertEqual(first, second)
def test_operator_remains_token_scoped(self):
first = self.rate_key(2, "operator-token", "198.51.100.10")
second = self.rate_key(2, "operator-token", "198.51.100.11")
self.assertEqual(first, second)
class TestBrowserEndpointAuth(unittest.TestCase):
"""SEC-5: endpoint browser stateless non fail-open (GAP-1-fix)."""
def _check_role_param(self, fn_name: str):
try:
import api.browser as bmod
fn = getattr(bmod, fn_name)
import inspect
sig = inspect.signature(fn)
self.assertIn('role', sig.parameters,
f"{fn_name} non ha parametro 'role' — require_role mancante")
except (ImportError, AttributeError) as e:
self.skipTest(str(e))
def test_screenshot_auth(self): self._check_role_param('browser_screenshot')
def test_navigate_auth(self): self._check_role_param('browser_navigate')
def test_close_auth(self): self._check_role_param('browser_close')
def test_session_shot_auth(self): self._check_role_param('browser_session_screenshot')
def test_list_sessions_auth(self): self._check_role_param('list_sessions')
class TestJsBlocklistAppliedToTS(unittest.TestCase):
"""SEC-6: _JS_BLOCKED_RE applicata anche a TypeScript (GAP-3-fix)."""
def setUp(self):
try:
import api.exec as emod
self._re = emod._JS_BLOCKED_RE
import inspect
self._exec_src = inspect.getsource(emod.exec_code)
except (ImportError, AttributeError) as e:
self.skipTest(str(e))
def test_ts_blocked_child_process(self):
"""Il codice TS con require('child_process') deve essere bloccato."""
dangerous = "import { exec } from 'child_process'; exec('whoami')"
self.assertIsNotNone(self._re.search(dangerous), "child_process non bloccato in TS")
def test_ts_block_check_in_source(self):
"""Il fix GAP-3 deve apparire nel sorgente di exec_code."""
# Il codice usa 'js_blocked' come prefisso per entrambi JS e TS
self.assertIn('js_blocked', self._exec_src, "GAP-3-fix non trovato in exec_code — TS check mancante")
# ─────────────────────────────────────────────────────────────────────────────
# SEC-7 → SEC-11 Audit 2026-07-09 — regression comportamentale GAP-4/6/7/8/9
# rev 2: SEC-8/10/11 riscritti da string-matching a test comportamentali reali
# dopo code review che segnalava falsi negativi possibili (vedi commit history).
# ─────────────────────────────────────────────────────────────────────────────
class TestUserIdFixed(unittest.TestCase):
"""SEC-7: _user_id() ritorna sempre 'default' — ignora X-User-ID client (GAP-4-fix)."""
def setUp(self):
try:
from api.auth_managed import _user_id
self._fn = _user_id
except ImportError as e:
self.skipTest(str(e))
def _make_req(self, headers: dict):
try:
from starlette.requests import Request as _Req
except ImportError as e:
self.skipTest(str(e))
scope = {
"type": "http",
"method": "GET",
"path": "/test",
"query_string": b"",
"headers": [(k.lower().encode(), v.encode()) for k, v in headers.items()],
}
return _Req(scope)
def test_returns_default_no_header(self):
req = self._make_req({})
self.assertEqual(self._fn(req), "default")
def test_ignores_x_user_id_header(self):
"""GAP-4: X-User-ID dal client NON deve influenzare il risultato."""
req = self._make_req({"X-User-ID": "attacker"})
self.assertEqual(self._fn(req), "default",
"REGRESSO GAP-4: _user_id ha letto X-User-ID — hijack possibile")
def test_ignores_spoofed_admin(self):
req = self._make_req({"X-User-ID": "admin", "x-forwarded-user": "root"})
self.assertEqual(self._fn(req), "default")
def test_ignores_multiple_hijack_headers_simultaneously(self):
req = self._make_req({
"X-User-ID": "admin",
"X-Forwarded-User": "root",
"X-Auth-User": "superuser",
})
self.assertEqual(self._fn(req), "default")
class TestCORSOriginValidationBehavior(unittest.TestCase):
"""SEC-8: _is_allowed_origin() valida realmente l'origin contro la whitelist (GAP-8-fix).
Esegue il vero codice sorgente di main.py (blocco estratto, non riscritto) in un
namespace isolato — evita gli import pesanti dell'intera app FastAPI (router,
scheduler, DB) mantenendo comportamento reale, non string-matching.
"""
def _load_fn(self, extra_env: dict | None = None):
_mp = os.path.join(os.path.dirname(__file__), "..", "main.py")
with open(_mp) as f:
src = f.read()
start = src.index("_ALLOWED_ORIGINS_ENV = os.getenv")
def_idx = src.index("def _is_allowed_origin")
end = src.index("\n\n", def_idx)
block = src[start:end]
env_ctx = unittest.mock.patch.dict(os.environ, extra_env or {})
with env_ctx:
ns: dict = {"os": os}
class _StubLogger:
def info(self, *a, **k): pass
def warning(self, *a, **k): pass
ns["_logger"] = _StubLogger()
exec(compile(block, "main_cors_block", "exec"), ns)
return ns["_is_allowed_origin"]
def setUp(self):
try:
self._fn = self._load_fn()
except Exception as e:
self.skipTest(str(e))
def test_blocks_unknown_origin(self):
self.assertFalse(self._fn("https://evil.com"), "GAP-8: origin sconosciuto non bloccato")
def test_blocks_none_origin(self):
self.assertFalse(self._fn(None))
def test_blocks_empty_origin(self):
self.assertFalse(self._fn(""))
def test_allows_localhost_dev(self):
self.assertTrue(self._fn("http://localhost:5173"))
def test_allows_pages_dev_pattern(self):
self.assertTrue(self._fn("https://agente-ai.pages.dev"))
def test_allows_hf_space_pattern(self):
self.assertTrue(self._fn("https://baida98-ai.hf.space"))
def test_blocks_suffix_spoofing_lookalike(self):
"""'*.hf.space.evil.com' non deve ingannare il check endswith."""
self.assertFalse(self._fn("https://notreal.hf.space.evil.com"),
"GAP-8: suffix spoofing bypassa endswith check")
def test_blocks_prefix_lookalike(self):
self.assertFalse(self._fn("https://my-hf.space.attacker.io"))
def test_env_configured_origin_allowed(self):
"""ALLOWED_ORIGINS env aggiunge origin custom alla whitelist — ma non apre a tutto."""
try:
fn2 = self._load_fn({"ALLOWED_ORIGINS": "https://custom-domain.com"})
except Exception as e:
self.skipTest(str(e))
self.assertTrue(fn2("https://custom-domain.com"))
self.assertFalse(fn2("https://random-other.com"))
class TestEmailFromDomainAllowlist(unittest.TestCase):
"""SEC-9: _from_domain_allowed() blocca mittenti non autorizzati (GAP-9-fix)."""
def setUp(self):
try:
from api.email import _from_domain_allowed
self._fn = _from_domain_allowed
except ImportError as e:
self.skipTest(str(e))
def test_allows_configured_domain(self):
with unittest.mock.patch.dict(os.environ, {
"RESEND_ALLOWED_DOMAINS": "mydomain.com",
"RESEND_FROM_EMAIL": "",
}):
self.assertTrue(self._fn("noreply@mydomain.com"))
def test_blocks_unconfigured_domain(self):
with unittest.mock.patch.dict(os.environ, {
"RESEND_ALLOWED_DOMAINS": "mydomain.com",
"RESEND_FROM_EMAIL": "",
}):
self.assertFalse(self._fn("attacker@evil.com"),
"GAP-9: open relay — dominio non autorizzato non bloccato")
def test_uses_resend_from_email_fallback(self):
with unittest.mock.patch.dict(os.environ, {
"RESEND_ALLOWED_DOMAINS": "",
"RESEND_FROM_EMAIL": "agent@myapp.io",
}):
self.assertTrue(self._fn("noreply@myapp.io"))
self.assertFalse(self._fn("hack@attacker.net"),
"GAP-9: dominio non in fallback RESEND_FROM_EMAIL non bloccato")
def test_blocks_invalid_email_format(self):
with unittest.mock.patch.dict(os.environ, {
"RESEND_ALLOWED_DOMAINS": "x.com",
"RESEND_FROM_EMAIL": "",
}):
self.assertFalse(self._fn("notanemail"))
def test_blocks_empty_from(self):
with unittest.mock.patch.dict(os.environ, {
"RESEND_ALLOWED_DOMAINS": "x.com",
"RESEND_FROM_EMAIL": "",
}):
self.assertFalse(self._fn(""))
def test_multi_domain_allowlist(self):
with unittest.mock.patch.dict(os.environ, {
"RESEND_ALLOWED_DOMAINS": "domain-a.com,domain-b.org",
"RESEND_FROM_EMAIL": "",
}):
self.assertTrue(self._fn("no-reply@domain-a.com"))
self.assertTrue(self._fn("bot@domain-b.org"))
self.assertFalse(self._fn("spy@domain-c.net"))
class TestTokenAutoRefreshBehavior(unittest.TestCase):
"""SEC-10: get_managed_token esegue davvero il refresh end-to-end (GAP-7-fix).
Mocka Supabase (_sb_get_token/_sb_upsert_token) e httpx.AsyncClient per
verificare comportamento reale, non solo presenza lessicale di token nel sorgente.
"""
def setUp(self):
try:
import api.auth_managed as amod
self.amod = amod
except ImportError as e:
self.skipTest(str(e))
def _row(self, expires_at, access="ACCESS_ENC", refresh="REFRESH_ENC"):
return {
'access_token': access, 'refresh_token': refresh,
'expires_at': expires_at, 'scope': 'read:user', 'raw_meta': '{}',
}
def test_valid_token_skips_refresh(self):
"""Token valido (>60s residui) → NON deve chiamare l'HTTP refresh."""
far_future = int((time.time() + 3600) * 1000)
row = self._row(far_future, access='PLAINTEXT_ACCESS')
async def fake_get(user_id, provider): return row
async def forbidden_post(*a, **k):
raise AssertionError("GAP-7: refresh chiamato anche con token ancora valido")
with unittest.mock.patch.object(self.amod, '_sb_get_token', fake_get), \
unittest.mock.patch.object(self.amod, '_decrypt', lambda t: t), \
unittest.mock.patch('httpx.AsyncClient.post', forbidden_post):
result = _run(self.amod.get_managed_token('default', 'github'))
self.assertEqual(result, 'PLAINTEXT_ACCESS')
def test_expired_token_refresh_success_persists_new_token(self):
"""Token scaduto + refresh_token valido → POST al provider, salva e ritorna il nuovo access_token."""
past = int((time.time() - 100) * 1000)
row = self._row(past)
upserted: dict = {}
async def fake_get(user_id, provider): return row
async def fake_upsert(user_id, provider, access, refresh, expires_at, scope, meta):
upserted.update(access=access, refresh=refresh, expires_at=expires_at)
class FakeResp:
status_code = 200
def json(self):
return {'access_token': 'NEW_ACCESS', 'refresh_token': 'NEW_REFRESH', 'expires_in': 3600}
async def fake_post(self, *a, **k):
return FakeResp()
with unittest.mock.patch.object(self.amod, '_sb_get_token', fake_get), \
unittest.mock.patch.object(self.amod, '_sb_upsert_token', fake_upsert), \
unittest.mock.patch.object(self.amod, '_decrypt', lambda t: t), \
unittest.mock.patch('httpx.AsyncClient.post', fake_post), \
unittest.mock.patch.dict(os.environ, {
'GITHUB_OAUTH_CLIENT_ID': 'cid', 'GITHUB_OAUTH_CLIENT_SECRET': 'csec'}):
result = _run(self.amod.get_managed_token('default', 'github'))
self.assertEqual(result, 'NEW_ACCESS', "GAP-7: refresh riuscito ma non ritorna il nuovo token")
self.assertEqual(upserted.get('access'), 'NEW_ACCESS', "GAP-7: nuovo token non persistito su Supabase")
def test_expired_token_refresh_fails_falls_back_to_old_token(self):
"""Refresh HTTP fallisce (status != 200) → ritorna comunque il vecchio access_token, nessun crash."""
past = int((time.time() - 100) * 1000)
row = self._row(past, access='OLD_ACCESS')
async def fake_get(user_id, provider): return row
class FakeResp:
status_code = 400
text = 'invalid_grant'
def json(self): return {}
async def fake_post(self, *a, **k):
return FakeResp()
with unittest.mock.patch.object(self.amod, '_sb_get_token', fake_get), \
unittest.mock.patch.object(self.amod, '_decrypt', lambda t: t), \
unittest.mock.patch('httpx.AsyncClient.post', fake_post), \
unittest.mock.patch.dict(os.environ, {
'GITHUB_OAUTH_CLIENT_ID': 'cid', 'GITHUB_OAUTH_CLIENT_SECRET': 'csec'}):
result = _run(self.amod.get_managed_token('default', 'github'))
self.assertEqual(result, 'OLD_ACCESS')
def test_no_refresh_token_returns_existing_without_crash(self):
"""Nessun refresh_token salvato → ritorna il token esistente anche se scaduto."""
past = int((time.time() - 100) * 1000)
row = self._row(past, refresh='')
async def fake_get(user_id, provider): return row
with unittest.mock.patch.object(self.amod, '_sb_get_token', fake_get), \
unittest.mock.patch.object(self.amod, '_decrypt', lambda t: t):
result = _run(self.amod.get_managed_token('default', 'github'))
self.assertEqual(result, 'ACCESS_ENC')
def test_unknown_or_disconnected_provider_returns_none(self):
async def fake_get(user_id, provider): return None
with unittest.mock.patch.object(self.amod, '_sb_get_token', fake_get):
result = _run(self.amod.get_managed_token('default', 'github'))
self.assertIsNone(result)
class TestOAuthStateBehavior(unittest.TestCase):
"""SEC-11: OAuth state — persistenza Supabase, TTL, one-shot, fail-closed (GAP-6-fix).
Usa un fake client Supabase (query builder minimale) per eseguire il vero
codice di _make_state/_consume_state end-to-end, senza rete reale.
"""
class _FakeTable:
def __init__(self, store):
self.store = store
self._eq = (None, None)
self._delete = False
def insert(self, payload):
self.store.append(dict(payload))
return self
def select(self, *a, **k):
return self
def eq(self, field, value):
self._eq = (field, value)
return self
def delete(self):
self._delete = True
return self
def limit(self, n):
return self
def execute(self):
field, value = self._eq
if self._delete:
self.store[:] = [r for r in self.store if r.get(field) != value]
return unittest.mock.MagicMock(data=[])
matches = [r for r in self.store if r.get(field) == value] if field else list(self.store)
return unittest.mock.MagicMock(data=matches)
class _FakeSupabase:
def __init__(self):
self._store: list = []
def table(self, name):
return TestOAuthStateBehavior._FakeTable(self._store)
def setUp(self):
try:
import api.auth_managed as amod
self.amod = amod
except ImportError as e:
self.skipTest(str(e))
def test_make_state_persists_to_supabase(self):
fake_sb = self._FakeSupabase()
import api.state as state_mod
with unittest.mock.patch.object(state_mod, '_sb', fake_sb):
state = _run(self.amod._make_state('github', 'default'))
self.assertEqual(len(fake_sb._store), 1, "GAP-6: _make_state non ha scritto su Supabase")
self.assertEqual(fake_sb._store[0]['state'], state)
self.assertEqual(fake_sb._store[0]['provider'], 'github')
def test_consume_state_one_shot_no_replay(self):
"""Dopo il primo consumo lo stesso state deve fallire — impedisce replay CSRF."""
fake_sb = self._FakeSupabase()
import api.state as state_mod
fake_sb._store.append({
'state': 'abc123', 'provider': 'github', 'user_id': 'default',
'created_at': datetime.now(timezone.utc).isoformat(),
})
with unittest.mock.patch.object(state_mod, '_sb', fake_sb):
first = _run(self.amod._consume_state('abc123'))
replay = _run(self.amod._consume_state('abc123'))
self.assertIsNotNone(first)
self.assertEqual(first['provider'], 'github')
self.assertIsNone(replay, "GAP-6: state riutilizzabile — replay CSRF possibile")
def test_consume_state_expired_ttl_rejected(self):
"""Uno state più vecchio di _STATE_TTL secondi deve essere rifiutato."""
fake_sb = self._FakeSupabase()
import api.state as state_mod
old_time = datetime.now(timezone.utc) - timedelta(seconds=self.amod._STATE_TTL + 60)
fake_sb._store.append({
'state': 'old_state', 'provider': 'github', 'user_id': 'default',
'created_at': old_time.isoformat(),
})
with unittest.mock.patch.object(state_mod, '_sb', fake_sb):
result = _run(self.amod._consume_state('old_state'))
self.assertIsNone(result, "GAP-6: state expired accettato — TTL non applicato")
def test_consume_state_unknown_rejected(self):
fake_sb = self._FakeSupabase()
import api.state as state_mod
with unittest.mock.patch.object(state_mod, '_sb', fake_sb):
result = _run(self.amod._consume_state('never-existed'))
self.assertIsNone(result)
def test_consume_fails_closed_when_supabase_down_in_production(self):
"""Supabase irraggiungibile in produzione → rifiuta lo state (fail-closed), niente fallback insicuro."""
class _BrokenTable:
def select(self, *a, **k): return self
def eq(self, *a, **k): return self
def limit(self, *a, **k): return self
def execute(self): raise ConnectionError("supabase down")
class _BrokenSupabase:
def table(self, name): return _BrokenTable()
import api.state as state_mod
with unittest.mock.patch.object(state_mod, '_sb', _BrokenSupabase()), \
unittest.mock.patch.dict(os.environ, {'RAILWAY_ENVIRONMENT': 'production'}):
result = _run(self.amod._consume_state('any-state'))
self.assertIsNone(result,
"GAP-6: fail-open in produzione con Supabase down — CSRF bypass possibile")
class TestWebhookSetWebhookAdminGate(unittest.TestCase):
"""SEC-12: /api/telegram/set-webhook fail-closed quando ADMIN_TOKEN non è configurato
(GAP-WEBHOOK-ADMIN-FIX, audit 2026-07-09).
Usa un vero TestClient FastAPI (non chiamata diretta alla dependency) perché
require_role() ha una sotto-dependency annidata (_resolve_role) che va
risolta dal framework — vedi nota in GAPS_TODO.md.
"""
def setUp(self):
try:
from fastapi import FastAPI
from fastapi.testclient import TestClient
import api.webhook as webhook_mod
app = FastAPI()
app.include_router(webhook_mod.router)
self.client = TestClient(app)
except ImportError as e:
self.skipTest(str(e))
def test_rejects_without_admin_token_configured(self):
"""ADMIN_TOKEN non settato → 503 (fail-closed), MAI 200."""
with unittest.mock.patch.dict(os.environ, {"ADMIN_TOKEN": ""}, clear=False):
resp = self.client.post("/api/telegram/set-webhook")
self.assertEqual(resp.status_code, 503,
"GAP-WEBHOOK-ADMIN-FIX: endpoint apribile senza ADMIN_TOKEN configurato")
def test_rejects_wrong_admin_token(self):
"""ADMIN_TOKEN configurato ma header errato/assente → 403."""
with unittest.mock.patch.dict(os.environ, {"ADMIN_TOKEN": "correct-secret"}, clear=False):
resp = self.client.post("/api/telegram/set-webhook",
headers={"X-Admin-Token": "wrong-guess"})
self.assertEqual(resp.status_code, 403)
def test_no_admin_header_rejected_even_if_token_configured(self):
with unittest.mock.patch.dict(os.environ, {"ADMIN_TOKEN": "correct-secret"}, clear=False):
resp = self.client.post("/api/telegram/set-webhook")
self.assertIn(resp.status_code, (401, 403))
if __name__ == "__main__":
unittest.main()