Files
superset2/tests/unit_tests/middleware/__init__.py
Joe Li 5b5f98af20 fix(subdirectory): add LegacyPrefixRedirectMiddleware 308 shim (Slice 5)
Closes the 404 gap for legacy `/superset/<canonical>` bookmarks after
`Superset.route_base = ""` collapsed the historical `/superset` prefix off
every view. Adds an outermost WSGI shim that 308-redirects enumerated legacy
paths to their canonical equivalents (or 410s POSTs against GET-only
canonicals, where a 308 would have the client retry-POST into a 405).

Layering invariant
------------------
The shim wraps `app.wsgi_app` in `create_app()` *after* `init_app()` returns,
so it sits outside AppRootMiddleware / ProxyFix / ChunkedEncodingFix /
ADDITIONAL_MIDDLEWARE and sees the raw inbound `PATH_INFO`. `Location` is
built from the `app_root` captured at construction time + the canonical
path — never from `environ["SCRIPT_NAME"]` (unset at this outer layer) and
never from `X-Forwarded-*`. See module docstring + PLAN.md "WSGI layering
invariant" for the full ordering rationale.

AF-4 coupling closure
---------------------
`SqlaTable.sql_url` previously used naive `?table_name=…` concatenation,
which produced double-`?` URLs when `Database.sql_url` already carried a
query string (Database.sql_url was reshaped in Slice 3 to
`/sqllab/?dbid=<id>`). Switch to `urlsplit` / `parse_qsl` / `urlencode`
with `quote_via=quote` so query strings compose correctly and names with
special characters (`/`, `&`, `+`, …) are properly encoded.

Closed-set discipline
---------------------
`LEGACY_REDIRECT_MAP` is a 16-row closed table, each row verified against
the canonical endpoint's `@expose` decorator at HEAD. `/superset/sql/<id>/`
is intentionally absent — `Database.sql_url` changed shape (path → query
string) so no 1:1 mapping exists; the hard re-bookmark break is documented
in UPDATING.md. A snapshot regression test pins the keyset.

Oracle results (all RED-against-HEAD, reverted)
-----------------------------------------------
5-a  remove shim wrap-site in app.py                 → 17 GETs fail
5-b  flip `path_info.startswith` → exact-only         → tail-bearing rows red
5-c  drop allowed-methods gate                       → POST-410 rows red
5-d  emit 302 instead of 308                         → status assertion red
5-e  read SCRIPT_NAME instead of captured app_root   → contamination guard red
5-f  read X-Forwarded-Prefix                          → contamination guard red
5-g  `_LEGACY_PREFIX = ""`                            → pass-through guard red
5-h  drop query-string passthrough                   → QS preservation red
5-i  swap exact/longest-prefix order in _match        → /dashboard/p/ row red
5-j  AF-4: revert sql_url to naive concat            → double-`?` test red

Round-3/4/6 review closures
---------------------------
* round-3 [High] prefix-source closure: `app_root_prefix` captured once at
  construction; never re-read from environ.
* round-4 [High] wrap-site closure: shim installed at the single sanctioned
  outermost wrap site in `create_app()`; pinned by `test_wrap_order_*`.
* round-6 `quote(safe=…)` pin: `Location` is %-encoded with a safe set that
  preserves header-safe URL bytes while sanitising raw control bytes from
  `PATH_INFO`.
* round-6 outermost-only-within-`create_app()` invariant: unconditional
  wrap (independent of `app_root != "/"`) — legacy bookmarks exist under
  root deployments too.
* H2 closure: 410 (not 308) for POST-against-GET-only avoids the 308→retry
  → 405 trap.

Tests
-----
Test file lives at `tests/unit_tests/middleware/` (not `integration_tests/`
per PLAN) because the local docker-light env can't service `/login/` POST.
Coverage is preserved via `werkzeug.test.Client` driving the middleware
around a sentinel inner WSGI app, plus `create_app()` + patched
`init_app()` for wrap-order assertions, plus live-route shadow pins.

* `tests/unit_tests/middleware/test_legacy_prefix_redirect.py` — 72 tests
  (path rewriter, query-string preservation, SCRIPT_NAME/X-Forwarded
  contamination guards, closed-set snapshot, wrap-order, live-route shadow
  pins, AF-4 query encoding).
* `tests/unit_tests/initialization_test.py` — `_unwrap_to_app_root` helper
  threads the new outermost layer so existing `TestCreateAppRoot` cases
  keep passing.

UPDATING.md
-----------
Documents (a) legacy `/superset/*` 308 shim with EOL at 5.0.0, (b) hard
re-bookmark break for `/superset/sql/<database_id>/`, (c) `SqlaTable.sql_url`
query-string format change (AF-4 fix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:52:31 -07:00

17 lines
785 B
Python

# 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.