datasette/tests/test_api.py
Alex Garcia 745b7872b8 Remove the hand-rolled tracer now that OpenTelemetry covers the same ground
Datasette had two tracing systems since the OpenTelemetry spans landed. The
hand-rolled one measures the wrong thing - issue 1730, open since 2022, is
about exactly that - and it cannot be rebuilt on top of the new spans without
core owning a TracerProvider, which is the one thing the OTel design refuses
to do. Rather than carry duplicate instrumentation on the db.execute() hot
path indefinitely, the old system goes.

Deleted: datasette/tracer.py, the trace_debug setting, the AsgiTracer
response-rewriting middleware and the ?_trace=1 query-string argument.

- datasette/database.py: the four `with trace(...)` wrappers PR 1 deliberately
  nested the OTel spans inside are removed and the bodies dedented. That also
  retires the `# noqa: SIM117` comments those wrappers required - a leftover
  unnecessary noqa trips ruff's RUF100 - and `kwargs["count"] = count` in
  execute_write_many, which fed the old tracer only. `git diff -w` on this file
  shows nothing but the deleted lines.
- datasette/views/base.py: stream_csv() still read ?_trace=1 to wrap CSV output
  in an HTML <textarea> debug page. That whole branch, including the
  EscapeHtmlWriter selection and the conditional content-type, is gone. The
  EscapeHtmlWriter class itself stays in datasette.utils - it is an importable
  public name and removing it would widen the API break.
- .github/workflows/deploy-latest.yml no longer passes --setting trace_debug 1.
  Worth stating precisely, because the ticket claimed otherwise: this would not
  have broken the deploy. Setting.convert() in cli.py only rewrites a bare name
  to settings.<name> for *known* settings, so `--setting trace_debug 1` would
  have been silently accepted as a meaningless top-level config key. The flag is
  removed because it is dead, not because it errors.

Tests. tests/test_tracer.py is deleted outright (6 items). Four other tests used
?_trace=1 as an assertion instrument rather than testing tracing:

- test_csv_trace tested the trace mechanism itself - deleted.
- test_table_csv_stream_does_not_calculate_facets,
  test_table_csv_stream_does_not_calculate_counts and
  test_nocount_nofacet_if_shape_is_object test real behaviour, and are rebuilt
  against captured spans. All three had silently stopped being able to fail: the
  facets test looked for "select content, count(*) as n", which facet suggestion
  has not emitted since it moved to a `with limited as (...)` CTE, and none of
  the three requested the count or facet work whose suppression they claim to
  check. The rebuilt versions ask for it explicitly, match strings the current
  SQL contains, and carry a guard assertion so an empty span list cannot
  masquerade as a pass. Each was confirmed to fail with the covered code broken.
- test_trace_correctly_escaped is kept, renamed test_query_page_escapes_sql,
  with ?_trace=1 dropped. It ran against ds_client, which has no trace_debug, so
  it never exercised the tracer - what it actually covered is the query page
  echoing user SQL into HTML, the surface of the two reflected-XSS advisories in
  issue 1360, and nothing else in the suite covers it. Deleting it would have
  quietly dropped that.

tests/test_utils.py's pairs_to_nested_config case used settings.trace_debug to
check that a later key overrides an earlier one; it now uses template_debug
rather than losing the case.

Docs: the datasette.tracer section of internals.rst, the trace_debug section of
settings.rst, the ?_trace=1 entries in json_api.rst and introspection.rst, and
the regenerated cli-reference.rst. changelog.rst gets a breaking-change entry
and keeps all its historical ?_trace=1 entries - two of them had to lose a
:ref: role pointing at a label this commit deletes, or Sphinx warns on every
build.

2368 passed, 39 skipped, 6 xfailed, 15 xpassed, 140 subtests, against 2375 /
141 before. Net -7 tests, fully accounted for: -6 test_tracer.py, -1
test_csv_trace, -1 test_trace_correctly_escaped, +1 test_query_page_escapes_sql.
The lost subtest is the per-setting case trace_debug generated in
test_settings_are_documented.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 16:24:35 -07:00

1098 lines
39 KiB
Python

