Compare commits

..
Author SHA1 Message Date
Superset Dev 7ebd6353c8 test(themes): add the parallel is_system_dark admin-overwrite case
Mirrors the existing is_system_default test for full branch coverage
of the admin carve-out.
2026-08-21 13:30:45 -07:00
Superset DevandClaude Sonnet 5 8d53c29092 fix(semantic_layers): allow the creator of a layer/view to modify it
SemanticLayer and SemanticView have no `editors` relationship, so
raise_for_editorship rejected every non-admin caller unconditionally,
including a layer's own creator. Replace the raise_for_editorship calls
in the semantic-layer create/update/delete commands with
current_user_can_modify_object, a helper that falls back to comparing
the resource's created_by against the current user when the resource
has no editors to check.

The helper is moved from commands/tag/utils.py (where it was added for
tag-object writes) to the shared commands/utils.py so both the tag and
semantic-layer commands can use it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 12:51:37 -07:00
rusackasandClaude Opus 4.8 3c209290af fix: document 403 responses on semantic-view endpoints; close unterminated-script XSS gap in SVG sanitizer
- Add the missing 403 OpenAPI response to the semantic-view structure and
  delete endpoints, matching the raise_for_access/response_403 calls they
  already make.
- Fix sanitize_svg_content to drop everything after an unterminated
  <script> opener instead of just the tag itself, since browsers treat an
  unterminated <script> as consuming the rest of the document. Strengthen
  the regression test to assert the payload text is gone too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 10:48:10 -07:00
Superset DevandClaude Sonnet 5 cb2e4bbb7d fix(semantic_layers,themes): add coverage for raise_for_access, fix stale permissions test, allow admin theme import overwrite
- Add unit tests for SemanticLayer.raise_for_access (all-datasources,
  perm-granted, perm-denied, no-perm-denied paths) to satisfy the
  100%-coverage gate on superset/semantic_layers.
- Update test_non_admin_cannot_set_system_default to log in as an Alpha
  user, since Theme was moved into GAMMA_READ_ONLY_MODEL_VIEWS: Gamma
  now gets rejected at the route-permission layer with FAB's generic
  message before reaching the admin-only check, while Alpha (which still
  has generic Theme write access) reaches the check and gets the specific
  message. Add a companion test asserting Gamma is blocked outright.
- Mirror UpdateThemeCommand's admin carve-out in import_theme(): only
  reject overwriting the active system-default/dark theme via import for
  non-admins, so admins can still re-import the theme rendered for every
  user, same as they can update it directly.
- Document the 403 response on the semantic-layer endpoints that gained
  a raise_for_access()/raise_for_editorship() check, per review feedback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 10:00:41 -07:00
Superset DevandClaude Sonnet 5 3f645b444b fix(themes): block import overwrite of system-slotted themes
import_theme's overwrite branch only checked can_write('Theme') before
replacing an existing theme matched by UUID, unlike UpdateThemeCommand and
DeleteThemeCommand which both refuse to touch system-protected themes.
Since seeded themes have stable, publicly-listable UUIDs, an import
bundle with overwrite=true could replace a system theme, or the theme
currently in the system-default/dark slot, bypassing the protection the
direct update/delete routes already enforce. Raise ThemeImportError
before binding the existing row's id when is_system, is_system_default,
or is_system_dark is set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 01:00:52 -07:00
Superset DevandClaude Sonnet 5 fae62663cb fix(themes): require Alpha role for theme writes, protect the active system theme, and harden script-tag stripping
Theme was missing from GAMMA_READ_ONLY_MODEL_VIEWS, so default Gamma role
sync granted full Theme CRUD instead of read-only access like the sibling
CssTemplate resource; move Theme writes to Alpha+ to match.

UpdateThemeCommand.validate only rejected edits to is_system themes, not
the theme currently promoted to the live system-default/dark slot
(is_system_default/is_system_dark), which SetSystemDefaultThemeCommand
never marks is_system. Add a non-admin guard on those flags so only an
admin can edit the theme actively rendered for every user; map the new
SystemThemeInUseError to a 403 in the PUT route.

sanitize_svg_content's script-tag regex only matched a bare literal
</script> closer, so a closing tag with trailing attributes/whitespace
(e.g. </script foo>), which browsers still treat as a valid closer,
survived removal. Make the closing-tag pattern tolerant of that, and add
a second pass that strips any leftover <script>/</script> fragment,
including an unterminated opener with no matching closer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 00:59:49 -07:00
Superset DevandClaude Sonnet 5 6430db9517 fix(semantic_layers): require editorship before updating or deleting a layer
Add security_manager.raise_for_editorship() checks to
UpdateSemanticLayerCommand.validate, DeleteSemanticLayerCommand.validate,
and CreateSemanticViewCommand.validate, mirroring the pattern already used
by the sibling Update/Delete/BulkDeleteSemanticViewCommand classes.
Previously these commands only checked existence/uniqueness, so anyone with
generic write access to semantic layers could modify or delete any layer.
Wire the new SemanticLayerForbiddenError into 403 responses on the layer
PUT/DELETE routes, and handle SemanticViewForbiddenError per-item in the
bulk semantic-view create loop so one forbidden item doesn't abort the
whole batch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 00:47:52 -07:00
Superset DevandClaude Sonnet 5 176257e42a fix(semantic_layers): check access before returning a layer's configuration
Add SemanticLayer.raise_for_access(), mirroring the existing
SemanticView.raise_for_access(), and call it from the layer GET, runtime
schema, and views endpoints before returning or exercising a layer's
decrypted configuration. Previously these endpoints resolved the layer by
UUID with no per-object permission check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 00:38:48 -07:00
40 changed files with 1373 additions and 594 deletions
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+68 -77
View File
@@ -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",
+6 -6
View File
@@ -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",
@@ -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))
+2 -24
View File
@@ -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
+7 -1
View File
@@ -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):
+9 -10
View File
@@ -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()
+7 -6
View File
@@ -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}")
+2 -5
View File
@@ -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
+2 -5
View File
@@ -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
-21
View File
@@ -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
)
+12
View File
@@ -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(
+10
View File
@@ -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()
+25
View File
@@ -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,
+1
View File
@@ -1408,6 +1408,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
"CssTemplate",
"Dataset",
"Datasource",
"Theme",
} | READ_ONLY_MODEL_VIEWS
GAMMA_EXCLUDED_PVMS = {
+37
View File
@@ -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))
+20
View File
@@ -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,
+2
View File
@@ -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
View File
@@ -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 (
+59
View File
@@ -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
-43
View File
@@ -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
# =============================================================================
+51
View File
@@ -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"""
+17 -1
View File
@@ -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"