This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 69adecd6a35 fix(reports): enforce server-side recipient on
chart/dashboard report subscriptions (#38847)
69adecd6a35 is described below
commit 69adecd6a35efa42ded0aa7341b3cbebe9cda8de
Author: Shaitan <[email protected]>
AuthorDate: Wed May 20 18:36:42 2026 +0100
fix(reports): enforce server-side recipient on chart/dashboard report
subscriptions (#38847)
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
---
.../reports/ReportModal/ReportModal.test.tsx | 20 +--
.../src/features/reports/ReportModal/actions.ts | 23 +++
.../src/features/reports/ReportModal/index.tsx | 38 ++---
.../src/features/reports/ReportModal/reducer.ts | 21 +++
superset/commands/report/base.py | 5 +-
superset/commands/report/update.py | 30 +++-
superset/reports/api.py | 79 ++++++++++-
superset/reports/schemas.py | 24 ++++
tests/integration_tests/reports/api_tests.py | 3 +-
tests/unit_tests/commands/report/update_test.py | 148 ++++++++++++++++++-
tests/unit_tests/reports/schemas_test.py | 158 ++++++++++++++++++++-
11 files changed, 514 insertions(+), 35 deletions(-)
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(<ReportModal {...defaultProps} />, { 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<ReportObject>) => (dispatch: Dispatch<AnyAction>) =>
+ 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<ReportObject> = {
+ const commonFields: Partial<ReportObject> = {
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": "[email protected]"},
+ }
+ ]
+ }
+ cmd = UpdateReportScheduleCommand(model_id=1, data=data)
+ with patch(_PATCH_GET_USER_EMAIL, return_value="[email protected]"):
+ cmd.validate()
+
+ recipients = cmd._properties["recipients"]
+ assert len(recipients) == 1
+ assert recipients[0]["recipient_config_json"]["target"] ==
"[email protected]"
+
+
+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": "[email protected]"},
+ }
+ ]
+ }
+ cmd = UpdateReportScheduleCommand(model_id=1, data=data)
+ with patch(_PATCH_GET_USER_EMAIL, return_value="[email protected]"):
+ cmd.validate()
+
+ recipients = cmd._properties["recipients"]
+ assert len(recipients) == 1
+ assert recipients[0]["recipient_config_json"]["target"] ==
"[email protected]"
+
+
+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": "[email protected]"},
+ }
+ cmd = UpdateReportScheduleCommand(
+ model_id=1, data={"recipients": [original_recipient]}
+ )
+ with patch(_PATCH_GET_USER_EMAIL, return_value="[email protected]"):
+ cmd.validate()
+
+ assert (
+ cmd._properties["recipients"][0]["recipient_config_json"]["target"]
+ == "[email protected]"
+ )
+
+
+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="[email protected]"):
+ 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":
"[email protected]"}}
+ ]
+ },
+ )
+ 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": "[email protected]"},
+ }
+ )
+ assert result["recipient_config_json"]["target"] == "[email protected]"
+
+
+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": "[email protected]",
+ "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": "[email protected]",
+ "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": "[email protected]",
+ "bccTarget": "",
+ },
+ }
+ )
+ assert result["recipient_config_json"]["target"] == "[email protected]"
+
+
+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": "[email protected]",
+ "ccTarget": "",
+ },
+ }
+ )
+ assert result["recipient_config_json"]["target"] == "[email protected]"
+
+
+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":
"[email protected]"}}
+ ],
+ "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",