datasette/tests/test_internals_datasette_client.py
2025-11-13 10:00:04 -08:00

315 lines
11 KiB
Python

import httpx
import pytest
import pytest_asyncio
from datasette.app import Datasette
@pytest_asyncio.fixture
async def datasette(ds_client):
await ds_client.ds.invoke_startup()
return ds_client.ds
@pytest_asyncio.fixture
async def datasette_with_permissions():
"""A datasette instance with permission restrictions for testing"""
ds = Datasette(config={"databases": {"test_db": {"allow": {"id": "admin"}}}})
await ds.invoke_startup()
db = ds.add_memory_database("test_datasette_with_permissions", name="test_db")
await db.execute_write(
"create table if not exists test_table (id integer primary key, name text)"
)
await db.execute_write(
"insert or ignore into test_table (id, name) values (1, 'Alice')"
)
# Trigger catalog refresh
await ds.client.get("/")
return ds
@pytest.mark.asyncio
@pytest.mark.parametrize(
"method,path,expected_status",
[
("get", "/", 200),
("options", "/", 200),
("head", "/", 200),
("put", "/", 405),
("patch", "/", 405),
("delete", "/", 405),
],
)
async def test_client_methods(datasette, method, path, expected_status):
client_method = getattr(datasette.client, method)
response = await client_method(path)
assert isinstance(response, httpx.Response)
assert response.status_code == expected_status
# Try that again using datasette.client.request
response2 = await datasette.client.request(method, path)
assert response2.status_code == expected_status
@pytest.mark.asyncio
@pytest.mark.parametrize("prefix", [None, "/prefix/"])
async def test_client_post(datasette, prefix):
original_base_url = datasette._settings["base_url"]
try:
if prefix is not None:
datasette._settings["base_url"] = prefix
response = await datasette.client.post(
"/-/messages",
data={
"message": "A message",
},
)
assert isinstance(response, httpx.Response)
assert response.status_code == 302
assert "ds_messages" in response.cookies
finally:
datasette._settings["base_url"] = original_base_url
@pytest.mark.asyncio
@pytest.mark.parametrize(
"prefix,expected_path", [(None, "/asgi-scope"), ("/prefix/", "/prefix/asgi-scope")]
)
async def test_client_path(datasette, prefix, expected_path):
original_base_url = datasette._settings["base_url"]
try:
if prefix is not None:
datasette._settings["base_url"] = prefix
response = await datasette.client.get("/asgi-scope")
path = response.json()["path"]
assert path == expected_path
finally:
datasette._settings["base_url"] = original_base_url
@pytest.mark.asyncio
async def test_skip_permission_checks_allows_forbidden_access(
datasette_with_permissions,
):
"""Test that skip_permission_checks=True bypasses permission checks"""
ds = datasette_with_permissions
# Without skip_permission_checks, anonymous user should get 403 for protected database
response = await ds.client.get("/test_db.json")
assert response.status_code == 403
# With skip_permission_checks=True, should get 200
response = await ds.client.get("/test_db.json", skip_permission_checks=True)
assert response.status_code == 200
data = response.json()
assert data["database"] == "test_db"
@pytest.mark.asyncio
async def test_skip_permission_checks_on_table(datasette_with_permissions):
"""Test skip_permission_checks works for table access"""
ds = datasette_with_permissions
# Without skip_permission_checks, should get 403
response = await ds.client.get("/test_db/test_table.json")
assert response.status_code == 403
# With skip_permission_checks=True, should get table data
response = await ds.client.get(
"/test_db/test_table.json", skip_permission_checks=True
)
assert response.status_code == 200
data = response.json()
assert data["rows"] == [{"id": 1, "name": "Alice"}]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"method", ["get", "post", "put", "patch", "delete", "options", "head"]
)
async def test_skip_permission_checks_all_methods(datasette_with_permissions, method):
"""Test that skip_permission_checks works with all HTTP methods"""
ds = datasette_with_permissions
# All methods should work with skip_permission_checks=True
client_method = getattr(ds.client, method)
response = await client_method("/test_db.json", skip_permission_checks=True)
# We don't check status code since some methods might not be allowed,
# but we verify the request doesn't fail due to permissions
assert isinstance(response, httpx.Response)
@pytest.mark.asyncio
async def test_skip_permission_checks_request_method(datasette_with_permissions):
"""Test that skip_permission_checks works with client.request()"""
ds = datasette_with_permissions
# Without skip_permission_checks
response = await ds.client.request("GET", "/test_db.json")
assert response.status_code == 403
# With skip_permission_checks=True
response = await ds.client.request(
"GET", "/test_db.json", skip_permission_checks=True
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_skip_permission_checks_isolated_to_request(datasette_with_permissions):
"""Test that skip_permission_checks doesn't affect other concurrent requests"""
ds = datasette_with_permissions
# First request with skip_permission_checks=True should succeed
response1 = await ds.client.get("/test_db.json", skip_permission_checks=True)
assert response1.status_code == 200
# Subsequent request without it should still get 403
response2 = await ds.client.get("/test_db.json")
assert response2.status_code == 403
# And another with skip should succeed again
response3 = await ds.client.get("/test_db.json", skip_permission_checks=True)
assert response3.status_code == 200
@pytest.mark.asyncio
async def test_skip_permission_checks_with_admin_actor(datasette_with_permissions):
"""Test that skip_permission_checks works even when actor is provided"""
ds = datasette_with_permissions
# Admin actor should normally have access
admin_cookies = {"ds_actor": ds.client.actor_cookie({"id": "admin"})}
response = await ds.client.get("/test_db.json", cookies=admin_cookies)
assert response.status_code == 200
# Non-admin actor should get 403
user_cookies = {"ds_actor": ds.client.actor_cookie({"id": "user"})}
response = await ds.client.get("/test_db.json", cookies=user_cookies)
assert response.status_code == 403
# Non-admin actor with skip_permission_checks=True should get 200
response = await ds.client.get(
"/test_db.json", cookies=user_cookies, skip_permission_checks=True
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_skip_permission_checks_shows_denied_tables():
"""Test that skip_permission_checks=True shows tables from denied databases in /-/tables.json"""
ds = Datasette(
config={
"databases": {
"fixtures": {"allow": False} # Deny all access to this database
}
}
)
await ds.invoke_startup()
db = ds.add_memory_database("fixtures")
await db.execute_write(
"CREATE TABLE test_table (id INTEGER PRIMARY KEY, name TEXT)"
)
await db.execute_write("INSERT INTO test_table (id, name) VALUES (1, 'Alice')")
await ds._refresh_schemas()
# Without skip_permission_checks, tables from denied database should not appear in /-/tables.json
response = await ds.client.get("/-/tables.json")
assert response.status_code == 200
data = response.json()
table_names = [match["name"] for match in data["matches"]]
# Should not see any fixtures tables since access is denied
fixtures_tables = [name for name in table_names if name.startswith("fixtures:")]
assert len(fixtures_tables) == 0
# With skip_permission_checks=True, tables from denied database SHOULD appear
response = await ds.client.get("/-/tables.json", skip_permission_checks=True)
assert response.status_code == 200
data = response.json()
table_names = [match["name"] for match in data["matches"]]
# Should see fixtures tables when permission checks are skipped
assert "fixtures: test_table" in table_names
@pytest.mark.asyncio
async def test_in_client_returns_false_outside_request(datasette):
"""Test that datasette.in_client() returns False outside of a client request"""
assert datasette.in_client() is False
@pytest.mark.asyncio
async def test_in_client_returns_true_inside_request():
"""Test that datasette.in_client() returns True inside a client request"""
from datasette import hookimpl, Response
from datasette.plugins import pm
class TestPlugin:
__name__ = "test_in_client_plugin"
@hookimpl
def register_routes(self):
async def test_view(datasette):
# Assert in_client() returns True within the view
assert datasette.in_client() is True
return Response.json({"in_client": datasette.in_client()})
return [
(r"^/-/test-in-client$", test_view),
]
pm.register(TestPlugin(), name="test_in_client_plugin")
try:
ds = Datasette()
await ds.invoke_startup()
# Outside of a client request, should be False
assert ds.in_client() is False
# Make a request via datasette.client
response = await ds.client.get("/-/test-in-client")
assert response.status_code == 200
assert response.json()["in_client"] is True
# After the request, should be False again
assert ds.in_client() is False
finally:
pm.unregister(name="test_in_client_plugin")
@pytest.mark.asyncio
async def test_in_client_with_skip_permission_checks():
"""Test that in_client() works regardless of skip_permission_checks value"""
from datasette import hookimpl
from datasette.plugins import pm
from datasette.utils.asgi import Response
in_client_values = []
class TestPlugin:
__name__ = "test_in_client_skip_plugin"
@hookimpl
def register_routes(self):
async def test_view(datasette):
in_client_values.append(datasette.in_client())
return Response.json({"in_client": datasette.in_client()})
return [
(r"^/-/test-in-client$", test_view),
]
pm.register(TestPlugin(), name="test_in_client_skip_plugin")
try:
ds = Datasette(config={"databases": {"test_db": {"allow": {"id": "admin"}}}})
await ds.invoke_startup()
# Request without skip_permission_checks
await ds.client.get("/-/test-in-client")
# Request with skip_permission_checks=True
await ds.client.get("/-/test-in-client", skip_permission_checks=True)
# Both should have detected in_client as True
assert (
len(in_client_values) == 2
), f"Expected 2 values, got {len(in_client_values)}"
assert all(in_client_values), f"Expected all True, got {in_client_values}"
finally:
pm.unregister(name="test_in_client_skip_plugin")