You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ma...@apache.org on 2026/09/16 23:01:40 UTC

(superset) 02/06: Merge remote-tracking branch 'origin/master' into madhu-one-time-login-tokens

This is an automated email from the ASF dual-hosted git repository.

madhushreeag pushed a commit to branch madhu-one-time-login-tokens
in repository https://gitbox.apache.org/repos/asf/superset.git

commit f79e824e7e16d6612728dde25a52d559f6d3881e
Merge: daf102f41fd eeafd65b746
Author: madhushree agarwal <ma...@apple.com>
AuthorDate: Mon Sep 14 12:44:58 2026 -0700

    Merge remote-tracking branch 'origin/master' into madhu-one-time-login-tokens

 .github/workflows/docker.yml                       |  23 +-
 .../workflows/scheduled-docker-image-refresh.yml   |   2 +-
 .github/workflows/superset-python-unittest.yml     |  13 +-
 .github/workflows/tag-release.yml                  |   2 +-
 Dockerfile                                         |  37 ++
 RESOURCES/INTHEWILD.yaml                           |   5 +
 SECURITY.md                                        |   4 +
 UPDATING.md                                        |  34 ++
 docker-compose-image-tag.yml                       |   2 +-
 docker-compose-light.yml                           |   2 +-
 docker-compose-non-dev.yml                         |   5 +-
 docker-compose.yml                                 |   2 +-
 docker/entrypoints/docker-ci.sh                    |   6 +-
 docs/admin_docs/installation/docker-builds.mdx     |  65 ++--
 docs/developer_docs/extensions/quick-start.md      |   2 +-
 docs/package.json                                  |   4 +-
 docs/static/img/logos/savantly.png                 | Bin 0 -> 5787 bytes
 docs/yarn.lock                                     | 151 ++++----
 pyproject.toml                                     |  17 +-
 requirements/base.txt                              |   6 +-
 requirements/development.txt                       |  15 +-
 superset-frontend/.storybook/main.mjs              |  10 +-
 superset-frontend/babel.config.js                  |   4 +-
 superset-frontend/changelog.config.js              |   2 +-
 .../eslint-plugin-i18n-strings/index.ts            |   4 +-
 .../no-eager-t-in-config.test.ts                   |   8 +-
 .../no-template-vars.test.ts                       |   6 +-
 .../eslint-plugin-i18n-strings/package.json        |   1 +
 .../eslint-rules/eslint-plugin-icons/index.ts      |   6 +-
 .../eslint-plugin-icons/no-fontawesome.test.ts     |   8 +-
 .../eslint-rules/eslint-plugin-icons/package.json  |   1 +
 .../eslint-plugin-theme-colors/index.ts            |   2 +-
 .../eslint-plugin-theme-colors/package.json        |   1 +
 superset-frontend/eslint.config.minimal.js         |  12 +-
 superset-frontend/jest.config.js                   |   2 +-
 superset-frontend/package-lock.json                | 366 +++++++++---------
 superset-frontend/package.json                     |  22 +-
 .../plugin-chart/templates/babel.config.erb        |   1 +
 .../templates/test/__mocks__/mockExportString.js   |   1 +
 .../packages/generator-superset/package.json       |   4 +-
 .../superset-ui-core/__mocks__/mockExportObject.js |   3 +-
 .../superset-ui-core/__mocks__/mockExportString.js |   3 +-
 .../packages/superset-ui-core/package.json         |   4 +-
 .../components/ActionButton/ActionButton.test.tsx  |  10 +-
 .../src/components/Checkbox/Checkbox.test.tsx      |   5 +-
 .../components/ConfirmModal/ConfirmModal.test.tsx  |   8 +-
 .../components/DeleteModal/DeleteModal.test.tsx    |  15 +-
 .../src/components/Select/AsyncSelect.test.tsx     |  79 +++-
 .../src/components/Select/Select.test.tsx          |  67 +++-
 .../cell-renderers/ActionCell/ActionCell.test.tsx  |   6 +-
 .../src/components/TableCollection/index.tsx       |   5 +-
 .../TimezoneSelector.DaylightSavingTime.test.tsx   |   7 +-
 .../TimezoneSelector/TimezoneSelector.test.tsx     |  17 +-
 .../UnsavedChangesModal.test.tsx                   |  14 +-
 .../generators/docs/docs-screenshots.spec.ts       |   3 +
 .../generators/docs/mobile-screenshots.spec.ts     |   3 +
 .../playwright/generators/playwright.config.ts     |   3 +
 .../tests/embedded/embedded-dashboard.spec.ts      |   5 +-
 .../tests/embedded/pivot-collapse-state.spec.ts    |   5 +-
 .../src/AgGridTable/index.tsx                      |  19 +
 .../src/AgGridTableChart.tsx                       |   3 +
 .../src/controlPanel.tsx                           |  14 +
 .../src/transformProps.ts                          |   2 +
 .../plugin-chart-ag-grid-table/src/types.ts        |   2 +
 .../src/utils/useColDefs.ts                        |   9 +-
 .../test/controlPanel.test.ts                      |  12 +
 .../test/transformProps.test.ts                    |  25 ++
 .../test/utils/useColDefs.test.ts                  |  62 ++++
 .../src/Gantt/transformProps.ts                    |  29 +-
 superset-frontend/scripts/build.js                 |  11 +-
 superset-frontend/scripts/bundle-size-summary.js   |  58 +--
 superset-frontend/scripts/check-custom-rules.js    |  19 +-
 .../scripts/check-storybook-coverage.js            |   9 +-
 superset-frontend/scripts/commitlint.js            |   2 +-
 superset-frontend/scripts/copyAssets.js            |   4 +-
 .../scripts/{ => internal}/bundle-size-summary.js  |   8 +-
 .../{ => internal}/oxlint-metrics-uploader.js      |  14 +-
 .../scripts/oxlint-metrics-uploader.js             | 267 +------------
 .../spec/__mocks__/mockExportObject.js             |   3 +-
 .../spec/__mocks__/mockExportString.js             |   3 +-
 ...summary.test.js => bundle-size-summary.test.ts} |   8 +-
 ...der.test.js => oxlint-metrics-uploader.test.ts} |   2 +-
 .../SaveDatasetModal/SaveDatasetModal.test.tsx     |  26 +-
 .../SqlLab/components/SaveQuery/SaveQuery.test.tsx |  48 ++-
 .../AlteredSliceTag/AlteredSliceTag.test.tsx       |   8 +-
 .../components/Chart/DrillBy/DrillByModal.test.tsx |  28 +-
 .../Chart/DrillBy/DrillBySubmenu.test.tsx          |  20 +-
 .../Chart/DrillDetail/DrillDetailModal.test.tsx    |   6 +-
 .../DrillDetail/DrillDetailTableControls.test.tsx  |   8 +-
 .../tests/DatasourceEditor.test.tsx                |   8 +-
 .../ErrorMessage/DatabaseErrorMessage.test.tsx     |  16 +-
 .../DatasourceSecurityAccessErrorMessage.test.tsx  |  64 +++-
 .../DatasourceSecurityAccessErrorMessage.tsx       |  37 +-
 .../ErrorMessageWithStackTrace.test.tsx            |   8 +-
 .../FrontendNetworkErrorMessage.test.tsx           |   4 +-
 .../ErrorMessage/ParameterErrorMessage.test.tsx    |  12 +-
 .../ErrorMessage/TimeoutErrorMessage.test.tsx      |  16 +-
 .../FilterableTable/FilterableTable.test.tsx       |  40 +-
 .../src/components/GridTable/HeaderMenu.test.tsx   |  61 +--
 .../src/components/ListView/CardCollection.tsx     |  16 +-
 .../src/components/ListView/ListView.tsx           |   4 +-
 superset-frontend/src/components/ListView/utils.ts |  69 ++--
 .../DetailsPanel/DetailsPanel.test.tsx             |  12 +-
 .../FilterIndicator/FilterIndicator.test.tsx       |  10 +-
 .../dashboard/components/Header/Header.test.tsx    |  95 ++---
 .../PropertiesModal/PropertiesModal.test.tsx       |  16 +-
 .../PublishedStatus/PublishedStatus.test.tsx       |  10 +-
 .../components/SliceHeader/SliceHeader.test.tsx    |  56 +--
 .../SliceHeaderControls.subdirectory.test.tsx      |  15 +-
 .../SliceHeaderControls.test.tsx                   | 224 +++++------
 .../components/filterscope/FilterScope.test.tsx    |  34 +-
 .../filterscope/FilterScopeSelector.test.tsx       |  12 +-
 .../gridComponents/Divider/Divider.test.tsx        |   4 +-
 .../components/gridComponents/Tab/Tab.test.tsx     |  16 +-
 .../components/gridComponents/Tabs/Tabs.test.tsx   |  18 +-
 .../DownloadMenuItems/DownloadMenuItems.test.tsx   |  37 +-
 .../dashboard/components/menu/HoverMenu.test.tsx   |   6 +-
 .../menu/ShareMenuItems/ShareMenuItems.test.tsx    |  28 +-
 .../FilterBar/ActionButtons/ActionButtons.test.tsx |  11 +-
 .../FilterBar/CrossFilters/CrossFilterTag.test.tsx |   4 +-
 .../ScopingModal/ChartsScopingListPanel.test.tsx   |  10 +-
 .../ScopingModal/ScopingModal.test.tsx             |  24 +-
 .../nativeFilters/FilterBar/FilterBar.test.tsx     |  50 +--
 .../FilterBarSettings/FilterBarSettings.test.tsx   |  42 +--
 .../FilterConfigurationLink.test.tsx               |   4 +-
 .../nativeFilters/FilterBar/Header/Header.test.tsx |   4 +-
 .../nativeFilters/FilterCard/FilterCard.test.tsx   |   6 +-
 .../FiltersConfigModal/FilterConfigPane.test.tsx   |   8 +-
 .../FiltersConfigForm/CollapsibleControl.test.tsx  |   5 +-
 .../FiltersConfigForm/DatasetSelect.test.tsx       |  30 +-
 .../FiltersConfigForm/getControlItemsMap.test.tsx  |   8 +-
 .../FiltersConfigModal/FiltersConfigModal.test.tsx |  10 +-
 .../DataTableControl/FilterInput.test.tsx          |   2 +-
 .../DatasourcePanel/DatasourcePanel.test.tsx       |  16 +-
 .../DatasourcePanel/DatasourcePanelItem.test.tsx   |   8 +-
 .../ExploreChartHeader/ExploreChartHeader.test.tsx | 178 ++++-----
 .../ExploreChartPanel/ExploreChartPanel.test.tsx   |   4 +-
 .../StandaloneDownloadControl.test.tsx             |  14 +-
 .../ExportToCSVDropdown.test.tsx                   |  16 +-
 .../RunQueryButton/RunQueryButton.test.tsx         |  20 +-
 .../AnnotationLayer.test.tsx                       |  58 +--
 .../components/controls/BoundsControl.test.tsx     |  10 +-
 .../components/controls/CheckboxControl.test.tsx   |   4 +-
 .../CollectionControl/CollectionControl.test.tsx   |   6 +-
 .../ColorBreakpointOption.test.tsx                 |   4 +-
 .../ColorBreakpointPopoverControl.test.tsx         |  48 +--
 .../ColorBreakpointPopoverTrigger.test.tsx         |  30 +-
 .../ColorBreakpointsControl.test.tsx               |  22 +-
 .../ControlPopover/ControlPopover.test.tsx         |  20 +-
 .../DateFilterControl/tests/AdvancedFrame.test.tsx |   8 +-
 .../DateFilterControl/tests/CustomFrame.test.tsx   |  62 ++--
 .../tests/DateFilterLabel.test.tsx                 |  36 +-
 .../ColumnSelectPopoverTrigger.test.tsx            |   4 +-
 .../DndColumnSelect.test.tsx                       |  22 +-
 .../DndMetricSelect.test.tsx                       |   7 +-
 .../DndColumnSelectControl/DndSelectLabel.test.tsx |   4 +-
 .../DndColumnSelectControl/Option.test.tsx         |   2 +-
 ...AdhocFilterEditPopoverSimpleTabContent.test.tsx |  50 +--
 .../AdhocFilterEditPopoverSqlTabContent.test.tsx   |   4 +-
 .../AdhocFilterOption/AdhocFilterOption.test.tsx   |   2 +-
 .../AdhocFilterPopoverTrigger.test.tsx             |   4 +-
 .../FixedOrMetricControl.test.tsx                  |  14 +-
 .../AdhocMetricEditPopover.test.tsx                |  38 +-
 .../AdhocMetricEditPopoverTitle.test.tsx           |   7 +-
 .../MetricControl/AdhocMetricOption.test.tsx       |  29 +-
 .../controls/MetricControl/MetricsControl.test.tsx |  18 +-
 .../controls/NumberControl/NumberControl.test.tsx  |  16 +-
 .../SelectAsyncControl/SelectAsyncControl.test.tsx |   4 +-
 .../components/controls/SelectControl.test.tsx     |  29 +-
 .../TimeSeriesColumnControl.test.tsx               | 137 +++----
 .../components/controls/ViewportControl.test.tsx   |   8 +-
 .../VizTypeControl/VizTypeControl.test.tsx         |  32 +-
 .../src/features/alerts/AlertReportModal.test.tsx  | 191 +++++-----
 .../alerts/components/NotificationMethod.test.tsx  |   8 +-
 .../databases/DatabaseModal/ModalHeader.tsx        |   2 +-
 .../DatabaseModal/SSHTunnelSwitch.test.tsx         |   6 +-
 .../databases/DatabaseModal/index.test.tsx         | 136 +++----
 .../src/features/databases/DatabaseModal/index.tsx |   2 +-
 .../AddDataset/LeftPanel/LeftPanel.test.tsx        |  28 +-
 .../src/features/home/ActivityTable.test.tsx       |   2 +-
 .../src/features/home/SubMenu.test.tsx             |   4 +-
 .../HeaderReportDropdown/index.test.tsx            |  14 +-
 .../reports/ReportModal/ReportModal.test.tsx       |  29 +-
 .../features/rls/RowLevelSecurityModal.test.tsx    |  30 +-
 .../subjects/SubjectPicker/SubjectPicker.test.tsx  |   2 +-
 .../src/features/themes/ThemeModal.test.tsx        |  21 +-
 .../components/Range/RangeFilterPlugin.test.tsx    |  10 +-
 .../components/Select/SelectFilterPlugin.test.tsx  | 102 ++---
 .../src/pages/ArchivedList/ArchivedList.test.tsx   |  26 +-
 superset-frontend/src/pages/Chart/Chart.test.tsx   | 189 +++++++++-
 superset-frontend/src/pages/Chart/index.tsx        |  44 ++-
 .../src/pages/ChartCreation/ChartCreation.test.tsx |  68 ++--
 .../RowLevelSecurityList.test.tsx                  |   2 +-
 superset-frontend/src/types/Chart.ts               |   8 +
 superset-frontend/src/types/react-table.d.ts       |  71 ++++
 .../mockExportString.js => src/types/yaml.d.ts}    |  11 +-
 .../src/utils/downloadAsImage.test.ts              |  35 +-
 superset-frontend/src/utils/downloadAsImage.tsx    |  15 +-
 superset-frontend/src/utils/downloadAsPdf.ts       |   9 +-
 superset-frontend/src/utils/downloadUtils.ts       |  14 +
 superset-frontend/src/utils/textUtils.ts           |  42 ++-
 superset-frontend/src/views/CRUD/hooks.ts          |   4 +-
 superset-frontend/stylelint.config.js              |   3 +-
 superset-frontend/test-runner-jest.config.js       |  33 --
 .../tools/webpack.proxy-config.test.js             |  14 +-
 superset-frontend/webpack.config.js                |  73 ++--
 superset-frontend/webpack.proxy-config.js          |  16 +-
 superset/commands/dataset/create.py                |  84 ++++-
 superset/config.py                                 |   4 +-
 superset/databases/schemas.py                      |  12 +-
 superset/datasets/api.py                           |  10 +-
 superset/explore/api.py                            |   3 +
 superset/mcp_service/chart/tool/get_chart_data.py  | 211 +++++++----
 .../migrations/shared/migrate_viz/processors.py    |   8 +
 superset/security/manager.py                       |  46 ++-
 superset/sql/dialects/starrocks.py                 |  11 +-
 superset/sql/parse.py                              | 412 +++++++++++++++------
 superset/tasks/utils.py                            |  20 +-
 superset/tasks/version_history_retention.py        |  14 +-
 superset/utils/dates.py                            |  22 +-
 superset/utils/machine_auth.py                     |  11 +-
 superset/versioning/api_helpers.py                 |  58 ++-
 superset/versioning/baseline/insertion.py          |  29 +-
 superset/versioning/queries.py                     |  39 +-
 superset/versioning/restore.py                     |   7 +
 tests/integration_tests/datasource/api_tests.py    |   4 +-
 .../integration_tests/utils/machine_auth_tests.py  |  10 +-
 .../versioning/conditional_write_lock_tests.py     | 225 +++++++++++
 tests/unit_tests/commands/dataset/test_create.py   | 180 +++++++++
 tests/unit_tests/commands/report/base_test.py      |  20 +-
 tests/unit_tests/conftest.py                       |  32 ++
 tests/unit_tests/databases/schema_tests.py         |  21 ++
 tests/unit_tests/docker_ci_entrypoint_test.py      | 142 +++++++
 tests/unit_tests/explore/api_test.py               |  44 +++
 .../mcp_service/chart/tool/test_get_chart_data.py  | 329 ++++++++++++++++
 tests/unit_tests/mcp_service/conftest.py           |  27 ++
 .../unit_tests/migrations/viz/table_v1_v2_test.py  |   4 +
 tests/unit_tests/security/login_token_test.py      |   2 +-
 .../security/test_permission_instructions_link.py  |  79 +++-
 tests/unit_tests/sql/parse_tests.py                | 171 +++++++++
 .../test_clock_reading_driver_preload.py           |  57 +++
 tests/unit_tests/versioning/test_lock_entity.py    |  97 +++++
 .../unit_tests/versioning/test_retention_races.py  | 353 ++++++++++++++++++
 243 files changed, 5467 insertions(+), 2605 deletions(-)

