diff --git a/docs/wiki/Configuration.md b/docs/wiki/Configuration.md index cd65b78..3aacf67 100644 --- a/docs/wiki/Configuration.md +++ b/docs/wiki/Configuration.md @@ -116,4 +116,17 @@ environment: - LOG_DIR=/app/data/logs - LOG_RETENTION_DAYS=90 - LOG_TO_STDOUT=0 +# The image ships a HEALTHCHECK that probes /healthz, which only the dashboard +# serves. With the dashboard off, that probe can never succeed and the container +# is reported unhealthy forever -- which also stops any `depends_on: +# service_healthy` from ever being satisfied. Disable the check along with the +# dashboard: +healthcheck: + disable: true ``` + +> **Portugues:** a imagem traz um HEALTHCHECK que consulta `/healthz`, servido +> apenas pelo painel. Com o painel desligado a sonda nunca passa e o container +> fica eternamente marcado como *unhealthy*, travando qualquer +> `depends_on: service_healthy`. Desligue a verificacao junto com o painel, como +> no exemplo acima. diff --git a/src/omini_rtksync/cli.py b/src/omini_rtksync/cli.py index 6aa3b10..e4a9443 100644 --- a/src/omini_rtksync/cli.py +++ b/src/omini_rtksync/cli.py @@ -122,6 +122,17 @@ def _sync_all_locked(self): log_msg("STATUS", f"[{provider} · {name}] {nota}") detalhe["actions"].append(nota) + # Trava de rate limit vencida sai antes de qualquer ramo: a janela + # do provedor reabriu, e isso vale para conexão OAuth, de chave ou + # local. Deixar a limpeza dentro de um único ramo mantinha a marca + # gravada — e a conexão amarela — em todos os outros. + trava = parse_expiry_to_ms(c.get("rateLimitedUntil")) + if trava is not None and trava <= int(time.time() * 1000): + if update_connection_health(self.settings.db_path, cid, clear_rate_limit=True): + nota = "Trava de rate limit vencida removida" + log_msg("STATUS", f"[{provider} · {name}] {nota}") + detalhe["actions"].append(nota) + # 1. Google / Antigravity OAuth if provider in ("antigravity", "gemini-cli"): local = self.google_provider.read_local_credential() @@ -154,8 +165,16 @@ def _sync_all_locked(self): # Recusado é motivo para renovar agora, não daqui a pouco. rem_sec = 0 elif veredito.state == STATE_VALID: + # Gravar só o resultado da sonda deixava para trás o + # `test_status='invalid'` de uma falha anterior, que não + # caduca sozinho. 'active' é o único valor que o + # OmniRoute trata como saudável (clearAccountError), e é + # o que a credencial acabou de provar que é. update_connection_health( - self.settings.db_path, cid, credential_state=veredito.state + self.settings.db_path, + cid, + test_status="active", + credential_state=veredito.state, ) if rem_sec <= self.settings.refresh_margin or not c.get("accessToken"): @@ -249,11 +268,9 @@ def _sync_all_locked(self): test_status=data.get("testStatus"), credential_state=data.get("credentialState"), last_error=data.get("lastError"), - # O provider ja apagou a trava vencida de `data`, entao - # inferir "limpar" da ausencia dela invertia o sentido e - # preservava justamente a trava que devia sair. Quem diz - # e a mensagem do provider. - clear_rate_limit=any("rateLimitedUntil" in n for n in notes), + # A trava vencida ja foi removida no topo do laco, para + # qualquer tipo de conexao. Repetir a decisao aqui so + # duplicaria a regra em um unico ramo. ) if renovou: refreshed += 1 diff --git a/src/omini_rtksync/database.py b/src/omini_rtksync/database.py index 0e686e8..92a887e 100644 --- a/src/omini_rtksync/database.py +++ b/src/omini_rtksync/database.py @@ -146,6 +146,11 @@ def get_all_connections(db_path: str) -> List[Dict[str, Any]]: "credentialState": (specific or {}).get("credentialState") or extra.get("credentialState"), "lastTested": item.get("last_tested") or extra.get("lastTested"), + # Renovar e verificar são eventos diferentes. Sem projetar este + # campo, "última renovação" no painel caía para o horário da + # última verificação e um token parado há dias parecia recém + # renovado a cada ciclo. + "lastRefreshAt": (specific or {}).get("lastRefreshAt") or extra.get("lastRefreshAt"), "lastHealthCheckAt": item.get("last_health_check_at"), "rateLimitedUntil": item.get("rate_limited_until") or extra.get("rateLimitedUntil"), "lastError": item.get("last_error"), @@ -222,14 +227,43 @@ def update_connection( # trata como saudável (src/sse/services/auth.ts::clearAccountError e # tokenHealthCheck.ts). 'ok' não é reconhecido e faz a conexão parecer # estar em estado de erro. - cursor.execute( - f""" - UPDATE {tbl} - SET access_token = ?, refresh_token = ?, expires_at = ?, test_status = 'active', updated_at = ? - WHERE id = ? - """, - (access_token, refresh_token, to_iso_utc(expires_at_ms), now_iso, connection_id), - ) + # O horário da renovação vive no JSON de provider_specific_data: + # o schema relacional não tem coluna para ele, e sem esse carimbo o + # painel não distingue "renovado agora" de "apenas verificado". + especifico = {} + if "provider_specific_data" in cols: + cursor.execute( + f"SELECT provider_specific_data FROM {tbl} WHERE id = ?", (connection_id,) + ) + linha = cursor.fetchone() + if linha: + especifico = _decode_json(linha["provider_specific_data"]) or {} + especifico["lastRefreshAt"] = now_iso + cursor.execute( + f""" + UPDATE {tbl} + SET access_token = ?, refresh_token = ?, expires_at = ?, test_status = 'active', + provider_specific_data = ?, updated_at = ? + WHERE id = ? + """, + ( + access_token, + refresh_token, + to_iso_utc(expires_at_ms), + json.dumps(especifico), + now_iso, + connection_id, + ), + ) + else: + cursor.execute( + f""" + UPDATE {tbl} + SET access_token = ?, refresh_token = ?, expires_at = ?, test_status = 'active', updated_at = ? + WHERE id = ? + """, + (access_token, refresh_token, to_iso_utc(expires_at_ms), now_iso, connection_id), + ) elif "data" in cols: # Formato compatível com JSON cursor.execute(f"SELECT data FROM {tbl} WHERE id = ?", (connection_id,)) @@ -248,6 +282,7 @@ def update_connection( # 9Router (resetHealthStateOnActivation em connectionsRepo.js); # 'ok' só é reconhecido pela UI e não limpa travas de erro. d["testStatus"] = "active" + d["lastRefreshAt"] = now_iso cursor.execute( f"UPDATE {tbl} SET data = ?, updatedAt = ? WHERE id = ?", (json.dumps(d), now_iso, connection_id), @@ -319,9 +354,48 @@ def update_connection_health( campos.append("provider_specific_data = ?") valores.append(json.dumps(atual)) + # Schema de coluna JSON única (o formato que o 9Router usa e que o + # OmniRoute aceita em instalações migradas). Sem este ramo a sondagem + # era descartada inteira nessas bases: não há `test_status` nem + # `provider_specific_data` para receber os campos acima, e a função + # saía por `if not campos` como se não houvesse nada a gravar. + if "data" in cols and "test_status" not in cols: + cursor.execute(f"SELECT data FROM {tbl} WHERE id = ?", (connection_id,)) + linha = cursor.fetchone() + d: Dict[str, Any] = {} + if linha and linha["data"]: + try: + carregado = json.loads(linha["data"]) + if isinstance(carregado, dict): + d = carregado + except Exception: + # Coluna corrompida ou em formato inesperado: seguimos com o + # dicionário vazio e regravamos a linha com os campos de + # saúde. Abortar aqui faria uma linha ilegível bloquear para + # sempre a gravação da sondagem — justamente na conexão que + # mais precisa ser diagnosticada. + pass + if test_status: + d["testStatus"] = test_status + if credential_state: + d["credentialState"] = credential_state + d["credentialCheckedAt"] = now_iso + if discovered_models is not None: + d["discoveredModels"] = discovered_models + if last_error is not None: + d["lastError"] = last_error or None + if clear_rate_limit: + d.pop("rateLimitedUntil", None) + d["lastTested"] = now_iso + campos.append("data = ?") + valores.append(json.dumps(d)) + if "updated_at" in cols: campos.append("updated_at = ?") valores.append(now_iso) + elif "updatedAt" in cols: + campos.append("updatedAt = ?") + valores.append(now_iso) if not campos: return False diff --git a/src/omini_rtksync/i18n.py b/src/omini_rtksync/i18n.py index f0b3d01..577b510 100644 --- a/src/omini_rtksync/i18n.py +++ b/src/omini_rtksync/i18n.py @@ -73,6 +73,11 @@ "table.models": "models", "reason.local_ok": "Local instance answered with {count} model(s)", "reason.local_unreachable": "Local instance did not answer the model catalog", + "reason.local_empty": "Local instance answered, but has no model installed yet", + "egress.bound": "own egress", + "egress.shared": "shares the gateway address with {count} accounts", + "egress.single": "gateway address (only account)", + "egress.unknown": "egress unknown", "table.combo": "Combo", "table.cascade": "Model cascade", "combos.title": "Resilience combos", @@ -182,6 +187,11 @@ "table.models": "modelos", "reason.local_ok": "Instância local respondeu com {count} modelo(s)", "reason.local_unreachable": "Instância local não respondeu ao catálogo de modelos", + "reason.local_empty": "Instância local respondeu, mas ainda não tem nenhum modelo instalado", + "egress.bound": "saída própria", + "egress.shared": "divide o endereço do gateway com {count} contas", + "egress.single": "endereço do gateway (única conta)", + "egress.unknown": "saída desconhecida", "table.combo": "Combo", "table.cascade": "Cascata de modelos", "combos.title": "Combos de resiliência", @@ -291,6 +301,11 @@ "table.models": "modelos", "reason.local_ok": "La instancia local respondió con {count} modelo(s)", "reason.local_unreachable": "La instancia local no respondió al catálogo de modelos", + "reason.local_empty": "La instancia local respondió, pero aún no tiene ningún modelo instalado", + "egress.bound": "salida propia", + "egress.shared": "comparte la dirección del gateway con {count} cuentas", + "egress.single": "dirección del gateway (única cuenta)", + "egress.unknown": "salida desconocida", "table.combo": "Combo", "table.cascade": "Cascada de modelos", "combos.title": "Combos de resiliencia", diff --git a/src/omini_rtksync/models.py b/src/omini_rtksync/models.py index 41a2352..b7dfaef 100644 --- a/src/omini_rtksync/models.py +++ b/src/omini_rtksync/models.py @@ -154,6 +154,20 @@ def credential_state(self) -> Optional[str]: state = self.data.get("credentialState") return str(state) if state else None + @property + def rate_limit_active(self) -> bool: + """Se a trava de rate limit ainda vale neste instante. + + `rateLimitedUntil` é um prazo, não uma bandeira: ele guarda o momento em + que a janela do provedor se reabre. Tratar a simples presença do campo + como "limitada" deixava a conexão amarela para sempre depois do primeiro + 429, porque nada apaga a marca quando o prazo vence. + """ + until = parse_expiry_to_ms(self.data.get("rateLimitedUntil")) + if until is None: + return False + return until > int(time.time() * 1000) + @property def health_status(self) -> str: """Semantic classification of the connection state. @@ -167,9 +181,25 @@ def health_status(self) -> str: if self.is_local: # unreachable is written when the model catalog does not answer. - return "unknown" if self.data.get("testStatus") == "unreachable" else "active" + estado = self.data.get("testStatus") + if estado == "unreachable": + return "unknown" + # Uma conexão recém-criada ainda não foi sondada por ninguém. Com + # CRON_ENABLED=0 ela pode nunca ser, e dizer "ativa" é alegar uma + # saúde que nenhuma sonda confirmou. + return "active" if estado in ("active", "ok", "success") else "not_checked" if self.is_oauth: + # O gateway já pode ter carimbado a conexão como recusada. **Sem uma + # sonda viva que diga o contrário**, o carimbo dele é a melhor + # informação que existe — ignorá-lo mostrava como saudável uma + # credencial que o próprio OmniRoute sabe estar quebrada. Mas uma + # validação viva vence tudo: o carimbo é do último erro do gateway + # e não caduca sozinho, então honrá-lo mesmo depois de a sonda + # aprovar a credencial repetia, ao contrário, a própria contradição + # entre tela e banco que este arquivo existe para evitar. + if probed != "valid" and self.data.get("testStatus") in ("invalid", "error", "failed"): + return "invalid" remaining = self.remaining_seconds if remaining is None: return "no_expiration" @@ -180,7 +210,7 @@ def health_status(self) -> str: return "active" if self.has_api_key: - if self.data.get("rateLimitedUntil"): + if self.rate_limit_active: return "rate_limited" # Nunca sondada: dizer isso, em vez de alegar saúde que ninguém verificou. return "active" if probed == "valid" else "not_checked" diff --git a/src/omini_rtksync/providers.py b/src/omini_rtksync/providers.py index 1ec2aa9..07d343a 100644 --- a/src/omini_rtksync/providers.py +++ b/src/omini_rtksync/providers.py @@ -247,14 +247,24 @@ def check_and_refresh(self, conn: Dict[str, Any]) -> Tuple[bool, Optional[Dict[s if result.state == STATE_VALID: res["testStatus"] = "active" - messages.append(f"Chave aceita pelo provedor ({result.detail})") + # Um 4xx que nao seja 401/403 continua provando que a + # autenticacao passou -- a sonda manda corpo vazio de + # proposito, e o provedor so chega a reclamar do corpo depois + # de aceitar a chave. Dizer apenas "aceita (HTTP 400)" fazia a + # tela parecer errada; a frase agora explica o que o numero + # significa. + messages.append( + f"Autenticação aceita pelo provedor ({result.detail})" + if result.detail and "200" in str(result.detail) + else f"Autenticação aceita pelo provedor; a sondagem em si foi recusada ({result.detail})" + ) elif result.state == STATE_INVALID: res["testStatus"] = "invalid" messages.append(f"Chave RECUSADA pelo provedor ({result.detail})") elif result.state == STATE_RATE_LIMITED: messages.append(f"Provedor aplicou rate limit na validação ({result.detail})") elif result.state == STATE_UNREACHABLE: - messages.append(f"Provedor inacessível, chave não verificada: {result.detail}") + messages.append(f"Chave não verificada: {result.detail}") else: messages.append(result.detail or "Credencial não verificável") diff --git a/src/omini_rtksync/render.py b/src/omini_rtksync/render.py index 1fdee38..2360a46 100644 --- a/src/omini_rtksync/render.py +++ b/src/omini_rtksync/render.py @@ -81,10 +81,17 @@ def render_refresh_reason(conn: Any, refresh_margin: int, lang: str = DEFAULT_LA "nada precisava ser renovado" de "a renovação falhou". """ if conn.is_local: + # Quem diz se a instância respondeu é a sonda, não o tamanho do + # catálogo: uma instalação nova, de pé e sem nenhum modelo baixado, + # devolve lista vazia com HTTP 200. Contar modelos aqui a anunciava + # como inalcançável, contradizendo o "ativa" que o próprio ciclo + # acabara de gravar no banco. + if conn.data.get("testStatus") == "unreachable": + return translate("reason.local_unreachable", lang) models = conn.local_models if models: return translate("reason.local_ok", lang, count=len(models)) - return translate("reason.local_unreachable", lang) + return translate("reason.local_empty", lang) if not conn.is_oauth: return translate("reason.api_key", lang) @@ -184,6 +191,41 @@ def render_notice_page(title: str, body: str, link_label: str = "") -> bytes: """.encode("utf-8") +def egress_chip(conn: Any, sharing_count: int, lang: str) -> str: + """Marca de saída de rede da conexão, em modo somente leitura. + + O risco de bloqueio não vem de várias sessões na mesma conta -- isso os + provedores aceitam -- e sim de várias contas saindo pelo mesmo endereço. + Por isso "compartilhada" só vira aviso a partir da segunda conta nessa + situação: sozinha, ela é a única dona daquele IP. + """ + estado = conn.egress_status + if estado == "bound": + pool = conn.egress_binding or "?" + return ( + '' + f'' + f'{esc(translate("egress.bound", lang))}: {esc(pool)}' + ) + if estado == "shared": + if sharing_count > 1: + return ( + '' + f'' + f'{esc(translate("egress.shared", lang, count=sharing_count))}' + ) + return ( + '' + f'' + f'{esc(translate("egress.single", lang))}' + ) + return ( + '' + f'' + f'{esc(translate("egress.unknown", lang))}' + ) + + def health_badge(status: str, lang: str) -> str: """Monta o badge de saúde com ícone de fonte.""" css, icon = HEALTH_PRESENTATION.get(status, HEALTH_PRESENTATION["unknown"]) @@ -256,6 +298,13 @@ def render_connections_table(connections: List[Any], refresh_margin: int, lang: {esc(translate("connections.empty", lang))} """ + # Uma conta sozinha compartilhando nao e problema: ela e a unica dona + # daquele IP. O alerta comeca na segunda, quando o provedor passa a ver + # identidades distintas na mesma origem. + compartilhando = sum( + 1 for c in connections if not c.is_local and c.egress_status == "shared" + ) + rows = [] for c in connections: if c.is_local: @@ -282,6 +331,13 @@ def render_connections_table(connections: List[Any], refresh_margin: int, lang: ) if parts: detail = f'
{" · ".join(parts)}
' + else: + # Saida de rede: somente leitura. Quem roteia a requisicao e o + # gateway; o painel existe para o operador ver quais contas dividem + # endereco antes que o provedor veja primeiro. + chip = egress_chip(c, compartilhando, lang) + if chip: + detail = f'
{chip}
' rows.append(f""" diff --git a/tests/test_auth_recovery.py b/tests/test_auth_recovery.py index 2eddc2a..5050623 100644 --- a/tests/test_auth_recovery.py +++ b/tests/test_auth_recovery.py @@ -5,7 +5,6 @@ import stat import tempfile import pathlib -import unittest import unittest.mock from omini_rtksync import cli as omini_rtksync_cli diff --git a/tests/test_egress_panel.py b/tests/test_egress_panel.py new file mode 100644 index 0000000..7ba5232 --- /dev/null +++ b/tests/test_egress_panel.py @@ -0,0 +1,91 @@ +"""A saída de rede de cada conta precisa aparecer na tela. + +O risco de bloqueio não vem de várias sessões na mesma conta — os provedores +convivem com isso — e sim de **várias contas saindo pelo mesmo endereço**, que +é o estado natural de um gateway com todas elas cadastradas. O modelo já sabia +calcular esse vínculo e nada no painel o mostrava, então o operador só +descobriria o problema depois do provedor. + +Estes testes fixam duas decisões: + +- compartilhar o endereço só vira aviso a partir da **segunda** conta: sozinha, + ela é a única dona daquele IP e não há o que sinalizar; +- o painel é somente leitura — quem roteia a requisição é o gateway. +""" + +import unittest + +from omini_rtksync import render +from omini_rtksync.models import ConnectionRecord + + +def conexao(nome: str, **dados) -> ConnectionRecord: + base = {"id": nome, "provider": dados.pop("provider", "groq"), "name": nome} + base.update(dados) + return ConnectionRecord(id=nome, provider=base["provider"], name=nome, data=base) + + +def vinculada(nome: str, pool: str) -> ConnectionRecord: + # No OmniRoute os interruptores vivem em colunas de provider_connections e o + # vinculo em proxy_assignments, ja resolvido para `egressProxy` na leitura. + return conexao(nome, apiKey="gsk_x", hasApiKey=True, proxyEnabled=True, egressProxy=pool) + + +def compartilhada(nome: str) -> ConnectionRecord: + return conexao(nome, apiKey="gsk_x", hasApiKey=True, proxyEnabled=True, egressProxy=None) + + +class TestVinculoAparece(unittest.TestCase): + def html(self, conexoes): + return render.render_connections_table(conexoes, refresh_margin=900, lang="pt") + + def test_a_bound_account_shows_its_own_pool(self): + h = self.html([vinculada("Conta A", "pool-a")]) + self.assertIn("pool-a", h) + self.assertIn("saída própria", h) + + def test_two_accounts_on_the_gateway_address_raise_a_warning(self): + h = self.html([compartilhada("Conta A"), compartilhada("Conta B")]) + self.assertIn("divide o endereço do gateway", h) + self.assertIn("text-bg-warning-subtle", h, "o estado que importa precisa se destacar") + + def test_a_single_account_sharing_is_not_a_warning(self): + # Uma conta sozinha e a unica dona daquele IP: nao ha nada a alertar. + h = self.html([compartilhada("Conta unica")]) + self.assertIn("única conta", h) + self.assertNotIn("text-bg-warning-subtle", h) + + def test_a_bound_account_does_not_count_toward_the_warning(self): + # Duas contas, mas so uma compartilha: ainda nao ha duas no mesmo IP. + h = self.html([vinculada("Conta A", "pool-a"), compartilhada("Conta B")]) + self.assertNotIn("divide o endereço do gateway", h) + + def test_three_sharing_accounts_report_the_real_count(self): + h = self.html([compartilhada("A"), compartilhada("B"), compartilhada("C")]) + self.assertIn("3", h) + + def test_a_local_instance_has_no_egress_mark(self): + # Instancia local nao sai para o provedor: a marca nao se aplica. + local = conexao( + "Ollama", + provider="ollama", + baseUrl="http://127.0.0.1:11434/v1", + apiKey="fachada", + ) + h = self.html([local]) + self.assertNotIn("saída própria", h) + self.assertNotIn("divide o endereço", h) + + +class TestPainelNaoEscreve(unittest.TestCase): + def test_the_pool_name_is_escaped(self): + # O nome vem do banco do gateway: tratar como texto, nunca como HTML. + h = render.render_connections_table( + [vinculada("Conta", '')], refresh_margin=900, lang="pt" + ) + self.assertNotIn(" str: + """Timestamp ISO deslocado a partir de agora.""" + return (datetime.now(timezone.utc) + timedelta(seconds=segundos)).isoformat().replace( + "+00:00", "Z" + ) + + +def conexao(**dados) -> ConnectionRecord: + base = {"id": "c1", "provider": "groq", "name": "Teste"} + base.update(dados) + return ConnectionRecord(id="c1", provider=base["provider"], name=base["name"], data=base) + + +class TestTravaDeRateLimitVencida(unittest.TestCase): + def test_a_hold_still_in_the_future_keeps_the_connection_limited(self): + c = conexao(apiKey="gsk_x", hasApiKey=True, rateLimitedUntil=agora_mais(600)) + self.assertTrue(c.rate_limit_active) + self.assertEqual(c.health_status, "rate_limited") + + def test_an_expired_hold_releases_the_connection(self): + c = conexao( + apiKey="gsk_x", + hasApiKey=True, + rateLimitedUntil=agora_mais(-600), + credentialState="valid", + ) + self.assertFalse(c.rate_limit_active, "a janela do provedor ja reabriu") + self.assertEqual( + c.health_status, + "active", + "uma trava vencida mantinha a conexao amarela para sempre", + ) + + def test_an_epoch_in_milliseconds_is_understood_too(self): + futuro = int((time.time() + 600) * 1000) + self.assertTrue(conexao(apiKey="k", hasApiKey=True, rateLimitedUntil=futuro).rate_limit_active) + passado = int((time.time() - 600) * 1000) + self.assertFalse(conexao(apiKey="k", hasApiKey=True, rateLimitedUntil=passado).rate_limit_active) + + def test_no_hold_at_all_is_not_a_hold(self): + self.assertFalse(conexao(apiKey="k", hasApiKey=True).rate_limit_active) + + +class TestLocalNaoSondadaNaoAlegaSaude(unittest.TestCase): + def test_a_brand_new_local_connection_is_not_checked_yet(self): + c = conexao(provider="ollama", baseUrl="http://127.0.0.1:11434/v1") + self.assertEqual( + c.health_status, + "not_checked", + "sem sonda nenhuma, dizer 'ativa' e alegar saude que ninguem verificou", + ) + + def test_a_probed_local_connection_is_active(self): + c = conexao(provider="ollama", baseUrl="http://127.0.0.1:11434/v1", testStatus="active") + self.assertEqual(c.health_status, "active") + + def test_a_local_instance_that_did_not_answer_is_unknown(self): + c = conexao(provider="ollama", baseUrl="http://127.0.0.1:11434/v1", testStatus="unreachable") + self.assertEqual(c.health_status, "unknown") + + +class TestCarimboDoGatewayVale(unittest.TestCase): + def test_an_oauth_connection_the_gateway_rejected_is_invalid(self): + c = conexao( + provider="github", + accessToken="tok", + isOAuth=True, + expiresAt=agora_mais(7200), + testStatus="invalid", + ) + self.assertEqual( + c.health_status, + "invalid", + "o gateway ja sabe que a credencial esta quebrada; ignorar isso mostra saude falsa", + ) + + def test_a_live_probe_outranks_a_stale_gateway_stamp(self): + # `test_status` guarda o ultimo erro do gateway e nao caduca sozinho. + # Depois que a sonda viva aprova a credencial, insistir no carimbo + # antigo repetia, ao contrario, a contradicao entre tela e banco. + c = conexao( + provider="github", + accessToken="tok", + isOAuth=True, + expiresAt=agora_mais(7200), + testStatus="invalid", + credentialState="valid", + ) + self.assertEqual( + c.health_status, + "active", + "a validacao viva vence o carimbo velho do gateway", + ) + + def test_a_healthy_oauth_connection_is_still_active(self): + c = conexao( + provider="github", + accessToken="tok", + isOAuth=True, + expiresAt=agora_mais(7200), + testStatus="active", + ) + self.assertEqual(c.health_status, "active") + + +class TestPainelNaoContradizOBanco(unittest.TestCase): + def test_an_empty_catalog_is_not_reported_as_unreachable(self): + c = conexao( + provider="ollama", + baseUrl="http://127.0.0.1:11434/v1", + testStatus="active", + discoveredModels=[], + ) + frase = render_refresh_reason(c, refresh_margin=900, lang="pt") + self.assertNotIn("não respondeu", frase) + self.assertIn("nenhum modelo", frase) + + def test_an_instance_that_really_did_not_answer_still_says_so(self): + c = conexao( + provider="ollama", + baseUrl="http://127.0.0.1:11434/v1", + testStatus="unreachable", + discoveredModels=[], + ) + self.assertIn("não respondeu", render_refresh_reason(c, refresh_margin=900, lang="pt")) + + +class TestPersistenciaNoSchemaJson(unittest.TestCase): + """Bases com coluna `data` única perdiam a sondagem inteira.""" + + def setUp(self): + self.dir = tempfile.mkdtemp() + self.db = os.path.join(self.dir, "storage.sqlite") + conn = sqlite3.connect(self.db) + conn.execute( + "CREATE TABLE providerConnections (id TEXT PRIMARY KEY, provider TEXT, name TEXT," + " data TEXT, createdAt TEXT, updatedAt TEXT)" + ) + conn.execute( + "INSERT INTO providerConnections VALUES (?,?,?,?,?,?)", + ( + "c1", + "groq", + "Teste", + json.dumps({"apiKey": "gsk_x", "rateLimitedUntil": agora_mais(-60)}), + "2026-01-01", + "2026-01-01", + ), + ) + conn.commit() + conn.close() + + def ler(self): + conn = sqlite3.connect(self.db) + conn.row_factory = sqlite3.Row + linha = conn.execute("SELECT data FROM providerConnections WHERE id='c1'").fetchone() + conn.close() + return json.loads(linha["data"]) + + def test_the_probe_result_reaches_a_json_schema_database(self): + ok = update_connection_health( + self.db, + "c1", + test_status="invalid", + credential_state="invalid", + last_error="401 do provedor", + clear_rate_limit=True, + ) + self.assertTrue(ok, "a sondagem tem de ser gravada tambem neste schema") + d = self.ler() + self.assertEqual(d["testStatus"], "invalid") + self.assertEqual(d["credentialState"], "invalid") + self.assertEqual(d["lastError"], "401 do provedor") + self.assertNotIn("rateLimitedUntil", d, "a trava vencida tinha de sair") + self.assertIn("lastTested", d) + + def test_the_existing_content_is_preserved(self): + update_connection_health(self.db, "c1", test_status="active") + self.assertEqual(self.ler()["apiKey"], "gsk_x", "gravar saude nao pode apagar a credencial") + + def test_the_renewal_stamps_its_own_timestamp(self): + update_connection(self.db, "c1", "novo-token", "novo-refresh", int(time.time() * 1000)) + d = self.ler() + self.assertIn("lastRefreshAt", d, "renovar e verificar sao eventos diferentes") + self.assertEqual(d["accessToken"], "novo-token") + + +class TestCarimboDeRenovacaoNoSchemaRelacional(unittest.TestCase): + def setUp(self): + self.dir = tempfile.mkdtemp() + self.db = os.path.join(self.dir, "storage.sqlite") + conn = sqlite3.connect(self.db) + conn.execute( + "CREATE TABLE provider_connections (id TEXT PRIMARY KEY, provider TEXT, name TEXT," + " access_token TEXT, refresh_token TEXT, api_key TEXT, expires_at TEXT," + " test_status TEXT, provider_specific_data TEXT, updated_at TEXT)" + ) + conn.execute( + "INSERT INTO provider_connections VALUES (?,?,?,?,?,?,?,?,?,?)", + ( + "c1", + "google", + "Conta", + "velho", + "refresh", + None, + "2026-01-01T00:00:00Z", + "active", + json.dumps({"baseUrl": "https://x.example"}), + "2026-01-01T00:00:00Z", + ), + ) + conn.commit() + conn.close() + + def test_the_renewal_timestamp_is_written_without_losing_the_rest(self): + update_connection(self.db, "c1", "token-novo", "refresh-novo", int(time.time() * 1000)) + conn = sqlite3.connect(self.db) + conn.row_factory = sqlite3.Row + linha = conn.execute("SELECT * FROM provider_connections WHERE id='c1'").fetchone() + conn.close() + especifico = json.loads(linha["provider_specific_data"]) + self.assertIn("lastRefreshAt", especifico) + self.assertEqual( + especifico["baseUrl"], "https://x.example", "o endereço declarado nao pode ser perdido" + ) + self.assertEqual(linha["access_token"], "token-novo") + + +class TestTravaVencidaSaiNoCicloReal(unittest.TestCase): + """A limpeza vale para qualquer conexão, não só para as de chave de API. + + O primeiro corte tratava a trava dentro do ramo de chave de API. Uma + conexão OAuth — que é o caso comum no OmniRoute — passava longe dele e a + marca vencida continuava gravada, deixando a conexão amarela para sempre. + Este teste roda o ciclo inteiro contra um banco no schema relacional real. + """ + + def setUp(self): + self.dir = tempfile.mkdtemp() + self.db = os.path.join(self.dir, "storage.sqlite") + conn = sqlite3.connect(self.db) + conn.execute( + "CREATE TABLE provider_connections (id TEXT PRIMARY KEY, provider TEXT, name TEXT," + " access_token TEXT, refresh_token TEXT, api_key TEXT, expires_at TEXT," + " test_status TEXT, rate_limited_until TEXT, last_tested TEXT, last_error TEXT," + " provider_specific_data TEXT, created_at TEXT, updated_at TEXT)" + ) + conn.execute( + "INSERT INTO provider_connections VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + "c1", + "github", + "Conta OAuth", + "tok", + "ref", + None, + agora_mais(7200), + "active", + agora_mais(-7200), # trava vencida há duas horas + None, + None, + None, + "2026-01-01", + "2026-01-01", + ), + ) + conn.commit() + conn.close() + + def trava_gravada(self): + conn = sqlite3.connect(self.db) + v = conn.execute("SELECT rate_limited_until FROM provider_connections WHERE id='c1'").fetchone()[0] + conn.close() + return v + + def test_an_expired_hold_is_cleared_for_an_oauth_connection(self): + from omini_rtksync.cli import OmniSyncEngine + from omini_rtksync.config import Settings + + self.assertIsNotNone(self.trava_gravada()) + motor = OmniSyncEngine(Settings(db_path=self.db, enable_web=False, validate_credentials=False)) + motor.sync_all() + self.assertIsNone( + self.trava_gravada(), + "a janela do provedor reabriu ha duas horas; a marca tinha de sair", + ) + + +if __name__ == "__main__": + unittest.main() + + +class TestFraseDaSondagem(unittest.TestCase): + """Um 400 prova que a autenticação passou; a frase tem de dizer isso.""" + + def mensagens(self, estado, detalhe): + import unittest.mock + from omini_rtksync.credential_check import CheckResult + from omini_rtksync.providers import ApiKeyProvider + + provider = ApiKeyProvider(validate_credentials=True) + with unittest.mock.patch( + "omini_rtksync.providers.check_connection", + return_value=CheckResult(state=estado, detail=detalhe, checked_at="2026-01-01T00:00:00Z"), + ): + _, _, msgs = provider.check_and_refresh( + {"id": "c1", "provider": "groq", "name": "T", "apiKey": "gsk_a"} + ) + return msgs + + def test_a_plain_200_reads_as_a_clean_acceptance(self): + from omini_rtksync.credential_check import STATE_VALID + + m = " ".join(self.mensagens(STATE_VALID, "HTTP 200")) + self.assertIn("Autenticação aceita", m) + self.assertNotIn("recusada", m) + + def test_a_400_says_what_the_number_means(self): + from omini_rtksync.credential_check import STATE_VALID + + m = " ".join(self.mensagens(STATE_VALID, "HTTP 400")) + self.assertIn("Autenticação aceita", m) + self.assertIn("sondagem em si foi recusada", m) diff --git a/tests/test_provider_dispatch.py b/tests/test_provider_dispatch.py index a3bfcf2..9e47b8c 100644 --- a/tests/test_provider_dispatch.py +++ b/tests/test_provider_dispatch.py @@ -16,7 +16,6 @@ import os import sqlite3 import tempfile -import unittest import unittest.mock from omini_rtksync.credential_check import ( diff --git a/tests/test_startup_storage.py b/tests/test_startup_storage.py index 01e2c6b..358c01b 100644 --- a/tests/test_startup_storage.py +++ b/tests/test_startup_storage.py @@ -9,7 +9,6 @@ import os import tempfile -import unittest import unittest.mock from omini_rtksync.auth import read_db_credentials, write_db_credentials diff --git a/tests/test_web_render.py b/tests/test_web_render.py index 4ac0612..02a9985 100644 --- a/tests/test_web_render.py +++ b/tests/test_web_render.py @@ -85,11 +85,25 @@ def test_local_instance_reason_reports_models(self): self.assertIn("2 model(s)", render.render_refresh_reason(local, 900)) def test_unreachable_local_instance_is_reported(self): + # Quem diz que a instancia nao respondeu e a sonda, gravada em + # testStatus. Um catalogo vazio sozinho nao prova queda nenhuma: + # instalacao nova, de pe e sem modelo baixado responde 200 com lista + # vazia, e anuncia-la como inalcancavel contradizia o proprio ciclo. local = make_conn("ollama-local", "Ollama Local", { "apiKey": "k", "baseUrl": "http://localhost:11434/v1", + "testStatus": "unreachable", }) self.assertIn("did not answer", render.render_refresh_reason(local, 900)) + def test_an_empty_catalog_is_not_called_unreachable(self): + local = make_conn("ollama-local", "Ollama Local", { + "apiKey": "k", "baseUrl": "http://localhost:11434/v1", + "testStatus": "active", "discoveredModels": [], + }) + frase = render.render_refresh_reason(local, 900) + self.assertNotIn("did not answer", frase) + self.assertIn("no model installed", frase) + class TestDashboardMarkup(unittest.TestCase): def _page(self, **overrides):