Skip to content

Commit 5a8cfff

Browse files
author
Koman Rudden
committed
test: add tests for invitation code support in OAuth login flow
1 parent 336fc05 commit 5a8cfff

4 files changed

Lines changed: 333 additions & 0 deletions

File tree

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import unittest
2+
import asyncio
3+
from unittest.mock import patch, MagicMock
4+
from urllib.parse import urlparse, parse_qs
5+
6+
from kinde_sdk.auth.oauth import OAuth
7+
from kinde_sdk.auth.enums import IssuerRouteTypes
8+
from kinde_sdk.auth.login_options import LoginOptions
9+
10+
11+
def run_async(coro):
12+
"""Helper function to run async tests"""
13+
return asyncio.run(coro)
14+
15+
16+
class TestInvitationCode(unittest.TestCase):
17+
"""Tests for invitation code support in OAuth login flow."""
18+
19+
@patch("requests.get")
20+
def setUp(self, mock_get):
21+
"""Set up test fixtures."""
22+
mock_response = MagicMock()
23+
mock_response.status_code = 200
24+
mock_response.json.return_value = {
25+
"authorization_endpoint": "https://example.com/oauth2/auth",
26+
"token_endpoint": "https://example.com/oauth2/token",
27+
"end_session_endpoint": "https://example.com/logout",
28+
"userinfo_endpoint": "https://example.com/oauth2/userinfo",
29+
}
30+
mock_get.return_value = mock_response
31+
32+
self.oauth = OAuth(
33+
client_id="test_client_id",
34+
client_secret="test_client_secret",
35+
redirect_uri="http://localhost:8000/callback",
36+
host="https://test.kinde.com",
37+
)
38+
self.mock_storage = MagicMock()
39+
self.oauth._session_manager = MagicMock()
40+
self.oauth._session_manager.storage_manager = self.mock_storage
41+
self.oauth.auth_url = "https://example.com/oauth2/auth"
42+
43+
# -- LoginOptions constants --
44+
45+
def test_login_options_has_invitation_code_constant(self):
46+
"""INVITATION_CODE constant is defined on LoginOptions."""
47+
self.assertEqual(LoginOptions.INVITATION_CODE, "invitation_code")
48+
49+
def test_login_options_has_is_invitation_constant(self):
50+
"""IS_INVITATION constant is defined on LoginOptions."""
51+
self.assertEqual(LoginOptions.IS_INVITATION, "is_invitation")
52+
53+
# -- generate_auth_url: invitation_code --
54+
55+
def test_invitation_code_appears_in_auth_url(self):
56+
"""invitation_code is forwarded as a query parameter."""
57+
result = run_async(
58+
self.oauth.generate_auth_url(
59+
login_options={LoginOptions.INVITATION_CODE: "abc123"}
60+
)
61+
)
62+
params = parse_qs(urlparse(result["url"]).query)
63+
self.assertEqual(params["invitation_code"], ["abc123"])
64+
65+
def test_invitation_code_auto_sets_is_invitation(self):
66+
"""is_invitation is automatically set to 'true' when invitation_code is present."""
67+
result = run_async(
68+
self.oauth.generate_auth_url(
69+
login_options={LoginOptions.INVITATION_CODE: "abc123"}
70+
)
71+
)
72+
params = parse_qs(urlparse(result["url"]).query)
73+
self.assertEqual(params["is_invitation"], ["true"])
74+
75+
def test_invitation_code_with_explicit_is_invitation_true(self):
76+
"""Explicit is_invitation=True is honoured alongside invitation_code."""
77+
result = run_async(
78+
self.oauth.generate_auth_url(
79+
login_options={
80+
LoginOptions.INVITATION_CODE: "abc123",
81+
LoginOptions.IS_INVITATION: True,
82+
}
83+
)
84+
)
85+
params = parse_qs(urlparse(result["url"]).query)
86+
self.assertEqual(params["invitation_code"], ["abc123"])
87+
self.assertEqual(params["is_invitation"], ["true"])
88+
89+
def test_invitation_code_with_explicit_is_invitation_false(self):
90+
"""When invitation_code is present but is_invitation is explicitly False,
91+
the auto-set logic still adds is_invitation='true'."""
92+
result = run_async(
93+
self.oauth.generate_auth_url(
94+
login_options={
95+
LoginOptions.INVITATION_CODE: "abc123",
96+
LoginOptions.IS_INVITATION: False,
97+
}
98+
)
99+
)
100+
params = parse_qs(urlparse(result["url"]).query)
101+
self.assertEqual(params["is_invitation"], ["true"])
102+
103+
def test_is_invitation_alone_without_invitation_code(self):
104+
"""is_invitation=True can be set independently of invitation_code."""
105+
result = run_async(
106+
self.oauth.generate_auth_url(
107+
login_options={LoginOptions.IS_INVITATION: True}
108+
)
109+
)
110+
params = parse_qs(urlparse(result["url"]).query)
111+
self.assertEqual(params["is_invitation"], ["true"])
112+
self.assertNotIn("invitation_code", params)
113+
114+
def test_is_invitation_false_alone_not_in_url(self):
115+
"""is_invitation=False without invitation_code produces no is_invitation param."""
116+
result = run_async(
117+
self.oauth.generate_auth_url(
118+
login_options={LoginOptions.IS_INVITATION: False}
119+
)
120+
)
121+
params = parse_qs(urlparse(result["url"]).query)
122+
self.assertNotIn("is_invitation", params)
123+
124+
def test_no_invitation_params_by_default(self):
125+
"""Neither invitation_code nor is_invitation appear when not requested."""
126+
result = run_async(self.oauth.generate_auth_url(login_options={}))
127+
params = parse_qs(urlparse(result["url"]).query)
128+
self.assertNotIn("invitation_code", params)
129+
self.assertNotIn("is_invitation", params)
130+
131+
def test_invitation_code_empty_string_not_set(self):
132+
"""An empty invitation_code does not add is_invitation."""
133+
result = run_async(
134+
self.oauth.generate_auth_url(
135+
login_options={LoginOptions.INVITATION_CODE: ""}
136+
)
137+
)
138+
params = parse_qs(urlparse(result["url"]).query)
139+
self.assertNotIn("is_invitation", params)
140+
141+
def test_invitation_code_none_not_set(self):
142+
"""invitation_code=None is ignored."""
143+
result = run_async(
144+
self.oauth.generate_auth_url(
145+
login_options={LoginOptions.INVITATION_CODE: None}
146+
)
147+
)
148+
params = parse_qs(urlparse(result["url"]).query)
149+
self.assertNotIn("invitation_code", params)
150+
self.assertNotIn("is_invitation", params)
151+
152+
# -- login() wrapper --
153+
154+
def test_login_passes_invitation_code(self):
155+
"""login() forwards invitation_code to the generated URL."""
156+
url = run_async(
157+
self.oauth.login(
158+
login_options={LoginOptions.INVITATION_CODE: "inv_xyz"}
159+
)
160+
)
161+
params = parse_qs(urlparse(url).query)
162+
self.assertEqual(params["invitation_code"], ["inv_xyz"])
163+
self.assertEqual(params["is_invitation"], ["true"])
164+
165+
def test_login_without_invitation_code(self):
166+
"""login() without invitation options produces no invitation params."""
167+
url = run_async(self.oauth.login())
168+
params = parse_qs(urlparse(url).query)
169+
self.assertNotIn("invitation_code", params)
170+
self.assertNotIn("is_invitation", params)
171+
172+
# -- register() wrapper --
173+
174+
def test_register_passes_invitation_code(self):
175+
"""register() forwards invitation_code to the generated URL."""
176+
url = run_async(
177+
self.oauth.register(
178+
login_options={LoginOptions.INVITATION_CODE: "inv_reg"}
179+
)
180+
)
181+
params = parse_qs(urlparse(url).query)
182+
self.assertEqual(params["invitation_code"], ["inv_reg"])
183+
self.assertEqual(params["is_invitation"], ["true"])
184+
185+
# -- Coexistence with other params --
186+
187+
def test_invitation_code_with_org_code(self):
188+
"""invitation_code works alongside org_code."""
189+
result = run_async(
190+
self.oauth.generate_auth_url(
191+
login_options={
192+
LoginOptions.INVITATION_CODE: "abc123",
193+
LoginOptions.ORG_CODE: "org_456",
194+
}
195+
)
196+
)
197+
params = parse_qs(urlparse(result["url"]).query)
198+
self.assertEqual(params["invitation_code"], ["abc123"])
199+
self.assertEqual(params["is_invitation"], ["true"])
200+
self.assertEqual(params["org_code"], ["org_456"])
201+
202+
203+
if __name__ == "__main__":
204+
unittest.main()