diff --cc tests/unit_tests/security/login_token_test.py
index 8957a4bfd9b,00000000000..ff044b47f17
mode 100644,000000..100644
--- a/tests/unit_tests/security/login_token_test.py
+++ b/tests/unit_tests/security/login_token_test.py
@@@ -1,189 -1,0 +1,189 @@@
 +# Licensed to the Apache Software Foundation (ASF) under one
 +# or more contributor license agreements.  See the NOTICE file
 +# distributed with this work for additional information
 +# regarding copyright ownership.  The ASF licenses this file
 +# to you under the Apache License, Version 2.0 (the
 +# "License"); you may not use this file except in compliance
 +# with the License.  You may obtain a copy of the License at
 +#
 +#   http://www.apache.org/licenses/LICENSE-2.0
 +#
 +# Unless required by applicable law or agreed to in writing,
 +# software distributed under the License is distributed on an
 +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 +# KIND, either express or implied.  See the License for the
 +# specific language governing permissions and limitations
 +# under the License.
 +
 +from datetime import datetime, timedelta
 +from typing import Any
 +
 +import pytest
 +from flask import current_app
 +from pytest_mock import MockerFixture
 +from sqlalchemy.orm.session import Session
 +
 +from superset.key_value.models import KeyValueEntry
 +from superset.security import login_token
 +from superset.security.login_token import (
 +    DEFAULT_LOGIN_TOKEN_TTL_SECONDS,
 +    LoginTokenUserInfo,
 +)
 +
 +USERINFO: LoginTokenUserInfo = {
 +    "username": "jdoe",
 +    "email": "jdoe@example.com",
 +    "first_name": "Jane",
 +    "last_name": "Doe",
 +    "role_keys": ["Gamma"],
 +}
 +
 +
 +@pytest.fixture
 +def kv_table(session: Session) -> Session:
 +    """The key-value table, on the in-memory session the DAO is patched onto."""
 +    KeyValueEntry.metadata.create_all(session.get_bind())
 +    return session
 +
 +
 +def _set_resolver(value: Any) -> None:
 +    current_app.config["LOGIN_TOKEN_IDENTITY_RESOLVER"] = value
 +
 +
 +def test_is_enabled_requires_flag_and_resolver(app_context: None) -> None:
 +    """Both the feature flag and a resolver are needed; either alone is closed."""
 +    original = current_app.config.get("LOGIN_TOKEN_IDENTITY_RESOLVER")
 +    try:
 +        _set_resolver(lambda request, **kwargs: USERINFO)
 +        current_app.config["FEATURE_FLAGS"]["LOGIN_TOKEN"] = False
 +        assert login_token.is_enabled() is False
 +
 +        current_app.config["FEATURE_FLAGS"]["LOGIN_TOKEN"] = True
 +        assert login_token.is_enabled() is True
 +
 +        _set_resolver(None)
 +        assert login_token.is_enabled() is False
 +    finally:
 +        current_app.config["FEATURE_FLAGS"]["LOGIN_TOKEN"] = False
 +        _set_resolver(original)
 +
 +
 +def test_get_ttl_seconds_falls_back(app_context: None) -> None:
 +    """A missing, non-numeric or non-positive TTL falls back to the default."""
 +    original = current_app.config.get("LOGIN_TOKEN_TTL_SECONDS")
 +    try:
 +        current_app.config["LOGIN_TOKEN_TTL_SECONDS"] = 30
 +        assert login_token.get_ttl_seconds() == 30
 +
 +        # Strings (e.g. from an env var) are coerced rather than crashing.
