mirror of
https://github.com/apache/superset.git
synced 2026-09-01 13:01:33 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c722f405a4 | ||
|
|
b930612a47 | ||
|
|
7ad5726717 | ||
|
|
c3ed8b312d |
+30
-1
@@ -779,6 +779,35 @@ function EditorsSelector({
|
||||
const ResultTable =
|
||||
extensionsRegistry.get('sqleditor.extension.resultTable') ?? FilterableTable;
|
||||
|
||||
// D3's '%' type is a valid spec that multiplies by 100, so it never trips
|
||||
// the "Invalid format" fallback even when applied to a raw count.
|
||||
const isPercentD3Format = (d3format?: string): boolean =>
|
||||
!!d3format && d3format.trim().endsWith('%');
|
||||
|
||||
const isCountExpression = (expression?: string): boolean =>
|
||||
!!expression && /^\s*count\s*\(/i.test(expression);
|
||||
|
||||
function renderMetricFormatWarning(item: Record<string, any>): ReactNode {
|
||||
if (
|
||||
!isCountExpression(item.expression) ||
|
||||
!isPercentD3Format(item.d3format)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Alert
|
||||
css={themeParam => ({ marginBottom: themeParam.sizeUnit * 4 })}
|
||||
type="warning"
|
||||
showIcon
|
||||
message={t(
|
||||
'This metric is a count, but its D3 format is a percentage. ' +
|
||||
'Percent formats multiply the value by 100, which will make a ' +
|
||||
'raw count render as a misleadingly large number.',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Redux connector types
|
||||
interface QueryPayload {
|
||||
client_id?: string;
|
||||
@@ -2140,7 +2169,7 @@ function DatasourceEditor({
|
||||
}}
|
||||
expandFieldset={
|
||||
<FormContainer>
|
||||
<Fieldset compact>
|
||||
<Fieldset compact renderWarning={renderMetricFormatWarning}>
|
||||
<Field
|
||||
fieldKey="expression"
|
||||
label={t('SQL expression')}
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { screen, userEvent, waitFor } from 'spec/helpers/testing-library';
|
||||
import {
|
||||
createProps,
|
||||
DATASOURCE_ENDPOINT,
|
||||
setupDatasourceEditorMocks,
|
||||
cleanupAsyncOperations,
|
||||
fastRender,
|
||||
dismissDatasourceWarning,
|
||||
} from './DatasourceEditor.test.utils';
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.get(DATASOURCE_ENDPOINT, [], { name: DATASOURCE_ENDPOINT });
|
||||
setupDatasourceEditorMocks();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupAsyncOperations();
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
});
|
||||
|
||||
const WARNING_TEXT = /D3 format is a percentage/i;
|
||||
|
||||
// A '%' format is valid syntax, so it never hits the "Invalid format" fallback.
|
||||
test('warns when a percent D3 format is set on a COUNT metric', async () => {
|
||||
const testProps = createProps();
|
||||
fastRender(testProps);
|
||||
await dismissDatasourceWarning();
|
||||
|
||||
await userEvent.click(await screen.findByTestId('collection-tab-Metrics'));
|
||||
const expandToggles = await screen.findAllByLabelText(/expand row/i);
|
||||
// Rows sort by metric id descending, so `COUNT(*)` (id 7) is first.
|
||||
await userEvent.click(expandToggles[0]);
|
||||
|
||||
expect(screen.queryByText(WARNING_TEXT)).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.type(await screen.findByPlaceholderText('%y/%m/%d'), '.0%');
|
||||
|
||||
expect(await screen.findByText(WARNING_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not warn for a non-percent format on a COUNT metric', async () => {
|
||||
const testProps = createProps();
|
||||
fastRender(testProps);
|
||||
await dismissDatasourceWarning();
|
||||
|
||||
await userEvent.click(await screen.findByTestId('collection-tab-Metrics'));
|
||||
const expandToggles = await screen.findAllByLabelText(/expand row/i);
|
||||
await userEvent.click(expandToggles[0]);
|
||||
|
||||
await userEvent.type(await screen.findByPlaceholderText('%y/%m/%d'), ',.0f');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(WARNING_TEXT)).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
test('does not warn for a percent format on a non-COUNT metric', async () => {
|
||||
const testProps = createProps();
|
||||
fastRender(testProps);
|
||||
await dismissDatasourceWarning();
|
||||
|
||||
await userEvent.click(await screen.findByTestId('collection-tab-Metrics'));
|
||||
const expandToggles = await screen.findAllByLabelText(/expand row/i);
|
||||
// Rows sort by metric id descending, so id 1 (`SUM(...)`) sorts last.
|
||||
await userEvent.click(expandToggles[6]);
|
||||
|
||||
await userEvent.type(await screen.findByPlaceholderText('%y/%m/%d'), '.0%');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(WARNING_TEXT)).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
@@ -28,6 +28,7 @@ export interface FieldsetProps {
|
||||
item?: Record<string, any>;
|
||||
title?: ReactNode;
|
||||
compact?: boolean;
|
||||
renderWarning?: (item: Record<string, any>) => ReactNode;
|
||||
}
|
||||
|
||||
type fieldKeyType = string | number;
|
||||
@@ -38,6 +39,7 @@ export default function Fieldset({
|
||||
item = {},
|
||||
title = null,
|
||||
compact = false,
|
||||
renderWarning,
|
||||
}: FieldsetProps) {
|
||||
// Controls report their edits asynchronously - TextControl debounces by
|
||||
// FAST_DEBOUNCE - so the callback that eventually fires was built during an
|
||||
@@ -78,6 +80,7 @@ export default function Fieldset({
|
||||
</Typography.Title>
|
||||
)}
|
||||
|
||||
{renderWarning?.(item)}
|
||||
{recurseReactClone(children, Field, propExtender)}
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -259,6 +259,21 @@ describe('isUserEditorOrAdmin', () => {
|
||||
test('returns false when editors is omitted', () => {
|
||||
expect(isUserEditorOrAdmin(outsiderUser)).toEqual(false);
|
||||
});
|
||||
|
||||
test('returns true when the user is granted editorship only through extra_editors', () => {
|
||||
expect(isUserEditorOrAdmin(editorUser, [], [10])).toEqual(true);
|
||||
});
|
||||
|
||||
test('unions editors and extra_editors rather than preferring one', () => {
|
||||
const nonMatchingSubject: Subject = { id: 999, label: 'Other', type: 1 };
|
||||
expect(isUserEditorOrAdmin(editorUser, [nonMatchingSubject], [10])).toEqual(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('returns false when extra_editors names other subjects', () => {
|
||||
expect(isUserEditorOrAdmin(editorUser, [], [999])).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
|
||||
@@ -55,9 +55,6 @@ export const isUserInSubjects = (
|
||||
);
|
||||
};
|
||||
|
||||
const isUserInEditors = (editors: Subject[] = []): boolean =>
|
||||
isUserInSubjects(editors);
|
||||
|
||||
export const isUserAdmin = (
|
||||
user?: UserWithPermissionsAndRoles | UndefinedUser,
|
||||
) =>
|
||||
@@ -66,10 +63,12 @@ export const isUserAdmin = (
|
||||
role => role.toLowerCase() === ADMIN_ROLE_NAME.toLowerCase(),
|
||||
);
|
||||
|
||||
/** `extraEditors` is editorship granted via a deployment's EXTRA_EDITORS_RESOLVER. */
|
||||
export const isUserEditorOrAdmin = (
|
||||
user?: UserWithPermissionsAndRoles | UndefinedUser,
|
||||
editors: Subject[] = [],
|
||||
): boolean => isUserInEditors(editors) || isUserAdmin(user);
|
||||
extraEditors?: SubjectRef[] | null,
|
||||
): boolean => isUserInSubjects(editors, extraEditors) || isUserAdmin(user);
|
||||
|
||||
/**
|
||||
* Editorship of *dashboard*, matching the server's `is_editor`: the explicit
|
||||
|
||||
+6
-1
@@ -88,7 +88,12 @@ export const ResultsPaneOnDashboard = ({
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Tabs activeKey={activeTabKey} onChange={setActiveTabKey} items={items} />
|
||||
<Tabs
|
||||
fullHeight
|
||||
activeKey={activeTabKey}
|
||||
onChange={setActiveTabKey}
|
||||
items={items}
|
||||
/>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -96,7 +96,11 @@ export default function ChartCard({
|
||||
const canEdit = hasPerm('can_write');
|
||||
const canDelete = hasPerm('can_write');
|
||||
const canExport = hasPerm('can_export');
|
||||
const allowEdit = isUserEditorOrAdmin(user, chart.editors);
|
||||
const allowEdit = isUserEditorOrAdmin(
|
||||
user,
|
||||
chart.editors,
|
||||
chart.extra_editors,
|
||||
);
|
||||
const menuItems: MenuItem[] = [];
|
||||
|
||||
if (canEdit) {
|
||||
|
||||
@@ -83,7 +83,11 @@ function DashboardCard({
|
||||
const canEdit = hasPerm('can_write');
|
||||
const canDelete = hasPerm('can_write');
|
||||
const canExport = hasPerm('can_export');
|
||||
const allowEdit = isUserEditorOrAdmin(user, dashboard.editors);
|
||||
const allowEdit = isUserEditorOrAdmin(
|
||||
user,
|
||||
dashboard.editors,
|
||||
dashboard.extra_editors,
|
||||
);
|
||||
const digest = dashboard.changed_on_utc || dashboard.changed_on;
|
||||
const thumbnailUrl =
|
||||
isFeatureEnabled(FeatureFlag.Thumbnails) && dashboard.id && digest
|
||||
|
||||
@@ -650,7 +650,11 @@ function ChartList(props: ChartListProps) {
|
||||
},
|
||||
{
|
||||
Cell: ({ row: { original } }: CellProps<Chart>) => {
|
||||
const allowEdit = isUserEditorOrAdmin(user, original.editors);
|
||||
const allowEdit = isUserEditorOrAdmin(
|
||||
user,
|
||||
original.editors,
|
||||
original.extra_editors,
|
||||
);
|
||||
const openEditModal = () => openChartEditModal(original);
|
||||
const handleExport = () => handleBulkChartExport([original]);
|
||||
if (!canEdit && !canDelete && !canExport) {
|
||||
|
||||
@@ -122,6 +122,8 @@ export interface Dashboard {
|
||||
description?: string;
|
||||
thumbnail_url?: string | null;
|
||||
editors?: Subject[];
|
||||
// Bare subject ids from a deployment's EXTRA_EDITORS_RESOLVER.
|
||||
extra_editors?: number[];
|
||||
viewers?: Subject[];
|
||||
tags: TagType[];
|
||||
created_by: object;
|
||||
@@ -505,7 +507,11 @@ function DashboardList(props: DashboardListProps) {
|
||||
},
|
||||
{
|
||||
Cell: ({ row: { original } }: CellProps<Dashboard>) => {
|
||||
const allowEdit = isUserEditorOrAdmin(user, original.editors);
|
||||
const allowEdit = isUserEditorOrAdmin(
|
||||
user,
|
||||
original.editors,
|
||||
original.extra_editors,
|
||||
);
|
||||
const handleDelete = () =>
|
||||
handleDashboardDelete(
|
||||
original,
|
||||
|
||||
@@ -45,6 +45,8 @@ export interface Chart {
|
||||
cache_timeout: number | null;
|
||||
thumbnail_url?: string;
|
||||
editors?: Subject[];
|
||||
// Bare subject ids from a deployment's EXTRA_EDITORS_RESOLVER.
|
||||
extra_editors?: number[];
|
||||
viewers?: Subject[];
|
||||
tags?: TagType[];
|
||||
last_saved_at?: string;
|
||||
|
||||
@@ -67,6 +67,8 @@ export interface Dashboard {
|
||||
url: string;
|
||||
thumbnail_url?: string | null;
|
||||
editors?: Subject[];
|
||||
// Bare subject ids from a deployment's EXTRA_EDITORS_RESOLVER.
|
||||
extra_editors?: number[];
|
||||
viewers?: Subject[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
+13
-1
@@ -92,7 +92,10 @@ from superset.exceptions import (
|
||||
)
|
||||
from superset.extensions import event_logger, security_manager
|
||||
from superset.models.slice import Slice
|
||||
from superset.security.manager import get_extra_editor_subject_ids
|
||||
from superset.security.manager import (
|
||||
get_extra_editor_subject_ids,
|
||||
get_extra_editors_by_pk,
|
||||
)
|
||||
from superset.subjects.filters import (
|
||||
FilterRelatedSubjects,
|
||||
subject_type_filter,
|
||||
@@ -410,6 +413,15 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
except ChartNotFoundError:
|
||||
return self.response_404()
|
||||
|
||||
def pre_get_list(self, data: dict[str, Any]) -> None:
|
||||
"""Attach ``extra_editors`` to each row, matching the single-object GET."""
|
||||
super().pre_get_list(data)
|
||||
ids = data.get("ids", [])
|
||||
extra_editors_by_id = get_extra_editors_by_pk(Slice, ids)
|
||||
for row, row_id in zip(data.get("result", []), ids, strict=False):
|
||||
if row_id in extra_editors_by_id:
|
||||
row["extra_editors"] = extra_editors_by_id[row_id]
|
||||
|
||||
@expose("/<pk>/deck_layers/", methods=("GET",))
|
||||
@protect()
|
||||
@safe
|
||||
|
||||
@@ -142,7 +142,10 @@ from superset.extensions import event_logger, security_manager
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.models.embedded_dashboard import EmbeddedDashboard
|
||||
from superset.security.guest_token import GuestUser
|
||||
from superset.security.manager import get_extra_editor_subject_ids
|
||||
from superset.security.manager import (
|
||||
get_extra_editor_subject_ids,
|
||||
get_extra_editors_by_pk,
|
||||
)
|
||||
from superset.subjects.filters import (
|
||||
FilterRelatedSubjects,
|
||||
subject_type_filter,
|
||||
@@ -433,6 +436,15 @@ class DashboardRestApi(
|
||||
"""
|
||||
return super().get_list(**kwargs)
|
||||
|
||||
def pre_get_list(self, data: dict[str, Any]) -> None:
|
||||
"""Attach ``extra_editors`` to each row, matching the single-object GET."""
|
||||
super().pre_get_list(data)
|
||||
ids = data.get("ids", [])
|
||||
extra_editors_by_id = get_extra_editors_by_pk(Dashboard, ids)
|
||||
for row, row_id in zip(data.get("result", []), ids, strict=False):
|
||||
if row_id in extra_editors_by_id:
|
||||
row["extra_editors"] = extra_editors_by_id[row_id]
|
||||
|
||||
list_select_columns = list_columns + ["changed_on", "created_on", "changed_by_fk"]
|
||||
order_columns = [
|
||||
"changed_by.first_name",
|
||||
|
||||
+1
-12
@@ -63,10 +63,7 @@ from superset.reports.schemas import (
|
||||
ReportScheduleSubscribeSchema,
|
||||
)
|
||||
from superset.subjects.filters import FilterRelatedSubjects, subject_type_filter
|
||||
from superset.utils.slack import (
|
||||
get_channels_with_search,
|
||||
SlackChannelListingClientError,
|
||||
)
|
||||
from superset.utils.slack import get_channels_with_search
|
||||
from superset.views.base_api import (
|
||||
BaseSupersetModelRestApi,
|
||||
RelatedFieldFilter,
|
||||
@@ -719,15 +716,7 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi):
|
||||
start = page * page_size
|
||||
channels = channels[start : start + page_size]
|
||||
return self.response(200, count=count, result=channels)
|
||||
except SlackChannelListingClientError as ex:
|
||||
# Permanent token/client-setup failures are expected, already-handled
|
||||
# noise (e.g. a revoked bot token), so log at WARNING to keep Sentry
|
||||
# clear of an actionable-looking signal.
|
||||
logger.warning("Error fetching slack channels %s", str(ex))
|
||||
return self.response_422(message=str(ex))
|
||||
except SupersetException as ex:
|
||||
# Transient listing failures (rate limits, transport errors) mean
|
||||
# Slack is unavailable, so keep ERROR to preserve an actionable signal.
|
||||
logger.error("Error fetching slack channels %s", str(ex))
|
||||
return self.response_422(message=str(ex))
|
||||
|
||||
|
||||
@@ -170,6 +170,36 @@ def get_extra_editor_subject_ids(resource: Model) -> list[int]:
|
||||
return subject_ids
|
||||
|
||||
|
||||
def get_extra_editors_by_pk(
|
||||
model_cls: type[Model], primary_keys: list[Any]
|
||||
) -> dict[Any, list[int]]:
|
||||
"""
|
||||
Resolve extra editor subject IDs for a batch of resources, keyed by
|
||||
primary key. List responses only have serialized rows, not model
|
||||
instances, so this re-queries the page's rows in one batched query.
|
||||
"""
|
||||
if not primary_keys or not (
|
||||
has_app_context() and current_app.config.get("EXTRA_EDITORS_RESOLVER")
|
||||
):
|
||||
return {}
|
||||
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from superset import db
|
||||
from superset.models.helpers import SKIP_VISIBILITY_FILTER_CLASSES
|
||||
|
||||
pk_col = inspect(model_cls).primary_key[0]
|
||||
resources = (
|
||||
db.session.query(model_cls)
|
||||
.execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {model_cls}})
|
||||
.filter(pk_col.in_(primary_keys))
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
getattr(resource, pk_col.name): get_extra_editor_subject_ids(resource)
|
||||
for resource in resources
|
||||
}
|
||||
|
||||
|
||||
def _render_permission_instructions_link(
|
||||
*,
|
||||
datasource_id: str = "",
|
||||
|
||||
+6
-22
@@ -141,10 +141,6 @@ _TRANSIENT_SLACK_API_ERROR_CODES = frozenset(
|
||||
}
|
||||
)
|
||||
|
||||
_AUTH_ERROR_CODES = frozenset(
|
||||
{"not_authed", "invalid_auth", "account_inactive", "token_revoked", "token_expired"}
|
||||
)
|
||||
|
||||
SLACK_TRANSIENT_TRANSPORT_ERRORS: tuple[type[Exception], ...] = (
|
||||
SlackClientNotConnectedError,
|
||||
URLError,
|
||||
@@ -396,24 +392,12 @@ def _get_channels(team_id: Optional[str] = None) -> list[SlackChannel]:
|
||||
)
|
||||
return channels
|
||||
except SlackApiError as ex:
|
||||
# Only bot-token auth failures (invalid/revoked/deactivated) are the
|
||||
# expected, already-handled multi-tenant condition this is meant to
|
||||
# quiet down. Rate limits and Slack server/API errors are actionable
|
||||
# outages, so they keep ERROR-level logging with a traceback.
|
||||
error_code = get_slack_api_error_code(ex)
|
||||
if error_code in _AUTH_ERROR_CODES:
|
||||
logger.warning(
|
||||
"Failed to fetch Slack channels after %d pages: %s",
|
||||
page_count,
|
||||
str(ex),
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"Failed to fetch Slack channels after %d pages: %s",
|
||||
page_count,
|
||||
str(ex),
|
||||
exc_info=True,
|
||||
)
|
||||
logger.error(
|
||||
"Failed to fetch Slack channels after %d pages: %s",
|
||||
page_count,
|
||||
str(ex),
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
|
||||
@@ -922,6 +922,55 @@ class TestDashboardApi(ApiEditorsTestCaseMixin, InsertChartMixin, SupersetTestCa
|
||||
db.session.delete(dashboard)
|
||||
db.session.commit()
|
||||
|
||||
def test_get_dashboards_list_omits_extra_editors_by_default(self):
|
||||
"""No EXTRA_EDITORS_RESOLVER configured: list rows omit extra_editors."""
|
||||
admin = self.get_user("admin")
|
||||
dashboard = self.insert_dashboard(
|
||||
"no_extra_editors_list_dashboard",
|
||||
"no-extra-editors-list-dashboard",
|
||||
[admin.id],
|
||||
)
|
||||
try:
|
||||
self.login(ADMIN_USERNAME)
|
||||
rv = self.client.get("api/v1/dashboard/")
|
||||
assert rv.status_code == 200
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
row = next(
|
||||
d
|
||||
for d in data["result"]
|
||||
if d["dashboard_title"] == dashboard.dashboard_title
|
||||
)
|
||||
assert "extra_editors" not in row
|
||||
finally:
|
||||
db.session.delete(dashboard)
|
||||
db.session.commit()
|
||||
|
||||
@with_config({"EXTRA_EDITORS_RESOLVER": lambda resource: [123]})
|
||||
def test_get_dashboards_list_includes_extra_editors_when_resolver_configured(
|
||||
self,
|
||||
):
|
||||
"""List rows get extra_editors too, mirroring the single-object GET."""
|
||||
admin = self.get_user("admin")
|
||||
dashboard = self.insert_dashboard(
|
||||
"extra_editors_list_dashboard",
|
||||
"extra-editors-list-dashboard",
|
||||
[admin.id],
|
||||
)
|
||||
try:
|
||||
self.login(ADMIN_USERNAME)
|
||||
rv = self.client.get("api/v1/dashboard/")
|
||||
assert rv.status_code == 200
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
row = next(
|
||||
d
|
||||
for d in data["result"]
|
||||
if d["dashboard_title"] == dashboard.dashboard_title
|
||||
)
|
||||
assert row["extra_editors"] == [123]
|
||||
finally:
|
||||
db.session.delete(dashboard)
|
||||
db.session.commit()
|
||||
|
||||
def test_get_charts_admin_sees_existing_charts(self):
|
||||
"""Regression for #25890: GET /api/v1/chart/ as an Admin user should
|
||||
return existing charts, not an empty list."""
|
||||
@@ -944,6 +993,41 @@ class TestDashboardApi(ApiEditorsTestCaseMixin, InsertChartMixin, SupersetTestCa
|
||||
db.session.delete(chart)
|
||||
db.session.commit()
|
||||
|
||||
def test_get_charts_list_omits_extra_editors_by_default(self):
|
||||
"""No EXTRA_EDITORS_RESOLVER configured: list rows omit extra_editors."""
|
||||
admin = self.get_user("admin")
|
||||
chart = self.insert_chart(
|
||||
"no_extra_editors_list_chart", [admin.id], 1, params="{}"
|
||||
)
|
||||
try:
|
||||
self.login(ADMIN_USERNAME)
|
||||
rv = self.client.get("api/v1/chart/")
|
||||
assert rv.status_code == 200
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
row = next(c for c in data["result"] if c["slice_name"] == chart.slice_name)
|
||||
assert "extra_editors" not in row
|
||||
finally:
|
||||
db.session.delete(chart)
|
||||
db.session.commit()
|
||||
|
||||
@with_config({"EXTRA_EDITORS_RESOLVER": lambda resource: [123]})
|
||||
def test_get_charts_list_includes_extra_editors_when_resolver_configured(self):
|
||||
"""List rows get extra_editors too, mirroring the single-object GET."""
|
||||
admin = self.get_user("admin")
|
||||
chart = self.insert_chart(
|
||||
"extra_editors_list_chart", [admin.id], 1, params="{}"
|
||||
)
|
||||
try:
|
||||
self.login(ADMIN_USERNAME)
|
||||
rv = self.client.get("api/v1/chart/")
|
||||
assert rv.status_code == 200
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
row = next(c for c in data["result"] if c["slice_name"] == chart.slice_name)
|
||||
assert row["extra_editors"] == [123]
|
||||
finally:
|
||||
db.session.delete(chart)
|
||||
db.session.commit()
|
||||
|
||||
def test_get_dashboards_filter(self):
|
||||
"""
|
||||
Dashboard API: Test get dashboards filter
|
||||
|
||||
@@ -19,10 +19,7 @@ from unittest.mock import patch
|
||||
|
||||
import rison
|
||||
|
||||
from superset.utils.slack import (
|
||||
SlackChannelListingClientError,
|
||||
SlackChannelListingError,
|
||||
)
|
||||
from superset.exceptions import SupersetException
|
||||
from tests.unit_tests.conftest import with_feature_flags
|
||||
|
||||
|
||||
@@ -83,43 +80,14 @@ def test_slack_channels_page_without_page_size_returns_all(
|
||||
|
||||
|
||||
@with_feature_flags(ALERT_REPORTS=True)
|
||||
@patch("superset.reports.api.logger")
|
||||
@patch("superset.reports.api.get_channels_with_search")
|
||||
def test_slack_channels_client_error_logs_warning(
|
||||
def test_slack_channels_handles_superset_exception(
|
||||
mock_search: Any,
|
||||
logger_mock: Any,
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
) -> None:
|
||||
# A permanent token/client-setup failure (e.g. a revoked bot token) is
|
||||
# expected, already-handled noise, so it must be logged at WARNING, not
|
||||
# ERROR, to avoid polluting Sentry with an actionable-looking signal.
|
||||
mock_search.side_effect = SlackChannelListingClientError("Slack API error")
|
||||
mock_search.side_effect = SupersetException("Slack API error")
|
||||
params = rison.dumps({})
|
||||
rv = client.get(f"/api/v1/report/slack_channels/?q={params}")
|
||||
assert rv.status_code == 422
|
||||
assert "Slack API error" in rv.json["message"]
|
||||
logger_mock.error.assert_not_called()
|
||||
logger_mock.warning.assert_called_once()
|
||||
assert "Slack API error" in logger_mock.warning.call_args.args[1]
|
||||
|
||||
|
||||
@with_feature_flags(ALERT_REPORTS=True)
|
||||
@patch("superset.reports.api.logger")
|
||||
@patch("superset.reports.api.get_channels_with_search")
|
||||
def test_slack_channels_transient_error_logs_error(
|
||||
mock_search: Any,
|
||||
logger_mock: Any,
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
) -> None:
|
||||
# A transient listing failure (rate limits, transport errors) means Slack is
|
||||
# unavailable, so it must stay ERROR to preserve an actionable signal.
|
||||
mock_search.side_effect = SlackChannelListingError("Slack API error")
|
||||
params = rison.dumps({})
|
||||
rv = client.get(f"/api/v1/report/slack_channels/?q={params}")
|
||||
assert rv.status_code == 422
|
||||
assert "Slack API error" in rv.json["message"]
|
||||
logger_mock.warning.assert_not_called()
|
||||
logger_mock.error.assert_called_once()
|
||||
assert "Slack API error" in logger_mock.error.call_args.args[1]
|
||||
|
||||
@@ -237,62 +237,6 @@ class TestGetChannelsWithSearch:
|
||||
The server responded with: missing scope: channels:read"""
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error_code",
|
||||
[
|
||||
"not_authed",
|
||||
"invalid_auth",
|
||||
"account_inactive",
|
||||
"token_revoked",
|
||||
"token_expired",
|
||||
],
|
||||
)
|
||||
def test_logs_slack_api_error_at_warning_not_error(self, error_code: str, mocker):
|
||||
"""An expired/revoked/inactive bot token is an expected multi-tenant
|
||||
config state that is already handled end-to-end (re-raised as a
|
||||
``SupersetException`` and turned into a 422), so it should be logged
|
||||
at WARNING, not ERROR, to avoid polluting Sentry."""
|
||||
from superset.exceptions import SupersetException
|
||||
|
||||
mock_client = mocker.Mock()
|
||||
mock_client.conversations_list.side_effect = SlackApiError(
|
||||
message="foo", response={"ok": False, "error": error_code}
|
||||
)
|
||||
mocker.patch("superset.utils.slack.get_slack_client", return_value=mock_client)
|
||||
logger_mock = mocker.patch("superset.utils.slack.logger")
|
||||
|
||||
with pytest.raises(SupersetException):
|
||||
get_channels_with_search()
|
||||
|
||||
logger_mock.error.assert_not_called()
|
||||
logger_mock.warning.assert_called_once()
|
||||
assert "Failed to fetch Slack channels" in logger_mock.warning.call_args.args[0]
|
||||
assert not logger_mock.warning.call_args.kwargs.get("exc_info")
|
||||
|
||||
@pytest.mark.parametrize("error_code", ["ratelimited", "internal_error", ""])
|
||||
def test_logs_non_auth_slack_api_error_at_error_with_traceback(
|
||||
self, error_code: str, mocker
|
||||
):
|
||||
"""Rate limits and Slack server/API errors are actionable outages, not
|
||||
the expected auth-noise condition — they must keep ERROR-level logging
|
||||
with a traceback so they still generate a Sentry event."""
|
||||
from superset.exceptions import SupersetException
|
||||
|
||||
mock_client = mocker.Mock()
|
||||
mock_client.conversations_list.side_effect = SlackApiError(
|
||||
message="foo", response={"ok": False, "error": error_code}
|
||||
)
|
||||
mocker.patch("superset.utils.slack.get_slack_client", return_value=mock_client)
|
||||
logger_mock = mocker.patch("superset.utils.slack.logger")
|
||||
|
||||
with pytest.raises(SupersetException):
|
||||
get_channels_with_search()
|
||||
|
||||
logger_mock.warning.assert_not_called()
|
||||
logger_mock.error.assert_called_once()
|
||||
assert "Failed to fetch Slack channels" in logger_mock.error.call_args.args[0]
|
||||
assert logger_mock.error.call_args.kwargs.get("exc_info") is True
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error_code", "expected_exception"),
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user