diff --git a/superset-frontend/src/features/reports/ReportModal/ReportModal.test.tsx b/superset-frontend/src/features/reports/ReportModal/ReportModal.test.tsx index 5fad71c7ad6..80cecb18c56 100644 --- a/superset-frontend/src/features/reports/ReportModal/ReportModal.test.tsx +++ b/superset-frontend/src/features/reports/ReportModal/ReportModal.test.tsx @@ -103,10 +103,12 @@ test('does not allow user to create a report without a name', () => { }); test('creates a new email report via modal Add button', async () => { + // The modal now calls POST /api/v1/report/subscribe; creation_method, owners, and + // recipients are derived server-side — the client payload intentionally omits them. fetchMock.post( - REPORT_ENDPOINT, + 'glob:*/api/v1/report/subscribe', { id: 1, result: {} }, - { name: 'post-report' }, + { name: 'post-subscribe' }, ); render(, { useRedux: true }); @@ -114,22 +116,22 @@ test('creates a new email report via modal Add button', async () => { const addButton = screen.getByRole('button', { name: /add/i }); await waitFor(() => userEvent.click(addButton)); - // Verify exactly one POST from the modal submit path + // Verify exactly one POST to the subscribe endpoint await waitFor(() => { - const postCalls = fetchMock.callHistory.calls('post-report'); + const postCalls = fetchMock.callHistory.calls('post-subscribe'); expect(postCalls).toHaveLength(1); }); - const postCalls = fetchMock.callHistory.calls('post-report'); + const postCalls = fetchMock.callHistory.calls('post-subscribe'); const body = JSON.parse(postCalls[0].options.body as string); expect(body.name).toBe('Weekly Report'); expect(body.type).toBe('Report'); - expect(body.creation_method).toBe('dashboards'); expect(body.crontab).toBeDefined(); - expect(body.recipients).toBeDefined(); - expect(body.recipients[0].type).toBe('Email'); + // creation_method, owners, and recipients are set server-side; not in the client payload + expect(body.creation_method).toBeUndefined(); + expect(body.recipients).toBeUndefined(); - fetchMock.removeRoute('post-report'); + fetchMock.removeRoute('post-subscribe'); }); test('text-based chart hides screenshot width and shows message content', () => { diff --git a/superset-frontend/src/features/reports/ReportModal/actions.ts b/superset-frontend/src/features/reports/ReportModal/actions.ts index 6ae2c3e6ee3..59e99568418 100644 --- a/superset-frontend/src/features/reports/ReportModal/actions.ts +++ b/superset-frontend/src/features/reports/ReportModal/actions.ts @@ -174,6 +174,28 @@ export const addReport = throw err; }); +export const SUBSCRIBE_REPORT = 'SUBSCRIBE_REPORT' as const; + +export interface SubscribeReportAction { + type: typeof SUBSCRIBE_REPORT; + json: ReportApiJsonResponse; +} + +export const subscribeReport = + (report: Partial) => (dispatch: Dispatch) => + SupersetClient.post({ + endpoint: `/api/v1/report/subscribe`, + jsonPayload: report, + }) + .then(({ json }) => { + dispatch({ type: SUBSCRIBE_REPORT, json } as SubscribeReportAction); + dispatch(addSuccessToast(t('The report has been created'))); + }) + .catch(err => { + dispatch(addDangerToast(t('Failed to create report'))); + throw err; + }); + export const EDIT_REPORT = 'EDIT_REPORT' as const; export interface EditReportAction { @@ -255,5 +277,6 @@ export function deleteActiveReport(report: DeletableReport) { export type ReportAction = | SetReportAction | AddReportAction + | SubscribeReportAction | EditReportAction | DeleteReportAction; diff --git a/superset-frontend/src/features/reports/ReportModal/index.tsx b/superset-frontend/src/features/reports/ReportModal/index.tsx index 061615cf2ff..05582574fab 100644 --- a/superset-frontend/src/features/reports/ReportModal/index.tsx +++ b/superset-frontend/src/features/reports/ReportModal/index.tsx @@ -31,8 +31,8 @@ import { Alert } from '@apache-superset/core/components'; import { SupersetTheme } from '@apache-superset/core/theme'; import { useDispatch, useSelector } from 'react-redux'; import { - addReport, editReport, + subscribeReport, } from 'src/features/reports/ReportModal/actions'; import { Input, @@ -179,26 +179,13 @@ function ReportModal({ }, [isEditMode, report]); const onSave = async () => { - // Create new Report - const newReportValues: Partial = { + const commonFields: Partial = { type: 'Report', active: true, force_screenshot: false, custom_width: currentReport.custom_width, - creation_method: creationMethod, dashboard: dashboardId, chart: chart?.id, - owners: [userId], - recipients: [ - { - recipient_config_json: { - target: userEmail, - ccTarget: ccEmail, - bccTarget: bccEmail, - }, - type: 'Email', - }, - ], name: currentReport.name, description: currentReport.description, crontab: currentReport.crontab, @@ -209,12 +196,27 @@ function ReportModal({ setCurrentReport({ isSubmitting: true, error: undefined }); try { if (isEditMode && currentReport.id) { + // Edit path: include all fields, PUT endpoint accepts recipients/owners directly await dispatch( - editReport(currentReport.id, newReportValues as ReportObject), + editReport(currentReport.id, { + ...commonFields, + creation_method: creationMethod, + owners: [userId], + recipients: [ + { + recipient_config_json: { + target: userEmail, + ccTarget: ccEmail, + bccTarget: bccEmail, + }, + type: 'Email', + }, + ], + } as ReportObject), ); } else { - // Create new report (either not in edit mode, or edit mode without valid ID) - await dispatch(addReport(newReportValues as ReportObject)); + // Subscribe path: creation_method, owners, and recipients are set server-side. + await dispatch(subscribeReport(commonFields as ReportObject)); } onHide(); } catch (e) { diff --git a/superset-frontend/src/features/reports/ReportModal/reducer.ts b/superset-frontend/src/features/reports/ReportModal/reducer.ts index efb5a84756b..3bea7f90b50 100644 --- a/superset-frontend/src/features/reports/ReportModal/reducer.ts +++ b/superset-frontend/src/features/reports/ReportModal/reducer.ts @@ -21,11 +21,13 @@ import { omit } from 'lodash'; import { SET_REPORT, ADD_REPORT, + SUBSCRIBE_REPORT, EDIT_REPORT, DELETE_REPORT, ReportAction, SetReportAction, AddReportAction, + SubscribeReportAction, EditReportAction, DeleteReportAction, } from './actions'; @@ -105,6 +107,25 @@ export default function reportsReducer( }; }, + [SUBSCRIBE_REPORT]() { + const { result, id } = (action as SubscribeReportAction).json; + const report: ReportObject = { ...result, id } as ReportObject; + const creationMethod = report.creation_method as ReportCreationMethod; + const key = report.dashboard ?? report.chart; + + if (key === undefined) { + return state; + } + + return { + ...state, + [creationMethod]: { + ...state[creationMethod], + [key]: report, + }, + }; + }, + [EDIT_REPORT]() { const actionTyped = action as EditReportAction; const report: ReportObject = { diff --git a/superset/commands/report/base.py b/superset/commands/report/base.py index 3a7ec66634f..268016a54c0 100644 --- a/superset/commands/report/base.py +++ b/superset/commands/report/base.py @@ -34,7 +34,10 @@ from superset.commands.report.exceptions import ( ) from superset.daos.chart import ChartDAO from superset.daos.dashboard import DashboardDAO -from superset.reports.models import ReportCreationMethod, ReportScheduleType +from superset.reports.models import ( + ReportCreationMethod, + ReportScheduleType, +) from superset.reports.types import ReportScheduleExtra from superset.utils import json diff --git a/superset/commands/report/update.py b/superset/commands/report/update.py index e09f1414f4b..ae127520151 100644 --- a/superset/commands/report/update.py +++ b/superset/commands/report/update.py @@ -33,12 +33,20 @@ from superset.commands.report.exceptions import ( ReportScheduleNameUniquenessValidationError, ReportScheduleNotFoundError, ReportScheduleUpdateFailedError, + ReportScheduleUserEmailNotFoundError, ) from superset.daos.database import DatabaseDAO from superset.daos.report import ReportScheduleDAO from superset.exceptions import SupersetSecurityException -from superset.reports.models import ReportSchedule, ReportScheduleType, ReportState +from superset.reports.models import ( + ReportCreationMethod, + ReportRecipientType, + ReportSchedule, + ReportScheduleType, + ReportState, +) from superset.utils import json +from superset.utils.core import get_user_email from superset.utils.decorators import on_error, transaction logger = logging.getLogger(__name__) @@ -89,6 +97,26 @@ class UpdateReportScheduleCommand(UpdateMixin, BaseReportScheduleCommand): ): self._properties["last_state"] = ReportState.NOOP + # For reports created from charts or dashboards the recipient must always + # be the requesting user's own email address. + if ( + self._model.creation_method + in ( + ReportCreationMethod.CHARTS, + ReportCreationMethod.DASHBOARDS, + ) + and "recipients" in self._properties + ): + if user_email := get_user_email(): + self._properties["recipients"] = [ + { + "type": ReportRecipientType.EMAIL, + "recipient_config_json": {"target": user_email}, + } + ] + else: + exceptions.append(ReportScheduleUserEmailNotFoundError()) + # Validate name/type uniqueness if either is changing if name != self._model.name or report_type != self._model.type: if not ReportScheduleDAO.validate_update_uniqueness( diff --git a/superset/reports/api.py b/superset/reports/api.py index ee5f876971c..9a79491b471 100644 --- a/superset/reports/api.py +++ b/superset/reports/api.py @@ -49,13 +49,14 @@ from superset.databases.filters import DatabaseFilter from superset.exceptions import SupersetException from superset.extensions import event_logger from superset.reports.filters import ReportScheduleAllTextFilter, ReportScheduleFilter -from superset.reports.models import ReportSchedule +from superset.reports.models import ReportCreationMethod, ReportSchedule from superset.reports.schemas import ( get_delete_ids_schema, get_slack_channels_schema, openapi_spec_methods_override, ReportSchedulePostSchema, ReportSchedulePutSchema, + ReportScheduleSubscribeSchema, ) from superset.utils.slack import get_channels_with_search from superset.views.base_api import ( @@ -82,6 +83,7 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi): RouteMethod.RELATED, "bulk_delete", "slack_channels", # not using RouteMethod since locally defined + "subscribe", } class_permission_name = "ReportSchedule" method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP @@ -198,6 +200,7 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi): edit_columns = add_columns add_model_schema = ReportSchedulePostSchema() edit_model_schema = ReportSchedulePutSchema() + subscribe_schema = ReportScheduleSubscribeSchema() order_columns = [ "active", @@ -318,6 +321,80 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi): ) return self.response_422(message=str(ex)) + @expose("/subscribe", methods=("POST",)) + @protect() + @statsd_metrics + @permission_name("subscribe") + @requires_json + def subscribe(self) -> Response: + """Subscribe the current user to a chart or dashboard report. + --- + post: + summary: Subscribe to a chart or dashboard report + description: >- + Creates a report schedule locked to the authenticated user's email. + ``creation_method`` is derived server-side from the payload + (chart → charts, dashboard → dashboards). ``recipients`` are not + accepted and are always set to the requesting user's email address. + requestBody: + description: Report schedule subscription schema + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/{{self.__class__.__name__}}.post' + responses: + 201: + description: Report schedule subscription created + content: + application/json: + schema: + type: object + properties: + id: + type: number + result: + $ref: '#/components/schemas/{{self.__class__.__name__}}.post' + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' + """ + try: + item = self.subscribe_schema.load(request.json) + except ValidationError as error: + return self.response_400(message=error.messages) + + # Derive creation_method server-side from the payload + if item.get("dashboard") is not None: + item["creation_method"] = ReportCreationMethod.DASHBOARDS + elif item.get("chart") is not None: + item["creation_method"] = ReportCreationMethod.CHARTS + else: + return self.response_400( + message={"_schema": ["Either chart or dashboard is required"]} + ) + + try: + new_model = CreateReportScheduleCommand(item).run() + return self.response(201, id=new_model.id, result=item) + except ReportScheduleNotFoundError as ex: + return self.response_400(message=str(ex)) + except ReportScheduleInvalidError as ex: + return self.response_422(message=ex.normalized_messages()) + except ReportScheduleCreateFailedError as ex: + logger.error( + "Error creating report schedule %s: %s", + self.__class__.__name__, + str(ex), + exc_info=True, + ) + return self.response_422(message=str(ex)) + @expose("/", methods=("POST",)) @protect() @statsd_metrics diff --git a/superset/reports/schemas.py b/superset/reports/schemas.py index 81d58e537c2..8f7314d2aa1 100644 --- a/superset/reports/schemas.py +++ b/superset/reports/schemas.py @@ -305,6 +305,30 @@ class ReportSchedulePostSchema(Schema): ) +class ReportScheduleSubscribeSchema(ReportSchedulePostSchema): + """Schema for creating a chart/dashboard subscription. + + ``recipients`` and ``creation_method`` are excluded — both are set + server-side: recipients are locked to the authenticated user's email, + and creation_method is derived from the presence of ``chart`` or + ``dashboard`` in the payload. + + ``type`` is restricted to ``Report`` — alert schedules cannot be + created through the subscribe endpoint. + """ + + type = fields.String( + metadata={"description": type_description}, + allow_none=False, + required=True, + validate=validate.OneOf(choices=[ReportScheduleType.REPORT.value]), + ) + + class Meta: + exclude = ("recipients", "creation_method", "owners") + unknown = EXCLUDE + + class ReportSchedulePutSchema(Schema): type = fields.String( metadata={"description": type_description}, diff --git a/tests/integration_tests/reports/api_tests.py b/tests/integration_tests/reports/api_tests.py index 26b69fd6020..e0b6a77408c 100644 --- a/tests/integration_tests/reports/api_tests.py +++ b/tests/integration_tests/reports/api_tests.py @@ -337,7 +337,8 @@ class TestReportSchedulesApi(SupersetTestCase): assert rv.status_code == 200 assert "can_read" in data["permissions"] assert "can_write" in data["permissions"] - assert len(data["permissions"]) == 2 + assert "can_subscribe" in data["permissions"] + assert len(data["permissions"]) == 3 @pytest.mark.usefixtures("create_report_schedules") def test_get_report_schedule_not_found(self): diff --git a/tests/unit_tests/commands/report/update_test.py b/tests/unit_tests/commands/report/update_test.py index 32be92073c4..19612347dda 100644 --- a/tests/unit_tests/commands/report/update_test.py +++ b/tests/unit_tests/commands/report/update_test.py @@ -14,11 +14,11 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -"""Unit tests for UpdateReportScheduleCommand.validate() database invariants.""" +"""Unit tests for UpdateReportScheduleCommand.validate().""" from __future__ import annotations -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest from pytest_mock import MockerFixture @@ -30,7 +30,11 @@ from superset.commands.report.exceptions import ( from superset.commands.report.update import UpdateReportScheduleCommand from superset.errors import ErrorLevel, SupersetError, SupersetErrorType from superset.exceptions import SupersetSecurityException -from superset.reports.models import ReportScheduleType, ReportState +from superset.reports.models import ( + ReportCreationMethod, + ReportScheduleType, + ReportState, +) def _make_model( @@ -38,10 +42,12 @@ def _make_model( *, model_type: ReportScheduleType | str, database_id: int | None, + creation_method: ReportCreationMethod = ReportCreationMethod.ALERTS_REPORTS, ) -> Mock: model = mocker.Mock() model.type = model_type model.database_id = database_id + model.creation_method = creation_method model.name = "test_schedule" model.crontab = "0 9 * * *" model.last_state = "noop" @@ -257,6 +263,142 @@ def test_report_to_alert_with_db_accepted(mocker: MockerFixture) -> None: cmd.validate() # should not raise +# --- Recipient enforcement for chart/dashboard reports --- + +_PATCH_GET_USER_EMAIL = "superset.commands.report.update.get_user_email" + + +def test_chart_report_update_recipient_overridden_with_owner_email( + mocker: MockerFixture, +) -> None: + """Updating recipients on a chart report always locks them to the owner's email.""" + model = _make_model( + mocker, + model_type=ReportScheduleType.REPORT, + database_id=None, + creation_method=ReportCreationMethod.CHARTS, + ) + _setup_mocks(mocker, model) + + data = { + "recipients": [ + { + "type": "Email", + "recipient_config_json": {"target": "other@example.com"}, + } + ] + } + cmd = UpdateReportScheduleCommand(model_id=1, data=data) + with patch(_PATCH_GET_USER_EMAIL, return_value="owner@example.com"): + cmd.validate() + + recipients = cmd._properties["recipients"] + assert len(recipients) == 1 + assert recipients[0]["recipient_config_json"]["target"] == "owner@example.com" + + +def test_dashboard_report_update_recipient_overridden_with_owner_email( + mocker: MockerFixture, +) -> None: + """Updating recipients on a dashboard report locks them to the owner's email.""" + model = _make_model( + mocker, + model_type=ReportScheduleType.REPORT, + database_id=None, + creation_method=ReportCreationMethod.DASHBOARDS, + ) + _setup_mocks(mocker, model) + + data = { + "recipients": [ + { + "type": "Email", + "recipient_config_json": {"target": "other@example.com"}, + } + ] + } + cmd = UpdateReportScheduleCommand(model_id=1, data=data) + with patch(_PATCH_GET_USER_EMAIL, return_value="owner@example.com"): + cmd.validate() + + recipients = cmd._properties["recipients"] + assert len(recipients) == 1 + assert recipients[0]["recipient_config_json"]["target"] == "owner@example.com" + + +def test_alerts_reports_update_recipient_not_overridden( + mocker: MockerFixture, +) -> None: + """Recipients on admin-created alerts/reports are not modified on update.""" + model = _make_model( + mocker, + model_type=ReportScheduleType.REPORT, + database_id=None, + creation_method=ReportCreationMethod.ALERTS_REPORTS, + ) + _setup_mocks(mocker, model) + + original_recipient = { + "type": "Email", + "recipient_config_json": {"target": "team@example.com"}, + } + cmd = UpdateReportScheduleCommand( + model_id=1, data={"recipients": [original_recipient]} + ) + with patch(_PATCH_GET_USER_EMAIL, return_value="owner@example.com"): + cmd.validate() + + assert ( + cmd._properties["recipients"][0]["recipient_config_json"]["target"] + == "team@example.com" + ) + + +def test_chart_report_update_no_recipients_in_payload_unchanged( + mocker: MockerFixture, +) -> None: + """If recipients are not in the update payload, nothing is changed.""" + model = _make_model( + mocker, + model_type=ReportScheduleType.REPORT, + database_id=None, + creation_method=ReportCreationMethod.CHARTS, + ) + _setup_mocks(mocker, model) + + cmd = UpdateReportScheduleCommand(model_id=1, data={"name": "new name"}) + with patch(_PATCH_GET_USER_EMAIL, return_value="owner@example.com"): + cmd.validate() + + assert "recipients" not in cmd._properties + + +def test_chart_report_update_no_user_email_raises(mocker: MockerFixture) -> None: + """Update fails with a validation error when the user has no email address.""" + model = _make_model( + mocker, + model_type=ReportScheduleType.REPORT, + database_id=None, + creation_method=ReportCreationMethod.CHARTS, + ) + _setup_mocks(mocker, model) + + cmd = UpdateReportScheduleCommand( + model_id=1, + data={ + "recipients": [ + {"type": "Email", "recipient_config_json": {"target": "x@y.com"}} + ] + }, + ) + with patch(_PATCH_GET_USER_EMAIL, return_value=None): + with pytest.raises(ReportScheduleInvalidError) as exc_info: + cmd.validate() + + messages = _get_validation_messages(exc_info) + assert "recipients" in messages + + # --- Deactivation state reset --- diff --git a/tests/unit_tests/reports/schemas_test.py b/tests/unit_tests/reports/schemas_test.py index 49e7fdfe649..8af9aae5f3f 100644 --- a/tests/unit_tests/reports/schemas_test.py +++ b/tests/unit_tests/reports/schemas_test.py @@ -19,7 +19,12 @@ import pytest from marshmallow import ValidationError from pytest_mock import MockerFixture -from superset.reports.schemas import ReportSchedulePostSchema, ReportSchedulePutSchema +from superset.reports.schemas import ( + ReportRecipientSchema, + ReportSchedulePostSchema, + ReportSchedulePutSchema, + ReportScheduleSubscribeSchema, +) def test_report_post_schema_custom_width_validation(mocker: MockerFixture) -> None: @@ -77,6 +82,157 @@ def test_report_post_schema_custom_width_validation(mocker: MockerFixture) -> No } +def test_report_recipient_schema_email_valid() -> None: + """Valid email target is accepted by the recipient schema.""" + schema = ReportRecipientSchema() + result = schema.load( + { + "type": "Email", + "recipient_config_json": {"target": "user@example.com"}, + } + ) + assert result["recipient_config_json"]["target"] == "user@example.com" + + +def test_report_recipient_schema_email_invalid_target() -> None: + """Invalid email address in target field raises a validation error.""" + schema = ReportRecipientSchema() + with pytest.raises(ValidationError) as excinfo: + schema.load( + { + "type": "Email", + "recipient_config_json": {"target": "not-an-email"}, + } + ) + assert "target" in excinfo.value.messages + + +def test_report_recipient_schema_email_invalid_cc() -> None: + """Invalid address in ccTarget field raises a validation error.""" + schema = ReportRecipientSchema() + with pytest.raises(ValidationError) as excinfo: + schema.load( + { + "type": "Email", + "recipient_config_json": { + "target": "user@example.com", + "ccTarget": "bad-email", + }, + } + ) + assert "ccTarget" in excinfo.value.messages + + +def test_report_recipient_schema_email_invalid_bcc() -> None: + """Invalid address in bccTarget field raises a validation error.""" + schema = ReportRecipientSchema() + with pytest.raises(ValidationError) as excinfo: + schema.load( + { + "type": "Email", + "recipient_config_json": { + "target": "user@example.com", + "bccTarget": "not-valid", + }, + } + ) + assert "bccTarget" in excinfo.value.messages + + +def test_report_recipient_schema_email_empty_bcc_allowed() -> None: + """Empty string in bccTarget is accepted (optional field).""" + schema = ReportRecipientSchema() + result = schema.load( + { + "type": "Email", + "recipient_config_json": { + "target": "user@example.com", + "bccTarget": "", + }, + } + ) + assert result["recipient_config_json"]["target"] == "user@example.com" + + +def test_report_recipient_schema_email_empty_cc_allowed() -> None: + """Empty string in ccTarget is accepted (optional field).""" + schema = ReportRecipientSchema() + result = schema.load( + { + "type": "Email", + "recipient_config_json": { + "target": "user@example.com", + "ccTarget": "", + }, + } + ) + assert result["recipient_config_json"]["target"] == "user@example.com" + + +def test_report_recipient_schema_slack_skips_email_validation() -> None: + """Slack recipients are not validated as email addresses.""" + schema = ReportRecipientSchema() + result = schema.load( + { + "type": "Slack", + "recipient_config_json": {"target": "#general"}, + } + ) + assert result["recipient_config_json"]["target"] == "#general" + + +def test_subscribe_schema_ignores_excluded_fields(mocker: MockerFixture) -> None: + """Excluded fields sent by the client are silently dropped, not rejected.""" + mocker.patch( + "flask.current_app.config", + { + "ALERT_REPORTS_MIN_CUSTOM_SCREENSHOT_WIDTH": 100, + "ALERT_REPORTS_MAX_CUSTOM_SCREENSHOT_WIDTH": 2000, + }, + ) + schema = ReportScheduleSubscribeSchema() + result = schema.load( + { + "type": "Report", + "name": "My subscription", + "crontab": "0 9 * * *", + "timezone": "UTC", + "chart": 1, + # These are excluded server-side — should be silently dropped + "recipients": [ + {"type": "Email", "recipient_config_json": {"target": "x@y.com"}} + ], + "creation_method": "alerts_reports", + } + ) + assert "recipients" not in result + assert "creation_method" not in result + assert "owners" not in result + + +def test_subscribe_schema_rejects_alert_type(mocker: MockerFixture) -> None: + """Subscribe endpoint must not allow Alert type — prevents privilege escalation.""" + mocker.patch( + "flask.current_app.config", + { + "ALERT_REPORTS_MIN_CUSTOM_SCREENSHOT_WIDTH": 100, + "ALERT_REPORTS_MAX_CUSTOM_SCREENSHOT_WIDTH": 2000, + }, + ) + schema = ReportScheduleSubscribeSchema() + with pytest.raises(ValidationError) as exc_info: + schema.load( + { + "type": "Alert", + "name": "My alert", + "crontab": "0 9 * * *", + "timezone": "UTC", + "chart": 1, + } + ) + assert "type" in exc_info.value.messages + + MINIMAL_POST_PAYLOAD = { "type": "Report", "name": "A report",