-         current_app.config["LOGIN_TOKEN_TTL_SECONDS"] = "45"
++        current_app.config["LOGIN_TOKEN_TTL_SECONDS"] = "45"  # noqa: S105
 +        assert login_token.get_ttl_seconds() == 45
 +
 +        for bad in ("not-a-number", 0, -1, None):
 +            current_app.config["LOGIN_TOKEN_TTL_SECONDS"] = bad
 +            assert login_token.get_ttl_seconds() == DEFAULT_LOGIN_TOKEN_TTL_SECONDS
 +    finally:
 +        current_app.config["LOGIN_TOKEN_TTL_SECONDS"] = original
 +
 +
 +@pytest.mark.parametrize(
 +    "resolver,reason",
 +    [
 +        (None, "no resolver configured"),
 +        ("not-callable", "resolver is not callable"),
 +        (lambda request, **kwargs: None, "resolver declined"),
 +        (lambda request, **kwargs: {}, "resolver returned an empty dict"),
 +        (lambda request, **kwargs: {"first_name": "Jane"}, "no username or email"),
 +    ],
 +)
 +def test_resolve_identity_rejections(
 +    app_context: None, resolver: Any, reason: str
 +) -> None:
 +    """Every failure mode resolves to None rather than a partial identity."""
 +    original = current_app.config.get("LOGIN_TOKEN_IDENTITY_RESOLVER")
 +    try:
 +        _set_resolver(resolver)
 +        assert login_token.resolve_identity(object()) is None, reason  # type: ignore[arg-type]
 +    finally:
 +        _set_resolver(original)
 +
 +
 +def test_resolve_identity_treats_a_raising_resolver_as_rejection(
 +    app_context: None,
 +) -> None:
 +    """A resolver that raises must reject, never surface as a server error.
 +
 +    A validation failure escaping as a 500 would be indistinguishable from an
 +    outage, and a caller retrying could otherwise be treated as authenticated.
 +    """
 +    original = current_app.config.get("LOGIN_TOKEN_IDENTITY_RESOLVER")
 +
 +    def boom(request: Any, **kwargs: Any) -> LoginTokenUserInfo:
 +        raise ValueError("bad signature")
 +
 +    try:
 +        _set_resolver(boom)
 +        assert login_token.resolve_identity(object()) is None  # type: ignore[arg-type]
 +    finally:
 +        _set_resolver(original)
 +
 +
 +def test_resolve_identity_accepts_email_only(app_context: None) -> None:
 +    """`email` alone is a valid identity — auth_user_oauth derives the username."""
 +    original = current_app.config.get("LOGIN_TOKEN_IDENTITY_RESOLVER")
 +    try:
 +        _set_resolver(lambda request, **kwargs: {"email": "jdoe@example.com"})
 +        assert login_token.resolve_identity(object()) == {  # type: ignore[arg-type]
 +            "email": "jdoe@example.com"
 +        }
 +    finally:
 +        _set_resolver(original)
 +
 +
 +def test_mint_then_consume_round_trip(app_context: None, kv_table: Session) -> None:
 +    """A minted token returns exactly the stored userinfo."""
 +    token, expires_on = login_token.mint(USERINFO)
 +
 +    assert token
 +    assert expires_on > datetime.now()
 +    assert login_token.consume(token) == USERINFO
 +
 +
 +def test_consume_is_single_use(app_context: None, kv_table: Session) -> None:
 +    """The second consume of the same token fails — the entry is deleted on use."""
 +    token, _ = login_token.mint(USERINFO)
 +
 +    assert login_token.consume(token) == USERINFO
 +    assert login_token.consume(token) is None
 +
 +
 +def test_consume_rejects_unknown_and_malformed_tokens(
 +    app_context: None, kv_table: Session
 +) -> None:
 +    """Garbage and well-formed-but-unknown tokens are both rejected."""
 +    assert login_token.consume("") is None
 +    assert login_token.consume("not-a-uuid") is None
 +    assert login_token.consume("6d2b9921-2274-43b8-94d6-e5e1f05372c4") is None
 +
 +
 +def test_consume_rejects_an_expired_token(
 +    app_context: None, kv_table: Session, mocker: MockerFixture
 +) -> None:
 +    """An expired token is rejected and removed rather than left to the pruner."""
 +    mocker.patch.object(login_token, "get_ttl_seconds", return_value=1)
 +    token, _ = login_token.mint(USERINFO)
 +
 +    # Move the stored expiry into the past rather than sleeping.
 +    entry = kv_table.query(KeyValueEntry).one()
 +    entry.expires_on = datetime.now() - timedelta(seconds=1)
 +
 +    assert login_token.consume(token) is None
 +    assert kv_table.query(KeyValueEntry).count() == 0
 +
 +
 +def test_token_carries_no_identity_data(app_context: None, kv_table: Session) -> None:
 +    """The token is an opaque handle — no claims travel in the URL."""
 +    token, _ = login_token.mint(USERINFO)
 +
 +    for value in ("jdoe", "jdoe@example.com", "Jane", "Doe", "Gamma"):
 +        assert value not in token