|
1 | 1 | from datasette.app import Datasette |
| 2 | +from datasette.utils import baseconv |
| 3 | +from datasette_auth_github.views import verify_config |
| 4 | +from http.cookies import SimpleCookie |
| 5 | +import inspect |
2 | 6 | import pytest |
3 | 7 | import pytest_asyncio |
4 | 8 | import sqlite_utils |
5 | 9 | import re |
6 | 10 |
|
7 | 11 |
|
| 12 | +def datasette_with_config(files=None, config=None, **kwargs): |
| 13 | + # Datasette 1.0 moved plugin and permission configuration out of metadata. |
| 14 | + config_key = ( |
| 15 | + "config" if "config" in inspect.signature(Datasette).parameters else "metadata" |
| 16 | + ) |
| 17 | + return Datasette(files, **{config_key: config}, **kwargs) |
| 18 | + |
| 19 | + |
8 | 20 | @pytest.fixture |
9 | 21 | def non_mocked_hosts(): |
10 | 22 | return ["localhost"] |
@@ -98,9 +110,9 @@ def mocked_github_api(httpx_mock): |
98 | 110 | async def ds(tmpdir): |
99 | 111 | filepath = str(tmpdir / "test.db") |
100 | 112 | filepath2 = str(tmpdir / "demouser_org_only.db") |
101 | | - ds = Datasette( |
| 113 | + ds = datasette_with_config( |
102 | 114 | [filepath, filepath2], |
103 | | - metadata={ |
| 115 | + config={ |
104 | 116 | "plugins": { |
105 | 117 | "datasette-auth-github": { |
106 | 118 | "client_id": "x_client_id", |
@@ -167,6 +179,115 @@ async def test_github_auth_callback(ds, mocked_github_api): |
167 | 179 | assert "/" == response.headers["location"] |
168 | 180 |
|
169 | 181 |
|
| 182 | +@pytest.mark.asyncio |
| 183 | +@pytest.mark.parametrize( |
| 184 | + "scheme,force_https_urls,expected_secure", |
| 185 | + [("http", False, False), ("https", False, True), ("http", True, True)], |
| 186 | + ids=["http", "https", "https-proxy"], |
| 187 | +) |
| 188 | +@pytest.mark.parametrize( |
| 189 | + "cookie_config,expected_max_age", |
| 190 | + [({}, 2592000), ({"login_max_age": 3600}, 3600), ({"login_max_age": None}, None)], |
| 191 | + ids=["default", "custom", "session-only"], |
| 192 | +) |
| 193 | +async def test_callback_cookie_attributes( |
| 194 | + mocked_github_api, |
| 195 | + monkeypatch, |
| 196 | + scheme, |
| 197 | + force_https_urls, |
| 198 | + expected_secure, |
| 199 | + cookie_config, |
| 200 | + expected_max_age, |
| 201 | +): |
| 202 | + now = 1700000000 |
| 203 | + monkeypatch.setattr("time.time", lambda: now) |
| 204 | + ds = datasette_with_config( |
| 205 | + config={ |
| 206 | + "plugins": { |
| 207 | + "datasette-auth-github": { |
| 208 | + "client_id": "x_client_id", |
| 209 | + "client_secret": "x_client_secret", |
| 210 | + **cookie_config, |
| 211 | + } |
| 212 | + } |
| 213 | + }, |
| 214 | + settings={"force_https_urls": force_https_urls}, |
| 215 | + ) |
| 216 | + response = await ds.client.request( |
| 217 | + "GET", |
| 218 | + f"{scheme}://localhost/-/github-auth-callback?code=github-code-here", |
| 219 | + follow_redirects=False, |
| 220 | + avoid_path_rewrites=True, |
| 221 | + ) |
| 222 | + assert response.status_code == 302 |
| 223 | + cookies = SimpleCookie() |
| 224 | + for header in response.headers.get_list("set-cookie"): |
| 225 | + cookies.load(header) |
| 226 | + cookie = cookies["ds_actor"] |
| 227 | + assert cookie["httponly"] is True |
| 228 | + assert bool(cookie["secure"]) is expected_secure |
| 229 | + assert cookie["samesite"].lower() == "lax" |
| 230 | + assert cookie["path"] == "/" |
| 231 | + assert cookie["domain"] == "" |
| 232 | + payload = ds.unsign(cookie.value, "actor") |
| 233 | + assert payload["a"]["id"] == "github:123" |
| 234 | + if expected_max_age is None: |
| 235 | + assert cookie["max-age"] == "" |
| 236 | + assert cookie["expires"] == "" |
| 237 | + assert "e" not in payload |
| 238 | + else: |
| 239 | + assert cookie["max-age"] == str(expected_max_age) |
| 240 | + assert int(baseconv.base62.decode(payload["e"])) == now + expected_max_age |
| 241 | + |
| 242 | + |
| 243 | +@pytest.mark.asyncio |
| 244 | +@pytest.mark.parametrize( |
| 245 | + "cookie_config,max_age", |
| 246 | + [({}, 2592000), ({"login_max_age": 3600}, 3600), ({"login_max_age": None}, None)], |
| 247 | + ids=["default", "custom", "session-only"], |
| 248 | +) |
| 249 | +async def test_callback_cookie_expiration( |
| 250 | + mocked_github_api, monkeypatch, cookie_config, max_age |
| 251 | +): |
| 252 | + now = 1700000000 |
| 253 | + monkeypatch.setattr("time.time", lambda: now) |
| 254 | + ds = datasette_with_config( |
| 255 | + config={ |
| 256 | + "plugins": { |
| 257 | + "datasette-auth-github": { |
| 258 | + "client_id": "x_client_id", |
| 259 | + "client_secret": "x_client_secret", |
| 260 | + **cookie_config, |
| 261 | + } |
| 262 | + } |
| 263 | + } |
| 264 | + ) |
| 265 | + response = await ds.client.get( |
| 266 | + "/-/github-auth-callback?code=github-code-here", follow_redirects=False |
| 267 | + ) |
| 268 | + # Always send the cookie, even after its browser expiry, to exercise |
| 269 | + # Datasette's verification of the signed expiration timestamp. |
| 270 | + headers = {"Cookie": "ds_actor={}".format(response.cookies["ds_actor"])} |
| 271 | + lifetime = max_age if max_age is not None else 2592000 |
| 272 | + now += lifetime - 1 |
| 273 | + response = await ds.client.get("/-/actor.json", headers=headers) |
| 274 | + assert response.json()["actor"]["id"] == "github:123" |
| 275 | + now += 2 |
| 276 | + response = await ds.client.get("/-/actor.json", headers=headers) |
| 277 | + if max_age is None: |
| 278 | + assert response.json()["actor"]["id"] == "github:123" |
| 279 | + else: |
| 280 | + assert response.json()["actor"] is None |
| 281 | + |
| 282 | + |
| 283 | +@pytest.mark.parametrize("value", [True, False, 0, -1, "3600", 1.5]) |
| 284 | +def test_invalid_login_max_age(value): |
| 285 | + with pytest.raises( |
| 286 | + ValueError, match="^login_max_age must be a positive integer or null$" |
| 287 | + ): |
| 288 | + verify_config({"login_max_age": value}) |
| 289 | + |
| 290 | + |
170 | 291 | @pytest.mark.asyncio |
171 | 292 | async def test_sign_in_with_github_button(ds): |
172 | 293 | response = await ds.client.get("/") |
@@ -197,11 +318,14 @@ async def test_database_access_permissions( |
197 | 318 | ) |
198 | 319 | cookies = {"ds_actor": auth_response.cookies["ds_actor"]} |
199 | 320 | databases = await ds.client.get("/.json", cookies=cookies) |
200 | | - # This differs between Datasette <1.0 and >=1.0a20 |
201 | | - if "databases" in databases.json(): |
202 | | - assert set(databases.json()["databases"].keys()) == expected_databases |
203 | | - else: |
204 | | - assert set(databases.json().keys()) == expected_databases |
| 321 | + # Datasette versions expose databases as a top-level or nested dictionary, |
| 322 | + # or as a list of objects with a name field. |
| 323 | + data = databases.json() |
| 324 | + data = data.get("databases", data) |
| 325 | + names = ( |
| 326 | + {database["name"] for database in data} if isinstance(data, list) else set(data) |
| 327 | + ) |
| 328 | + assert names == expected_databases |
205 | 329 |
|
206 | 330 |
|
207 | 331 | @pytest.mark.asyncio |
@@ -235,9 +359,9 @@ async def test_github_enterprise_host(tmpdir, httpx_mock): |
235 | 359 |
|
236 | 360 | # Create Datasette instance with GitHub Enterprise configuration |
237 | 361 | filepath = str(tmpdir / "test.db") |
238 | | - ds = Datasette( |
| 362 | + ds = datasette_with_config( |
239 | 363 | [filepath], |
240 | | - metadata={ |
| 364 | + config={ |
241 | 365 | "plugins": { |
242 | 366 | "datasette-auth-github": { |
243 | 367 | "client_id": "enterprise_client_id", |
|
0 commit comments