register_token_handler() plugin hook for custom API token backends (#2650)
Closes #2649
* Add register_token_handler plugin hook for pluggable token backends
Adds a new register_token_handler hook that allows plugins to provide
custom token creation and verification backends. This enables plugins
like datasette-oauth to issue tokens without depending on specific
backend plugins like datasette-auth-tokens.
Key changes:
- New datasette/tokens.py with TokenHandler base class and SignedTokenHandler
(the default signed-token implementation moved here)
- New register_token_handler hookspec in hookspecs.py
- Datasette.create_token() is now async and delegates to token handlers
- New Datasette.verify_token() method tries all handlers in sequence
- handler= parameter on create_token() to select a specific backend
- TokenHandler exported from datasette package for plugin use
- Fixed actor_from_request loop to await all coroutines (avoids warnings)
* Add documentation and hook test for register_token_handler
Fixes CI failures: the new hook needs a section in docs/plugin_hooks.rst
(checked by test_plugin_hooks_are_documented) and a test_hook_* function
in test_plugins.py (checked by test_plugin_hooks_have_tests).
* Register tokens module as separate default plugin
Instead of re-exporting hookimpls from default_permissions/__init__.py,
register datasette.default_permissions.tokens as its own DEFAULT_PLUGINS
entry. Cleaner and avoids confusing import-for-side-effect patterns.
* Replace restrict_x params with TokenRestrictions dataclass
Consolidates the three separate restrict_all, restrict_database, and
restrict_resource parameters into a single TokenRestrictions dataclass.
Cleaner API surface for both Datasette.create_token() and
TokenHandler.create_token().
Also clarifies docs re: default handler selection via pluggy ordering.
* Add builder methods to TokenRestrictions
Adds allow_all(), allow_database(), and allow_resource() methods that
return self for chaining. Callers no longer need to manipulate nested
dicts directly:
restrictions = (TokenRestrictions()
.allow_all("view-instance")
.allow_database("mydb", "create-table")
.allow_resource("mydb", "mytable", "insert-row"))
* docs: add 1.0a25 upgrade guide section for create_token() signature change
Ref: https://github.com/simonw/datasette/issues/2649#issuecomment-3962639393
* docs: note that create_token() is now async in upgrade guide
* docs: update internals, plugin_hooks, authentication for new token API
- internals.rst: new async create_token() signature with restrictions
and handler params, add TokenRestrictions reference docs
- plugin_hooks.rst: show full create_token signature in TokenHandler
example, note list returns and error cases
- authentication.rst: cross-reference TokenRestrictions from the
restrictions section
* style: apply black formatting to token handler files
* docs: fix RST heading underline length in internals.rst
* tests: add restrictions round-trip and expiration tests for token handler
Covers allow_database/allow_resource builders, _r payload encoding,
and token_expires in verified actors. Coverage 76% -> 90%.
* tests: add test for signed tokens disabled
* fix: add TokenRestrictions TYPE_CHECKING import to fix ruff F821
* docs: regenerate plugins.rst with cog
* docs: reformat code blocks in plugin_hooks.rst with blacken-docs
* docs: add await .verify_token() to internals.rst
* tests: rewrite register_token_handler test to use real plugin handler
Adds a HardcodedTokenHandler to the test plugins dir that creates
tokens like dstok_hardcoded_token_1. The test now exercises creating
tokens via the default handler (which is the plugin's hardcoded one),
by explicitly naming the hardcoded handler, and by explicitly naming
the signed handler -- then verifies each token round-trips correctly.
* tests: clarify test_token_handler_via_http tests the default signed handler
* fix: use handler="signed" explicitly where signed tokens are expected
The HardcodedTokenHandler in my_plugin.py gets globally registered,
so create_token() without a handler name picks it up as the default.
Fix the create-token view, CLI, and tests to explicitly request the
signed handler where they depend on signed token behavior.
* fix: use handler="signed" in test_create_table_permissions
https://claude.ai/code/session_013cQFiDQjYRrRBH2biFfKuS
2026-02-25 16:32:45 -08:00
|
|
|
"""
|
|
|
|
|
Tests for the register_token_handler plugin hook.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from datasette.app import Datasette
|
|
|
|
|
from datasette.hookspecs import hookimpl
|
|
|
|
|
from datasette.plugins import pm
|
|
|
|
|
from datasette.tokens import TokenHandler, TokenRestrictions, SignedTokenHandler
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def datasette():
|
|
|
|
|
return Datasette()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_default_signed_handler_registered(datasette):
|
|
|
|
|
"""The default SignedTokenHandler should be registered automatically."""
|
|
|
|
|
handlers = datasette._token_handlers()
|
|
|
|
|
assert len(handlers) >= 1
|
|
|
|
|
assert any(isinstance(h, SignedTokenHandler) for h in handlers)
|
|
|
|
|
assert any(h.name == "signed" for h in handlers)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_create_token_default(datasette):
|
|
|
|
|
"""create_token() with handler='signed' should create a signed token."""
|
|
|
|
|
token = await datasette.create_token("test_actor", handler="signed")
|
|
|
|
|
assert token.startswith("dstok_")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_create_token_with_restrictions(datasette):
|
|
|
|
|
"""create_token() should handle restriction parameters."""
|
|
|
|
|
token = await datasette.create_token(
|
|
|
|
|
"test_actor",
|
|
|
|
|
handler="signed",
|
|
|
|
|
expires_after=3600,
|
|
|
|
|
restrictions=TokenRestrictions().allow_all("view-instance"),
|
|
|
|
|
)
|
|
|
|
|
assert token.startswith("dstok_")
|
|
|
|
|
# Verify the token contains the expected data
|
|
|
|
|
decoded = datasette.unsign(token[len("dstok_") :], namespace="token")
|
|
|
|
|
assert decoded["a"] == "test_actor"
|
|
|
|
|
assert decoded["d"] == 3600
|
|
|
|
|
assert "_r" in decoded
|
|
|
|
|
assert "a" in decoded["_r"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_verify_token_default(datasette):
|
|
|
|
|
"""verify_token() should verify signed tokens."""
|
|
|
|
|
token = await datasette.create_token("test_actor", handler="signed")
|
|
|
|
|
actor = await datasette.verify_token(token)
|
|
|
|
|
assert actor is not None
|
|
|
|
|
assert actor["id"] == "test_actor"
|
|
|
|
|
assert actor["token"] == "dstok"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_verify_token_unknown_returns_none(datasette):
|
|
|
|
|
"""verify_token() should return None for unrecognized tokens."""
|
|
|
|
|
result = await datasette.verify_token("unknown_token_format_xyz")
|
|
|
|
|
assert result is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_verify_token_bad_signature_returns_none(datasette):
|
|
|
|
|
"""verify_token() should return None for tokens with bad signatures."""
|
|
|
|
|
result = await datasette.verify_token("dstok_tampered_data_here")
|
|
|
|
|
assert result is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_create_token_with_named_handler(datasette):
|
|
|
|
|
"""create_token(handler='signed') should select the signed handler."""
|
|
|
|
|
token = await datasette.create_token("test_actor", handler="signed")
|
|
|
|
|
assert token.startswith("dstok_")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_create_token_unknown_handler_raises(datasette):
|
|
|
|
|
"""create_token(handler='nonexistent') should raise ValueError."""
|
|
|
|
|
with pytest.raises(ValueError, match="Token handler 'nonexistent' not found"):
|
|
|
|
|
await datasette.create_token("test_actor", handler="nonexistent")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_custom_token_handler(datasette):
|
|
|
|
|
"""A custom token handler should be usable for both create and verify."""
|
|
|
|
|
|
|
|
|
|
class CustomHandler(TokenHandler):
|
|
|
|
|
name = "custom"
|
|
|
|
|
|
|
|
|
|
async def create_token(self, datasette, actor_id, **kwargs):
|
|
|
|
|
return f"custom_{actor_id}"
|
|
|
|
|
|
|
|
|
|
async def verify_token(self, datasette, token):
|
|
|
|
|
if token.startswith("custom_"):
|
|
|
|
|
return {"id": token[len("custom_") :], "token": "custom"}
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
class Plugin:
|
|
|
|
|
__name__ = "CustomTokenPlugin"
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
@hookimpl
|
|
|
|
|
def register_token_handler(datasette):
|
|
|
|
|
return CustomHandler()
|
|
|
|
|
|
|
|
|
|
pm.register(Plugin(), name="test_custom_handler")
|
|
|
|
|
try:
|
|
|
|
|
handlers = datasette._token_handlers()
|
|
|
|
|
assert any(h.name == "custom" for h in handlers)
|
|
|
|
|
|
|
|
|
|
# Create with custom handler
|
|
|
|
|
token = await datasette.create_token("alice", handler="custom")
|
|
|
|
|
assert token == "custom_alice"
|
|
|
|
|
|
|
|
|
|
# Verify custom token
|
|
|
|
|
actor = await datasette.verify_token("custom_alice")
|
|
|
|
|
assert actor is not None
|
|
|
|
|
assert actor["id"] == "alice"
|
|
|
|
|
assert actor["token"] == "custom"
|
|
|
|
|
|
|
|
|
|
# Signed tokens should still work
|
|
|
|
|
signed_token = await datasette.create_token("bob", handler="signed")
|
|
|
|
|
assert signed_token.startswith("dstok_")
|
|
|
|
|
actor = await datasette.verify_token(signed_token)
|
|
|
|
|
assert actor["id"] == "bob"
|
|
|
|
|
finally:
|
|
|
|
|
pm.unregister(name="test_custom_handler")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_verify_token_tries_all_handlers(datasette):
|
|
|
|
|
"""verify_token() should try each handler until one matches."""
|
|
|
|
|
|
|
|
|
|
class HandlerA(TokenHandler):
|
|
|
|
|
name = "handler_a"
|
|
|
|
|
|
|
|
|
|
async def create_token(self, datasette, actor_id, **kwargs):
|
|
|
|
|
return f"a_{actor_id}"
|
|
|
|
|
|
|
|
|
|
async def verify_token(self, datasette, token):
|
|
|
|
|
if token.startswith("a_"):
|
|
|
|
|
return {"id": token[2:], "token": "handler_a"}
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
class HandlerB(TokenHandler):
|
|
|
|
|
name = "handler_b"
|
|
|
|
|
|
|
|
|
|
async def create_token(self, datasette, actor_id, **kwargs):
|
|
|
|
|
return f"b_{actor_id}"
|
|
|
|
|
|
|
|
|
|
async def verify_token(self, datasette, token):
|
|
|
|
|
if token.startswith("b_"):
|
|
|
|
|
return {"id": token[2:], "token": "handler_b"}
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
class PluginA:
|
|
|
|
|
__name__ = "PluginA"
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
@hookimpl
|
|
|
|
|
def register_token_handler(datasette):
|
|
|
|
|
return HandlerA()
|
|
|
|
|
|
|
|
|
|
class PluginB:
|
|
|
|
|
__name__ = "PluginB"
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
@hookimpl
|
|
|
|
|
def register_token_handler(datasette):
|
|
|
|
|
return HandlerB()
|
|
|
|
|
|
|
|
|
|
pm.register(PluginA(), name="test_handler_a")
|
|
|
|
|
pm.register(PluginB(), name="test_handler_b")
|
|
|
|
|
try:
|
|
|
|
|
# Both handler tokens should verify
|
|
|
|
|
actor_a = await datasette.verify_token("a_alice")
|
|
|
|
|
assert actor_a is not None
|
|
|
|
|
assert actor_a["id"] == "alice"
|
|
|
|
|
assert actor_a["token"] == "handler_a"
|
|
|
|
|
|
|
|
|
|
actor_b = await datasette.verify_token("b_bob")
|
|
|
|
|
assert actor_b is not None
|
|
|
|
|
assert actor_b["id"] == "bob"
|
|
|
|
|
assert actor_b["token"] == "handler_b"
|
|
|
|
|
|
|
|
|
|
# Unknown token should return None
|
|
|
|
|
assert await datasette.verify_token("c_charlie") is None
|
|
|
|
|
finally:
|
|
|
|
|
pm.unregister(name="test_handler_a")
|
|
|
|
|
pm.unregister(name="test_handler_b")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_token_handler_via_http(datasette):
|
|
|
|
|
"""Default signed tokens should work through HTTP auth."""
|
|
|
|
|
token = await datasette.create_token("http_user", handler="signed")
|
|
|
|
|
response = await datasette.client.get(
|
|
|
|
|
"/-/actor.json",
|
|
|
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
|
|
|
)
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
actor = response.json()["actor"]
|
|
|
|
|
assert actor["id"] == "http_user"
|
|
|
|
|
assert actor["token"] == "dstok"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_custom_handler_via_http(datasette):
|
|
|
|
|
"""Custom handler tokens should work through HTTP auth."""
|
|
|
|
|
|
|
|
|
|
class CustomHandler(TokenHandler):
|
|
|
|
|
name = "custom_http"
|
|
|
|
|
|
|
|
|
|
async def create_token(self, datasette, actor_id, **kwargs):
|
|
|
|
|
return f"chttp_{actor_id}"
|
|
|
|
|
|
|
|
|
|
async def verify_token(self, datasette, token):
|
|
|
|
|
if token.startswith("chttp_"):
|
|
|
|
|
return {"id": token[len("chttp_") :], "token": "custom_http"}
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
class Plugin:
|
|
|
|
|
__name__ = "CustomHTTPPlugin"
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
@hookimpl
|
|
|
|
|
def register_token_handler(datasette):
|
|
|
|
|
return CustomHandler()
|
|
|
|
|
|
|
|
|
|
pm.register(Plugin(), name="test_custom_http")
|
|
|
|
|
try:
|
|
|
|
|
token = await datasette.create_token("web_user", handler="custom_http")
|
|
|
|
|
response = await datasette.client.get(
|
|
|
|
|
"/-/actor.json",
|
|
|
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
|
|
|
)
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
actor = response.json()["actor"]
|
|
|
|
|
assert actor["id"] == "web_user"
|
|
|
|
|
assert actor["token"] == "custom_http"
|
|
|
|
|
finally:
|
|
|
|
|
pm.unregister(name="test_custom_http")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_token_handler_base_class_raises():
|
|
|
|
|
"""TokenHandler base class methods should raise NotImplementedError."""
|
|
|
|
|
handler = TokenHandler()
|
|
|
|
|
ds = Datasette()
|
|
|
|
|
with pytest.raises(NotImplementedError):
|
|
|
|
|
await handler.create_token(ds, "test")
|
|
|
|
|
with pytest.raises(NotImplementedError):
|
|
|
|
|
await handler.verify_token(ds, "test")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_restrictions_round_trip(datasette):
|
|
|
|
|
"""Tokens with database/resource restrictions should round-trip correctly."""
|
|
|
|
|
restrictions = (
|
|
|
|
|
TokenRestrictions()
|
|
|
|
|
.allow_all("view-instance")
|
|
|
|
|
.allow_database("docs", "view-query")
|
|
|
|
|
.allow_resource("docs", "attachments", "insert-row")
|
|
|
|
|
)
|
|
|
|
|
token = await datasette.create_token(
|
|
|
|
|
"test_actor", handler="signed", restrictions=restrictions
|
|
|
|
|
)
|
|
|
|
|
actor = await datasette.verify_token(token)
|
|
|
|
|
assert actor is not None
|
|
|
|
|
assert actor["id"] == "test_actor"
|
|
|
|
|
assert actor["_r"]["a"] == ["view-instance"]
|
|
|
|
|
assert actor["_r"]["d"] == {"docs": ["view-query"]}
|
|
|
|
|
assert actor["_r"]["r"] == {"docs": {"attachments": ["insert-row"]}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_expires_after_round_trip(datasette):
|
|
|
|
|
"""Tokens with expires_after should include token_expires in the actor."""
|
|
|
|
|
token = await datasette.create_token(
|
|
|
|
|
"test_actor", handler="signed", expires_after=3600
|
|
|
|
|
)
|
|
|
|
|
actor = await datasette.verify_token(token)
|
|
|
|
|
assert actor is not None
|
|
|
|
|
assert actor["id"] == "test_actor"
|
|
|
|
|
assert "token_expires" in actor
|
|
|
|
|
|
|
|
|
|
|
2026-04-17 08:44:43 -07:00
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"build_restrictions,expected",
|
|
|
|
|
[
|
|
|
|
|
(lambda r: r, None),
|
|
|
|
|
(lambda r: r.allow_all("view-instance"), {"a": ["vi"]}),
|
|
|
|
|
(
|
|
|
|
|
lambda r: r.allow_database("docs", "view-query"),
|
|
|
|
|
{"d": {"docs": ["vq"]}},
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
lambda r: r.allow_resource("docs", "attachments", "insert-row"),
|
|
|
|
|
{"r": {"docs": {"attachments": ["ir"]}}},
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
lambda r: r.allow_all("view-instance")
|
|
|
|
|
.allow_database("docs", "view-query")
|
|
|
|
|
.allow_resource("docs", "attachments", "insert-row"),
|
|
|
|
|
{
|
|
|
|
|
"a": ["vi"],
|
|
|
|
|
"d": {"docs": ["vq"]},
|
|
|
|
|
"r": {"docs": {"attachments": ["ir"]}},
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
lambda r: r.allow_all("not-a-real-action"),
|
|
|
|
|
{"a": ["not-a-real-action"]},
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
ids=["empty", "all", "database", "resource", "combined", "unknown_action"],
|
|
|
|
|
)
|
|
|
|
|
async def test_token_restrictions_abbreviated(datasette, build_restrictions, expected):
|
|
|
|
|
await datasette.invoke_startup()
|
|
|
|
|
restrictions = build_restrictions(TokenRestrictions())
|
|
|
|
|
assert restrictions.abbreviated(datasette) == expected
|
|
|
|
|
|
|
|
|
|
|
register_token_handler() plugin hook for custom API token backends (#2650)
Closes #2649
* Add register_token_handler plugin hook for pluggable token backends
Adds a new register_token_handler hook that allows plugins to provide
custom token creation and verification backends. This enables plugins
like datasette-oauth to issue tokens without depending on specific
backend plugins like datasette-auth-tokens.
Key changes:
- New datasette/tokens.py with TokenHandler base class and SignedTokenHandler
(the default signed-token implementation moved here)
- New register_token_handler hookspec in hookspecs.py
- Datasette.create_token() is now async and delegates to token handlers
- New Datasette.verify_token() method tries all handlers in sequence
- handler= parameter on create_token() to select a specific backend
- TokenHandler exported from datasette package for plugin use
- Fixed actor_from_request loop to await all coroutines (avoids warnings)
* Add documentation and hook test for register_token_handler
Fixes CI failures: the new hook needs a section in docs/plugin_hooks.rst
(checked by test_plugin_hooks_are_documented) and a test_hook_* function
in test_plugins.py (checked by test_plugin_hooks_have_tests).
* Register tokens module as separate default plugin
Instead of re-exporting hookimpls from default_permissions/__init__.py,
register datasette.default_permissions.tokens as its own DEFAULT_PLUGINS
entry. Cleaner and avoids confusing import-for-side-effect patterns.
* Replace restrict_x params with TokenRestrictions dataclass
Consolidates the three separate restrict_all, restrict_database, and
restrict_resource parameters into a single TokenRestrictions dataclass.
Cleaner API surface for both Datasette.create_token() and
TokenHandler.create_token().
Also clarifies docs re: default handler selection via pluggy ordering.
* Add builder methods to TokenRestrictions
Adds allow_all(), allow_database(), and allow_resource() methods that
return self for chaining. Callers no longer need to manipulate nested
dicts directly:
restrictions = (TokenRestrictions()
.allow_all("view-instance")
.allow_database("mydb", "create-table")
.allow_resource("mydb", "mytable", "insert-row"))
* docs: add 1.0a25 upgrade guide section for create_token() signature change
Ref: https://github.com/simonw/datasette/issues/2649#issuecomment-3962639393
* docs: note that create_token() is now async in upgrade guide
* docs: update internals, plugin_hooks, authentication for new token API
- internals.rst: new async create_token() signature with restrictions
and handler params, add TokenRestrictions reference docs
- plugin_hooks.rst: show full create_token signature in TokenHandler
example, note list returns and error cases
- authentication.rst: cross-reference TokenRestrictions from the
restrictions section
* style: apply black formatting to token handler files
* docs: fix RST heading underline length in internals.rst
* tests: add restrictions round-trip and expiration tests for token handler
Covers allow_database/allow_resource builders, _r payload encoding,
and token_expires in verified actors. Coverage 76% -> 90%.
* tests: add test for signed tokens disabled
* fix: add TokenRestrictions TYPE_CHECKING import to fix ruff F821
* docs: regenerate plugins.rst with cog
* docs: reformat code blocks in plugin_hooks.rst with blacken-docs
* docs: add await .verify_token() to internals.rst
* tests: rewrite register_token_handler test to use real plugin handler
Adds a HardcodedTokenHandler to the test plugins dir that creates
tokens like dstok_hardcoded_token_1. The test now exercises creating
tokens via the default handler (which is the plugin's hardcoded one),
by explicitly naming the hardcoded handler, and by explicitly naming
the signed handler -- then verifies each token round-trips correctly.
* tests: clarify test_token_handler_via_http tests the default signed handler
* fix: use handler="signed" explicitly where signed tokens are expected
The HardcodedTokenHandler in my_plugin.py gets globally registered,
so create_token() without a handler name picks it up as the default.
Fix the create-token view, CLI, and tests to explicitly request the
signed handler where they depend on signed token behavior.
* fix: use handler="signed" in test_create_table_permissions
https://claude.ai/code/session_013cQFiDQjYRrRBH2biFfKuS
2026-02-25 16:32:45 -08:00
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_signed_tokens_disabled():
|
|
|
|
|
"""create_token and verify_token should fail/skip when signed tokens are disabled."""
|
|
|
|
|
ds = Datasette(settings={"allow_signed_tokens": False})
|
|
|
|
|
with pytest.raises(ValueError, match="Signed tokens are not enabled"):
|
|
|
|
|
await ds.create_token("test_actor", handler="signed")
|
|
|
|
|
# verify_token should return None rather than raising
|
|
|
|
|
assert await ds.verify_token("dstok_anything") is None
|