diff --git a/backend/app/services/git_providers/forgejo.py b/backend/app/services/git_providers/forgejo.py index a567e3f08..5a24b639f 100644 --- a/backend/app/services/git_providers/forgejo.py +++ b/backend/app/services/git_providers/forgejo.py @@ -13,8 +13,9 @@ class ForgejoBackend(GiteaBackend): """Backend for Forgejo instances. Forgejo v15+ returns 404 (not 403) for private repositories when the token - lacks repository scope, requiring a /user pre-check to distinguish bad tokens - from inaccessible repos. test_connection is overridden to handle this. + lacks repository scope, so a bare repo call cannot tell "bad token" from + "repo not visible" on its own. test_connection probes /user first to catch + the outright-rejected token, then lets the repo call decide everything else. Other methods are inherited from GiteaBackend unchanged. """ @@ -24,37 +25,45 @@ class ForgejoBackend(GiteaBackend): api_base = self.get_api_base(repo_url) headers = self.get_headers(token) - # Verify token validity before hitting the repo. On Forgejo v15+, - # private repos return 404 (not 403) when the token lacks repo scope, - # so we must distinguish "bad token" from "token OK but repo not visible". + # Probe /user, but only a 401 here is conclusive: the instance rejects + # the token outright, and saying so beats the 404 the repo call may + # answer with instead (Forgejo v15+ hides private repos behind 404 + # rather than 403). + # + # Every other status falls through to the repo check (#2775). A + # repository-scoped token — the kind Forgejo v15 recommends, limited + # to one repo — can only carry read/write:issue and + # read/write:repository, so /user answers 403 for exactly the tokens + # worth encouraging. Treating that as fatal rejected a token that + # reaches its own repository perfectly well, which is all a backup + # needs: the push path uses the Contents API and the restore path + # reads commits, trees and blobs, all under /repos/{owner}/{repo}. user_resp = await client.get(f"{api_base}/user", headers=headers) if user_resp.status_code == 401: return {"success": False, "message": "Invalid access token", "repo_name": None, "permissions": None} - if user_resp.status_code == 403: - return { - "success": False, - "message": "Token has no read:user scope; cannot validate identity", - "repo_name": None, - "permissions": None, - } - if user_resp.status_code != 200: - return { - "success": False, - "message": f"Forgejo API error on /user: {user_resp.status_code}", - "repo_name": None, - "permissions": None, - } + # Whether the token's identity was confirmed. Only used to word the + # 404 below — an unconfirmed identity leaves "the token is invalid" + # on the list of causes, a confirmed one rules it out. + identity_confirmed = user_resp.status_code == 200 repo_resp = await client.get(f"{api_base}/repos/{owner}/{repo}", headers=headers) + if repo_resp.status_code == 401: + return {"success": False, "message": "Invalid access token", "repo_name": None, "permissions": None} + if repo_resp.status_code == 404: + message = ( + "Repository not found or token cannot access it. " + "On Forgejo v15+, private repositories return 404 (not 403) " + "when the token lacks repository scope. Check that the token has " + "write:repository, and that this repository is one it covers if the " + "token is scoped to specific repositories." + ) + if not identity_confirmed: + message += " The token itself may also be invalid or expired." return { "success": False, - "message": ( - "Repository not found or token cannot access it. " - "On Forgejo v15+, private repositories return 404 (not 403) " - "when the token lacks repository scope." - ), + "message": message, "repo_name": None, "permissions": None, } diff --git a/backend/tests/unit/test_git_providers.py b/backend/tests/unit/test_git_providers.py index 5c3a822ff..da31e1d96 100644 --- a/backend/tests/unit/test_git_providers.py +++ b/backend/tests/unit/test_git_providers.py @@ -1424,28 +1424,80 @@ class TestForgejoTestConnection: assert client.get.call_count == 1 # only /user was called @pytest.mark.asyncio - async def test_zero_scope_token_403_on_user_returns_scope_hint(self): - """A 403 from /user (v15+ zero-scope token) returns a clear message without hitting the repo.""" + async def test_repository_scoped_token_403_on_user_still_connects(self): + """#2775: a Forgejo v15 repository-scoped token can only hold + read/write on issues and repositories, so /user answers 403. That says + nothing about whether the token reaches its own repository — which is + all a backup needs — so the repo call decides.""" client = AsyncMock() - client.get = AsyncMock(return_value=_make_mock_response(403, {})) + client.get = AsyncMock( + side_effect=[ + _make_mock_response(403, {}), + _make_mock_response(200, {"full_name": "owner/repo", "permissions": {"push": True, "pull": True}}), + ] + ) result = await self.backend.test_connection(self.repo_url, self.token, client) - assert result["success"] is False - assert "read:user scope" in result["message"] - assert client.get.call_count == 1 + assert result["success"] is True + assert result["repo_name"] == "owner/repo" + assert client.get.call_count == 2 # /user did not short-circuit the repo call @pytest.mark.asyncio - async def test_unexpected_user_status_returns_status_code(self): - """A non-200/401/403 response from /user (e.g. 429, 5xx) surfaces the status code.""" + async def test_unexpected_user_status_does_not_block_the_repo_call(self): + """A transient non-200 from /user (429, 5xx) is not a verdict on the + token either — the repo call is the one that matters.""" client = AsyncMock() - client.get = AsyncMock(return_value=_make_mock_response(429, {})) + client.get = AsyncMock( + side_effect=[ + _make_mock_response(429, {}), + _make_mock_response(200, {"full_name": "owner/repo", "permissions": {"push": True, "pull": True}}), + ] + ) + + result = await self.backend.test_connection(self.repo_url, self.token, client) + + assert result["success"] is True + assert client.get.call_count == 2 + + @pytest.mark.asyncio + async def test_scoped_token_without_this_repo_names_the_scope_to_fix(self): + """The 404 is the only signal left when /user was inconclusive, so its + message has to name both remaining causes: a repository the token's + scope doesn't cover, and a token that was never valid.""" + client = AsyncMock() + client.get = AsyncMock( + side_effect=[ + _make_mock_response(403, {}), + _make_mock_response(404, {}), + ] + ) result = await self.backend.test_connection(self.repo_url, self.token, client) assert result["success"] is False - assert "429" in result["message"] - assert client.get.call_count == 1 + assert "write:repository" in result["message"] + assert "specific repositories" in result["message"] + assert "may also be invalid" in result["message"] + + @pytest.mark.asyncio + async def test_bad_token_rejected_by_the_repo_call_is_named_as_such(self): + """An instance that answers /user with something inconclusive but 401s + the repo call must still surface 'invalid token', not a generic API + error — otherwise relaxing the /user gate would have cost the clearest + message we can give.""" + client = AsyncMock() + client.get = AsyncMock( + side_effect=[ + _make_mock_response(403, {}), + _make_mock_response(401, {}), + ] + ) + + result = await self.backend.test_connection(self.repo_url, self.token, client) + + assert result["success"] is False + assert result["message"] == "Invalid access token" @pytest.mark.asyncio async def test_repo_404_after_valid_token_surfaces_v15_scope_hint(self): @@ -1462,6 +1514,9 @@ class TestForgejoTestConnection: assert result["success"] is False assert "v15" in result["message"] assert "scope" in result["message"] + # /user confirmed the identity, so the token itself is not a suspect + # here and the message must not send the user off checking it. + assert "may also be invalid" not in result["message"] @pytest.mark.asyncio async def test_token_lacks_push_permission_returns_failed(self): diff --git a/frontend/src/__tests__/components/GitHubBackupSettings.provider.test.tsx b/frontend/src/__tests__/components/GitHubBackupSettings.provider.test.tsx index 58a657e53..e17f345b2 100644 --- a/frontend/src/__tests__/components/GitHubBackupSettings.provider.test.tsx +++ b/frontend/src/__tests__/components/GitHubBackupSettings.provider.test.tsx @@ -112,6 +112,31 @@ describe('GitHubBackupSettings - Provider Selection', () => { expect(options).toContain('forgejo'); }); + it('names the scopes of the selected provider under the token field', async () => { + // #2775: one shared hint could only ever be right for one provider, and the + // one it was right for was GitHub. A Forgejo user reading "Contents read and + // write" has no such setting to find, and guesses wide. + render(); + await waitFor(() => { + expect(screen.getByRole('combobox', { name: /git provider/i })).toBeInTheDocument(); + }); + + expect(screen.getByText(/Contents read and write access/i)).toBeInTheDocument(); + + const select = screen.getByRole('combobox', { name: /git provider/i }); + fireEvent.change(select, { target: { value: 'forgejo' } }); + + await waitFor(() => { + expect(screen.getByText(/write:repository/i)).toBeInTheDocument(); + }); + expect(screen.queryByText(/Contents read and write access/i)).not.toBeInTheDocument(); + + fireEvent.change(select, { target: { value: 'gitlab' } }); + await waitFor(() => { + expect(screen.getByText(/read_repository/i)).toBeInTheDocument(); + }); + }); + it('loads forgejo provider from existing config', async () => { server.use( http.get('/api/v1/github-backup/config', () => diff --git a/frontend/src/components/GitHubBackupSettings.tsx b/frontend/src/components/GitHubBackupSettings.tsx index b7baacce6..04efe2a43 100644 --- a/frontend/src/components/GitHubBackupSettings.tsx +++ b/frontend/src/components/GitHubBackupSettings.tsx @@ -95,6 +95,16 @@ const PROVIDER_TOKEN_PLACEHOLDER: Record = { gitlab: 'glpat-xxxxxxxxxxxx', }; +// Each provider names its scopes differently, so a single hint could only ever +// be right for one of them (#2775). Naming the scopes up front is also what +// keeps a token from being minted more permissive than a backup needs. +const PROVIDER_TOKEN_HINT_I18N_KEY: Record = { + github: 'backup.tokenHintGitHub', + gitea: 'backup.tokenHintGitea', + forgejo: 'backup.tokenHintForgejo', + gitlab: 'backup.tokenHintGitLab', +}; + interface GitHubBackupAutosaveState { repository_url: string; branch: string; @@ -714,7 +724,7 @@ export function GitHubBackupSettings() { className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" />

