Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 MagicMock
Comment thread
magopian marked this conversation as resolved.
Outdated


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) -> MagicMock:
"""Build a GithubIntegration mock that returns the given list of pull requests."""
pr_mocks = []
for pr in pulls:
mock_pr = MagicMock()
mock_pr.number = pr["number"]
mock_pr.title = pr["title"]
mock_pr.body = pr["body"]
pr_mocks.append(mock_pr)

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

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

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

mock_gi = MagicMock()
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", MagicMock())
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", MagicMock())

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