testv2/testv2_framework/__init__.py

Whitespace-only changes.
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import unittest
2+
from unittest.mock import AsyncMock, MagicMock, patch
3+
4+
from fastapi import FastAPI
5+
from starlette.testclient import TestClient
6+
from starlette.middleware.sessions import SessionMiddleware
7+
8+
from kinde_fastapi.framework.fastapi_framework import FastAPIFramework
9+
10+
11+
class TestFastAPILoginInvitationCode(unittest.TestCase):
12+
"""Tests for invitation_code extraction in the FastAPI /login route."""
13+
14+
def setUp(self):
15+
self.app = FastAPI()
16+
self.app.add_middleware(SessionMiddleware, secret_key="test-secret")
17+
18+
self.framework = FastAPIFramework(app=self.app)
19+
20+
# Mock OAuth so no real auth happens
21+
self.mock_oauth = MagicMock()
22+
self.mock_oauth.login = AsyncMock(return_value="https://kinde.example.com/authorize")
23+
self.framework._oauth = self.mock_oauth
24+
25+
# Register routes (skip middleware added by start() to avoid duplicate)
26+
self.framework._register_kinde_routes()
27+
28+
self.client = TestClient(self.app, follow_redirects=False)
29+
30+
def test_login_with_invitation_code(self):
31+
"""invitation_code query param is forwarded to oauth.login()."""
32+
resp = self.client.get("/login?invitation_code=inv_abc123")
33+
34+
self.mock_oauth.login.assert_called_once()
35+
login_options = self.mock_oauth.login.call_args[0][0]
36+
self.assertEqual(login_options["invitation_code"], "inv_abc123")
37+
38+
def test_login_without_invitation_code(self):
39+
"""No invitation_code means oauth.login() gets an empty dict."""
40+
resp = self.client.get("/login")
41+
42+
self.mock_oauth.login.assert_called_once()
43+
login_options = self.mock_oauth.login.call_args[0][0]
44+
self.assertNotIn("invitation_code", login_options)
45+
46+
def test_login_with_empty_invitation_code(self):
47+
"""An empty invitation_code query param is not forwarded."""
48+
resp = self.client.get("/login?invitation_code=")
49+
50+
self.mock_oauth.login.assert_called_once()
51+
login_options = self.mock_oauth.login.call_args[0][0]
52+
self.assertNotIn("invitation_code", login_options)
53+
54+
def test_login_redirects_to_oauth_url(self):
55+
"""The route returns a redirect to the URL from oauth.login()."""
56+
resp = self.client.get("/login?invitation_code=inv_xyz")
57+
58+
self.assertEqual(resp.status_code, 307)
59+
self.assertEqual(resp.headers["location"], "https://kinde.example.com/authorize")
60+
61+
62+
if __name__ == "__main__":
63+
unittest.main()
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import unittest
2+
from unittest.mock import AsyncMock, MagicMock, patch
3+
from flask import Flask
4+
5+
from kinde_flask.framework.flask_framework import FlaskFramework
6+
7+
8+
class TestFlaskLoginInvitationCode(unittest.TestCase):
9+
"""Tests for invitation_code extraction in the Flask /login route."""
10+
11+
def setUp(self):
12+
self.app = Flask(__name__)
13+
self.app.config["SECRET_KEY"] = "test-secret"
14+
self.app.config["TESTING"] = True
15+
16+
self.framework = FlaskFramework(app=self.app)
17+
18+
# Mock OAuth so no real auth happens
19+
self.mock_oauth = MagicMock()
20+
self.mock_oauth.login = AsyncMock(return_value="https://kinde.example.com/authorize")
21+
self.framework._oauth = self.mock_oauth
22+
23+
# Register routes
24+
self.framework._initialized = False
25+
self.framework._register_kinde_routes()
26+
27+
self.client = self.app.test_client()
28+
29+
def test_login_with_invitation_code(self):
30+
"""invitation_code query param is forwarded to oauth.login()."""
31+
with self.app.test_request_context():
32+
resp = self.client.get("/login?invitation_code=inv_abc123")
33+
34+
self.mock_oauth.login.assert_called_once()
35+
login_options = self.mock_oauth.login.call_args[0][0]
36+
self.assertEqual(login_options["invitation_code"], "inv_abc123")
37+
38+
def test_login_without_invitation_code(self):
39+
"""No invitation_code means oauth.login() gets an empty dict."""
40+
with self.app.test_request_context():
41+
resp = self.client.get("/login")
42+
43+
self.mock_oauth.login.assert_called_once()
44+
login_options = self.mock_oauth.login.call_args[0][0]
45+
self.assertNotIn("invitation_code", login_options)
46+
47+
def test_login_with_empty_invitation_code(self):
48+
"""An empty invitation_code query param is not forwarded."""
49+
with self.app.test_request_context():
50+
resp = self.client.get("/login?invitation_code=")
51+
52+
self.mock_oauth.login.assert_called_once()
53+
login_options = self.mock_oauth.login.call_args[0][0]
54+
self.assertNotIn("invitation_code", login_options)
55+
56+
def test_login_redirects_to_oauth_url(self):
57+
"""The route returns a redirect to the URL from oauth.login()."""
58+
with self.app.test_request_context():
59+
resp = self.client.get("/login?invitation_code=inv_xyz")
60+
61+
self.assertEqual(resp.status_code, 302)
62+
self.assertEqual(resp.headers["Location"], "https://kinde.example.com/authorize")
63+
64+
65+
if __name__ == "__main__":
66+
unittest.main()

0 commit comments

Comments
 (0)