From e7014de65faa379b6e083ac21409d7dd84de57d7 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 14 Jun 2026 21:45:36 -0300 Subject: [PATCH] test(oauth): prove refresh_token preserved on real gemini-cli/antigravity dispatch (#3850) (#3869) --- CHANGELOG.md | 1 + tests/unit/token-health-check.test.ts | 60 ++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74b68b2ffc..aaeee31c88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### πŸ› Fixed +- **test(oauth): prove refresh_token preservation for the real gemini-cli / antigravity dispatch** β€” the #3679/#3766 regression test used a synthetic provider that routes through the generic `tokenUrl` path, so the fix was never proven for the actual Google-family providers, which dispatch through `refreshGoogleToken()` against the hardcoded `OAUTH_ENDPOINTS.google.token`. Added a test that drives `checkConnection` through the real `gemini-cli`/`antigravity` path (redirecting the Google token endpoint to a local server returning `invalid_grant`) and asserts the `refresh_token` is preserved (not nulled) β€” confirming these connections are not spuriously destroyed on a failed refresh. ([#3850](https://github.com/diegosouzapw/OmniRoute/issues/3850) β€” thanks @3xa228148) - **fix(oauth): clear setup message for GitLab Duo instead of "Internal server error"** β€” adding a GitLab Duo connection without a registered OAuth client returned an opaque `Internal server error` at the Add Connection step. `buildAuthUrl` **threw** when `GITLAB_DUO_OAUTH_CLIENT_ID` was missing, and the route swallowed it into a generic 500. It now returns `null` (mirroring the Qoder provider) and the authorize route surfaces an actionable message: register an OAuth app at `https://gitlab.com/-/profile/applications` with redirect URI `http://localhost:20128/callback` and scopes `ai_features read_user`, then set `GITLAB_DUO_OAUTH_CLIENT_ID`. ([#3861](https://github.com/diegosouzapw/OmniRoute/issues/3861) β€” thanks @sidinsearch) - **fix(db): persist the "Keep latest backups" retention setting** β€” changing the backup-retention count in Settings β†’ Database backup retention had no effect: it always snapped back to 20 on refresh (and editing `.env` post-start was ignored too, since `process.env` isn't reloaded). `getDbBackupMaxFiles()` only read the `DB_BACKUP_MAX_FILES` env var β€” there was no setter and no persisted value. The value now round-trips through a dedicated `key_value` store (`getDbBackupMaxFiles` precedence: env override β†’ persisted UI value β†’ default 20), and the "Clean old backups" action persists the chosen count. Existing installs keep the historical default of 20 until explicitly changed. ([#3834](https://github.com/diegosouzapw/OmniRoute/issues/3834) β€” thanks @netstratego) - **fix(sse): clamp Gemini thinking budget to the model's real cap (`reasoning_effort`/`effort=high` 400)** β€” translating OpenAI `reasoning_effort=high` (and Claude-Code `output_config.effort=high`) to a Gemini target sent a hardcoded `thinkingBudget: 32768`, which exceeds Flash-tier Gemini's real max of 24576 β†’ upstream HTTP 400 (the `thinkingLevel=high` path already used 24576 and worked on the same model). `gemini-2.5-flash` now declares its real `thinkingBudgetCap` (24576) so the existing `capThinkingBudget()` chokepoint actually clamps, and the Claudeβ†’Gemini `output_config.effort` path β€” which previously sent the raw value with no cap at all β€” now routes through the same clamp (pro-tier, real cap 32768, is left untouched). ([#3842](https://github.com/diegosouzapw/OmniRoute/issues/3842) β€” thanks @andrea-kingautomation) diff --git a/tests/unit/token-health-check.test.ts b/tests/unit/token-health-check.test.ts index 2fbf7569ba..6b6430d3bc 100644 --- a/tests/unit/token-health-check.test.ts +++ b/tests/unit/token-health-check.test.ts @@ -14,7 +14,7 @@ process.env.DATA_DIR = TEST_DATA_DIR; const core = await import("../../src/lib/db/core.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); const settingsDb = await import("../../src/lib/db/settings.ts"); -const { PROVIDERS } = await import("../../open-sse/config/constants.ts"); +const { PROVIDERS, OAUTH_ENDPOINTS } = await import("../../open-sse/config/constants.ts"); const tokenHealthCheck = await import("../../src/lib/tokenHealthCheck.ts"); async function resetStorage() { @@ -526,3 +526,61 @@ test("checkConnection preserves refresh_token for non-rotating providers on unre } ); }); + +// Regression for #3850 (continuation of #3679): the #3679 test above uses a SYNTHETIC +// provider that routes through the generic refreshAccessToken/tokenUrl path. The real +// Google-family providers (gemini-cli / antigravity) instead dispatch through +// refreshGoogleToken() against the HARDCODED OAUTH_ENDPOINTS.google.token β€” a path the +// synthetic test never exercised, which left #3766's correctness unproven for the +// actual reported providers. This drives checkConnection through the REAL gemini-cli / +// antigravity dispatch and asserts the refresh_token is preserved (NOT nulled) when +// Google rejects the refresh with invalid_grant. +for (const providerId of ["gemini-cli", "antigravity"]) { + test(`checkConnection preserves refresh_token for ${providerId} on invalid_grant (#3850)`, async () => { + await resetStorage(); + + let refreshCount = 0; + const originalGoogleTokenUrl = OAUTH_ENDPOINTS.google.token; + + await withHttpServer( + (_req, res) => { + refreshCount += 1; + // Google returns invalid_grant β†’ isUnrecoverableRefreshError() is true. + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "invalid_grant", error_description: "Bad Request" })); + }, + async (tokenServer) => { + // gemini-cli / antigravity refresh hits OAUTH_ENDPOINTS.google.token directly + // (not a per-provider tokenUrl), so redirect that hardcoded endpoint. + OAUTH_ENDPOINTS.google.token = `${tokenServer.url}/token`; + try { + const connection = await providersDb.createProviderConnection({ + provider: providerId, + authType: "oauth", + name: `${providerId} Account`, + email: `${providerId}@example.com`, + accessToken: "expired-access-token", + refreshToken: "rt-keep-3850", + // Already expired β†’ proactive refresh runs AND the still-valid guard fails, + // so execution reaches the deactivation branch. + expiresAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + isActive: true, + }); + + await tokenHealthCheck.checkConnection(connection); + + const updated = await providersDb.getProviderConnectionById((connection as any).id); + assert.equal(refreshCount, 1, "the expired token must trigger one refresh attempt"); + assert.equal(updated?.testStatus, "expired", "should reach the unrecoverable branch"); + assert.equal( + updated?.refreshToken, + "rt-keep-3850", + `${providerId} (non-rotating) must keep its refresh_token for recovery` + ); + } finally { + OAUTH_ENDPOINTS.google.token = originalGoogleTokenUrl; + } + } + ); + }); +}