mirror of
https://github.com/apache/superset.git
synced 2026-08-22 16:11:14 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ebd6353c8 | ||
|
|
8d53c29092 | ||
|
|
3c209290af | ||
|
|
cb2e4bbb7d | ||
|
|
3f645b444b | ||
|
|
fae62663cb | ||
|
|
6430db9517 | ||
|
|
176257e42a |
@@ -48,7 +48,7 @@ jobs:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
enable-cache: true
|
||||
|
||||
+2
-2
@@ -58,7 +58,7 @@
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@saucelabs/theme-github-codeblock": "^0.3.0",
|
||||
"@storybook/addon-docs": "^10.5.8",
|
||||
"@storybook/addon-docs": "^10.5.7",
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.47",
|
||||
"antd": "^6.6.0",
|
||||
@@ -77,7 +77,7 @@
|
||||
"react-table": "^7.8.0",
|
||||
"remark-import-partial": "^0.0.2",
|
||||
"reselect": "^5.2.0",
|
||||
"storybook": "^10.5.8",
|
||||
"storybook": "^10.5.7",
|
||||
"swagger-ui-react": "^5.32.13",
|
||||
"swc-loader": "^0.2.7",
|
||||
"tinycolor2": "^1.4.2",
|
||||
|
||||
+18
-18
@@ -4122,23 +4122,23 @@
|
||||
resolved "https://registry.yarnpkg.com/@standard-schema/utils/-/utils-0.3.0.tgz#3d5e608f16c2390c10528e98e59aef6bf73cae7b"
|
||||
integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==
|
||||
|
||||
"@storybook/addon-docs@^10.5.8":
|
||||
version "10.5.8"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.5.8.tgz#767c10c7a4cc1b625b93f869b2a2b09fc8514f2e"
|
||||
integrity sha512-NlHiMKW/UvW/uL8HXFDCEVwoH3qZeGYZ/qlWax4d7H471b/T54MBq2KcB4ZrdA785FfIH3numAJdBb5jwn00Mg==
|
||||
"@storybook/addon-docs@^10.5.7":
|
||||
version "10.5.7"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.5.7.tgz#6d599c94fc871c248ce06a5c081f57655c83f40a"
|
||||
integrity sha512-KNARJfjICaizinsR3INMEiipZm1ObYo+xw+E26gteu50Bcy2dIZUtk5uHY5XdtardU3AXX6yRXoBZ2HCY3lbHA==
|
||||
dependencies:
|
||||
"@mdx-js/react" "^3.0.0"
|
||||
"@storybook/csf-plugin" "10.5.8"
|
||||
"@storybook/csf-plugin" "10.5.7"
|
||||
"@storybook/icons" "^2.0.2"
|
||||
"@storybook/react-dom-shim" "10.5.8"
|
||||
"@storybook/react-dom-shim" "10.5.7"
|
||||
react "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
react-dom "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
ts-dedent "^2.0.0"
|
||||
|
||||
"@storybook/csf-plugin@10.5.8":
|
||||
version "10.5.8"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.5.8.tgz#c626c5bfe55d0e279b2457e5cf150a788d1fc637"
|
||||
integrity sha512-/FHiMyOWWEXfwK/lM0WxmkP9GLzbSJJuzGtfeuNWSOVDnvAMbjavitxfHb5wSbWKIQo0XYC1EJ2Y7x91XNYP4w==
|
||||
"@storybook/csf-plugin@10.5.7":
|
||||
version "10.5.7"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.5.7.tgz#bc73f164d1b5f8e2931b2774f4b389a06453cf6e"
|
||||
integrity sha512-IaX8FlM0H36HNFhJ2+4L9bCldqfvHGqcLg841SJNyK/DhfMlM7JsvY/GDH2ZFuWrUf8FSOx96GRRnHq6XfRKag==
|
||||
dependencies:
|
||||
unplugin "^2.3.5"
|
||||
|
||||
@@ -4152,10 +4152,10 @@
|
||||
resolved "https://registry.yarnpkg.com/@storybook/icons/-/icons-2.1.0.tgz#edfc2450a39c5e780f28c6cbc49acd7bff59b41a"
|
||||
integrity sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg==
|
||||
|
||||
"@storybook/react-dom-shim@10.5.8":
|
||||
version "10.5.8"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.5.8.tgz#40cc3e32af424baa2e4109a325dae2ede29999e2"
|
||||
integrity sha512-N8D13/Xny+V3kfe1KBgsAHS0nKWXLLdgOOXS9poKdYzVwVCN+CGEGBxWX0zMMtdCptqa6/57em9coPlZMoO+bg==
|
||||
"@storybook/react-dom-shim@10.5.7":
|
||||
version "10.5.7"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.5.7.tgz#9a5aa0e0f89c09e71c6cbfc6bb1abeb537e5aabf"
|
||||
integrity sha512-lxOkyh+wu/MiBXvYQHjZfD+DRKOa4bHBzbuGuiHXnHXmdOcTRdcrQTsoeN2FPtfugmmOG66cZUEgDwNX+k5eRA==
|
||||
|
||||
"@superset-ui/core@^0.20.4":
|
||||
version "0.20.4"
|
||||
@@ -14783,10 +14783,10 @@ stop-iteration-iterator@^1.1.0:
|
||||
es-errors "^1.3.0"
|
||||
internal-slot "^1.1.0"
|
||||
|
||||
storybook@^10.5.8:
|
||||
version "10.5.8"
|
||||
resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.5.8.tgz#d5f051983e6232c0a73ea02149a72c7bafb43275"
|
||||
integrity sha512-rR4oFMSiWBSqI0lvsJPtcQUPj8+hzj3TkLu+Mw61Wo6YxPSb5FsLSHai0jZnuaIdKIlmu25KCfwlSQl4e1uvnA==
|
||||
storybook@^10.5.7:
|
||||
version "10.5.7"
|
||||
resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.5.7.tgz#adfc465e51f337291c095278c23f1b8024ef2da7"
|
||||
integrity sha512-oiKvWIwIoOhFP1i6dASYyMXwPHKEtVZMshqSB7EvIVYjWRh0l9H7gHEt1z4Gh2rLGFMekWdsm4s94rvwpR7gkg==
|
||||
dependencies:
|
||||
"@storybook/global" "^5.0.0"
|
||||
"@storybook/icons" "^2.0.2"
|
||||
|
||||
Generated
+68
-77
@@ -180,9 +180,9 @@
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||
"@storybook/addon-docs": "10.5.8",
|
||||
"@storybook/addon-links": "10.5.8",
|
||||
"@storybook/react-webpack5": "10.5.8",
|
||||
"@storybook/addon-docs": "10.5.7",
|
||||
"@storybook/addon-links": "10.5.7",
|
||||
"@storybook/react-webpack5": "10.5.7",
|
||||
"@storybook/test-runner": "0.24.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.47",
|
||||
@@ -218,7 +218,7 @@
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"baseline-browser-mapping": "^2.11.14",
|
||||
"baseline-browser-mapping": "^2.11.13",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.4",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
@@ -235,7 +235,7 @@
|
||||
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
|
||||
"eslint-plugin-storybook": "10.5.8",
|
||||
"eslint-plugin-storybook": "10.5.7",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
"fetch-mock": "^12.6.0",
|
||||
@@ -266,7 +266,7 @@
|
||||
"source-map": "^0.8.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"speed-measure-webpack-plugin": "^1.6.0",
|
||||
"storybook": "10.5.8",
|
||||
"storybook": "10.5.7",
|
||||
"style-loader": "^4.0.0",
|
||||
"stylelint": "^17.14.1",
|
||||
"swc-loader": "^0.2.7",
|
||||
@@ -10765,16 +10765,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@storybook/addon-docs": {
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.8.tgz",
|
||||
"integrity": "sha512-NlHiMKW/UvW/uL8HXFDCEVwoH3qZeGYZ/qlWax4d7H471b/T54MBq2KcB4ZrdA785FfIH3numAJdBb5jwn00Mg==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.7.tgz",
|
||||
"integrity": "sha512-KNARJfjICaizinsR3INMEiipZm1ObYo+xw+E26gteu50Bcy2dIZUtk5uHY5XdtardU3AXX6yRXoBZ2HCY3lbHA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"@storybook/csf-plugin": "10.5.8",
|
||||
"@storybook/csf-plugin": "10.5.7",
|
||||
"@storybook/icons": "^2.0.2",
|
||||
"@storybook/react-dom-shim": "10.5.8",
|
||||
"@storybook/react-dom-shim": "10.5.7",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"ts-dedent": "^2.0.0"
|
||||
@@ -10785,7 +10785,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.8"
|
||||
"storybook": "10.5.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -10794,9 +10794,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/addon-docs/node_modules/@storybook/csf-plugin": {
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.8.tgz",
|
||||
"integrity": "sha512-/FHiMyOWWEXfwK/lM0WxmkP9GLzbSJJuzGtfeuNWSOVDnvAMbjavitxfHb5wSbWKIQo0XYC1EJ2Y7x91XNYP4w==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.7.tgz",
|
||||
"integrity": "sha512-IaX8FlM0H36HNFhJ2+4L9bCldqfvHGqcLg841SJNyK/DhfMlM7JsvY/GDH2ZFuWrUf8FSOx96GRRnHq6XfRKag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -10809,7 +10809,7 @@
|
||||
"peerDependencies": {
|
||||
"esbuild": "*",
|
||||
"rollup": "*",
|
||||
"storybook": "10.5.8",
|
||||
"storybook": "10.5.7",
|
||||
"vite": "*",
|
||||
"webpack": "*"
|
||||
},
|
||||
@@ -10829,9 +10829,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/addon-docs/node_modules/@storybook/react-dom-shim": {
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.8.tgz",
|
||||
"integrity": "sha512-N8D13/Xny+V3kfe1KBgsAHS0nKWXLLdgOOXS9poKdYzVwVCN+CGEGBxWX0zMMtdCptqa6/57em9coPlZMoO+bg==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.7.tgz",
|
||||
"integrity": "sha512-lxOkyh+wu/MiBXvYQHjZfD+DRKOa4bHBzbuGuiHXnHXmdOcTRdcrQTsoeN2FPtfugmmOG66cZUEgDwNX+k5eRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -10843,7 +10843,7 @@
|
||||
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.8"
|
||||
"storybook": "10.5.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -10855,9 +10855,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/addon-links": {
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.5.8.tgz",
|
||||
"integrity": "sha512-mpWw4alBJVGqgVh897LZ2keN/xnMHcH93wKJG+oGg4+cdEUA+06hCs5T4k+AS5Aa+EZ6LvdOoi2VPHssyQlCCA==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.5.7.tgz",
|
||||
"integrity": "sha512-17PxEOocLhAEaPeQ4q+8yul/LF9YEIePS1arknCAS7U1pQXTe0uj+R0pB6uPLVflM5gECQMiP4WzIj4tEiL6+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -10870,7 +10870,7 @@
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.8"
|
||||
"storybook": "10.5.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -10964,15 +10964,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5": {
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-10.5.8.tgz",
|
||||
"integrity": "sha512-HkPi42WaoNSHC0DAERsJEF7Vhnluzsp/aiuhnH65GGYG5TmdLL9G8KDiYvXHGDCyb4RfoAPYrtzxaLMFfPPFvQ==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-10.5.7.tgz",
|
||||
"integrity": "sha512-vvl07oXp2qfmHJHZ77Aw1F3LFOo7XubOta+lC8UmlEw3rDDJhQxJN3erJJVHavNhdA2jBTK6VUXQKdqQh7X7nQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/builder-webpack5": "10.5.8",
|
||||
"@storybook/preset-react-webpack": "10.5.8",
|
||||
"@storybook/react": "10.5.8"
|
||||
"@storybook/builder-webpack5": "10.5.7",
|
||||
"@storybook/preset-react-webpack": "10.5.7",
|
||||
"@storybook/react": "10.5.7"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -10981,7 +10981,7 @@
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.8",
|
||||
"storybook": "10.5.7",
|
||||
"typescript": ">= 4.9.x"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -10991,13 +10991,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/builder-webpack5": {
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.5.8.tgz",
|
||||
"integrity": "sha512-ke5x27gtWQ4gpXCLWxdGkr8ZlJwBykV/KjbBTAlC04dmS9OkI9MBzGj+TteUlgrcaN7LwoNTR5zRmxKStOZYzQ==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.5.7.tgz",
|
||||
"integrity": "sha512-4n4c60LihFivZnjAcXGO5+XbgZthoUtKb/nPKVgypj3MpEetzjq6XR83A4UNnRsXYmjqfn6bsDWNgEJ/RvQg5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/core-webpack": "10.5.8",
|
||||
"@storybook/core-webpack": "10.5.7",
|
||||
"case-sensitive-paths-webpack-plugin": "^2.4.0",
|
||||
"cjs-module-lexer": "^1.2.3",
|
||||
"css-loader": "^7.1.2",
|
||||
@@ -11019,7 +11019,7 @@
|
||||
"url": "https://opencollective.com/storybook"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"storybook": "10.5.8"
|
||||
"storybook": "10.5.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
@@ -11028,9 +11028,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/builder-webpack5/node_modules/@storybook/core-webpack": {
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.8.tgz",
|
||||
"integrity": "sha512-HccINB0UbTtnyJtKpaX+C35BRTSnAwnreIMwwI+LpeUd4x9mQg0G9orB7lfBBZwd5LQf8YhM2Vkjiawzo41GLg==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.7.tgz",
|
||||
"integrity": "sha512-0dtDw/FNPREoeCHX2RgZz0OecxaAGol1R7bCobFevArxyFIPJisTfjDMUFHKr+3B7BilTd3vnatl7Nlvgs0EiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -11041,17 +11041,17 @@
|
||||
"url": "https://opencollective.com/storybook"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"storybook": "10.5.8"
|
||||
"storybook": "10.5.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/preset-react-webpack": {
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-10.5.8.tgz",
|
||||
"integrity": "sha512-0JjgVoX5t9Wb+gwddYHx/Ej7KFqwd65lpHXEhBoT4pFWRqVI0pvfHu42M+DRGjsgOye3uE+3pH4yHR3+0/fCHA==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-10.5.7.tgz",
|
||||
"integrity": "sha512-xwNRcoVlIDx1/YYCFBAxfh/91vFiOgrVI+0Ir4u9eO87SH2leehRnJh619QEOrlQEU5px487y2BmL2ZVtmTpYA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/core-webpack": "10.5.8",
|
||||
"@storybook/core-webpack": "10.5.7",
|
||||
"@storybook/react-docgen-typescript-plugin": "1.0.6--canary.9.0c3f3b7.0",
|
||||
"@types/semver": "^7.7.1",
|
||||
"magic-string": "^0.30.5",
|
||||
@@ -11068,7 +11068,7 @@
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.8"
|
||||
"storybook": "10.5.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
@@ -11077,9 +11077,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/preset-react-webpack/node_modules/@storybook/core-webpack": {
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.8.tgz",
|
||||
"integrity": "sha512-HccINB0UbTtnyJtKpaX+C35BRTSnAwnreIMwwI+LpeUd4x9mQg0G9orB7lfBBZwd5LQf8YhM2Vkjiawzo41GLg==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.7.tgz",
|
||||
"integrity": "sha512-0dtDw/FNPREoeCHX2RgZz0OecxaAGol1R7bCobFevArxyFIPJisTfjDMUFHKr+3B7BilTd3vnatl7Nlvgs0EiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -11090,18 +11090,18 @@
|
||||
"url": "https://opencollective.com/storybook"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"storybook": "10.5.8"
|
||||
"storybook": "10.5.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/react": {
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.8.tgz",
|
||||
"integrity": "sha512-6qqkmqX6imtL+0Z9Uan2tIfYivOI0FiVmWr0zpqqQR15AkJ18JfNcNTQoyjeAlCO0Kei56SWqnu2qLq52TYplg==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.7.tgz",
|
||||
"integrity": "sha512-uFvty2MMdFXzW5PcQe1JqDAZkz6cQq7q/9G/cbGVnBEvP6zsOVeL+bmrQ0/WBlFQN0Ko9+ZoCTvaQ9s65zBa5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/global": "^5.0.0",
|
||||
"@storybook/react-dom-shim": "10.5.8",
|
||||
"@storybook/react-dom-shim": "10.5.7",
|
||||
"react-docgen": "^8.0.2",
|
||||
"react-docgen-typescript": "^2.2.2"
|
||||
},
|
||||
@@ -11114,7 +11114,7 @@
|
||||
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.8",
|
||||
"storybook": "10.5.7",
|
||||
"typescript": ">= 4.9.x"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -11130,9 +11130,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/react/node_modules/@storybook/react-dom-shim": {
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.8.tgz",
|
||||
"integrity": "sha512-N8D13/Xny+V3kfe1KBgsAHS0nKWXLLdgOOXS9poKdYzVwVCN+CGEGBxWX0zMMtdCptqa6/57em9coPlZMoO+bg==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.7.tgz",
|
||||
"integrity": "sha512-lxOkyh+wu/MiBXvYQHjZfD+DRKOa4bHBzbuGuiHXnHXmdOcTRdcrQTsoeN2FPtfugmmOG66cZUEgDwNX+k5eRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -11144,7 +11144,7 @@
|
||||
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.8"
|
||||
"storybook": "10.5.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -15697,9 +15697,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.11.14",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz",
|
||||
"integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==",
|
||||
"version": "2.11.13",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz",
|
||||
"integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -20219,9 +20219,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-storybook": {
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.8.tgz",
|
||||
"integrity": "sha512-bf9W5nZyWdIaCUZf4aEZnEeD1mn+csNYX8dYUQjAo6L7/DkSLtr65R4zFZ1xeS4m6dOXO6UtUySesCSw4e8w1g==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.7.tgz",
|
||||
"integrity": "sha512-mLpamG1Rsica2jYbUzIZOEuy7Fm1IMtVLMvvxGTpjTVKUMxTXJsANx3MBpH2VSbGQB8Yzlt5399WL/O07K97Ig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -20230,7 +20230,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": ">=8",
|
||||
"storybook": "10.5.8"
|
||||
"storybook": "10.5.7"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-testing-library": {
|
||||
@@ -37711,9 +37711,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/storybook": {
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.8.tgz",
|
||||
"integrity": "sha512-rR4oFMSiWBSqI0lvsJPtcQUPj8+hzj3TkLu+Mw61Wo6YxPSb5FsLSHai0jZnuaIdKIlmu25KCfwlSQl4e1uvnA==",
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.7.tgz",
|
||||
"integrity": "sha512-oiKvWIwIoOhFP1i6dASYyMXwPHKEtVZMshqSB7EvIVYjWRh0l9H7gHEt1z4Gh2rLGFMekWdsm4s94rvwpR7gkg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -43073,15 +43073,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/dompurify": {
|
||||
"version": "3.4.13",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
|
||||
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/react-ace": {
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-ace/-/react-ace-14.0.1.tgz",
|
||||
|
||||
@@ -257,9 +257,9 @@
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||
"@storybook/addon-docs": "10.5.8",
|
||||
"@storybook/addon-links": "10.5.8",
|
||||
"@storybook/react-webpack5": "10.5.8",
|
||||
"@storybook/addon-docs": "10.5.7",
|
||||
"@storybook/addon-links": "10.5.7",
|
||||
"@storybook/react-webpack5": "10.5.7",
|
||||
"@storybook/test-runner": "0.24.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.47",
|
||||
@@ -295,7 +295,7 @@
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"baseline-browser-mapping": "^2.11.14",
|
||||
"baseline-browser-mapping": "^2.11.13",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.4",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
@@ -312,7 +312,7 @@
|
||||
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
|
||||
"eslint-plugin-storybook": "10.5.8",
|
||||
"eslint-plugin-storybook": "10.5.7",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
"fetch-mock": "^12.6.0",
|
||||
@@ -343,7 +343,7 @@
|
||||
"source-map": "^0.8.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"speed-measure-webpack-plugin": "^1.6.0",
|
||||
"storybook": "10.5.8",
|
||||
"storybook": "10.5.7",
|
||||
"style-loader": "^4.0.0",
|
||||
"stylelint": "^17.14.1",
|
||||
"swc-loader": "^0.2.7",
|
||||
|
||||
+1
-42
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen, userEvent } from 'spec/helpers/testing-library';
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import SaveDatasetActionButton from 'src/SqlLab/components/SaveDatasetActionButton';
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
@@ -27,7 +27,6 @@ describe('SaveDatasetActionButton', () => {
|
||||
<SaveDatasetActionButton
|
||||
setShowSave={() => true}
|
||||
onSaveAsExplore={onSaveAsExplore}
|
||||
canSaveDataset
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -42,44 +41,4 @@ describe('SaveDatasetActionButton', () => {
|
||||
expect(saveBtn).toBeVisible();
|
||||
expect(saveDatasetBtn).toBeVisible();
|
||||
});
|
||||
|
||||
test('disables only the dataset button when canSaveDataset is false', () => {
|
||||
const onSaveAsExplore = jest.fn();
|
||||
render(
|
||||
<SaveDatasetActionButton
|
||||
setShowSave={() => true}
|
||||
onSaveAsExplore={onSaveAsExplore}
|
||||
canSaveDataset={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Saving the query needs no results.
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save dataset/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test('disables the save dataset button when the query did not run successfully', async () => {
|
||||
render(
|
||||
<SaveDatasetActionButton
|
||||
setShowSave={() => true}
|
||||
onSaveAsExplore={jest.fn()}
|
||||
canSaveDataset={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const saveDatasetBtn = screen.getByRole('button', {
|
||||
name: /save dataset/i,
|
||||
});
|
||||
expect(saveDatasetBtn).toBeDisabled();
|
||||
|
||||
// the disabled button is wrapped in a span so the tooltip still triggers
|
||||
userEvent.hover(saveDatasetBtn.parentElement as HTMLElement);
|
||||
expect(
|
||||
await screen.findByRole('tooltip', {
|
||||
name: 'You must run the query successfully first',
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,13 +23,11 @@ import { Button } from '@superset-ui/core/components';
|
||||
interface SaveDatasetActionButtonProps {
|
||||
setShowSave: (arg0: boolean) => void;
|
||||
onSaveAsExplore?: () => void;
|
||||
canSaveDataset: boolean;
|
||||
}
|
||||
|
||||
const SaveDatasetActionButton = ({
|
||||
setShowSave,
|
||||
onSaveAsExplore,
|
||||
canSaveDataset,
|
||||
}: SaveDatasetActionButtonProps) => (
|
||||
<>
|
||||
<Button
|
||||
@@ -45,13 +43,8 @@ const SaveDatasetActionButton = ({
|
||||
color="default"
|
||||
variant="text"
|
||||
onClick={() => onSaveAsExplore?.()}
|
||||
disabled={!canSaveDataset}
|
||||
icon={<Icons.TableOutlined />}
|
||||
tooltip={
|
||||
canSaveDataset
|
||||
? t('Save or Overwrite Dataset')
|
||||
: t('You must run the query successfully first')
|
||||
}
|
||||
tooltip={t('Save or Overwrite Dataset')}
|
||||
aria-label={t('Save dataset')}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -19,14 +19,12 @@
|
||||
import { act, type ComponentProps } from 'react';
|
||||
import {
|
||||
cleanup,
|
||||
createStore,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import reducerIndex from 'spec/helpers/reducerIndex';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal';
|
||||
import { createDatasource } from 'src/SqlLab/actions/sqlLab';
|
||||
@@ -65,12 +63,6 @@ beforeEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// In-body restores are skipped when an assertion throws, leaking a
|
||||
// configured spy into later tests.
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Mock createDatasource to return a thunk that resolves with the dataset's
|
||||
// new id. The test's mock store includes redux-thunk middleware (from RTK's
|
||||
// getDefaultMiddleware), so dispatch(createDatasource(...)) properly unwraps
|
||||
@@ -526,39 +518,6 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('surfaces the error and keeps the modal open when saving fails', async () => {
|
||||
// The chart-payload step's toast was built but never dispatched, so a
|
||||
// failure there was silent.
|
||||
const postFormData = jest.spyOn(
|
||||
require('src/explore/exploreUtils/formData'),
|
||||
'postFormData',
|
||||
);
|
||||
postFormData.mockRejectedValue(new Error('Boom'));
|
||||
const onHide = jest.fn();
|
||||
const store = createStore({ user }, reducerIndex);
|
||||
|
||||
render(<SaveDatasetModal {...mockedProps} onHide={onHide} />, { store });
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue(/unimportant/i), {
|
||||
target: { value: 'my dataset' },
|
||||
});
|
||||
userEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
// `createStore` builds its reducer map at runtime, so state isn't typed.
|
||||
const toasts = () =>
|
||||
(
|
||||
store.getState() as unknown as {
|
||||
messageToasts: { toastType: string }[];
|
||||
}
|
||||
).messageToasts;
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toasts()).toHaveLength(1);
|
||||
});
|
||||
expect(toasts()[0].toastType).toBe('DANGER_TOAST');
|
||||
expect(onHide).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('clearDatasetCache is imported and available', () => {
|
||||
const { clearDatasetCache } = require('src/utils/cachedSupersetGet');
|
||||
|
||||
|
||||
@@ -61,9 +61,6 @@ import type Subject from 'src/types/Subject';
|
||||
import { openInNewTab, redirect } from 'src/utils/navigationUtils';
|
||||
import { mapSubjectValuesToIds } from 'src/features/subjects/SubjectPicker';
|
||||
|
||||
// Derived so it can't drift from what `getClientErrorObject` accepts.
|
||||
type SaveErrorSource = Parameters<typeof getClientErrorObject>[0];
|
||||
|
||||
interface QueryDatabase {
|
||||
id?: number;
|
||||
}
|
||||
@@ -394,18 +391,9 @@ export const SaveDatasetModal = ({
|
||||
setDatasetName(getDefaultDatasetName());
|
||||
onHide();
|
||||
})
|
||||
.catch((error?: SaveErrorSource) => {
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
// `createDatasource` already toasted the server's message and rejects
|
||||
// with nothing; only the chart-payload step needs its own.
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
getClientErrorObject(error).then(e =>
|
||||
dispatch(
|
||||
addDangerToast(e.error || t('An error occurred saving dataset')),
|
||||
),
|
||||
);
|
||||
addDangerToast(t('An error occurred saving dataset'));
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -27,8 +27,6 @@ import {
|
||||
import SaveQuery from 'src/SqlLab/components/SaveQuery';
|
||||
import { initialState, databases } from 'src/SqlLab/fixtures';
|
||||
|
||||
const RESULT_COLUMNS = [{ column_name: 'col', type: 'STRING' }];
|
||||
|
||||
const mockedProps = {
|
||||
queryEditorId: '123',
|
||||
animation: false,
|
||||
@@ -61,31 +59,8 @@ const splitSaveBtnProps = {
|
||||
...mockedProps.database,
|
||||
allows_virtual_table_explore: true,
|
||||
},
|
||||
columns: RESULT_COLUMNS,
|
||||
};
|
||||
|
||||
const EDITOR_SQL = 'SELECT * FROM t';
|
||||
|
||||
const stateWithLatestQuery = ({
|
||||
id,
|
||||
state,
|
||||
sql = EDITOR_SQL,
|
||||
}: {
|
||||
id: string;
|
||||
state: string;
|
||||
sql?: string;
|
||||
}) => ({
|
||||
...mockState,
|
||||
sqlLab: {
|
||||
...mockState.sqlLab,
|
||||
queryEditors: mockState.sqlLab.queryEditors.map(qe => ({
|
||||
...qe,
|
||||
latestQueryId: id,
|
||||
})),
|
||||
queries: { [id]: { id, state, sql } },
|
||||
},
|
||||
});
|
||||
|
||||
const middlewares = [thunk];
|
||||
const mockStore = configureStore(middlewares);
|
||||
|
||||
@@ -121,71 +96,6 @@ describe('SavedQuery', () => {
|
||||
expect(saveBtn).toBeVisible();
|
||||
});
|
||||
|
||||
test('blocks "Save dataset" until the query has run successfully', () => {
|
||||
// Without a successful run the save can only fail server-side.
|
||||
render(<SaveQuery {...splitSaveBtnProps} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'failed' })),
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save dataset/i }),
|
||||
).toBeDisabled();
|
||||
// Saving the query itself is unaffected.
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
|
||||
});
|
||||
|
||||
test('blocks "Save dataset" when no query has been run at all', () => {
|
||||
render(<SaveQuery {...splitSaveBtnProps} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(mockState),
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save dataset/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test('blocks "Save dataset" when the SQL changed after a successful run', () => {
|
||||
// The run succeeded, but not for what is in the editor now -- and it is
|
||||
// the editor's SQL that gets saved.
|
||||
render(<SaveQuery {...splitSaveBtnProps} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(
|
||||
stateWithLatestQuery({
|
||||
id: 'qid-1',
|
||||
state: 'success',
|
||||
sql: 'SELECT 1 AS ran_earlier',
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save dataset/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test('blocks "Save dataset" when the successful query returned no columns', () => {
|
||||
// e.g. a DDL/DML statement -- there is nothing to introspect into a dataset.
|
||||
render(<SaveQuery {...splitSaveBtnProps} columns={[]} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'success' })),
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save dataset/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test('enables "Save dataset" once the query has succeeded', () => {
|
||||
render(<SaveQuery {...splitSaveBtnProps} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'success' })),
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: /save dataset/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
test('renders a save query modal when user clicks save button', () => {
|
||||
render(<SaveQuery {...mockedProps} />, {
|
||||
useRedux: true,
|
||||
@@ -323,7 +233,7 @@ describe('SavedQuery', () => {
|
||||
test('renders a save dataset modal when user clicks "save dataset" menu item', async () => {
|
||||
render(<SaveQuery {...splitSaveBtnProps} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'success' })),
|
||||
store: mockStore(mockState),
|
||||
});
|
||||
|
||||
const saveDatasetMenuItem = await screen.findByLabelText(/save dataset/i);
|
||||
@@ -337,7 +247,7 @@ describe('SavedQuery', () => {
|
||||
test('renders the save dataset modal UI', async () => {
|
||||
render(<SaveQuery {...splitSaveBtnProps} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'success' })),
|
||||
store: mockStore(mockState),
|
||||
});
|
||||
const saveDatasetMenuItem = await screen.findByLabelText(/save dataset/i);
|
||||
userEvent.click(saveDatasetMenuItem);
|
||||
|
||||
@@ -17,8 +17,6 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useState, useEffect, useMemo, ChangeEvent } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Query, QueryState } from '@superset-ui/core';
|
||||
import type { DatabaseObject } from 'src/features/databases/types';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
@@ -39,7 +37,7 @@ import {
|
||||
} from 'src/SqlLab/components/SaveDatasetModal';
|
||||
import { getDatasourceAsSaveableDataset } from 'src/utils/datasourceUtils';
|
||||
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
|
||||
import { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
|
||||
import { QueryEditor } from 'src/SqlLab/types';
|
||||
import useLogAction from 'src/logger/useLogAction';
|
||||
import {
|
||||
LOG_ACTIONS_SQLLAB_CREATE_CHART,
|
||||
@@ -113,17 +111,6 @@ const SaveQuery = ({
|
||||
const [label, setLabel] = useState<string>(defaultLabel);
|
||||
const [showSave, setShowSave] = useState<boolean>(false);
|
||||
const [showSaveDatasetModal, setShowSaveDatasetModal] = useState(false);
|
||||
// Saving a dataset runs the SQL to introspect columns, so it needs a
|
||||
// successful run of the SQL being saved that produced at least one column
|
||||
// -- editing after a run invalidates it, and running a selection only
|
||||
// validates that selection.
|
||||
const latestQuery = useSelector<SqlLabRootState, Query | undefined>(
|
||||
({ sqlLab }) => sqlLab.queries[queryEditor.latestQueryId || ''],
|
||||
);
|
||||
const canSaveDataset =
|
||||
latestQuery?.state === QueryState.Success &&
|
||||
latestQuery.sql === queryEditor.sql &&
|
||||
columns.length > 0;
|
||||
const isSaved = !!query.remoteId;
|
||||
const isLabelEmpty = label.trim().length === 0;
|
||||
const canExploreDatabase = !!database?.allows_virtual_table_explore;
|
||||
@@ -220,7 +207,6 @@ const SaveQuery = ({
|
||||
<SaveDatasetActionButton
|
||||
setShowSave={setShowSave}
|
||||
onSaveAsExplore={canExploreDatabase ? onSaveAsExplore : undefined}
|
||||
canSaveDataset={canSaveDataset}
|
||||
/>
|
||||
)}
|
||||
<SaveDatasetModal
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
isFeatureEnabled,
|
||||
getExtensionsRegistry,
|
||||
FeatureFlag,
|
||||
QueryState,
|
||||
} from '@superset-ui/core';
|
||||
import {
|
||||
act,
|
||||
@@ -335,50 +334,6 @@ describe('SqlEditor', () => {
|
||||
expect(await findByText('10 000')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const setupWithLatestQuery = (overrides: Partial<typeof latestQuery>) =>
|
||||
setup(
|
||||
mockedProps,
|
||||
createStore({
|
||||
...mockInitialState,
|
||||
sqlLab: {
|
||||
...mockInitialState.sqlLab,
|
||||
queries: {
|
||||
[latestQuery.id]: { ...latestQuery, ...overrides },
|
||||
},
|
||||
databases: {
|
||||
1991: {
|
||||
...mockInitialState.sqlLab.databases[1991],
|
||||
allows_virtual_table_explore: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
test('enables the save dataset button when the latest query succeeded', async () => {
|
||||
const { findByRole } = setupWithLatestQuery({
|
||||
state: QueryState.Success,
|
||||
sql: mockedProps.queryEditor.sql,
|
||||
});
|
||||
expect(await findByRole('button', { name: 'Save dataset' })).toBeEnabled();
|
||||
});
|
||||
|
||||
test('disables the save dataset button when the latest query failed', async () => {
|
||||
const { findByRole } = setupWithLatestQuery({
|
||||
state: QueryState.Failed,
|
||||
results: undefined,
|
||||
});
|
||||
expect(await findByRole('button', { name: 'Save dataset' })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('disables the save dataset button when the results are not loaded', async () => {
|
||||
const { findByRole } = setupWithLatestQuery({
|
||||
state: QueryState.Success,
|
||||
results: undefined,
|
||||
});
|
||||
expect(await findByRole('button', { name: 'Save dataset' })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('renders an Extension if provided', async () => {
|
||||
const extensionsRegistry = getExtensionsRegistry();
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
getExtensionsRegistry,
|
||||
QueryResponse,
|
||||
Query,
|
||||
QueryState,
|
||||
} from '@superset-ui/core';
|
||||
import { Alert } from '@apache-superset/core/components';
|
||||
import { css, styled, useTheme } from '@apache-superset/core/theme';
|
||||
@@ -296,9 +295,6 @@ const SqlEditor: FC<Props> = ({
|
||||
|
||||
const SqlFormExtension = extensionsRegistry.get('sqleditor.extension.form');
|
||||
|
||||
const successful = latestQuery?.state === QueryState.Success;
|
||||
const resultColumns = latestQuery?.results?.columns || [];
|
||||
|
||||
const startQuery = useCallback(
|
||||
(
|
||||
ctasArg = false,
|
||||
@@ -716,6 +712,7 @@ const SqlEditor: FC<Props> = ({
|
||||
|
||||
const getSecondaryMenuItems = () => {
|
||||
const qe = queryEditor;
|
||||
const successful = latestQuery?.state === 'success';
|
||||
const scheduleToolTip = successful
|
||||
? t('Schedule the query periodically')
|
||||
: t('You must run the query successfully first');
|
||||
@@ -861,7 +858,7 @@ const SqlEditor: FC<Props> = ({
|
||||
)}
|
||||
<SaveQuery
|
||||
queryEditorId={queryEditor.id}
|
||||
columns={resultColumns}
|
||||
columns={latestQuery?.results?.columns || []}
|
||||
onSave={onSaveQuery}
|
||||
onUpdate={(query, remoteId) =>
|
||||
dispatch(updateSavedQuery(query, remoteId))
|
||||
|
||||
@@ -33,11 +33,7 @@ from superset.commands.dataset.exceptions import (
|
||||
)
|
||||
from superset.commands.utils import populate_subjects
|
||||
from superset.daos.dataset import DatasetDAO
|
||||
from superset.exceptions import (
|
||||
SupersetException,
|
||||
SupersetParseError,
|
||||
SupersetSecurityException,
|
||||
)
|
||||
from superset.exceptions import SupersetParseError, SupersetSecurityException
|
||||
from superset.extensions import security_manager
|
||||
from superset.sql.parse import Table
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
@@ -54,25 +50,7 @@ class CreateDatasetCommand(CreateMixin, BaseCommand):
|
||||
self.validate()
|
||||
|
||||
dataset = DatasetDAO.create(attributes=self._properties)
|
||||
try:
|
||||
dataset.fetch_metadata()
|
||||
except SupersetException as ex:
|
||||
# Not a SQLAlchemyError, so ``on_error`` re-raises it untouched and
|
||||
# it escapes to FAB's ``@safe`` as an opaque 500 "Fatal error".
|
||||
# Deliberately covers the 403 ``SupersetSecurityException`` raised
|
||||
# for mutation/multi-statement SQL too: ``validate()`` already
|
||||
# reports that class of rejection as a 422 on ``sql`` via
|
||||
# ``DatasetDataAccessIsNotAllowed``.
|
||||
raise DatasetInvalidError(
|
||||
exceptions=[
|
||||
ValidationError(
|
||||
# ``lazy_gettext`` messages aren't ``str``, so
|
||||
# marshmallow won't wrap them into a list on its own.
|
||||
[str(ex.message)],
|
||||
field_name="sql" if self._properties.get("sql") else "table",
|
||||
)
|
||||
]
|
||||
) from ex
|
||||
dataset.fetch_metadata()
|
||||
return dataset
|
||||
|
||||
def validate(self) -> None: # noqa: C901
|
||||
|
||||
@@ -29,7 +29,9 @@ from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerInvalidError,
|
||||
SemanticLayerNotFoundError,
|
||||
SemanticViewCreateFailedError,
|
||||
SemanticViewForbiddenError,
|
||||
)
|
||||
from superset.commands.utils import current_user_can_modify_object
|
||||
from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
|
||||
from superset.semantic_layers.registry import registry
|
||||
from superset.utils import json
|
||||
@@ -92,9 +94,13 @@ class CreateSemanticViewCommand(BaseCommand):
|
||||
|
||||
def validate(self) -> None:
|
||||
layer_uuid: str = self._properties.get("semantic_layer_uuid", "")
|
||||
if not SemanticLayerDAO.find_by_uuid(layer_uuid):
|
||||
layer = SemanticLayerDAO.find_by_uuid(layer_uuid)
|
||||
if not layer:
|
||||
raise SemanticLayerNotFoundError()
|
||||
|
||||
if not current_user_can_modify_object(layer):
|
||||
raise SemanticViewForbiddenError()
|
||||
|
||||
name: str = self._properties.get("name", "")
|
||||
configuration: dict[str, Any] = self._properties.get("configuration") or {}
|
||||
if not SemanticViewDAO.validate_uniqueness(name, layer_uuid, configuration):
|
||||
|
||||
@@ -21,17 +21,17 @@ from functools import partial
|
||||
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from superset import security_manager
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerDeleteFailedError,
|
||||
SemanticLayerForbiddenError,
|
||||
SemanticLayerNotFoundError,
|
||||
SemanticViewDeleteFailedError,
|
||||
SemanticViewForbiddenError,
|
||||
SemanticViewNotFoundError,
|
||||
)
|
||||
from superset.commands.utils import current_user_can_modify_object
|
||||
from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.semantic_layers.models import SemanticLayer, SemanticView
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
|
||||
@@ -60,6 +60,9 @@ class DeleteSemanticLayerCommand(BaseCommand):
|
||||
if not self._model:
|
||||
raise SemanticLayerNotFoundError()
|
||||
|
||||
if not current_user_can_modify_object(self._model):
|
||||
raise SemanticLayerForbiddenError()
|
||||
|
||||
|
||||
class DeleteSemanticViewCommand(BaseCommand):
|
||||
def __init__(self, pk: int):
|
||||
@@ -82,10 +85,8 @@ class DeleteSemanticViewCommand(BaseCommand):
|
||||
self._model = SemanticViewDAO.find_by_id(self._pk, id_column="id")
|
||||
if not self._model:
|
||||
raise SemanticViewNotFoundError()
|
||||
try:
|
||||
security_manager.raise_for_editorship(self._model)
|
||||
except SupersetSecurityException as ex:
|
||||
raise SemanticViewForbiddenError() from ex
|
||||
if not current_user_can_modify_object(self._model):
|
||||
raise SemanticViewForbiddenError()
|
||||
|
||||
|
||||
class BulkDeleteSemanticViewCommand(BaseCommand):
|
||||
@@ -109,7 +110,5 @@ class BulkDeleteSemanticViewCommand(BaseCommand):
|
||||
if len(self._models) != len(self._model_ids):
|
||||
raise SemanticViewNotFoundError()
|
||||
for model in self._models:
|
||||
try:
|
||||
security_manager.raise_for_editorship(model)
|
||||
except SupersetSecurityException as ex:
|
||||
raise SemanticViewForbiddenError() from ex
|
||||
if not current_user_can_modify_object(model):
|
||||
raise SemanticViewForbiddenError()
|
||||
|
||||
@@ -23,9 +23,9 @@ from typing import Any
|
||||
from flask_appbuilder.models.sqla import Model
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from superset import security_manager
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerForbiddenError,
|
||||
SemanticLayerInvalidError,
|
||||
SemanticLayerNotFoundError,
|
||||
SemanticLayerUpdateFailedError,
|
||||
@@ -33,8 +33,8 @@ from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticViewNotFoundError,
|
||||
SemanticViewUpdateFailedError,
|
||||
)
|
||||
from superset.commands.utils import current_user_can_modify_object
|
||||
from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.semantic_layers.models import SemanticLayer, SemanticView
|
||||
from superset.semantic_layers.registry import registry
|
||||
from superset.utils import json
|
||||
@@ -66,10 +66,8 @@ class UpdateSemanticViewCommand(BaseCommand):
|
||||
if not self._model:
|
||||
raise SemanticViewNotFoundError()
|
||||
|
||||
try:
|
||||
security_manager.raise_for_editorship(self._model)
|
||||
except SupersetSecurityException as ex:
|
||||
raise SemanticViewForbiddenError() from ex
|
||||
if not current_user_can_modify_object(self._model):
|
||||
raise SemanticViewForbiddenError()
|
||||
|
||||
name = self._properties.get("name", self._model.name)
|
||||
layer_uuid = str(self._model.semantic_layer_uuid)
|
||||
@@ -116,6 +114,9 @@ class UpdateSemanticLayerCommand(BaseCommand):
|
||||
if not self._model:
|
||||
raise SemanticLayerNotFoundError()
|
||||
|
||||
if not current_user_can_modify_object(self._model):
|
||||
raise SemanticLayerForbiddenError()
|
||||
|
||||
name = self._properties.get("name")
|
||||
if name and not SemanticLayerDAO.validate_update_uniqueness(self._uuid, name):
|
||||
raise SemanticLayerInvalidError(f"Name already exists: {name}")
|
||||
|
||||
@@ -21,11 +21,8 @@ from typing import Any
|
||||
from superset import security_manager
|
||||
from superset.commands.base import BaseCommand, CreateMixin
|
||||
from superset.commands.tag.exceptions import TagCreateFailedError, TagInvalidError
|
||||
from superset.commands.tag.utils import (
|
||||
current_user_can_modify_object,
|
||||
to_object_model,
|
||||
to_object_type,
|
||||
)
|
||||
from superset.commands.tag.utils import to_object_model, to_object_type
|
||||
from superset.commands.utils import current_user_can_modify_object
|
||||
from superset.daos.tag import TagDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.tags.models import ObjectType, TagType
|
||||
|
||||
@@ -22,11 +22,8 @@ from flask_appbuilder.models.sqla import Model
|
||||
from superset import db
|
||||
from superset.commands.base import BaseCommand, UpdateMixin
|
||||
from superset.commands.tag.exceptions import TagInvalidError, TagNotFoundError
|
||||
from superset.commands.tag.utils import (
|
||||
current_user_can_modify_object,
|
||||
to_object_model,
|
||||
to_object_type,
|
||||
)
|
||||
from superset.commands.tag.utils import to_object_model, to_object_type
|
||||
from superset.commands.utils import current_user_can_modify_object
|
||||
from superset.daos.tag import TagDAO
|
||||
from superset.tags.models import Tag
|
||||
from superset.utils.decorators import transaction
|
||||
|
||||
@@ -17,11 +17,9 @@
|
||||
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from superset import security_manager
|
||||
from superset.daos.chart import ChartDAO
|
||||
from superset.daos.dashboard import DashboardDAO
|
||||
from superset.daos.query import SavedQueryDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.models.slice import Slice
|
||||
from superset.models.sql_lab import SavedQuery
|
||||
@@ -52,22 +50,3 @@ def to_object_model(
|
||||
|
||||
return DatasetDAO.find_by_id(object_id, skip_base_filter=skip_base_filter)
|
||||
return None
|
||||
|
||||
|
||||
def current_user_can_modify_object(model: Any) -> bool:
|
||||
"""Whether the current user may create/modify tag relationships on ``model``.
|
||||
|
||||
Mirrors the editorship check the bulk-create path already applies, or the
|
||||
object's creator, so the tag-update path enforces the same boundary.
|
||||
Look the model up with
|
||||
``skip_base_filter=True`` before calling this, so an object the user cannot
|
||||
access reaches the check instead of resolving to ``None`` and being written
|
||||
without any check.
|
||||
"""
|
||||
try:
|
||||
security_manager.raise_for_editorship(model)
|
||||
return True
|
||||
except SupersetSecurityException:
|
||||
return bool(
|
||||
model.created_by and model.created_by == security_manager.current_user
|
||||
)
|
||||
|
||||
@@ -44,6 +44,18 @@ def import_theme(config: dict[str, Any], overwrite: bool = False) -> "Theme | No
|
||||
if existing:
|
||||
if not overwrite or not can_write:
|
||||
return existing
|
||||
if existing.is_system:
|
||||
raise ThemeImportError("Cannot overwrite a system theme via import")
|
||||
# The active system-default/dark theme slot may be overwritten by
|
||||
# admins only; a non-admin overwriting it would change the theme
|
||||
# rendered for every user, including the login page and other
|
||||
# admins.
|
||||
if (
|
||||
existing.is_system_default or existing.is_system_dark
|
||||
) and not security_manager.is_admin():
|
||||
raise ThemeImportError(
|
||||
"Cannot overwrite the active system-default/dark theme via import"
|
||||
)
|
||||
config["id"] = existing.id
|
||||
elif not can_write:
|
||||
raise ThemeImportError(
|
||||
|
||||
@@ -18,8 +18,10 @@ import logging
|
||||
from functools import partial
|
||||
from typing import Any, Optional
|
||||
|
||||
from superset import security_manager
|
||||
from superset.commands.base import UpdateMixin
|
||||
from superset.commands.theme.exceptions import (
|
||||
SystemThemeInUseError,
|
||||
SystemThemeProtectedError,
|
||||
ThemeNotFoundError,
|
||||
)
|
||||
@@ -52,3 +54,11 @@ class UpdateThemeCommand(UpdateMixin):
|
||||
# Check if it's a system theme
|
||||
if self._model.is_system:
|
||||
raise SystemThemeProtectedError()
|
||||
|
||||
# The active system-default/dark theme slot may be edited by admins
|
||||
# only; a non-admin editing it would change the theme rendered for
|
||||
# every user, including the login page and other admins.
|
||||
if (
|
||||
self._model.is_system_default or self._model.is_system_dark
|
||||
) and not security_manager.is_admin():
|
||||
raise SystemThemeInUseError()
|
||||
|
||||
@@ -32,6 +32,7 @@ from superset.commands.exceptions import (
|
||||
from superset.daos.datasource import DatasourceDAO
|
||||
from superset.daos.exceptions import DatasourceNotFound
|
||||
from superset.daos.tag import TagDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.subjects.exceptions import SubjectsNotFoundValidationError
|
||||
from superset.subjects.models import Subject
|
||||
from superset.subjects.utils import (
|
||||
@@ -52,6 +53,30 @@ def _has_extra_editors_resolver() -> bool:
|
||||
return bool(has_app_context() and current_app.config.get("EXTRA_EDITORS_RESOLVER"))
|
||||
|
||||
|
||||
def current_user_can_modify_object(model: Any) -> bool:
|
||||
"""Whether the current user is authorized to create/modify ``model``.
|
||||
|
||||
Delegates to ``security_manager.raise_for_editorship``, which grants
|
||||
access to admins and any subject in ``model.editors`` (when that
|
||||
relationship exists). For models that don't carry an ``editors``
|
||||
relationship, or when the current subject isn't one of them, this falls
|
||||
back to allowing the object's creator.
|
||||
|
||||
Callers that need to distinguish "not found" from "no access" should
|
||||
look the model up bypassing DAO base filters (e.g.
|
||||
``skip_base_filter=True``) before calling this, so an object the user
|
||||
cannot access reaches the check instead of resolving to ``None`` and
|
||||
being written without any check.
|
||||
"""
|
||||
try:
|
||||
security_manager.raise_for_editorship(model)
|
||||
return True
|
||||
except SupersetSecurityException:
|
||||
return bool(
|
||||
model.created_by and model.created_by == security_manager.current_user
|
||||
)
|
||||
|
||||
|
||||
def populate_subject_list(
|
||||
subject_ids: list[int] | None,
|
||||
default_to_user: bool,
|
||||
|
||||
@@ -1408,6 +1408,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
"CssTemplate",
|
||||
"Dataset",
|
||||
"Datasource",
|
||||
"Theme",
|
||||
} | READ_ONLY_MODEL_VIEWS
|
||||
|
||||
GAMMA_EXCLUDED_PVMS = {
|
||||
|
||||
@@ -41,6 +41,7 @@ from superset.commands.semantic_layer.delete import (
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerCreateFailedError,
|
||||
SemanticLayerDeleteFailedError,
|
||||
SemanticLayerForbiddenError,
|
||||
SemanticLayerInvalidError,
|
||||
SemanticLayerNotFoundError,
|
||||
SemanticLayerUpdateFailedError,
|
||||
@@ -210,6 +211,8 @@ class SemanticViewRestApi(BaseSupersetModelRestApi):
|
||||
description: Semantic view structure
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
403:
|
||||
$ref: '#/components/responses/403'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
422:
|
||||
@@ -338,6 +341,8 @@ class SemanticViewRestApi(BaseSupersetModelRestApi):
|
||||
errors.append(
|
||||
{"name": view_data.get("name"), "error": "Semantic layer not found"}
|
||||
)
|
||||
except SemanticViewForbiddenError as ex:
|
||||
errors.append({"name": view_data.get("name"), "error": str(ex)})
|
||||
except SemanticViewCreateFailedError as ex:
|
||||
logger.error(
|
||||
"Error creating semantic view: %s",
|
||||
@@ -447,6 +452,8 @@ class SemanticViewRestApi(BaseSupersetModelRestApi):
|
||||
description: Semantic view deleted
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
403:
|
||||
$ref: '#/components/responses/403'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
422:
|
||||
@@ -665,6 +672,8 @@ class SemanticLayerRestApi(BaseSupersetApi):
|
||||
description: Runtime JSON Schema
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
403:
|
||||
$ref: '#/components/responses/403'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
"""
|
||||
@@ -672,6 +681,11 @@ class SemanticLayerRestApi(BaseSupersetApi):
|
||||
if not layer:
|
||||
return self.response_404()
|
||||
|
||||
try:
|
||||
layer.raise_for_access()
|
||||
except SupersetSecurityException as ex:
|
||||
return self.response(403, message=ex.message)
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
runtime_data = body.get("runtime_data")
|
||||
|
||||
@@ -716,6 +730,8 @@ class SemanticLayerRestApi(BaseSupersetApi):
|
||||
description: Available views
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
403:
|
||||
$ref: '#/components/responses/403'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
"""
|
||||
@@ -726,6 +742,11 @@ class SemanticLayerRestApi(BaseSupersetApi):
|
||||
if not layer:
|
||||
return self.response_404()
|
||||
|
||||
try:
|
||||
layer.raise_for_access()
|
||||
except SupersetSecurityException as ex:
|
||||
return self.response(403, message=ex.message)
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
runtime_data = body.get("runtime_data", {})
|
||||
|
||||
@@ -855,6 +876,8 @@ class SemanticLayerRestApi(BaseSupersetApi):
|
||||
$ref: '#/components/responses/400'
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
403:
|
||||
$ref: '#/components/responses/403'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
422:
|
||||
@@ -870,6 +893,8 @@ class SemanticLayerRestApi(BaseSupersetApi):
|
||||
return self.response(200, result={"uuid": str(changed_model.uuid)})
|
||||
except SemanticLayerNotFoundError:
|
||||
return self.response_404()
|
||||
except SemanticLayerForbiddenError as ex:
|
||||
return self.response(403, message=str(ex))
|
||||
except SemanticLayerInvalidError as ex:
|
||||
return self.response_422(message=str(ex))
|
||||
except SemanticLayerUpdateFailedError as ex:
|
||||
@@ -899,6 +924,8 @@ class SemanticLayerRestApi(BaseSupersetApi):
|
||||
description: Semantic layer deleted
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
403:
|
||||
$ref: '#/components/responses/403'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
422:
|
||||
@@ -909,6 +936,8 @@ class SemanticLayerRestApi(BaseSupersetApi):
|
||||
return self.response(200, message="OK")
|
||||
except SemanticLayerNotFoundError:
|
||||
return self.response_404()
|
||||
except SemanticLayerForbiddenError as ex:
|
||||
return self.response(403, message=str(ex))
|
||||
except SemanticLayerDeleteFailedError as ex:
|
||||
logger.error(
|
||||
"Error deleting semantic layer: %s",
|
||||
@@ -1152,10 +1181,18 @@ class SemanticLayerRestApi(BaseSupersetApi):
|
||||
description: A semantic layer
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
403:
|
||||
$ref: '#/components/responses/403'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
"""
|
||||
layer = SemanticLayerDAO.find_by_uuid(uuid)
|
||||
if not layer:
|
||||
return self.response_404()
|
||||
|
||||
try:
|
||||
layer.raise_for_access()
|
||||
except SupersetSecurityException as ex:
|
||||
return self.response(403, message=ex.message)
|
||||
|
||||
return self.response(200, result=_serialize_layer(layer))
|
||||
|
||||
@@ -148,6 +148,26 @@ class SemanticLayer(AuditMixinNullable, Model):
|
||||
"""Compute the permission string for this semantic layer."""
|
||||
return f"[{self.name}](id:{self.uuid.hex})"
|
||||
|
||||
def raise_for_access(self) -> None:
|
||||
"""Check that the user has access to this semantic layer."""
|
||||
from superset import security_manager
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
|
||||
if security_manager.can_access_all_datasources():
|
||||
return
|
||||
|
||||
if self.perm and security_manager.can_access("datasource_access", self.perm):
|
||||
return
|
||||
|
||||
raise SupersetSecurityException(
|
||||
SupersetError(
|
||||
error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
|
||||
message=str(_("You don't have access to this semantic layer.")),
|
||||
level=ErrorLevel.ERROR,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def after_insert(
|
||||
mapper: Mapper,
|
||||
|
||||
@@ -350,6 +350,8 @@ class ThemeRestApi(BaseSupersetModelRestApi):
|
||||
return self.response_404()
|
||||
except SystemThemeProtectedError:
|
||||
return self.response_403()
|
||||
except SystemThemeInUseError:
|
||||
return self.response_403()
|
||||
except Exception as ex:
|
||||
logger.exception("Unexpected error in PUT /theme/%s", pk)
|
||||
return self.response_422(message=str(ex))
|
||||
|
||||
+13
-1
@@ -615,9 +615,21 @@ def sanitize_svg_content(svg_content: str) -> str:
|
||||
return ""
|
||||
|
||||
# Minimal protection: remove obvious malicious content, preserve all SVG features
|
||||
# The closing tag pattern tolerates attributes/whitespace after "script"
|
||||
# (e.g. "</script foo>"), which browsers still parse as a valid closer.
|
||||
content = re.sub(
|
||||
r"<script[^>]*>.*?</script>", "", svg_content, flags=re.IGNORECASE | re.DOTALL
|
||||
r"<script\b[^>]*>.*?</script\b[^>]*>",
|
||||
"",
|
||||
svg_content,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
# Second pass: an unterminated <script ...> opener has no matching
|
||||
# closer, so browsers treat everything after it as script content
|
||||
# through end-of-file. Drop the opener and the remainder of the
|
||||
# content with it, rather than leaving the payload text behind.
|
||||
content = re.sub(r"<script\b[^>]*>.*", "", content, flags=re.IGNORECASE | re.DOTALL)
|
||||
# Drop any orphaned closing </script ...> fragment too.
|
||||
content = re.sub(r"</script\b[^>]*>?", "", content, flags=re.IGNORECASE)
|
||||
content = re.sub(r"javascript:", "", content, flags=re.IGNORECASE)
|
||||
content = re.sub(r"data:[^;]*;[^,]*,.*javascript", "", content, flags=re.IGNORECASE)
|
||||
|
||||
|
||||
@@ -23,7 +23,11 @@ from superset.models.core import Theme
|
||||
from superset.utils import json
|
||||
from tests.conftest import with_config
|
||||
from tests.integration_tests.base_tests import SupersetTestCase
|
||||
from tests.integration_tests.constants import ADMIN_USERNAME, GAMMA_USERNAME
|
||||
from tests.integration_tests.constants import (
|
||||
ADMIN_USERNAME,
|
||||
ALPHA_USERNAME,
|
||||
GAMMA_USERNAME,
|
||||
)
|
||||
|
||||
|
||||
class TestThemeAPIPermissions(SupersetTestCase):
|
||||
@@ -92,9 +96,11 @@ class TestThemeAPIPermissions(SupersetTestCase):
|
||||
|
||||
@with_config({"ENABLE_UI_THEME_ADMINISTRATION": True})
|
||||
def test_non_admin_cannot_set_system_default(self):
|
||||
"""Test that non-admin users cannot set system themes"""
|
||||
# Login as gamma user
|
||||
self.login(GAMMA_USERNAME)
|
||||
"""Test that a non-admin user with theme write access (Alpha) still
|
||||
cannot set system themes, since that is an admin-only action."""
|
||||
# Login as alpha user, who has generic write access to themes but
|
||||
# is not an admin
|
||||
self.login(ALPHA_USERNAME)
|
||||
|
||||
# Try to set theme as system default
|
||||
response = self.client.put(
|
||||
@@ -110,6 +116,26 @@ class TestThemeAPIPermissions(SupersetTestCase):
|
||||
theme = db.session.query(Theme).filter_by(id=self.regular_theme.id).first()
|
||||
assert theme.is_system_default is False
|
||||
|
||||
@with_config({"ENABLE_UI_THEME_ADMINISTRATION": True})
|
||||
def test_gamma_cannot_write_themes(self):
|
||||
"""Test that gamma users, who only have read access to themes, are
|
||||
rejected before reaching the admin-only check."""
|
||||
# Login as gamma user
|
||||
self.login(GAMMA_USERNAME)
|
||||
|
||||
# Try to set theme as system default
|
||||
response = self.client.put(
|
||||
f"/api/v1/theme/{self.regular_theme.id}/set_system_default"
|
||||
)
|
||||
|
||||
# Should be forbidden at the permission layer, since gamma has no
|
||||
# write access to themes at all
|
||||
assert response.status_code == 403
|
||||
|
||||
# Verify theme is not system default
|
||||
theme = db.session.query(Theme).filter_by(id=self.regular_theme.id).first()
|
||||
assert theme.is_system_default is False
|
||||
|
||||
@with_config({"ENABLE_UI_THEME_ADMINISTRATION": False})
|
||||
def test_system_theme_requires_config_enabled(self):
|
||||
"""Test that system theme APIs require configuration to be enabled"""
|
||||
|
||||
@@ -18,15 +18,11 @@ from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from marshmallow import ValidationError
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from superset.commands.dataset.create import CreateDatasetCommand
|
||||
from superset.commands.dataset.exceptions import DatasetInvalidError
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import (
|
||||
SupersetGenericDBErrorException,
|
||||
SupersetParseError,
|
||||
)
|
||||
from superset.exceptions import SupersetParseError
|
||||
from superset.models.core import Database
|
||||
|
||||
|
||||
@@ -254,83 +250,3 @@ def test_create_dataset_generic_exists_error_when_no_twin() -> None:
|
||||
)
|
||||
with pytest.raises(DatasetInvalidError):
|
||||
command.validate()
|
||||
|
||||
|
||||
def test_create_dataset_metadata_fetch_error_is_structured(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A metadata-fetch failure must surface the engine's own message.
|
||||
|
||||
``run()`` executes the SQL to introspect columns; the resulting
|
||||
``SupersetGenericDBErrorException`` used to escape as a 500 "Fatal error".
|
||||
"""
|
||||
mocker.patch.object(CreateDatasetCommand, "validate")
|
||||
dataset = Mock()
|
||||
dataset.fetch_metadata.side_effect = SupersetGenericDBErrorException(
|
||||
message="Invalid SQL: Unable to parse: SELECT ...",
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.commands.dataset.create.DatasetDAO.create",
|
||||
return_value=dataset,
|
||||
)
|
||||
|
||||
command = CreateDatasetCommand(
|
||||
{
|
||||
"database": 1,
|
||||
"table_name": "dataset wrong",
|
||||
"sql": "SELECT ...",
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(DatasetInvalidError) as exc_info:
|
||||
command.run()
|
||||
|
||||
validation_errors = exc_info.value._exceptions
|
||||
assert len(validation_errors) == 1
|
||||
assert validation_errors[0].field_name == "sql"
|
||||
assert "Invalid SQL: Unable to parse: SELECT ..." in str(
|
||||
validation_errors[0].messages[0]
|
||||
)
|
||||
|
||||
|
||||
def test_create_dataset_metadata_fetch_error_physical_table(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""The same conversion applies to physical datasets, keyed on ``table``."""
|
||||
mocker.patch.object(CreateDatasetCommand, "validate")
|
||||
dataset = Mock()
|
||||
dataset.fetch_metadata.side_effect = SupersetGenericDBErrorException(
|
||||
message="(psycopg2.OperationalError) could not connect to server",
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.commands.dataset.create.DatasetDAO.create",
|
||||
return_value=dataset,
|
||||
)
|
||||
|
||||
command = CreateDatasetCommand({"database": 1, "table_name": "physical_table"})
|
||||
|
||||
with pytest.raises(DatasetInvalidError) as exc_info:
|
||||
command.run()
|
||||
|
||||
validation_errors = exc_info.value._exceptions
|
||||
assert validation_errors[0].field_name == "table"
|
||||
assert "could not connect to server" in str(validation_errors[0].messages[0])
|
||||
|
||||
|
||||
def test_create_dataset_run_succeeds_when_metadata_fetch_works(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Control: the happy path still returns the created dataset."""
|
||||
mocker.patch.object(CreateDatasetCommand, "validate")
|
||||
dataset = Mock()
|
||||
mocker.patch(
|
||||
"superset.commands.dataset.create.DatasetDAO.create",
|
||||
return_value=dataset,
|
||||
)
|
||||
|
||||
command = CreateDatasetCommand(
|
||||
{"database": 1, "table_name": "good_dataset", "sql": "SELECT 1 AS a"}
|
||||
)
|
||||
|
||||
assert command.run() is dataset
|
||||
dataset.fetch_metadata.assert_called_once()
|
||||
|
||||
@@ -25,6 +25,7 @@ from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerCreateFailedError,
|
||||
SemanticLayerInvalidError,
|
||||
)
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
|
||||
|
||||
def test_create_semantic_layer_success(mocker: MockerFixture) -> None:
|
||||
@@ -56,6 +57,36 @@ def test_create_semantic_layer_success(mocker: MockerFixture) -> None:
|
||||
mock_cls.from_configuration.assert_called_once_with({"account": "test"})
|
||||
|
||||
|
||||
def test_create_semantic_layer_configuration_already_serialized(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""When ``configuration`` is already a JSON string, it is passed through
|
||||
to the DAO unchanged instead of being re-serialized."""
|
||||
new_model = MagicMock()
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticLayerDAO",
|
||||
)
|
||||
dao.validate_uniqueness.return_value = True
|
||||
dao.create.return_value = new_model
|
||||
|
||||
mock_cls = MagicMock()
|
||||
mocker.patch.dict(
|
||||
"superset.commands.semantic_layer.create.registry",
|
||||
{"snowflake": mock_cls},
|
||||
)
|
||||
|
||||
data = {
|
||||
"name": "My Layer",
|
||||
"type": "snowflake",
|
||||
"configuration": '{"account": "test"}',
|
||||
}
|
||||
result = CreateSemanticLayerCommand(data).run()
|
||||
|
||||
assert result == new_model
|
||||
dao.create.assert_called_once_with(attributes=data)
|
||||
|
||||
|
||||
def test_create_semantic_layer_unknown_type(mocker: MockerFixture) -> None:
|
||||
"""Test that SemanticLayerInvalidError is raised for unknown type."""
|
||||
mocker.patch(
|
||||
@@ -166,6 +197,11 @@ def test_create_semantic_view_success(mocker: MockerFixture) -> None:
|
||||
mock_model.name = "orders"
|
||||
dao_view.create.return_value = mock_model
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.create.current_user_can_modify_object",
|
||||
return_value=True,
|
||||
)
|
||||
|
||||
from superset.commands.semantic_layer.create import CreateSemanticViewCommand
|
||||
|
||||
result = CreateSemanticViewCommand(
|
||||
@@ -182,6 +218,42 @@ def test_create_semantic_view_success(mocker: MockerFixture) -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_create_semantic_view_configuration_already_serialized(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""When ``configuration`` is already a JSON string, it is passed through
|
||||
to the DAO unchanged instead of being re-serialized."""
|
||||
mock_layer = MagicMock()
|
||||
dao_layer = mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticLayerDAO",
|
||||
)
|
||||
dao_layer.find_by_uuid.return_value = mock_layer
|
||||
|
||||
dao_view = mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticViewDAO",
|
||||
)
|
||||
dao_view.validate_uniqueness.return_value = True
|
||||
mock_model = MagicMock()
|
||||
dao_view.create.return_value = mock_model
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.create.current_user_can_modify_object",
|
||||
return_value=True,
|
||||
)
|
||||
|
||||
from superset.commands.semantic_layer.create import CreateSemanticViewCommand
|
||||
|
||||
data = {
|
||||
"name": "orders",
|
||||
"semantic_layer_uuid": "layer-uuid",
|
||||
"configuration": '{"db": "prod"}',
|
||||
}
|
||||
result = CreateSemanticViewCommand(data).run()
|
||||
|
||||
assert result == mock_model
|
||||
dao_view.create.assert_called_once_with(attributes=data)
|
||||
|
||||
|
||||
def test_create_semantic_view_layer_not_found(mocker: MockerFixture) -> None:
|
||||
"""Test CreateSemanticViewCommand raises when layer not found."""
|
||||
dao_layer = mocker.patch(
|
||||
@@ -215,6 +287,11 @@ def test_create_semantic_view_duplicate(mocker: MockerFixture) -> None:
|
||||
)
|
||||
dao_view.validate_uniqueness.return_value = False
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.create.current_user_can_modify_object",
|
||||
return_value=True,
|
||||
)
|
||||
|
||||
from superset.commands.semantic_layer.create import CreateSemanticViewCommand
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticViewCreateFailedError,
|
||||
@@ -228,3 +305,110 @@ def test_create_semantic_view_duplicate(mocker: MockerFixture) -> None:
|
||||
"configuration": {"db": "prod"},
|
||||
}
|
||||
).run()
|
||||
|
||||
|
||||
def test_create_semantic_view_forbidden(mocker: MockerFixture) -> None:
|
||||
"""Test CreateSemanticViewCommand raises when the caller may not modify
|
||||
the parent layer."""
|
||||
from superset.commands.semantic_layer.create import CreateSemanticViewCommand
|
||||
from superset.commands.semantic_layer.exceptions import SemanticViewForbiddenError
|
||||
|
||||
mock_layer = MagicMock()
|
||||
dao_layer = mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticLayerDAO",
|
||||
)
|
||||
dao_layer.find_by_uuid.return_value = mock_layer
|
||||
|
||||
dao_view = mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticViewDAO",
|
||||
)
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.create.current_user_can_modify_object",
|
||||
return_value=False,
|
||||
)
|
||||
|
||||
with pytest.raises(SemanticViewForbiddenError):
|
||||
CreateSemanticViewCommand(
|
||||
{
|
||||
"name": "orders",
|
||||
"semantic_layer_uuid": "layer-uuid",
|
||||
"configuration": {"db": "prod"},
|
||||
}
|
||||
).run()
|
||||
|
||||
dao_view.create.assert_not_called()
|
||||
|
||||
|
||||
def test_create_semantic_view_creator_of_layer_allowed(mocker: MockerFixture) -> None:
|
||||
"""A non-admin who created the parent layer, but holds no explicit
|
||||
editorship on it, can still add a semantic view to it."""
|
||||
from superset.commands.semantic_layer.create import CreateSemanticViewCommand
|
||||
|
||||
mock_layer = MagicMock()
|
||||
dao_layer = mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticLayerDAO",
|
||||
)
|
||||
dao_layer.find_by_uuid.return_value = mock_layer
|
||||
|
||||
dao_view = mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticViewDAO",
|
||||
)
|
||||
dao_view.validate_uniqueness.return_value = True
|
||||
mock_model = MagicMock()
|
||||
dao_view.create.return_value = mock_model
|
||||
|
||||
sm = mocker.patch("superset.commands.utils.security_manager")
|
||||
sm.raise_for_editorship = MagicMock(
|
||||
side_effect=SupersetSecurityException(MagicMock()),
|
||||
)
|
||||
mock_layer.created_by = sm.current_user
|
||||
|
||||
result = CreateSemanticViewCommand(
|
||||
{
|
||||
"name": "orders",
|
||||
"semantic_layer_uuid": "layer-uuid",
|
||||
"configuration": {"db": "prod"},
|
||||
}
|
||||
).run()
|
||||
|
||||
assert result == mock_model
|
||||
dao_view.create.assert_called_once()
|
||||
|
||||
|
||||
def test_create_semantic_view_non_creator_non_editor_forbidden(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A non-admin who neither created the parent layer nor is an editor of
|
||||
it is rejected."""
|
||||
from superset.commands.semantic_layer.create import CreateSemanticViewCommand
|
||||
from superset.commands.semantic_layer.exceptions import SemanticViewForbiddenError
|
||||
|
||||
mock_layer = MagicMock()
|
||||
dao_layer = mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticLayerDAO",
|
||||
)
|
||||
dao_layer.find_by_uuid.return_value = mock_layer
|
||||
|
||||
dao_view = mocker.patch(
|
||||
"superset.commands.semantic_layer.create.SemanticViewDAO",
|
||||
)
|
||||
|
||||
sm = mocker.patch(
|
||||
"superset.commands.utils.security_manager",
|
||||
)
|
||||
sm.raise_for_editorship = MagicMock(
|
||||
side_effect=SupersetSecurityException(MagicMock()),
|
||||
)
|
||||
mock_layer.created_by = MagicMock(name="someone_else")
|
||||
|
||||
with pytest.raises(SemanticViewForbiddenError):
|
||||
CreateSemanticViewCommand(
|
||||
{
|
||||
"name": "orders",
|
||||
"semantic_layer_uuid": "layer-uuid",
|
||||
"configuration": {"db": "prod"},
|
||||
}
|
||||
).run()
|
||||
|
||||
dao_view.create.assert_not_called()
|
||||
|
||||
@@ -21,7 +21,11 @@ import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from superset.commands.semantic_layer.delete import DeleteSemanticLayerCommand
|
||||
from superset.commands.semantic_layer.exceptions import SemanticLayerNotFoundError
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerForbiddenError,
|
||||
SemanticLayerNotFoundError,
|
||||
)
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
|
||||
|
||||
def test_delete_semantic_layer_success(mocker: MockerFixture) -> None:
|
||||
@@ -33,6 +37,11 @@ def test_delete_semantic_layer_success(mocker: MockerFixture) -> None:
|
||||
)
|
||||
dao.find_by_uuid.return_value = mock_model
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.current_user_can_modify_object",
|
||||
return_value=True,
|
||||
)
|
||||
|
||||
DeleteSemanticLayerCommand("some-uuid").run()
|
||||
|
||||
dao.find_by_uuid.assert_called_once_with("some-uuid")
|
||||
@@ -50,6 +59,71 @@ def test_delete_semantic_layer_not_found(mocker: MockerFixture) -> None:
|
||||
DeleteSemanticLayerCommand("missing-uuid").run()
|
||||
|
||||
|
||||
def test_delete_semantic_layer_forbidden(mocker: MockerFixture) -> None:
|
||||
"""Test that SemanticLayerForbiddenError is raised for non-editors."""
|
||||
mock_model = MagicMock()
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.SemanticLayerDAO",
|
||||
)
|
||||
dao.find_by_uuid.return_value = mock_model
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.current_user_can_modify_object",
|
||||
return_value=False,
|
||||
)
|
||||
|
||||
with pytest.raises(SemanticLayerForbiddenError):
|
||||
DeleteSemanticLayerCommand("some-uuid").run()
|
||||
|
||||
dao.delete.assert_not_called()
|
||||
|
||||
|
||||
def test_delete_semantic_layer_creator_allowed(mocker: MockerFixture) -> None:
|
||||
"""A non-admin who created the layer, but holds no explicit editorship
|
||||
on it, can still delete it."""
|
||||
mock_model = MagicMock()
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.SemanticLayerDAO",
|
||||
)
|
||||
dao.find_by_uuid.return_value = mock_model
|
||||
|
||||
sm = mocker.patch("superset.commands.utils.security_manager")
|
||||
sm.raise_for_editorship = MagicMock(
|
||||
side_effect=SupersetSecurityException(MagicMock()),
|
||||
)
|
||||
mock_model.created_by = sm.current_user
|
||||
|
||||
DeleteSemanticLayerCommand("some-uuid").run()
|
||||
|
||||
dao.delete.assert_called_once_with([mock_model])
|
||||
|
||||
|
||||
def test_delete_semantic_layer_non_creator_non_editor_forbidden(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A non-admin who neither created the layer nor is an editor of it is
|
||||
rejected."""
|
||||
mock_model = MagicMock()
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.SemanticLayerDAO",
|
||||
)
|
||||
dao.find_by_uuid.return_value = mock_model
|
||||
|
||||
sm = mocker.patch("superset.commands.utils.security_manager")
|
||||
sm.raise_for_editorship = MagicMock(
|
||||
side_effect=SupersetSecurityException(MagicMock()),
|
||||
)
|
||||
mock_model.created_by = MagicMock(name="someone_else")
|
||||
|
||||
with pytest.raises(SemanticLayerForbiddenError):
|
||||
DeleteSemanticLayerCommand("some-uuid").run()
|
||||
|
||||
dao.delete.assert_not_called()
|
||||
|
||||
|
||||
def test_delete_semantic_view_success(mocker: MockerFixture) -> None:
|
||||
"""Test successful deletion of a semantic view."""
|
||||
mock_model = MagicMock()
|
||||
@@ -59,10 +133,11 @@ def test_delete_semantic_view_success(mocker: MockerFixture) -> None:
|
||||
)
|
||||
dao.find_by_id.return_value = mock_model
|
||||
|
||||
# Admin is owner of everything — no exception raised
|
||||
# Admin (or an editor) can modify anything — no exception raised.
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.security_manager"
|
||||
).raise_for_editorship.return_value = None
|
||||
"superset.commands.semantic_layer.delete.current_user_can_modify_object",
|
||||
return_value=True,
|
||||
)
|
||||
|
||||
from superset.commands.semantic_layer.delete import DeleteSemanticViewCommand
|
||||
|
||||
@@ -76,12 +151,13 @@ def test_delete_semantic_view_forbidden(mocker: MockerFixture) -> None:
|
||||
"""Test that SemanticViewForbiddenError is raised for non-owners."""
|
||||
from superset.commands.semantic_layer.delete import DeleteSemanticViewCommand
|
||||
from superset.commands.semantic_layer.exceptions import SemanticViewForbiddenError
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.SemanticViewDAO",
|
||||
)
|
||||
dao.find_by_id.return_value = MagicMock()
|
||||
model = MagicMock()
|
||||
model.created_by = None
|
||||
dao.find_by_id.return_value = model
|
||||
|
||||
mocker.patch(
|
||||
"superset.security_manager.raise_for_editorship",
|
||||
@@ -92,6 +168,56 @@ def test_delete_semantic_view_forbidden(mocker: MockerFixture) -> None:
|
||||
DeleteSemanticViewCommand(42).run()
|
||||
|
||||
|
||||
def test_delete_semantic_view_creator_allowed(mocker: MockerFixture) -> None:
|
||||
"""A non-admin who created the view, but holds no explicit editorship on
|
||||
it, can still delete it."""
|
||||
from superset.commands.semantic_layer.delete import DeleteSemanticViewCommand
|
||||
|
||||
mock_model = MagicMock()
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.SemanticViewDAO",
|
||||
)
|
||||
dao.find_by_id.return_value = mock_model
|
||||
|
||||
sm = mocker.patch("superset.commands.utils.security_manager")
|
||||
sm.raise_for_editorship = MagicMock(
|
||||
side_effect=SupersetSecurityException(MagicMock()),
|
||||
)
|
||||
mock_model.created_by = sm.current_user
|
||||
|
||||
DeleteSemanticViewCommand(42).run()
|
||||
|
||||
dao.delete.assert_called_once_with([mock_model])
|
||||
|
||||
|
||||
def test_delete_semantic_view_non_creator_non_editor_forbidden(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A non-admin who neither created the view nor is an editor of it is
|
||||
rejected."""
|
||||
from superset.commands.semantic_layer.delete import DeleteSemanticViewCommand
|
||||
from superset.commands.semantic_layer.exceptions import SemanticViewForbiddenError
|
||||
|
||||
mock_model = MagicMock()
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.SemanticViewDAO",
|
||||
)
|
||||
dao.find_by_id.return_value = mock_model
|
||||
|
||||
sm = mocker.patch("superset.commands.utils.security_manager")
|
||||
sm.raise_for_editorship = MagicMock(
|
||||
side_effect=SupersetSecurityException(MagicMock()),
|
||||
)
|
||||
mock_model.created_by = MagicMock(name="someone_else")
|
||||
|
||||
with pytest.raises(SemanticViewForbiddenError):
|
||||
DeleteSemanticViewCommand(42).run()
|
||||
|
||||
dao.delete.assert_not_called()
|
||||
|
||||
|
||||
def test_delete_semantic_view_not_found(mocker: MockerFixture) -> None:
|
||||
"""Test that SemanticViewNotFoundError is raised when view is missing."""
|
||||
dao = mocker.patch(
|
||||
@@ -118,8 +244,9 @@ def test_bulk_delete_semantic_view_success(mocker: MockerFixture) -> None:
|
||||
dao.find_by_ids.return_value = mock_models
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.security_manager"
|
||||
).raise_for_editorship.return_value = None
|
||||
"superset.commands.semantic_layer.delete.current_user_can_modify_object",
|
||||
return_value=True,
|
||||
)
|
||||
|
||||
from superset.commands.semantic_layer.delete import BulkDeleteSemanticViewCommand
|
||||
|
||||
@@ -133,7 +260,6 @@ def test_bulk_delete_semantic_view_forbidden(mocker: MockerFixture) -> None:
|
||||
"""Test that SemanticViewForbiddenError is raised for non-owners."""
|
||||
from superset.commands.semantic_layer.delete import BulkDeleteSemanticViewCommand
|
||||
from superset.commands.semantic_layer.exceptions import SemanticViewForbiddenError
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.SemanticViewDAO",
|
||||
@@ -141,14 +267,67 @@ def test_bulk_delete_semantic_view_forbidden(mocker: MockerFixture) -> None:
|
||||
dao.find_by_ids.return_value = [MagicMock(), MagicMock()]
|
||||
|
||||
mocker.patch(
|
||||
"superset.security_manager.raise_for_editorship",
|
||||
side_effect=SupersetSecurityException(MagicMock()),
|
||||
"superset.commands.semantic_layer.delete.current_user_can_modify_object",
|
||||
return_value=False,
|
||||
)
|
||||
|
||||
with pytest.raises(SemanticViewForbiddenError):
|
||||
BulkDeleteSemanticViewCommand([1, 2]).run()
|
||||
|
||||
|
||||
def test_bulk_delete_semantic_view_creator_allowed(mocker: MockerFixture) -> None:
|
||||
"""A non-admin who created every view in the batch, but holds no
|
||||
explicit editorship on them, can still bulk-delete them."""
|
||||
from superset.commands.semantic_layer.delete import BulkDeleteSemanticViewCommand
|
||||
|
||||
mock_models = [MagicMock(), MagicMock()]
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.SemanticViewDAO",
|
||||
)
|
||||
dao.find_by_ids.return_value = mock_models
|
||||
|
||||
sm = mocker.patch("superset.commands.utils.security_manager")
|
||||
sm.raise_for_editorship = MagicMock(
|
||||
side_effect=SupersetSecurityException(MagicMock()),
|
||||
)
|
||||
for model in mock_models:
|
||||
model.created_by = sm.current_user
|
||||
|
||||
BulkDeleteSemanticViewCommand([1, 2]).run()
|
||||
|
||||
dao.delete.assert_called_once_with(mock_models)
|
||||
|
||||
|
||||
def test_bulk_delete_semantic_view_non_creator_non_editor_forbidden(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A non-admin who is neither the creator of, nor an editor for, one of
|
||||
the views in the batch is rejected."""
|
||||
from superset.commands.semantic_layer.delete import BulkDeleteSemanticViewCommand
|
||||
from superset.commands.semantic_layer.exceptions import SemanticViewForbiddenError
|
||||
|
||||
mock_models = [MagicMock(), MagicMock()]
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.delete.SemanticViewDAO",
|
||||
)
|
||||
dao.find_by_ids.return_value = mock_models
|
||||
|
||||
sm = mocker.patch("superset.commands.utils.security_manager")
|
||||
sm.raise_for_editorship = MagicMock(
|
||||
side_effect=SupersetSecurityException(MagicMock()),
|
||||
)
|
||||
# The first view belongs to the current user, the second doesn't.
|
||||
mock_models[0].created_by = sm.current_user
|
||||
mock_models[1].created_by = MagicMock(name="someone_else")
|
||||
|
||||
with pytest.raises(SemanticViewForbiddenError):
|
||||
BulkDeleteSemanticViewCommand([1, 2]).run()
|
||||
|
||||
dao.delete.assert_not_called()
|
||||
|
||||
|
||||
def test_bulk_delete_semantic_view_not_found(mocker: MockerFixture) -> None:
|
||||
"""Test that SemanticViewNotFoundError is raised when any id is missing."""
|
||||
dao = mocker.patch(
|
||||
|
||||
@@ -21,6 +21,7 @@ import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerForbiddenError,
|
||||
SemanticLayerInvalidError,
|
||||
SemanticLayerNotFoundError,
|
||||
SemanticViewForbiddenError,
|
||||
@@ -46,7 +47,7 @@ def test_update_semantic_view_success(mocker: MockerFixture) -> None:
|
||||
dao.update.return_value = mock_model
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.update.security_manager",
|
||||
"superset.commands.semantic_layer.update.current_user_can_modify_object",
|
||||
)
|
||||
|
||||
data = {"description": "Updated", "cache_timeout": 300}
|
||||
@@ -77,18 +78,65 @@ def test_update_semantic_view_forbidden(mocker: MockerFixture) -> None:
|
||||
)
|
||||
dao.find_by_id.return_value = mock_model
|
||||
|
||||
sm = mocker.patch(
|
||||
"superset.commands.semantic_layer.update.security_manager",
|
||||
)
|
||||
# Use a regular MagicMock for raise_for_editorship to avoid AsyncMock issues
|
||||
sm.raise_for_editorship = MagicMock(
|
||||
side_effect=SupersetSecurityException(MagicMock()),
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.update.current_user_can_modify_object",
|
||||
return_value=False,
|
||||
)
|
||||
|
||||
with pytest.raises(SemanticViewForbiddenError):
|
||||
UpdateSemanticViewCommand(1, {"description": "test"}).run()
|
||||
|
||||
|
||||
def test_update_semantic_view_creator_allowed(mocker: MockerFixture) -> None:
|
||||
"""A non-admin who created the view, but holds no explicit editorship on
|
||||
it, can still update it."""
|
||||
mock_model = MagicMock()
|
||||
mock_model.id = 1
|
||||
mock_model.configuration = "{}"
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.update.SemanticViewDAO",
|
||||
)
|
||||
dao.find_by_id.return_value = mock_model
|
||||
dao.update.return_value = mock_model
|
||||
|
||||
sm = mocker.patch("superset.commands.utils.security_manager")
|
||||
sm.raise_for_editorship = MagicMock(
|
||||
side_effect=SupersetSecurityException(MagicMock()),
|
||||
)
|
||||
mock_model.created_by = sm.current_user
|
||||
|
||||
data = {"description": "Updated"}
|
||||
result = UpdateSemanticViewCommand(1, data).run()
|
||||
|
||||
assert result == mock_model
|
||||
dao.update.assert_called_once_with(mock_model, attributes=data)
|
||||
|
||||
|
||||
def test_update_semantic_view_non_creator_non_editor_forbidden(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A non-admin who neither created the view nor is an editor of it is
|
||||
rejected."""
|
||||
mock_model = MagicMock()
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.update.SemanticViewDAO",
|
||||
)
|
||||
dao.find_by_id.return_value = mock_model
|
||||
|
||||
sm = mocker.patch("superset.commands.utils.security_manager")
|
||||
sm.raise_for_editorship = MagicMock(
|
||||
side_effect=SupersetSecurityException(MagicMock()),
|
||||
)
|
||||
mock_model.created_by = MagicMock(name="someone_else")
|
||||
|
||||
with pytest.raises(SemanticViewForbiddenError):
|
||||
UpdateSemanticViewCommand(1, {"description": "test"}).run()
|
||||
|
||||
dao.update.assert_not_called()
|
||||
|
||||
|
||||
def test_update_semantic_view_copies_data(mocker: MockerFixture) -> None:
|
||||
"""Test that the command copies input data and does not mutate it."""
|
||||
mock_model = MagicMock()
|
||||
@@ -101,7 +149,7 @@ def test_update_semantic_view_copies_data(mocker: MockerFixture) -> None:
|
||||
dao.update.return_value = mock_model
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.update.security_manager",
|
||||
"superset.commands.semantic_layer.update.current_user_can_modify_object",
|
||||
)
|
||||
|
||||
original_data = {"description": "Original"}
|
||||
@@ -127,6 +175,10 @@ def test_update_semantic_layer_success(mocker: MockerFixture) -> None:
|
||||
dao.find_by_uuid.return_value = mock_model
|
||||
dao.update.return_value = mock_model
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.update.current_user_can_modify_object",
|
||||
)
|
||||
|
||||
data = {"name": "Updated", "description": "New desc"}
|
||||
result = UpdateSemanticLayerCommand("some-uuid", data).run()
|
||||
|
||||
@@ -146,6 +198,77 @@ def test_update_semantic_layer_not_found(mocker: MockerFixture) -> None:
|
||||
UpdateSemanticLayerCommand("missing-uuid", {"name": "test"}).run()
|
||||
|
||||
|
||||
def test_update_semantic_layer_forbidden(mocker: MockerFixture) -> None:
|
||||
"""Test that SemanticLayerForbiddenError is raised on ownership failure."""
|
||||
mock_model = MagicMock()
|
||||
mock_model.type = "snowflake"
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.update.SemanticLayerDAO",
|
||||
)
|
||||
dao.find_by_uuid.return_value = mock_model
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.update.current_user_can_modify_object",
|
||||
return_value=False,
|
||||
)
|
||||
|
||||
with pytest.raises(SemanticLayerForbiddenError):
|
||||
UpdateSemanticLayerCommand("some-uuid", {"name": "test"}).run()
|
||||
|
||||
dao.update.assert_not_called()
|
||||
|
||||
|
||||
def test_update_semantic_layer_creator_allowed(mocker: MockerFixture) -> None:
|
||||
"""A non-admin who created the layer, but holds no explicit editorship
|
||||
on it, can still update it."""
|
||||
mock_model = MagicMock()
|
||||
mock_model.type = "snowflake"
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.update.SemanticLayerDAO",
|
||||
)
|
||||
dao.find_by_uuid.return_value = mock_model
|
||||
dao.update.return_value = mock_model
|
||||
|
||||
sm = mocker.patch("superset.commands.utils.security_manager")
|
||||
sm.raise_for_editorship = MagicMock(
|
||||
side_effect=SupersetSecurityException(MagicMock()),
|
||||
)
|
||||
mock_model.created_by = sm.current_user
|
||||
|
||||
data = {"description": "Updated"}
|
||||
result = UpdateSemanticLayerCommand("some-uuid", data).run()
|
||||
|
||||
assert result == mock_model
|
||||
dao.update.assert_called_once_with(mock_model, attributes=data)
|
||||
|
||||
|
||||
def test_update_semantic_layer_non_creator_non_editor_forbidden(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A non-admin who neither created the layer nor is an editor of it is
|
||||
rejected."""
|
||||
mock_model = MagicMock()
|
||||
mock_model.type = "snowflake"
|
||||
|
||||
dao = mocker.patch(
|
||||
"superset.commands.semantic_layer.update.SemanticLayerDAO",
|
||||
)
|
||||
dao.find_by_uuid.return_value = mock_model
|
||||
|
||||
sm = mocker.patch("superset.commands.utils.security_manager")
|
||||
sm.raise_for_editorship = MagicMock(
|
||||
side_effect=SupersetSecurityException(MagicMock()),
|
||||
)
|
||||
mock_model.created_by = MagicMock(name="someone_else")
|
||||
|
||||
with pytest.raises(SemanticLayerForbiddenError):
|
||||
UpdateSemanticLayerCommand("some-uuid", {"name": "test"}).run()
|
||||
|
||||
dao.update.assert_not_called()
|
||||
|
||||
|
||||
def test_update_semantic_layer_duplicate_name(mocker: MockerFixture) -> None:
|
||||
"""Test that SemanticLayerInvalidError is raised for duplicate names."""
|
||||
mock_model = MagicMock()
|
||||
@@ -157,6 +280,10 @@ def test_update_semantic_layer_duplicate_name(mocker: MockerFixture) -> None:
|
||||
dao.find_by_uuid.return_value = mock_model
|
||||
dao.validate_update_uniqueness.return_value = False
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.update.current_user_can_modify_object",
|
||||
)
|
||||
|
||||
with pytest.raises(SemanticLayerInvalidError):
|
||||
UpdateSemanticLayerCommand("some-uuid", {"name": "Duplicate"}).run()
|
||||
|
||||
@@ -174,6 +301,10 @@ def test_update_semantic_layer_validates_configuration(
|
||||
dao.find_by_uuid.return_value = mock_model
|
||||
dao.update.return_value = mock_model
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.update.current_user_can_modify_object",
|
||||
)
|
||||
|
||||
mock_cls = MagicMock()
|
||||
mocker.patch.dict(
|
||||
"superset.commands.semantic_layer.update.registry",
|
||||
@@ -199,6 +330,10 @@ def test_update_semantic_layer_skips_name_check_when_no_name(
|
||||
dao.find_by_uuid.return_value = mock_model
|
||||
dao.update.return_value = mock_model
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.update.current_user_can_modify_object",
|
||||
)
|
||||
|
||||
UpdateSemanticLayerCommand("some-uuid", {"description": "Updated"}).run()
|
||||
|
||||
dao.validate_update_uniqueness.assert_not_called()
|
||||
@@ -215,6 +350,10 @@ def test_update_semantic_layer_copies_data(mocker: MockerFixture) -> None:
|
||||
dao.find_by_uuid.return_value = mock_model
|
||||
dao.update.return_value = mock_model
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.update.current_user_can_modify_object",
|
||||
)
|
||||
|
||||
original_data = {"description": "Original"}
|
||||
UpdateSemanticLayerCommand("some-uuid", original_data).run()
|
||||
|
||||
@@ -249,7 +388,7 @@ def test_update_uniqueness_different_config_same_name(
|
||||
dao.validate_update_uniqueness.return_value = True
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.update.security_manager",
|
||||
"superset.commands.semantic_layer.update.current_user_can_modify_object",
|
||||
)
|
||||
|
||||
# Update to a config that differs from an existing view
|
||||
@@ -279,7 +418,7 @@ def test_update_uniqueness_same_config_different_name(
|
||||
dao.validate_update_uniqueness.return_value = True
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.update.security_manager",
|
||||
"superset.commands.semantic_layer.update.current_user_can_modify_object",
|
||||
)
|
||||
|
||||
data = {"name": "renamed_view", "configuration": {"schema": "prod"}}
|
||||
@@ -307,7 +446,7 @@ def test_update_uniqueness_same_config_same_name_fails(
|
||||
dao.validate_update_uniqueness.return_value = False
|
||||
|
||||
mocker.patch(
|
||||
"superset.commands.semantic_layer.update.security_manager",
|
||||
"superset.commands.semantic_layer.update.current_user_can_modify_object",
|
||||
)
|
||||
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
|
||||
@@ -22,11 +22,13 @@ import pytest
|
||||
|
||||
from superset.commands.exceptions import TagForbiddenError, TagNotFoundValidationError
|
||||
from superset.commands.utils import (
|
||||
current_user_can_modify_object,
|
||||
Tag,
|
||||
TagType,
|
||||
update_tags,
|
||||
validate_tags,
|
||||
)
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.tags.models import ObjectType
|
||||
|
||||
OBJECT_TYPES = {ObjectType.chart, ObjectType.chart}
|
||||
@@ -343,3 +345,60 @@ def test_update_tags_no_tags(mock_tag_dao, object_type):
|
||||
mock_tag_dao.create_custom_tagged_objects.assert_called_once_with(
|
||||
object_type, 1, new_tag_names
|
||||
)
|
||||
|
||||
|
||||
@patch("superset.commands.utils.security_manager")
|
||||
def test_current_user_can_modify_object_editor(mock_sm):
|
||||
"""
|
||||
An editor of the resource (or an admin, since ``raise_for_editorship``
|
||||
treats admins as editors of everything) is allowed to modify it.
|
||||
"""
|
||||
mock_sm.raise_for_editorship.return_value = None
|
||||
model = MagicMock()
|
||||
|
||||
assert current_user_can_modify_object(model) is True
|
||||
|
||||
|
||||
@patch("superset.commands.utils.security_manager")
|
||||
def test_current_user_can_modify_object_creator_fallback(mock_sm):
|
||||
"""
|
||||
A resource without an ``editors`` relationship (or a user who isn't in
|
||||
it) still allows the object's creator through.
|
||||
"""
|
||||
mock_sm.raise_for_editorship = MagicMock(
|
||||
side_effect=SupersetSecurityException(MagicMock())
|
||||
)
|
||||
model = MagicMock()
|
||||
model.created_by = mock_sm.current_user
|
||||
|
||||
assert current_user_can_modify_object(model) is True
|
||||
|
||||
|
||||
@patch("superset.commands.utils.security_manager")
|
||||
def test_current_user_can_modify_object_denies_non_creator(mock_sm):
|
||||
"""
|
||||
A user who is neither an editor nor the creator is denied.
|
||||
"""
|
||||
mock_sm.raise_for_editorship = MagicMock(
|
||||
side_effect=SupersetSecurityException(MagicMock())
|
||||
)
|
||||
mock_sm.current_user = MagicMock(name="current_user")
|
||||
model = MagicMock()
|
||||
model.created_by = MagicMock(name="someone_else")
|
||||
|
||||
assert current_user_can_modify_object(model) is False
|
||||
|
||||
|
||||
@patch("superset.commands.utils.security_manager")
|
||||
def test_current_user_can_modify_object_no_creator(mock_sm):
|
||||
"""
|
||||
A resource with no ``created_by`` set (e.g. created programmatically)
|
||||
is denied to non-editors.
|
||||
"""
|
||||
mock_sm.raise_for_editorship = MagicMock(
|
||||
side_effect=SupersetSecurityException(MagicMock())
|
||||
)
|
||||
model = MagicMock()
|
||||
model.created_by = None
|
||||
|
||||
assert current_user_can_modify_object(model) is False
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
# 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.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from superset.commands.theme.exceptions import ThemeImportError
|
||||
from superset.commands.theme.import_themes import import_theme
|
||||
from superset.models.core import Theme
|
||||
|
||||
|
||||
def _mock_existing(
|
||||
is_system: bool = False,
|
||||
is_system_default: bool = False,
|
||||
is_system_dark: bool = False,
|
||||
) -> MagicMock:
|
||||
theme = MagicMock(spec=Theme)
|
||||
theme.id = 1
|
||||
theme.is_system = is_system
|
||||
theme.is_system_default = is_system_default
|
||||
theme.is_system_dark = is_system_dark
|
||||
return theme
|
||||
|
||||
|
||||
@patch("superset.security_manager")
|
||||
@patch("superset.db")
|
||||
def test_import_theme_refuses_system_theme_overwrite(mock_db, mock_security_manager):
|
||||
"""overwrite=True must not be able to replace a seeded system theme."""
|
||||
mock_security_manager.can_access.return_value = True
|
||||
existing = _mock_existing(is_system=True)
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
|
||||
existing
|
||||
)
|
||||
|
||||
config = {"uuid": "some-uuid", "theme_name": "hostile", "json_data": "{}"}
|
||||
|
||||
with pytest.raises(ThemeImportError):
|
||||
import_theme(config, overwrite=True)
|
||||
|
||||
|
||||
@patch("superset.security_manager")
|
||||
@patch("superset.db")
|
||||
def test_import_theme_refuses_system_default_overwrite(mock_db, mock_security_manager):
|
||||
"""A non-admin overwrite=True must not replace the active default theme."""
|
||||
mock_security_manager.can_access.return_value = True
|
||||
# Use a regular Mock for is_admin to avoid AsyncMock auto-detection
|
||||
mock_security_manager.is_admin = Mock(return_value=False)
|
||||
existing = _mock_existing(is_system_default=True)
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
|
||||
existing
|
||||
)
|
||||
|
||||
config = {"uuid": "some-uuid", "theme_name": "hostile", "json_data": "{}"}
|
||||
|
||||
with pytest.raises(ThemeImportError):
|
||||
import_theme(config, overwrite=True)
|
||||
|
||||
|
||||
@patch("superset.security_manager")
|
||||
@patch("superset.db")
|
||||
def test_import_theme_refuses_system_dark_overwrite(mock_db, mock_security_manager):
|
||||
"""A non-admin overwrite=True must not replace the active dark theme."""
|
||||
mock_security_manager.can_access.return_value = True
|
||||
# Use a regular Mock for is_admin to avoid AsyncMock auto-detection
|
||||
mock_security_manager.is_admin = Mock(return_value=False)
|
||||
existing = _mock_existing(is_system_dark=True)
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
|
||||
existing
|
||||
)
|
||||
|
||||
config = {"uuid": "some-uuid", "theme_name": "hostile", "json_data": "{}"}
|
||||
|
||||
with pytest.raises(ThemeImportError):
|
||||
import_theme(config, overwrite=True)
|
||||
|
||||
|
||||
@patch("superset.utils.core.get_user")
|
||||
@patch("superset.security_manager")
|
||||
@patch("superset.db")
|
||||
def test_import_theme_admin_allows_system_default_overwrite(
|
||||
mock_db, mock_security_manager, mock_get_user
|
||||
):
|
||||
"""An admin overwrite=True may still replace the active default theme,
|
||||
mirroring UpdateThemeCommand's admin carve-out."""
|
||||
mock_security_manager.can_access.return_value = True
|
||||
# Use a regular Mock for is_admin to avoid AsyncMock auto-detection
|
||||
mock_security_manager.is_admin = Mock(return_value=True)
|
||||
mock_get_user.return_value = None
|
||||
existing = _mock_existing(is_system_default=True)
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
|
||||
existing
|
||||
)
|
||||
|
||||
config = {"uuid": "some-uuid", "theme_name": "updated", "json_data": "{}"}
|
||||
|
||||
with patch("superset.models.core.Theme.import_from_dict") as mock_import_from_dict:
|
||||
mock_theme = MagicMock(spec=Theme)
|
||||
mock_theme.id = 1
|
||||
mock_import_from_dict.return_value = mock_theme
|
||||
|
||||
result = import_theme(config, overwrite=True)
|
||||
|
||||
assert result is mock_theme
|
||||
assert config["id"] == existing.id
|
||||
|
||||
|
||||
@patch("superset.utils.core.get_user")
|
||||
@patch("superset.security_manager")
|
||||
@patch("superset.db")
|
||||
def test_import_theme_admin_allows_system_dark_overwrite(
|
||||
mock_db, mock_security_manager, mock_get_user
|
||||
):
|
||||
"""An admin overwrite=True may still replace the active dark theme,
|
||||
mirroring UpdateThemeCommand's admin carve-out."""
|
||||
mock_security_manager.can_access.return_value = True
|
||||
# Use a regular Mock for is_admin to avoid AsyncMock auto-detection
|
||||
mock_security_manager.is_admin = Mock(return_value=True)
|
||||
mock_get_user.return_value = None
|
||||
existing = _mock_existing(is_system_dark=True)
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
|
||||
existing
|
||||
)
|
||||
|
||||
config = {"uuid": "some-uuid", "theme_name": "updated", "json_data": "{}"}
|
||||
|
||||
with patch("superset.models.core.Theme.import_from_dict") as mock_import_from_dict:
|
||||
mock_theme = MagicMock(spec=Theme)
|
||||
mock_theme.id = 1
|
||||
mock_import_from_dict.return_value = mock_theme
|
||||
|
||||
result = import_theme(config, overwrite=True)
|
||||
|
||||
assert result is mock_theme
|
||||
assert config["id"] == existing.id
|
||||
|
||||
|
||||
@patch("superset.utils.core.get_user")
|
||||
@patch("superset.security_manager")
|
||||
@patch("superset.db")
|
||||
def test_import_theme_allows_regular_theme_overwrite(
|
||||
mock_db, mock_security_manager, mock_get_user
|
||||
):
|
||||
"""A regular (non-system) theme can still be overwritten as before."""
|
||||
mock_security_manager.can_access.return_value = True
|
||||
mock_get_user.return_value = None
|
||||
existing = _mock_existing()
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
|
||||
existing
|
||||
)
|
||||
|
||||
config = {"uuid": "some-uuid", "theme_name": "updated", "json_data": "{}"}
|
||||
|
||||
with patch("superset.models.core.Theme.import_from_dict") as mock_import_from_dict:
|
||||
mock_theme = MagicMock(spec=Theme)
|
||||
mock_theme.id = 1
|
||||
mock_import_from_dict.return_value = mock_theme
|
||||
|
||||
result = import_theme(config, overwrite=True)
|
||||
|
||||
assert result is mock_theme
|
||||
assert config["id"] == existing.id
|
||||
|
||||
|
||||
@patch("superset.security_manager")
|
||||
@patch("superset.db")
|
||||
def test_import_theme_no_overwrite_returns_existing(mock_db, mock_security_manager):
|
||||
"""Without overwrite=True, the existing theme is returned untouched."""
|
||||
mock_security_manager.can_access.return_value = True
|
||||
existing = _mock_existing(is_system_default=True)
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = (
|
||||
existing
|
||||
)
|
||||
|
||||
config = {"uuid": "some-uuid", "theme_name": "hostile", "json_data": "{}"}
|
||||
|
||||
result = import_theme(config, overwrite=False)
|
||||
|
||||
assert result is existing
|
||||
@@ -214,46 +214,3 @@ def test_handle_filters_args_returns_request_scoped_filters(
|
||||
fresh_filters = api.datamodel.get_filters.return_value
|
||||
assert fresh_filters.rest_add_filters.call_count == 2
|
||||
assert fresh_filters.get_joined_filters.call_count == 2
|
||||
|
||||
|
||||
def test_post_dataset_with_invalid_sql_returns_actionable_422(
|
||||
session: Session,
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
) -> None:
|
||||
"""Saving a dataset over unrunnable SQL must explain what is wrong.
|
||||
|
||||
With blanket database access ``validate()`` never parses the SQL, so
|
||||
``run()``'s column introspection is the first thing to reject it. That
|
||||
used to surface as a bare 500 ``{"message": "Fatal error"}``.
|
||||
"""
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
from superset.models.core import Database
|
||||
|
||||
SqlaTable.metadata.create_all(db.session.get_bind())
|
||||
|
||||
database = Database(database_name="invalid_sql_db", sqlalchemy_uri="sqlite://")
|
||||
db.session.add(database)
|
||||
db.session.flush()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/dataset/",
|
||||
json={
|
||||
"database": database.id,
|
||||
"schema": "main",
|
||||
"table_name": "dataset wrong",
|
||||
"sql": "SELECT ...",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
message = response.json["message"]
|
||||
assert "Fatal error" not in str(message)
|
||||
# Not the parser's exact wording -- that would break on a sqlglot bump.
|
||||
assert message["sql"][0].startswith("Invalid SQL")
|
||||
|
||||
# The failed create must not leave a half-built dataset behind.
|
||||
assert (
|
||||
db.session.query(SqlaTable).filter_by(table_name="dataset wrong").one_or_none()
|
||||
is None
|
||||
)
|
||||
|
||||
@@ -36,6 +36,7 @@ from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticViewNotFoundError,
|
||||
SemanticViewUpdateFailedError,
|
||||
)
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.semantic_layers.api import SemanticLayerRestApi, SemanticViewRestApi
|
||||
|
||||
@@ -529,6 +530,34 @@ def test_runtime_schema_not_found(
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_runtime_schema_forbidden(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test POST /<uuid>/schema/runtime returns 403 when access is denied."""
|
||||
test_uuid = str(uuid_lib.uuid4())
|
||||
mock_layer = MagicMock()
|
||||
mock_layer.raise_for_access.side_effect = SupersetSecurityException(
|
||||
SupersetError(
|
||||
error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
|
||||
message="You don't have access to this semantic layer.",
|
||||
level=ErrorLevel.ERROR,
|
||||
)
|
||||
)
|
||||
|
||||
mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
|
||||
mock_dao.find_by_uuid.return_value = mock_layer
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/semantic_layer/{test_uuid}/schema/runtime",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
mock_layer.raise_for_access.assert_called_once()
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_runtime_schema_unknown_type(
|
||||
client: Any,
|
||||
@@ -729,6 +758,30 @@ def test_put_semantic_layer_not_found(
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_put_semantic_layer_forbidden(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test PUT /<uuid> returns 403 when the caller is not an editor."""
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerForbiddenError,
|
||||
)
|
||||
|
||||
mock_command = mocker.patch(
|
||||
"superset.semantic_layers.api.UpdateSemanticLayerCommand",
|
||||
)
|
||||
mock_command.return_value.run.side_effect = SemanticLayerForbiddenError()
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1/semantic_layer/{uuid_lib.uuid4()}",
|
||||
json={"name": "New"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_put_semantic_layer_invalid(
|
||||
client: Any,
|
||||
@@ -821,6 +874,27 @@ def test_delete_semantic_layer_not_found(
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_delete_semantic_layer_forbidden(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test DELETE /<uuid> returns 403 when the caller is not an editor."""
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerForbiddenError,
|
||||
)
|
||||
|
||||
mock_command = mocker.patch(
|
||||
"superset.semantic_layers.api.DeleteSemanticLayerCommand",
|
||||
)
|
||||
mock_command.return_value.run.side_effect = SemanticLayerForbiddenError()
|
||||
|
||||
response = client.delete(f"/api/v1/semantic_layer/{uuid_lib.uuid4()}")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_delete_semantic_layer_failed(
|
||||
client: Any,
|
||||
@@ -939,6 +1013,33 @@ def test_get_semantic_layer_not_found(
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_get_semantic_layer_forbidden(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test GET /<uuid> returns 403 when user lacks access to the layer."""
|
||||
test_uuid = uuid_lib.uuid4()
|
||||
layer = MagicMock()
|
||||
layer.uuid = test_uuid
|
||||
layer.raise_for_access.side_effect = SupersetSecurityException(
|
||||
SupersetError(
|
||||
error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
|
||||
message="You don't have access to this semantic layer.",
|
||||
level=ErrorLevel.ERROR,
|
||||
)
|
||||
)
|
||||
|
||||
mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
|
||||
mock_dao.find_by_uuid.return_value = layer
|
||||
|
||||
response = client.get(f"/api/v1/semantic_layer/{test_uuid}")
|
||||
|
||||
assert response.status_code == 403
|
||||
layer.raise_for_access.assert_called_once()
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_serialize_layer_string_config(
|
||||
client: Any,
|
||||
@@ -1645,6 +1746,35 @@ def test_post_semantic_view_layer_not_found(
|
||||
assert result["errors"][0]["error"] == "Semantic layer not found"
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_post_semantic_view_forbidden(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test POST / collects forbidden errors instead of aborting the batch."""
|
||||
mock_command = mocker.patch(
|
||||
"superset.semantic_layers.api.CreateSemanticViewCommand",
|
||||
)
|
||||
mock_command.return_value.run.side_effect = SemanticViewForbiddenError()
|
||||
|
||||
payload = {
|
||||
"views": [
|
||||
{
|
||||
"name": "View 1",
|
||||
"semantic_layer_uuid": str(uuid_lib.uuid4()),
|
||||
"configuration": {},
|
||||
},
|
||||
],
|
||||
}
|
||||
response = client.post("/api/v1/semantic_view/", json=payload)
|
||||
|
||||
assert response.status_code == 422
|
||||
result = response.json["result"]
|
||||
assert len(result["errors"]) == 1
|
||||
assert not result["created"]
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_post_semantic_view_create_failed(
|
||||
client: Any,
|
||||
@@ -1956,6 +2086,37 @@ def test_get_views(
|
||||
assert result[1]["name"] == "View B"
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_get_views_forbidden(
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test POST /<uuid>/views returns 403 when access is denied."""
|
||||
test_uuid = str(uuid_lib.uuid4())
|
||||
mock_layer = MagicMock()
|
||||
mock_layer.uuid = uuid_lib.uuid4()
|
||||
mock_layer.raise_for_access.side_effect = SupersetSecurityException(
|
||||
SupersetError(
|
||||
error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
|
||||
message="You don't have access to this semantic layer.",
|
||||
level=ErrorLevel.ERROR,
|
||||
)
|
||||
)
|
||||
|
||||
mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
|
||||
mock_dao.find_by_uuid.return_value = mock_layer
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/semantic_layer/{test_uuid}/views",
|
||||
json={"runtime_data": {"database": "mydb"}},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
mock_layer.raise_for_access.assert_called_once()
|
||||
mock_layer.implementation.get_semantic_views.assert_not_called()
|
||||
|
||||
|
||||
@SEMANTIC_LAYERS_APP
|
||||
def test_get_views_with_existing(
|
||||
client: Any,
|
||||
|
||||
@@ -1100,6 +1100,90 @@ def test_semantic_layer_get_perm_special_characters() -> None:
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SemanticLayer.raise_for_access tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_semantic_layer_raise_for_access_all_datasources(app: Any) -> None:
|
||||
"""Test raise_for_access passes when user has all_datasource_access."""
|
||||
from superset import security_manager
|
||||
|
||||
layer = SemanticLayer()
|
||||
layer.name = "Layer"
|
||||
layer.uuid = uuid.UUID("abcdef12-3456-7890-abcd-ef1234567890")
|
||||
layer.perm = layer.get_perm()
|
||||
|
||||
with patch.object(
|
||||
security_manager, "can_access_all_datasources", return_value=True
|
||||
):
|
||||
layer.raise_for_access()
|
||||
|
||||
|
||||
def test_semantic_layer_raise_for_access_perm(app: Any) -> None:
|
||||
"""Test raise_for_access passes when user has datasource_access to the
|
||||
layer's perm."""
|
||||
from superset import security_manager
|
||||
|
||||
layer = SemanticLayer()
|
||||
layer.name = "Layer"
|
||||
layer.uuid = uuid.UUID("abcdef12-3456-7890-abcd-ef1234567890")
|
||||
layer.perm = layer.get_perm()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
security_manager, "can_access_all_datasources", return_value=False
|
||||
),
|
||||
patch.object(
|
||||
security_manager, "can_access", return_value=True
|
||||
) as mock_can_access,
|
||||
):
|
||||
layer.raise_for_access()
|
||||
mock_can_access.assert_called_once_with("datasource_access", layer.perm)
|
||||
|
||||
|
||||
def test_semantic_layer_raise_for_access_denied(app: Any) -> None:
|
||||
"""Test raise_for_access raises SupersetSecurityException when denied."""
|
||||
from superset import security_manager
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
|
||||
layer = SemanticLayer()
|
||||
layer.name = "Layer"
|
||||
layer.uuid = uuid.UUID("abcdef12-3456-7890-abcd-ef1234567890")
|
||||
layer.perm = layer.get_perm()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
security_manager, "can_access_all_datasources", return_value=False
|
||||
),
|
||||
patch.object(security_manager, "can_access", return_value=False),
|
||||
):
|
||||
with pytest.raises(SupersetSecurityException):
|
||||
layer.raise_for_access()
|
||||
|
||||
|
||||
def test_semantic_layer_raise_for_access_no_perm_denied(app: Any) -> None:
|
||||
"""Test raise_for_access raises SupersetSecurityException when the layer
|
||||
has no perm set, without even attempting a datasource_access check."""
|
||||
from superset import security_manager
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
|
||||
layer = SemanticLayer()
|
||||
layer.name = "Layer"
|
||||
layer.uuid = uuid.UUID("abcdef12-3456-7890-abcd-ef1234567890")
|
||||
layer.perm = None
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
security_manager, "can_access_all_datasources", return_value=False
|
||||
),
|
||||
patch.object(security_manager, "can_access") as mock_can_access,
|
||||
):
|
||||
with pytest.raises(SupersetSecurityException):
|
||||
layer.raise_for_access()
|
||||
mock_can_access.assert_not_called()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SemanticView.raise_for_access tests
|
||||
# =============================================================================
|
||||
|
||||
@@ -20,6 +20,7 @@ from unittest.mock import Mock, patch
|
||||
import pytest
|
||||
|
||||
from superset.commands.theme.exceptions import (
|
||||
SystemThemeInUseError,
|
||||
SystemThemeProtectedError,
|
||||
ThemeNotFoundError,
|
||||
)
|
||||
@@ -62,6 +63,8 @@ class TestUpdateThemeCommand:
|
||||
# Arrange
|
||||
mock_theme = Mock(spec=Theme)
|
||||
mock_theme.is_system = False
|
||||
mock_theme.is_system_default = False
|
||||
mock_theme.is_system_dark = False
|
||||
mock_theme_dao.find_by_id.return_value = mock_theme
|
||||
command = UpdateThemeCommand(123, {"theme_name": "test"})
|
||||
|
||||
@@ -77,6 +80,8 @@ class TestUpdateThemeCommand:
|
||||
# Arrange
|
||||
mock_theme = Mock(spec=Theme)
|
||||
mock_theme.is_system = False
|
||||
mock_theme.is_system_default = False
|
||||
mock_theme.is_system_dark = False
|
||||
mock_updated_theme = Mock(spec=Theme)
|
||||
mock_theme_dao.find_by_id.return_value = mock_theme
|
||||
mock_theme_dao.update.return_value = mock_updated_theme
|
||||
@@ -92,6 +97,52 @@ class TestUpdateThemeCommand:
|
||||
mock_theme, {"theme_name": "updated_name"}
|
||||
)
|
||||
|
||||
@patch("superset.commands.theme.update.security_manager")
|
||||
@patch("superset.commands.theme.update.ThemeDAO")
|
||||
def test_validate_system_default_theme_blocks_non_admin(
|
||||
self, mock_theme_dao, mock_security_manager
|
||||
):
|
||||
"""Non-admins cannot update the active system-default/dark theme slot."""
|
||||
mock_theme = Mock(spec=Theme)
|
||||
mock_theme.is_system = False
|
||||
mock_theme.is_system_default = True
|
||||
mock_theme.is_system_dark = False
|
||||
mock_theme_dao.find_by_id.return_value = mock_theme
|
||||
# Use a regular Mock for is_admin to avoid AsyncMock auto-detection
|
||||
mock_security_manager.is_admin = Mock(return_value=False)
|
||||
|
||||
command = UpdateThemeCommand(123, {"json_data": "{}"})
|
||||
|
||||
with pytest.raises(SystemThemeInUseError):
|
||||
command.validate()
|
||||
|
||||
@patch("superset.commands.theme.update.security_manager")
|
||||
@patch("superset.commands.theme.update.ThemeDAO")
|
||||
def test_validate_system_default_theme_allows_admin(
|
||||
self, mock_theme_dao, mock_security_manager
|
||||
):
|
||||
"""Admins can still update the active system-default/dark theme slot."""
|
||||
mock_theme = Mock(spec=Theme)
|
||||
mock_theme.is_system = False
|
||||
mock_theme.is_system_default = True
|
||||
mock_theme.is_system_dark = False
|
||||
mock_theme_dao.find_by_id.return_value = mock_theme
|
||||
# Use a regular Mock for is_admin to avoid AsyncMock auto-detection
|
||||
mock_security_manager.is_admin = Mock(return_value=True)
|
||||
|
||||
command = UpdateThemeCommand(123, {"json_data": "{}"})
|
||||
|
||||
command.validate() # Should not raise any exception
|
||||
|
||||
assert command._model == mock_theme
|
||||
|
||||
|
||||
def test_theme_is_gamma_read_only():
|
||||
"""Theme writes must require at least Alpha; Gamma only gets read access."""
|
||||
from superset import security_manager
|
||||
|
||||
assert "Theme" in security_manager.GAMMA_READ_ONLY_MODEL_VIEWS
|
||||
|
||||
|
||||
class TestSeedSystemThemesCommand:
|
||||
"""Unit tests for SeedSystemThemesCommand"""
|
||||
|
||||
@@ -1942,13 +1942,29 @@ def test_sanitize_svg_content_safe():
|
||||
|
||||
|
||||
def test_sanitize_svg_content_removes_scripts():
|
||||
"""Test that nh3 removes dangerous script content."""
|
||||
"""Test that dangerous script content is removed."""
|
||||
malicious_svg = '<svg><script>alert("xss")</script><rect/></svg>'
|
||||
result = sanitize_svg_content(malicious_svg)
|
||||
assert "script" not in result.lower()
|
||||
assert "alert" not in result
|
||||
|
||||
|
||||
def test_sanitize_svg_content_removes_script_with_attributes_on_closer():
|
||||
"""A closing </script foo> tag is still a valid closer to browsers."""
|
||||
malicious_svg = "<svg><script>fetch('/api/v1/me/')</script foo></svg>"
|
||||
result = sanitize_svg_content(malicious_svg)
|
||||
assert "script" not in result.lower()
|
||||
assert "fetch" not in result
|
||||
|
||||
|
||||
def test_sanitize_svg_content_removes_unterminated_script():
|
||||
"""An unterminated <script> opener with no closing tag is still stripped."""
|
||||
malicious_svg = "<svg><script>alert('xss')"
|
||||
result = sanitize_svg_content(malicious_svg)
|
||||
assert "script" not in result.lower()
|
||||
assert "alert" not in result
|
||||
|
||||
|
||||
def test_sanitize_url_relative():
|
||||
"""Test that relative URLs are allowed."""
|
||||
assert sanitize_url("/static/spinner.gif") == "/static/spinner.gif"
|
||||
|
||||
Reference in New Issue
Block a user