import pathlib
import urllib
import pytest
from datasette.app import Datasette
from datasette.plugins import DEFAULT_PLUGINS
from datasette.utils import UNSTABLE_API_MESSAGE, escape_sqlite, tilde_encode
from datasette.utils.sqlite import sqlite_version
from datasette.version import __version__
from .fixtures import EXPECTED_PLUGINS, make_app_client
@pytest.mark.asyncio
async def test_homepage(ds_client):
response = await ds_client.get("/.json")
assert response.status_code == 200
assert "application/json; charset=utf-8" == response.headers["content-type"]
data = response.json()
assert sorted(data.get("metadata").keys()) == [
"about",
"about_url",
"description_html",
"license",
"license_url",
"source",
"source_url",
"title",
]
databases = data.get("databases")
assert isinstance(databases, list)
assert [d["name"] for d in databases] == ["fixtures"]
d = databases[0]
assert d["name"] == "fixtures"
assert isinstance(d["tables_count"], int)
assert isinstance(len(d["tables_and_views_truncated"]), int)
assert d["tables_and_views_more"] is True
assert isinstance(d["hidden_tables_count"], int)
assert isinstance(d["hidden_table_rows_sum"], int)
assert isinstance(d["views_count"], int)
@pytest.mark.asyncio
async def test_homepage_sort_by_relationships(ds_client):
response = await ds_client.get("/.json?_sort=relationships")
assert response.status_code == 200
tables = [
t["name"] for t in response.json()["databases"][0]["tables_and_views_truncated"]
]
assert tables == [
"simple_primary_key",
"foreign_key_references",
"complex_foreign_keys",
"roadside_attraction_characteristics",
"searchable_tags",
]
@pytest.mark.asyncio
async def test_database_page(ds_client):
response = await ds_client.get("/fixtures.json")
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
assert data["database"] == "fixtures"
# Build lookup for easier assertions
tables = data["tables"]
tables_by_name = {t["name"]: t for t in tables}
# Verify tables are sorted by (hidden, name) - visible first, then hidden
table_names = [t["name"] for t in tables]
expected_order = sorted(tables, key=lambda t: (t["hidden"], t["name"]))
assert table_names == [t["name"] for t in expected_order]
# Expected visible tables (not hidden)
expected_visible_tables = {
"123_starts_with_digits",
"Table With Space In Name",
"attraction_characteristic",
"binary_data",
"complex_foreign_keys",
"compound_primary_key",
"compound_three_primary_keys",
"custom_foreign_key_label",
"facet_cities",
"facetable",
"foreign_key_references",
"infinity",
"primary_key_multiple_columns",
"primary_key_multiple_columns_explicit_label",
"roadside_attraction_characteristics",
"roadside_attractions",
"searchable",
"searchable_tags",
"select",
"simple_primary_key",
"sortable",
"table/with/slashes.csv",
"tags",
}
# Expected hidden tables
expected_hidden_tables = {
"no_primary_key",
"searchable_fts",
"searchable_fts_config",
"searchable_fts_data",
"searchable_fts_docsize",
"searchable_fts_idx",
}
# Verify all expected tables exist
assert expected_visible_tables.issubset(tables_by_name.keys())
assert expected_hidden_tables.issubset(tables_by_name.keys())
# Verify hidden status
visible_tables = {t["name"] for t in tables if not t["hidden"]}
hidden_tables = {t["name"] for t in tables if t["hidden"]}
assert expected_visible_tables == visible_tables
assert expected_hidden_tables == hidden_tables
# Helper to compare foreign keys (order-insensitive)
def fk_set(fks):
return {(fk["other_table"], fk["column"], fk["other_column"]) for fk in fks}
# Test specific table properties
# -- facetable: has outgoing FK to facet_cities
facetable = tables_by_name["facetable"]
assert facetable["count"] == 15
assert facetable["primary_keys"] == ["pk"]
assert facetable["fts_table"] is None
assert facetable["private"] is False
assert fk_set(facetable["foreign_keys"]["outgoing"]) == {
("facet_cities", "_city_id", "id")
}
assert fk_set(facetable["foreign_keys"]["incoming"]) == set()
# -- facet_cities: has incoming FK from facetable
facet_cities = tables_by_name["facet_cities"]
assert facet_cities["count"] == 4
assert facet_cities["columns"] == ["id", "name"]
assert fk_set(facet_cities["foreign_keys"]["incoming"]) == {
("facetable", "id", "_city_id")
}
# -- simple_primary_key: has multiple incoming FKs
simple_pk = tables_by_name["simple_primary_key"]
assert simple_pk["count"] == 5
assert simple_pk["columns"] == ["id", "content"]
assert simple_pk["primary_keys"] == ["id"]
# Should have incoming FKs from complex_foreign_keys (f1, f2, f3) and foreign_key_references
incoming = fk_set(simple_pk["foreign_keys"]["incoming"])
assert ("complex_foreign_keys", "id", "f1") in incoming
assert ("complex_foreign_keys", "id", "f2") in incoming
assert ("complex_foreign_keys", "id", "f3") in incoming
assert ("foreign_key_references", "id", "foreign_key_with_label") in incoming
assert ("foreign_key_references", "id", "foreign_key_with_blank_label") in incoming
# -- complex_foreign_keys: has multiple outgoing FKs to same table
complex_fk = tables_by_name["complex_foreign_keys"]
assert complex_fk["count"] == 1
assert complex_fk["columns"] == ["pk", "f1", "f2", "f3"]
outgoing = fk_set(complex_fk["foreign_keys"]["outgoing"])
assert outgoing == {
("simple_primary_key", "f1", "id"),
("simple_primary_key", "f2", "id"),
("simple_primary_key", "f3", "id"),
}
# -- searchable: has FTS table association
searchable = tables_by_name["searchable"]
assert searchable["count"] == 2
assert searchable["fts_table"] == "searchable_fts"
assert searchable["columns"] == ["pk", "text1", "text2", "name with . and spaces"]
# -- searchable_fts: is the FTS virtual table (hidden)
searchable_fts = tables_by_name["searchable_fts"]
assert searchable_fts["hidden"] is True
assert searchable_fts["fts_table"] == "searchable_fts"
# The "rank" column became visible in pragma_table_info in SQLite 3.37+
if sqlite_version() >= (3, 37, 0):
assert "rank" in searchable_fts["columns"]
# -- compound primary keys
compound_pk = tables_by_name["compound_primary_key"]
assert compound_pk["primary_keys"] == ["pk1", "pk2"]
assert compound_pk["count"] == 3
compound_three = tables_by_name["compound_three_primary_keys"]
assert compound_three["primary_keys"] == ["pk1", "pk2", "pk3"]
assert compound_three["count"] == 1001
# -- sortable: generated data
sortable = tables_by_name["sortable"]
assert sortable["count"] == 201
assert sortable["primary_keys"] == ["pk1", "pk2"]
# -- no_primary_key: hidden table with generated data
no_pk = tables_by_name["no_primary_key"]
assert no_pk["hidden"] is True
assert no_pk["count"] == 202
assert no_pk["primary_keys"] == []
# -- roadside attractions relationship chain
attractions = tables_by_name["roadside_attractions"]
assert attractions["count"] == 4
assert fk_set(attractions["foreign_keys"]["incoming"]) == {
("roadside_attraction_characteristics", "pk", "attraction_id")
}
characteristics = tables_by_name["attraction_characteristic"]
assert characteristics["count"] == 2
assert fk_set(characteristics["foreign_keys"]["incoming"]) == {
("roadside_attraction_characteristics", "pk", "characteristic_id")
}
# -- searchable_tags: multiple outgoing FKs
searchable_tags = tables_by_name["searchable_tags"]
assert searchable_tags["primary_keys"] == ["searchable_id", "tag"]
outgoing = fk_set(searchable_tags["foreign_keys"]["outgoing"])
assert outgoing == {
("searchable", "searchable_id", "pk"),
("tags", "tag", "tag"),
}
# -- tables with special names
assert "123_starts_with_digits" in tables_by_name
assert "Table With Space In Name" in tables_by_name
assert "table/with/slashes.csv" in tables_by_name
assert "select" in tables_by_name # SQL reserved word
# Verify select table has SQL reserved word columns
select_table = tables_by_name["select"]
assert set(select_table["columns"]) == {"group", "having", "and", "json"}
# Verify all tables have required fields
for table in tables:
assert "name" in table
assert "columns" in table
assert "primary_keys" in table
assert "count" in table
assert "hidden" in table
assert "fts_table" in table
assert "foreign_keys" in table
assert "private" in table
assert "incoming" in table["foreign_keys"]
assert "outgoing" in table["foreign_keys"]
def test_no_files_uses_memory_database(app_client_no_files):
response = app_client_no_files.get("/.json")
assert response.status == 200
assert {
"ok": True,
"unstable": UNSTABLE_API_MESSAGE,
"databases": [
{
"name": "_memory",
"hash": None,
"color": "a6c7b9",
"path": "/_memory",
"tables_and_views_truncated": [],
"tables_and_views_more": False,
"tables_count": 0,
"table_rows_sum": 0,
"show_table_row_counts": False,
"hidden_table_rows_sum": 0,
"hidden_tables_count": 0,
"views_count": 0,
"private": False,
},
],
"metadata": {},
} == response.json
# Try that SQL query
response = app_client_no_files.get(
"/_memory/-/query.json?sql=select+sqlite_version()&_shape=array"
)
assert 1 == len(response.json)
assert ["sqlite_version()"] == list(response.json[0].keys())
@pytest.mark.parametrize(
"path,expected_redirect",
(
("/:memory:", "/_memory"),
("/:memory:.json", "/_memory.json"),
("/:memory:?sql=select+1", "/_memory?sql=select+1"),
("/:memory:.json?sql=select+1", "/_memory.json?sql=select+1"),
("/:memory:.csv?sql=select+1", "/_memory.csv?sql=select+1"),
),
)
def test_old_memory_urls_redirect(app_client_no_files, path, expected_redirect):
response = app_client_no_files.get(path)
assert response.status == 301
assert response.headers["location"] == expected_redirect
def test_database_page_for_database_with_dot_in_name(app_client_with_dot):
response = app_client_with_dot.get("/fixtures~2Edot.json")
assert response.status == 200
@pytest.mark.asyncio
async def test_custom_sql(ds_client):
response = await ds_client.get(
"/fixtures/-/query.json?sql=select+content+from+simple_primary_key",
)
data = response.json()
assert data == {
"rows": [
{"content": "hello"},
{"content": "world"},
{"content": ""},
{"content": "RENDER_CELL_DEMO"},
{"content": "RENDER_CELL_ASYNC"},
],
"ok": True,
"truncated": False,
}
@pytest.mark.xfail(reason="Sometimes flaky in CI due to timing issues")
def test_sql_time_limit(app_client_shorter_time_limit):
response = app_client_shorter_time_limit.get(
"/fixtures/-/query.json?sql=select+sleep(0.5)",
)
assert 400 == response.status
expected_message = (
"SQL query took too long. The time limit is"
" controlled by the sql_time_limit_ms setting."
)
assert response.json == {
"ok": False,
"error": expected_message,
"errors": [expected_message],
"status": 400,
}
@pytest.mark.asyncio
async def test_custom_sql_time_limit(ds_client):
response = await ds_client.get(
"/fixtures/-/query.json?sql=select+sleep(0.01)",
)
assert response.status_code == 200
response = await ds_client.get(
"/fixtures/-/query.json?sql=select+sleep(0.01)&_timelimit=5",
)
assert response.status_code == 400
assert response.json()["error"].startswith("SQL query took too long.")
@pytest.mark.asyncio
async def test_invalid_custom_sql(ds_client):
response = await ds_client.get(
"/fixtures/-/query.json?sql=.schema",
)
assert response.status_code == 400
assert response.json()["ok"] is False
assert "Statement must be a SELECT" == response.json()["error"]
@pytest.mark.asyncio
async def test_row(ds_client):
response = await ds_client.get("/fixtures/simple_primary_key/1.json?_shape=objects")
assert response.status_code == 200
assert response.json()["ok"] is True
assert response.json()["rows"] == [{"id": 1, "content": "hello"}]
@pytest.mark.asyncio
@pytest.mark.parametrize("suffix", ("", ".json"))
@pytest.mark.parametrize(
"row_path",
(
"a", # too few components for a two-column primary key
"a,b,c", # too many components for a two-column primary key
),
)
async def test_row_pk_arity_mismatch_returns_400(ds_client, row_path, suffix):
# A row URL with the wrong number of comma-separated primary key
# components used to raise an uncaught sqlite3.ProgrammingError (HTTP 500)
# because the SQL had one bind placeholder per PK column but params were
# only bound for the supplied components. It should be a 400 instead,
# mirroring the existing guard in datasette/views/table.py.
response = await ds_client.get(f"/fixtures/compound_primary_key/{row_path}{suffix}")
assert response.status_code == 400
if suffix == ".json":
assert response.json()["ok"] is False
assert response.json()["status"] == 400
@pytest.mark.asyncio
async def test_row_compound_pk_correct_arity(ds_client):
# The valid two-component URL still resolves the row.
response = await ds_client.get(
"/fixtures/compound_primary_key/a,b.json?_shape=objects"
)
assert response.status_code == 200
assert response.json()["rows"] == [{"pk1": "a", "pk2": "b", "content": "c"}]
@pytest.mark.asyncio
async def test_row_strange_table_name(ds_client):
response = await ds_client.get(
"/fixtures/table~2Fwith~2Fslashes~2Ecsv/3.json?_shape=objects"
)
assert response.status_code == 200
assert response.json()["rows"] == [{"pk": "3", "content": "hey"}]
@pytest.mark.asyncio
async def test_row_foreign_key_tables(ds_client):
response = await ds_client.get(
"/fixtures/simple_primary_key/1.json?_extra=foreign_key_tables"
)
assert response.status_code == 200
# Foreign keys are sorted by (other_table, column, other_column)
assert response.json()["foreign_key_tables"] == [
{
"other_table": "complex_foreign_keys",
"column": "id",
"other_column": "f1",
"count": 1,
"link": "/fixtures/complex_foreign_keys?f1=1",
},
{
"other_table": "complex_foreign_keys",
"column": "id",
"other_column": "f2",
"count": 0,
"link": "/fixtures/complex_foreign_keys?f2=1",
},
{
"other_table": "complex_foreign_keys",
"column": "id",
"other_column": "f3",
"count": 1,
"link": "/fixtures/complex_foreign_keys?f3=1",
},
{
"other_table": "foreign_key_references",
"column": "id",
"other_column": "foreign_key_with_blank_label",
"count": 0,
"link": "/fixtures/foreign_key_references?foreign_key_with_blank_label=1",
},
{
"other_table": "foreign_key_references",
"column": "id",
"other_column": "foreign_key_with_label",
"count": 1,
"link": "/fixtures/foreign_key_references?foreign_key_with_label=1",
},
]
@pytest.mark.asyncio
async def test_row_extras(ds_client):
response = await ds_client.get(
"/fixtures/simple_primary_key/1.json?_extra=database,table,primary_keys,query,request,debug,foreign_key_tables,column_details"
)
assert response.status_code == 200
data = response.json()
assert data["database"] == "fixtures"
assert data["table"] == "simple_primary_key"
assert data["primary_keys"] == ["id"]
assert data["query"]["sql"] == 'select * from simple_primary_key where "id"=:p0'
assert data["query"]["params"] == {"p0": "1"}
assert data["request"]["path"] == "/fixtures/simple_primary_key/1.json"
assert data["debug"]["url_vars"] == {
"database": "fixtures",
"table": "simple_primary_key",
"pks": "1",
"format": "json",
}
assert len(data["foreign_key_tables"]) == 5
id_detail = data["column_details"]["id"]
assert id_detail["type"].lower() == "integer"
assert id_detail == {
"type": id_detail["type"],
"sqlite_type": "INTEGER",
"notnull": False,
"default": None,
"is_pk": True,
"pk_position": 1,
"hidden": 0,
}
content_detail = data["column_details"]["content"]
assert content_detail["type"].lower() == "text"
assert content_detail == {
"type": content_detail["type"],
"sqlite_type": "TEXT",
"notnull": False,
"default": None,
"is_pk": False,
"pk_position": 0,
"hidden": 0,
}
@pytest.mark.asyncio
async def test_column_details_extra_row_for_null_blob(ds_client):
response = await ds_client.get("/fixtures/binary_data/3.json?_extra=column_details")
assert response.status_code == 200
data_detail = response.json()["column_details"]["data"]
assert data_detail["type"].lower() == "blob"
assert data_detail == {
"type": data_detail["type"],
"sqlite_type": "BLOB",
"notnull": False,
"default": None,
"is_pk": False,
"pk_position": 0,
"hidden": 0,
}
@pytest.mark.asyncio
async def test_row_extra_render_cell():
"""Test that _extra=render_cell returns rendered HTML from render_cell plugin hook on row pages"""
from datasette import hookimpl
from datasette.app import Datasette
class TestRenderCellPlugin:
__name__ = "TestRenderCellPlugin"
@hookimpl
def render_cell(self, value, column, table, database):
# Only modify cells in our test table
if table == "test_render" and column == "name":
return f"<strong>{value}</strong>"
return None
ds = Datasette(memory=True)
await ds.invoke_startup()
db = ds.add_memory_database("test_row_render")
await db.execute_write(
"create table test_render (id integer primary key, name text)"
)
await db.execute_write("insert into test_render values (1, 'Alice')")
# Register our test plugin
ds.pm.register(TestRenderCellPlugin(), name="TestRenderCellPlugin")
try:
# Request row with _extra=render_cell
response = await ds.client.get(
"/test_row_render/test_render/1.json?_extra=render_cell"
)
assert response.status_code == 200
data = response.json()
# Verify the response structure
assert "render_cell" in data
assert "rows" in data
# render_cell should be a list with one row (since this is a row page)
# Only columns modified by plugins are included (sparse output)
render_cell = data["render_cell"]
assert len(render_cell) == 1
# The row: id=1, name='Alice'
# The 'name' column should be rendered by our plugin as <strong>Alice</strong>
assert render_cell[0]["name"] == "<strong>Alice</strong>"
# The 'id' column is not included since no plugin modified it
assert "id" not in render_cell[0]
# The regular rows should still contain raw values
assert data["rows"] == [{"id": 1, "name": "Alice"}]
finally:
ds.pm.unregister(name="TestRenderCellPlugin")
def test_databases_json(app_client_two_attached_databases_one_immutable):
response = app_client_two_attached_databases_one_immutable.get("/-/databases.json")
databases = response.json["databases"]
assert 2 == len(databases)
extra_database, fixtures_database = databases
assert "extra database" == extra_database["name"]
assert extra_database["hash"] is None
assert extra_database["is_mutable"] is True
assert extra_database["is_memory"] is False
assert "fixtures" == fixtures_database["name"]
assert fixtures_database["hash"] is not None
assert fixtures_database["is_mutable"] is False
assert fixtures_database["is_memory"] is False
@pytest.mark.asyncio
async def test_threads_json(ds_client):
ds_client.ds.root_enabled = True
try:
response = await ds_client.get("/-/threads.json", actor={"id": "root"})
finally:
ds_client.ds.root_enabled = False
expected_keys = {"ok", "threads", "num_threads"}
expected_keys.update({"tasks", "num_tasks"})
data = response.json()
assert set(data.keys()) == expected_keys
# Should be at least one _execute_writes thread for __INTERNAL__
thread_names = [thread["name"] for thread in data["threads"]]
assert "_execute_writes for database __INTERNAL__" in thread_names
@pytest.mark.asyncio
async def test_plugins_json(ds_client):
response = await ds_client.get("/-/plugins.json")
# Filter out TrackEventPlugin
actual_plugins = sorted(
[p for p in response.json() if p["name"] != "TrackEventPlugin"],
key=lambda p: p["name"],
)
assert EXPECTED_PLUGINS == actual_plugins
# Try with ?all=1
response = await ds_client.get("/-/plugins.json?all=1")
names = {p["name"] for p in response.json()}
assert names.issuperset(p["name"] for p in EXPECTED_PLUGINS)
assert names.issuperset(DEFAULT_PLUGINS)
@pytest.mark.asyncio
async def test_versions_json(ds_client):
response = await ds_client.get("/-/versions.json")
data = response.json()
assert "python" in data
assert "3.0" == data.get("asgi")
assert "version" in data["python"]
assert "full" in data["python"]
assert "datasette" in data
assert "version" in data["datasette"]
assert data["datasette"]["version"] == __version__
assert "sqlite" in data
assert "version" in data["sqlite"]
assert "fts_versions" in data["sqlite"]
assert "compile_options" in data["sqlite"]
# By default, the json1 extension is enabled in the SQLite
# provided by the `ubuntu-latest` github actions runner, and
# all versions of SQLite from 3.38.0 onwards
assert data["sqlite"]["extensions"]["json1"]
@pytest.mark.asyncio
async def test_actions_json(ds_client):
original_root_enabled = ds_client.ds.root_enabled
try:
ds_client.ds.root_enabled = True
response = await ds_client.get("/-/actions.json", actor={"id": "root"})
data = response.json()["actions"]
finally:
ds_client.ds.root_enabled = original_root_enabled
assert isinstance(data, list)
assert len(data) > 0
# Check structure of first action
action = data[0]
for key in (
"name",
"abbr",
"description",
"takes_parent",
"takes_child",
"resource_class",
"also_requires",
):
assert key in action
# Check that some expected actions exist
action_names = {a["name"] for a in data}
for expected_action in (
"view-instance",
"view-database",
"view-table",
"execute-sql",
):
assert expected_action in action_names
@pytest.mark.asyncio
async def test_settings_json(ds_client):
response = await ds_client.get("/-/settings.json")
assert response.json() == {
"ok": True,
"default_page_size": 50,
"default_facet_size": 30,
"default_allow_sql": True,
"facet_suggest_time_limit_ms": 200,
"facet_time_limit_ms": 200,
"max_returned_rows": 100,
"max_insert_rows": 100,
"max_post_body_bytes": 2 * 1024 * 1024,
"sql_time_limit_ms": 200,
"allow_download": True,
"allow_signed_tokens": True,
"max_signed_tokens_ttl": 0,
"allow_facet": True,
"suggest_facets": True,
"default_cache_ttl": 5,
"num_sql_threads": 1,
"cache_size_kb": 0,
"allow_csv_stream": True,
"max_csv_mb": 100,
"truncate_cells_html": 2048,
"force_https_urls": False,
"template_debug": False,
"base_url": "/",
}
test_json_columns_default_expected = [
{"intval": 1, "strval": "s", "floatval": 0.5, "jsonval": '{"foo": "bar"}'}
]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"extra_args,expected",
[
("", test_json_columns_default_expected),
("&_json=intval", test_json_columns_default_expected),
("&_json=strval", test_json_columns_default_expected),
("&_json=floatval", test_json_columns_default_expected),
(
"&_json=jsonval",
[{"intval": 1, "strval": "s", "floatval": 0.5, "jsonval": {"foo": "bar"}}],
),
],
)
async def test_json_columns(ds_client, extra_args, expected):
sql = """
select 1 as intval, "s" as strval, 0.5 as floatval,
'{"foo": "bar"}' as jsonval
"""
path = "/fixtures/-/query.json?" + urllib.parse.urlencode(
{"sql": sql, "_shape": "array"}
)
path += extra_args
response = await ds_client.get(
path,
)
assert response.json() == expected
def test_config_cache_size(app_client_larger_cache_size):
response = app_client_larger_cache_size.get("/fixtures/pragma_cache_size.json")
assert response.json["rows"] == [{"cache_size": -2500}]
def test_config_force_https_urls():
with make_app_client(settings={"force_https_urls": True}) as client:
response = client.get(
"/fixtures/facetable.json?_size=3&_facet=state&_extra=suggested_facets"
)
assert response.json["next_url"].startswith("https://")
assert response.json["facet_results"]["results"]["state"]["results"][0][
"toggle_url"
].startswith("https://")
assert response.json["suggested_facets"][0]["toggle_url"].startswith("https://")
# Also confirm that request.url and request.scheme are set correctly
response = client.get("/")
assert client.ds._last_request.url.startswith("https://")
assert client.ds._last_request.scheme == "https"
@pytest.mark.parametrize(
"path,status_code",
[
("/fixtures.db", 200),
("/fixtures.json", 200),
("/fixtures/no_primary_key.json", 200),
# A 400 invalid SQL query should still have the header:
("/fixtures/-/query.json?sql=select+blah", 400),
# Write APIs
("/fixtures/-/create", 405),
("/fixtures/facetable/-/insert", 405),
("/fixtures/facetable/-/drop", 405),
],
)
def test_cors(
app_client_with_cors,
app_client_two_attached_databases_one_immutable,
path,
status_code,
):
response = app_client_with_cors.get(
path,
)
assert response.status == status_code
assert response.headers["Access-Control-Allow-Origin"] == "*"
assert (
response.headers["Access-Control-Allow-Headers"]
== "Authorization, Content-Type"
)
assert response.headers["Access-Control-Expose-Headers"] == "Link"
assert (
response.headers["Access-Control-Allow-Methods"] == "GET, POST, HEAD, OPTIONS"
)
assert response.headers["Access-Control-Max-Age"] == "3600"
# Same request to app_client_two_attached_databases_one_immutable
# should not have those headers - I'm using that fixture because
# regular app_client doesn't have immutable fixtures.db which means
# the test for /fixtures.db returns a 403 error
response = app_client_two_attached_databases_one_immutable.get(
path,
)
assert response.status == status_code
assert "Access-Control-Allow-Origin" not in response.headers
assert "Access-Control-Allow-Headers" not in response.headers
assert "Access-Control-Expose-Headers" not in response.headers
assert "Access-Control-Allow-Methods" not in response.headers
assert "Access-Control-Max-Age" not in response.headers
def test_cors_query_redirect(app_client_with_cors):
# /db?sql= redirects to /db/-/query - the redirect itself needs CORS
# headers, otherwise browsers refuse to follow it cross-origin
response = app_client_with_cors.get(
"/fixtures?sql=select+1", follow_redirects=False
)
assert response.status == 302
assert response.headers["Location"] == "/fixtures/-/query?sql=select+1"
assert response.headers["Access-Control-Allow-Origin"] == "*"
@pytest.mark.parametrize(
"path",
(
"/",
".json",
"/searchable",
"/searchable.json",
"/searchable_view",
"/searchable_view.json",
),
)
def test_database_with_space_in_name(app_client_two_attached_databases, path):
response = app_client_two_attached_databases.get(
"/extra~20database" + path, follow_redirects=True
)
assert response.status == 200
def test_common_prefix_database_names(app_client_conflicting_database_names):
# https://github.com/simonw/datasette/issues/597
assert ["foo-bar", "foo", "fixtures"] == [
d["name"]
for d in app_client_conflicting_database_names.get("/-/databases.json").json[
"databases"
]
]
for db_name, path in (("foo", "/foo.json"), ("foo-bar", "/foo-bar.json")):
data = app_client_conflicting_database_names.get(path).json
assert db_name == data["database"]
def test_inspect_file_used_for_count(app_client_immutable_and_inspect_file):
response = app_client_immutable_and_inspect_file.get(
"/fixtures/sortable.json?_extra=count"
)
assert response.json["count"] == 100
@pytest.mark.asyncio
async def test_http_options_request(ds_client):
response = await ds_client.options("/fixtures")
assert response.status_code == 200
assert response.text == "ok"
@pytest.mark.asyncio
async def test_db_path(app_client):
# Needs app_client because needs file based database
db = app_client.ds.get_database()
path = pathlib.Path(db.path)
assert path.exists()
datasette = Datasette([path])
# Previously this broke if path was a pathlib.Path:
await datasette.refresh_schemas()
@pytest.mark.asyncio
async def test_hidden_sqlite_stat1_table():
ds = Datasette()
db = ds.add_memory_database("db")
await db.execute_write("create table normal (id integer primary key, name text)")
await db.execute_write("create index idx on normal (name)")
await db.execute_write("analyze")
data = (await ds.client.get("/db.json?_show_hidden=1")).json()
tables = [(t["name"], t["hidden"]) for t in data["tables"]]
assert tables in (
[("normal", False), ("sqlite_stat1", True)],
[("normal", False), ("sqlite_stat1", True), ("sqlite_stat4", True)],
)
@pytest.mark.asyncio
async def test_hide_tables_starting_with_underscore():
ds = Datasette()
db = ds.add_memory_database("test_hide_tables_starting_with_underscore")
await db.execute_write("create table normal (id integer primary key, name text)")
await db.execute_write("create table _hidden (id integer primary key, name text)")
data = (
await ds.client.get(
"/test_hide_tables_starting_with_underscore.json?_show_hidden=1"
)
).json()
tables = [(t["name"], t["hidden"]) for t in data["tables"]]
assert tables == [("normal", False), ("_hidden", True)]
@pytest.mark.asyncio
@pytest.mark.parametrize("db_name", ("foo", r"fo%o", "f~/c.d"))
async def test_tilde_encoded_database_names(db_name):
ds = Datasette()
ds.add_memory_database(db_name)
response = await ds.client.get("/.json")
databases_by_name = {d["name"]: d for d in response.json()["databases"]}
assert db_name in databases_by_name
path = databases_by_name[db_name]["path"]
# And the JSON for that database
response2 = await ds.client.get(path + ".json")
assert response2.status_code == 200
@pytest.mark.asyncio
@pytest.mark.parametrize("table_name", ("[foo]", "foo]", "[foo]/bar"))
async def test_table_with_reserved_characters_in_name(table_name):
# Table names containing characters such as "]" that cannot be escaped
# using SQLite [bracket] quoting used to break schema introspection and
# the table page - https://github.com/simonw/datasette/issues/2431
ds = Datasette()
db = ds.add_memory_database("test_reserved_table_names")
await db.execute_write(
f"create table {escape_sqlite(table_name)} (id integer primary key, name text)"
)
await db.execute_write(
f"insert into {escape_sqlite(table_name)} (id, name) values (1, 'one')"
)
# Schema introspection (populate_schema_tables) must not crash:
db_response = await ds.client.get("/test_reserved_table_names.json")
assert db_response.status_code == 200
tables = {t["name"]: t for t in db_response.json()["tables"]}
assert tables[table_name]["count"] == 1
# And the table page itself must load and return the row:
table_response = await ds.client.get(
f"/test_reserved_table_names/{tilde_encode(table_name)}.json?_shape=array"
)
assert table_response.status_code == 200
assert table_response.json() == [{"id": 1, "name": "one"}]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"config,expected",
(
({}, {}),
({"plugins": {"datasette-foo": "bar"}}, {"plugins": {"datasette-foo": "bar"}}),
# Test redaction
(
{
"plugins": {
"datasette-auth": {"secret_key": "key"},
"datasette-foo": "bar",
"datasette-auth2": {"password": "password"},
"datasette-sentry": {
"dsn": "sentry:///foo",
},
}
},
{
"plugins": {
"datasette-auth": {"secret_key": "***"},
"datasette-foo": "bar",
"datasette-auth2": {"password": "***"},
"datasette-sentry": {"dsn": "***"},
}
},
),
),
)
async def test_config_json(config, expected):
"/-/config.json should return redacted configuration"
ds = Datasette(config=config)
response = await ds.client.get("/-/config.json")
assert response.json() == {"ok": True, **expected}
@pytest.mark.asyncio
@pytest.mark.skip(reason="rm?")
@pytest.mark.parametrize(
"metadata,expected_config,expected_metadata",
(
({}, {}, {}),
(
# Metadata input
{
"title": "Datasette Fixtures",
"databases": {
"fixtures": {
"tables": {
"sortable": {
"sortable_columns": [
"sortable",
"sortable_with_nulls",
"sortable_with_nulls_2",
"text",
],
},
"no_primary_key": {"sortable_columns": [], "hidden": True},
"primary_key_multiple_columns_explicit_label": {
"label_column": "content2"
},
"simple_view": {"sortable_columns": ["content"]},
"searchable_view_configured_by_metadata": {
"fts_table": "searchable_fts",
"fts_pk": "pk",
},
"roadside_attractions": {
"columns": {
"name": "The name of the attraction",
"address": "The street address for the attraction",
}
},
"attraction_characteristic": {"sort_desc": "pk"},
"facet_cities": {"sort": "name"},
"paginated_view": {"size": 25},
},
}
},
},
# Should produce a config with just the table configuration keys
{
"databases": {
"fixtures": {
"tables": {
"sortable": {
"sortable_columns": [
"sortable",
"sortable_with_nulls",
"sortable_with_nulls_2",
"text",
]
},
# These one get redacted:
"no_primary_key": "***",
"primary_key_multiple_columns_explicit_label": "***",
"simple_view": {"sortable_columns": ["content"]},
"searchable_view_configured_by_metadata": {
"fts_table": "searchable_fts",
"fts_pk": "pk",
},
"attraction_characteristic": {"sort_desc": "pk"},
"facet_cities": {"sort": "name"},
"paginated_view": {"size": 25},
}
}
}
},
# And metadata with everything else
{
"title": "Datasette Fixtures",
"databases": {
"fixtures": {
"tables": {
"roadside_attractions": {
"columns": {
"name": "The name of the attraction",
"address": "The street address for the attraction",
}
},
}
}
},
},
),
),
)
async def test_upgrade_metadata(metadata, expected_config, expected_metadata):
ds = Datasette(metadata=metadata)
response = await ds.client.get("/-/config.json")
assert response.json() == {"ok": True, **expected_config}
response2 = await ds.client.get("/-/metadata.json")
assert response2.json() == expected_metadata
class Either:
def __init__(self, a, b):
self.a = a
self.b = b
def __eq__(self, other):
return other == self.a or other == self.b