Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,9 @@ PUBLIC_API_GEO_COUNTRY_QUERY_ENDPOINT="/api/resources/3580bf65-1d11-4574-a2ca-90
#### TCHAP canal variable
PUBLIC_CONTACT_URL="https://www.tchap.gouv.fr/#/room/!pwyfzLTDXyMeinVsgL:agent.dinum.tchap.gouv.fr"
PUBLIC_CONTACT_EMAIL="equipe-ami@numerique.gouv.fr"

# Github App credentials to list open PRs
# Generate with: base64 -i ami-reviewapps.private-key.pem
GITHUB_APP_ID=""
GITHUB_APP_INSTALLATION_ID=""
GITHUB_APP_PRIVATE_KEY_B64=""
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,6 @@ static/

# The private key file for Firebase Cloud Messaging
*-adminsdk-*.json

# The private key for the "ami-reviewapps" Github App
*.private-key.pem
20 changes: 20 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,3 +301,23 @@ For vite, we used to take advantage of `basicSsl` provided by a vitejs `plugin-b
```sh
mkcert -install
```


# Making authenticated requests to the Github API

As explained in
https://github.com/numerique-gouv/ami-notifications-api/issues/417, we need to
make authenticated requests to the Github API, in the staging mobile apps.

To do so, we use a Github App named `ami-reviewapps`, and store its ID in the following env variable: `GITHUB_APP_ID`.

This app is then used to generate a private key stored in the `GITHUB_APP_PRIVATE_KEY` env variable,
as a base64 value, generated using:

```sh
base64 -i ami-reviewapps.private-key.pem
```

The call chain with PyGithub is:

`Auth.AppAuth → GithubIntegration → get_installation → get_pulls`
9 changes: 9 additions & 0 deletions ami/settings.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import base64
import os
import sys
from pathlib import Path
Expand Down Expand Up @@ -297,6 +298,14 @@ def before_send(event, hint):
PARTNERS_PSL_OTV_JWT_CERT_PUBLIC_KEY = CONFIG.get("PARTNERS_PSL_OTV_JWT_CERT_PUBLIC_KEY", "")
PARTNERS_PSL_OTV_JWE_PUBLIC_KEY = CONFIG.get("PARTNERS_PSL_OTV_JWE_PUBLIC_KEY", "")

# Github App credentials to list open PRs
GITHUB_APP_ID = CONFIG.get("GITHUB_APP_ID", "")
GITHUB_APP_PRIVATE_KEY = (
base64.b64decode(CONFIG.get("GITHUB_APP_PRIVATE_KEY_B64", "")).decode()
if CONFIG.get("GITHUB_APP_PRIVATE_KEY_B64")
else ""
)

# Channels
CHANNEL_UNAUTHORIZED_CODE = 4001

Expand Down
35 changes: 17 additions & 18 deletions ami/utils/api_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
from django.db import connection
from django.http import HttpResponse
from drf_spectacular.utils import extend_schema
from github import Auth, GithubIntegration
from rest_framework.decorators import api_view
from rest_framework.response import Response

from ami.user.utils import build_fc_hash
from ami.utils.httpx import httpxClient
from ami.utils.serializers import RecipientFcHashSerializer


Expand Down Expand Up @@ -55,34 +55,33 @@ def _dev_utils_recipient_fc_hash(request) -> HttpResponse:
@api_view(["GET"])
def _dev_utils_review_apps(request) -> Response[list[dict[str, str | int]]]:
"""Returns a list of tuples: (review app url, pull request title)."""
with httpxClient() as httpx_client:
response = httpx_client.get(
"https://api.github.com/repos/numerique-gouv/ami-notifications-api/pulls",
params={"state": "open", "sort": "created", "per_page": 100},
headers={
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
)
staging_app = {
"url": "https://ami-back-staging.osc-fr1.scalingo.io/",
"title": "Staging",
"number": 0,
"description": "Staging",
}
if response.status_code >= 400:
# Possibly rate limited
try:
auth = Auth.AppAuth(
app_id=settings.GITHUB_APP_ID,
private_key=settings.GITHUB_APP_PRIVATE_KEY,
)
gi = GithubIntegration(auth=auth)
installation = gi.get_repo_installation("numerique-gouv", "ami-notifications-api")
gh = installation.get_github_for_installation()
repo = gh.get_repo("numerique-gouv/ami-notifications-api")
open_pulls = repo.get_pulls(state="open", sort="created")
except Exception:
return Response([staging_app])

json_data = response.json()
review_apps = [
{
"url": f"https://ami-back-staging-pr{review_app['number']}.osc-fr1.scalingo.io/",
"title": f"PR{review_app['number']}: {review_app['title']}",
"number": review_app["number"],
"description": review_app["body"],
"url": f"https://ami-back-staging-pr{pr.number}.osc-fr1.scalingo.io/",
"title": f"PR{pr.number}: {pr.title}",
"number": pr.number,
"description": pr.body,
}
for review_app in json_data
for pr in open_pulls
]
return Response([staging_app] + review_apps)

Expand Down
57 changes: 43 additions & 14 deletions ami/utils/tests/test_all.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from typing import Any

from pytest_httpx import HTTPXMock
from unittest.mock import Mock


def test_ping(app) -> None:
Expand Down Expand Up @@ -35,24 +34,54 @@ def test_recipient_fc_hash(app) -> None:
assert response.text == "7e74df2cbebae761eccedbc24b7fe589bb83137f7808a2930031f52c73d75efe"


def test_review_apps(app, httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(
method="GET",
url="https://api.github.com/repos/numerique-gouv/ami-notifications-api/pulls?state=open&sort=created&per_page=100",
json=TRUNCATED_GITHUB_JSON_RESPONSE,
def _make_github_mock(pulls: list) -> Mock:
"""Build a GithubIntegration mock that returns the given list of pull requests."""
pr_mocks = []
for pr in pulls:
mock_pr = Mock()
mock_pr.number = pr["number"]
mock_pr.title = pr["title"]
mock_pr.body = pr["body"]
pr_mocks.append(mock_pr)

mock_repo = Mock()
mock_repo.get_pulls.return_value = pr_mocks

mock_gh = Mock()
mock_gh.get_repo.return_value = mock_repo

mock_installation = Mock()
mock_installation.get_github_for_installation.return_value = mock_gh

mock_gi = Mock()
mock_gi.get_repo_installation.return_value = mock_installation

return mock_gi


def test_review_apps(app, monkeypatch, settings) -> None:
settings.GITHUB_APP_ID = "123456"
settings.GITHUB_APP_PRIVATE_KEY = "fake-key"
monkeypatch.setattr("ami.utils.api_views.Auth", Mock())
monkeypatch.setattr(
"ami.utils.api_views.GithubIntegration",
lambda **kwargs: _make_github_mock(TRUNCATED_GITHUB_JSON_RESPONSE),
)
response = app.get("/dev-utils/review-apps")
json_data = response.json
assert len(json_data) == 2 # Staging + the PR returned in GITHUB_JSON_RESPONSE
assert len(json_data) == 2 # Staging + the PR returned in TRUNCATED_GITHUB_JSON_RESPONSE
assert json_data[0]["title"] == "Staging"


def test_review_apps_github_failure(app, httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(
method="GET",
url="https://api.github.com/repos/numerique-gouv/ami-notifications-api/pulls?state=open&sort=created&per_page=100",
status_code=400,
)
def test_review_apps_github_failure(app, monkeypatch, settings) -> None:
settings.GITHUB_APP_ID = "123456"
settings.GITHUB_APP_PRIVATE_KEY = "fake-key"
monkeypatch.setattr("ami.utils.api_views.Auth", Mock())

def raise_error(**kwargs):
raise Exception("GitHub API error")

monkeypatch.setattr("ami.utils.api_views.GithubIntegration", raise_error)
response = app.get("/dev-utils/review-apps")
json_data = response.json
assert len(json_data) == 1 # Only the hardcoded Staging
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ dependencies = [
"libsass>=0.23.0",
"mozilla-django-oidc>=5.0.2",
"psycopg[binary]>=3.3.3",
"pygithub>=2.9.1",
"python-dotenv>=1.2.2",
"sentry-sdk>=2.29.1",
"types-requests>=2.32.0.20250328",
Expand Down
55 changes: 54 additions & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading