Skip to content

Commit 1d5e361

Browse files
committed
NEW: added github mocking service for integration testing
1 parent 69d97f6 commit 1d5e361

File tree

11 files changed

+680
-1
lines changed

11 files changed

+680
-1
lines changed

integration_tests/docker-compose.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
include:
22
- ../docker-compose.yml # Defines the `db` service
3-
- ../../github-static-mock/docker-compose.yaml
3+
- ./services/github-static-mock/docker-compose.yaml # Defines the `github` mock backend service
44

55
services:
66
integration_tests:
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
FROM python:3.13
2+
3+
WORKDIR /app
4+
5+
# Install Poetry in a place that's already in $PATH
6+
ENV POETRY_HOME=/usr/local
7+
RUN curl -sSL https://install.python-poetry.org | python3 -
8+
9+
COPY . .
10+
RUN poetry install
11+
12+
EXPOSE 9000
13+
CMD ["poetry", "run", "start"]
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Static oauth GitHub mock
2+
This project mocks GitHub oauth server's [web application flow](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#web-application-flow).
3+
As this is a static project, codes and tokens are hardcoded. An endpoint is available to set the user data to be
4+
returned on the next call to the `/user` endpoint. By doing this, a webpage with form input on the mocked user is
5+
avoided, keeping this simple for automated tests.
6+
7+
WARNING: This is a project for testing purposes only. Nothing about this implementation is secure.
8+
9+
## Running the project
10+
Currently only the Redis cache runs on Docker Compose. This stores the upcoming user's data. Run it with
11+
`docker compose up`.
12+
13+
The web application itself can be run with `poetry run start` after running `poetry install`.
14+
15+
## Endpoints
16+
### Authorization mechanics
17+
The oauth endpoints mocked facilitate the web application flow. Calls in order are:
18+
1. Call `/system/upcoming-user` to set the next mocked user data.
19+
2. Call `/login/oauth/authorize` to have the application return a response with a code and state.
20+
3. Call `/login/oauth/access_token` to mock exchanging the code for a bearer token.
21+
4. Call `/user` to obtain user data.
22+
23+
## /system/upcoming-user
24+
Example call (using [HTTPie](https://httpie.io/)):
25+
26+
```shell
27+
http "http://localhost:9000/system/upcoming-user" name="John Smith" login=jsmith
28+
```
29+
30+
## /login/oauth/authorize
31+
Authorization requires query parameters: response_type, client_id, scope, state and redirect_uri. The redirect_uri is
32+
used to redirect the client. The state is included in the redirect URI for further use by the client. The code in the
33+
redirect URI is hardcoded.
34+
35+
```shell
36+
http "http://localhost:9000/login/oauth/authorize?response_type=code&client_id=Ov23liuEhYT3CT9Yh6VA&scope=user%3Alogin%2Cname&state=JQOy3kw1PDiQh662ln4DuTGX20ajwb&redirect_uri=http%3A%2F%2Flocalhost%3A8000%2Foauth%2Fgithub%2Fcallback"
37+
```
38+
39+
## /login/oauth/access_token
40+
This endpoint takes a token and returns a bearer token. The bearer token is hardcoded. This endpoint requires
41+
application/x-www-form-urlencoded data.
42+
43+
```shell
44+
http --form POST "http://localhost:9000/login/oauth/access_token" grant_type=authorization_code code=SplxlOBeZQQYbYS6WxSbIA
45+
```
46+
47+
## /user
48+
Finally, the user endpoint is called. This returns the user's profile data. The `login` and `name` in the response are
49+
retrieved from Redis and are the values set via the `/system/upcoming-user` endpoint. This endpoint requires an
50+
Authorization header containing `Bearer `, the token itself is not evaluated. Calling this endpoint without setting an
51+
upcoming user in cache results in an HTTP400 Bad Request.
52+
53+
```shell
54+
http "http://localhost:9000/user" "Authorization: Bearer gho_xxx"
55+
```
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
services:
2+
cache:
3+
image: redis
4+
restart: always
5+
ports:
6+
- '6379:6379'
7+
profiles:
8+
- test
9+
github:
10+
build: .
11+
ports:
12+
- '9000:9000'
13+
environment:
14+
CACHE_HOST: 'cache'
15+
ELEKTO_HOST: 'elekto'
16+
ELEKTO_PORT: '8000'
17+
profiles:
18+
- test

integration_tests/services/github-static-mock/poetry.lock

Lines changed: 353 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
[project]
2+
name = "github-static-mock"
3+
version = "0.1.0"
4+
description = ""
5+
authors = [
6+
{name = "Your Name",email = "you@example.com"}
7+
]
8+
readme = "README.md"
9+
requires-python = ">=3.13"
10+
dependencies = [
11+
"fastapi (>=0.116.1,<0.117.0)",
12+
"uvicorn (>=0.35.0,<0.36.0)",
13+
"python-multipart (>=0.0.20,<0.0.21)",
14+
"redis (>=6.4.0,<7.0.0)"
15+
]
16+
17+
[tool.poetry]
18+
packages = [{include = "github_static_mock", from = "src"}]
19+
20+
[tool.poetry.scripts]
21+
start = "github_static_mock.app:start"
22+
23+
[build-system]
24+
requires = ["poetry-core>=2.0.0,<3.0.0"]
25+
build-backend = "poetry.core.masonry.api"

integration_tests/services/github-static-mock/src/github_static_mock/__init__.py

Whitespace-only changes.
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import json
2+
import logging
3+
import os
4+
5+
from typing import Annotated
6+
7+
from redis.asyncio import Redis
8+
9+
from fastapi import FastAPI, Form, Response, Request, Query, status, HTTPException
10+
import uvicorn
11+
12+
from github_static_mock.exceptions import NoUpcomingUserException
13+
from github_static_mock.models import AuthorizeQueryParams, AccessTokenResponse, UserResponse, UpcomingUser
14+
15+
logger = logging.getLogger(__name__)
16+
app = FastAPI()
17+
18+
CACHE_HOST = os.environ.get("CACHE_HOST", "localhost")
19+
20+
21+
async def get_upcoming_user() -> UpcomingUser:
22+
cache = Redis(host=CACHE_HOST, port=6379, db=0)
23+
raw_data = await cache.get('upcoming_user')
24+
25+
if raw_data is None:
26+
raise NoUpcomingUserException()
27+
28+
return UpcomingUser(**json.loads(raw_data.decode('utf-8')))
29+
30+
31+
@app.get('/login/oauth/authorize')
32+
def authorize(query: Annotated[AuthorizeQueryParams, Query()]) -> Response:
33+
"""
34+
Authorize application.
35+
36+
http://localhost:9000/login/oauth/authorize?response_type=code&client_id=Ov23liuEhYT3CT9Yh6VA&scope=user%3Alogin%2Cname&state=JQOy3kw1PDiQh662ln4DuTGX20ajwb&redirect_uri=http%3A%2F%2Flocalhost%3A8000%2Foauth%2Fgithub%2Fcallback
37+
38+
:param request:
39+
:param query:
40+
:return:
41+
"""
42+
logger.info(f'Authorize request: {query.model_dump_json()}')
43+
print(query.redirect_uri)
44+
45+
return Response(
46+
status_code=status.HTTP_302_FOUND,
47+
headers={
48+
'Location': f'{query.redirect_uri}?code=SplxlOBeZQQYbYS6WxSbIA&state={query.state}'
49+
},
50+
)
51+
52+
53+
@app.post('/login/oauth/access_token')
54+
async def access_token(grant_type: Annotated[str, Form()], code: Annotated[str, Form()]) -> AccessTokenResponse:
55+
"""
56+
Exchange code for access token.
57+
58+
NOTE: This endpoint receives form data (application/x-www-form-urlencoded), not JSON. This is the standard for the
59+
GitHub API. While GitHub also supports application/json and application/xml, this mock does not.
60+
61+
As this endpoint implementation is static, the grant_type and code are ignored. The access token is specific to the
62+
mocked user (using the login name) to prevent the client application perceiving all requests as coming from the same
63+
user.
64+
65+
:return:
66+
"""
67+
upcoming_user = await get_upcoming_user()
68+
69+
return AccessTokenResponse(
70+
access_token=f'gho_myososecretbearertoken_{upcoming_user.login}',
71+
token_type='bearer',
72+
scope=''
73+
)
74+
75+
76+
@app.get('/user')
77+
async def user(request: Request) -> UserResponse:
78+
if 'Bearer ' not in request.headers.get('Authorization', ''):
79+
raise HTTPException(
80+
status_code=status.HTTP_401_UNAUTHORIZED,
81+
)
82+
83+
upcoming_user = await get_upcoming_user()
84+
85+
return UserResponse(**{
86+
'login': upcoming_user.login,
87+
'id': 14993302,
88+
'node_id': 'MDQ6VXNlcjE0OTkzMzAy',
89+
'avatar_url': 'https://avatars.githubusercontent.com/u/14993302?v=4',
90+
'gravatar_id': '',
91+
'url': 'https://api.github.com/users/oduludo',
92+
'html_url': 'https://github.com/oduludo',
93+
'followers_url': 'https://api.github.com/users/oduludo/followers',
94+
'following_url': 'https://api.github.com/users/oduludo/following{/other_user}',
95+
'gists_url': 'https://api.github.com/users/oduludo/gists{/gist_id}',
96+
'starred_url': 'https://api.github.com/users/oduludo/starred{/owner}{/repo}',
97+
'subscriptions_url': 'https://api.github.com/users/oduludo/subscriptions',
98+
'organizations_url': 'https://api.github.com/users/oduludo/orgs',
99+
'repos_url': 'https://api.github.com/users/oduludo/repos',
100+
'events_url': 'https://api.github.com/users/oduludo/events{/privacy}',
101+
'received_events_url': 'https://api.github.com/users/oduludo/received_events',
102+
'type': 'User',
103+
'user_view_type': 'public',
104+
'site_admin': False,
105+
'name': upcoming_user.name,
106+
'company': 'Monsters, Inc.',
107+
'blog': '',
108+
'location': 'The Netherlands',
109+
'email': None,
110+
'hireable': None,
111+
'bio': None,
112+
'twitter_username': None,
113+
'notification_email': None,
114+
'public_repos': 6,
115+
'public_gists': 3,
116+
'followers': 7,
117+
'following': 5,
118+
'created_at': '2015-10-06T08:40:53Z',
119+
'updated_at': '2025-08-06T15:43:33Z'
120+
})
121+
122+
123+
@app.post('/system/upcoming-user')
124+
async def store_upcoming_user(request: Request, data: UpcomingUser) -> Response:
125+
"""
126+
Set the upcoming user's data to be returned from the next call to the /user endpoint.
127+
128+
:param request:
129+
:param data:
130+
:return:
131+
"""
132+
cache = Redis(host=CACHE_HOST, port=6379, db=0)
133+
await cache.set('upcoming_user', data.model_dump_json())
134+
return Response(status_code=status.HTTP_201_CREATED)
135+
136+
137+
def start() -> None:
138+
"""Launched with `poetry run start` at root level"""
139+
uvicorn.run('github_static_mock.app:app', host='0.0.0.0', port=9000, reload=True)
140+
141+
if __name__ == '__main__':
142+
start()
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from fastapi.exceptions import HTTPException
2+
from starlette import status
3+
4+
5+
class NoUpcomingUserException(HTTPException):
6+
def __init__(self):
7+
super().__init__(
8+
status_code=status.HTTP_400_BAD_REQUEST,
9+
detail='No upcoming user configured.'
10+
)
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import os
2+
from typing import Literal
3+
4+
from pydantic import BaseModel, HttpUrl
5+
6+
ELEKTO_HOST = os.environ.get('ELEKTO_HOST', 'localhost')
7+
ELEKTO_PORT = os.environ.get('ELEKTO_PORT', '8000')
8+
9+
10+
class AuthorizeQueryParams(BaseModel):
11+
client_id: str
12+
response_type: Literal['code']
13+
scope: str
14+
state: str
15+
redirect_uri: HttpUrl = f'http://{ELEKTO_HOST}:{ELEKTO_PORT}/oauth/github/callback'
16+
17+
18+
class AccessTokenResponse(BaseModel):
19+
access_token: str
20+
token_type: str
21+
scope: str
22+
23+
24+
class UserResponse(BaseModel):
25+
login: str
26+
id: int
27+
node_id: str
28+
avatar_url: str
29+
gravatar_id: str
30+
url: str
31+
html_url: str
32+
followers_url: str
33+
following_url: str
34+
gists_url: str
35+
starred_url: str
36+
subscriptions_url: str
37+
organizations_url: str
38+
repos_url: str
39+
events_url: str
40+
received_events_url: str
41+
type: str
42+
user_view_type: str
43+
site_admin: bool
44+
name: str
45+
company: str
46+
blog: str
47+
location: str
48+
email: str | None
49+
hireable: bool | None
50+
bio: str | None
51+
twitter_username: str | None
52+
notification_email: str | None
53+
public_repos: int
54+
public_gists: int
55+
followers: int
56+
following: int
57+
created_at: str
58+
updated_at: str
59+
60+
61+
class UpcomingUser(BaseModel):
62+
name: str
63+
login: str

0 commit comments

Comments
 (0)