Compare commits

...
Author SHA1 Message Date
hainenber 7b29ae4320 chore(ci): disable Git commit info capture in Playwright E2E tests to avoid timeout
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-08-15 15:06:55 +07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2e8a8031f8 chore(deps): bump cachetools from 7.1.6 to 7.1.7 (#43208)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-15 14:33:36 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> cece082eed chore(deps-dev): update pyocient requirement from <4,>=1.0.15 to >=3.9.0,<4 (#43211)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-15 14:33:21 +07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
22dde07a8e chore(deps): bump mako from 1.3.12 to 1.4.1 (#43207)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-15 14:33:06 +07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Joe LiEvan Rusackas
93cc520482 chore(deps): bump @googleapis/sheets from 13.0.2 to 14.0.0 in /superset-frontend (#43047)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Joe Li <joe@preset.io>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-08-15 14:23:13 +07:00
7679c5641a fix(lint-metrics): record oxlint plugin rules under their canonical id (#43198)
Co-authored-by: bikashJMV <bikash@jmv.co.in>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 14:21:38 +07:00
Evan RusackasandClaude Code f7c70568c3 fix(charts): fix Bubble chart crash and correct example's time_range to a year with real data (#43201)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-08-15 14:16:29 +07:00
Michael GerberandEvan Rusackas 69c4011a95 feat(embedded): Add setDataMask API method (#35754)
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-08-14 22:07:24 -07:00
4684851336 fix(import): isolate per-tag import in a SAVEPOINT to avoid poisoned session (#42912) (#42920)
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-08-14 21:56:43 -07:00
e7139a7ac2 fix(models): enforce one OAuth2 token per user+database (#42897)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 21:50:56 -07:00
27 changed files with 1633 additions and 51 deletions
+3 -3
View File
@@ -42,7 +42,7 @@ dependencies = [
# ``google-auth`` 2.53+ dropped it, so Superset must declare it
# explicitly to keep fresh ``pip install apache-superset`` working
# without the ``base.txt`` lock file (#40962).
"cachetools>=7.1.6, <8",
"cachetools>=7.1.7, <8",
"celery>=5.6.3, <6.0.0",
"click>=8.4.2",
"click-option-group",
@@ -75,7 +75,7 @@ dependencies = [
"humanize",
"isodate",
"jsonpath-ng>=1.8.0, <2",
"Mako>=1.2.2",
"Mako>=1.4.1",
"markdown>=3.10.3",
# marshmallow 4 compatibility: see superset/marshmallow_compatibility.py for a
# Flask-AppBuilder workaround. Tracking issue:
@@ -221,7 +221,7 @@ ocient = [
# unpinned sqlalchemy>=1.4 declared, but SQLAlchemy 2.0 support is
# unverified. Lower confidence than the other bumps in this PR.
"sqlalchemy-ocient>=3.0.0, <4",
"pyocient>=1.0.15, <4",
"pyocient>=3.9.0, <4",
"shapely",
"geojson",
]
+1 -1
View File
@@ -28,7 +28,7 @@ numexpr>=2.9.0
# Security: CVE-2026-34073 (MEDIUM) - Improper Certificate Validation
cryptography>=50.0.0,<51.0.0
# Security: Snyk - XSS vulnerability in Mako templates
mako>=1.3.11,<2.0.0
mako>=1.4.1,<2.0.0
# Security: CVE-2024-52338 (CRITICAL) - Deserialization of untrusted data in IPC/Parquet readers
pyarrow>=24.0.0,<26.0.0
# Security: CVE-2026-27459 - pyopenssl certificate validation
+2 -2
View File
@@ -44,7 +44,7 @@ cachelib==0.13.0
# via
# flask-caching
# flask-session
cachetools==7.1.6
cachetools==7.1.7
# via apache-superset (pyproject.toml)
cattrs==25.1.1
# via requests-cache
@@ -207,7 +207,7 @@ kombu==5.6.2
# via celery
limits==5.1.0
# via flask-limiter
mako==1.3.12
mako==1.4.1
# via
# -r requirements/base.in
# apache-superset (pyproject.toml)
+2 -2
View File
@@ -99,7 +99,7 @@ cachelib==0.13.0
# -c requirements/base-constraint.txt
# flask-caching
# flask-session
cachetools==7.1.6
cachetools==7.1.7
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -510,7 +510,7 @@ limits==5.1.0
# flask-limiter
lz4==4.4.5
# via trino
mako==1.3.12
mako==1.4.1
# via
# -c requirements/base-constraint.txt
# alembic
+506
View File
@@ -19,6 +19,7 @@
"@babel/preset-typescript": "7.29.7",
"@types/node": "^25.4.0",
"babel-loader": "^9.1.3",
"jsdom": "^26.1.0",
"tscw-config": "^1.1.2",
"typescript": "^5.9.3",
"vitest": "^4.0.18",
@@ -26,6 +27,27 @@
"webpack-cli": "^5.1.4"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^2.1.3",
"@csstools/css-color-parser": "^3.0.9",
"@csstools/css-parser-algorithms": "^3.0.4",
"@csstools/css-tokenizer": "^3.0.3",
"lru-cache": "^10.4.3"
}
},
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/@babel/cli": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.29.7.tgz",
@@ -1656,6 +1678,121 @@
"node": ">=6.9.0"
}
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=18"
}
},
"node_modules/@csstools/css-calc": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^5.1.0",
"@csstools/css-calc": "^2.1.4"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@discoveryjs/json-ext": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.6.tgz",
@@ -2501,6 +2638,16 @@
"acorn": "^8.14.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
@@ -2868,6 +3015,34 @@
"node": ">= 8"
}
},
"node_modules/cssstyle": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^3.2.0",
"rrweb-cssom": "^0.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/data-urls": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2886,6 +3061,13 @@
}
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2915,6 +3097,19 @@
"node": ">=10.13.0"
}
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/envinfo": {
"version": "7.13.0",
"resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.13.0.tgz",
@@ -3316,6 +3511,60 @@
"node": ">= 0.4"
}
},
"node_modules/html-encoding-sniffer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-encoding": "^3.1.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/import-local": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz",
@@ -3438,6 +3687,13 @@
"node": ">=0.10.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -3498,6 +3754,46 @@
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
"version": "26.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
"decimal.js": "^10.5.0",
"html-encoding-sniffer": "^4.0.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"is-potential-custom-element-name": "^1.0.1",
"nwsapi": "^2.2.16",
"parse5": "^7.2.1",
"rrweb-cssom": "^0.8.0",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^5.1.1",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^7.0.0",
"whatwg-encoding": "^3.1.1",
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.1.1",
"ws": "^8.18.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -3977,6 +4273,13 @@
"node": ">=0.10.0"
}
},
"node_modules/nwsapi": {
"version": "2.2.24",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz",
"integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==",
"dev": true,
"license": "MIT"
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
@@ -4033,6 +4336,19 @@
"node": ">=6"
}
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -4142,6 +4458,16 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -4314,6 +4640,33 @@
"@rolldown/binding-win32-x64-msvc": "1.1.3"
}
},
"node_modules/rrweb-cssom": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
"dev": true,
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/schema-utils": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
@@ -4447,6 +4800,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
@@ -4592,6 +4952,26 @@
"node": ">=14.0.0"
}
},
"node_modules/tldts": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^6.1.86"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
"dev": true,
"license": "MIT"
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -4605,6 +4985,32 @@
"node": ">=8.0"
}
},
"node_modules/tough-cookie": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^6.1.32"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/tscw-config": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/tscw-config/-/tscw-config-1.1.2.tgz",
@@ -4931,6 +5337,19 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/watchpack": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz",
@@ -4944,6 +5363,16 @@
"node": ">=10.13.0"
}
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/webpack": {
"version": "5.105.0",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz",
@@ -5070,6 +5499,44 @@
"node": ">=10.13.0"
}
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"dev": true,
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-url": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -5114,6 +5581,45 @@
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
"dev": true
},
"node_modules/ws": {
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+1
View File
@@ -43,6 +43,7 @@
"@babel/preset-typescript": "7.29.7",
"@types/node": "^25.4.0",
"babel-loader": "^9.1.3",
"jsdom": "^26.1.0",
"tscw-config": "^1.1.2",
"typescript": "^5.9.3",
"vitest": "^4.0.18",
+213
View File
@@ -0,0 +1,213 @@
/**
* 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.
*/
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { Switchboard } from "@superset-ui/switchboard";
import { embedDashboard } from "./index";
vi.mock("@superset-ui/switchboard");
function makeFakeJWT(claims: any) {
// not a valid jwt, but close enough for this code
const tokenifiedClaims = Buffer.from(JSON.stringify(claims)).toString(
"base64",
);
return `abc.${tokenifiedClaims}.xyz`;
}
describe("embedDashboard", () => {
let mountPoint: HTMLElement;
let mockSwitchboard: Switchboard;
beforeEach(() => {
mountPoint = document.createElement("div");
document.body.appendChild(mountPoint);
mockSwitchboard = {
emit: vi.fn(),
get: vi.fn(),
start: vi.fn(),
defineMethod: vi.fn(),
} as any;
// Constructor mocks must use `function`, since arrow functions cannot be
// invoked with `new`.
vi.mocked(Switchboard).mockImplementation(function () {
return mockSwitchboard;
} as any);
// Mock MessageChannel API
globalThis.MessageChannel = vi.fn(function (this: any) {
this.port1 = {};
this.port2 = {};
}) as any;
// Mock iframe load event and sandbox
const originalCreateElement = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation(tagName => {
const element = originalCreateElement(tagName);
if (tagName === "iframe") {
// Mock sandbox DOMTokenList
(element as any).sandbox = {
add: vi.fn(),
};
// Mock contentWindow for postMessage
Object.defineProperty(element, "contentWindow", {
writable: true,
value: {
postMessage: vi.fn(),
},
});
setTimeout(() => {
element.dispatchEvent(new Event("load"));
}, 0);
}
return element;
});
});
afterEach(() => {
document.body.removeChild(mountPoint);
vi.restoreAllMocks();
});
test("setDataMask sends dataMask to iframe", async () => {
const fakeToken = makeFakeJWT({ exp: Date.now() / 1000 + 300 });
const mockFetchGuestToken = vi.fn().mockResolvedValue(fakeToken);
const testDataMask = {
"NATIVE_FILTER-1": {
filterState: {
value: ["value1", "value2"],
},
},
};
const dashboard = await embedDashboard({
id: "test-id",
supersetDomain: "https://superset.example.com",
mountPoint,
fetchGuestToken: mockFetchGuestToken,
});
dashboard.setDataMask(testDataMask);
expect(mockSwitchboard.get).toHaveBeenCalledWith("setDataMask", {
dataMask: testDataMask,
});
});
test("setDataMask sends empty dataMask", async () => {
const fakeToken = makeFakeJWT({ exp: Date.now() / 1000 + 300 });
const mockFetchGuestToken = vi.fn().mockResolvedValue(fakeToken);
const emptyDataMask = {};
const dashboard = await embedDashboard({
id: "test-id",
supersetDomain: "https://superset.example.com",
mountPoint,
fetchGuestToken: mockFetchGuestToken,
});
dashboard.setDataMask(emptyDataMask);
expect(mockSwitchboard.get).toHaveBeenCalledWith("setDataMask", {
dataMask: emptyDataMask,
});
});
test("setDataMask drops the change-trigger flags observeDataMask adds", async () => {
const fakeToken = makeFakeJWT({ exp: Date.now() / 1000 + 300 });
const mockFetchGuestToken = vi.fn().mockResolvedValue(fakeToken);
const observedMask = {
"NATIVE_FILTER-1": {
filterState: {
value: ["CA"],
},
},
crossFiltersChanged: false,
nativeFiltersChanged: true,
};
const dashboard = await embedDashboard({
id: "test-id",
supersetDomain: "https://superset.example.com",
mountPoint,
fetchGuestToken: mockFetchGuestToken,
});
dashboard.setDataMask(observedMask);
expect(mockSwitchboard.get).toHaveBeenCalledWith("setDataMask", {
dataMask: {
"NATIVE_FILTER-1": observedMask["NATIVE_FILTER-1"],
},
});
});
test("setDataMask sends complex dataMask with multiple filters", async () => {
const fakeToken = makeFakeJWT({ exp: Date.now() / 1000 + 300 });
const mockFetchGuestToken = vi.fn().mockResolvedValue(fakeToken);
const complexDataMask = {
"NATIVE_FILTER-1": {
filterState: {
value: ["CA", "NY"],
},
},
"NATIVE_FILTER-2": {
filterState: {
value: [2023, 2024],
},
},
};
const dashboard = await embedDashboard({
id: "test-id",
supersetDomain: "https://superset.example.com",
mountPoint,
fetchGuestToken: mockFetchGuestToken,
});
dashboard.setDataMask(complexDataMask);
expect(mockSwitchboard.get).toHaveBeenCalledWith("setDataMask", {
dataMask: complexDataMask,
});
});
test("setDataMask rejects when the embedded page does not support it", async () => {
const fakeToken = makeFakeJWT({ exp: Date.now() / 1000 + 300 });
const mockFetchGuestToken = vi.fn().mockResolvedValue(fakeToken);
vi.mocked(mockSwitchboard.get).mockRejectedValue(
new Error('Method "setDataMask" is not defined'),
);
const dashboard = await embedDashboard({
id: "test-id",
supersetDomain: "https://superset.example.com",
mountPoint,
fetchGuestToken: mockFetchGuestToken,
});
await expect(dashboard.setDataMask({})).rejects.toThrow(
'Method "setDataMask" is not defined',
);
});
});
+22
View File
@@ -115,6 +115,12 @@ export type EmbeddedDashboard = {
getActiveTabs: () => Promise<string[]>;
observeDataMask: (callbackFn: ObserveDataMaskCallbackFn) => void;
getDataMask: () => Promise<Record<string, any>>;
/**
* Applies a data mask to the dashboard.
* Rejects if the embedded Superset page does not support `setDataMask`,
* so a version mismatch surfaces instead of silently doing nothing.
*/
setDataMask: (dataMask: Record<string, any>) => Promise<void>;
getChartStates: () => Promise<Record<string, any>>;
getChartDataPayloads: (params?: {
chartId?: number;
@@ -355,6 +361,21 @@ export async function embedDashboard({
ourPort.get<string>("getDashboardPermalink", { anchor });
const getActiveTabs = () => ourPort.get<string[]>("getActiveTabs");
const getDataMask = () => ourPort.get<Record<string, any>>("getDataMask");
// `observeDataMask` hands the host a mask with the change-trigger booleans
// mixed in, so feeding that payload straight back into `setDataMask` is a
// natural thing for a host to do. Keep only the entries that look like a
// filter's mask, so those flags never reach the dashboard as filter ids.
// Sent with `get` rather than `emit` so the iframe acknowledges the call:
// an embedded page that predates `setDataMask` replies with an error instead
// of dropping the message silently.
const setDataMask = (dataMask: Record<string, any>) =>
ourPort.get<void>("setDataMask", {
dataMask: Object.fromEntries(
Object.entries(dataMask).filter(
([, mask]) => typeof mask === "object" && mask !== null,
),
),
});
const getChartStates = () =>
ourPort.get<Record<string, any>>("getChartStates");
const getChartDataPayloads = (params?: { chartId?: number }) =>
@@ -396,6 +417,7 @@ export async function embedDashboard({
getActiveTabs,
observeDataMask,
getDataMask,
setDataMask,
getChartStates,
getChartDataPayloads,
setThemeConfig,
+4 -4
View File
@@ -33,7 +33,7 @@
"@fontsource/fira-code": "^5.3.0",
"@fontsource/ibm-plex-mono": "^5.3.0",
"@fontsource/inter": "^5.3.0",
"@googleapis/sheets": "^13.0.2",
"@googleapis/sheets": "^14.0.0",
"@great-expectations/jsonforms-antd-renderers": "^2.2.10",
"@jsonforms/core": "^3.7.0",
"@jsonforms/react": "^3.7.0",
@@ -4142,9 +4142,9 @@
}
},
"node_modules/@googleapis/sheets": {
"version": "13.0.2",
"resolved": "https://registry.npmjs.org/@googleapis/sheets/-/sheets-13.0.2.tgz",
"integrity": "sha512-b1tBlMcfvNEziM4DZCikLOc9iqSlgCK1e5bMKtNQIADRXr1CQmbkHV3ZBVvTsFsjLErgihqO58Itn/kzCnSZ0A==",
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/@googleapis/sheets/-/sheets-14.0.0.tgz",
"integrity": "sha512-fANEl4RQohsPYUWhcLSYyUyE8A8bRfvw/bp8h0t8VDQqTgdQ3itZBkty4nddtdqCAvNDpA+KM66OejVKDd6aFg==",
"license": "Apache-2.0",
"dependencies": {
"googleapis-common": "^8.0.0"
+1 -1
View File
@@ -110,7 +110,7 @@
"@fontsource/fira-code": "^5.3.0",
"@fontsource/ibm-plex-mono": "^5.3.0",
"@fontsource/inter": "^5.3.0",
"@googleapis/sheets": "^13.0.2",
"@googleapis/sheets": "^14.0.0",
"@great-expectations/jsonforms-antd-renderers": "^2.2.10",
"@jsonforms/core": "^3.7.0",
"@jsonforms/react": "^3.7.0",
+4
View File
@@ -47,6 +47,10 @@ export default defineConfig({
// Retry logic - 2 retries in CI, 0 locally
retries: process.env.CI ? 2 : 0,
// Disable capturing Git commit info as the project's history is increasingly dense
// and breach Playwright's default 3-seconds `git` command timeout limit
captureGitInfo: { commit: false, diff: false },
// Reporter configuration - multiple reporters for better visibility
reporter: process.env.CI
? [
@@ -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 { SqlaFormData } from '@superset-ui/core';
import transformProps from './transformProps';
import { EchartsBubbleChartProps } from './types';
const baseFormData: SqlaFormData = {
datasource: '1__table',
viz_type: 'bubble_v2',
entity: 'customer_name',
x: 'price',
y: 'sales',
size: 'count',
};
const baseChartProps = {
width: 400,
height: 400,
hooks: {},
queriesData: [
{
data: [
{ customer_name: 'A', price: 10, sales: 100, count: 5 },
{ customer_name: 'B', price: 20, sales: 200, count: 8 },
],
},
],
theme: { colorText: '#000' },
};
test('nests xAxisLabelInterval under axisLabel rather than the axis itself', () => {
// Regression test: xAxis.interval forces echarts' IntervalScale into a
// fixed-tick-spacing mode that expects a number and crashes on the
// 'auto'/'0' strings this control actually produces (observed as an
// uncaught assertion deep in echarts' axis "nice" tick calculation,
// reproducing on every dashboard bubble chart). The interval belongs on
// axisLabel, where it only controls how many labels are skipped.
const { echartOptions } = transformProps({
...baseChartProps,
formData: baseFormData,
} as unknown as EchartsBubbleChartProps);
expect((echartOptions.xAxis as any).interval).toBeUndefined();
expect((echartOptions.xAxis as any).axisLabel.interval).toBe('auto');
});
test('honors an explicit xAxisLabelInterval override', () => {
const { echartOptions } = transformProps({
...baseChartProps,
formData: { ...baseFormData, xAxisLabelInterval: '0' },
} as unknown as EchartsBubbleChartProps);
expect((echartOptions.xAxis as any).axisLabel.interval).toBe('0');
});
@@ -212,13 +212,16 @@ export default function transformProps(chartProps: EchartsBubbleChartProps) {
const echartOptions: EChartsCoreOption = {
series,
xAxis: {
axisLabel: { formatter: xAxisFormatter, rotate: xAxisLabelRotation },
axisLabel: {
formatter: xAxisFormatter,
rotate: xAxisLabelRotation,
interval: xAxisLabelInterval,
},
splitLine: {
lineStyle: {
type: 'dashed',
},
},
interval: xAxisLabelInterval,
scale: true,
name: bubbleXAxisTitle,
nameLocation: 'middle',
@@ -35,6 +35,43 @@ if (SERVICE_ACCOUNT_KEY.client_email) {
const DATETIME = new Date().toISOString().replace(/T/, ' ').replace(/\..+/, '');
/**
* Turn an oxlint diagnostic code into the canonical rule id used by the metrics
* series.
*
* oxlint reports `<plugin>(<rule>)`, where the plugin is the linter the rule came
* from: `eslint(no-console)`, `react-hooks(exhaustive-deps)`, `react(jsx-key)`,
* `jest(no-conditional-expect)`, `oxc(erasing-op)`, and the legacy
* `eslint-plugin-unicorn(no-new-array)` spelling.
*
* `eslint` is the implicit namespace, so its rules keep their bare name and stay
* comparable with the rows recorded before the oxlint migration. Every other
* plugin becomes `<plugin>/<rule>`, which is the id those rules are known by in
* config and in the pre-migration history.
*
* @param {string | undefined} code the diagnostic's `code` field
* @returns {string} the rule id to record
*/
function parseRuleId(code) {
if (!code) {
return 'unknown';
}
const match = code.match(/^([\w-]+)\(([^)]+)\)$/);
if (!match) {
return code;
}
const [, namespace, rule] = match;
if (namespace === 'eslint') {
return rule;
}
// `eslint-plugin-unicorn(...)` is the same rule as `unicorn/...`
const plugin = namespace.replace(/^eslint-plugin-/, '');
return `${plugin}/${rule}`;
}
async function writeToGoogleSheet(data, range, headers, append = false) {
if (!sheets) {
console.log('No Google Sheets credentials, skipping upload');
@@ -101,17 +138,7 @@ async function runOxlintAndProcess() {
// OXC JSON format has diagnostics array
if (results.diagnostics && Array.isArray(results.diagnostics)) {
results.diagnostics.forEach(diagnostic => {
// Extract rule ID from code like "eslint(no-unused-vars)" or "eslint-plugin-unicorn(no-new-array)"
const codeMatch = diagnostic.code?.match(
/^(?:eslint(?:-plugin-(\w+))?\()([^)]+)\)$/,
);
let ruleId = diagnostic.code || 'unknown';
if (codeMatch) {
const plugin = codeMatch[1];
const rule = codeMatch[2];
ruleId = plugin ? `${plugin}/${rule}` : rule;
}
const ruleId = parseRuleId(diagnostic.code);
const file = diagnostic.filename || 'unknown';
const line = diagnostic.labels?.[0]?.span?.line || 0;
@@ -251,5 +278,10 @@ async function runOxlintAndProcess() {
}
}
// Run the process
runOxlintAndProcess().catch(console.error);
// Run the process, unless this file was imported (e.g. by a test) rather than
// executed, in which case nothing should be linted or uploaded on import.
if (require.main === module) {
runOxlintAndProcess().catch(console.error);
}
module.exports = { parseRuleId };
@@ -0,0 +1,58 @@
/**
* 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.
*/
const { parseRuleId } = require('../../scripts/oxlint-metrics-uploader');
test('eslint rules keep their bare name', () => {
expect(parseRuleId('eslint(no-console)')).toBe('no-console');
expect(parseRuleId('eslint(prefer-destructuring)')).toBe(
'prefer-destructuring',
);
});
test('plugin rules are recorded under plugin/rule (#42981)', () => {
// These are the codes oxlint actually emits. They previously fell through to
// the raw `react-hooks(exhaustive-deps)` string, so the rows no longer lined
// up with the ids the same rules were recorded under before the migration.
expect(parseRuleId('react-hooks(exhaustive-deps)')).toBe(
'react-hooks/exhaustive-deps',
);
expect(parseRuleId('react-hooks(rules-of-hooks)')).toBe(
'react-hooks/rules-of-hooks',
);
expect(parseRuleId('react(jsx-key)')).toBe('react/jsx-key');
expect(parseRuleId('jest(no-conditional-expect)')).toBe(
'jest/no-conditional-expect',
);
expect(parseRuleId('oxc(erasing-op)')).toBe('oxc/erasing-op');
expect(parseRuleId('typescript(no-explicit-any)')).toBe(
'typescript/no-explicit-any',
);
});
test('the legacy eslint-plugin- prefix still collapses to the plugin name', () => {
expect(parseRuleId('eslint-plugin-unicorn(no-new-array)')).toBe(
'unicorn/no-new-array',
);
});
test('an unrecognized or missing code is passed through rather than dropped', () => {
expect(parseRuleId('something-unparseable')).toBe('something-unparseable');
expect(parseRuleId(undefined)).toBe('unknown');
expect(parseRuleId('')).toBe('unknown');
});
+155
View File
@@ -0,0 +1,155 @@
/**
* 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 { DataMaskStateWithId } from '@superset-ui/core';
// Mock factories must build their own jest.fn()s: jest.mock calls are hoisted
// above this file's declarations, so a factory closing over a const would read
// it before initialization.
jest.mock('@apache-superset/core/utils', () => ({
logging: { debug: jest.fn(), warn: jest.fn(), error: jest.fn() },
}));
jest.mock('../views/store', () => ({
store: { dispatch: jest.fn(), getState: jest.fn(), subscribe: jest.fn() },
}));
// eslint-disable-next-line import/first
import { embeddedApi } from './api';
// eslint-disable-next-line import/first
import { updateDataMask } from '../dataMask/actions';
const { logging: mockLogging } = jest.requireMock(
'@apache-superset/core/utils',
);
const { store: mockStore } = jest.requireMock('../views/store');
const mockDispatch = mockStore.dispatch;
const mockGetState = mockStore.getState;
const nativeFilterMask = { filterState: { value: ['CA'] } };
const crossFilterMask = { filterState: { value: [2024] } };
// `dashboardInfo.id` is only set once HYDRATE_DASHBOARD lands, so it doubles as
// the "dashboard is hydrated" signal setDataMask waits for.
function stateWithFilters(filterIds: string[]) {
return {
dashboardInfo: { id: 1 },
dataMask: Object.fromEntries(filterIds.map(id => [id, { id }])),
};
}
beforeEach(() => {
jest.clearAllMocks();
});
test('setDataMask dispatches an update for each known filter', () => {
mockGetState.mockReturnValue(
stateWithFilters(['NATIVE_FILTER-1', 'NATIVE_FILTER-2']),
);
embeddedApi.setDataMask({
dataMask: {
'NATIVE_FILTER-1': nativeFilterMask,
'NATIVE_FILTER-2': crossFilterMask,
} as unknown as DataMaskStateWithId,
});
expect(mockDispatch).toHaveBeenCalledTimes(2);
expect(mockDispatch).toHaveBeenCalledWith(
updateDataMask('NATIVE_FILTER-1', nativeFilterMask),
);
expect(mockDispatch).toHaveBeenCalledWith(
updateDataMask('NATIVE_FILTER-2', crossFilterMask),
);
expect(mockLogging.warn).not.toHaveBeenCalled();
});
test('setDataMask ignores filter ids the dashboard does not know', () => {
mockGetState.mockReturnValue(stateWithFilters(['NATIVE_FILTER-1']));
embeddedApi.setDataMask({
dataMask: {
'NATIVE_FILTER-1': nativeFilterMask,
'NATIVE_FILTER-from-another-dashboard': crossFilterMask,
} as unknown as DataMaskStateWithId,
});
expect(mockDispatch).toHaveBeenCalledTimes(1);
expect(mockDispatch).toHaveBeenCalledWith(
updateDataMask('NATIVE_FILTER-1', nativeFilterMask),
);
expect(mockLogging.warn).toHaveBeenCalledWith(
expect.stringContaining('unknown filter ids'),
'NATIVE_FILTER-from-another-dashboard',
);
});
test('setDataMask ignores the change-trigger flags observeDataMask emits', () => {
mockGetState.mockReturnValue(stateWithFilters(['NATIVE_FILTER-1']));
embeddedApi.setDataMask({
dataMask: {
'NATIVE_FILTER-1': nativeFilterMask,
crossFiltersChanged: false,
nativeFiltersChanged: true,
} as unknown as DataMaskStateWithId,
});
expect(mockDispatch).toHaveBeenCalledTimes(1);
expect(mockDispatch).toHaveBeenCalledWith(
updateDataMask('NATIVE_FILTER-1', nativeFilterMask),
);
});
test('setDataMask dispatches nothing when no filter id is known', () => {
mockGetState.mockReturnValue(stateWithFilters([]));
embeddedApi.setDataMask({
dataMask: {
'NATIVE_FILTER-1': nativeFilterMask,
} as unknown as DataMaskStateWithId,
});
expect(mockDispatch).not.toHaveBeenCalled();
expect(mockLogging.warn).toHaveBeenCalled();
});
test('setDataMask queues the mask until the dashboard hydrates', () => {
let notifyStoreSubscribers = () => {};
mockStore.subscribe.mockImplementation((listener: () => void) => {
notifyStoreSubscribers = listener;
return jest.fn();
});
mockGetState.mockReturnValue({ dataMask: {} });
embeddedApi.setDataMask({
dataMask: {
'NATIVE_FILTER-1': nativeFilterMask,
} as unknown as DataMaskStateWithId,
});
expect(mockDispatch).not.toHaveBeenCalled();
expect(mockLogging.warn).not.toHaveBeenCalled();
mockGetState.mockReturnValue(stateWithFilters(['NATIVE_FILTER-1']));
notifyStoreSubscribers();
expect(mockDispatch).toHaveBeenCalledWith(
updateDataMask('NATIVE_FILTER-1', nativeFilterMask),
);
});
+57
View File
@@ -17,12 +17,15 @@
* under the License.
*/
import { DataMaskStateWithId, JsonObject } from '@superset-ui/core';
import { logging } from '@apache-superset/core/utils';
import getBootstrapData from 'src/utils/getBootstrapData';
import { batch } from 'react-redux';
import { store } from '../views/store';
import { getDashboardPermalink as getDashboardPermalinkUtil } from '../utils/urlUtils';
import { DashboardChartStates } from '../dashboard/types/chartState';
import { hasStatefulCharts } from '../dashboard/util/chartStateConverter';
import { getChartDataPayloads as getChartDataPayloadsUtil } from './utils';
import { updateDataMask } from '../dataMask/actions';
const bootstrapData = getBootstrapData();
@@ -40,6 +43,7 @@ type EmbeddedSupersetApi = {
getChartDataPayloads: (params?: {
chartId?: number;
}) => Promise<Record<string, JsonObject>>;
setDataMask: ({ dataMask }: { dataMask: DataMaskStateWithId }) => void;
};
const getScrollSize = (): Size => ({
@@ -83,6 +87,58 @@ const getActiveTabs = () => store?.getState()?.dashboardState?.activeTabs || [];
const getDataMask = () => store?.getState()?.dataMask || {};
const isDashboardHydrated = () => Boolean(store?.getState()?.dashboardInfo?.id);
const applyDataMask = (dataMask: DataMaskStateWithId) => {
// The dashboard's own data mask holds an entry for every native filter and
// every cross-filter-capable chart, so it doubles as the set of filter ids
// this dashboard can accept. Anything else — a filter id from a different
// dashboard, or the change-trigger flags that `observeDataMask` emits
// alongside the mask — would otherwise be inserted as a bogus filter and
// treated as a globally scoped filter by the active-filter derivation.
const knownFilterIds = new Set(Object.keys(getDataMask()));
const entries = Object.entries(dataMask);
const applicable = entries.filter(([id]) => knownFilterIds.has(id));
const ignored = entries.filter(([id]) => !knownFilterIds.has(id));
if (ignored.length) {
logging.warn(
'[superset] setDataMask ignored unknown filter ids:',
ignored.map(([id]) => id).join(', '),
);
}
batch(() => {
applicable.forEach(([filterId, mask]) => {
store?.dispatch(updateDataMask(filterId, mask));
});
});
};
// A mask requested before the dashboard hydrates cannot be applied yet: the
// store holds no filter entries to validate the ids against, and hydration
// would replace anything dispatched in the meantime. Hold the request and
// replay it once hydration lands.
let queuedDataMask: DataMaskStateWithId | undefined;
let unsubscribeFromHydration: (() => void) | undefined;
const setDataMask = ({ dataMask }: { dataMask: DataMaskStateWithId }) => {
if (isDashboardHydrated()) {
applyDataMask(dataMask);
return;
}
queuedDataMask = { ...queuedDataMask, ...dataMask };
unsubscribeFromHydration ??= store?.subscribe(() => {
if (!isDashboardHydrated()) return;
unsubscribeFromHydration?.();
unsubscribeFromHydration = undefined;
const pending = queuedDataMask;
queuedDataMask = undefined;
if (pending) applyDataMask(pending);
});
};
const getChartStates = () =>
store?.getState()?.dashboardState?.chartStates || {};
@@ -102,4 +158,5 @@ export const embeddedApi: EmbeddedSupersetApi = {
getDataMask,
getChartStates,
getChartDataPayloads,
setDataMask,
};
+1
View File
@@ -298,6 +298,7 @@ window.addEventListener('message', function embeddedPageInitializer(event) {
Switchboard.defineMethod('getActiveTabs', embeddedApi.getActiveTabs);
Switchboard.defineMethod('getDataMask', embeddedApi.getDataMask);
Switchboard.defineMethod('getChartStates', embeddedApi.getChartStates);
Switchboard.defineMethod('setDataMask', embeddedApi.setDataMask);
Switchboard.defineMethod(
'getChartDataPayloads',
embeddedApi.getChartDataPayloads,
+6
View File
@@ -21,6 +21,7 @@ from functools import partial
from typing import cast
from uuid import UUID
from superset import db
from superset.commands.base import BaseCommand
from superset.commands.database.exceptions import DatabaseNotFoundError
from superset.daos.database import DatabaseUserOAuth2TokensDAO
@@ -96,6 +97,11 @@ class OAuth2StoreTokenCommand(BaseCommand):
database_id=self._state["database_id"],
):
DatabaseUserOAuth2TokensDAO.delete([existing])
# flush the delete before inserting the replacement -- the unit
# of work otherwise emits INSERTs before DELETEs within a single
# flush, which would trip the (user_id, database_id) unique
# index below on the old row.
db.session.flush()
# store tokens
expiration = datetime.now() + timedelta(seconds=token_response["expires_in"])
+32 -18
View File
@@ -318,27 +318,39 @@ def import_tag(
for tag_name in target_tag_names:
try:
tag = existing_tags.get(tag_name)
# Isolate each tag operation in a SAVEPOINT so a failure (e.g. a
# concurrent unique-constraint violation) rolls back only the failed
# tag and leaves the session usable for the remaining tags, instead
# of poisoning the session with a pending-rollback state.
with db_session.begin_nested():
tag = existing_tags.get(tag_name)
# If tag does not exist, create it
if tag is None:
description = tag_descriptions.get(tag_name, None)
tag = Tag(name=tag_name, description=description, type="custom")
db_session.add(tag)
existing_tags[tag_name] = tag # Update the existing_tags dictionary
# If tag does not exist, create it
if tag is None:
description = tag_descriptions.get(tag_name, None)
tag = Tag(name=tag_name, description=description, type="custom")
db_session.add(tag)
existing_tags[tag_name] = tag # Update the existing_tags dictionary
# Ensure the association with the object
tagged_object = (
db_session.query(TaggedObject)
.filter_by(object_id=object_id, object_type=object_type, tag_id=tag.id)
.first()
)
if not tagged_object:
new_tagged_object = TaggedObject(
tag_id=tag.id, object_id=object_id, object_type=object_type
# Ensure the association with the object
tagged_object = (
db_session.query(TaggedObject)
.filter_by(
object_id=object_id, object_type=object_type, tag_id=tag.id
)
.first()
)
db_session.add(new_tagged_object)
if not tagged_object:
new_tagged_object = TaggedObject(
tag_id=tag.id, object_id=object_id, object_type=object_type
)
db_session.add(new_tagged_object)
# Only record the tag as imported once the SAVEPOINT has been
# released (and its pending inserts flushed) without error; the
# nested block's own flush can still fail on a concurrent
# unique-constraint violation, in which case this line must not
# run.
new_tag_ids.append(tag.id)
except SQLAlchemyError as err:
@@ -349,7 +361,9 @@ def import_tag(
object_id,
err,
)
continue # No need for manual rollback, handled by transaction decorator
# The SAVEPOINT was rolled back by begin_nested(); the session is
# still usable for the remaining tags.
continue
# Remove old tags not in the new config
for tag in existing_assocs:
@@ -59,7 +59,7 @@ params:
expressionType: SIMPLE
label: SUM(SP_POP_TOTL)
optionName: metric_size_life_expectancy_vs_rural
time_range: '2014-01-01 : 2014-01-02'
time_range: '2011-01-01 : 2011-01-02'
tooltipSizeFormat: SMART_NUMBER
truncateXAxis: true
viz_type: bubble_v2
@@ -0,0 +1,97 @@
# 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.
"""Enforce one OAuth2 token per (user_id, database_id).
`OAuth2StoreTokenCommand` always deletes any existing token for a
user+database pair before storing a new one, so the table is only ever
meant to carry a single live row per pair -- but that invariant was only
enforced in application code, via a plain (non-unique) lookup index. A
race between two concurrent OAuth2 callbacks for the same user+database
can leave duplicate rows behind, and nothing downstream picks a
deterministic one of them. Flagged as a follow-up during review of #42211
(which fixed an unrelated `purge_oauth2_tokens` filter bug on this same
table).
Pre-flight: deletes any pre-existing duplicate rows, keeping the
highest-id row per (user_id, database_id) pair, since
`OAuth2StoreTokenCommand` always deletes-then-inserts and a higher id is
therefore the more recently issued token.
Revision ID: da0e3f0081bf
Revises: b8d2f4a6c901
Create Date: 2026-08-07 09:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
from superset.migrations.shared.utils import create_index, drop_index
# revision identifiers, used by Alembic.
revision: str = "da0e3f0081bf"
down_revision: str = "b8d2f4a6c901"
TABLE_NAME = "database_user_oauth2_tokens"
INDEX_NAME = "idx_user_id_database_id"
# Temporary name for the unique index while it and the old non-unique index
# briefly coexist -- see the comment in ``upgrade`` below.
TMP_INDEX_NAME = "idx_user_id_database_id_tmp_unique"
def upgrade() -> None:
bind = op.get_bind()
metadata = sa.MetaData()
table = sa.Table(TABLE_NAME, metadata, autoload_with=bind)
# Find the highest id per (user_id, database_id) pair by reading into
# Python first, rather than deleting via a subquery on the same table:
# MySQL rejects a DELETE whose WHERE clause subqueries the target table
# (error 1093).
rows = bind.execute(
sa.select(table.c.id, table.c.user_id, table.c.database_id)
).fetchall()
max_id_by_pair: dict[tuple[int, int], int] = {}
for row in rows:
pair = (row.user_id, row.database_id)
if row.id > max_id_by_pair.get(pair, -1):
max_id_by_pair[pair] = row.id
keep_ids = set(max_id_by_pair.values())
dupe_ids = [row.id for row in rows if row.id not in keep_ids]
if dupe_ids:
bind.execute(table.delete().where(table.c.id.in_(dupe_ids)))
# MySQL's InnoDB won't drop an index that's still needed to satisfy a
# foreign key (error 1553) -- the existing (user_id, database_id) index
# is the only one covering the `user_id` FK. Create the new unique
# index (which covers the same leading column) before dropping the old
# one, so an FK-satisfying index always exists, then swap it into its
# final name.
create_index(TABLE_NAME, TMP_INDEX_NAME, ["user_id", "database_id"], unique=True)
drop_index(TABLE_NAME, INDEX_NAME)
create_index(TABLE_NAME, INDEX_NAME, ["user_id", "database_id"], unique=True)
drop_index(TABLE_NAME, TMP_INDEX_NAME)
def downgrade() -> None:
# The pre-flight dedupe above is not reversible -- any rows it removed
# stay removed -- but that only ever discards rows that violated the
# single-token-per-pair invariant the application already assumed.
create_index(TABLE_NAME, TMP_INDEX_NAME, ["user_id", "database_id"])
drop_index(TABLE_NAME, INDEX_NAME)
create_index(TABLE_NAME, INDEX_NAME, ["user_id", "database_id"])
drop_index(TABLE_NAME, TMP_INDEX_NAME)
@@ -0,0 +1,35 @@
# 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.
"""merge oauth2 token uniqueness with report_schedule include_cta
Revision ID: 1072de5ed955
Revises: ('da0e3f0081bf', '2d6ad72e4af6')
Create Date: 2026-08-15 01:39:00.000000
"""
# revision identifiers, used by Alembic.
revision = "1072de5ed955"
down_revision = ("da0e3f0081bf", "2d6ad72e4af6")
def upgrade():
pass
def downgrade():
pass
+3 -1
View File
@@ -1607,7 +1607,9 @@ class DatabaseUserOAuth2Tokens(Model, AuditMixinNullable):
"""
__tablename__ = "database_user_oauth2_tokens"
__table_args__ = (sqla.Index("idx_user_id_database_id", "user_id", "database_id"),)
__table_args__ = (
sqla.Index("idx_user_id_database_id", "user_id", "database_id", unique=True),
)
id = Column(Integer, primary_key=True)
@@ -19,12 +19,14 @@
import copy
from collections.abc import Generator
from datetime import datetime, timezone
from typing import Any
from unittest.mock import patch
import pytest
import yaml
from flask_appbuilder.security.sqla.models import Role, User
from pytest_mock import MockerFixture
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm.session import Session
from superset import security_manager
@@ -506,3 +508,71 @@ def test_import_tag_logic_for_charts(session_with_schema: Session):
.all()
)
assert len(associated_tags) == 0
def test_import_tag_savepoint_keeps_session_usable(
mocker: MockerFixture, session_with_schema: Session
) -> None:
"""
When a single tag operation fails with a SQLAlchemyError (e.g. a unique
constraint violation from a concurrent import), the per-tag SAVEPOINT
isolates the failure so the session is not left in a pending-rollback
state, and the remaining tags still import successfully.
"""
contents = {
"tags.yaml": yaml.dump(
{
"tags": [
{"tag_name": "tag_1", "description": "Description for tag_1"},
{"tag_name": "tag_2", "description": "Description for tag_2"},
]
}
)
}
object_id = 1
object_type = "chart"
# Simulate a unique-constraint violation discovered when the first
# TaggedObject's SAVEPOINT is flushed (e.g. a concurrent import already
# created the same association) -- not synchronously from Session.add().
# `import_tag`'s own pre-insert existence check would normally catch a
# real duplicate row, so the failure is injected at the point SQLAlchemy
# actually persists the pending row: `begin_nested()`'s implicit flush
# on a successful `with` exit, which calls `Session.flush()` directly
# (see `SessionTransaction._prepare_impl`).
pending_tagged_objects: list[TaggedObject] = []
original_add = session_with_schema.add
def tracking_add(obj: object) -> None:
if isinstance(obj, TaggedObject):
pending_tagged_objects.append(obj)
original_add(obj)
original_flush = session_with_schema.flush
def flaky_flush(*args: Any, **kwargs: Any) -> None:
# Only the first TaggedObject ever added should fail, and only while
# it's still pending -- once its SAVEPOINT rolls back, SQLAlchemy
# expunges it from the session, so this does not also fail tag_2's
# flush.
if (
pending_tagged_objects
and pending_tagged_objects[0] is not None
and pending_tagged_objects[0] in session_with_schema.new
):
raise SQLAlchemyError("UNIQUE constraint failed: tagged_object")
original_flush(*args, **kwargs)
mocker.patch.object(session_with_schema, "add", side_effect=tracking_add)
mocker.patch.object(session_with_schema, "flush", side_effect=flaky_flush)
with patch.object(feature_flag_manager, "is_feature_enabled", return_value=True):
new_tag_ids = import_tag(
["tag_1", "tag_2"], contents, object_id, object_type, session_with_schema
)
# tag_1 failed on the unique violation, but tag_2 succeeded.
assert len(new_tag_ids) == 1
# The session is still usable — no PendingRollbackError.
assert session_with_schema.query(TaggedObject).count() == 1
@@ -0,0 +1,173 @@
# 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.
"""Tests for migration ``da0e3f0081bf_enforce_oauth2_token_uniqueness``.
Runs the migration's ``upgrade()`` and ``downgrade()`` against an
in-memory SQLite engine seeded with pre-existing duplicate
(user_id, database_id) rows -- the state a deployment could be in today,
since the prior plain index never stopped them from accumulating.
"""
from __future__ import annotations
from importlib import import_module
import pytest
from alembic.migration import MigrationContext
from alembic.operations import Operations
from sqlalchemy import (
Column,
create_engine,
inspect,
Integer,
MetaData,
select,
Table,
)
from sqlalchemy.engine import Engine
from sqlalchemy.exc import IntegrityError
migration = import_module(
"superset.migrations.versions."
"2026-08-07_09-00_da0e3f0081bf_enforce_oauth2_token_uniqueness"
)
TABLE_NAME: str = migration.TABLE_NAME # "database_user_oauth2_tokens"
INDEX_NAME: str = migration.INDEX_NAME # "idx_user_id_database_id"
@pytest.fixture
def engine() -> Engine:
"""In-memory SQLite seeded with a minimal pre-migration token table,
including duplicate (user_id, database_id) rows -- id 2 and id 3 both
belong to user 1 + database 10, which the plain index never prevented.
"""
engine = create_engine("sqlite:///:memory:", future=True)
md = MetaData()
table = Table(
TABLE_NAME,
md,
Column("id", Integer, primary_key=True),
Column("user_id", Integer, nullable=False),
Column("database_id", Integer, nullable=False),
)
md.create_all(engine)
with engine.begin() as conn:
conn.execute(
table.insert(),
[
{"id": 1, "user_id": 1, "database_id": 20},
{"id": 2, "user_id": 1, "database_id": 10},
{"id": 3, "user_id": 1, "database_id": 10},
{"id": 4, "user_id": 2, "database_id": 10},
],
)
return engine
def _indexes(engine: Engine) -> dict[str, bool]:
"""Map of index name -> ``unique`` flag on the target table."""
return {ix["name"]: ix["unique"] for ix in inspect(engine).get_indexes(TABLE_NAME)}
def _rows(engine: Engine) -> list[tuple[int, int, int]]:
with engine.begin() as conn:
table = Table(TABLE_NAME, MetaData(), autoload_with=conn)
return sorted(
(r.id, r.user_id, r.database_id)
for r in conn.execute(select(table)).fetchall()
)
def test_upgrade_deletes_duplicates_keeping_highest_id(engine: Engine) -> None:
"""Only the highest-id row survives per (user_id, database_id) pair;
unrelated pairs are untouched.
"""
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
with Operations.context(ctx):
migration.upgrade()
assert _rows(engine) == [
(1, 1, 20),
(3, 1, 10),
(4, 2, 10),
], "upgrade() must keep only the highest-id row per duplicate pair"
def test_upgrade_makes_the_index_unique(engine: Engine) -> None:
"""upgrade() replaces the plain lookup index with a unique one."""
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
with Operations.context(ctx):
migration.upgrade()
assert _indexes(engine) == {INDEX_NAME: True}
def test_upgrade_index_rejects_new_duplicates(engine: Engine) -> None:
"""After upgrade(), inserting a second row for an existing
(user_id, database_id) pair is rejected at the DB level.
"""
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
with Operations.context(ctx):
migration.upgrade()
table = Table(TABLE_NAME, MetaData(), autoload_with=engine)
with pytest.raises(IntegrityError):
with engine.begin() as conn:
conn.execute(table.insert().values(id=99, user_id=1, database_id=20))
def test_downgrade_restores_a_plain_non_unique_index(engine: Engine) -> None:
"""downgrade() drops the unique index and recreates a plain one --
the dedupe deletions from upgrade() are not reversed, since those rows
already violated the single-token-per-pair invariant the application
assumed.
"""
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
with Operations.context(ctx):
migration.upgrade()
migration.downgrade()
assert _indexes(engine) == {INDEX_NAME: False}
assert _rows(engine) == [
(1, 1, 20),
(3, 1, 10),
(4, 2, 10),
]
# Now that the index is plain again, duplicates are allowed once more.
table = Table(TABLE_NAME, MetaData(), autoload_with=engine)
with engine.begin() as conn:
conn.execute(table.insert().values(id=99, user_id=1, database_id=20))
assert (99, 1, 20) in _rows(engine)
def test_upgrade_is_idempotent(engine: Engine) -> None:
"""``create_index``/``drop_index`` are skip-if-exists; running
``upgrade()`` twice must not raise.
"""
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
with Operations.context(ctx):
migration.upgrade()
migration.upgrade()
+66 -3
View File
@@ -1513,12 +1513,22 @@ def test_purge_oauth2_tokens_scoped_by_database_id(session: Session) -> None:
session.add(database1)
session.flush()
# Insert several tokens on database1 so token PKs advance past 1 and drift
# away from any single database PK.
# Insert several tokens on database1, one per distinct user, so token PKs
# advance past 1 and drift away from any single database PK. Each token
# is for a different user to respect the unique (user_id, database_id)
# constraint -- PK drift is what this test needs, not repeat users.
for i in range(5):
drift_user = User(
first_name="Drift",
last_name=str(i),
email=f"drift{i}@example.org",
username=f"drift{i}",
)
session.add(drift_user)
session.flush()
session.add(
DatabaseUserOAuth2Tokens(
user_id=user.id,
user_id=drift_user.id,
database_id=database1.id,
access_token=f"db1_access_{i}", # noqa: S106
access_token_expiration=datetime(2023, 1, 1),
@@ -1573,6 +1583,59 @@ def test_purge_oauth2_tokens_scoped_by_database_id(session: Session) -> None:
)
def test_oauth2_tokens_unique_per_user_and_database(session: Session) -> None:
"""
``database_user_oauth2_tokens`` allows at most one row per
(user_id, database_id) pair. `OAuth2StoreTokenCommand` always
deletes any existing token before storing a new one, so a second row for
the same pair can only appear via a lost race between two concurrent
callbacks -- the DB-level constraint is what makes that impossible rather
than merely unlikely.
"""
from flask_appbuilder.security.sqla.models import Role, User # noqa: F401
from sqlalchemy.exc import IntegrityError
from superset.models.core import Database, DatabaseUserOAuth2Tokens
Database.metadata.create_all(session.get_bind()) # pylint: disable=no-member
user = User(
first_name="Alice",
last_name="Doe",
email="adoe3@example.org",
username="adoe3",
)
session.add(user)
session.flush()
database = Database(database_name="my_unique_oauth2_db", sqlalchemy_uri="sqlite://")
session.add(database)
session.flush()
session.add(
DatabaseUserOAuth2Tokens(
user_id=user.id,
database_id=database.id,
access_token="first_access_token", # noqa: S106
access_token_expiration=datetime(2023, 1, 1),
refresh_token="first_refresh_token", # noqa: S106
)
)
session.flush()
session.add(
DatabaseUserOAuth2Tokens(
user_id=user.id,
database_id=database.id,
access_token="second_access_token", # noqa: S106
access_token_expiration=datetime(2023, 1, 1),
refresh_token="second_refresh_token", # noqa: S106
)
)
with pytest.raises(IntegrityError):
session.flush()
def test_compile_sqla_query_no_optimization(query: Select) -> None:
"""
Test the `compile_sqla_query` method.