- {t('backup.tokenHint')} + {t(PROVIDER_TOKEN_HINT_I18N_KEY[provider])}

diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index a85fe2719..35588150c 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -4948,7 +4948,10 @@ export default { personalAccessToken: 'Persönlicher Zugriffstoken', tokenSaved: '(gespeichert)', enterNewToken: 'Neuen Token eingeben zum Aktualisieren', - tokenHint: 'Feingranularer Token mit Lese-/Schreibberechtigung für Inhalte', + tokenHintGitHub: 'Feingranularer Token mit Lese- und Schreibzugriff auf Contents oder klassischer Token mit dem Bereich repo.', + tokenHintGitLab: 'Token mit dem Bereich api oder read_repository zusammen mit write_repository.', + tokenHintGitea: 'Token mit dem Bereich write:repository.', + tokenHintForgejo: 'Token mit dem Bereich write:repository. Ein Token, der nur für dieses eine Repository gilt, genügt.', branch: 'Branch', provider: 'Git-Anbieter', providerGitHub: 'GitHub', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 1845c230f..09561353d 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -4991,7 +4991,10 @@ export default { personalAccessToken: 'Personal Access Token', tokenSaved: '(saved)', enterNewToken: 'Enter new token to update', - tokenHint: 'Fine-grained token with Contents read/write permission', + tokenHintGitHub: 'Fine-grained token with Contents read and write access, or a classic token with the repo scope.', + tokenHintGitLab: 'Token with the api scope, or read_repository and write_repository together.', + tokenHintGitea: 'Token with the write:repository scope.', + tokenHintForgejo: 'Token with the write:repository scope. A token limited to this one repository is enough.', branch: 'Branch', provider: 'Git Provider', providerGitHub: 'GitHub', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index 21a41218a..1f8042e20 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -4955,7 +4955,10 @@ export default { personalAccessToken: 'Token de acceso personal', tokenSaved: '(guardado)', enterNewToken: 'Introduzca un nuevo token para actualizar', - tokenHint: 'Token de granularidad fina con permiso de lectura/escritura de Contents', + tokenHintGitHub: 'Token de granularidad fina con acceso de lectura y escritura a Contents, o token clásico con el ámbito repo.', + tokenHintGitLab: 'Token con el ámbito api, o read_repository junto con write_repository.', + tokenHintGitea: 'Token con el ámbito write:repository.', + tokenHintForgejo: 'Token con el ámbito write:repository. Basta con un token limitado a este único repositorio.', branch: 'Rama', provider: 'Proveedor de Git', providerGitHub: 'GitHub', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 5bfde90a7..0de18f66b 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -4937,7 +4937,10 @@ export default { personalAccessToken: 'Jeton d\'accès personnel', tokenSaved: '(enregistré)', enterNewToken: 'Entrez un nouveau jeton pour mettre à jour', - tokenHint: 'Jeton à granularité fine avec permission de lecture/écriture du contenu', + tokenHintGitHub: 'Jeton à granularité fine avec accès en lecture et écriture à Contents, ou jeton classique avec la portée repo.', + tokenHintGitLab: 'Jeton avec la portée api, ou read_repository et write_repository ensemble.', + tokenHintGitea: 'Jeton avec la portée write:repository.', + tokenHintForgejo: 'Jeton avec la portée write:repository. Un jeton limité à ce seul dépôt suffit.', branch: 'Branche', provider: 'Fournisseur Git', providerGitHub: 'GitHub', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 532c4029b..6bd8c2db9 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -4936,7 +4936,10 @@ export default { personalAccessToken: 'Token di accesso personale', tokenSaved: '(salvato)', enterNewToken: 'Inserisci un nuovo token per aggiornare', - tokenHint: 'Token a grana fine con permesso di lettura/scrittura dei contenuti', + tokenHintGitHub: 'Token a grana fine con accesso in lettura e scrittura a Contents, oppure token classico con ambito repo.', + tokenHintGitLab: 'Token con ambito api, oppure read_repository insieme a write_repository.', + tokenHintGitea: 'Token con ambito write:repository.', + tokenHintForgejo: 'Token con ambito write:repository. Basta un token limitato a questo solo repository.', branch: 'Branch', provider: 'Provider Git', providerGitHub: 'GitHub', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 22be478f5..e30e623a2 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -4948,7 +4948,10 @@ export default { personalAccessToken: '個人アクセストークン', tokenSaved: '(保存済み)', enterNewToken: '新しいトークンを入力して更新', - tokenHint: 'Contents読み書き権限を持つきめ細かいトークン', + tokenHintGitHub: 'Contentsの読み書き権限を持つきめ細かいトークン、またはrepoスコープを持つクラシックトークン。', + tokenHintGitLab: 'apiスコープ、またはread_repositoryとwrite_repositoryの両方を持つトークン。', + tokenHintGitea: 'write:repositoryスコープを持つトークン。', + tokenHintForgejo: 'write:repositoryスコープを持つトークン。このリポジトリだけに限定したトークンで十分です。', branch: 'ブランチ', provider: 'Gitプロバイダー', providerGitHub: 'GitHub', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index ad8bcaa61..ba767423b 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -4711,7 +4711,10 @@ export default { personalAccessToken: '개인 액세스 토큰', tokenSaved: '(저장됨)', enterNewToken: '업데이트하려면 새 토큰 입력', - tokenHint: '콘텐츠 읽기/쓰기 권한이 있는 세분화된 토큰', + tokenHintGitHub: 'Contents 읽기 및 쓰기 권한이 있는 세분화된 토큰 또는 repo 범위를 가진 클래식 토큰.', + tokenHintGitLab: 'api 범위 또는 read_repository와 write_repository를 함께 가진 토큰.', + tokenHintGitea: 'write:repository 범위를 가진 토큰.', + tokenHintForgejo: 'write:repository 범위를 가진 토큰. 이 저장소 하나로만 제한된 토큰이면 충분합니다.', branch: '브랜치', provider: 'Git 제공자', providerGitHub: 'GitHub', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 241787326..2cd540ac4 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -4936,7 +4936,10 @@ export default { personalAccessToken: 'Token de acesso pessoal', tokenSaved: '(salvo)', enterNewToken: 'Digite um novo token para atualizar', - tokenHint: 'Token de granularidade fina com permissão de leitura/escrita de conteúdo', + tokenHintGitHub: 'Token de granularidade fina com acesso de leitura e escrita a Contents, ou token clássico com o escopo repo.', + tokenHintGitLab: 'Token com o escopo api, ou read_repository junto com write_repository.', + tokenHintGitea: 'Token com o escopo write:repository.', + tokenHintForgejo: 'Token com o escopo write:repository. Um token limitado somente a este repositório já basta.', branch: 'Branch', provider: 'Provedor Git', providerGitHub: 'GitHub', diff --git a/frontend/src/i18n/locales/ru.ts b/frontend/src/i18n/locales/ru.ts index 5a77302a4..b24953a26 100644 --- a/frontend/src/i18n/locales/ru.ts +++ b/frontend/src/i18n/locales/ru.ts @@ -4703,7 +4703,10 @@ export default { personalAccessToken: "Персональный токен доступа", tokenSaved: "(сохранён)", enterNewToken: "Введите новый токен для обновления", - tokenHint: "Токен с точечными правами на чтение и запись содержимого", + tokenHintGitHub: "Токен с точечными правами на чтение и запись Contents либо классический токен с областью repo.", + tokenHintGitLab: "Токен с областью api либо read_repository вместе с write_repository.", + tokenHintGitea: "Токен с областью write:repository.", + tokenHintForgejo: "Токен с областью write:repository. Достаточно токена, ограниченного только этим репозиторием.", branch: "Ветка", provider: "Git-провайдер", providerGitHub: "GitHub", diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index 94e3c0d56..9ec744faf 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/frontend/src/i18n/locales/tr.ts @@ -4925,7 +4925,10 @@ export default { personalAccessToken: 'Kişisel Erişim Belirteci', tokenSaved: '(kaydedildi)', enterNewToken: 'Güncellemek için yeni belirteç girin', - tokenHint: 'Contents okuma/yazma izni olan ayrıntılı belirteç', + tokenHintGitHub: 'Contents okuma ve yazma erişimi olan ayrıntılı belirteç ya da repo kapsamlı klasik belirteç.', + tokenHintGitLab: 'api kapsamlı belirteç ya da read_repository ile write_repository birlikte.', + tokenHintGitea: 'write:repository kapsamlı belirteç.', + tokenHintForgejo: 'write:repository kapsamlı belirteç. Yalnızca bu depoyla sınırlı bir belirteç yeterlidir.', branch: 'Dal', provider: 'Git Sağlayıcısı', providerGitHub: 'GitHub', diff --git a/frontend/src/i18n/locales/uk.ts b/frontend/src/i18n/locales/uk.ts index e0513d59d..6c72ea5cd 100644 --- a/frontend/src/i18n/locales/uk.ts +++ b/frontend/src/i18n/locales/uk.ts @@ -4990,7 +4990,10 @@ export default { personalAccessToken: "Персональний токен доступу", tokenSaved: "(збережено)", enterNewToken: "Введіть новий токен для оновлення", - tokenHint: "Токен із деталізованими правами та дозволом на читання й запис вмісту", + tokenHintGitHub: "Токен із деталізованими правами на читання й запис Contents або класичний токен з областю repo.", + tokenHintGitLab: "Токен з областю api або read_repository разом із write_repository.", + tokenHintGitea: "Токен з областю write:repository.", + tokenHintForgejo: "Токен з областю write:repository. Достатньо токена, обмеженого лише цим репозиторієм.", branch: "Гілка", provider: "Постачальник Git", providerGitHub: "GitHub", diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index f4d6139b9..81d10fd0a 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -4936,7 +4936,10 @@ export default { personalAccessToken: '个人访问令牌', tokenSaved: '(已保存)', enterNewToken: '输入新令牌以更新', - tokenHint: '具有内容读写权限的细粒度令牌', + tokenHintGitHub: '具有 Contents 读写权限的细粒度令牌,或具有 repo 范围的经典令牌。', + tokenHintGitLab: '具有 api 范围的令牌,或同时具有 read_repository 和 write_repository。', + tokenHintGitea: '具有 write:repository 范围的令牌。', + tokenHintForgejo: '具有 write:repository 范围的令牌。仅限于此仓库的令牌即可。', branch: '分支', provider: 'Git 提供商', providerGitHub: 'GitHub', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index e125647fb..cba45fd93 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -4936,7 +4936,10 @@ export default { personalAccessToken: '個人存取權杖', tokenSaved: '(已儲存)', enterNewToken: '輸入新權杖以更新', - tokenHint: '具有內容讀寫權限的細粒度權杖', + tokenHintGitHub: '具有 Contents 讀寫權限的細粒度權杖,或具有 repo 範圍的傳統權杖。', + tokenHintGitLab: '具有 api 範圍的權杖,或同時具有 read_repository 與 write_repository。', + tokenHintGitea: '具有 write:repository 範圍的權杖。', + tokenHintForgejo: '具有 write:repository 範圍的權杖。僅限於此存放庫的權杖即可。', branch: '分支', provider: 'Git 供應商', providerGitHub: 'GitHub',