This commit is contained in:
Alex Garcia 2026-07-10 12:11:47 -07:00 committed by GitHub
commit 26e6cfd4f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 563 additions and 18 deletions

View file

@ -85,6 +85,7 @@ from .views.special import (
JumpView,
InstanceSchemaView,
DatabaseSchemaView,
DatabaseEditorSchemaView,
TableSchemaView,
)
from .views.table import (
@ -2716,6 +2717,10 @@ class Datasette:
DatabaseSchemaView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/schema(\.(?P<format>json|md))?$",
)
add_route(
DatabaseEditorSchemaView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/editor-schema\.json$",
)
add_route(
QueryParametersView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/query/parameters$",

File diff suppressed because one or more lines are too long

View file

@ -1,4 +1,5 @@
import { EditorView, basicSetup } from "codemirror";
import { Compartment } from "@codemirror/state";
import { keymap } from "@codemirror/view";
import { sql, SQLDialect } from "@codemirror/lang-sql";
@ -17,10 +18,22 @@ const SQLite = SQLDialect.define({
caseInsensitiveIdentifiers: true,
});
// Builds the sql() extension from a {schema, defaultTable, defaultSchema} conf object
function sqlExtension(conf) {
return sql({
dialect: SQLite,
schema: conf.schema,
defaultTable: conf.defaultTable,
defaultSchema: conf.defaultSchema,
});
}
// Utility function from https://codemirror.net/docs/migration/
export function editorFromTextArea(textarea, conf = {}) {
// This could also be configured with a set of tables and columns for better autocomplete:
// https://github.com/codemirror/lang-sql#user-content-sqlconfig.tables
// Wraps the sql() extension so it can be swapped out later via view.updateSchema()
// https://codemirror.net/examples/config/#dynamic-configuration
let sqlCompartment = new Compartment();
let view = new EditorView({
doc: textarea.value,
extensions: [
@ -46,15 +59,17 @@ export function editorFromTextArea(textarea, conf = {}) {
// Meta-Enter from running
basicSetup,
EditorView.lineWrapping,
sql({
dialect: SQLite,
schema: conf.schema,
defaultTable: conf.defaultTable,
defaultSchema: conf.defaultSchema,
}),
sqlCompartment.of(sqlExtension(conf)),
],
});
// Allows callers (and plugins) to update the schema/defaultTable/defaultSchema
// used for autocomplete after the editor has already been created.
view.updateSchema = (conf2) =>
view.dispatch({
effects: sqlCompartment.reconfigure(sqlExtension(conf2)),
});
// Idea taken from https://discuss.codemirror.net/t/resizing-codemirror-6/3265.
// Using CSS resize: both and scheduling a measurement when the element changes.
let editorDOM = view.contentDOM.closest(".cm-editor");

View file

@ -15,6 +15,9 @@
if (sqlInput) {
var editor = (window.editor = cm.editorFromTextArea(sqlInput, {
schema,
{% if default_table is defined and default_table %}
defaultTable: {{ default_table|tojson }},
{% endif %}
}));
if (sqlFormat) {
sqlFormat.addEventListener("click", (ev) => {

View file

@ -126,7 +126,7 @@
{% endif %}
{% if query.sql and allow_execute_sql %}
<p><a class="not-underlined" title="{{ query.sql }}" href="{{ urls.database(database) }}?{{ {'sql': query.sql}|urlencode|safe }}{% if query.params %}&amp;{{ query.params|urlencode|safe }}{% endif %}">&#x270e; <span class="underlined">View and edit SQL</span></a></p>
<p><a class="not-underlined" title="{{ query.sql }}" href="{{ urls.database(database) }}?{{ {'sql': query.sql, '_table': table}|urlencode|safe }}{% if query.params %}&amp;{{ query.params|urlencode|safe }}{% endif %}">&#x270e; <span class="underlined">View and edit SQL</span></a></p>
{% endif %}
<p class="export-links">This data as {% for name, url in renderers.items() %}<a href="{{ url }}">{{ name }}</a>{{ ", " if not loop.last }}{% endfor %}{% if display_rows %}, <a href="{{ url_csv }}">CSV</a> (<a href="#export">advanced</a>){% endif %}</p>

View file

@ -36,7 +36,10 @@ from datasette.utils.asgi import AsgiFileDownload, NotFound, Response, Forbidden
from datasette.plugins import pm
from .base import DatasetteError, View, stream_csv
from .query_helpers import _ensure_stored_query_execution_permissions, _table_columns
from .query_helpers import (
_ensure_stored_query_execution_permissions,
_editor_schema,
)
from .table_extras import (
QueryExtraContext,
resolve_query_extras,
@ -201,7 +204,7 @@ class DatabaseView(View):
"queries_count": queries_count,
"allow_execute_sql": allow_execute_sql,
"table_columns": (
await _table_columns(datasette, database) if allow_execute_sql else {}
await _editor_schema(datasette, database) if allow_execute_sql else {}
),
"metadata": await datasette.get_database_metadata(database),
}
@ -242,7 +245,7 @@ class DatabaseView(View):
queries_count=queries_count,
allow_execute_sql=allow_execute_sql,
table_columns=(
await _table_columns(datasette, database)
await _editor_schema(datasette, database)
if allow_execute_sql
else {}
),
@ -454,6 +457,11 @@ class QueryContext(Context):
"help": "Dictionary mapping table names to lists of column names, used to power SQL autocomplete."
}
)
default_table: str = field(
metadata={
"help": "Name of the focal table for this query, if any - set when the query page was reached from a table-scoped context (such as the table page's 'View and edit SQL' link) so the SQL editor can complete that table's columns unprefixed. ``None`` otherwise, including for stored/canned queries."
}
)
alternate_url_json: str = field(
metadata={"help": "URL for alternate JSON version of this page"}
)
@ -713,6 +721,15 @@ class QueryView(View):
# Create lookup dict for quick access
allowed_dict = {r.child: r for r in allowed_tables_page.resources}
# If the request carries a ?_table= pointing at a real (visible) table
# or view in this database, treat this as a table-scoped query - e.g.
# arriving here via the "View and edit SQL" link on a table page - so
# the SQL editor can offer that table's columns unprefixed. Anything
# else (including stored/canned queries, which may reference more
# than one table) leaves this as None.
requested_table = request.args.get("_table")
default_table = requested_table if requested_table in allowed_dict else None
# Are we a stored query?
stored_query = None
stored_query_write = False
@ -1094,10 +1111,11 @@ class QueryView(View):
datasette, database, request, rows, columns
),
table_columns=(
await _table_columns(datasette, database)
await _editor_schema(datasette, database)
if allow_execute_sql
else {}
),
default_table=default_table,
columns=columns,
renderers=renderers,
url_csv=datasette.urls.path(

View file

@ -21,6 +21,7 @@ from .query_helpers import (
_inserted_row_url,
_json_or_form_payload,
_prepare_execute_write,
_editor_schema,
_table_columns,
_wants_json,
)
@ -266,6 +267,7 @@ class ExecuteWriteView(BaseView):
write_template_tables = await _write_template_tables(
self.ds, db, table_columns, hidden_table_names, request.actor
)
editor_schema = await _editor_schema(self.ds, db.name)
write_template_operations = _write_template_operations(write_template_tables)
write_create_table_template_sql = await _create_table_template_sql(
self.ds, db, request.actor
@ -328,7 +330,7 @@ class ExecuteWriteView(BaseView):
"sql_parameter_name_prefix": SQL_PARAMETER_FORM_PREFIX,
"execute_disabled": bool(execute_disabled_reason),
"execute_disabled_reason": execute_disabled_reason,
"table_columns": table_columns,
"table_columns": editor_schema,
"write_template_tables": write_template_tables,
"write_template_operations": write_template_operations,
"write_create_table_template_sql": write_create_table_template_sql,

View file

@ -634,3 +634,92 @@ async def _table_columns(datasette, database_name):
for view_name in await db.view_names():
table_columns[view_name] = []
return table_columns
def _column_completion(name, type_):
# A @codemirror/lang-sql Completion object for a single column. boost keeps
# columns ranked above bare SQL keywords in the autocomplete popup.
completion = {
"label": name,
"type": "property",
"boost": 10,
}
if type_:
completion["detail"] = type_
return completion
async def _schema_tables(datasette, database_name, *, include_hidden=True):
"""
Neutral introspection of a database's tables and views for SQL editors.
Returns an ordered list of dicts, one per table or view::
{"name": str, "view": bool,
"columns": [{"name": str, "type": str}, ...]}
``type`` is the SQLite declared column type (empty string when the column
has no declared type). Regular-table columns come from the internal
``catalog_columns`` catalog; views are absent from that catalog so their
columns are read directly via PRAGMA table_xinfo. Hidden tables (FTS shadow
tables and the like) are excluded unless ``include_hidden`` is True. This is
the shared, serialization-agnostic source for both ``_editor_schema`` (which
maps it to lang-sql Completion objects) and the ``/-/editor-schema.json``
endpoint (which emits it directly).
"""
internal_db = datasette.get_internal_database()
result = await internal_db.execute(
"select table_name, name, type from catalog_columns where database_name = ?",
[database_name],
)
table_columns = {}
for row in result.rows:
table_columns.setdefault(row["table_name"], []).append(
{"name": row["name"], "type": row["type"]}
)
db = datasette.get_database(database_name)
hidden = set() if include_hidden else set(await db.hidden_table_names())
tables = []
for table_name, columns in table_columns.items():
if table_name in hidden:
continue
tables.append({"name": table_name, "view": False, "columns": columns})
# Views are not represented in catalog_columns, so pull their real columns
# directly (PRAGMA table_xinfo works against views too).
for view_name in await db.view_names():
columns = [
{"name": column.name, "type": column.type}
for column in await db.table_column_details(view_name)
]
tables.append({"name": view_name, "view": True, "columns": columns})
return tables
async def _editor_schema(datasette, database_name):
"""
Build a lang-sql SQLNamespace for the CodeMirror SQL editor autocomplete.
Returns a dict keyed by table or view name. Table values are lists of
Completion objects (one per column, carrying the column's SQLite type as
``detail``). Views are wrapped in a ``{"self": Completion, "children": [...]}``
container so the popup can label them as views while still completing their
real columns. See @codemirror/lang-sql >= 6.6 SQLNamespace / Completion.
"""
schema = {}
for table in await _schema_tables(datasette, database_name, include_hidden=True):
completions = [
_column_completion(column["name"], column["type"])
for column in table["columns"]
]
if table["view"]:
schema[table["name"]] = {
"self": {
"label": table["name"],
"type": "class",
"detail": "view",
},
"children": completions,
}
else:
schema[table["name"]] = completions
return schema

View file

@ -1345,6 +1345,59 @@ class DatabaseSchemaView(SchemaBaseView):
return await self.format_html_response(request, schemas)
class DatabaseEditorSchemaView(BaseView):
"""
JSON introspection of a database's tables, views and columns shaped for SQL
editor autocomplete consumers (the CodeMirror ``<datasette-sql-editor>``
component and external clients such as datasette-paper).
Distinct from :class:`DatabaseSchemaView` (``/<db>/-/schema.json``), which
returns the raw DDL as a SQL string gated on ``view-database`` alone. This
endpoint returns a neutral structured payload and is gated on both
``view-database`` and ``execute-sql`` the same permissions as the inline
editor schema handed to the SQL query page.
"""
name = "database_editor_schema"
has_json_alternate = False
async def get(self, request):
from .query_helpers import _schema_tables
database_name = request.url_vars["database"]
# view-database is checked first so actors without it cannot
# distinguish an existing database from a missing one, and a denied
# request only ever leaks the permission action name, never table names.
await self.ds.ensure_permission(
action="view-database",
resource=DatabaseResource(database=database_name),
actor=request.actor,
)
if database_name not in self.ds.databases:
headers = {}
if self.ds.cors:
add_cors_headers(headers)
return Response.json(
error_body("Database not found", 404), status=404, headers=headers
)
await self.ds.ensure_permission(
action="execute-sql",
resource=DatabaseResource(database=database_name),
actor=request.actor,
)
await self.ds.refresh_schemas()
tables = await _schema_tables(self.ds, database_name, include_hidden=False)
headers = {}
if self.ds.cors:
add_cors_headers(headers)
return Response.json(
{"database": database_name, "tables": tables}, headers=headers
)
class TableSchemaView(SchemaBaseView):
"""
Displays schema for a specific table.

View file

@ -152,6 +152,62 @@ Values for named SQL parameters can be provided as additional query string param
The response uses the same default representation described above.
.. _json_api_editor_schema:
.. _DatabaseEditorSchemaView:
Schema for SQL editors
----------------------
The ``/-/editor-schema.json`` endpoint returns a machine-readable description of
a database's tables, views and columns, shaped for SQL editor autocomplete. It
powers Datasette's own CodeMirror SQL editor and is available for external
consumers such as embeddable editor components.
::
GET /<database>/-/editor-schema.json
Access requires both the :ref:`actions_view_database` and
:ref:`actions_execute_sql` permissions for the database - the same gate as the
inline editor schema on the SQL query page. A request that fails either check
receives a ``403`` JSON error that does not reveal any table or column names.
The response is a neutral structure - a ``database`` name and a list of
``tables``, each with a ``view`` flag (``true`` for SQL views) and a list of
``columns`` carrying the SQLite declared ``type`` (an empty string when the
column has no declared type):
.. code-block:: json
{
"database": "fixtures",
"tables": [
{
"name": "facetable",
"view": false,
"columns": [
{"name": "pk", "type": "INTEGER"},
{"name": "state", "type": "TEXT"}
]
},
{
"name": "paginated_view",
"view": true,
"columns": [
{"name": "content", "type": "TEXT"}
]
}
]
}
Hidden tables - such as the shadow tables that back SQLite full-text search -
are excluded from the response.
This endpoint is distinct from the :ref:`database schema endpoint <DatabaseSchemaView>`
at ``/<database>/-/schema.json``, which returns the raw ``CREATE`` statements as
a SQL string.
.. _json_api_shapes:
Different shapes

View file

@ -168,6 +168,9 @@ The page for arbitrary SQL queries (/database/-/query?sql=...) and stored querie
``db_is_immutable`` - ``bool``
Boolean indicating if this database is immutable
``default_table`` - ``str``
Name of the focal table for this query, if any - set when the query page was reached from a table-scoped context (such as the table page's 'View and edit SQL' link) so the SQL editor can complete that table's columns unprefixed. ``None`` otherwise, including for stored/canned queries.
``display_rows`` - ``list``
List of result rows formatted for HTML display. Each row is a list of rendered cell values in the same order as ``columns``.

1
package-lock.json generated
View file

@ -7,6 +7,7 @@
"name": "datasette",
"dependencies": {
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/state": "^6.7.1",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-terser": "^0.1.0",
"codemirror": "^6.0.2",

View file

@ -11,6 +11,7 @@
},
"dependencies": {
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/state": "^6.7.1",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-terser": "^0.1.0",
"codemirror": "^6.0.2",

View file

@ -284,6 +284,53 @@ async def test_query_page_with_no_sql(ds_client):
assert 'class="rows-and-columns"' not in response.text
@pytest.mark.asyncio
async def test_table_page_view_and_edit_sql_link_carries_table(ds_client):
# The table page's "View and edit SQL" link should point at the query
# page with a &_table= param identifying the focal table, so the SQL
# editor can offer that table's columns unprefixed.
response = await ds_client.get("/fixtures/facetable")
assert response.status_code == 200
soup = Soup(response.content, "html.parser")
link = soup.find("span", string="View and edit SQL").find_parent("a")
assert link is not None
assert "_table=facetable" in link["href"]
@pytest.mark.asyncio
async def test_query_page_default_table_from_table_scoped_link(ds_client):
# Following the table page's edit-SQL link should result in a query page
# whose SQL editor is initialized with defaultTable set to that table.
table_response = await ds_client.get("/fixtures/facetable")
soup = Soup(table_response.content, "html.parser")
href = soup.find("span", string="View and edit SQL").find_parent("a")["href"]
response = await ds_client.get(href, follow_redirects=True)
assert response.status_code == 200
assert 'defaultTable: "facetable"' in response.text
@pytest.mark.asyncio
async def test_query_page_no_default_table_without_table_scope(ds_client):
# The plain database query page (no focal table) should not set
# defaultTable at all.
response = await ds_client.get("/fixtures/-/query?sql=select+1")
assert response.status_code == 200
assert "defaultTable" not in response.text
@pytest.mark.asyncio
async def test_query_page_ignores_invalid_table_param(ds_client):
# A ?_table= value that isn't a real table/view in this database should
# not be reflected back into the page - and should not break execution
# of the query itself (leading-underscore params are not treated as SQL
# bind parameters unless they appear as :name in the SQL).
response = await ds_client.get(
"/fixtures/-/query?sql=select+1&_table=not_a_real_table"
)
assert response.status_code == 200
assert "defaultTable" not in response.text
@pytest.mark.asyncio
async def test_query_csv_with_no_sql_is_400(ds_client):
# https://github.com/simonw/datasette/issues/2743

View file

@ -295,13 +295,24 @@ def test_execute_sql(config):
# Extract the schema= portion of the JavaScript
schema_json = schema_re.search(response_text).group(1)
schema = json.loads(schema_json)
assert set(schema["attraction_characteristic"]) == {"name", "pk"}
assert schema["paginated_view"] == []
assert {c["label"] for c in schema["attraction_characteristic"]} == {
"name",
"pk",
}
# Views are self/children containers carrying their real columns
assert schema["paginated_view"]["self"]["detail"] == "view"
assert {c["label"] for c in schema["paginated_view"]["children"]} == {
"content",
"content_extra",
}
assert form_fragment in response_text
query_response = client.get("/fixtures/-/query?sql=select+1", cookies=cookies)
assert query_response.status == 200
schema2 = json.loads(schema_re.search(query_response.text).group(1))
assert set(schema2["attraction_characteristic"]) == {"name", "pk"}
assert {c["label"] for c in schema2["attraction_characteristic"]} == {
"name",
"pk",
}
assert (
client.get("/fixtures/facet_cities?_where=id=3", cookies=cookies).status
== 200

View file

@ -94,6 +94,83 @@ async def test_queries_internal_table_schema():
]
@pytest.mark.asyncio
async def test_editor_schema_rich_completions():
from datasette.fixtures import TABLES
from datasette.views.query_helpers import _editor_schema
ds = Datasette(memory=True)
db = ds.add_memory_database("editor_schema")
await ds.invoke_startup()
await db.execute_write_script(TABLES)
await ds.refresh_schemas()
schema = await _editor_schema(ds, "editor_schema")
# Tables are plain lists of Completion objects carrying type + boost
attractions = schema["roadside_attractions"]
assert isinstance(attractions, list)
pk_completion = next(c for c in attractions if c["label"] == "pk")
assert pk_completion == {
"label": "pk",
"type": "property",
"boost": 10,
"detail": "INTEGER",
}
# Every column completion is boosted above bare keywords
assert all(c["boost"] == 10 and c["type"] == "property" for c in attractions)
# Views are wrapped in a self/children container labelled as a view
view = schema["paginated_view"]
assert view["self"] == {
"label": "paginated_view",
"type": "class",
"detail": "view",
}
child_labels = [c["label"] for c in view["children"]]
assert child_labels == ["content", "content_extra"]
# Column type inherited from the underlying table flows through as detail;
# the computed expression column has no declared type so carries no detail
content = next(c for c in view["children"] if c["label"] == "content")
assert content["detail"] == "TEXT"
content_extra = next(c for c in view["children"] if c["label"] == "content_extra")
assert "detail" not in content_extra
# Whole payload must be JSON-serializable
json.dumps(schema)
@pytest.mark.asyncio
async def test_database_page_editor_schema_permission_gated():
import secrets
from datasette.database import Database
from datasette.fixtures import TABLES
async def schema_for(datasette):
# Unique memory name so parallel instances don't share a backing DB
name = f"editor_gated_{secrets.token_hex(8)}"
db = datasette.add_database(
Database(datasette, memory_name=name), name="editor_gated"
)
await datasette.invoke_startup()
await db.execute_write_script(TABLES)
await datasette.refresh_schemas()
response = await datasette.client.get("/editor_gated.json")
assert response.status_code == 200
return response.json()["table_columns"]
# execute-sql allowed (default): rich schema is emitted
allowed = await schema_for(Datasette(memory=True))
assert isinstance(allowed["roadside_attractions"], list)
assert allowed["paginated_view"]["self"]["detail"] == "view"
# execute-sql denied: no schema leak, empty dict
denied = await schema_for(
Datasette(memory=True, settings={"default_allow_sql": False})
)
assert denied == {}
@pytest.mark.asyncio
async def test_add_get_and_remove_query():
ds = Datasette(memory=True)

View file

@ -1,3 +1,5 @@
import json
import pytest
import pytest_asyncio
from datasette.app import Datasette
@ -245,3 +247,165 @@ async def test_table_not_exists(schema_ds):
response = await schema_ds.client.get("/schema_public_db/nonexistent/-/schema.md")
assert response.status_code == 404
assert "not found" in response.text.lower()
# ---------------------------------------------------------------------------
# /<database>/-/editor-schema.json — neutral structured schema for SQL editors
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture(scope="module")
async def editor_schema_ds():
"""Datasette instance exercising the editor-schema endpoint.
- public db: tables + a view + an FTS table (hidden shadow tables)
- private db: gated behind view-database (allow root only)
- noexec db: view-database allowed for anyone, execute-sql denied
"""
ds = Datasette(
config={
"databases": {
"editor_private_db": {"allow": {"id": "root"}},
"editor_noexec_db": {
# Everyone may view the database, but nobody may run SQL
"allow_sql": {"id": "root"},
},
}
}
)
public_db = ds.add_memory_database("editor_public_db")
await public_db.execute_write(
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)"
)
await public_db.execute_write(
"CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT, body TEXT)"
)
await public_db.execute_write(
"CREATE VIEW recent_posts AS SELECT id, title FROM posts ORDER BY id DESC"
)
# An FTS table produces hidden shadow tables (users_fts_data, etc.)
await public_db.execute_write(
"CREATE VIRTUAL TABLE users_fts USING fts5(name, content=users)"
)
private_db = ds.add_memory_database("editor_private_db")
await private_db.execute_write(
"CREATE TABLE secret_data (id INTEGER PRIMARY KEY, value TEXT)"
)
noexec_db = ds.add_memory_database("editor_noexec_db")
await noexec_db.execute_write(
"CREATE TABLE locked (id INTEGER PRIMARY KEY, value TEXT)"
)
await ds.invoke_startup()
await ds.refresh_schemas()
return ds
@pytest.mark.asyncio
async def test_editor_schema_allowed_shape(editor_schema_ds):
"""Authorized fetch returns tables, columns, types and views in the
documented neutral shape."""
response = await editor_schema_ds.client.get(
"/editor_public_db/-/editor-schema.json"
)
assert response.status_code == 200
data = response.json()
assert data["database"] == "editor_public_db"
assert isinstance(data["tables"], list)
by_name = {t["name"]: t for t in data["tables"]}
# Regular table with columns + declared types
users = by_name["users"]
assert users["view"] is False
assert users["columns"] == [
{"name": "id", "type": "INTEGER"},
{"name": "name", "type": "TEXT"},
]
posts = by_name["posts"]
assert posts["view"] is False
assert {c["name"] for c in posts["columns"]} == {"id", "title", "body"}
# View is flagged and carries its real columns
view = by_name["recent_posts"]
assert view["view"] is True
assert [c["name"] for c in view["columns"]] == ["id", "title"]
# Whole payload is JSON-serializable and every entry matches the shape
for table in data["tables"]:
assert set(table) == {"name", "view", "columns"}
for column in table["columns"]:
assert set(column) == {"name", "type"}
@pytest.mark.asyncio
async def test_editor_schema_excludes_hidden_tables(editor_schema_ds):
"""FTS shadow tables (hidden_table_names) must not appear."""
response = await editor_schema_ds.client.get(
"/editor_public_db/-/editor-schema.json"
)
assert response.status_code == 200
names = {t["name"] for t in response.json()["tables"]}
assert not any("_fts_" in name or name.endswith("_fts") for name in names), names
# Sanity: the visible objects are still there
assert {"users", "posts", "recent_posts"} <= names
@pytest.mark.asyncio
async def test_editor_schema_denied_view_database_403_no_leak(editor_schema_ds):
"""Anonymous user lacking view-database gets a 403 that leaks no names."""
response = await editor_schema_ds.client.get(
"/editor_private_db/-/editor-schema.json"
)
assert response.status_code == 403
body = response.text
assert "secret_data" not in body
data = response.json()
assert data["ok"] is False
assert "secret_data" not in json.dumps(data)
# The permitted actor can read it
response = await editor_schema_ds.client.get(
"/editor_private_db/-/editor-schema.json", actor={"id": "root"}
)
assert response.status_code == 200
names = {t["name"] for t in response.json()["tables"]}
assert "secret_data" in names
@pytest.mark.asyncio
async def test_editor_schema_denied_execute_sql_403_no_leak(editor_schema_ds):
"""A viewer who lacks execute-sql gets a 403 with no schema data."""
# Anonymous user may view editor_noexec_db but not run SQL against it
response = await editor_schema_ds.client.get(
"/editor_noexec_db/-/editor-schema.json"
)
assert response.status_code == 403
data = response.json()
assert data["ok"] is False
assert "tables" not in data
assert "locked" not in json.dumps(data)
# The actor granted execute-sql can read the schema
response = await editor_schema_ds.client.get(
"/editor_noexec_db/-/editor-schema.json", actor={"id": "root"}
)
assert response.status_code == 200
names = {t["name"] for t in response.json()["tables"]}
assert "locked" in names
@pytest.mark.asyncio
async def test_editor_schema_database_not_found(editor_schema_ds):
"""A non-existent database returns a 404 JSON error."""
response = await editor_schema_ds.client.get(
"/nonexistent_db/-/editor-schema.json"
)
assert response.status_code == 404
data = response.json()
assert data["ok"] is False
assert "not found" in data["error"].lower()