mirror of
https://github.com/apache/superset.git
synced 2026-06-16 04:59:17 +00:00
Compare commits
21 Commits
geido/fix-
...
bump-setup
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c299afa185 | ||
|
|
11e35eca3b | ||
|
|
8093197c97 | ||
|
|
919c6eddc1 | ||
|
|
886bb200d0 | ||
|
|
b5ca00d06b | ||
|
|
5719f8e349 | ||
|
|
9d72a39e10 | ||
|
|
66733a5d72 | ||
|
|
a435002293 | ||
|
|
2d8447af42 | ||
|
|
bf5daf0a1e | ||
|
|
b656b1d477 | ||
|
|
5a97e01d6e | ||
|
|
38cc70de2f | ||
|
|
a1bc3c67ed | ||
|
|
e5b6642b18 | ||
|
|
dd3a61156b | ||
|
|
820e3d18d3 | ||
|
|
2dd8fe362f | ||
|
|
9d2f625e55 |
2
.github/actions/setup-backend/action.yml
vendored
2
.github/actions/setup-backend/action.yml
vendored
@@ -42,7 +42,7 @@ runs:
|
||||
fi
|
||||
echo "python-version=$RESOLVED_VERSION" >> "$GITHUB_OUTPUT"
|
||||
- name: Set up Python ${{ steps.set-python-version.outputs.python-version }}
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: ${{ steps.set-python-version.outputs.python-version }}
|
||||
cache: ${{ inputs.cache }}
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: steps.check.outputs.superset-extensions-cli
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
flags: superset-extensions-cli
|
||||
|
||||
2
.github/workflows/superset-frontend.yml
vendored
2
.github/workflows/superset-frontend.yml
vendored
@@ -134,7 +134,7 @@ jobs:
|
||||
run: npx nyc merge coverage/ merged-output/coverage-summary.json
|
||||
|
||||
- name: Upload Code Coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
flags: javascript
|
||||
use_oidc: true
|
||||
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
run: |
|
||||
./scripts/python_tests.sh
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
flags: python,mysql
|
||||
verbose: true
|
||||
@@ -173,7 +173,7 @@ jobs:
|
||||
run: |
|
||||
./scripts/python_tests.sh
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
flags: python,postgres
|
||||
verbose: true
|
||||
@@ -222,7 +222,7 @@ jobs:
|
||||
run: |
|
||||
./scripts/python_tests.sh
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
flags: python,sqlite
|
||||
verbose: true
|
||||
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
run: |
|
||||
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
flags: python,presto
|
||||
verbose: true
|
||||
@@ -152,7 +152,7 @@ jobs:
|
||||
pip install -e .[hive]
|
||||
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
flags: python,hive
|
||||
verbose: true
|
||||
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
pytest --durations-min=0.5 --cov=superset/sql/ ./tests/unit_tests/sql/ --cache-clear --cov-fail-under=100
|
||||
pytest --durations-min=0.5 --cov=superset/semantic_layers/ ./tests/unit_tests/semantic_layers/ --cache-clear --cov-fail-under=100
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
flags: python,unit
|
||||
verbose: true
|
||||
|
||||
30
UPDATING.md
30
UPDATING.md
@@ -140,6 +140,36 @@ Both default to empty (no behavior change). They apply to both the `LOCAL_EXTENS
|
||||
|
||||
The Dynamic Group By chart customization now orders its display values according to the "Sort display control values" toggle: ascending (A–Z), descending (Z–A), or the dataset's source order when the toggle is unset. Previously the dropdown always sorted alphabetically. Existing dashboards where the toggle was never set will show options in source order instead of A–Z; open the customization and enable the toggle to restore alphabetical ordering.
|
||||
|
||||
### Selectable encryption engine for app-encrypted fields (AES-GCM)
|
||||
|
||||
App-encrypted fields (database passwords, SSH tunnel credentials, OAuth tokens, etc.) can now use authenticated **AES-GCM** encryption instead of the historical unauthenticated **AES-CBC**. A new config selects the engine for the default adapter:
|
||||
|
||||
```python
|
||||
# "aes" (AES-CBC, historical default) | "aes-gcm" (authenticated, recommended for new installs)
|
||||
SQLALCHEMY_ENCRYPTED_FIELD_ENGINE = "aes"
|
||||
```
|
||||
|
||||
**No action required / no behavior change:** the default remains `"aes"`, so existing installs are unaffected.
|
||||
|
||||
**Opting in on an existing install:** flipping the engine on a populated database without re-encrypting first will make stored secrets undecryptable, because the two ciphertext formats are not compatible. A migrator is provided. Recommended runbook:
|
||||
|
||||
1. Take a metadata-DB backup.
|
||||
2. Re-encrypt existing secrets into the new engine (the `SECRET_KEY` is unchanged):
|
||||
```bash
|
||||
superset re-encrypt-secrets --engine aes-gcm
|
||||
```
|
||||
3. Set `SQLALCHEMY_ENCRYPTED_FIELD_ENGINE = "aes-gcm"` in your config.
|
||||
4. Restart Superset.
|
||||
5. Re-run the migrator once more after the restart:
|
||||
```bash
|
||||
superset re-encrypt-secrets --engine aes-gcm
|
||||
```
|
||||
A live instance keeps writing *new* secrets as AES-CBC during the window between step 2 and the restart in step 4; this second pass sweeps those up (it is idempotent, so already-migrated values are skipped).
|
||||
|
||||
Schedule the cutover in a quiet window. Runtime reads use only the single configured engine, so in a multi-worker deployment there is an unavoidable brief decrypt-outage between the migration commit and the last worker restarting with the new config — each migrator run is transactional, but the fleet-wide cutover is not zero-downtime.
|
||||
|
||||
The migration is transactional (all-or-nothing) and idempotent — it can be safely re-run or resumed. Note that AES-GCM, unlike AES-CBC, does not support querying directly over encrypted columns; audit any code that filters on an encrypted column before switching. See the SIP at `docs/sip/authenticated-encryption-at-rest.md` for details.
|
||||
|
||||
### Granular Export Controls
|
||||
|
||||
A new feature flag `GRANULAR_EXPORT_CONTROLS` introduces three fine-grained permissions that replace the legacy `can_csv` permission:
|
||||
|
||||
@@ -72,8 +72,8 @@
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.40",
|
||||
"antd": "^6.4.3",
|
||||
"baseline-browser-mapping": "^2.10.33",
|
||||
"caniuse-lite": "^1.0.30001793",
|
||||
"baseline-browser-mapping": "^2.10.34",
|
||||
"caniuse-lite": "^1.0.30001797",
|
||||
"docusaurus-plugin-openapi-docs": "^5.0.2",
|
||||
"docusaurus-theme-openapi-docs": "^5.0.2",
|
||||
"js-yaml": "^4.2.0",
|
||||
|
||||
136
docs/sip/authenticated-encryption-at-rest.md
Normal file
136
docs/sip/authenticated-encryption-at-rest.md
Normal file
@@ -0,0 +1,136 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
|
||||
# SIP: Authenticated encryption (AES-GCM) for app-encrypted fields
|
||||
|
||||
## [DRAFT — proposal for discussion]
|
||||
|
||||
This document is a draft proposal accompanying the code in this PR. It is
|
||||
intended to seed the formal SIP discussion. The code here ships the
|
||||
backward-compatible engine selection **and** the re-encryption migrator
|
||||
(Phases 1–2 below); both are opt-in and change nothing for existing installs by
|
||||
default. Flipping the default for fresh installs (Phase 3) remains future work.
|
||||
|
||||
## Motivation
|
||||
|
||||
Superset app-encrypts a number of sensitive fields before persisting them to
|
||||
the metadata database, including:
|
||||
|
||||
- database connection passwords and `encrypted_extra` (`superset/models/core.py`),
|
||||
- SSH tunnel credentials — password, private key, private-key password
|
||||
(`superset/databases/ssh_tunnel/models.py`),
|
||||
- OAuth2 tokens and other secrets stored via `EncryptedType`.
|
||||
|
||||
These fields are encrypted with `sqlalchemy_utils.EncryptedType`, which
|
||||
**defaults to `AesEngine` (AES-CBC)**. AES-CBC provides confidentiality but is
|
||||
**unauthenticated**: it has no integrity tag. An attacker with write access to
|
||||
the ciphertext (e.g. direct metadata-DB access, a backup, or a compromised
|
||||
replica) can perform **bit-flipping / chosen-ciphertext manipulation** to
|
||||
silently alter the decrypted plaintext of a secret without detection.
|
||||
|
||||
`AesGcmEngine` (AES-GCM) is authenticated encryption: tampering causes
|
||||
decryption to fail loudly rather than yielding attacker-influenced plaintext.
|
||||
Using authenticated encryption for secrets at rest is an ASVS L1 expectation
|
||||
(11.3.2 / cryptography best practice).
|
||||
|
||||
`config.py` already documents that operators *can* switch to GCM by writing a
|
||||
custom `AbstractEncryptedFieldAdapter`, but:
|
||||
|
||||
1. it is opt-in, undocumented as a security recommendation, and easy to miss;
|
||||
2. there is **no migration path** — flipping the engine on a populated database
|
||||
makes every existing secret undecryptable, because GCM ciphertext is not
|
||||
format-compatible with CBC.
|
||||
|
||||
## Proposed change
|
||||
|
||||
A three-part change, delivered incrementally so existing deployments are never
|
||||
broken:
|
||||
|
||||
### Phase 1 — engine selection (this PR)
|
||||
|
||||
- Add a `SQLALCHEMY_ENCRYPTED_FIELD_ENGINE` config (`"aes"` | `"aes-gcm"`),
|
||||
**defaulting to `"aes"`** (no behavior change for existing installs).
|
||||
- Teach the default `SQLAlchemyUtilsAdapter` to honor it (an explicit `engine`
|
||||
kwarg still wins, so the migrator can pin an engine).
|
||||
- This lets **new** deployments choose AES-GCM from day one with a one-line
|
||||
config, instead of writing a custom adapter.
|
||||
|
||||
### Phase 2 — CBC→GCM re-encryption migrator (this PR)
|
||||
|
||||
The existing `SecretsMigrator` (previously only used for `SECRET_KEY` rotation)
|
||||
gains an **engine migration** mode that:
|
||||
|
||||
1. discovers every `EncryptedType` column (via `discover_encrypted_fields()`),
|
||||
2. decrypts each value with the **source** engine (AES-CBC) under the current
|
||||
`SECRET_KEY`,
|
||||
3. re-encrypts with the **target** engine (AES-GCM),
|
||||
4. runs transactionally per the existing all-or-nothing semantics, and is
|
||||
idempotent per column (already-migrated values are skipped), so a run can be
|
||||
safely repeated or resumed.
|
||||
|
||||
Exposed via a new `--engine` option on the existing CLI command:
|
||||
`superset re-encrypt-secrets --engine aes-gcm`, runnable by operators with a DB
|
||||
backup in hand. The `SECRET_KEY` is unchanged; an engine change and a key
|
||||
rotation can also be combined (pass `--previous_secret_key` as well).
|
||||
|
||||
### Phase 3 — flip the default for new installs
|
||||
|
||||
Once the migrator and docs are in place, change the default to `"aes-gcm"` for
|
||||
**fresh** installs only (e.g. keyed off an empty metadata DB / documented in
|
||||
`UPDATING.md`), keeping existing installs on `"aes"` until they run Phase 2.
|
||||
|
||||
## New or changed public interfaces
|
||||
|
||||
- New config: `SQLALCHEMY_ENCRYPTED_FIELD_ENGINE: Literal["aes", "aes-gcm"]`.
|
||||
- New (Phase 2) CLI: `superset re-encrypt-secrets --engine <name>`.
|
||||
- No schema changes; ciphertext format changes per migrated column.
|
||||
|
||||
## Migration plan and compatibility
|
||||
|
||||
- **Backward compatible by default.** Phase 1 changes nothing unless the
|
||||
operator opts in.
|
||||
- Switching an existing deployment to `"aes-gcm"` **without** running the Phase
|
||||
2 migrator will make existing secrets undecryptable — this is called out in
|
||||
the config comment and must be in `UPDATING.md`.
|
||||
- Recommended operator runbook: take a metadata-DB backup → run
|
||||
`re-encrypt-secrets --engine aes-gcm` → set
|
||||
`SQLALCHEMY_ENCRYPTED_FIELD_ENGINE = "aes-gcm"` → restart → re-run
|
||||
`re-encrypt-secrets --engine aes-gcm` once more to sweep up any secrets a live
|
||||
instance wrote as AES-CBC during the cutover window. The canonical, more
|
||||
detailed version of this runbook lives in `UPDATING.md`; this is a summary.
|
||||
- `AesEngine` allows queryability over encrypted fields; AES-GCM does not.
|
||||
Any code that filters/queries on an encrypted column directly must be audited
|
||||
before Phase 3 (none is expected, but it must be verified).
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
- **Flip the default immediately.** Rejected: bricks every existing
|
||||
deployment's secrets with no migration path.
|
||||
- **Document-only (custom adapter).** Status quo; high friction and no
|
||||
migration tooling — most operators will never do it.
|
||||
|
||||
## Open questions
|
||||
|
||||
- GCM→CBC rollback (for operators who need queryability) already works via the
|
||||
same command (`re-encrypt-secrets --engine aes`), since the migrator is
|
||||
engine-symmetric. Should rollback be documented as a supported path or
|
||||
discouraged?
|
||||
- The migrator already supports a concurrent `SECRET_KEY` rotation + engine
|
||||
change in a single pass (pass `--previous_secret_key` alongside `--engine`).
|
||||
Is that combination worth calling out in the operator docs, or kept advanced?
|
||||
@@ -5578,10 +5578,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
|
||||
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
|
||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||
|
||||
baseline-browser-mapping@^2.10.33, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.33"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz#27c299b096404978831958d429f48390424c4f9b"
|
||||
integrity sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==
|
||||
baseline-browser-mapping@^2.10.34, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.34"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz#dedb606362446777cfe328d30d4ee15056d06303"
|
||||
integrity sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==
|
||||
|
||||
batch@0.6.1:
|
||||
version "0.6.1"
|
||||
@@ -5824,10 +5824,10 @@ caniuse-api@^3.0.0:
|
||||
lodash.memoize "^4.1.2"
|
||||
lodash.uniq "^4.5.0"
|
||||
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001793:
|
||||
version "1.0.30001793"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz#238887ddf5fcfc8c36d872394d0a78a517312a72"
|
||||
integrity sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001797:
|
||||
version "1.0.30001797"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz#1332709e1439f01ff92085dd17001e0a45897ec0"
|
||||
integrity sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==
|
||||
|
||||
ccount@^2.0.0:
|
||||
version "2.0.1"
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
**/*{.,-}min.js
|
||||
**/*.sh
|
||||
coverage/**
|
||||
dist/*
|
||||
src/assets/images/*
|
||||
node_modules/*
|
||||
node_modules*/*
|
||||
vendor/*
|
||||
docs/*
|
||||
src/dashboard/deprecated/*
|
||||
src/temp/*
|
||||
**/node_modules
|
||||
*.d.ts
|
||||
coverage/
|
||||
esm/
|
||||
lib/
|
||||
tmp/
|
||||
storybook-static/
|
||||
@@ -1,537 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// Register TypeScript require hook so ESLint can load .ts plugin files
|
||||
require('tsx/cjs');
|
||||
|
||||
const packageConfig = require('./package.json');
|
||||
|
||||
const importCoreModules = [];
|
||||
Object.entries(packageConfig.dependencies).forEach(([pkg]) => {
|
||||
if (/@superset-ui/.test(pkg)) {
|
||||
importCoreModules.push(pkg);
|
||||
}
|
||||
});
|
||||
|
||||
// ignore files in production mode
|
||||
let ignorePatterns = [];
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
ignorePatterns = [
|
||||
'*.test.{js,ts,jsx,tsx}',
|
||||
'plugins/**/test/**/*',
|
||||
'packages/**/test/**/*',
|
||||
'packages/generator-superset/**/*',
|
||||
];
|
||||
}
|
||||
|
||||
const restrictedImportsRules = {
|
||||
'no-design-icons': {
|
||||
name: '@ant-design/icons',
|
||||
message:
|
||||
'Avoid importing icons directly from @ant-design/icons. Use the src/components/Icons component instead.',
|
||||
},
|
||||
'no-moment': {
|
||||
name: 'moment',
|
||||
message:
|
||||
'Please use the dayjs library instead of moment.js. See https://day.js.org',
|
||||
},
|
||||
'no-lodash-memoize': {
|
||||
name: 'lodash/memoize',
|
||||
message: 'Lodash Memoize is unsafe! Please use memoize-one instead',
|
||||
},
|
||||
'no-testing-library-react': {
|
||||
name: '@superset-ui/core/spec',
|
||||
message: 'Please use spec/helpers/testing-library instead',
|
||||
},
|
||||
'no-testing-library-react-dom-utils': {
|
||||
name: '@testing-library/react-dom-utils',
|
||||
message: 'Please use spec/helpers/testing-library instead',
|
||||
},
|
||||
'no-antd': {
|
||||
name: 'antd',
|
||||
message: 'Please import Ant components from the index of src/components',
|
||||
},
|
||||
'no-superset-theme': {
|
||||
name: '@superset-ui/core',
|
||||
importNames: ['supersetTheme'],
|
||||
message:
|
||||
'Please use the theme directly from the ThemeProvider rather than importing supersetTheme.',
|
||||
},
|
||||
'no-query-string': {
|
||||
name: 'query-string',
|
||||
message: 'Please use the URLSearchParams API instead of query-string.',
|
||||
},
|
||||
'no-jest-mock-console': {
|
||||
name: 'jest-mock-console',
|
||||
message: 'Please use native Jest spies, i.e. jest.spyOn(console, "warn")',
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:import/recommended',
|
||||
'plugin:react-prefer-function-component/recommended',
|
||||
'plugin:storybook/recommended',
|
||||
'prettier',
|
||||
],
|
||||
parser: '@babel/eslint-parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
requireConfigFile: false,
|
||||
babelOptions: {
|
||||
presets: ['@babel/preset-react', '@babel/preset-env'],
|
||||
},
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
es2020: true,
|
||||
},
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
node: {
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'],
|
||||
moduleDirectory: ['node_modules', '.'],
|
||||
},
|
||||
typescript: {
|
||||
alwaysTryTypes: true,
|
||||
project: [
|
||||
'./tsconfig.json',
|
||||
'./packages/superset-ui-core/tsconfig.json',
|
||||
'./packages/superset-ui-chart-controls/',
|
||||
'./plugins/*/tsconfig.json',
|
||||
],
|
||||
},
|
||||
},
|
||||
'import/core-modules': importCoreModules,
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
'import',
|
||||
'lodash',
|
||||
'theme-colors',
|
||||
'icons',
|
||||
'i18n-strings',
|
||||
'react-prefer-function-component',
|
||||
'react-you-might-not-need-an-effect',
|
||||
'prettier',
|
||||
],
|
||||
rules: {
|
||||
// === Essential Superset customizations ===
|
||||
|
||||
// Prettier integration
|
||||
'prettier/prettier': 'error',
|
||||
|
||||
// Custom Superset rules
|
||||
'theme-colors/no-literal-colors': 'error',
|
||||
'icons/no-fa-icons-usage': 'error',
|
||||
'i18n-strings/no-template-vars': 'error',
|
||||
'i18n-strings/no-eager-t-in-config': 'off', // enabled only for controlPanel files via overrides below
|
||||
|
||||
// Core ESLint overrides for Superset
|
||||
'no-console': 'warn',
|
||||
'no-unused-vars': 'off', // TypeScript handles this
|
||||
camelcase: [
|
||||
'error',
|
||||
{
|
||||
allow: ['^UNSAFE_', '__REDUX_DEVTOOLS_EXTENSION_COMPOSE__'],
|
||||
properties: 'never',
|
||||
},
|
||||
],
|
||||
'prefer-destructuring': ['error', { object: true, array: false }],
|
||||
'no-prototype-builtins': 0,
|
||||
curly: 'off',
|
||||
|
||||
// Import plugin overrides
|
||||
'import/extensions': [
|
||||
'error',
|
||||
'ignorePackages',
|
||||
{
|
||||
js: 'never',
|
||||
jsx: 'never',
|
||||
ts: 'never',
|
||||
tsx: 'never',
|
||||
},
|
||||
],
|
||||
'import/no-cycle': 0,
|
||||
'import/prefer-default-export': 0,
|
||||
'import/no-named-as-default-member': 0,
|
||||
'import/no-extraneous-dependencies': [
|
||||
'error',
|
||||
{
|
||||
devDependencies: [
|
||||
'test/**',
|
||||
'tests/**',
|
||||
'spec/**',
|
||||
'**/__tests__/**',
|
||||
'**/__mocks__/**',
|
||||
'*.test.{js,jsx,ts,tsx}',
|
||||
'*.spec.{js,jsx,ts,tsx}',
|
||||
'**/*.test.{js,jsx,ts,tsx}',
|
||||
'**/*.spec.{js,jsx,ts,tsx}',
|
||||
'**/jest.config.js',
|
||||
'**/jest.setup.js',
|
||||
'**/webpack.config.js',
|
||||
'**/webpack.config.*.js',
|
||||
'**/.eslintrc*.js',
|
||||
],
|
||||
optionalDependencies: false,
|
||||
},
|
||||
],
|
||||
|
||||
// React plugin overrides
|
||||
'react-prefer-function-component/react-prefer-function-component': 1,
|
||||
|
||||
// React effect best practices
|
||||
'react-you-might-not-need-an-effect/no-empty-effect': 'error',
|
||||
'react-you-might-not-need-an-effect/no-pass-live-state-to-parent': 'error',
|
||||
'react-you-might-not-need-an-effect/no-initialize-state': 'error',
|
||||
|
||||
// Lodash
|
||||
'lodash/import-scope': [2, 'member'],
|
||||
|
||||
// React effect best practices
|
||||
'react-you-might-not-need-an-effect/no-reset-all-state-on-prop-change':
|
||||
'error',
|
||||
'react-you-might-not-need-an-effect/no-chain-state-updates': 'error',
|
||||
'react-you-might-not-need-an-effect/no-event-handler': 'error',
|
||||
'react-you-might-not-need-an-effect/no-derived-state': 'error',
|
||||
|
||||
// Storybook
|
||||
'storybook/prefer-pascal-case': 'error',
|
||||
|
||||
// File progress
|
||||
'file-progress/activate': 1,
|
||||
|
||||
// React effect rules
|
||||
'react-you-might-not-need-an-effect/no-adjust-state-on-prop-change':
|
||||
'error',
|
||||
'react-you-might-not-need-an-effect/no-pass-data-to-parent': 'error',
|
||||
|
||||
// Restricted imports
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: Object.values(restrictedImportsRules).filter(Boolean),
|
||||
patterns: ['antd/*'],
|
||||
},
|
||||
],
|
||||
|
||||
// Temporarily disabled for migration
|
||||
'no-unsafe-optional-chaining': 0,
|
||||
'no-import-assign': 0,
|
||||
'import/no-relative-packages': 0,
|
||||
'no-promise-executor-return': 0,
|
||||
'import/no-import-module-exports': 0,
|
||||
|
||||
// Restrict certain syntax patterns
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
selector:
|
||||
"ImportDeclaration[source.value='react'] :matches(ImportDefaultSpecifier, ImportNamespaceSpecifier)",
|
||||
message:
|
||||
'Default React import is not required due to automatic JSX runtime in React 16.4',
|
||||
},
|
||||
{
|
||||
selector: 'ImportNamespaceSpecifier[parent.source.value!=/^(\\.|src)/]',
|
||||
message: 'Wildcard imports are not allowed',
|
||||
},
|
||||
],
|
||||
},
|
||||
overrides: [
|
||||
// Eager t()/tn() in `label`/`description` config props is captured at
|
||||
// module-load time, before i18n initializes — labels stay in the fallback
|
||||
// language even after the user switches. Surfaced as a warning (with
|
||||
// autofix to `() => t(...)`) wherever this is a real foot-gun:
|
||||
// controlPanel files. Many pre-existing call sites need conversion;
|
||||
// run `eslint --fix` on a controlPanel file to sweep it. Promote to
|
||||
// `'error'` once the codebase is clean.
|
||||
{
|
||||
files: ['**/controlPanel.{ts,tsx,js,jsx}'],
|
||||
rules: {
|
||||
'i18n-strings/no-eager-t-in-config': 'warn',
|
||||
},
|
||||
},
|
||||
// Ban JavaScript files in src/ - all new code must be TypeScript
|
||||
{
|
||||
files: ['src/**/*.js', 'src/**/*.jsx'],
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
selector: 'Program',
|
||||
message:
|
||||
'JavaScript files are not allowed in src/. Please use TypeScript (.ts/.tsx) instead.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// Ban JavaScript files in plugins/ - all plugin source code must be TypeScript
|
||||
{
|
||||
files: ['plugins/**/src/**/*.js', 'plugins/**/src/**/*.jsx'],
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
selector: 'Program',
|
||||
message:
|
||||
'JavaScript files are not allowed in plugins/. Please use TypeScript (.ts/.tsx) instead.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// Ban JavaScript files in packages/ - with exceptions for config files and generators
|
||||
{
|
||||
files: ['packages/**/src/**/*.js', 'packages/**/src/**/*.jsx'],
|
||||
excludedFiles: [
|
||||
'packages/generator-superset/**/*', // Yeoman generator templates run via Node
|
||||
'packages/**/__mocks__/**/*', // Test mocks
|
||||
],
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
selector: 'Program',
|
||||
message:
|
||||
'JavaScript files are not allowed in packages/. Please use TypeScript (.ts/.tsx) instead.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['*.ts', '*.tsx'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
tsconfigRootDir: __dirname,
|
||||
project: ['./tsconfig.json'],
|
||||
},
|
||||
extends: ['plugin:@typescript-eslint/recommended', 'prettier'],
|
||||
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||
rules: {
|
||||
// TypeScript-specific rule overrides
|
||||
'@typescript-eslint/ban-ts-ignore': 0,
|
||||
'@typescript-eslint/ban-ts-comment': 0,
|
||||
'@typescript-eslint/ban-types': 0,
|
||||
'@typescript-eslint/naming-convention': [
|
||||
'error',
|
||||
{
|
||||
selector: 'enum',
|
||||
format: ['PascalCase'],
|
||||
},
|
||||
{
|
||||
selector: 'enumMember',
|
||||
format: ['PascalCase'],
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-empty-function': 0,
|
||||
'@typescript-eslint/no-explicit-any': 0,
|
||||
'@typescript-eslint/no-use-before-define': 'error',
|
||||
'@typescript-eslint/no-non-null-assertion': 0,
|
||||
'@typescript-eslint/explicit-function-return-type': 0,
|
||||
'@typescript-eslint/explicit-module-boundary-types': 0,
|
||||
'@typescript-eslint/no-unused-vars': 'warn',
|
||||
'@typescript-eslint/prefer-optional-chain': 'error',
|
||||
|
||||
// Disable base rules that conflict with TS versions
|
||||
'no-unused-vars': 'off',
|
||||
'no-use-before-define': 'off',
|
||||
'no-shadow': 'off',
|
||||
|
||||
// Import overrides for TypeScript
|
||||
'import/extensions': [
|
||||
'error',
|
||||
'ignorePackages',
|
||||
{
|
||||
js: 'never',
|
||||
jsx: 'never',
|
||||
ts: 'never',
|
||||
tsx: 'never',
|
||||
},
|
||||
],
|
||||
},
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
typescript: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['packages/**'],
|
||||
rules: {
|
||||
'import/no-extraneous-dependencies': [
|
||||
'error',
|
||||
{ devDependencies: true },
|
||||
],
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
restrictedImportsRules['no-moment'],
|
||||
restrictedImportsRules['no-lodash-memoize'],
|
||||
restrictedImportsRules['no-superset-theme'],
|
||||
],
|
||||
patterns: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['plugins/**'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
restrictedImportsRules['no-moment'],
|
||||
restrictedImportsRules['no-lodash-memoize'],
|
||||
],
|
||||
patterns: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/components/**', 'src/theme/**'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: Object.values(restrictedImportsRules).filter(
|
||||
r => r.name !== 'antd',
|
||||
),
|
||||
patterns: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'*.test.ts',
|
||||
'*.test.tsx',
|
||||
'*.test.js',
|
||||
'*.test.jsx',
|
||||
'*.stories.tsx',
|
||||
'*.stories.jsx',
|
||||
'fixtures.*',
|
||||
'**/test/**/*',
|
||||
'**/tests/**/*',
|
||||
'spec/**/*',
|
||||
'**/fixtures/**/*',
|
||||
'**/__mocks__/**/*',
|
||||
'**/spec/**/*',
|
||||
],
|
||||
excludedFiles: 'cypress-base/cypress/**/*',
|
||||
plugins: ['jest-dom', 'no-only-tests', 'testing-library'],
|
||||
extends: ['plugin:jest-dom/recommended', 'plugin:testing-library/react'],
|
||||
rules: {
|
||||
'import/no-extraneous-dependencies': [
|
||||
'error',
|
||||
{ devDependencies: true },
|
||||
],
|
||||
'prefer-promise-reject-errors': 0,
|
||||
'max-classes-per-file': 0,
|
||||
|
||||
// Temporary for migration
|
||||
'testing-library/await-async-queries': 0,
|
||||
'testing-library/await-async-utils': 0,
|
||||
'testing-library/no-await-sync-events': 0,
|
||||
'testing-library/no-render-in-lifecycle': 0,
|
||||
'testing-library/no-unnecessary-act': 0,
|
||||
'testing-library/no-wait-for-multiple-assertions': 0,
|
||||
'testing-library/prefer-screen-queries': 0,
|
||||
'testing-library/await-async-events': 0,
|
||||
'testing-library/no-node-access': 0,
|
||||
'testing-library/no-wait-for-side-effects': 0,
|
||||
'testing-library/prefer-presence-queries': 0,
|
||||
'testing-library/render-result-naming-convention': 0,
|
||||
'testing-library/no-container': 0,
|
||||
'testing-library/prefer-find-by': 0,
|
||||
'testing-library/no-manual-cleanup': 0,
|
||||
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
selector:
|
||||
"ImportDeclaration[source.value='react'] :matches(ImportDefaultSpecifier, ImportNamespaceSpecifier)",
|
||||
message:
|
||||
'Default React import is not required due to automatic JSX runtime in React 16.4',
|
||||
},
|
||||
],
|
||||
'no-restricted-imports': 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'*.test.ts',
|
||||
'*.test.tsx',
|
||||
'*.test.js',
|
||||
'*.test.jsx',
|
||||
'*.stories.tsx',
|
||||
'*.stories.jsx',
|
||||
'fixtures.*',
|
||||
'**/test/**/*',
|
||||
'**/tests/**/*',
|
||||
'spec/**/*',
|
||||
'**/fixtures/**/*',
|
||||
'**/__mocks__/**/*',
|
||||
'**/spec/**/*',
|
||||
'cypress-base/cypress/**/*',
|
||||
'Stories.tsx',
|
||||
'packages/superset-ui-core/src/theme/index.tsx',
|
||||
],
|
||||
rules: {
|
||||
'theme-colors/no-literal-colors': 0,
|
||||
'icons/no-fa-icons-usage': 0,
|
||||
'i18n-strings/no-template-vars': 0,
|
||||
'no-restricted-imports': 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'packages/**/*.stories.*',
|
||||
'packages/**/*.overview.*',
|
||||
'packages/**/fixtures.*',
|
||||
],
|
||||
rules: {
|
||||
'import/no-extraneous-dependencies': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['playwright/**/*.ts', 'playwright/**/*.js'],
|
||||
rules: {
|
||||
'import/no-extraneous-dependencies': [
|
||||
'error',
|
||||
{ devDependencies: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
ignorePatterns,
|
||||
};
|
||||
@@ -1,124 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// Register TypeScript require hook so ESLint can load .ts plugin files
|
||||
require('tsx/cjs');
|
||||
|
||||
/**
|
||||
* MINIMAL ESLint config - ONLY for rules OXC doesn't support
|
||||
* This config is designed to be run alongside OXC linter
|
||||
*
|
||||
* Only covers:
|
||||
* - Custom Superset plugins (theme-colors, icons, i18n)
|
||||
* - Prettier formatting
|
||||
* - File progress indicator
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
// Don't report on eslint-disable comments for rules we don't have
|
||||
reportUnusedDisableDirectives: false,
|
||||
// Simple parser - no TypeScript needed since OXC handles that
|
||||
parser: '@babel/eslint-parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
requireConfigFile: false,
|
||||
babelOptions: {
|
||||
presets: ['@babel/preset-react', '@babel/preset-env'],
|
||||
},
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
es2020: true,
|
||||
},
|
||||
plugins: [
|
||||
// ONLY custom Superset plugins that OXC doesn't support
|
||||
'theme-colors',
|
||||
'icons',
|
||||
'i18n-strings',
|
||||
'file-progress',
|
||||
'prettier',
|
||||
],
|
||||
rules: {
|
||||
// === ONLY rules that OXC cannot handle ===
|
||||
|
||||
// Prettier integration (formatting)
|
||||
'prettier/prettier': 'error',
|
||||
|
||||
// Custom Superset plugins
|
||||
'theme-colors/no-literal-colors': 'error',
|
||||
'icons/no-fa-icons-usage': 'error',
|
||||
'i18n-strings/no-template-vars': 'error',
|
||||
'file-progress/activate': 1,
|
||||
|
||||
// Explicitly turn off all other rules to avoid conflicts
|
||||
// when the config gets merged with other configs
|
||||
'import/no-unresolved': 'off',
|
||||
'import/extensions': 'off',
|
||||
'@typescript-eslint/naming-convention': 'off',
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
// Disable custom rules in test/story files
|
||||
files: [
|
||||
'**/*.test.*',
|
||||
'**/*.spec.*',
|
||||
'**/*.stories.*',
|
||||
'**/test/**',
|
||||
'**/tests/**',
|
||||
'**/spec/**',
|
||||
'**/__tests__/**',
|
||||
'**/__mocks__/**',
|
||||
'cypress-base/**',
|
||||
'packages/superset-ui-core/src/theme/index.tsx',
|
||||
],
|
||||
rules: {
|
||||
'theme-colors/no-literal-colors': 0,
|
||||
'icons/no-fa-icons-usage': 0,
|
||||
'i18n-strings/no-template-vars': 0,
|
||||
'file-progress/activate': 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
// Only check src/ files where theme/icon rules matter
|
||||
ignorePatterns: [
|
||||
'node_modules',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'coverage',
|
||||
'*.min.js',
|
||||
'vendor',
|
||||
// Skip packages/plugins since they have different theming rules
|
||||
'packages/**',
|
||||
'plugins/**',
|
||||
// Skip generated/external files
|
||||
'*.generated.*',
|
||||
'*.config.js',
|
||||
'webpack.*',
|
||||
// Temporary analysis files
|
||||
'*.js', // Skip all standalone JS files in root
|
||||
'*.json',
|
||||
],
|
||||
};
|
||||
@@ -31,12 +31,13 @@ const plugin: { rules: Record<string, Rule.RuleModule> } = require('.');
|
||||
// Tests
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
const ruleTester = new RuleTester({ parserOptions: { ecmaVersion: 6 } });
|
||||
const ruleTester = new RuleTester({ languageOptions: { ecmaVersion: 6 } });
|
||||
const rule: Rule.RuleModule = plugin.rules['no-template-vars'];
|
||||
|
||||
const errors: Array<{ type: string }> = [
|
||||
const errors: Array<{ message: string }> = [
|
||||
{
|
||||
type: 'CallExpression',
|
||||
message:
|
||||
"Don't use variables in translation string templates. Flask-babel is a static translation service, so it can't handle strings that include variables",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -31,7 +31,10 @@ const plugin: { rules: Record<string, Rule.RuleModule> } = require('.');
|
||||
// Tests
|
||||
//------------------------------------------------------------------------------
|
||||
const ruleTester = new RuleTester({
|
||||
parserOptions: { ecmaVersion: 6, ecmaFeatures: { jsx: true } },
|
||||
languageOptions: {
|
||||
ecmaVersion: 6,
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
});
|
||||
const rule: Rule.RuleModule = plugin.rules['no-fa-icons-usage'];
|
||||
|
||||
|
||||
137
superset-frontend/eslint.config.minimal.js
Normal file
137
superset-frontend/eslint.config.minimal.js
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* MINIMAL ESLint flat config - ONLY for rules OXC doesn't support.
|
||||
*
|
||||
* This config is run alongside the OXC (oxlint) linter, which handles the
|
||||
* bulk of linting. ESLint here only covers the custom Superset plugins and
|
||||
* Prettier formatting that oxlint cannot express. It is consumed by
|
||||
* `scripts/oxlint-metrics-uploader.js` (`npm run lint-stats`).
|
||||
*
|
||||
* Migrated from the legacy `.eslintrc.minimal.js` (eslintrc) format to flat
|
||||
* config for ESLint v9+/v10, where eslintrc is no longer supported.
|
||||
*
|
||||
* Only covers:
|
||||
* - Custom Superset plugins (theme-colors, icons, i18n-strings)
|
||||
* - Prettier formatting
|
||||
*/
|
||||
|
||||
// Register the TypeScript require hook so ESLint can load the .ts plugin files
|
||||
// from eslint-rules/*.
|
||||
require('tsx/cjs');
|
||||
|
||||
const tsParser = require('@typescript-eslint/parser');
|
||||
const prettierPlugin = require('eslint-plugin-prettier');
|
||||
const themeColorsPlugin = require('eslint-plugin-theme-colors');
|
||||
const iconsPlugin = require('eslint-plugin-icons');
|
||||
const i18nStringsPlugin = require('eslint-plugin-i18n-strings');
|
||||
|
||||
module.exports = [
|
||||
// Files this config applies to. Flat config has no `--ext`; globs live here.
|
||||
// Only check src/ files where the theme/icon/i18n rules matter.
|
||||
{
|
||||
ignores: [
|
||||
'node_modules/**',
|
||||
'dist/**',
|
||||
'build/**',
|
||||
'.next/**',
|
||||
'coverage/**',
|
||||
'**/*.min.js',
|
||||
'vendor/**',
|
||||
// Skip packages/plugins since they have different theming rules
|
||||
'packages/**',
|
||||
'plugins/**',
|
||||
// Skip generated/external/config files
|
||||
'**/*.generated.*',
|
||||
'**/*.config.js',
|
||||
'**/webpack.*',
|
||||
'*.json',
|
||||
],
|
||||
},
|
||||
{
|
||||
files: ['**/*.{js,jsx,ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
// The @typescript-eslint parser handles both TS/TSX and plain JS/JSX and
|
||||
// is compatible with ESLint v10's scope manager. (The legacy
|
||||
// @babel/eslint-parser does not support ESLint v10.) The custom rules
|
||||
// here are pure AST visitors and do not require type information, so no
|
||||
// `project` is configured — this keeps parsing fast.
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
// Don't report on eslint-disable comments for rules we don't have.
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: false,
|
||||
},
|
||||
plugins: {
|
||||
prettier: prettierPlugin,
|
||||
'theme-colors': themeColorsPlugin,
|
||||
icons: iconsPlugin,
|
||||
'i18n-strings': i18nStringsPlugin,
|
||||
},
|
||||
rules: {
|
||||
// Prettier integration (formatting)
|
||||
'prettier/prettier': 'error',
|
||||
|
||||
// Custom Superset plugins
|
||||
'theme-colors/no-literal-colors': 'error',
|
||||
'icons/no-fa-icons-usage': 'error',
|
||||
'i18n-strings/no-template-vars': 'error',
|
||||
// Enabled only for controlPanel files via the override below.
|
||||
'i18n-strings/no-eager-t-in-config': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Eager t()/tn() in `label`/`description` config props is captured at
|
||||
// module-load time, before i18n initializes — labels stay in the fallback
|
||||
// language even after the user switches. Surfaced as a warning (with
|
||||
// autofix to `() => t(...)`) wherever this is a real foot-gun:
|
||||
// controlPanel files. Promote to `'error'` once the codebase is clean.
|
||||
files: ['**/controlPanel.{ts,tsx,js,jsx}'],
|
||||
rules: {
|
||||
'i18n-strings/no-eager-t-in-config': 'warn',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Disable custom rules in test/story files
|
||||
files: [
|
||||
'**/*.test.*',
|
||||
'**/*.spec.*',
|
||||
'**/*.stories.*',
|
||||
'**/test/**',
|
||||
'**/tests/**',
|
||||
'**/spec/**',
|
||||
'**/__tests__/**',
|
||||
'**/__mocks__/**',
|
||||
'cypress-base/**',
|
||||
],
|
||||
rules: {
|
||||
'theme-colors/no-literal-colors': 'off',
|
||||
'icons/no-fa-icons-usage': 'off',
|
||||
'i18n-strings/no-template-vars': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -287,13 +287,15 @@
|
||||
"ignorePatterns": [
|
||||
"packages/generator-superset/**/*",
|
||||
"cypress-base/**",
|
||||
"node_modules/**",
|
||||
"**/node_modules/**",
|
||||
"build/**",
|
||||
"dist/**",
|
||||
"lib/**",
|
||||
"esm/**",
|
||||
"*.min.js",
|
||||
"**/dist/**",
|
||||
"**/lib/**",
|
||||
"**/esm/**",
|
||||
"**/*.min.js",
|
||||
"**/*.d.ts",
|
||||
"coverage/**",
|
||||
"storybook-static/**",
|
||||
".git/**",
|
||||
"**/*.config.js",
|
||||
"**/*.config.ts"
|
||||
|
||||
650
superset-frontend/package-lock.json
generated
650
superset-frontend/package-lock.json
generated
@@ -121,7 +121,7 @@
|
||||
"query-string": "9.4.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react": "^18.2.0",
|
||||
"react-arborist": "^3.8.0",
|
||||
"react-arborist": "^3.10.1",
|
||||
"react-checkbox-tree": "^1.8.0",
|
||||
"react-diff-viewer-continued": "^4.2.2",
|
||||
"react-dnd": "^11.1.3",
|
||||
@@ -197,11 +197,11 @@
|
||||
"@types/content-disposition": "^0.5.9",
|
||||
"@types/dom-to-image": "^2.6.7",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/jquery": "^4.0.0",
|
||||
"@types/jquery": "^4.0.1",
|
||||
"@types/js-levenshtein": "^1.1.3",
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/node": "^25.9.2",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@types/react-loadable": "^5.5.11",
|
||||
@@ -214,22 +214,22 @@
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/tinycolor2": "^1.4.3",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@typescript-eslint/eslint-plugin": "^8.60.1",
|
||||
"@typescript-eslint/parser": "^8.59.4",
|
||||
"@typescript-eslint/eslint-plugin": "^8.61.0",
|
||||
"@typescript-eslint/parser": "^8.61.0",
|
||||
"babel-jest": "^30.4.1",
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"baseline-browser-mapping": "^2.10.33",
|
||||
"baseline-browser-mapping": "^2.10.34",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.3",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"css-loader": "^7.1.4",
|
||||
"css-minimizer-webpack-plugin": "^8.0.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^7.2.0",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-import-resolver-alias": "^1.1.2",
|
||||
"eslint-import-resolver-typescript": "^4.4.5",
|
||||
"eslint-plugin-cypress": "^3.6.0",
|
||||
@@ -237,11 +237,11 @@
|
||||
"eslint-plugin-icons": "file:eslint-rules/eslint-plugin-icons",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jest-dom": "^5.5.0",
|
||||
"eslint-plugin-lodash": "^7.4.0",
|
||||
"eslint-plugin-lodash": "^8.0.0",
|
||||
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.4",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.0",
|
||||
"eslint-plugin-storybook": "10.4.2",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
@@ -3805,92 +3805,108 @@
|
||||
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
|
||||
"integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
|
||||
"node_modules/@eslint/config-array": {
|
||||
"version": "0.23.5",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz",
|
||||
"integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ajv": "^6.12.4",
|
||||
"debug": "^4.3.2",
|
||||
"espree": "^9.6.0",
|
||||
"globals": "^13.19.0",
|
||||
"ignore": "^5.2.0",
|
||||
"import-fresh": "^3.2.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"minimatch": "^3.1.2",
|
||||
"strip-json-comments": "^3.1.1"
|
||||
"@eslint/object-schema": "^3.0.5",
|
||||
"debug": "^4.3.1",
|
||||
"minimatch": "^10.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/ajv": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
"json-schema-traverse": "^0.4.1",
|
||||
"uri-js": "^4.2.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/js-yaml": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
|
||||
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "8.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
|
||||
"integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
|
||||
"node_modules/@eslint/config-array/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array/node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-helpers": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz",
|
||||
"integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/core": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/core": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz",
|
||||
"integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.15"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/object-schema": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",
|
||||
"integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/plugin-kit": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz",
|
||||
"integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/core": "^1.2.1",
|
||||
"levn": "^0.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@exodus/bytes": {
|
||||
@@ -4069,20 +4085,42 @@
|
||||
"@hapi/hoek": "^11.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||
"integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==",
|
||||
"deprecated": "Use @eslint/config-array instead",
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||
"integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@humanwhocodes/object-schema": "^2.0.3",
|
||||
"debug": "^4.3.1",
|
||||
"minimatch": "^3.0.5"
|
||||
"@humanfs/types": "^0.15.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.10.0"
|
||||
"node": ">=18.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/node": {
|
||||
"version": "0.16.8",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
|
||||
"integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@humanfs/core": "^0.19.2",
|
||||
"@humanfs/types": "^0.15.0",
|
||||
"@humanwhocodes/retry": "^0.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/types": {
|
||||
"version": "0.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
|
||||
"integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/module-importer": {
|
||||
@@ -4099,13 +4137,19 @@
|
||||
"url": "https://github.com/sponsors/nzakas"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/object-schema": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
|
||||
"integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
|
||||
"deprecated": "Use @eslint/object-schema instead",
|
||||
"node_modules/@humanwhocodes/retry": {
|
||||
"version": "0.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
|
||||
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nzakas"
|
||||
}
|
||||
},
|
||||
"node_modules/@hutson/parse-repository-url": {
|
||||
"version": "3.0.2",
|
||||
@@ -11305,6 +11349,26 @@
|
||||
"integrity": "sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/eslint": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "*",
|
||||
"@types/json-schema": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/esrecurse": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||
"integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -11511,9 +11575,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jquery": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-4.0.0.tgz",
|
||||
"integrity": "sha512-Z+to+A2VkaHq1DfI2oSwsoCdhCHMpTSgjWzNcbNlRGYzksDBpPUgEcAL+RQjOBJRaLoEAOHXxqDGBVP+BblBwg==",
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-4.0.1.tgz",
|
||||
"integrity": "sha512-9a59A/tycXgYuPABcp6/3spSShn0NT2UOM4EfHvMumjYi4lJWTsK5SZWjhx3yRm9IHGCeWXdV2YfNsrWrft/CA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -11631,9 +11695,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
|
||||
"version": "25.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz",
|
||||
"integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
@@ -12097,17 +12161,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz",
|
||||
"integrity": "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==",
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz",
|
||||
"integrity": "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.60.1",
|
||||
"@typescript-eslint/type-utils": "8.60.1",
|
||||
"@typescript-eslint/utils": "8.60.1",
|
||||
"@typescript-eslint/visitor-keys": "8.60.1",
|
||||
"@typescript-eslint/scope-manager": "8.61.0",
|
||||
"@typescript-eslint/type-utils": "8.61.0",
|
||||
"@typescript-eslint/utils": "8.61.0",
|
||||
"@typescript-eslint/visitor-keys": "8.61.0",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
@@ -12120,7 +12184,7 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.60.1",
|
||||
"@typescript-eslint/parser": "^8.61.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
@@ -12136,16 +12200,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz",
|
||||
"integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==",
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz",
|
||||
"integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.60.1",
|
||||
"@typescript-eslint/types": "8.60.1",
|
||||
"@typescript-eslint/typescript-estree": "8.60.1",
|
||||
"@typescript-eslint/visitor-keys": "8.60.1",
|
||||
"@typescript-eslint/scope-manager": "8.61.0",
|
||||
"@typescript-eslint/types": "8.61.0",
|
||||
"@typescript-eslint/typescript-estree": "8.61.0",
|
||||
"@typescript-eslint/visitor-keys": "8.61.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -12161,14 +12225,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz",
|
||||
"integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==",
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz",
|
||||
"integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.60.1",
|
||||
"@typescript-eslint/types": "^8.60.1",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.61.0",
|
||||
"@typescript-eslint/types": "^8.61.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -12183,14 +12247,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz",
|
||||
"integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==",
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz",
|
||||
"integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.60.1",
|
||||
"@typescript-eslint/visitor-keys": "8.60.1"
|
||||
"@typescript-eslint/types": "8.61.0",
|
||||
"@typescript-eslint/visitor-keys": "8.61.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -12201,9 +12265,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz",
|
||||
"integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==",
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz",
|
||||
"integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -12218,15 +12282,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz",
|
||||
"integrity": "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==",
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz",
|
||||
"integrity": "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.60.1",
|
||||
"@typescript-eslint/typescript-estree": "8.60.1",
|
||||
"@typescript-eslint/utils": "8.60.1",
|
||||
"@typescript-eslint/types": "8.61.0",
|
||||
"@typescript-eslint/typescript-estree": "8.61.0",
|
||||
"@typescript-eslint/utils": "8.61.0",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
@@ -12243,9 +12307,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz",
|
||||
"integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==",
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz",
|
||||
"integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -12257,16 +12321,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz",
|
||||
"integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==",
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz",
|
||||
"integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.60.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.60.1",
|
||||
"@typescript-eslint/types": "8.60.1",
|
||||
"@typescript-eslint/visitor-keys": "8.60.1",
|
||||
"@typescript-eslint/project-service": "8.61.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.61.0",
|
||||
"@typescript-eslint/types": "8.61.0",
|
||||
"@typescript-eslint/visitor-keys": "8.61.0",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@@ -12324,16 +12388,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.1.tgz",
|
||||
"integrity": "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==",
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.0.tgz",
|
||||
"integrity": "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.60.1",
|
||||
"@typescript-eslint/types": "8.60.1",
|
||||
"@typescript-eslint/typescript-estree": "8.60.1"
|
||||
"@typescript-eslint/scope-manager": "8.61.0",
|
||||
"@typescript-eslint/types": "8.61.0",
|
||||
"@typescript-eslint/typescript-estree": "8.61.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -12348,13 +12412,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz",
|
||||
"integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==",
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz",
|
||||
"integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.60.1",
|
||||
"@typescript-eslint/types": "8.61.0",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -14404,9 +14468,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.33",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz",
|
||||
"integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==",
|
||||
"version": "2.10.34",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz",
|
||||
"integrity": "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -18856,71 +18920,73 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "8.57.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz",
|
||||
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
|
||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.1.tgz",
|
||||
"integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
"@eslint/eslintrc": "^2.1.4",
|
||||
"@eslint/js": "8.57.1",
|
||||
"@humanwhocodes/config-array": "^0.13.0",
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@eslint/config-array": "^0.23.5",
|
||||
"@eslint/config-helpers": "^0.6.0",
|
||||
"@eslint/core": "^1.2.1",
|
||||
"@eslint/plugin-kit": "^0.7.2",
|
||||
"@humanfs/node": "^0.16.6",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
"@nodelib/fs.walk": "^1.2.8",
|
||||
"@ungap/structured-clone": "^1.2.0",
|
||||
"ajv": "^6.12.4",
|
||||
"chalk": "^4.0.0",
|
||||
"cross-spawn": "^7.0.2",
|
||||
"@humanwhocodes/retry": "^0.4.2",
|
||||
"@types/estree": "^1.0.6",
|
||||
"ajv": "^6.14.0",
|
||||
"cross-spawn": "^7.0.6",
|
||||
"debug": "^4.3.2",
|
||||
"doctrine": "^3.0.0",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"eslint-scope": "^7.2.2",
|
||||
"eslint-visitor-keys": "^3.4.3",
|
||||
"espree": "^9.6.1",
|
||||
"esquery": "^1.4.2",
|
||||
"eslint-scope": "^9.1.2",
|
||||
"eslint-visitor-keys": "^5.0.1",
|
||||
"espree": "^11.2.0",
|
||||
"esquery": "^1.7.0",
|
||||
"esutils": "^2.0.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"file-entry-cache": "^6.0.1",
|
||||
"file-entry-cache": "^8.0.0",
|
||||
"find-up": "^5.0.0",
|
||||
"glob-parent": "^6.0.2",
|
||||
"globals": "^13.19.0",
|
||||
"graphemer": "^1.4.0",
|
||||
"ignore": "^5.2.0",
|
||||
"imurmurhash": "^0.1.4",
|
||||
"is-glob": "^4.0.0",
|
||||
"is-path-inside": "^3.0.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
"levn": "^0.4.1",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"minimatch": "^3.1.2",
|
||||
"minimatch": "^10.2.4",
|
||||
"natural-compare": "^1.4.0",
|
||||
"optionator": "^0.9.3",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"text-table": "^0.2.0"
|
||||
"optionator": "^0.9.3"
|
||||
},
|
||||
"bin": {
|
||||
"eslint": "bin/eslint.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
"url": "https://eslint.org/donate"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"jiti": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"jiti": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-prettier": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-7.2.0.tgz",
|
||||
"integrity": "sha512-rV4Qu0C3nfJKPOAhFujFxB7RMP+URFyQqqOZW9DMRD7ZDTFyjaIlETU3xzHELt++4ugC0+Jm084HQYkkJe+Ivg==",
|
||||
"version": "10.1.8",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
|
||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint-config-prettier"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": ">=7.0.0"
|
||||
}
|
||||
@@ -19198,9 +19264,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-lodash": {
|
||||
"version": "7.4.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-lodash/-/eslint-plugin-lodash-7.4.0.tgz",
|
||||
"integrity": "sha512-Tl83UwVXqe1OVeBRKUeWcfg6/pCW1GTRObbdnbEJgYwjxp5Q92MEWQaH9+dmzbRt6kvYU1Mp893E79nJiCSM8A==",
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-lodash/-/eslint-plugin-lodash-8.0.0.tgz",
|
||||
"integrity": "sha512-7DA8485FolmWRzh+8t4S8Pzin2TTuWfb0ZW3j/2fYElgk82ZanFz8vDcvc4BBPceYdX1p/za+tkbO68maDBGGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -19210,7 +19276,7 @@
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": ">=2"
|
||||
"eslint": ">=9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-no-only-tests": {
|
||||
@@ -19262,9 +19328,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint-plugin-react-you-might-not-need-an-effect": {
|
||||
"version": "0.10.4",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-you-might-not-need-an-effect/-/eslint-plugin-react-you-might-not-need-an-effect-0.10.4.tgz",
|
||||
"integrity": "sha512-T6UFIOl2yWzVJ7LRk27z6EbJm2pfO4+VCTp2TBRsmAUREkDFUXjtWxoD9NsDcg6NmMFETZLbAD1XzV/w/GOmqw==",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-you-might-not-need-an-effect/-/eslint-plugin-react-you-might-not-need-an-effect-1.0.0.tgz",
|
||||
"integrity": "sha512-8exakZ5dCJdZb7TA3P8vD47HlnM3IllA9sjKzU22wyLEx0PZBDjFPxT5+5Rb10tSE6uWmwoBsgClIDNuJ8UW6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -19376,38 +19442,56 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"node_modules/eslint/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/eslint-scope": {
|
||||
"version": "7.2.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
|
||||
"integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
|
||||
"version": "9.1.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
|
||||
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@types/esrecurse": "^4.3.1",
|
||||
"@types/estree": "^1.0.8",
|
||||
"esrecurse": "^4.3.0",
|
||||
"estraverse": "^5.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/eslint-visitor-keys": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
|
||||
"integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
@@ -19426,29 +19510,6 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/js-yaml": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
|
||||
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
@@ -19456,19 +19517,35 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint/node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/espree": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
|
||||
"integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
|
||||
"integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"acorn": "^8.9.0",
|
||||
"acorn": "^8.16.0",
|
||||
"acorn-jsx": "^5.3.2",
|
||||
"eslint-visitor-keys": "^3.4.1"
|
||||
"eslint-visitor-keys": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
@@ -19488,13 +19565,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/espree/node_modules/eslint-visitor-keys": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
|
||||
"integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
@@ -19514,9 +19591,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esquery": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
|
||||
"integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
|
||||
"integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
@@ -20042,16 +20119,30 @@
|
||||
}
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||
"integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"flat-cache": "^3.0.4"
|
||||
"flat-cache": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10.12.0 || >=12.0.0"
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/file-entry-cache/node_modules/flat-cache": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
|
||||
"integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"flatted": "^3.2.9",
|
||||
"keyv": "^4.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/file-saver": {
|
||||
@@ -21824,13 +21915,6 @@
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/graphemer": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
|
||||
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/h3-js": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/h3-js/-/h3-js-4.2.1.tgz",
|
||||
@@ -23710,16 +23794,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-path-inside": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
|
||||
"integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-obj": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz",
|
||||
@@ -35324,9 +35398,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-arborist": {
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/react-arborist/-/react-arborist-3.8.0.tgz",
|
||||
"integrity": "sha512-66UQK2mWtodjkHg4efiIYjQt0VlFhQ4LXTphcqHi0+1Jc7hAxcxAC1SbmCUCpZYUW7C+14WgsnYgBRqG0AYb1A==",
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/react-arborist/-/react-arborist-3.10.1.tgz",
|
||||
"integrity": "sha512-P9WJuj2zvtNGVzaNqTKIIBeS5aDMfvXdYT9KDVOZaALHwcfGJQON+X+gbMscTdAb5frdXDHBWl677HX3XEV1PA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-dnd": "^14.0.3",
|
||||
@@ -44420,9 +44494,9 @@
|
||||
"@types/d3-scale": "^2.1.1",
|
||||
"@types/d3-time": "^3.0.4",
|
||||
"@types/d3-time-format": "^4.0.3",
|
||||
"@types/jquery": "^4.0.0",
|
||||
"@types/jquery": "^4.0.1",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/node": "^25.9.2",
|
||||
"@types/prop-types": "^15.7.15",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/react-table": "^7.7.20",
|
||||
@@ -44489,6 +44563,16 @@
|
||||
"react-dom": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/@types/node": {
|
||||
"version": "25.9.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz",
|
||||
"integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
|
||||
@@ -204,7 +204,7 @@
|
||||
"query-string": "9.4.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react": "^18.2.0",
|
||||
"react-arborist": "^3.8.0",
|
||||
"react-arborist": "^3.10.1",
|
||||
"react-checkbox-tree": "^1.8.0",
|
||||
"react-diff-viewer-continued": "^4.2.2",
|
||||
"react-dnd": "^11.1.3",
|
||||
@@ -280,11 +280,11 @@
|
||||
"@types/content-disposition": "^0.5.9",
|
||||
"@types/dom-to-image": "^2.6.7",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/jquery": "^4.0.0",
|
||||
"@types/jquery": "^4.0.1",
|
||||
"@types/js-levenshtein": "^1.1.3",
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/node": "^25.9.2",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@types/react-loadable": "^5.5.11",
|
||||
@@ -297,22 +297,22 @@
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/tinycolor2": "^1.4.3",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@typescript-eslint/eslint-plugin": "^8.60.1",
|
||||
"@typescript-eslint/parser": "^8.59.4",
|
||||
"@typescript-eslint/eslint-plugin": "^8.61.0",
|
||||
"@typescript-eslint/parser": "^8.61.0",
|
||||
"babel-jest": "^30.4.1",
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"baseline-browser-mapping": "^2.10.33",
|
||||
"baseline-browser-mapping": "^2.10.34",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.3",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"css-loader": "^7.1.4",
|
||||
"css-minimizer-webpack-plugin": "^8.0.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^7.2.0",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-import-resolver-alias": "^1.1.2",
|
||||
"eslint-import-resolver-typescript": "^4.4.5",
|
||||
"eslint-plugin-cypress": "^3.6.0",
|
||||
@@ -320,11 +320,11 @@
|
||||
"eslint-plugin-icons": "file:eslint-rules/eslint-plugin-icons",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jest-dom": "^5.5.0",
|
||||
"eslint-plugin-lodash": "^7.4.0",
|
||||
"eslint-plugin-lodash": "^8.0.0",
|
||||
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.4",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.0",
|
||||
"eslint-plugin-storybook": "10.4.2",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
@@ -414,7 +414,16 @@
|
||||
"@jest/types": "^30.4.0",
|
||||
"jest-util": "^30.4.0",
|
||||
"jest-circus": "^30.4.0",
|
||||
"jest-environment-node": "^30.4.0"
|
||||
"jest-environment-node": "^30.4.0",
|
||||
"@babel/eslint-parser": {
|
||||
"eslint": "$eslint"
|
||||
},
|
||||
"eslint-plugin-import": {
|
||||
"eslint": "$eslint"
|
||||
},
|
||||
"eslint-plugin-jest-dom": {
|
||||
"eslint": "$eslint"
|
||||
}
|
||||
},
|
||||
"readme": "ERROR: No README data found!",
|
||||
"scarfSettings": {
|
||||
|
||||
@@ -75,9 +75,9 @@
|
||||
"@types/d3-scale": "^2.1.1",
|
||||
"@types/d3-time": "^3.0.4",
|
||||
"@types/d3-time-format": "^4.0.3",
|
||||
"@types/jquery": "^4.0.0",
|
||||
"@types/jquery": "^4.0.1",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/node": "^25.9.2",
|
||||
"@types/prop-types": "^15.7.15",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/react-table": "^7.7.20",
|
||||
|
||||
@@ -70,11 +70,46 @@ test('a change event that arrives before isEditing flips is not dropped', () =>
|
||||
});
|
||||
|
||||
test('prop changes mid-edit do not clobber unsaved typing', async () => {
|
||||
const { rerender } = render(<Harness initialTitle="Foo" />);
|
||||
// Rerender DynamicEditableTitle directly with a changed title prop so the
|
||||
// sync effect actually runs. Going through Harness would not exercise the
|
||||
// bug because Harness owns its own state and only reads initialTitle once.
|
||||
const onSave = jest.fn();
|
||||
const props = {
|
||||
placeholder: 'placeholder',
|
||||
canEdit: true,
|
||||
label: 'Title',
|
||||
onSave,
|
||||
};
|
||||
const { rerender } = render(<DynamicEditableTitle {...props} title="Foo" />);
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
userEvent.click(input);
|
||||
await userEvent.type(input, 'X', { delay: 1 });
|
||||
expect(input.value).toBe('FooX');
|
||||
rerender(<Harness initialTitle="Foo" />);
|
||||
rerender(<DynamicEditableTitle {...props} title="Bar" />);
|
||||
expect(input.value).toBe('FooX');
|
||||
// Locks in commit semantics: blur after a real edit must persist the
|
||||
// user's typed value, even when a competing parent-driven title arrived
|
||||
// mid-edit.
|
||||
fireEvent.blur(input);
|
||||
expect(onSave).toHaveBeenCalledWith('FooX');
|
||||
});
|
||||
|
||||
test('passive focus then parent-driven title change then blur does not revert', () => {
|
||||
// Phantom-revert scenario: user clicks the input but does not type, the
|
||||
// parent autosaves a new title from elsewhere, then the user blurs. The
|
||||
// component must NOT call onSave with the stale local value, otherwise it
|
||||
// would silently overwrite the parent's update.
|
||||
const onSave = jest.fn();
|
||||
const props = {
|
||||
placeholder: 'placeholder',
|
||||
canEdit: true,
|
||||
label: 'Title',
|
||||
onSave,
|
||||
};
|
||||
const { rerender } = render(<DynamicEditableTitle {...props} title="Foo" />);
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
userEvent.click(input);
|
||||
rerender(<DynamicEditableTitle {...props} title="Bar" />);
|
||||
fireEvent.blur(input);
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -81,12 +81,25 @@ export const DynamicEditableTitle = memo(
|
||||
|
||||
const sizerRef = useRef<HTMLSpanElement>(null);
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
// Tracks whether the user has actually typed since entering edit mode.
|
||||
// Gates onSave so that passive focus (click without typing) followed by a
|
||||
// parent-driven title change and blur does not silently revert the
|
||||
// parent's update with our stale currentTitle.
|
||||
const dirtyRef = useRef(false);
|
||||
const { width: containerWidth, ref: containerRef } = useResizeDetector({
|
||||
refreshMode: 'debounce',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentTitle(title);
|
||||
// Don't overwrite in-flight user input when the parent re-renders with a
|
||||
// new title prop mid-edit. handleBlur already syncs currentTitle on commit;
|
||||
// re-running this effect when isEditing flips would resync to a stale
|
||||
// title prop, so isEditing is intentionally read via closure rather than
|
||||
// listed as a dep.
|
||||
if (!isEditing) {
|
||||
setCurrentTitle(title);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [title]);
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
@@ -138,10 +151,19 @@ export const DynamicEditableTitle = memo(
|
||||
return;
|
||||
}
|
||||
const formattedTitle = currentTitle.trim();
|
||||
setCurrentTitle(formattedTitle);
|
||||
if (title !== formattedTitle) {
|
||||
// Only commit when the user actually typed. Passive focus must not
|
||||
// overwrite a parent-driven title change that landed mid-edit.
|
||||
if (dirtyRef.current && title !== formattedTitle) {
|
||||
setCurrentTitle(formattedTitle);
|
||||
onSave(formattedTitle);
|
||||
} else if (!dirtyRef.current) {
|
||||
// Drop any stale local state and resync to the latest title prop so a
|
||||
// subsequent edit starts from the current parent value.
|
||||
setCurrentTitle(title);
|
||||
} else {
|
||||
setCurrentTitle(formattedTitle);
|
||||
}
|
||||
dirtyRef.current = false;
|
||||
setIsEditing(false);
|
||||
}, [canEdit, currentTitle, onSave, title]);
|
||||
|
||||
@@ -158,6 +180,7 @@ export const DynamicEditableTitle = memo(
|
||||
if (!isEditing) {
|
||||
setIsEditing(true);
|
||||
}
|
||||
dirtyRef.current = true;
|
||||
setCurrentTitle(ev.target.value);
|
||||
},
|
||||
[canEdit, isEditing],
|
||||
|
||||
@@ -29,12 +29,33 @@ export class ExplorePage {
|
||||
private static readonly SELECTORS = {
|
||||
DATASOURCE_CONTROL: '[data-test="datasource-control"]',
|
||||
VIZ_SWITCHER: '[data-test="fast-viz-switcher"]',
|
||||
CHART_CONTAINER: '[data-test="chart-container"]',
|
||||
} as const;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates to the Explore page for a given chart and waits for it to load.
|
||||
*
|
||||
* @param chartId - ID of the chart (slice) to open
|
||||
* @param options - Optional wait options
|
||||
*/
|
||||
async goto(chartId: number, options?: { timeout?: number }): Promise<void> {
|
||||
await this.page.goto(`explore/?slice_id=${chartId}`);
|
||||
await this.waitForPageLoad(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the chart container locator (where the rendered viz appears).
|
||||
*
|
||||
* @returns Locator for the chart container
|
||||
*/
|
||||
getChartContainer(): Locator {
|
||||
return this.page.locator(ExplorePage.SELECTORS.CHART_CONTAINER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the Explore page to load.
|
||||
* Validates URL contains /explore/ and datasource control is visible.
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Regression for #32960: the `formatDate` Handlebars helper (provided by
|
||||
* just-handlebars-helpers) stopped working after 4.1.2, rendering
|
||||
* "i is not a function" (minified) / "moment is not a function" (dev) instead
|
||||
* of the formatted date. The library helper resolves `moment` lazily via
|
||||
* `global.moment` / `require('moment/min/moment-with-locales')`, which the
|
||||
* bundled HandlebarsViewer no longer satisfies (it switched to dayjs).
|
||||
*
|
||||
* The fix registers a dayjs-backed `formatDate` override in HandlebarsViewer
|
||||
* (superset-frontend/plugins/plugin-chart-handlebars). This spec guards it: it
|
||||
* creates a Handlebars chart whose template uses `{{formatDate 'DD.MM.YYYY' ds}}`
|
||||
* and asserts the chart renders a real formatted date rather than the helper
|
||||
* error. Because the failure was a bundling/minification artifact (moment
|
||||
* resolves fine under Jest's Node `require`), an E2E test is required to cover it.
|
||||
*/
|
||||
import { testWithAssets, expect } from '../../helpers/fixtures';
|
||||
import { apiPostChart } from '../../helpers/api/chart';
|
||||
import { getDatasetByName } from '../../helpers/api/dataset';
|
||||
import { ExplorePage } from '../../pages/ExplorePage';
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
|
||||
const DATASET_NAME = 'birth_names';
|
||||
|
||||
testWithAssets(
|
||||
'Handlebars formatDate helper renders a formatted date (#32960)',
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
const dataset = await getDatasetByName(page, DATASET_NAME);
|
||||
if (!dataset) {
|
||||
throw new Error(`Dataset ${DATASET_NAME} not found`);
|
||||
}
|
||||
const datasetId = dataset.id;
|
||||
|
||||
const params = {
|
||||
datasource: `${datasetId}__table`,
|
||||
viz_type: 'handlebars',
|
||||
query_mode: 'aggregate',
|
||||
groupby: ['ds'],
|
||||
metrics: ['count'],
|
||||
adhoc_filters: [],
|
||||
row_limit: 5,
|
||||
// Note: HTML_SANITIZATION (on by default) strips non-allowlisted
|
||||
// attributes such as `class`, so the rendered markup is plain
|
||||
// <ul>/<li> elements. The assertions below target `li` directly.
|
||||
handlebarsTemplate:
|
||||
'<ul>{{#each data}}' +
|
||||
"<li>{{formatDate 'DD.MM.YYYY' ds}}</li>" +
|
||||
'{{/each}}</ul>',
|
||||
styleTemplate: '',
|
||||
};
|
||||
|
||||
const chartResp = await apiPostChart(page, {
|
||||
slice_name: `handlebars_format_date_${Date.now()}`,
|
||||
viz_type: 'handlebars',
|
||||
datasource_id: datasetId,
|
||||
datasource_type: 'table',
|
||||
params: JSON.stringify(params),
|
||||
});
|
||||
expect(chartResp.ok()).toBe(true);
|
||||
// The chart API may return either a top-level `{ id }` or a wrapped
|
||||
// `{ result: { id } }` shape; handle both and fail explicitly otherwise.
|
||||
const chartBody = await chartResp.json();
|
||||
const chartId: number = chartBody.result?.id ?? chartBody.id;
|
||||
expect(chartId, 'chart creation should return an id').toBeTruthy();
|
||||
testAssets.trackChart(chartId);
|
||||
|
||||
const explorePage = new ExplorePage(page);
|
||||
await explorePage.goto(chartId);
|
||||
|
||||
const panel = explorePage.getChartContainer();
|
||||
await panel.waitFor({ state: 'visible', timeout: TIMEOUT.PAGE_LOAD });
|
||||
|
||||
// The helper error surfaces as a "... is not a function" message rendered
|
||||
// in place of the chart content.
|
||||
await expect(panel).not.toContainText('is not a function', {
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// At least one list item should contain a DD.MM.YYYY formatted date.
|
||||
await expect(panel.locator('li').first()).toHaveText(/\d{2}\.\d{2}\.\d{4}/, {
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Regression for #33406: in a Pivot Table (v2) with nested rows, collapsing a
|
||||
* row group with the [-] toggle should stay collapsed after the collapsed rows
|
||||
* scroll out of the viewport and back. The bug reproduces specifically when the
|
||||
* dashboard is embedded via an iframe — the collapse/expand state lives in the
|
||||
* pivot renderer's local React state (`collapsedRows` initialised to `{}`), so
|
||||
* anything that remounts the chart resets it and the rows re-expand.
|
||||
*
|
||||
* This spec runs on the embedded harness (the only place the bug is reported to
|
||||
* reproduce). It collapses a top-level row, scrolls the embedded dashboard so
|
||||
* the pivot leaves and re-enters the viewport, and asserts the row is still
|
||||
* collapsed.
|
||||
*
|
||||
* CI green => collapse state survives the scroll round-trip; merging closes
|
||||
* #33406 and guards against regressions.
|
||||
* CI red => the rows re-expanded; the bug is live and the fix belongs in
|
||||
* plugin-chart-pivot-table (lift collapse state out of transient
|
||||
* component state, e.g. persist `collapsedRows`/`collapsedCols`).
|
||||
*
|
||||
* NOTE: the embedded suite only runs when the embedded SDK bundle is built and
|
||||
* INCLUDE_EMBEDDED=true (CI sets both). It is skipped otherwise.
|
||||
*/
|
||||
import { test, expect, Browser, BrowserContext, Page } from '@playwright/test';
|
||||
import { createServer, IncomingMessage, ServerResponse, Server } from 'http';
|
||||
import { AddressInfo, Socket } from 'net';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
apiEnableEmbedding,
|
||||
getAccessToken,
|
||||
getGuestToken,
|
||||
} from '../../helpers/api/embedded';
|
||||
import { apiPost, apiPut } from '../../helpers/api/requests';
|
||||
import { apiPostDashboard, apiDeleteDashboard } from '../../helpers/api/dashboard';
|
||||
import { apiDeleteChart } from '../../helpers/api/chart';
|
||||
import { EmbeddedPage } from '../../pages/EmbeddedPage';
|
||||
import { EMBEDDED } from '../../utils/constants';
|
||||
|
||||
const SUPERSET_DOMAIN = (() => {
|
||||
const url = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088';
|
||||
return url.replace(/\/+$/, '');
|
||||
})();
|
||||
const SUPERSET_BASE_URL = SUPERSET_DOMAIN.endsWith('/')
|
||||
? SUPERSET_DOMAIN
|
||||
: `${SUPERSET_DOMAIN}/`;
|
||||
|
||||
const SDK_BUNDLE_PATH = join(
|
||||
__dirname,
|
||||
'../../../../superset-embedded-sdk/bundle/index.js',
|
||||
);
|
||||
const EMBED_APP_DIR = join(__dirname, '../../embedded-app');
|
||||
const INDEX_HTML_PATH = join(EMBED_APP_DIR, 'index.html');
|
||||
const DATASET_NAME = 'birth_names';
|
||||
|
||||
interface EmbedAppServer {
|
||||
server: Server;
|
||||
url: string;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
async function startEmbedAppServer(): Promise<EmbedAppServer> {
|
||||
const sockets = new Set<Socket>();
|
||||
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
||||
const urlPath = req.url?.split('?')[0] || '/';
|
||||
if (urlPath === '/sdk/index.js') {
|
||||
if (!existsSync(SDK_BUNDLE_PATH)) {
|
||||
res.writeHead(404);
|
||||
res.end(
|
||||
'SDK bundle not found. Run: cd superset-embedded-sdk && npm ci && npm run build',
|
||||
);
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'text/javascript' });
|
||||
res.end(readFileSync(SDK_BUNDLE_PATH));
|
||||
return;
|
||||
}
|
||||
if (urlPath === '/' || urlPath === '/index.html') {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(readFileSync(INDEX_HTML_PATH));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
});
|
||||
server.on('connection', socket => {
|
||||
sockets.add(socket);
|
||||
socket.once('close', () => sockets.delete(socket));
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
server.removeListener('error', reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
const address = server.address() as AddressInfo;
|
||||
return {
|
||||
server,
|
||||
url: `http://127.0.0.1:${address.port}`,
|
||||
close: () =>
|
||||
new Promise<void>(resolve => {
|
||||
for (const socket of sockets) socket.destroy();
|
||||
sockets.clear();
|
||||
server.close(() => resolve());
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createAdminContext(browser: Browser): Promise<BrowserContext> {
|
||||
return browser.newContext({
|
||||
storageState: 'playwright/.auth/user.json',
|
||||
baseURL: SUPERSET_BASE_URL,
|
||||
});
|
||||
}
|
||||
|
||||
async function findDatasetIdByName(page: Page, name: string): Promise<number> {
|
||||
const query = `(filters:!((col:table_name,opr:eq,value:'${name}')))`;
|
||||
const resp = await page.request.get(`api/v1/dataset/?q=${query}`);
|
||||
const body = await resp.json();
|
||||
if (!body.result?.length) {
|
||||
throw new Error(`Dataset ${name} not found`);
|
||||
}
|
||||
return body.result[0].id;
|
||||
}
|
||||
|
||||
test.describe('Embedded Pivot Table collapse state (#33406)', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
test.setTimeout(90000);
|
||||
|
||||
let appServer: EmbedAppServer;
|
||||
let accessToken: string;
|
||||
let embedUuid: string;
|
||||
let dashboardId: number;
|
||||
let chartId: number;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
test.skip(
|
||||
!existsSync(SDK_BUNDLE_PATH),
|
||||
'Embedded SDK bundle not found. Build it with: cd superset-embedded-sdk && npm ci && npm run build',
|
||||
);
|
||||
|
||||
appServer = await startEmbedAppServer();
|
||||
const context = await createAdminContext(browser);
|
||||
const setupPage = await context.newPage();
|
||||
try {
|
||||
const datasetId = await findDatasetIdByName(setupPage, DATASET_NAME);
|
||||
|
||||
const params = {
|
||||
datasource: `${datasetId}__table`,
|
||||
viz_type: 'pivot_table_v2',
|
||||
groupbyRows: ['state', 'name'],
|
||||
groupbyColumns: [],
|
||||
metrics: ['count'],
|
||||
metricsLayout: 'COLUMNS',
|
||||
aggregateFunction: 'Count',
|
||||
rowSubTotals: true,
|
||||
rowTotals: true,
|
||||
valueFormat: 'SMART_NUMBER',
|
||||
row_limit: 1000,
|
||||
order_desc: true,
|
||||
};
|
||||
const chartResp = await apiPost(setupPage, 'api/v1/chart/', {
|
||||
slice_name: `pivot_collapse_repro_${Date.now()}`,
|
||||
viz_type: 'pivot_table_v2',
|
||||
datasource_id: datasetId,
|
||||
datasource_type: 'table',
|
||||
params: JSON.stringify(params),
|
||||
});
|
||||
chartId = (await chartResp.json()).id;
|
||||
|
||||
const chartLayoutKey = `CHART-${chartId}`;
|
||||
const positionJson = {
|
||||
DASHBOARD_VERSION_KEY: 'v2',
|
||||
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['GRID_ID'] },
|
||||
GRID_ID: {
|
||||
type: 'GRID',
|
||||
id: 'GRID_ID',
|
||||
children: ['ROW-1'],
|
||||
parents: ['ROOT_ID'],
|
||||
},
|
||||
'ROW-1': {
|
||||
type: 'ROW',
|
||||
id: 'ROW-1',
|
||||
children: [chartLayoutKey],
|
||||
parents: ['ROOT_ID', 'GRID_ID'],
|
||||
meta: { background: 'BACKGROUND_TRANSPARENT' },
|
||||
},
|
||||
[chartLayoutKey]: {
|
||||
type: 'CHART',
|
||||
id: chartLayoutKey,
|
||||
children: [],
|
||||
parents: ['ROOT_ID', 'GRID_ID', 'ROW-1'],
|
||||
meta: {
|
||||
chartId,
|
||||
width: 6,
|
||||
height: 80,
|
||||
sliceName: 'pivot_collapse_repro',
|
||||
},
|
||||
},
|
||||
};
|
||||
const dashResp = await apiPostDashboard(setupPage, {
|
||||
dashboard_title: `pivot_collapse_repro_${Date.now()}`,
|
||||
published: true,
|
||||
position_json: JSON.stringify(positionJson),
|
||||
});
|
||||
const dashBody = await dashResp.json();
|
||||
dashboardId = dashBody.id;
|
||||
await apiPut(setupPage, `api/v1/chart/${chartId}`, {
|
||||
dashboards: [dashboardId],
|
||||
});
|
||||
|
||||
const embedded = await apiEnableEmbedding(setupPage, dashboardId);
|
||||
embedUuid = embedded.uuid;
|
||||
accessToken = await getAccessToken(setupPage);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
const context = await createAdminContext(browser);
|
||||
try {
|
||||
const cleanupPage = await context.newPage();
|
||||
if (dashboardId !== undefined) {
|
||||
await apiDeleteDashboard(cleanupPage, dashboardId, {
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
}
|
||||
if (chartId !== undefined) {
|
||||
await apiDeleteChart(cleanupPage, chartId, { failOnStatusCode: false });
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[pivot-collapse teardown] cleanup failed:', err);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
if (appServer) await appServer.close();
|
||||
});
|
||||
|
||||
test('collapsed rows stay collapsed after a scroll round-trip', async ({
|
||||
page,
|
||||
}) => {
|
||||
const embeddedPage = new EmbeddedPage(page);
|
||||
await embeddedPage.exposeTokenFetcher(async () =>
|
||||
getGuestToken(page, dashboardId, { accessToken }),
|
||||
);
|
||||
await embeddedPage.goto({
|
||||
appUrl: appServer.url,
|
||||
uuid: embedUuid,
|
||||
supersetDomain: SUPERSET_DOMAIN,
|
||||
});
|
||||
await embeddedPage.waitForIframe();
|
||||
await embeddedPage.waitForDashboardContent();
|
||||
await embeddedPage.waitForChartRendered();
|
||||
|
||||
const rowLabels = embeddedPage.iframe.locator('.pvtRowLabel');
|
||||
await expect
|
||||
.poll(() => rowLabels.count(), { timeout: EMBEDDED.CHART_RENDER })
|
||||
.toBeGreaterThan(1);
|
||||
const expandedCount = await rowLabels.count();
|
||||
|
||||
// Collapse the first top-level row group via its [-] toggle. Scope to
|
||||
// `.pvtTable` so we never match a stray `toggle` class elsewhere in the DOM.
|
||||
await embeddedPage.iframe.locator('.pvtTable .toggle').first().click();
|
||||
await expect
|
||||
.poll(() => embeddedPage.iframe.locator('.pvtRowLabel').count(), {
|
||||
timeout: EMBEDDED.CHART_RENDER,
|
||||
})
|
||||
.toBeLessThan(expandedCount);
|
||||
const collapsedCount = await embeddedPage.iframe
|
||||
.locator('.pvtRowLabel')
|
||||
.count();
|
||||
|
||||
// Scroll the embedded dashboard so the pivot leaves the viewport, then back.
|
||||
await embeddedPage.iframe.locator('body').evaluate(() => {
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
});
|
||||
await page.waitForTimeout(800);
|
||||
await embeddedPage.iframe.locator('body').evaluate(() => {
|
||||
window.scrollTo(0, 0);
|
||||
});
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
// The collapsed group must remain collapsed (row-label count unchanged).
|
||||
await expect(embeddedPage.iframe.locator('.pvtRowLabel')).toHaveCount(
|
||||
collapsedCount,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -116,3 +116,22 @@ Handlebars.registerHelper('parseJson', (jsonString: string) => {
|
||||
|
||||
Helpers.registerHelpers(Handlebars);
|
||||
HandlebarsGroupBy.register(Handlebars);
|
||||
|
||||
// `just-handlebars-helpers` registers a `formatDate` helper that lazily
|
||||
// resolves `moment` via `global.moment` / `require('moment/min/moment-with-locales')`.
|
||||
// The bundled viewer switched to dayjs and never satisfies that lookup, so the
|
||||
// original helper throws "... is not a function" (see #32960). Re-register a
|
||||
// dayjs-backed `formatDate` with the same `{{formatDate formatString date [locale]}}`
|
||||
// signature so existing templates keep rendering.
|
||||
Handlebars.registerHelper('formatDate', (formatString, date, localeString) => {
|
||||
const format = typeof formatString === 'string' ? formatString : '';
|
||||
const instance = dayjs(date ?? new Date());
|
||||
// Handlebars always passes its options object as the final argument, so a
|
||||
// locale is only present when the caller supplied an explicit string.
|
||||
// Note: `extendedDayjs` only loads the `en` locale, so passing a non-English
|
||||
// locale here quietly falls back to English unless that locale bundle has
|
||||
// been imported elsewhere; dayjs's instance `.locale()` is a no-op otherwise.
|
||||
return typeof localeString === 'string'
|
||||
? instance.locale(localeString).format(format)
|
||||
: instance.format(format);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import Handlebars from 'handlebars';
|
||||
|
||||
// Importing the viewer registers the dayjs-backed `formatDate` override (#32960).
|
||||
// The end-to-end behavior (the bundling/minification regression) is covered by a
|
||||
// Playwright spec; these unit tests guard the helper's edge cases, which run fine
|
||||
// under Jest's Node environment without a browser.
|
||||
import '../../src/components/Handlebars/HandlebarsViewer';
|
||||
|
||||
// Handlebars passes its options object as the trailing argument, so callers that
|
||||
// omit the optional locale still get a non-string final arg. Mimic that here.
|
||||
const options = {} as unknown as string;
|
||||
|
||||
const formatDate = (
|
||||
format: string,
|
||||
date: unknown,
|
||||
locale: string = options,
|
||||
): string =>
|
||||
(Handlebars.helpers.formatDate as (...args: unknown[]) => string)(
|
||||
format,
|
||||
date,
|
||||
locale,
|
||||
);
|
||||
|
||||
test('formats a valid date string with the supplied format', () => {
|
||||
expect(formatDate('DD.MM.YYYY', '2024-06-14')).toBe('14.06.2024');
|
||||
});
|
||||
|
||||
test('renders "Invalid date" for an unparseable date string', () => {
|
||||
expect(formatDate('DD.MM.YYYY', 'not-a-date')).toBe('Invalid date');
|
||||
});
|
||||
|
||||
test('coerces a non-string format to dayjs default output without throwing', () => {
|
||||
// The helper guards against a non-string format by passing '' to dayjs,
|
||||
// which renders its default ISO 8601 representation rather than throwing.
|
||||
expect(() =>
|
||||
formatDate(undefined as unknown as string, '2024-06-14'),
|
||||
).not.toThrow();
|
||||
expect(formatDate(undefined as unknown as string, '2024-06-14')).toContain(
|
||||
'2024-06-14',
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves the epoch-0 timestamp instead of falling back to now', () => {
|
||||
// 1970-01-01 in UTC, which is 1969 or 1970 locally depending on tz offset;
|
||||
// the point is it is NOT coerced to the current date.
|
||||
expect(formatDate('YYYY', 0)).toMatch(/^(1969|1970)$/);
|
||||
});
|
||||
|
||||
test('silently falls back to English for a locale that is not loaded', () => {
|
||||
// extendedDayjs only loads the `en` locale, so a non-English locale no-ops.
|
||||
expect(formatDate('MMMM', '2024-06-14', 'fr')).toBe('June');
|
||||
});
|
||||
@@ -134,9 +134,11 @@ async function runOxlintAndProcess() {
|
||||
console.log('Running minimal ESLint for custom rules...');
|
||||
let eslintOutput = '[]';
|
||||
try {
|
||||
// Run ESLint and capture output directly
|
||||
// Run ESLint and capture output directly.
|
||||
// Flat config (eslint.config.minimal.js) is explicitly selected via
|
||||
// --config; ESLint v9+/v10 no longer support eslintrc or --no-eslintrc.
|
||||
eslintOutput = execSync(
|
||||
'npx eslint --no-eslintrc --config .eslintrc.minimal.js --no-inline-config --format json src',
|
||||
'npx eslint --config eslint.config.minimal.js --no-inline-config --format json src',
|
||||
{
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
|
||||
@@ -425,6 +425,7 @@ const ResultSet = ({
|
||||
url: makeUrl('/api/v1/sqllab/export_streaming/'),
|
||||
payload: { client_id: query.id },
|
||||
exportType: 'csv',
|
||||
exportSource: 'sqllab',
|
||||
expectedRows: rows,
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { JsonObject, QueryFormData, VizType } from '@superset-ui/core';
|
||||
import { getChartDataRequest } from 'src/components/Chart/chartAction';
|
||||
|
||||
/**
|
||||
* Integration (mocked-network) port of the deprecated Cypress spec
|
||||
* `cypress/e2e/dashboard/_skip.url_params.test.ts` (sc-107448).
|
||||
*
|
||||
* The original test loaded a dashboard with query-string params, intercepted
|
||||
* `/api/v1/chart/data`, and asserted each query in the request body carried
|
||||
* `url_params`. That assertion is request-construction logic — the form_data
|
||||
* → query-context pipeline — which is exercised here without a backend.
|
||||
*
|
||||
* Intentional narrowing: the URL-string → `form_data.url_params` hop (handled
|
||||
* in `src/dashboard/actions/hydrate.ts` via `extractUrlParams`) is not covered
|
||||
* here. This file verifies the chart-data side of the contract only; the
|
||||
* dashboard hydration side is covered by its own unit tests.
|
||||
*/
|
||||
const CHART_DATA_GLOB = 'glob:*/api/v1/chart/data*';
|
||||
const CHART_DATA_ROUTE = 'urlParamsForwarding-chartData';
|
||||
const URL_PARAMS = { param1: '123', param2: 'abc' };
|
||||
|
||||
type ChartDataRequestBody = {
|
||||
queries: JsonObject[];
|
||||
form_data: JsonObject;
|
||||
};
|
||||
|
||||
const buildFormData = (
|
||||
overrides: Partial<QueryFormData> = {},
|
||||
): QueryFormData => ({
|
||||
datasource: '1__table',
|
||||
granularity_sqla: 'ds',
|
||||
viz_type: VizType.Table,
|
||||
url_params: URL_PARAMS,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const lastChartDataBody = (): ChartDataRequestBody => {
|
||||
const calls = fetchMock.callHistory.calls(CHART_DATA_ROUTE);
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
return JSON.parse(
|
||||
calls[calls.length - 1].options.body as string,
|
||||
) as ChartDataRequestBody;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.post(
|
||||
CHART_DATA_GLOB,
|
||||
{ result: [{ data: [] }] },
|
||||
{
|
||||
name: CHART_DATA_ROUTE,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Remove only this file's route so global routes registered in
|
||||
// setupSupersetClient (e.g. CSRF) survive into the next test.
|
||||
afterEach(() => {
|
||||
fetchMock.clearHistory();
|
||||
fetchMock.removeRoutes({ names: [CHART_DATA_ROUTE] });
|
||||
});
|
||||
|
||||
test('forwards url_params from form_data onto each query in the chart-data request body', async () => {
|
||||
await getChartDataRequest({ formData: buildFormData() });
|
||||
|
||||
const body = lastChartDataBody();
|
||||
expect(Array.isArray(body.queries)).toBe(true);
|
||||
expect(body.queries.length).toBeGreaterThan(0);
|
||||
body.queries.forEach(query => {
|
||||
expect(query.url_params).toEqual(URL_PARAMS);
|
||||
});
|
||||
});
|
||||
|
||||
test('preserves url_params on form_data echoed back in the chart-data request body', async () => {
|
||||
await getChartDataRequest({ formData: buildFormData() });
|
||||
|
||||
const body = lastChartDataBody();
|
||||
expect(body.form_data.url_params).toEqual(URL_PARAMS);
|
||||
});
|
||||
|
||||
// buildQueryObject defaults missing url_params to `{}` (see
|
||||
// packages/superset-ui-core/src/query/buildQueryObject.ts), so the chart-data
|
||||
// request body carries an empty object — not `undefined`. This test documents
|
||||
// that contract; a future change that flips the default should update both.
|
||||
test('emits an empty url_params object on each query when form_data has none', async () => {
|
||||
await getChartDataRequest({
|
||||
formData: buildFormData({ url_params: undefined }),
|
||||
});
|
||||
|
||||
const body = lastChartDataBody();
|
||||
expect(body.queries.length).toBeGreaterThan(0);
|
||||
body.queries.forEach(query => {
|
||||
expect(query.url_params).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -48,11 +48,11 @@ global.URL.revokeObjectURL = jest.fn();
|
||||
|
||||
global.fetch = jest.fn();
|
||||
|
||||
const { SupersetClient } = jest.requireMock('@superset-ui/core');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
global.fetch = jest.fn();
|
||||
const { SupersetClient } = jest.requireMock('@superset-ui/core');
|
||||
SupersetClient.getCSRFToken.mockResolvedValue('mock-csrf-token');
|
||||
SupersetClient.getGuestToken.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
@@ -228,6 +228,7 @@ test('sets ERROR status and calls onError when fetch rejects', async () => {
|
||||
// URL prefix guard tests - prevent regression of missing app root prefix
|
||||
const { applicationRoot } = jest.requireMock('src/utils/getBootstrapData');
|
||||
const { makeUrl } = jest.requireMock('src/utils/pathUtils');
|
||||
const { SupersetClient } = jest.requireMock('@superset-ui/core');
|
||||
|
||||
const createPrefixTestMockFetch = () =>
|
||||
jest.fn().mockResolvedValue({
|
||||
@@ -242,6 +243,107 @@ const createPrefixTestMockFetch = () =>
|
||||
},
|
||||
});
|
||||
|
||||
test('guest-token chart exports skip CSRF fetch and include guest_token form field', async () => {
|
||||
applicationRoot.mockReturnValue('');
|
||||
SupersetClient.getGuestToken.mockReturnValue('guest-token');
|
||||
SupersetClient.getCSRFToken.mockRejectedValue(new Error('CSRF forbidden'));
|
||||
|
||||
const csvData = new TextEncoder().encode('id,name\n1,Alice\n');
|
||||
let readCount = 0;
|
||||
const mockFetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers({
|
||||
'Content-Disposition': 'attachment; filename="embedded.csv"',
|
||||
}),
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: jest.fn().mockImplementation(() => {
|
||||
readCount += 1;
|
||||
if (readCount === 1) {
|
||||
return Promise.resolve({ done: false, value: csvData });
|
||||
}
|
||||
return Promise.resolve({ done: true, value: undefined });
|
||||
}),
|
||||
}),
|
||||
},
|
||||
});
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const { result } = renderHook(() => useStreamingExport());
|
||||
|
||||
act(() => {
|
||||
result.current.startExport({
|
||||
url: '/api/v1/chart/data',
|
||||
payload: { datasource: '1__table', viz_type: 'table' },
|
||||
exportType: 'csv',
|
||||
exportSource: 'chart',
|
||||
expectedRows: 100000,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.progress.status).toBe(ExportStatus.COMPLETED);
|
||||
});
|
||||
|
||||
expect(SupersetClient.getCSRFToken).not.toHaveBeenCalled();
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const [, requestInit] = mockFetch.mock.calls[0];
|
||||
const body = requestInit.body as URLSearchParams;
|
||||
|
||||
expect(body.get('guest_token')).toBe('guest-token');
|
||||
expect(body.get('expected_rows')).toBe('100000');
|
||||
expect(body.get('form_data')).toBe(
|
||||
JSON.stringify({ datasource: '1__table', viz_type: 'table' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('non-guest chart exports fetch CSRF and include X-CSRFToken header', async () => {
|
||||
applicationRoot.mockReturnValue('');
|
||||
|
||||
const csvData = new TextEncoder().encode('id,name\n1,Alice\n');
|
||||
let readCount = 0;
|
||||
const mockFetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers({
|
||||
'Content-Disposition': 'attachment; filename="chart.csv"',
|
||||
}),
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: jest.fn().mockImplementation(() => {
|
||||
readCount += 1;
|
||||
if (readCount === 1) {
|
||||
return Promise.resolve({ done: false, value: csvData });
|
||||
}
|
||||
return Promise.resolve({ done: true, value: undefined });
|
||||
}),
|
||||
}),
|
||||
},
|
||||
});
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const { result } = renderHook(() => useStreamingExport());
|
||||
|
||||
act(() => {
|
||||
result.current.startExport({
|
||||
url: '/api/v1/chart/data',
|
||||
payload: { datasource: '1__table', viz_type: 'table' },
|
||||
exportType: 'csv',
|
||||
exportSource: 'chart',
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.progress.status).toBe(ExportStatus.COMPLETED);
|
||||
});
|
||||
|
||||
expect(SupersetClient.getCSRFToken).toHaveBeenCalledTimes(1);
|
||||
const [, requestInit] = mockFetch.mock.calls[0];
|
||||
expect(requestInit.headers).toMatchObject({
|
||||
'X-CSRFToken': 'mock-csrf-token',
|
||||
});
|
||||
expect((requestInit.body as URLSearchParams).has('guest_token')).toBe(false);
|
||||
});
|
||||
|
||||
test('chart streaming export includes guest token in form body when configured', async () => {
|
||||
SupersetClient.getGuestToken.mockReturnValue('guest-token');
|
||||
const mockFetch = createPrefixTestMockFetch();
|
||||
@@ -254,6 +356,7 @@ test('chart streaming export includes guest token in form body when configured',
|
||||
url: '/api/v1/chart/data',
|
||||
payload: { datasource: '1__table', viz_type: 'table' },
|
||||
exportType: 'csv',
|
||||
exportSource: 'chart',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -268,6 +371,70 @@ test('chart streaming export includes guest token in form body when configured',
|
||||
);
|
||||
});
|
||||
|
||||
test('SQL Lab exports fetch CSRF and omit guest_token even when guest token exists', async () => {
|
||||
applicationRoot.mockReturnValue('');
|
||||
SupersetClient.getGuestToken.mockReturnValue('guest-token');
|
||||
|
||||
const mockFetch = createPrefixTestMockFetch();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const { result } = renderHook(() => useStreamingExport());
|
||||
|
||||
act(() => {
|
||||
result.current.startExport({
|
||||
url: '/api/v1/sqllab/export_streaming/',
|
||||
payload: { client_id: 'test-id' },
|
||||
exportType: 'csv',
|
||||
exportSource: 'sqllab',
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(SupersetClient.getCSRFToken).toHaveBeenCalledTimes(1);
|
||||
const [, requestInit] = mockFetch.mock.calls[0];
|
||||
const body = requestInit.body as URLSearchParams;
|
||||
|
||||
expect(requestInit.headers).toMatchObject({
|
||||
'X-CSRFToken': 'mock-csrf-token',
|
||||
});
|
||||
expect(body.get('client_id')).toBe('test-id');
|
||||
expect(body.has('guest_token')).toBe(false);
|
||||
});
|
||||
|
||||
test('guest tokens do not bypass CSRF for unclassified non-client exports', async () => {
|
||||
applicationRoot.mockReturnValue('');
|
||||
SupersetClient.getGuestToken.mockReturnValue('guest-token');
|
||||
|
||||
const mockFetch = createPrefixTestMockFetch();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const { result } = renderHook(() => useStreamingExport());
|
||||
|
||||
act(() => {
|
||||
result.current.startExport({
|
||||
url: '/api/v1/other/export_streaming/',
|
||||
payload: { export_id: 'test-id' },
|
||||
exportType: 'csv',
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(SupersetClient.getCSRFToken).toHaveBeenCalledTimes(1);
|
||||
const [, requestInit] = mockFetch.mock.calls[0];
|
||||
const body = requestInit.body as URLSearchParams;
|
||||
|
||||
expect(requestInit.headers).toMatchObject({
|
||||
'X-CSRFToken': 'mock-csrf-token',
|
||||
});
|
||||
expect(body.has('guest_token')).toBe(false);
|
||||
});
|
||||
|
||||
test('URL prefix guard applies prefix to unprefixed relative URL when app root is configured', async () => {
|
||||
const appRoot = '/superset';
|
||||
applicationRoot.mockReturnValue(appRoot);
|
||||
|
||||
@@ -31,6 +31,8 @@ interface StreamingExportPayload {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
type StreamingExportSource = 'chart' | 'sqllab';
|
||||
|
||||
interface StreamingExportParams {
|
||||
/**
|
||||
* The API endpoint URL for the export request.
|
||||
@@ -46,6 +48,7 @@ interface StreamingExportParams {
|
||||
payload: StreamingExportPayload;
|
||||
filename?: string;
|
||||
exportType: 'csv' | 'xlsx';
|
||||
exportSource?: StreamingExportSource;
|
||||
expectedRows?: number;
|
||||
}
|
||||
|
||||
@@ -95,6 +98,7 @@ const createFetchRequest = async (
|
||||
payload: StreamingExportPayload,
|
||||
filename: string | undefined,
|
||||
_exportType: string,
|
||||
exportSource: StreamingExportSource | undefined,
|
||||
expectedRows: number | undefined,
|
||||
signal: AbortSignal,
|
||||
): Promise<RequestInit> => {
|
||||
@@ -102,10 +106,19 @@ const createFetchRequest = async (
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
};
|
||||
|
||||
// Get CSRF token using SupersetClient
|
||||
const csrfToken = await SupersetClient.getCSRFToken();
|
||||
if (csrfToken) {
|
||||
headers['X-CSRFToken'] = csrfToken;
|
||||
const guestToken = SupersetClient.getGuestToken();
|
||||
const isGuestTokenChartExport =
|
||||
Boolean(guestToken) &&
|
||||
exportSource === 'chart' &&
|
||||
!('client_id' in payload);
|
||||
|
||||
// Embedded guest sessions cannot fetch CSRF tokens. Guest chart exports are
|
||||
// safe because chart data is CSRF-exempt and auth is carried by guest_token.
|
||||
if (!isGuestTokenChartExport) {
|
||||
const csrfToken = await SupersetClient.getCSRFToken();
|
||||
if (csrfToken) {
|
||||
headers['X-CSRFToken'] = csrfToken;
|
||||
}
|
||||
}
|
||||
|
||||
const formParams: Record<string, string> = {};
|
||||
@@ -118,8 +131,7 @@ const createFetchRequest = async (
|
||||
formParams.expected_rows = expectedRows.toString();
|
||||
}
|
||||
|
||||
const guestToken = SupersetClient.getGuestToken();
|
||||
if (guestToken) {
|
||||
if (guestToken && isGuestTokenChartExport) {
|
||||
formParams.guest_token = guestToken;
|
||||
}
|
||||
|
||||
@@ -185,7 +197,8 @@ export const useStreamingExport = (options: UseStreamingExportOptions = {}) => {
|
||||
|
||||
const executeExport = useCallback(
|
||||
async (params: StreamingExportParams) => {
|
||||
const { url, payload, filename, exportType, expectedRows } = params;
|
||||
const { url, payload, filename, exportType, exportSource, expectedRows } =
|
||||
params;
|
||||
if (isExportingRef.current) {
|
||||
return;
|
||||
}
|
||||
@@ -210,6 +223,7 @@ export const useStreamingExport = (options: UseStreamingExportOptions = {}) => {
|
||||
payload,
|
||||
filename,
|
||||
exportType,
|
||||
exportSource,
|
||||
expectedRows,
|
||||
abortControllerRef.current.signal,
|
||||
);
|
||||
|
||||
@@ -165,6 +165,62 @@ describe('DashboardBuilder', () => {
|
||||
expect(header).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should hide DashboardHeader when standalone mode hides nav and title (?standalone=2)', () => {
|
||||
// React-level equivalent of the legacy `cy.get('#app-menu').should('not.exist')`
|
||||
// Cypress assertion. The `#app-menu` node lives in Flask's spa.html template,
|
||||
// gated by `{% if standalone_mode %}`, so RTL cannot reach it directly.
|
||||
// `?standalone=2` maps to DashboardStandaloneMode.HideNavAndTitle, which the
|
||||
// DashboardBuilder honours by suppressing the React-side DashboardHeader.
|
||||
const originalHref = window.location.href;
|
||||
window.history.replaceState({}, '', '/?standalone=2');
|
||||
try {
|
||||
const { queryByTestId } = setup();
|
||||
expect(
|
||||
queryByTestId('dashboard-header-container'),
|
||||
).not.toBeInTheDocument();
|
||||
} finally {
|
||||
window.history.replaceState({}, '', originalHref);
|
||||
}
|
||||
});
|
||||
|
||||
test('should keep the DashboardHeader when standalone mode only hides nav (?standalone=1)', () => {
|
||||
// `?standalone=1` maps to DashboardStandaloneMode.HideNav, which only hides the
|
||||
// Flask-rendered global app menu (#app-menu) — it must NOT suppress the React-side
|
||||
// DashboardHeader. This pins the boundary against HideNavAndTitle (?standalone=2).
|
||||
const originalHref = window.location.href;
|
||||
window.history.replaceState({}, '', '/?standalone=1');
|
||||
try {
|
||||
const { queryByTestId } = setup();
|
||||
expect(queryByTestId('dashboard-header-container')).toBeInTheDocument();
|
||||
} finally {
|
||||
window.history.replaceState({}, '', originalHref);
|
||||
}
|
||||
});
|
||||
|
||||
test('should keep the header hidden in standalone mode (?standalone=2) while editMode is active', () => {
|
||||
// Orthogonality analogue of the legacy `?edit=true&standalone=true` Cypress mount.
|
||||
// editMode is sourced from Redux (state.dashboardState.editMode), not the URL —
|
||||
// DashboardBuilder only reads URL_PARAMS.standalone — so the legacy `edit=true`
|
||||
// param is inert here and is intentionally omitted. Contract under test:
|
||||
// standalone=2 (HideNavAndTitle) suppresses DashboardHeader even while editMode
|
||||
// drives the `dashboard--editing` class on the wrapper.
|
||||
const originalHref = window.location.href;
|
||||
window.history.replaceState({}, '', '/?standalone=2');
|
||||
try {
|
||||
const { getByTestId, queryByTestId } = setup({
|
||||
dashboardState: { ...mockState.dashboardState, editMode: true },
|
||||
});
|
||||
expect(getByTestId('dashboard-content-wrapper')).toHaveClass(
|
||||
'dashboard dashboard--editing',
|
||||
);
|
||||
expect(
|
||||
queryByTestId('dashboard-header-container'),
|
||||
).not.toBeInTheDocument();
|
||||
} finally {
|
||||
window.history.replaceState({}, '', originalHref);
|
||||
}
|
||||
});
|
||||
|
||||
test('should render a Sticky top-level Tabs if the dashboard has tabs', async () => {
|
||||
const { findAllByTestId } = setup({
|
||||
dashboardLayout: undoableDashboardLayoutWithTabs,
|
||||
|
||||
@@ -99,6 +99,7 @@ interface ExportChartParams {
|
||||
url: string | null;
|
||||
payload: QueryFormData | ReturnType<typeof buildQueryContext>;
|
||||
exportType: string;
|
||||
exportSource: 'chart';
|
||||
}) => void)
|
||||
| null;
|
||||
}
|
||||
@@ -394,6 +395,7 @@ export const exportChart = async ({
|
||||
url: url ? ensureAppRoot(url) : url,
|
||||
payload,
|
||||
exportType: resultFormat,
|
||||
exportSource: 'chart',
|
||||
});
|
||||
} else {
|
||||
// SupersetClient.postForm calls getUrl({ endpoint }) internally, which prepends
|
||||
|
||||
@@ -168,6 +168,31 @@ test('non-text chart shows screenshot width and message content', () => {
|
||||
expect(screen.getByText('Screenshot width')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('screenshot width input preserves a typed zero instead of dropping it', () => {
|
||||
const lineChartProps = {
|
||||
...defaultProps,
|
||||
dashboardId: undefined,
|
||||
chart: { id: 1, sliceFormData: { viz_type: VizType.Line } },
|
||||
chartName: 'My Line Chart',
|
||||
creationMethod: 'charts' as const,
|
||||
};
|
||||
render(<ReportModal {...lineChartProps} />, { useRedux: true });
|
||||
|
||||
const widthInput = screen.getByPlaceholderText(
|
||||
'Input custom width in pixels',
|
||||
);
|
||||
|
||||
// The old `|| null` / `|| ''` logic silently coerced a typed 0 to null, so the
|
||||
// invalid width was swallowed instead of being submitted and surfaced by the
|
||||
// server's min-width validation. The field must preserve the literal value.
|
||||
userEvent.type(widthInput, '0');
|
||||
expect(widthInput).toHaveDisplayValue('0');
|
||||
|
||||
// Clearing the field still yields an empty value (parsed NaN → null).
|
||||
userEvent.clear(widthInput);
|
||||
expect(widthInput).toHaveDisplayValue('');
|
||||
});
|
||||
|
||||
test('dashboard report hides message content section', () => {
|
||||
const dashboardProps = {
|
||||
...defaultProps,
|
||||
|
||||
@@ -296,11 +296,12 @@ function ReportModal({
|
||||
<Input
|
||||
type="number"
|
||||
name="custom_width"
|
||||
value={currentReport?.custom_width || ''}
|
||||
value={currentReport?.custom_width ?? ''}
|
||||
placeholder={t('Input custom width in pixels')}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||
const parsedWidth = parseInt(event.target.value, 10);
|
||||
setCurrentReport({
|
||||
custom_width: parseInt(event.target.value, 10) || null,
|
||||
custom_width: Number.isNaN(parsedWidth) ? null : parsedWidth,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
14
superset-websocket/package-lock.json
generated
14
superset-websocket/package-lock.json
generated
@@ -23,7 +23,7 @@
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/node": "^25.9.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.60.1",
|
||||
"@typescript-eslint/parser": "^8.60.1",
|
||||
@@ -1798,9 +1798,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
|
||||
"version": "25.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz",
|
||||
"integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -7883,9 +7883,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "25.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
|
||||
"version": "25.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz",
|
||||
"integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/node": "^25.9.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.60.1",
|
||||
"@typescript-eslint/parser": "^8.60.1",
|
||||
|
||||
@@ -31,7 +31,7 @@ from flask_appbuilder.api.manager import resolver
|
||||
|
||||
import superset.utils.database as database_utils
|
||||
from superset.utils.decorators import transaction
|
||||
from superset.utils.encrypt import SecretsMigrator
|
||||
from superset.utils.encrypt import ENCRYPTION_ENGINES, SecretsMigrator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -110,17 +110,45 @@ def update_api_docs() -> None:
|
||||
help="An optional previous secret key, if PREVIOUS_SECRET_KEY "
|
||||
"is not set on the config",
|
||||
)
|
||||
def re_encrypt_secrets(previous_secret_key: Optional[str] = None) -> None:
|
||||
@click.option(
|
||||
"--engine",
|
||||
"-e",
|
||||
"target_engine_name",
|
||||
required=False,
|
||||
type=click.Choice(sorted(ENCRYPTION_ENGINES), case_sensitive=False),
|
||||
help="Re-encrypt all app-encrypted fields with this encryption engine "
|
||||
"(e.g. 'aes-gcm' for authenticated encryption). The SECRET_KEY is "
|
||||
"unchanged. Take a metadata-DB backup first, then set "
|
||||
"SQLALCHEMY_ENCRYPTED_FIELD_ENGINE to the same value and restart.",
|
||||
)
|
||||
def re_encrypt_secrets(
|
||||
previous_secret_key: Optional[str] = None,
|
||||
target_engine_name: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Re-encrypt every app-encrypted field via :class:`SecretsMigrator`.
|
||||
|
||||
Supports key rotation (``previous_secret_key``, falling back to the
|
||||
``PREVIOUS_SECRET_KEY`` config) and engine migration (``target_engine_name``,
|
||||
a case-insensitive ``ENCRYPTION_ENGINES`` key such as ``aes-gcm``); the two
|
||||
can combine. With neither provided the command is a no-op. Exits non-zero on
|
||||
failure.
|
||||
"""
|
||||
previous_secret_key = previous_secret_key or current_app.config.get(
|
||||
"PREVIOUS_SECRET_KEY"
|
||||
)
|
||||
if previous_secret_key is None:
|
||||
target_engine = (
|
||||
ENCRYPTION_ENGINES[target_engine_name] if target_engine_name else None
|
||||
)
|
||||
if previous_secret_key is None and target_engine is None:
|
||||
click.secho(
|
||||
"No previous secret key provided; nothing to re-encrypt.",
|
||||
"No previous secret key or target engine provided; nothing to re-encrypt.",
|
||||
fg="yellow",
|
||||
)
|
||||
return
|
||||
secrets_migrator = SecretsMigrator(previous_secret_key=previous_secret_key)
|
||||
secrets_migrator = SecretsMigrator(
|
||||
previous_secret_key=previous_secret_key,
|
||||
target_engine=target_engine,
|
||||
)
|
||||
try:
|
||||
stats = secrets_migrator.run()
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
|
||||
@@ -289,7 +289,10 @@ SQLALCHEMY_CUSTOM_PASSWORD_STORE = None
|
||||
# as key material. Do note that AesEngine allows for queryability over the
|
||||
# encrypted fields.
|
||||
#
|
||||
# To change the default engine you need to define your own adapter:
|
||||
# To switch the engine used by the default adapter, prefer the
|
||||
# ``SQLALCHEMY_ENCRYPTED_FIELD_ENGINE`` knob below (e.g. "aes-gcm"). Defining a
|
||||
# custom adapter, as shown next, is only needed for behaviour the built-in
|
||||
# engines do not cover:
|
||||
#
|
||||
# e.g.:
|
||||
#
|
||||
@@ -314,6 +317,16 @@ SQLALCHEMY_ENCRYPTED_FIELD_TYPE_ADAPTER = ( # pylint: disable=invalid-name
|
||||
SQLAlchemyUtilsAdapter
|
||||
)
|
||||
|
||||
# Encryption engine used by the default SQLAlchemyUtilsAdapter for app-encrypted
|
||||
# fields. Options:
|
||||
# "aes" - AES-CBC (historical default; unauthenticated, queryable)
|
||||
# "aes-gcm" - AES-GCM (authenticated encryption; recommended for NEW installs)
|
||||
# WARNING: changing this on a database that already holds encrypted secrets
|
||||
# (database passwords, SSH tunnel credentials, OAuth tokens, ...) will make
|
||||
# those values undecryptable unless they are re-encrypted first. See the
|
||||
# authenticated-encryption SIP/migration before switching an existing install.
|
||||
SQLALCHEMY_ENCRYPTED_FIELD_ENGINE: Literal["aes", "aes-gcm"] = "aes"
|
||||
|
||||
# Extends the default SQLGlot dialects with additional dialects
|
||||
SQLGLOT_DIALECTS_EXTENSIONS: DialectExtensions | Callable[[], DialectExtensions] = {}
|
||||
|
||||
|
||||
@@ -25,21 +25,37 @@ import io
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from typing import Any
|
||||
from typing import Any, Callable
|
||||
|
||||
# Must redirect click output BEFORE importing anything that uses it
|
||||
import click
|
||||
|
||||
# Monkey-patch click to redirect output to stderr in stdio mode
|
||||
if os.environ.get("FASTMCP_TRANSPORT", "stdio") == "stdio":
|
||||
original_echo = click.echo
|
||||
original_secho = click.secho
|
||||
|
||||
def secho_to_stderr(*args: Any, **kwargs: Any) -> Any:
|
||||
kwargs["file"] = sys.stderr
|
||||
return original_secho(*args, **kwargs)
|
||||
def redirect_to_stderr(
|
||||
original_func: Callable[..., None],
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
if len(args) >= 2:
|
||||
args = (args[0], sys.stderr, *args[2:])
|
||||
kwargs.pop("file", None)
|
||||
else:
|
||||
kwargs["file"] = sys.stderr
|
||||
|
||||
original_func(*args, **kwargs)
|
||||
|
||||
def echo_to_stderr(*args: Any, **kwargs: Any) -> None:
|
||||
redirect_to_stderr(original_echo, *args, **kwargs)
|
||||
|
||||
def secho_to_stderr(*args: Any, **kwargs: Any) -> None:
|
||||
redirect_to_stderr(original_secho, *args, **kwargs)
|
||||
|
||||
click.echo = echo_to_stderr
|
||||
click.secho = secho_to_stderr
|
||||
click.echo = lambda *args, **kwargs: click.echo(*args, file=sys.stderr, **kwargs)
|
||||
|
||||
from superset.mcp_service.app import init_fastmcp_server, mcp
|
||||
from superset.mcp_service.middleware import create_response_size_guard_middleware
|
||||
|
||||
@@ -516,6 +516,27 @@ def _fix_call_tool_arguments(tool: Any) -> Any:
|
||||
return tool
|
||||
|
||||
|
||||
def _fix_search_tool_query(tool: Any) -> Any:
|
||||
"""Fix anyOf schema in search_tools ``query`` for MCP bridge compatibility.
|
||||
|
||||
The optional ``query: str | None`` parameter emits an ``anyOf`` JSON
|
||||
Schema with no top-level ``type``. Some MCP bridges (mcp-remote,
|
||||
Claude Desktop) don't handle ``anyOf`` and strip it, leaving the field
|
||||
typeless — the same failure mode ``_fix_call_tool_arguments`` guards
|
||||
against. Replaces the ``anyOf`` with a flat ``type: string``.
|
||||
|
||||
Only the advertised schema changes; FastMCP validates calls against
|
||||
the function signature, so omitting ``query`` remains valid.
|
||||
"""
|
||||
if "query" in (props := (tool.parameters or {}).get("properties", {})):
|
||||
props["query"] = {
|
||||
"default": None,
|
||||
"description": "Natural language query. Omit to list all available tools.",
|
||||
"type": "string",
|
||||
}
|
||||
return tool
|
||||
|
||||
|
||||
def _normalize_call_tool_arguments(
|
||||
arguments: dict[str, Any] | None,
|
||||
tool_schema: dict[str, Any] | None,
|
||||
@@ -626,7 +647,7 @@ def _apply_tool_search_transform(mcp_instance: Any, config: dict[str, Any]) -> N
|
||||
)
|
||||
|
||||
|
||||
def _create_search_transform(
|
||||
def _create_search_transform( # noqa: C901
|
||||
*,
|
||||
strategy: str,
|
||||
kwargs: dict[str, Any],
|
||||
@@ -634,6 +655,32 @@ def _create_search_transform(
|
||||
) -> Any:
|
||||
"""Create the configured search transform with tool-permission filtering."""
|
||||
from fastmcp.server.context import Context
|
||||
from fastmcp.tools.tool import Tool
|
||||
|
||||
def _make_optional_query_search_tool(transform: Any) -> Any:
|
||||
"""Create search tool with optional query — returns all tools when omitted."""
|
||||
|
||||
async def search_tools(
|
||||
query: Annotated[
|
||||
str | None,
|
||||
"Natural language query. Omit to list all available tools.",
|
||||
] = None,
|
||||
ctx: Context = None,
|
||||
) -> str | list[dict[str, Any]]:
|
||||
"""Search for tools using natural language.
|
||||
|
||||
Returns matching tool definitions ranked by relevance.
|
||||
If no query is provided, returns all available tools.
|
||||
"""
|
||||
hidden = await transform._get_visible_tools(ctx)
|
||||
if not query:
|
||||
results = hidden
|
||||
else:
|
||||
results = await transform._search(hidden, query)
|
||||
return await transform._render_results(results)
|
||||
|
||||
tool = Tool.from_function(fn=search_tools, name=transform._search_tool_name)
|
||||
return _fix_search_tool_query(tool)
|
||||
|
||||
if strategy == "regex":
|
||||
from fastmcp.server.transforms.search import RegexSearchTransform
|
||||
@@ -650,6 +697,10 @@ def _create_search_transform(
|
||||
"""Build the normalized ``call_tool`` proxy for regex search."""
|
||||
return make_normalizing_call_tool(self)
|
||||
|
||||
def _make_search_tool(self) -> Any:
|
||||
"""Build the optional-query ``search_tools`` for regex search."""
|
||||
return _make_optional_query_search_tool(self)
|
||||
|
||||
return _FixedRegexSearchTransform(**kwargs)
|
||||
|
||||
from fastmcp.server.transforms.search import BM25SearchTransform
|
||||
@@ -666,6 +717,10 @@ def _create_search_transform(
|
||||
"""Build the normalized ``call_tool`` proxy for BM25 search."""
|
||||
return make_normalizing_call_tool(self)
|
||||
|
||||
def _make_search_tool(self) -> Any:
|
||||
"""Build the optional-query ``search_tools`` for BM25 search."""
|
||||
return _make_optional_query_search_tool(self)
|
||||
|
||||
return _FixedBM25SearchTransform(**kwargs)
|
||||
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from sqlalchemy import or_
|
||||
from sqlalchemy.orm.query import Query
|
||||
|
||||
from superset import db, security_manager
|
||||
from superset.daos.base import _escape_like
|
||||
from superset.reports.models import ReportSchedule
|
||||
from superset.views.base import BaseFilter
|
||||
|
||||
@@ -47,11 +48,13 @@ class ReportScheduleAllTextFilter(BaseFilter): # pylint: disable=too-few-public
|
||||
def apply(self, query: Query, value: Any) -> Query:
|
||||
if not value:
|
||||
return query
|
||||
ilike_value = f"%{value}%"
|
||||
# ``value`` may arrive as a non-string (e.g. an int in the API ``filters``
|
||||
# array); coerce it so escaping never raises on ``.replace``.
|
||||
ilike_value = f"%{_escape_like(str(value))}%"
|
||||
return query.filter(
|
||||
or_(
|
||||
ReportSchedule.name.ilike(ilike_value),
|
||||
ReportSchedule.description.ilike(ilike_value),
|
||||
ReportSchedule.sql.ilike(ilike_value),
|
||||
ReportSchedule.name.ilike(ilike_value, escape="\\"),
|
||||
ReportSchedule.description.ilike(ilike_value, escape="\\"),
|
||||
ReportSchedule.sql.ilike(ilike_value, escape="\\"),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -19,17 +19,87 @@ from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
from flask import Flask
|
||||
from flask import current_app, Flask
|
||||
from flask_babel import lazy_gettext as _
|
||||
from sqlalchemy import Table, text, TypeDecorator
|
||||
from sqlalchemy.engine import Connection, Dialect, Row
|
||||
from sqlalchemy_utils import EncryptedType as SqlaEncryptedType
|
||||
from sqlalchemy_utils.types.encrypted.encrypted_type import (
|
||||
AesEngine,
|
||||
AesGcmEngine,
|
||||
EncryptionDecryptionBaseEngine,
|
||||
)
|
||||
|
||||
|
||||
class EncryptedType(SqlaEncryptedType):
|
||||
cache_ok = True
|
||||
|
||||
|
||||
# Named encryption engines selectable via the ``SQLALCHEMY_ENCRYPTED_FIELD_ENGINE``
|
||||
# config. "aes" (AES-CBC) is the historical default; "aes-gcm" is authenticated
|
||||
# encryption (recommended for new deployments). NOTE: switching an existing
|
||||
# deployment from "aes" to "aes-gcm" requires re-encrypting all stored secrets
|
||||
# first — see the SIP referenced in the docs. Changing this on a populated
|
||||
# database without that migration will make existing secrets undecryptable.
|
||||
ENCRYPTION_ENGINES: dict[str, type[EncryptionDecryptionBaseEngine]] = {
|
||||
"aes": AesEngine,
|
||||
"aes-gcm": AesGcmEngine,
|
||||
}
|
||||
|
||||
# The historical fallback engine when the config does not name one.
|
||||
DEFAULT_ENCRYPTION_ENGINE_NAME = "aes"
|
||||
|
||||
# Engines whose ciphertext is authenticated: a successful decrypt is
|
||||
# cryptographic proof the value is genuinely in that form. AES-GCM carries an
|
||||
# authentication tag; AES-CBC does not, so a CBC "success" can be coincidental.
|
||||
# Classification logic (the migrator's idempotency fast path) must let an
|
||||
# authenticated decrypt win over an unauthenticated one, never the reverse.
|
||||
AUTHENTICATED_ENGINES: frozenset[type[EncryptionDecryptionBaseEngine]] = frozenset(
|
||||
{AesGcmEngine}
|
||||
)
|
||||
|
||||
|
||||
def _is_authenticated_engine(engine: type[EncryptionDecryptionBaseEngine]) -> bool:
|
||||
"""Return whether ``engine`` produces authenticated ciphertext (e.g. AES-GCM)."""
|
||||
return engine in AUTHENTICATED_ENGINES
|
||||
|
||||
|
||||
def resolve_encryption_engine(
|
||||
engine_name: Any,
|
||||
) -> type[EncryptionDecryptionBaseEngine]:
|
||||
"""Resolve a configured engine name to its engine class, fail-closed.
|
||||
|
||||
The value is normalized (trimmed, lower-cased, underscores → hyphens) so it
|
||||
matches the case-insensitive CLI ``click.Choice``. An unrecognized name
|
||||
raises so a misconfiguration fails at field-construction (startup) rather
|
||||
than silently degrading to unauthenticated AES-CBC — which would let an
|
||||
operator who typo'd ``"aes_gcm"`` believe they had authenticated encryption,
|
||||
and, after a GCM migration, write new secrets as CBC into a GCM database.
|
||||
|
||||
The offending value is deliberately kept out of the error message: it comes
|
||||
from the app config (which also holds ``SECRET_KEY``), and static analysis
|
||||
flags interpolating config-sourced values into logs/errors as potential
|
||||
clear-text secret exposure. The set of valid engine names is enough to
|
||||
diagnose a typo.
|
||||
"""
|
||||
# A non-string config value (e.g. ``None`` from a custom override) must take
|
||||
# the same fail-closed path rather than blowing up with an ``AttributeError``
|
||||
# during field construction.
|
||||
if not isinstance(engine_name, str):
|
||||
raise ValueError(
|
||||
"Unrecognized SQLALCHEMY_ENCRYPTED_FIELD_ENGINE. Valid engines: "
|
||||
+ ", ".join(sorted(ENCRYPTION_ENGINES))
|
||||
)
|
||||
normalized = engine_name.strip().lower().replace("_", "-")
|
||||
try:
|
||||
return ENCRYPTION_ENGINES[normalized]
|
||||
except KeyError:
|
||||
raise ValueError(
|
||||
"Unrecognized SQLALCHEMY_ENCRYPTED_FIELD_ENGINE. Valid engines: "
|
||||
+ ", ".join(sorted(ENCRYPTION_ENGINES))
|
||||
) from None
|
||||
|
||||
|
||||
ENC_ADAPTER_TAG_ATTR_NAME = "__created_by_enc_field_adapter__"
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -65,6 +135,25 @@ class SQLAlchemyUtilsAdapter( # pylint: disable=too-few-public-methods
|
||||
**kwargs: Optional[dict[str, Any]],
|
||||
) -> TypeDecorator:
|
||||
if app_config:
|
||||
# Select the encryption engine from config, defaulting to the
|
||||
# historical AES-CBC engine for backward compatibility when the key
|
||||
# is absent. A *present but unrecognized* value fails closed (see
|
||||
# ``resolve_encryption_engine``) rather than silently degrading to
|
||||
# AES-CBC. An explicit ``engine`` kwarg (e.g. from the migrator)
|
||||
# always takes precedence.
|
||||
if "engine" not in kwargs:
|
||||
# Only an *absent* key defaults to AES-CBC; a present value
|
||||
# (even an empty string) is routed through the fail-closed
|
||||
# resolver so a blanked-out config does not silently degrade to
|
||||
# unauthenticated encryption.
|
||||
engine_name = app_config.get(
|
||||
"SQLALCHEMY_ENCRYPTED_FIELD_ENGINE",
|
||||
DEFAULT_ENCRYPTION_ENGINE_NAME,
|
||||
)
|
||||
# ``**kwargs`` is loosely annotated as ``Optional[dict]`` here, so
|
||||
# route the resolved engine class through an ``Any`` local.
|
||||
engine_cls: Any = resolve_encryption_engine(engine_name)
|
||||
kwargs["engine"] = engine_cls
|
||||
return EncryptedType(*args, lambda: app_config["SECRET_KEY"], **kwargs)
|
||||
|
||||
raise Exception( # pylint: disable=broad-exception-raised
|
||||
@@ -101,10 +190,48 @@ class EncryptedFieldFactory:
|
||||
|
||||
|
||||
class SecretsMigrator:
|
||||
def __init__(self, previous_secret_key: str) -> None:
|
||||
"""Re-encrypts every app-encrypted column in the ORM.
|
||||
|
||||
Two modes, which can also be combined:
|
||||
|
||||
- **Key rotation** — pass ``previous_secret_key``. Values are decrypted
|
||||
under the previous key and re-encrypted under the current ``SECRET_KEY``
|
||||
using the currently-configured engine.
|
||||
- **Engine migration** — pass ``target_engine`` (e.g. ``AesGcmEngine``).
|
||||
Values are decrypted under the current ``SECRET_KEY`` with the source
|
||||
engine and re-encrypted with the target engine. This is how an existing
|
||||
install moves from AES-CBC to authenticated AES-GCM without bricking
|
||||
stored secrets; the ``SECRET_KEY`` itself is unchanged.
|
||||
|
||||
Both modes share the same all-or-nothing transaction and per-column
|
||||
idempotency: a value already readable in the target form is left untouched,
|
||||
so a run can be safely repeated or resumed.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
previous_secret_key: Optional[str] = None,
|
||||
target_engine: Optional[type[Any]] = None,
|
||||
) -> None:
|
||||
"""Configure a migration run.
|
||||
|
||||
``previous_secret_key`` enables key rotation (decrypt under the old key,
|
||||
re-encrypt under the current ``SECRET_KEY``). ``target_engine`` enables
|
||||
engine migration (e.g. ``AesGcmEngine``); in that mode the ``SECRET_KEY``
|
||||
is unchanged, so an absent ``previous_secret_key`` defaults to the current
|
||||
one. Passing both combines key rotation and engine migration in one run.
|
||||
"""
|
||||
from superset import db # pylint: disable=import-outside-toplevel
|
||||
|
||||
self._db = db
|
||||
self._secret_key = current_app.config["SECRET_KEY"]
|
||||
self._target_engine = target_engine
|
||||
# In engine-migration mode the SECRET_KEY does not change: the source
|
||||
# ciphertext is decrypted under the current key (with the source
|
||||
# engine), so default the "previous" key to the current one when the
|
||||
# caller only asked for an engine change.
|
||||
if target_engine is not None and not previous_secret_key:
|
||||
previous_secret_key = self._secret_key
|
||||
self._previous_secret_key = previous_secret_key
|
||||
self._dialect: Dialect = db.engine.url.get_dialect()
|
||||
|
||||
@@ -177,6 +304,89 @@ class SecretsMigrator:
|
||||
cols = ",".join(pk_columns + column_names)
|
||||
return conn.execute(f"SELECT {cols} FROM {table_name}") # noqa: S608
|
||||
|
||||
def _target_type(self, encrypted_type: EncryptedType) -> EncryptedType:
|
||||
"""The EncryptedType to re-encrypt a value *into*.
|
||||
|
||||
For a key rotation this is the column's own configured type (current
|
||||
engine + lazily-resolved current key). For an engine migration it is a
|
||||
type pinned to the requested target engine under the current key.
|
||||
"""
|
||||
if self._target_engine is None:
|
||||
return encrypted_type
|
||||
return EncryptedType(
|
||||
type_in=encrypted_type.underlying_type,
|
||||
key=self._secret_key,
|
||||
engine=self._target_engine,
|
||||
)
|
||||
|
||||
def _source_decryptors(self, encrypted_type: EncryptedType) -> list[EncryptedType]:
|
||||
"""Candidate decryptors, tried in order, to recover a value's plaintext.
|
||||
|
||||
1. The column's configured type — current key + currently-configured
|
||||
engine. During an engine migration this reads the source ciphertext
|
||||
while the config still points at the source engine.
|
||||
2. The previous key under each supported engine. Trying the column's
|
||||
configured engine covers ``SECRET_KEY`` rotation for any engine
|
||||
(including AES-GCM); also trying the historical AES-CBC engine
|
||||
covers an engine migration whose config was flipped to the target
|
||||
engine *before* the migrator ran (the source data is still CBC under
|
||||
the current key, which the previous key defaults to in
|
||||
engine-migration mode).
|
||||
"""
|
||||
decryptors = [encrypted_type]
|
||||
if self._previous_secret_key:
|
||||
# Try the column's own engine first, then any remaining supported
|
||||
# engines (notably the historical AES-CBC), de-duplicated so we
|
||||
# never build the same decryptor twice.
|
||||
engines: list[type[EncryptionDecryptionBaseEngine]] = [
|
||||
type(encrypted_type.engine)
|
||||
]
|
||||
for engine in ENCRYPTION_ENGINES.values():
|
||||
if engine not in engines:
|
||||
engines.append(engine)
|
||||
# When the previous key equals the current key (engine-migration
|
||||
# mode, or a no-op rotation) the column's own engine under that key
|
||||
# is already ``decryptors[0]``; don't append it again as a fallback.
|
||||
if self._previous_secret_key == self._secret_key:
|
||||
engines = engines[1:]
|
||||
decryptors.extend(
|
||||
EncryptedType(
|
||||
type_in=encrypted_type.underlying_type,
|
||||
key=self._previous_secret_key,
|
||||
engine=engine,
|
||||
)
|
||||
for engine in engines
|
||||
)
|
||||
return decryptors
|
||||
|
||||
def _decrypts_under_authenticated_engine(
|
||||
self, encrypted_type: EncryptedType, raw_value: bytes
|
||||
) -> bool:
|
||||
"""Whether ``raw_value`` decrypts under any authenticated engine.
|
||||
|
||||
A successful authenticated (AES-GCM) decrypt is cryptographic proof the
|
||||
value is genuinely in that engine's form — the authentication tag makes
|
||||
a coincidental success negligibly unlikely. Tried under both the current
|
||||
and previous keys so it holds during a combined key-rotation + engine
|
||||
migration. Used to stop an *unauthenticated* target decrypt from
|
||||
coincidentally classifying an authenticated value as "already migrated".
|
||||
"""
|
||||
keys = {self._secret_key}
|
||||
if self._previous_secret_key:
|
||||
keys.add(self._previous_secret_key)
|
||||
for engine in AUTHENTICATED_ENGINES:
|
||||
for key in keys:
|
||||
try:
|
||||
EncryptedType(
|
||||
type_in=encrypted_type.underlying_type,
|
||||
key=key,
|
||||
engine=engine,
|
||||
).process_result_value(raw_value, self._dialect)
|
||||
except Exception: # noqa: BLE001, S112 # pylint: disable=broad-except
|
||||
continue
|
||||
return True
|
||||
return False
|
||||
|
||||
def _re_encrypt_row(
|
||||
self,
|
||||
conn: Connection,
|
||||
@@ -187,24 +397,28 @@ class SecretsMigrator:
|
||||
stats: ReEncryptStats,
|
||||
) -> None:
|
||||
"""
|
||||
Re encrypts all columns in a Row
|
||||
Re-encrypts all columns in a Row into the target form.
|
||||
|
||||
Re-encryption is idempotent per column: we first ask whether the
|
||||
current key can already decrypt the value, and skip if so. Only if
|
||||
the current key fails do we fall back to decrypting with the
|
||||
previous key and re-encrypting. Checking the current key first
|
||||
keeps ``run()`` idempotent regardless of what ``previous_secret_key``
|
||||
the caller supplies — even re-running with the same (unchanged)
|
||||
``SECRET_KEY`` will not rewrite rows.
|
||||
The "target form" is current-key + currently-configured engine for a
|
||||
``SECRET_KEY`` rotation, or current-key + the requested engine for an
|
||||
engine migration (see ``_target_type``).
|
||||
|
||||
Re-encryption is idempotent per column: a value that can already be
|
||||
read in the target form is left untouched. This keeps ``run()``
|
||||
idempotent regardless of what ``previous_secret_key`` the caller
|
||||
supplies, and lets an interrupted engine migration be resumed.
|
||||
Otherwise the plaintext is recovered from the first candidate source
|
||||
decryptor that can read it (see ``_source_decryptors``) and re-encrypted
|
||||
into the target form.
|
||||
|
||||
NULL values are never encrypted, so they are reported separately
|
||||
(neither re-encrypted nor "skipped because already current").
|
||||
|
||||
Per-column outcomes are accumulated onto ``stats`` so the caller can
|
||||
report a summary. Columns whose ciphertext is unreadable under both
|
||||
keys are counted as failures and logged; the exception is not
|
||||
propagated, so processing continues. The caller is responsible for
|
||||
raising once all rows have been scanned.
|
||||
report a summary. Columns whose ciphertext is unreadable under any
|
||||
candidate key/engine are counted as failures and logged; the exception
|
||||
is not propagated, so processing continues. The caller is responsible
|
||||
for raising once all rows have been scanned.
|
||||
|
||||
If no columns need re-encryption, no UPDATE is issued.
|
||||
|
||||
@@ -223,38 +437,67 @@ class SecretsMigrator:
|
||||
stats.null += 1
|
||||
continue
|
||||
|
||||
# Fast path: if the current key can already read the value,
|
||||
# leave it untouched. A failure here simply means we need to try
|
||||
# the previous key below — not a condition worth logging.
|
||||
try:
|
||||
encrypted_type.process_result_value(raw_value, self._dialect)
|
||||
except Exception: # noqa: BLE001, S110 # pylint: disable=broad-except
|
||||
pass
|
||||
else:
|
||||
stats.skipped += 1
|
||||
continue
|
||||
target_type = self._target_type(encrypted_type)
|
||||
|
||||
# Current key cannot decrypt — try the previous key.
|
||||
previous_encrypted_type = EncryptedType(
|
||||
type_in=encrypted_type.underlying_type, key=self._previous_secret_key
|
||||
# Fast path: if the value can already be read in the target form,
|
||||
# leave it untouched. A failure here simply means we need to try the
|
||||
# source decryptors below — not a condition worth logging.
|
||||
#
|
||||
# Caveat for an *unauthenticated* target (AES-CBC, e.g. an
|
||||
# ``--engine aes`` rollback): CBC decryption has no authentication
|
||||
# tag, so an authenticated (AES-GCM) ciphertext can coincidentally
|
||||
# "decrypt" under CBC and be wrongly classified as already migrated —
|
||||
# leaving it as GCM, which then bricks once the config is flipped to
|
||||
# CBC, with the run still reporting success. So when the target is
|
||||
# unauthenticated, first rule out that the value is provably in an
|
||||
# authenticated form; an authenticated decrypt must win over an
|
||||
# unauthenticated one. (A forward migration to AES-GCM is unaffected:
|
||||
# an authenticated target read is itself proof.)
|
||||
target_is_authenticated = _is_authenticated_engine(type(target_type.engine))
|
||||
provably_authenticated = (
|
||||
not target_is_authenticated
|
||||
and self._decrypts_under_authenticated_engine(encrypted_type, raw_value)
|
||||
)
|
||||
try:
|
||||
unencrypted_value = previous_encrypted_type.process_result_value(
|
||||
raw_value, self._dialect
|
||||
)
|
||||
except Exception as prev_ex: # noqa: BLE001 # pylint: disable=broad-except
|
||||
if not provably_authenticated:
|
||||
try:
|
||||
target_type.process_result_value(raw_value, self._dialect)
|
||||
except ( # noqa: S110 # pylint: disable=broad-except
|
||||
Exception # noqa: BLE001
|
||||
):
|
||||
pass
|
||||
else:
|
||||
stats.skipped += 1
|
||||
continue
|
||||
|
||||
# Recover the plaintext from the first source decryptor that can
|
||||
# read the value (current engine/key, then previous key).
|
||||
unencrypted_value = None
|
||||
decrypted = False
|
||||
last_error: Optional[Exception] = None
|
||||
for decryptor in self._source_decryptors(encrypted_type):
|
||||
try:
|
||||
unencrypted_value = decryptor.process_result_value(
|
||||
raw_value, self._dialect
|
||||
)
|
||||
except Exception as ex: # noqa: BLE001 # pylint: disable=broad-except
|
||||
last_error = ex
|
||||
continue
|
||||
decrypted = True
|
||||
break
|
||||
|
||||
if not decrypted:
|
||||
logger.error(
|
||||
"Column [%s.%s] cannot be decrypted under the previous"
|
||||
" or current secret key (%s: %s)",
|
||||
"Column [%s.%s] cannot be decrypted under any known"
|
||||
" key/engine (%s: %s)",
|
||||
table_name,
|
||||
column_name,
|
||||
type(prev_ex).__name__,
|
||||
prev_ex,
|
||||
type(last_error).__name__ if last_error else "None",
|
||||
last_error,
|
||||
)
|
||||
stats.failed += 1
|
||||
continue
|
||||
|
||||
re_encrypted_columns[column_name] = encrypted_type.process_bind_param(
|
||||
re_encrypted_columns[column_name] = target_type.process_bind_param(
|
||||
unencrypted_value,
|
||||
self._dialect,
|
||||
)
|
||||
@@ -276,12 +519,13 @@ class SecretsMigrator:
|
||||
|
||||
def run(self) -> ReEncryptStats:
|
||||
"""
|
||||
Re-encrypt every encrypted column in the ORM under the current
|
||||
``SECRET_KEY``.
|
||||
Re-encrypt every encrypted column in the ORM into the target form
|
||||
(current ``SECRET_KEY`` and, for an engine migration, the target
|
||||
engine).
|
||||
|
||||
Returns per-value counts of re-encrypted, skipped (already under the
|
||||
current key), and failed (undecryptable) outcomes. If any failures
|
||||
occurred the transaction is rolled back by raising after the
|
||||
Returns per-value counts of re-encrypted, skipped (already in the
|
||||
target form), null, and failed (undecryptable) outcomes. If any
|
||||
failures occurred the transaction is rolled back by raising after the
|
||||
summary is logged, so partial re-encryption never commits.
|
||||
"""
|
||||
encrypted_meta_info = self.discover_encrypted_fields()
|
||||
|
||||
@@ -24,11 +24,14 @@ from zipfile import is_zipfile, ZipFile
|
||||
import pytest
|
||||
import yaml # noqa: F401
|
||||
from flask import current_app
|
||||
from flask.ctx import AppContext
|
||||
from freezegun import freeze_time
|
||||
from sqlalchemy_utils.types.encrypted.encrypted_type import AesGcmEngine
|
||||
|
||||
import superset.cli.importexport
|
||||
import superset.cli.thumbnails
|
||||
import superset.cli.update
|
||||
import superset.utils.encrypt
|
||||
from superset import db
|
||||
from superset.models.dashboard import Dashboard
|
||||
from tests.integration_tests.fixtures.birth_names_dashboard import (
|
||||
@@ -364,3 +367,102 @@ def test_re_encrypt_secrets_failure_exits_nonzero(app_context):
|
||||
# The failure path must be handled by the CLI, not leaked as an
|
||||
# uncaught exception.
|
||||
assert response.exception is None or isinstance(response.exception, SystemExit)
|
||||
|
||||
|
||||
def test_re_encrypt_secrets_engine_option_invokes_migrator(
|
||||
app_context: AppContext,
|
||||
) -> None:
|
||||
"""
|
||||
When --engine is provided, the CLI must resolve the engine name to the
|
||||
correct engine class and pass it to SecretsMigrator as target_engine.
|
||||
"""
|
||||
current_app.config.pop("PREVIOUS_SECRET_KEY", None)
|
||||
runner = current_app.test_cli_runner()
|
||||
with mock.patch.object(
|
||||
superset.cli.update,
|
||||
"SecretsMigrator",
|
||||
) as migrator_mock:
|
||||
migrator_mock.return_value.run.return_value = (
|
||||
superset.utils.encrypt.ReEncryptStats()
|
||||
)
|
||||
response = runner.invoke(
|
||||
superset.cli.update.re_encrypt_secrets,
|
||||
["--engine", "aes-gcm"],
|
||||
)
|
||||
|
||||
assert response.exit_code == 0
|
||||
call_kwargs = migrator_mock.call_args.kwargs
|
||||
assert call_kwargs.get("target_engine") is AesGcmEngine
|
||||
assert call_kwargs.get("previous_secret_key") is None
|
||||
|
||||
|
||||
def test_re_encrypt_secrets_engine_option_case_insensitive(
|
||||
app_context: AppContext,
|
||||
) -> None:
|
||||
"""
|
||||
The --engine option must be case-insensitive per
|
||||
click.Choice(..., case_sensitive=False).
|
||||
"""
|
||||
current_app.config.pop("PREVIOUS_SECRET_KEY", None)
|
||||
runner = current_app.test_cli_runner()
|
||||
with mock.patch.object(
|
||||
superset.cli.update,
|
||||
"SecretsMigrator",
|
||||
) as migrator_mock:
|
||||
migrator_mock.return_value.run.return_value = (
|
||||
superset.utils.encrypt.ReEncryptStats()
|
||||
)
|
||||
response = runner.invoke(
|
||||
superset.cli.update.re_encrypt_secrets,
|
||||
["--engine", "AES-GCM"],
|
||||
)
|
||||
|
||||
assert response.exit_code == 0
|
||||
assert migrator_mock.call_args.kwargs.get("target_engine") is AesGcmEngine
|
||||
|
||||
|
||||
def test_re_encrypt_secrets_combined_key_rotation_and_engine(
|
||||
app_context: AppContext,
|
||||
) -> None:
|
||||
"""
|
||||
--previous_secret_key and --engine combine in a single run: the migrator
|
||||
must receive both the previous key (for decryption) and the target engine
|
||||
(for re-encryption). This is the mode most likely to regress, since the
|
||||
single-option tests each pin only the other's variable.
|
||||
"""
|
||||
current_app.config.pop("PREVIOUS_SECRET_KEY", None)
|
||||
runner = current_app.test_cli_runner()
|
||||
with mock.patch.object(
|
||||
superset.cli.update,
|
||||
"SecretsMigrator",
|
||||
) as migrator_mock:
|
||||
migrator_mock.return_value.run.return_value = (
|
||||
superset.utils.encrypt.ReEncryptStats()
|
||||
)
|
||||
response = runner.invoke(
|
||||
superset.cli.update.re_encrypt_secrets,
|
||||
["--previous_secret_key", "old-key", "--engine", "aes-gcm"],
|
||||
)
|
||||
|
||||
assert response.exit_code == 0
|
||||
call_kwargs = migrator_mock.call_args.kwargs
|
||||
assert call_kwargs.get("target_engine") is AesGcmEngine
|
||||
assert call_kwargs.get("previous_secret_key") == "old-key"
|
||||
|
||||
|
||||
def test_re_encrypt_secrets_engine_option_invalid_raises_usage(
|
||||
app_context: AppContext,
|
||||
) -> None:
|
||||
"""
|
||||
An unrecognized engine name must produce a click usage error, not a
|
||||
traceback or silent failure.
|
||||
"""
|
||||
runner = current_app.test_cli_runner()
|
||||
response = runner.invoke(
|
||||
superset.cli.update.re_encrypt_secrets,
|
||||
["--engine", "nonexistent-engine"],
|
||||
)
|
||||
|
||||
assert response.exit_code != 0
|
||||
assert "Invalid value" in response.output or "Usage:" in response.output
|
||||
assert "aes" in response.output or "aes-gcm" in response.output
|
||||
|
||||
@@ -447,6 +447,43 @@ def test_ownership_check_raises_forbidden(mocker: MockerFixture) -> None:
|
||||
cmd.validate()
|
||||
|
||||
|
||||
# --- Dashboard extra (activeTabs) validation on update ---
|
||||
|
||||
|
||||
def test_update_rejects_invalid_active_tab_ids(mocker: MockerFixture) -> None:
|
||||
"""On PUT, activeTabs must be validated against the model's dashboard layout.
|
||||
|
||||
The dashboard is not in the payload, so validation must fall back to the
|
||||
existing model's dashboard; tab ids absent from position_json are rejected.
|
||||
"""
|
||||
model = _make_model(mocker, model_type=ReportScheduleType.REPORT, database_id=None)
|
||||
model.dashboard.position_json = '{"TAB-valid": {}}'
|
||||
_setup_mocks(mocker, model)
|
||||
|
||||
cmd = UpdateReportScheduleCommand(
|
||||
model_id=1,
|
||||
data={"extra": {"dashboard": {"activeTabs": ["TAB-missing"]}}},
|
||||
)
|
||||
with pytest.raises(ReportScheduleInvalidError) as exc_info:
|
||||
cmd.validate()
|
||||
messages = _get_validation_messages(exc_info)
|
||||
assert "extra" in messages
|
||||
assert "invalid tab ids" in messages["extra"].lower()
|
||||
|
||||
|
||||
def test_update_accepts_valid_active_tab_ids(mocker: MockerFixture) -> None:
|
||||
"""A tab id present in the model dashboard's position_json passes validation."""
|
||||
model = _make_model(mocker, model_type=ReportScheduleType.REPORT, database_id=None)
|
||||
model.dashboard.position_json = '{"TAB-valid": {}}'
|
||||
_setup_mocks(mocker, model)
|
||||
|
||||
cmd = UpdateReportScheduleCommand(
|
||||
model_id=1,
|
||||
data={"extra": {"dashboard": {"activeTabs": ["TAB-valid"]}}},
|
||||
)
|
||||
cmd.validate() # should not raise
|
||||
|
||||
|
||||
# --- Database not found for alert ---
|
||||
|
||||
|
||||
|
||||
@@ -90,3 +90,40 @@ def test_find_by_extra_metadata_escapes_underscore_wildcard(
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].name == "with-underscore"
|
||||
|
||||
|
||||
def test_find_by_native_filter_id_returns_matching_reports(
|
||||
session: Session,
|
||||
) -> None:
|
||||
extra = json.dumps({"dashboard": {"nativeFilters": "NATIVE_FILTER-abc123"}})
|
||||
_create_report(session, "match", extra_json=extra)
|
||||
_create_report(session, "no-match", extra_json="{}")
|
||||
|
||||
results = ReportScheduleDAO.find_by_native_filter_id("NATIVE_FILTER-abc123")
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].name == "match"
|
||||
|
||||
|
||||
def test_find_by_native_filter_id_escapes_percent_wildcard(
|
||||
session: Session,
|
||||
) -> None:
|
||||
_create_report(session, "with-percent", extra_json='{"id": "FILTER-100%x"}')
|
||||
_create_report(session, "other", extra_json='{"id": "FILTER-100yx"}')
|
||||
|
||||
results = ReportScheduleDAO.find_by_native_filter_id("FILTER-100%x")
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].name == "with-percent"
|
||||
|
||||
|
||||
def test_find_by_native_filter_id_escapes_underscore_wildcard(
|
||||
session: Session,
|
||||
) -> None:
|
||||
_create_report(session, "with-underscore", extra_json='{"id": "FILTER-a_b"}')
|
||||
_create_report(session, "other", extra_json='{"id": "FILTER-axb"}')
|
||||
|
||||
results = ReportScheduleDAO.find_by_native_filter_id("FILTER-a_b")
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].name == "with-underscore"
|
||||
|
||||
@@ -1226,3 +1226,129 @@ def test_create_serializer_include_schemas_true_with_compact():
|
||||
assert result[0]["inputSchema"]["properties"]["filters"]["items"] == {
|
||||
"type": "object"
|
||||
}
|
||||
|
||||
|
||||
# -- search_tools optional query tests --
|
||||
|
||||
|
||||
def test_search_tool_query_is_optional_in_schema() -> None:
|
||||
"""search_tools schema marks query optional with a flat concrete type.
|
||||
|
||||
The query schema must not use ``anyOf`` — MCP bridges (mcp-remote,
|
||||
Claude Desktop) strip ``anyOf`` and leave the field typeless, the same
|
||||
failure mode ``_fix_call_tool_arguments`` guards against.
|
||||
"""
|
||||
mock_mcp = MagicMock()
|
||||
config = {
|
||||
"strategy": "bm25",
|
||||
"max_results": 5,
|
||||
"always_visible": [],
|
||||
"search_tool_name": "search_tools",
|
||||
"call_tool_name": "call_tool",
|
||||
}
|
||||
_apply_tool_search_transform(mock_mcp, config)
|
||||
transform = mock_mcp.add_transform.call_args[0][0]
|
||||
search_tool = transform._make_search_tool()
|
||||
|
||||
params = search_tool.parameters
|
||||
assert "query" not in params.get("required", [])
|
||||
query_schema = params["properties"]["query"]
|
||||
assert query_schema["type"] == "string"
|
||||
assert "anyOf" not in query_schema
|
||||
|
||||
|
||||
def test_search_tool_with_no_query_returns_all_visible_tools() -> None:
|
||||
"""search_tools returns all visible tools when called with no arguments."""
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
mock_mcp = MagicMock()
|
||||
config = {
|
||||
"strategy": "bm25",
|
||||
"max_results": 5,
|
||||
"always_visible": [],
|
||||
"search_tool_name": "search_tools",
|
||||
"call_tool_name": "call_tool",
|
||||
}
|
||||
_apply_tool_search_transform(mock_mcp, config)
|
||||
transform = mock_mcp.add_transform.call_args[0][0]
|
||||
|
||||
tool_a = MagicMock()
|
||||
tool_b = MagicMock()
|
||||
all_tools = [tool_a, tool_b]
|
||||
|
||||
async def run() -> list[MagicMock]:
|
||||
transform._get_visible_tools = AsyncMock(return_value=all_tools)
|
||||
transform._render_results = AsyncMock(return_value=[{"name": "tool_a"}])
|
||||
search_tool = transform._make_search_tool()
|
||||
await search_tool.run({}) # must not raise ValidationError
|
||||
return transform._render_results.call_args[0][0]
|
||||
|
||||
rendered_with = asyncio.run(run())
|
||||
assert rendered_with == all_tools
|
||||
|
||||
|
||||
def test_search_tool_empty_string_query_returns_all_visible_tools() -> None:
|
||||
"""An explicitly empty query is treated like an omitted one (fail open).
|
||||
|
||||
BM25/regex search with no search terms would rank nothing and return
|
||||
an empty catalog — the same discovery footgun as the required-query
|
||||
bug. Clients sending ``{"query": ""}`` to mean "list everything" get
|
||||
the full visible catalog instead.
|
||||
"""
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
mock_mcp = MagicMock()
|
||||
config = {
|
||||
"strategy": "bm25",
|
||||
"max_results": 5,
|
||||
"always_visible": [],
|
||||
"search_tool_name": "search_tools",
|
||||
"call_tool_name": "call_tool",
|
||||
}
|
||||
_apply_tool_search_transform(mock_mcp, config)
|
||||
transform = mock_mcp.add_transform.call_args[0][0]
|
||||
|
||||
all_tools = [MagicMock(), MagicMock()]
|
||||
|
||||
async def run() -> list[MagicMock]:
|
||||
transform._get_visible_tools = AsyncMock(return_value=all_tools)
|
||||
transform._search = AsyncMock()
|
||||
transform._render_results = AsyncMock(return_value=[])
|
||||
search_tool = transform._make_search_tool()
|
||||
await search_tool.run({"query": ""})
|
||||
return transform._render_results.call_args[0][0]
|
||||
|
||||
rendered_with = asyncio.run(run())
|
||||
assert rendered_with == all_tools
|
||||
assert not transform._search.called
|
||||
|
||||
|
||||
def test_search_tool_regex_with_no_query_returns_all_visible_tools() -> None:
|
||||
"""Regex strategy returns all visible tools when called with no arguments."""
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
mock_mcp = MagicMock()
|
||||
config = {
|
||||
"strategy": "regex",
|
||||
"max_results": 5,
|
||||
"always_visible": [],
|
||||
"search_tool_name": "search_tools",
|
||||
"call_tool_name": "call_tool",
|
||||
}
|
||||
_apply_tool_search_transform(mock_mcp, config)
|
||||
transform = mock_mcp.add_transform.call_args[0][0]
|
||||
|
||||
all_tools = [MagicMock(), MagicMock()]
|
||||
|
||||
async def run() -> list[MagicMock]:
|
||||
transform._get_visible_tools = AsyncMock(return_value=all_tools)
|
||||
transform._render_results = AsyncMock(return_value=[])
|
||||
search_tool = transform._make_search_tool()
|
||||
await search_tool.run({})
|
||||
return transform._render_results.call_args[0][0]
|
||||
|
||||
rendered_with = asyncio.run(run())
|
||||
assert rendered_with == all_tools
|
||||
|
||||
@@ -62,3 +62,47 @@ def test_report_schedule_all_text_filter_applies_ilike() -> None:
|
||||
f = ReportScheduleAllTextFilter("name", MagicMock())
|
||||
f.apply(query, "test")
|
||||
query.filter.assert_called_once()
|
||||
|
||||
|
||||
@patch("superset.reports.filters.or_")
|
||||
@patch("superset.reports.filters.ReportSchedule")
|
||||
def test_report_schedule_all_text_filter_escapes_wildcards(
|
||||
mock_report_schedule: MagicMock, mock_or: MagicMock
|
||||
) -> None:
|
||||
"""User-supplied wildcards must be escaped so they match literally."""
|
||||
from superset.reports.filters import ReportScheduleAllTextFilter
|
||||
|
||||
query = MagicMock()
|
||||
f = ReportScheduleAllTextFilter("name", MagicMock())
|
||||
# raw input contains every LIKE special character plus a backslash
|
||||
f.apply(query, "50%_off\\promo")
|
||||
|
||||
# %, _ and \ are all escaped, and the literal is wrapped for a "contains" match
|
||||
expected = "%50\\%\\_off\\\\promo%"
|
||||
for column in (
|
||||
mock_report_schedule.name,
|
||||
mock_report_schedule.description,
|
||||
mock_report_schedule.sql,
|
||||
):
|
||||
column.ilike.assert_called_once_with(expected, escape="\\")
|
||||
|
||||
|
||||
@patch("superset.reports.filters.or_")
|
||||
@patch("superset.reports.filters.ReportSchedule")
|
||||
def test_report_schedule_all_text_filter_coerces_non_string(
|
||||
mock_report_schedule: MagicMock, mock_or: MagicMock
|
||||
) -> None:
|
||||
"""A non-string value (e.g. an int) must not raise when escaping."""
|
||||
from superset.reports.filters import ReportScheduleAllTextFilter
|
||||
|
||||
query = MagicMock()
|
||||
f = ReportScheduleAllTextFilter("name", MagicMock())
|
||||
f.apply(query, 50)
|
||||
|
||||
expected = "%50%"
|
||||
for column in (
|
||||
mock_report_schedule.name,
|
||||
mock_report_schedule.description,
|
||||
mock_report_schedule.sql,
|
||||
):
|
||||
column.ilike.assert_called_once_with(expected, escape="\\")
|
||||
|
||||
108
tests/unit_tests/test_mcp_stdio_entrypoint.py
Normal file
108
tests/unit_tests/test_mcp_stdio_entrypoint.py
Normal file
@@ -0,0 +1,108 @@
|
||||
# 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.
|
||||
|
||||
import io
|
||||
import sys
|
||||
from importlib import util
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import click
|
||||
import pytest
|
||||
|
||||
_ENTRYPOINT_PATH = (
|
||||
Path(__file__).resolve().parents[2] / "superset/mcp_service/__main__.py"
|
||||
)
|
||||
|
||||
|
||||
def _install_entrypoint_stubs(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
superset_module = cast(Any, ModuleType("superset"))
|
||||
superset_module.__path__ = []
|
||||
|
||||
mcp_service_module = cast(Any, ModuleType("superset.mcp_service"))
|
||||
mcp_service_module.__path__ = []
|
||||
|
||||
app_module = cast(Any, ModuleType("superset.mcp_service.app"))
|
||||
app_module.init_fastmcp_server = MagicMock()
|
||||
app_module.mcp = MagicMock()
|
||||
|
||||
middleware_module = cast(Any, ModuleType("superset.mcp_service.middleware"))
|
||||
middleware_module.create_response_size_guard_middleware = lambda: None
|
||||
|
||||
server_module = cast(Any, ModuleType("superset.mcp_service.server"))
|
||||
server_module.build_middleware_list = lambda: []
|
||||
|
||||
monkeypatch.setitem(sys.modules, "superset", superset_module)
|
||||
monkeypatch.setitem(sys.modules, "superset.mcp_service", mcp_service_module)
|
||||
monkeypatch.setitem(sys.modules, "superset.mcp_service.app", app_module)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"superset.mcp_service.middleware",
|
||||
middleware_module,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "superset.mcp_service.server", server_module)
|
||||
|
||||
|
||||
def _load_entrypoint(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
module_name = "superset.mcp_service.__main__"
|
||||
spec = util.spec_from_file_location(module_name, _ENTRYPOINT_PATH)
|
||||
assert spec is not None
|
||||
assert spec.loader is not None
|
||||
|
||||
module = util.module_from_spec(spec)
|
||||
monkeypatch.setitem(sys.modules, module_name, module)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
|
||||
def test_stdio_click_output_is_redirected_to_stderr(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""click output should use the saved original functions in stdio mode."""
|
||||
original_echo = click.echo
|
||||
original_secho = click.secho
|
||||
|
||||
monkeypatch.setenv("FASTMCP_TRANSPORT", "stdio")
|
||||
monkeypatch.setattr(click, "echo", original_echo)
|
||||
monkeypatch.setattr(click, "secho", original_secho)
|
||||
_install_entrypoint_stubs(monkeypatch)
|
||||
|
||||
try:
|
||||
_load_entrypoint(monkeypatch)
|
||||
|
||||
other_stream = io.StringIO()
|
||||
click.echo("plain message")
|
||||
click.echo("keyword file message", file=other_stream)
|
||||
click.echo("positional file message", other_stream)
|
||||
click.secho("styled message")
|
||||
click.secho("styled keyword file message", file=other_stream)
|
||||
click.secho("styled positional file message", other_stream)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
assert other_stream.getvalue() == ""
|
||||
assert "plain message" in captured.err
|
||||
assert "keyword file message" in captured.err
|
||||
assert "positional file message" in captured.err
|
||||
assert "styled message" in captured.err
|
||||
assert "styled keyword file message" in captured.err
|
||||
assert "styled positional file message" in captured.err
|
||||
finally:
|
||||
click.echo = original_echo
|
||||
click.secho = original_secho
|
||||
373
tests/unit_tests/utils/encrypt_test.py
Normal file
373
tests/unit_tests/utils/encrypt_test.py
Normal file
@@ -0,0 +1,373 @@
|
||||
# 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 unittest import mock
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlalchemy_utils.types.encrypted.encrypted_type import AesEngine, AesGcmEngine
|
||||
|
||||
from superset.utils.encrypt import (
|
||||
EncryptedType,
|
||||
ReEncryptStats,
|
||||
resolve_encryption_engine,
|
||||
SecretsMigrator,
|
||||
SQLAlchemyUtilsAdapter,
|
||||
)
|
||||
|
||||
SECRET = {"SECRET_KEY": "x" * 32}
|
||||
SECRET_KEY = "k" * 32
|
||||
DIALECT = make_url("sqlite://").get_dialect()
|
||||
|
||||
|
||||
def _encrypted_type(engine: type) -> EncryptedType:
|
||||
"""A standalone EncryptedType for a given engine under SECRET_KEY."""
|
||||
return EncryptedType(String(1024), key=lambda: SECRET_KEY, engine=engine)
|
||||
|
||||
|
||||
def _engine_migrator(target_engine: type) -> SecretsMigrator:
|
||||
"""Build a SecretsMigrator in engine-migration mode without an app context.
|
||||
|
||||
``__init__`` reads ``current_app`` and the DB dialect, so — like the
|
||||
existing row-level tests that override ``_dialect`` — we set the few
|
||||
attributes ``_re_encrypt_row`` actually uses directly.
|
||||
"""
|
||||
migrator = SecretsMigrator.__new__(SecretsMigrator)
|
||||
migrator._secret_key = SECRET_KEY # noqa: SLF001
|
||||
migrator._target_engine = target_engine # noqa: SLF001
|
||||
# Engine migration keeps the SECRET_KEY; previous key defaults to current.
|
||||
migrator._previous_secret_key = SECRET_KEY # noqa: SLF001
|
||||
migrator._dialect = DIALECT # noqa: SLF001
|
||||
return migrator
|
||||
|
||||
|
||||
def test_default_engine_is_aes_cbc() -> None:
|
||||
"""Without config, the adapter keeps the historical AES-CBC engine."""
|
||||
field = SQLAlchemyUtilsAdapter().create(SECRET, String(128))
|
||||
assert isinstance(field.engine, AesEngine)
|
||||
|
||||
|
||||
def test_aes_gcm_engine_selected_by_config() -> None:
|
||||
"""SQLALCHEMY_ENCRYPTED_FIELD_ENGINE='aes-gcm' selects authenticated AES-GCM."""
|
||||
field = SQLAlchemyUtilsAdapter().create(
|
||||
{**SECRET, "SQLALCHEMY_ENCRYPTED_FIELD_ENGINE": "aes-gcm"},
|
||||
String(128),
|
||||
)
|
||||
assert isinstance(field.engine, AesGcmEngine)
|
||||
|
||||
|
||||
def test_unknown_engine_raises_fail_closed() -> None:
|
||||
"""An unrecognized engine name fails closed at field construction.
|
||||
|
||||
Silently falling back to unauthenticated AES-CBC would let an operator who
|
||||
typo'd the engine believe they had authenticated encryption — and, after a
|
||||
GCM migration, write new secrets as CBC into a GCM database. The error must
|
||||
not leak the configured value (it shares the config namespace as SECRET_KEY)
|
||||
but must list the valid engines so a typo is diagnosable.
|
||||
"""
|
||||
with pytest.raises(
|
||||
ValueError, match="Unrecognized SQLALCHEMY_ENCRYPTED_FIELD_ENGINE"
|
||||
) as exc_info:
|
||||
SQLAlchemyUtilsAdapter().create(
|
||||
{**SECRET, "SQLALCHEMY_ENCRYPTED_FIELD_ENGINE": "bogus"},
|
||||
String(128),
|
||||
)
|
||||
message = str(exc_info.value)
|
||||
assert "bogus" not in message
|
||||
assert "aes" in message
|
||||
assert "aes-gcm" in message
|
||||
|
||||
|
||||
def test_empty_engine_value_raises_fail_closed() -> None:
|
||||
"""A present-but-empty engine value fails closed instead of defaulting.
|
||||
|
||||
Only an *absent* key falls back to AES-CBC. An empty string (e.g. a
|
||||
blanked-out env var) must not silently degrade to unauthenticated CBC after
|
||||
a GCM migration — it routes through the same fail-closed resolver as any
|
||||
other unrecognized value.
|
||||
"""
|
||||
with pytest.raises(
|
||||
ValueError, match="Unrecognized SQLALCHEMY_ENCRYPTED_FIELD_ENGINE"
|
||||
):
|
||||
SQLAlchemyUtilsAdapter().create(
|
||||
{**SECRET, "SQLALCHEMY_ENCRYPTED_FIELD_ENGINE": ""},
|
||||
String(128),
|
||||
)
|
||||
|
||||
|
||||
def test_non_string_engine_value_raises_fail_closed() -> None:
|
||||
"""A non-string engine value (e.g. ``None``) fails closed, not with an
|
||||
``AttributeError``.
|
||||
|
||||
A custom config override could set the engine to a non-string. That must
|
||||
take the same controlled ``ValueError`` path as any unrecognized value
|
||||
rather than raising ``AttributeError`` when the resolver normalizes it.
|
||||
"""
|
||||
with pytest.raises(
|
||||
ValueError, match="Unrecognized SQLALCHEMY_ENCRYPTED_FIELD_ENGINE"
|
||||
):
|
||||
resolve_encryption_engine(None)
|
||||
|
||||
|
||||
def test_engine_name_is_normalized() -> None:
|
||||
"""Engine names are case/separator-normalized to match the CLI's Choice."""
|
||||
for name in ("AES-GCM", "aes_gcm", " Aes-Gcm "):
|
||||
field = SQLAlchemyUtilsAdapter().create(
|
||||
{**SECRET, "SQLALCHEMY_ENCRYPTED_FIELD_ENGINE": name},
|
||||
String(128),
|
||||
)
|
||||
assert isinstance(field.engine, AesGcmEngine)
|
||||
|
||||
|
||||
def test_explicit_engine_kwarg_takes_precedence() -> None:
|
||||
"""An explicit engine kwarg overrides the config (used by the migrator)."""
|
||||
field = SQLAlchemyUtilsAdapter().create(
|
||||
{**SECRET, "SQLALCHEMY_ENCRYPTED_FIELD_ENGINE": "aes-gcm"},
|
||||
String(128),
|
||||
engine=AesEngine,
|
||||
)
|
||||
assert isinstance(field.engine, AesEngine)
|
||||
|
||||
|
||||
def test_engine_migration_cbc_to_gcm_re_encrypts() -> None:
|
||||
"""CBC source value is re-encrypted into GCM under the same SECRET_KEY.
|
||||
|
||||
Mirrors the recommended runbook: the migrator runs while the config still
|
||||
points at AES-CBC (the column type), re-encrypting into AES-GCM.
|
||||
"""
|
||||
cbc = _encrypted_type(AesEngine)
|
||||
ciphertext = cbc.process_bind_param("hunter2", DIALECT)
|
||||
|
||||
migrator = _engine_migrator(AesGcmEngine)
|
||||
conn = MagicMock()
|
||||
row = {"id": 1, "password": ciphertext}
|
||||
stats = ReEncryptStats()
|
||||
|
||||
migrator._re_encrypt_row( # noqa: SLF001
|
||||
conn, row, "dbs", {"password": cbc}, ["id"], stats
|
||||
)
|
||||
|
||||
assert stats == ReEncryptStats(re_encrypted=1)
|
||||
assert conn.execute.call_count == 1
|
||||
new_value = conn.execute.call_args.kwargs["password"]
|
||||
# The stored value changed and now decrypts as GCM back to the plaintext.
|
||||
assert new_value != ciphertext
|
||||
gcm = _encrypted_type(AesGcmEngine)
|
||||
assert gcm.process_result_value(new_value, DIALECT) == "hunter2"
|
||||
|
||||
|
||||
def test_engine_migration_idempotent_for_already_target() -> None:
|
||||
"""A value already in the target (GCM) form is skipped — runs are resumable.
|
||||
|
||||
The column is still configured as CBC (config not yet flipped), but the
|
||||
value has already been migrated to GCM, so it must be left untouched.
|
||||
"""
|
||||
gcm_value = _encrypted_type(AesGcmEngine).process_bind_param("hunter2", DIALECT)
|
||||
cbc_column = _encrypted_type(AesEngine)
|
||||
|
||||
migrator = _engine_migrator(AesGcmEngine)
|
||||
conn = MagicMock()
|
||||
row = {"id": 1, "password": gcm_value}
|
||||
stats = ReEncryptStats()
|
||||
|
||||
migrator._re_encrypt_row( # noqa: SLF001
|
||||
conn, row, "dbs", {"password": cbc_column}, ["id"], stats
|
||||
)
|
||||
|
||||
assert stats == ReEncryptStats(skipped=1)
|
||||
assert conn.execute.call_count == 0
|
||||
|
||||
|
||||
def test_engine_migration_reads_cbc_after_config_already_flipped() -> None:
|
||||
"""CBC source is still migrated when the config was flipped to GCM first.
|
||||
|
||||
If an operator sets the config engine to GCM before running the migrator,
|
||||
the column type can no longer read the CBC value; the previous-key (== the
|
||||
current key) AES-CBC decryptor recovers it and it is re-encrypted as GCM.
|
||||
"""
|
||||
cbc_value = _encrypted_type(AesEngine).process_bind_param("hunter2", DIALECT)
|
||||
gcm_column = _encrypted_type(AesGcmEngine)
|
||||
|
||||
migrator = _engine_migrator(AesGcmEngine)
|
||||
conn = MagicMock()
|
||||
row = {"id": 1, "password": cbc_value}
|
||||
stats = ReEncryptStats()
|
||||
|
||||
migrator._re_encrypt_row( # noqa: SLF001
|
||||
conn, row, "dbs", {"password": gcm_column}, ["id"], stats
|
||||
)
|
||||
|
||||
assert stats == ReEncryptStats(re_encrypted=1)
|
||||
new_value = conn.execute.call_args.kwargs["password"]
|
||||
assert gcm_column.process_result_value(new_value, DIALECT) == "hunter2"
|
||||
|
||||
|
||||
def test_engine_migration_gcm_to_cbc_rolls_back() -> None:
|
||||
"""GCM source value is rolled back to CBC under the same SECRET_KEY.
|
||||
|
||||
The reverse of the forward migration (``--engine aes``). The idempotency
|
||||
fast-path decrypts in the *target* form first; since the target here is
|
||||
unauthenticated AES-CBC, this guards against it mis-reading the AES-GCM
|
||||
ciphertext and wrongly skipping the value instead of re-encrypting it.
|
||||
"""
|
||||
gcm_value = _encrypted_type(AesGcmEngine).process_bind_param("hunter2", DIALECT)
|
||||
gcm_column = _encrypted_type(AesGcmEngine)
|
||||
|
||||
migrator = _engine_migrator(AesEngine)
|
||||
conn = MagicMock()
|
||||
row = {"id": 1, "password": gcm_value}
|
||||
stats = ReEncryptStats()
|
||||
|
||||
migrator._re_encrypt_row( # noqa: SLF001
|
||||
conn, row, "dbs", {"password": gcm_column}, ["id"], stats
|
||||
)
|
||||
|
||||
assert stats == ReEncryptStats(re_encrypted=1)
|
||||
new_value = conn.execute.call_args.kwargs["password"]
|
||||
assert new_value != gcm_value
|
||||
# The rolled-back value now decrypts as AES-CBC back to the plaintext.
|
||||
assert _encrypted_type(AesEngine).process_result_value(new_value, DIALECT) == (
|
||||
"hunter2"
|
||||
)
|
||||
|
||||
|
||||
def test_rollback_authenticated_probe_wins_over_spurious_cbc_skip() -> None:
|
||||
"""Rolling back to unauthenticated CBC must re-encrypt a provably-GCM value,
|
||||
never skip it — even if the unauthenticated target decrypt coincidentally
|
||||
succeeds. The authenticated (GCM) interpretation must win.
|
||||
|
||||
The coincidental CBC-decrypt-of-a-GCM-blob can't be crafted deterministically
|
||||
(it's a ~2^-128 event), so this pins the *ordering invariant* instead: force
|
||||
the target (CBC) read to "succeed", and assert the value is still re-encrypted
|
||||
because the authenticated probe is consulted first and wins. Without the
|
||||
guard this row would be wrongly counted as ``skipped``.
|
||||
"""
|
||||
gcm_value = _encrypted_type(AesGcmEngine).process_bind_param("hunter2", DIALECT)
|
||||
cbc_column = _encrypted_type(AesEngine)
|
||||
|
||||
migrator = _engine_migrator(AesEngine) # target = unauthenticated CBC (rollback)
|
||||
|
||||
# Simulate the spurious case: the unauthenticated CBC target read "succeeds"
|
||||
# even though the value is really GCM.
|
||||
spurious_target = MagicMock()
|
||||
spurious_target.engine = AesEngine()
|
||||
spurious_target.underlying_type = cbc_column.underlying_type
|
||||
spurious_target.process_result_value.return_value = "garbage"
|
||||
spurious_target.process_bind_param.return_value = b"new-cbc-ciphertext"
|
||||
|
||||
conn = MagicMock()
|
||||
row = {"id": 1, "password": gcm_value}
|
||||
stats = ReEncryptStats()
|
||||
|
||||
with mock.patch.object(migrator, "_target_type", return_value=spurious_target):
|
||||
migrator._re_encrypt_row( # noqa: SLF001
|
||||
conn, row, "dbs", {"password": cbc_column}, ["id"], stats
|
||||
)
|
||||
|
||||
# Re-encrypted, NOT skipped: the GCM authenticator beat the spurious CBC read.
|
||||
assert stats == ReEncryptStats(re_encrypted=1)
|
||||
assert spurious_target.process_bind_param.call_count == 1
|
||||
|
||||
|
||||
def test_combined_key_rotation_and_engine_migration() -> None:
|
||||
"""Old-key AES-CBC value → current-key AES-GCM in a single run.
|
||||
|
||||
Exercises the combined mode (``--previous_secret_key`` + ``--engine``): the
|
||||
source ciphertext is CBC under the *previous* key, and must be recovered and
|
||||
re-encrypted as GCM under the *current* key. This is the mode most likely to
|
||||
regress, since each single-mode test pins only the other's variable.
|
||||
"""
|
||||
old_key = "o" * 32
|
||||
cbc_old = EncryptedType(String(1024), key=lambda: old_key, engine=AesEngine)
|
||||
old_value = cbc_old.process_bind_param("hunter2", DIALECT)
|
||||
cbc_column = _encrypted_type(AesEngine)
|
||||
|
||||
migrator = _engine_migrator(AesGcmEngine)
|
||||
migrator._previous_secret_key = old_key # noqa: SLF001 # rotate key too
|
||||
|
||||
conn = MagicMock()
|
||||
row = {"id": 1, "password": old_value}
|
||||
stats = ReEncryptStats()
|
||||
|
||||
migrator._re_encrypt_row( # noqa: SLF001
|
||||
conn, row, "dbs", {"password": cbc_column}, ["id"], stats
|
||||
)
|
||||
|
||||
assert stats == ReEncryptStats(re_encrypted=1)
|
||||
new_value = conn.execute.call_args.kwargs["password"]
|
||||
# The migrated value decrypts as GCM under the *current* key.
|
||||
assert _encrypted_type(AesGcmEngine).process_result_value(new_value, DIALECT) == (
|
||||
"hunter2"
|
||||
)
|
||||
|
||||
|
||||
def _key_rotation_migrator(previous_secret_key: str) -> SecretsMigrator:
|
||||
"""Build a SecretsMigrator in key-rotation mode without an app context.
|
||||
|
||||
Like ``_engine_migrator`` but with no target engine: values are decrypted
|
||||
under ``previous_secret_key`` and re-encrypted under the current key using
|
||||
the column's own engine.
|
||||
"""
|
||||
migrator = SecretsMigrator.__new__(SecretsMigrator)
|
||||
migrator._secret_key = SECRET_KEY # noqa: SLF001
|
||||
migrator._target_engine = None # noqa: SLF001
|
||||
migrator._previous_secret_key = previous_secret_key # noqa: SLF001
|
||||
migrator._dialect = DIALECT # noqa: SLF001
|
||||
return migrator
|
||||
|
||||
|
||||
def test_key_rotation_for_aes_gcm_column() -> None:
|
||||
"""SECRET_KEY rotation works for an AES-GCM column.
|
||||
|
||||
The previous-key fallback must use the column's AES-GCM engine, otherwise
|
||||
GCM ciphertext written under the old key cannot be decrypted and the
|
||||
rotation rolls back.
|
||||
"""
|
||||
old_key = "o" * 32
|
||||
gcm_old = EncryptedType(String(1024), key=lambda: old_key, engine=AesGcmEngine)
|
||||
old_value = gcm_old.process_bind_param("hunter2", DIALECT)
|
||||
gcm_column = _encrypted_type(AesGcmEngine)
|
||||
|
||||
migrator = _key_rotation_migrator(previous_secret_key=old_key)
|
||||
conn = MagicMock()
|
||||
row = {"id": 1, "password": old_value}
|
||||
stats = ReEncryptStats()
|
||||
|
||||
migrator._re_encrypt_row( # noqa: SLF001
|
||||
conn, row, "dbs", {"password": gcm_column}, ["id"], stats
|
||||
)
|
||||
|
||||
assert stats == ReEncryptStats(re_encrypted=1)
|
||||
new_value = conn.execute.call_args.kwargs["password"]
|
||||
assert gcm_column.process_result_value(new_value, DIALECT) == "hunter2"
|
||||
|
||||
|
||||
def test_engine_migration_unreadable_value_counts_as_failure() -> None:
|
||||
"""A value no engine/key can read is a failure, not a silent pass-through."""
|
||||
migrator = _engine_migrator(AesGcmEngine)
|
||||
conn = MagicMock()
|
||||
row = {"id": 1, "password": b"not-valid-ciphertext"}
|
||||
stats = ReEncryptStats()
|
||||
|
||||
migrator._re_encrypt_row( # noqa: SLF001
|
||||
conn, row, "dbs", {"password": _encrypted_type(AesEngine)}, ["id"], stats
|
||||
)
|
||||
|
||||
assert stats == ReEncryptStats(failed=1)
|
||||
assert conn.execute.call_count == 0
|
||||
Reference in New Issue
Block a user