Spaces:
Running
Running
| """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() | |