File size: 27,107 Bytes
28a08e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22bf795
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28a08e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
"""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()