diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 7680194..d2d18a2 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -72,7 +72,7 @@ {"id":"ifcurl-6xq","title":"Query panel does not appear after selector resolves","description":"When a selector is set in the viewer and a query= parameter is also present, the query results table (showing GUID → value pairs) should appear in a side panel. This panel never appears. The applyQuery() function in viewer.js fetches /query from the ifcurl service and populates queryTbody, but the panel remains hidden. Needs investigation: check whether the query param is being passed correctly, whether the /query endpoint returns data, and whether queryPanel.style.display is being set.","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-05-16T13:24:59Z","created_by":"Bruno Postle","updated_at":"2026-05-16T20:02:21Z","started_at":"2026-05-16T14:14:52Z","closed_at":"2026-05-16T20:02:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-hwk","title":"Meta panel: missing type counts, icon is hamburger not (i)","description":"The metadata panel works for storey isolation but has two issues: (1) The toolbar button is a hamburger icon, not an (i) icon as documented in CLAUDE.md check #17. Update the checklist text to match reality. (2) The type counts breakdown (IfcWall: N, IfcSlab: N, etc.) does not appear in the panel — only the storey list is shown. Investigate whether populateMetaPanel() is failing silently or the type classifier isn't running correctly.","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-05-16T13:24:45Z","created_by":"Bruno Postle","updated_at":"2026-05-16T20:02:21Z","started_at":"2026-05-16T14:15:06Z","closed_at":"2026-05-16T20:02:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-xfi","title":"Implement OpenCDE document picker (select-documents flow)","description":"The OpenCDE Documents API defines a browser-redirect flow for document discovery. Implement POST /documents/1.0/select-documents which returns a select_documents_url. That URL serves a small HTML page: owner/repo selector then file-tree browser. When user selects an IFC file, generate the document_id and redirect to the callback_url with document_ids[] params.","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-29T20:38:16Z","created_by":"Bruno Postle","updated_at":"2026-04-29T20:38:16Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"ifcurl-bil","title":"Implement OpenCDE OAuth2 proxy routes","description":"The OpenCDE Foundation API requires OAuth2 endpoints. Forgejo already has OAuth2 support — proxy through to it. Routes needed: GET /foundation/1.0/oauth2/auth_url (return Forgejo OAuth2 auth URL), POST /foundation/1.0/oauth2/token (proxy code exchange to Forgejo token endpoint), POST /foundation/1.0/oauth2/token_refresh (proxy refresh). Requires a one-time step of registering an OAuth2 app in Forgejo admin and storing client_id/client_secret as env vars.","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-29T20:38:15Z","created_by":"Bruno Postle","updated_at":"2026-04-29T20:38:15Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"ifcurl-bil","title":"Implement OpenCDE OAuth2 proxy routes","description":"The OpenCDE Foundation API requires OAuth2 endpoints. Forgejo already has OAuth2 support — proxy through to it. Routes needed: GET /foundation/1.0/oauth2/auth_url (return Forgejo OAuth2 auth URL), POST /foundation/1.0/oauth2/token (proxy code exchange to Forgejo token endpoint), POST /foundation/1.0/oauth2/token_refresh (proxy refresh). Requires a one-time step of registering an OAuth2 app in Forgejo admin and storing client_id/client_secret as env vars.","status":"in_progress","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-29T20:38:15Z","created_by":"Bruno Postle","updated_at":"2026-06-07T08:42:43Z","started_at":"2026-06-07T08:42:43Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-k3v","title":"Add GET /documents/1.0/document-metadata/{document_id}","description":"The OpenCDE Documents API spec includes a GET endpoint for individual file metadata. Implement it: decode the document_id, fetch the file's latest info from Forgejo (name, size, last-modified), return as a metadata response.","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-29T20:38:13Z","created_by":"Bruno Postle","updated_at":"2026-04-29T21:25:07Z","started_at":"2026-04-29T21:22:34Z","closed_at":"2026-04-29T21:25:07Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-ye6","title":"Add BCF topic filtering query params","description":"The BCF 3.0 REST spec defines query parameters for GET /topics (topic_status, topic_type, priority, assignee, etc.). Currently we return all topics unfiltered. Translate BCF filter params to Forgejo issues API params (state, labels, assignee) and pass them through.","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-29T20:38:00Z","created_by":"Bruno Postle","updated_at":"2026-04-29T21:25:06Z","started_at":"2026-04-29T21:22:21Z","closed_at":"2026-04-29T21:25:06Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-lwl","title":"Add OpenCDE Foundation API discovery endpoint","description":"Expose GET /foundation/versions on the Forgejo instance so OpenCDE clients can auto-discover which APIs the server supports. Returns JSON declaring BCF API 3.0 and Documents API with their base URLs and OAuth2 configuration.\n\n## Architecture\n\nStandalone Python daemon, proxied at /foundation/ on the Forgejo hostname. Static or config-driven JSON response — no Forgejo internals needed, no auth required (Foundation API discovery endpoint is public by spec). Likely a small route in the existing preview service.","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-28T22:41:06Z","created_by":"Bruno Postle","updated_at":"2026-04-29T18:21:36Z","started_at":"2026-04-29T18:13:51Z","closed_at":"2026-04-29T18:21:36Z","close_reason":"Closed","dependency_count":0,"dependent_count":1,"comment_count":0} @@ -137,6 +137,6 @@ {"id":"ifcurl-vam","title":"Phase 6: Bonsai offer ifc:// URL when saving linked model reference","description":"When saving an IFCDOCUMENTREFERENCE in Bonsai, offer the option to write the location as an ifc:// URL rather than a relative path. When a relative path would traverse above the repository root, prompt the user to convert it to an ifc:// URL. Requires Phase 4 federation (ifcurl-bqt).","status":"deferred","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:49:16Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:15:13Z","defer_until":"2026-12-01T00:00:00Z","dependencies":[{"issue_id":"ifcurl-vam","depends_on_id":"ifcurl-bqt","type":"blocks","created_at":"2026-04-23T06:49:23Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"ifcurl-9k2","title":"Phase 5: IFC Viewer integration (equivalent of Phase 4)","description":"Add ifc:// OS protocol handler and 'Copy view URL' button to the IFC Viewer desktop application, equivalent to the Bonsai Phase 4 integration. Depends on Phase 4 being complete as a reference implementation.","status":"deferred","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:49:08Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:15:12Z","defer_until":"2026-12-01T00:00:00Z","dependencies":[{"issue_id":"ifcurl-9k2","depends_on_id":"ifcurl-0oj","type":"blocks","created_at":"2026-04-23T06:49:14Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"ifcurl-prc","title":"Phase 3c: 3D diff view for IFC PR merges","description":"After a PR merge completes, surface a 'View merge in 3D' button using the Phase 3b viewer. Use @thatopen/components highlighter to colour added, removed, and modified elements differently, driven by comparing the merged IFC against either parent. Requires Phase 3b viewer (ifcurl-i1s) to be working.","status":"closed","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:30Z","created_by":"Bruno Postle","updated_at":"2026-04-26T17:15:07Z","closed_at":"2026-04-26T17:15:07Z","close_reason":"Rendered diff already implemented in footer.tmpl Case 3; static PNG via /render_diff endpoint, not interactive 3D","dependencies":[{"issue_id":"ifcurl-prc","depends_on_id":"ifcurl-i1s","type":"blocks","created_at":"2026-04-23T06:48:36Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"memory","key":"torawurl-in-viewer-url-js-must-support-github","value":"toRawUrl in viewer-url.js must support GitHub, GitLab, Forgejo/Gitea (codeberg.org, self-hosted), and localhost. Tests in forgejo/tests/viewer-url.test.js cover all four. The localhost check (http vs https) is based on host.startsWith('localhost'), so subdomain.localhost gets https."} {"_type":"memory","key":"forgejo-build-and-deploy-procedure-go-build-c","value":"Forgejo build and deploy procedure: go build -C /home/bruno/src/forgejo -tags 'sqlite sqlite_unlock_notify' -ldflags \"-X 'forgejo.org/modules/setting.StaticRootPath=/usr/share/forgejo'\" -o /home/bruno/src/forgejo/forgejo . — then sudo cp forgejo /usr/bin/forgejo. StaticRootPath=/usr/share/forgejo. CustomPath=/var/lib/forgejo/custom. Custom templates go to /var/lib/forgejo/custom/templates/custom/ (e.g. footer.tmpl → /var/lib/forgejo/custom/templates/custom/footer.tmpl). Custom static files go to /var/lib/forgejo/custom/public/assets/ (NOT /etc/forgejo/public/assets/) and are served at /assets/*."} {"_type":"memory","key":"ifcurl-service-names-are-ifcurl-api-service-and","value":"ifcurl service names are ifcurl-api.service and ifcurl-render.service (not ifcurl.service). Install path for Python package: /opt/ifcurl/lib/python3.14/site-packages/ifcurl/. Python binary: /opt/ifcurl/bin/python3."} -{"_type":"memory","key":"torawurl-in-viewer-url-js-must-support-github","value":"toRawUrl in viewer-url.js must support GitHub, GitLab, Forgejo/Gitea (codeberg.org, self-hosted), and localhost. Tests in forgejo/tests/viewer-url.test.js cover all four. The localhost check (http vs https) is based on host.startsWith('localhost'), so subdomain.localhost gets https."} diff --git a/ifcurl/foundation_api.py b/ifcurl/foundation_api.py new file mode 100644 index 0000000..0580643 --- /dev/null +++ b/ifcurl/foundation_api.py @@ -0,0 +1,157 @@ +# IFC URL — OpenCDE Foundation API 1.1 OAuth2 proxy routes +# Copyright (C) 2026 Bruno Postle +# SPDX-License-Identifier: LGPL-3.0-or-later +# +# This file is part of IFC URL. +# +# IFC URL is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IFC URL is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IFC URL. If not, see . + +"""OpenCDE Foundation API 1.1 — OAuth2 proxy routes. + +Routes are mounted at /foundation/1.1 in service.py. + +Forgejo is used as the OAuth2 provider. A one-time setup step is required: +register an OAuth2 application in Forgejo admin settings (Admin → Applications +→ OAuth2 Applications) and set the following environment variables: + + IFCURL_OAUTH2_CLIENT_ID — OAuth2 application client ID + IFCURL_OAUTH2_CLIENT_SECRET — OAuth2 application client secret + +The auth_url endpoint constructs and returns the Forgejo authorization URL. +The token and token_refresh endpoints proxy to Forgejo's token endpoint, +injecting the client credentials from env vars so callers never handle them. +""" + +from __future__ import annotations + +import os +from urllib.parse import quote + +import httpx +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +router = APIRouter(prefix="/foundation/1.1") + +_FORGEJO_URL: str = os.environ.get("IFCURL_FORGEJO_URL", "http://localhost:3000") +_CLIENT_ID: str | None = os.environ.get("IFCURL_OAUTH2_CLIENT_ID") +_CLIENT_SECRET: str | None = os.environ.get("IFCURL_OAUTH2_CLIENT_SECRET") + + +def _credentials() -> tuple[str, str]: + """Return (client_id, client_secret) or raise 503 if not configured.""" + if not _CLIENT_ID or not _CLIENT_SECRET: + raise HTTPException( + status_code=503, + detail=( + "OAuth2 not configured — set IFCURL_OAUTH2_CLIENT_ID " + "and IFCURL_OAUTH2_CLIENT_SECRET" + ), + ) + return _CLIENT_ID, _CLIENT_SECRET + + +# --------------------------------------------------------------------------- +# GET /foundation/1.1/oauth2/auth_url +# --------------------------------------------------------------------------- + +@router.get("/oauth2/auth_url") +def oauth2_auth_url(request: Request, redirect_uri: str | None = None) -> JSONResponse: + """Return the Forgejo OAuth2 authorization URL. + + The client should redirect the user to the returned URL to begin the + authorization code flow. The optional ``redirect_uri`` query parameter + is forwarded to Forgejo; it must match a URI registered with the OAuth2 + application. + """ + client_id, _ = _credentials() + params = f"client_id={quote(client_id, safe='')}&response_type=code" + if redirect_uri: + params += f"&redirect_uri={quote(redirect_uri, safe='')}" + return JSONResponse( + {"url": f"{_FORGEJO_URL}/login/oauth/authorize?{params}", "expires_in": 3600} + ) + + +# --------------------------------------------------------------------------- +# POST /foundation/1.1/oauth2/token +# --------------------------------------------------------------------------- + +class TokenRequest(BaseModel): + code: str + redirect_uri: str | None = None + + +@router.post("/oauth2/token") +def oauth2_token(body: TokenRequest) -> JSONResponse: + """Exchange an authorization code for an access + refresh token pair. + + Proxies to Forgejo's token endpoint, injecting the stored client + credentials. The caller never sees or handles client_id/client_secret. + """ + client_id, client_secret = _credentials() + payload: dict = { + "client_id": client_id, + "client_secret": client_secret, + "code": body.code, + "grant_type": "authorization_code", + } + if body.redirect_uri: + payload["redirect_uri"] = body.redirect_uri + return _proxy_token(payload) + + +# --------------------------------------------------------------------------- +# POST /foundation/1.1/oauth2/token_refresh +# --------------------------------------------------------------------------- + +class RefreshRequest(BaseModel): + refresh_token: str + + +@router.post("/oauth2/token_refresh") +def oauth2_token_refresh(body: RefreshRequest) -> JSONResponse: + """Exchange a refresh token for a new access + refresh token pair. + + Proxies to Forgejo's token endpoint with grant_type=refresh_token. + """ + client_id, client_secret = _credentials() + payload: dict = { + "client_id": client_id, + "client_secret": client_secret, + "refresh_token": body.refresh_token, + "grant_type": "refresh_token", + } + return _proxy_token(payload) + + +# --------------------------------------------------------------------------- +# Internal +# --------------------------------------------------------------------------- + +def _proxy_token(payload: dict) -> JSONResponse: + """POST payload to Forgejo's token endpoint and return the JSON response.""" + try: + resp = httpx.post( + f"{_FORGEJO_URL}/login/oauth/access_token", + json=payload, + headers={"Accept": "application/json"}, + timeout=10, + ) + except httpx.TransportError as exc: + raise HTTPException(status_code=503, detail=f"Forgejo unreachable: {exc}") from exc + if resp.status_code != 200: + raise HTTPException(status_code=resp.status_code, detail=resp.text) + return JSONResponse(resp.json()) diff --git a/ifcurl/service.py b/ifcurl/service.py index c852f9b..61861d4 100644 --- a/ifcurl/service.py +++ b/ifcurl/service.py @@ -54,6 +54,7 @@ from pydantic import BaseModel from ifcurl.auth import get_token_for_host from ifcurl.bcf_api import router as bcf_router from ifcurl.documents_api import router as documents_router +from ifcurl.foundation_api import router as foundation_router from ifcurl.git import diff_text as git_diff_text from ifcurl.git import fetch_ifc from ifcurl.render_service import _sandboxed_clash, _sandboxed_diff, _sandboxed_pipeline, _sandboxed_query, _sandboxed_select @@ -212,6 +213,7 @@ app = FastAPI( ) app.include_router(bcf_router) app.include_router(documents_router) +app.include_router(foundation_router) # --------------------------------------------------------------------------- # Rate limiting (in-process, per client IP, sliding window) diff --git a/tests/test_foundation_api.py b/tests/test_foundation_api.py new file mode 100644 index 0000000..e2e587e --- /dev/null +++ b/tests/test_foundation_api.py @@ -0,0 +1,203 @@ +"""Tests for ifcurl.foundation_api — OpenCDE Foundation API 1.1 OAuth2 routes.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +import ifcurl.foundation_api as foundation_api +from ifcurl.service import _rate_hits, app + +client = TestClient(app) + + +@pytest.fixture(autouse=True) +def reset_rate_limit(): + _rate_hits.clear() + yield + _rate_hits.clear() + + +@pytest.fixture() +def with_credentials(monkeypatch): + monkeypatch.setattr(foundation_api, "_CLIENT_ID", "test-client-id") + monkeypatch.setattr(foundation_api, "_CLIENT_SECRET", "test-client-secret") + monkeypatch.setattr(foundation_api, "_FORGEJO_URL", "http://forgejo.example.com") + + +# --------------------------------------------------------------------------- +# GET /foundation/1.1/oauth2/auth_url +# --------------------------------------------------------------------------- + +class TestAuthUrl: + def test_returns_200(self, with_credentials): + r = client.get("/foundation/1.1/oauth2/auth_url") + assert r.status_code == 200 + + def test_returns_url_and_expires_in(self, with_credentials): + r = client.get("/foundation/1.1/oauth2/auth_url") + data = r.json() + assert "url" in data + assert "expires_in" in data + + def test_url_contains_client_id(self, with_credentials): + r = client.get("/foundation/1.1/oauth2/auth_url") + assert "test-client-id" in r.json()["url"] + + def test_url_contains_response_type_code(self, with_credentials): + r = client.get("/foundation/1.1/oauth2/auth_url") + assert "response_type=code" in r.json()["url"] + + def test_url_points_to_forgejo_authorize(self, with_credentials): + r = client.get("/foundation/1.1/oauth2/auth_url") + assert r.json()["url"].startswith("http://forgejo.example.com/login/oauth/authorize") + + def test_redirect_uri_forwarded(self, with_credentials): + r = client.get( + "/foundation/1.1/oauth2/auth_url", + params={"redirect_uri": "https://app.example.com/callback"}, + ) + assert "redirect_uri=" in r.json()["url"] + assert "app.example.com" in r.json()["url"] + + def test_no_redirect_uri_omits_param(self, with_credentials): + r = client.get("/foundation/1.1/oauth2/auth_url") + assert "redirect_uri" not in r.json()["url"] + + def test_503_when_not_configured(self, monkeypatch): + monkeypatch.setattr(foundation_api, "_CLIENT_ID", None) + monkeypatch.setattr(foundation_api, "_CLIENT_SECRET", None) + r = client.get("/foundation/1.1/oauth2/auth_url") + assert r.status_code == 503 + + +# --------------------------------------------------------------------------- +# POST /foundation/1.1/oauth2/token +# --------------------------------------------------------------------------- + +def _mock_forgejo_token_ok(): + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = { + "access_token": "ACCESS", + "token_type": "bearer", + "expires_in": 3600, + "refresh_token": "REFRESH", + } + return resp + + +class TestToken: + def test_returns_200(self, with_credentials): + with patch("ifcurl.foundation_api.httpx.post", return_value=_mock_forgejo_token_ok()): + r = client.post("/foundation/1.1/oauth2/token", json={"code": "abc123"}) + assert r.status_code == 200 + + def test_returns_access_token(self, with_credentials): + with patch("ifcurl.foundation_api.httpx.post", return_value=_mock_forgejo_token_ok()): + r = client.post("/foundation/1.1/oauth2/token", json={"code": "abc123"}) + assert r.json()["access_token"] == "ACCESS" + + def test_proxies_authorization_code_grant(self, with_credentials): + mock_post = MagicMock(return_value=_mock_forgejo_token_ok()) + with patch("ifcurl.foundation_api.httpx.post", mock_post): + client.post("/foundation/1.1/oauth2/token", json={"code": "abc123"}) + payload = mock_post.call_args.kwargs["json"] + assert payload["grant_type"] == "authorization_code" + assert payload["code"] == "abc123" + assert payload["client_id"] == "test-client-id" + assert payload["client_secret"] == "test-client-secret" + + def test_redirect_uri_forwarded(self, with_credentials): + mock_post = MagicMock(return_value=_mock_forgejo_token_ok()) + with patch("ifcurl.foundation_api.httpx.post", mock_post): + client.post( + "/foundation/1.1/oauth2/token", + json={"code": "abc123", "redirect_uri": "https://app.example.com/cb"}, + ) + payload = mock_post.call_args.kwargs["json"] + assert payload["redirect_uri"] == "https://app.example.com/cb" + + def test_no_redirect_uri_omitted(self, with_credentials): + mock_post = MagicMock(return_value=_mock_forgejo_token_ok()) + with patch("ifcurl.foundation_api.httpx.post", mock_post): + client.post("/foundation/1.1/oauth2/token", json={"code": "abc123"}) + payload = mock_post.call_args.kwargs["json"] + assert "redirect_uri" not in payload + + def test_forgejo_error_propagated(self, with_credentials): + err_resp = MagicMock() + err_resp.status_code = 401 + err_resp.text = "invalid_grant" + with patch("ifcurl.foundation_api.httpx.post", return_value=err_resp): + r = client.post("/foundation/1.1/oauth2/token", json={"code": "bad"}) + assert r.status_code == 401 + + def test_forgejo_unreachable_returns_503(self, with_credentials): + import httpx as _httpx + with patch("ifcurl.foundation_api.httpx.post", side_effect=_httpx.ConnectError("refused")): + r = client.post("/foundation/1.1/oauth2/token", json={"code": "abc123"}) + assert r.status_code == 503 + + def test_503_when_not_configured(self, monkeypatch): + monkeypatch.setattr(foundation_api, "_CLIENT_ID", None) + monkeypatch.setattr(foundation_api, "_CLIENT_SECRET", None) + r = client.post("/foundation/1.1/oauth2/token", json={"code": "abc123"}) + assert r.status_code == 503 + + def test_missing_code_returns_422(self, with_credentials): + r = client.post("/foundation/1.1/oauth2/token", json={}) + assert r.status_code == 422 + + +# --------------------------------------------------------------------------- +# POST /foundation/1.1/oauth2/token_refresh +# --------------------------------------------------------------------------- + +class TestTokenRefresh: + def test_returns_200(self, with_credentials): + with patch("ifcurl.foundation_api.httpx.post", return_value=_mock_forgejo_token_ok()): + r = client.post( + "/foundation/1.1/oauth2/token_refresh", + json={"refresh_token": "REFRESH"}, + ) + assert r.status_code == 200 + + def test_proxies_refresh_token_grant(self, with_credentials): + mock_post = MagicMock(return_value=_mock_forgejo_token_ok()) + with patch("ifcurl.foundation_api.httpx.post", mock_post): + client.post( + "/foundation/1.1/oauth2/token_refresh", + json={"refresh_token": "REFRESH"}, + ) + payload = mock_post.call_args.kwargs["json"] + assert payload["grant_type"] == "refresh_token" + assert payload["refresh_token"] == "REFRESH" + assert payload["client_id"] == "test-client-id" + assert payload["client_secret"] == "test-client-secret" + + def test_forgejo_error_propagated(self, with_credentials): + err_resp = MagicMock() + err_resp.status_code = 401 + err_resp.text = "invalid_grant" + with patch("ifcurl.foundation_api.httpx.post", return_value=err_resp): + r = client.post( + "/foundation/1.1/oauth2/token_refresh", + json={"refresh_token": "expired"}, + ) + assert r.status_code == 401 + + def test_503_when_not_configured(self, monkeypatch): + monkeypatch.setattr(foundation_api, "_CLIENT_ID", None) + monkeypatch.setattr(foundation_api, "_CLIENT_SECRET", None) + r = client.post( + "/foundation/1.1/oauth2/token_refresh", + json={"refresh_token": "REFRESH"}, + ) + assert r.status_code == 503 + + def test_missing_refresh_token_returns_422(self, with_credentials): + r = client.post("/foundation/1.1/oauth2/token_refresh", json={}) + assert r.status_code == 422