From 40650fda2aef8d3bff85748a1ab4f8aeae391532 Mon Sep 17 00:00:00 2001 From: Kristi Dodaj Date: Mon, 19 Oct 2026 14:14:02 -0400 Subject: [PATCH 01/30] feat(reports): add optional Teams integration configuration --- .../configuration/alerts-reports.mdx | 29 ++++ docs/static/feature-flags.json | 6 + pyproject.toml | 1 + superset/config.py | 24 +++ superset/integrations/__init__.py | 16 ++ superset/integrations/teams/__init__.py | 16 ++ superset/integrations/teams/config.py | 131 ++++++++++++++++ .../integrations/teams/config_test.py | 147 ++++++++++++++++++ 8 files changed, 370 insertions(+) create mode 100644 superset/integrations/__init__.py create mode 100644 superset/integrations/teams/__init__.py create mode 100644 superset/integrations/teams/config.py create mode 100644 tests/unit_tests/integrations/teams/config_test.py diff --git a/docs/admin_docs/configuration/alerts-reports.mdx b/docs/admin_docs/configuration/alerts-reports.mdx index 3d88f92c91af..b1053711cf4d 100644 --- a/docs/admin_docs/configuration/alerts-reports.mdx +++ b/docs/admin_docs/configuration/alerts-reports.mdx @@ -892,6 +892,35 @@ This information can then be retrieved from the endpoint `/api/v1/saved_query/` schedule the queries that have `schedule_info` in their JSON metadata. For schedulers other than Airflow, additional fields can be easily added to the configuration file above. +## Microsoft Teams configuration (development) + +The optional `apache-superset[teams]` extra provides MSAL for bot authentication. +`FEATURE_FLAGS["ALERT_REPORT_TEAMS"]` defaults to `False`. This configuration +foundation does not register a recipient type or send messages yet. + +Set `ALERT_REPORTS_TEAMS` in `superset_config.py` with `tenant_id` (UUID), +`application_id` (bot application UUID), `credential` (client secret from your +secret store), and a nonempty `destinations` list. Each destination requires: + +- `key`: unique stable identifier, 1–128 letters, digits, underscores or hyphens, + starting with a letter or digit. Retain this key when changing channel details. +- `label`: display name. +- `team_id`: Teams team identifier. +- `channel_id`: Teams channel identifier. + +An empty mapping leaves Teams unconfigured. The server-side +`superset.integrations.teams.config.parse_teams_config` function validates the +mapping and rejects missing/unknown fields and duplicate keys or channels. +Only one tenant and bot identity are accepted per deployment. Operators must +approve standard channels in that tenant; the parser cannot verify channel type +or tenant membership without Teams metadata. Private/shared channels, DMs, +scheduled reports, and attachments are outside the integration scope. + +Keep the entire mapping on the server. Browser destination lists must use +`TeamsConfig.public_destinations()` (keys and labels only). Recipient JSON must +store only the selected destination key, never credentials or routing details. +Do not serialize the parsed configuration object, including via `dataclasses.asdict`. + :::resources - [Tutorial: Automated Alerts and Reporting via Slack/Email in Superset](https://dev.to/ngtduc693/apache-superset-topic-5-automated-alerts-and-reporting-via-slackemail-in-superset-2gbe) diff --git a/docs/static/feature-flags.json b/docs/static/feature-flags.json index e39581a4439c..e35adb3fd771 100644 --- a/docs/static/feature-flags.json +++ b/docs/static/feature-flags.json @@ -21,6 +21,12 @@ "lifecycle": "development", "description": "Enables experimental tabs UI for Alerts and Reports" }, + { + "name": "ALERT_REPORT_TEAMS", + "default": false, + "lifecycle": "development", + "description": "Enable notification-only Microsoft Teams alerts to approved standard channels." + }, { "name": "CHART_PLUGINS_EXPERIMENTAL", "default": false, diff --git a/pyproject.toml b/pyproject.toml index ef8495b6dd6f..5aef80b56cb8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -268,6 +268,7 @@ pinot = ["pinotdb[sqlalchemy]>=9.2.2, <10.0.0"] playwright = ["playwright>=1.64.0, <2"] postgres = ["psycopg2-binary==2.9.13"] presto = ["pyhive[presto]>=0.7.0"] +teams = ["msal>=1.31.0, <2"] trino = ["trino>=0.340.0"] prophet = [ "prophet>=1.5.0, <2", diff --git a/superset/config.py b/superset/config.py index efba67f3f816..4519b5039873 100644 --- a/superset/config.py +++ b/superset/config.py @@ -846,6 +846,9 @@ class D3TimeFormat(TypedDict, total=False): # Enables webhook integration for Alerts and Reports # @lifecycle: testing "ALERT_REPORT_WEBHOOK": False, + # Enable notification-only Microsoft Teams alerts to approved standard channels. + # @lifecycle: development + "ALERT_REPORT_TEAMS": False, # Allow users to export full CSV of table viz type. # Warning: Could cause server memory/compute issues with large datasets. # @lifecycle: testing @@ -2942,6 +2945,27 @@ def EMAIL_HEADER_MUTATOR( # pylint: disable=invalid-name,unused-argument # noq # The text for call-to-action link in Alerts & Reports emails EMAIL_REPORTS_CTA = "Explore in Superset" +# Server-side configuration for the notification-only Teams bot. One tenant per +# deployment; only operator-approved standard channels are supported. Scheduled +# reports, private/shared channels, DMs, and attachments are excluded. +# See superset.integrations.teams.config.parse_teams_config for validation. +# Never expose this mapping to the browser or store it in recipient JSON. +# Recipients must reference only a destination's stable key. +# ALERT_REPORTS_TEAMS = { +# "tenant_id": "", +# "application_id": "", +# "credential": os.environ["TEAMS_BOT_CLIENT_SECRET"], +# "destinations": [ +# { +# "key": "operations", +# "label": "Operations alerts", +# "team_id": "", +# "channel_id": "", +# }, +# ], +# } +ALERT_REPORTS_TEAMS: dict[str, Any] = {} + # Slack API token for the superset reports, either string or callable SLACK_API_TOKEN: Callable[[], str] | str | None = None SLACK_PROXY = None diff --git a/superset/integrations/__init__.py b/superset/integrations/__init__.py new file mode 100644 index 000000000000..13a83393a912 --- /dev/null +++ b/superset/integrations/__init__.py @@ -0,0 +1,16 @@ +# 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. diff --git a/superset/integrations/teams/__init__.py b/superset/integrations/teams/__init__.py new file mode 100644 index 000000000000..13a83393a912 --- /dev/null +++ b/superset/integrations/teams/__init__.py @@ -0,0 +1,16 @@ +# 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. diff --git a/superset/integrations/teams/config.py b/superset/integrations/teams/config.py new file mode 100644 index 000000000000..31ba60ff9683 --- /dev/null +++ b/superset/integrations/teams/config.py @@ -0,0 +1,131 @@ +# 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. +"""Server-only configuration for attachment-free, single-tenant Teams alerts.""" + +import re +from collections.abc import Mapping +from dataclasses import dataclass, field +from uuid import UUID + + +class TeamsConfigError(ValueError): + """Invalid deployment configuration, with no supplied values in the message.""" + + +@dataclass(frozen=True) +class TeamsDestination: + """An operator-approved standard channel, identified by a stable recipient key.""" + + key: str + label: str + team_id: str + channel_id: str + + def as_public_dict(self) -> dict[str, str]: + """Return the only destination fields intended for browser responses.""" + return {"key": self.key, "label": self.label} + + +@dataclass(frozen=True) +class TeamsConfig: + """Deployment identity and destinations; never serialize this object to clients.""" + + tenant_id: str + application_id: str + credential: str = field(repr=False) + destinations: tuple[TeamsDestination, ...] + + def get_destination(self, key: str) -> TeamsDestination: + """Resolve a recipient key against the deployment allowlist, failing closed.""" + for destination in self.destinations: + if destination.key == key: + return destination + raise TeamsConfigError("Teams destination is not approved") + + def public_destinations(self) -> list[dict[str, str]]: + """Project selectable destinations without identity, routing, or credentials.""" + return [destination.as_public_dict() for destination in self.destinations] + + +def _mapping(value: object, fields: set[str], path: str) -> Mapping[str, object]: + """Require exactly the supported fields without echoing unknown keys or values.""" + if not isinstance(value, Mapping) or set(value) != fields: + raise TeamsConfigError(f"{path} must contain exactly the supported fields") + return value + + +def _text(value: object, path: str) -> str: + """Require a nonempty string without silently rewriting identifiers or secrets.""" + if not isinstance(value, str) or not value.strip(): + raise TeamsConfigError(f"{path} must be a nonempty string") + return value + + +def _uuid(value: object, path: str) -> str: + """Require a concrete tenant or application UUID, excluding tenant aliases.""" + text = _text(value, path) + try: + parsed = UUID(text) + except ValueError: + raise TeamsConfigError(f"{path} must be a UUID") from None + if parsed.int == 0: + raise TeamsConfigError(f"{path} must be a nonzero UUID") + return str(parsed) + + +def parse_teams_config(value: object) -> TeamsConfig | None: + """Validate ALERT_REPORTS_TEAMS; an empty mapping leaves Teams unconfigured. + + This parser performs no network calls and imports no optional Teams dependency. + Operators must approve standard channels in the configured tenant. Channel IDs + alone cannot establish membership or distinguish standard/private/shared types. + Feature gating and Teams metadata checks belong to the future delivery layer. + """ + if isinstance(value, Mapping) and not value: + return None + config = _mapping( + value, + {"tenant_id", "application_id", "credential", "destinations"}, + "ALERT_REPORTS_TEAMS", + ) + tenant_id = _uuid(config["tenant_id"], "Teams tenant_id") + application_id = _uuid(config["application_id"], "Teams application_id") + credential = _text(config["credential"], "Teams credential") + raw_destinations = config["destinations"] + if not isinstance(raw_destinations, (list, tuple)) or not raw_destinations: + raise TeamsConfigError("Teams destinations must be a nonempty list or tuple") + destinations: list[TeamsDestination] = [] + keys: set[str] = set() + channels: set[tuple[str, str]] = set() + for raw_destination in raw_destinations: + destination = _mapping( + raw_destination, + {"key", "label", "team_id", "channel_id"}, + "Teams destination", + ) + key = _text(destination["key"], "Teams destination key") + if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}", key): + raise TeamsConfigError("Teams destination key must be a stable identifier") + label = _text(destination["label"], "Teams destination label") + team_id = _text(destination["team_id"], "Teams destination team_id") + channel_id = _text(destination["channel_id"], "Teams destination channel_id") + if key in keys or (team_id, channel_id) in channels: + raise TeamsConfigError("Teams destination keys and channels must be unique") + keys.add(key) + channels.add((team_id, channel_id)) + destinations.append(TeamsDestination(key, label, team_id, channel_id)) + return TeamsConfig(tenant_id, application_id, credential, tuple(destinations)) diff --git a/tests/unit_tests/integrations/teams/config_test.py b/tests/unit_tests/integrations/teams/config_test.py new file mode 100644 index 000000000000..68191db541ec --- /dev/null +++ b/tests/unit_tests/integrations/teams/config_test.py @@ -0,0 +1,147 @@ +# 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. +"""Tests for server-side Teams configuration and safe destination projection.""" + +from copy import deepcopy +from typing import Any + +import pytest + +from superset.integrations.teams.config import parse_teams_config, TeamsConfigError + + +@pytest.fixture +def deployment() -> dict[str, Any]: + """Return a single-tenant configuration with an approved channel.""" + return { + "tenant_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "application_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + "credential": "test-secret-never-return", + "destinations": [ + { + "key": "operations", + "label": "Operations alerts", + "team_id": "team-1", + "channel_id": "19:channel@thread.tacv2", + }, + ], + } + + +def test_unconfigured() -> None: + assert parse_teams_config({}) is None + + +def test_valid_config_and_safe_projection(deployment: dict[str, Any]) -> None: + config = parse_teams_config(deployment) + assert config is not None + assert config.tenant_id == deployment["tenant_id"] + assert config.application_id == deployment["application_id"] + assert config.credential == deployment["credential"] + assert config.get_destination("operations").channel_id == "19:channel@thread.tacv2" + assert config.public_destinations() == [ + {"key": "operations", "label": "Operations alerts"}, + ] + assert deployment["credential"] not in repr(config) + assert deployment["credential"] not in str(config.public_destinations()) + deployment["destinations"][0]["key"] = "changed" + assert config.get_destination("operations").key == "operations" + with pytest.raises(TeamsConfigError, match="not approved"): + config.get_destination("unapproved") + + +@pytest.mark.parametrize( + "field", ["tenant_id", "application_id", "credential", "destinations"] +) +def test_required_fields(deployment: dict[str, Any], field: str) -> None: + del deployment[field] + with pytest.raises(TeamsConfigError): + parse_teams_config(deployment) + + +@pytest.mark.parametrize("field", ["tenant_id", "application_id", "credential"]) +@pytest.mark.parametrize("value", [None, "", " ", 123, []]) +def test_invalid_identity( + deployment: dict[str, Any], field: str, value: object +) -> None: + deployment[field] = value + with pytest.raises(TeamsConfigError): + parse_teams_config(deployment) + + +@pytest.mark.parametrize("field", ["tenant_id", "application_id"]) +@pytest.mark.parametrize("value", ["common", "organizations", "not-a-uuid", "0" * 32]) +def test_requires_concrete_uuid( + deployment: dict[str, Any], field: str, value: str +) -> None: + deployment[field] = value + with pytest.raises(TeamsConfigError): + parse_teams_config(deployment) + + +@pytest.mark.parametrize("value", [None, [], "secret-value", 1]) +def test_invalid_top_level(value: object) -> None: + with pytest.raises(TeamsConfigError) as exc: + parse_teams_config(value) + assert "secret-value" not in str(exc.value) + + +@pytest.mark.parametrize("value", [None, {}, [], "operations", [None]]) +def test_invalid_destinations(deployment: dict[str, Any], value: object) -> None: + deployment["destinations"] = value + with pytest.raises(TeamsConfigError): + parse_teams_config(deployment) + + +@pytest.mark.parametrize("field", ["key", "label", "team_id", "channel_id"]) +@pytest.mark.parametrize("value", [None, "", " ", 123]) +def test_invalid_destination_fields( + deployment: dict[str, Any], field: str, value: object +) -> None: + deployment["destinations"][0][field] = value + with pytest.raises(TeamsConfigError): + parse_teams_config(deployment) + + +@pytest.mark.parametrize("key", ["has spaces", "../path", "a" * 129]) +def test_invalid_stable_key(deployment: dict[str, Any], key: str) -> None: + deployment["destinations"][0]["key"] = key + with pytest.raises(TeamsConfigError): + parse_teams_config(deployment) + + +@pytest.mark.parametrize("duplicate_key", [True, False]) +def test_duplicate_destinations( + deployment: dict[str, Any], duplicate_key: bool +) -> None: + duplicate = deepcopy(deployment["destinations"][0]) + duplicate["channel_id" if duplicate_key else "key"] = "other" + deployment["destinations"].append(duplicate) + with pytest.raises(TeamsConfigError, match="unique"): + parse_teams_config(deployment) + + +@pytest.mark.parametrize("nested", [True, False]) +def test_unknown_fields_do_not_leak_values( + deployment: dict[str, Any], nested: bool +) -> None: + target = deployment["destinations"][0] if nested else deployment + target["secret-unknown-key"] = deployment["credential"] + with pytest.raises(TeamsConfigError) as exc: + parse_teams_config(deployment) + assert deployment["credential"] not in str(exc.value) + assert "secret-unknown-key" not in str(exc.value) From eeaa2617bdf46d5b3b097c57c411c92075e47fab Mon Sep 17 00:00:00 2001 From: Kristi Dodaj Date: Mon, 19 Oct 2026 14:47:41 -0400 Subject: [PATCH 02/30] feat(reports): persist Teams bot installations --- .../configuration/alerts-reports.mdx | 9 + superset/integrations/teams/models.py | 53 ++++ superset/integrations/teams/store.py | 162 ++++++++++ ...00_7b9d2f8a6c31_add_teams_installations.py | 55 ++++ superset/models/__init__.py | 2 + .../integrations/teams/store_test.py | 283 ++++++++++++++++++ 6 files changed, 564 insertions(+) create mode 100644 superset/integrations/teams/models.py create mode 100644 superset/integrations/teams/store.py create mode 100644 superset/migrations/versions/2026-10-19_00-00_7b9d2f8a6c31_add_teams_installations.py create mode 100644 tests/unit_tests/integrations/teams/store_test.py diff --git a/docs/admin_docs/configuration/alerts-reports.mdx b/docs/admin_docs/configuration/alerts-reports.mdx index b1053711cf4d..c90766e7b428 100644 --- a/docs/admin_docs/configuration/alerts-reports.mdx +++ b/docs/admin_docs/configuration/alerts-reports.mdx @@ -916,6 +916,15 @@ approve standard channels in that tenant; the parser cannot verify channel type or tenant membership without Teams metadata. Private/shared channels, DMs, scheduled reports, and attachments are outside the integration scope. +Bot installations are stored separately in `teams_installations`, uniquely keyed +by tenant, application, and team. Installation events refresh the service URL +and reactivate the same UUID record; removal makes it inactive. Duplicate events +are safe and events are applied in processing order. The store leaves transaction +commit/rollback to its caller. Event authentication, deployment identity checks, +and service URL ownership verification must happen before invoking the store. +Installation records never add selectable destinations or store channels or +credentials. The additive migration can be downgraded by dropping only this table. + Keep the entire mapping on the server. Browser destination lists must use `TeamsConfig.public_destinations()` (keys and labels only). Recipient JSON must store only the selected destination key, never credentials or routing details. diff --git a/superset/integrations/teams/models.py b/superset/integrations/teams/models.py new file mode 100644 index 000000000000..3751b83b2b03 --- /dev/null +++ b/superset/integrations/teams/models.py @@ -0,0 +1,53 @@ +# 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. +"""Teams bot installation metadata, separate from destination configuration.""" + +from datetime import datetime +from uuid import UUID, uuid4 + +from flask_appbuilder import Model +from sqlalchemy import Boolean, DateTime, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy_utils import UUIDType + + +class TeamsInstallation(Model): + """One bot installation per tenant, application, and team, retained on removal.""" + + __tablename__ = "teams_installations" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "application_id", + "team_id", + name="uq_teams_installation_identity", + ), + ) + + id: Mapped[UUID] = mapped_column( + UUIDType(binary=True), primary_key=True, default=uuid4 + ) + tenant_id: Mapped[UUID] = mapped_column(UUIDType(binary=True), nullable=False) + application_id: Mapped[UUID] = mapped_column(UUIDType(binary=True), nullable=False) + team_id: Mapped[str] = mapped_column(String(255), nullable=False) + # Set only from an authenticated installation event after service URL verification. + service_url: Mapped[str] = mapped_column(String(2048), nullable=False) + active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + # Naive UTC, matching Superset metadata timestamps. + # changed_on records the last event processing time. + created_on: Mapped[datetime] = mapped_column(DateTime, nullable=False) + changed_on: Mapped[datetime] = mapped_column(DateTime, nullable=False) diff --git a/superset/integrations/teams/store.py b/superset/integrations/teams/store.py new file mode 100644 index 000000000000..6d28de417dc6 --- /dev/null +++ b/superset/integrations/teams/store.py @@ -0,0 +1,162 @@ +# 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. +"""Transactional persistence for authenticated Teams installation events.""" + +from datetime import datetime, timezone +from urllib.parse import urlsplit +from uuid import UUID + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from superset.integrations.teams.models import TeamsInstallation + + +def _identity( + tenant_id: str, application_id: str, team_id: str +) -> tuple[UUID, UUID, str]: + """Validate and normalize the installation identity without echoing input.""" + try: + tenant = UUID(tenant_id) + application = UUID(application_id) + except (ValueError, TypeError, AttributeError): + raise ValueError("Teams tenant and application IDs must be UUIDs") from None + if not tenant.int or not application.int: + raise ValueError("Teams tenant and application IDs must be nonzero UUIDs") + if not isinstance(team_id, str) or not team_id.strip() or len(team_id) > 255: + raise ValueError("Teams team ID must contain 1 to 255 characters") + if team_id != team_id.strip(): + raise ValueError("Teams team ID must not have surrounding whitespace") + return tenant, application, team_id + + +def _service_url(value: str) -> str: + """Check URL shape; the caller must verify authenticity and Microsoft ownership.""" + try: + url = urlsplit(value) + valid = ( + isinstance(value, str) + and len(value) <= 2048 + and not any( + character.isspace() or ord(character) < 32 for character in value + ) + and url.scheme == "https" + and bool(url.hostname) + and url.port in (None, 443) + and url.username is None + and url.password is None + and not url.query + and not url.fragment + ) + except (ValueError, TypeError, AttributeError): + valid = False + if not valid: + raise ValueError("Teams verified service URL must be an HTTPS URL") + return value + + +class TeamsInstallationStore: + """Persist verified events without committing or modifying configured destinations. + + Callers must authenticate events, enforce the deployment tenant/application, + and verify service URLs before invoking this store. This layer stores no + credentials or channels and never makes a team selectable on its own. + Events are applied in processing order; duplicate installs/removals are safe. + """ + + def __init__(self, session: Session) -> None: + """Use the caller's metadata database transaction.""" + self.session = session + + def get_active( + self, tenant_id: str, application_id: str, team_id: str + ) -> TeamsInstallation | None: + """Return the active installation for exactly the supplied identity.""" + tenant, application, team = _identity(tenant_id, application_id, team_id) + return ( + self.session.query(TeamsInstallation) + .populate_existing() + .filter_by( + tenant_id=tenant, application_id=application, team_id=team, active=True + ) + .one_or_none() + ) + + def install( + self, + tenant_id: str, + application_id: str, + team_id: str, + verified_service_url: str, + ) -> TeamsInstallation: + """Create, refresh, or reactivate an installation, including insert races.""" + tenant, application, team = _identity(tenant_id, application_id, team_id) + service_url = _service_url(verified_service_url) + now = datetime.now(timezone.utc).replace(tzinfo=None) + table = TeamsInstallation.__table__ + update = ( + table.update() + .where(table.c.tenant_id == tenant) + .where(table.c.application_id == application) + .where(table.c.team_id == team) + .values(service_url=service_url, active=True, changed_on=now) + ) + # Flush unrelated pending work outside the savepoint and exception handler. + self.session.flush() + connection = self.session.connection() + if not connection.execute(update).rowcount: + try: + with connection.begin_nested(): + connection.execute( + table.insert().values( + tenant_id=tenant, + application_id=application, + team_id=team, + service_url=service_url, + active=True, + created_on=now, + changed_on=now, + ) + ) + except IntegrityError: + # A concurrent event may have inserted this identity. Use a write + # to see that row even on repeatable-read databases. Do not hide + # unrelated integrity failures if no matching identity exists. + if not connection.execute(update).rowcount: + raise + return ( + self.session.query(TeamsInstallation) + .populate_existing() + .filter_by(tenant_id=tenant, application_id=application, team_id=team) + .one() + ) + + def remove(self, tenant_id: str, application_id: str, team_id: str) -> None: + """Deactivate an existing row; repeated or unknown removals are harmless.""" + tenant, application, team = _identity(tenant_id, application_id, team_id) + self.session.flush() + ( + self.session.query(TeamsInstallation) + .filter_by(tenant_id=tenant, application_id=application, team_id=team) + .update( + { + "active": False, + "changed_on": datetime.now(timezone.utc).replace(tzinfo=None), + }, + synchronize_session="fetch", + ) + ) diff --git a/superset/migrations/versions/2026-10-19_00-00_7b9d2f8a6c31_add_teams_installations.py b/superset/migrations/versions/2026-10-19_00-00_7b9d2f8a6c31_add_teams_installations.py new file mode 100644 index 000000000000..99bf955ce2e3 --- /dev/null +++ b/superset/migrations/versions/2026-10-19_00-00_7b9d2f8a6c31_add_teams_installations.py @@ -0,0 +1,55 @@ +# 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. +"""Add Teams bot installation persistence. + +Revision ID: 7b9d2f8a6c31 +Revises: c179af013f48 +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy_utils import UUIDType + +revision: str = "7b9d2f8a6c31" +down_revision: str = "c179af013f48" + + +def upgrade() -> None: + """Add installation metadata without changing alerts or destinations.""" + op.create_table( + "teams_installations", + sa.Column("id", UUIDType(binary=True), nullable=False), + sa.Column("tenant_id", UUIDType(binary=True), nullable=False), + sa.Column("application_id", UUIDType(binary=True), nullable=False), + sa.Column("team_id", sa.String(255), nullable=False), + sa.Column("service_url", sa.String(2048), nullable=False), + sa.Column("active", sa.Boolean(), nullable=False), + sa.Column("created_on", sa.DateTime(), nullable=False), + sa.Column("changed_on", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "tenant_id", + "application_id", + "team_id", + name="uq_teams_installation_identity", + ), + ) + + +def downgrade() -> None: + """Remove only the installation metadata table.""" + op.drop_table("teams_installations") diff --git a/superset/models/__init__.py b/superset/models/__init__.py index 1db3711227c9..eb9430bf86b8 100644 --- a/superset/models/__init__.py +++ b/superset/models/__init__.py @@ -14,6 +14,8 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +from superset.integrations.teams import models as teams_models # noqa: F401 + from . import ( # noqa: F401 core, dynamic_plugins, diff --git a/tests/unit_tests/integrations/teams/store_test.py b/tests/unit_tests/integrations/teams/store_test.py new file mode 100644 index 000000000000..a5b41f9c10de --- /dev/null +++ b/tests/unit_tests/integrations/teams/store_test.py @@ -0,0 +1,283 @@ +# 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. +"""Database-backed tests of the Teams installation lifecycle and migration.""" + +from collections.abc import Iterator +from copy import deepcopy +from datetime import datetime +from importlib import import_module +from typing import Any +from unittest.mock import MagicMock, patch +from uuid import UUID, uuid4 + +import pytest +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy import create_engine, inspect, select +from sqlalchemy.engine import Engine +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from superset.integrations.teams.config import parse_teams_config +from superset.integrations.teams.models import TeamsInstallation +from superset.integrations.teams.store import TeamsInstallationStore + +migration = import_module( + "superset.migrations.versions.2026-10-19_00-00_7b9d2f8a6c31_add_teams_installations" +) +TENANT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" +APPLICATION = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" +OTHER = "cccccccc-cccc-4ccc-8ccc-cccccccccccc" +SERVICE_URL = "https://smba.trafficmanager.net/amer/" +REFRESHED_URL = "https://smba.trafficmanager.net/emea/" + + +@pytest.fixture +def engine() -> Iterator[Engine]: + """Use the actual migration, rather than create_all, to create the test table.""" + database = create_engine("sqlite://") + with database.begin() as connection: + with Operations.context(MigrationContext.configure(connection)): + migration.upgrade() + yield database + database.dispose() + + +@pytest.fixture +def session(engine: Engine) -> Iterator[Session]: + """Provide a caller-owned transaction.""" + with Session(engine) as database_session: + yield database_session + + +def test_installation_is_persisted(engine: Engine, session: Session) -> None: + store = TeamsInstallationStore(session) + installation = store.install(TENANT, APPLICATION, "team-1", SERVICE_URL) + identity = installation.id + assert isinstance(identity, UUID) + assert installation.tenant_id == UUID(TENANT) + assert installation.application_id == UUID(APPLICATION) + assert installation.active + assert installation.service_url == SERVICE_URL + assert isinstance(installation.created_on, datetime) + assert installation.changed_on == installation.created_on + session.commit() + with Session(engine) as reader: + persisted = reader.get(TeamsInstallation, identity) + assert persisted is not None + assert persisted.service_url == SERVICE_URL + + +def test_duplicate_install_refreshes_same_record(session: Session) -> None: + store = TeamsInstallationStore(session) + original = store.install(TENANT, APPLICATION, "team-1", SERVICE_URL) + identity, created_on = original.id, original.created_on + refreshed = store.install(TENANT, APPLICATION, "team-1", REFRESHED_URL) + repeated = store.install(TENANT, APPLICATION, "team-1", REFRESHED_URL) + assert refreshed is original + assert repeated.id == identity + assert repeated.created_on == created_on + assert repeated.changed_on >= created_on + assert repeated.service_url == REFRESHED_URL + assert session.query(TeamsInstallation).count() == 1 + + +def test_remove_and_reinstall_preserves_identity(session: Session) -> None: + store = TeamsInstallationStore(session) + original = store.install(TENANT, APPLICATION, "team-1", SERVICE_URL) + identity, created_on = original.id, original.created_on + store.remove(TENANT, APPLICATION, "team-1") + store.remove(TENANT, APPLICATION, "team-1") + assert not original.active + assert store.get_active(TENANT, APPLICATION, "team-1") is None + assert session.query(TeamsInstallation).count() == 1 + reinstalled = store.install(TENANT, APPLICATION, "team-1", REFRESHED_URL) + assert reinstalled.id == identity + assert reinstalled.created_on == created_on + assert reinstalled.active + assert reinstalled.service_url == REFRESHED_URL + assert store.get_active(TENANT, APPLICATION, "team-1") is reinstalled + + +def test_unknown_removal_does_not_create_installation(session: Session) -> None: + store = TeamsInstallationStore(session) + store.remove(TENANT, APPLICATION, "unknown") + store.remove(TENANT, APPLICATION, "unknown") + assert store.get_active(TENANT, APPLICATION, "unknown") is None + assert session.query(TeamsInstallation).count() == 0 + + +@pytest.mark.parametrize( + "identity", + [ + (OTHER, APPLICATION, "team-1"), + (TENANT, OTHER, "team-1"), + (TENANT, APPLICATION, "team-2"), + ], +) +def test_identity_is_scoped_to_all_three_fields( + session: Session, identity: tuple[str, str, str] +) -> None: + store = TeamsInstallationStore(session) + first = store.install(TENANT, APPLICATION, "team-1", SERVICE_URL) + assert store.get_active(*identity) is None + second = store.install(*identity, REFRESHED_URL) + assert second.id != first.id + store.remove(*identity) + assert first.active + assert not second.active + assert session.query(TeamsInstallation).count() == 2 + + +def test_database_enforces_identity_uniqueness(session: Session) -> None: + store = TeamsInstallationStore(session) + original = store.install(TENANT, APPLICATION, "team-1", SERVICE_URL) + with pytest.raises(IntegrityError), session.begin_nested(): + session.execute( + TeamsInstallation.__table__.insert().values( + id=uuid4(), + tenant_id=UUID(TENANT), + application_id=UUID(APPLICATION), + team_id="team-1", + service_url=SERVICE_URL, + active=True, + created_on=original.created_on, + changed_on=original.changed_on, + ) + ) + assert session.query(TeamsInstallation).count() == 1 + + +def test_insert_collision_recovers_inside_savepoint(session: Session) -> None: + store = TeamsInstallationStore(session) + winner = store.install(TENANT, APPLICATION, "team-1", SERVICE_URL) + identity = winner.id + connection = session.connection() + execute = connection.execute + # Model another writer winning the insert after our first UPDATE found no row. + with patch.object(connection, "execute") as mocked: + mocked.side_effect = lambda statement, *args, **kwargs: ( + MagicMock(rowcount=0) + if mocked.call_count == 1 + else execute(statement, *args, **kwargs) + ) + refreshed = store.install(TENANT, APPLICATION, "team-1", REFRESHED_URL) + assert refreshed.id == identity + assert refreshed.service_url == REFRESHED_URL + assert session.query(TeamsInstallation).count() == 1 + # The insert error did not invalidate the caller's transaction. + assert store.install(TENANT, APPLICATION, "team-2", SERVICE_URL).active + session.commit() + + +def test_unrelated_integrity_error_is_not_swallowed(session: Session) -> None: + store = TeamsInstallationStore(session) + connection = session.connection() + execute = connection.execute + + def fail_insert(statement: Any, *args: Any, **kwargs: Any) -> Any: + """Inject an insert failure while exercising real savepoint recovery.""" + if statement.is_insert: + raise IntegrityError("insert failed", {}, Exception("unrelated constraint")) + return execute(statement, *args, **kwargs) + + with patch.object(connection, "execute", side_effect=fail_insert): + with pytest.raises(IntegrityError, match="unrelated constraint"): + store.install(TENANT, APPLICATION, "team-1", SERVICE_URL) + assert session.query(TeamsInstallation).count() == 0 + assert store.install(TENANT, APPLICATION, "team-2", SERVICE_URL).active + + +def test_caller_can_roll_back_install_and_remove(session: Session) -> None: + store = TeamsInstallationStore(session) + store.install(TENANT, APPLICATION, "team-1", SERVICE_URL) + session.rollback() + assert session.query(TeamsInstallation).count() == 0 + store.install(TENANT, APPLICATION, "team-1", SERVICE_URL) + session.commit() + store.remove(TENANT, APPLICATION, "team-1") + session.rollback() + assert store.get_active(TENANT, APPLICATION, "team-1") is not None + + +@pytest.mark.parametrize( + "url", + [ + "http://example.com", + "https://user:secret@example.com", + "https://example.com?secret=1", + "https://example.com/#fragment", + "https://example.com:80", + "https://example.com/with space", + "", + "https://[broken", + ], +) +def test_rejects_invalid_service_url(session: Session, url: str) -> None: + with pytest.raises(ValueError, match="HTTPS URL"): + TeamsInstallationStore(session).install(TENANT, APPLICATION, "team-1", url) + assert session.query(TeamsInstallation).count() == 0 + + +def test_installation_does_not_authorize_destinations(session: Session) -> None: + raw_config = { + "tenant_id": TENANT, + "application_id": APPLICATION, + "credential": "test-secret", + "destinations": [ + { + "key": "ops", + "label": "Operations", + "team_id": "approved", + "channel_id": "channel-1", + } + ], + } + before = deepcopy(raw_config) + config = parse_teams_config(raw_config) + assert config is not None + store = TeamsInstallationStore(session) + store.install(TENANT, APPLICATION, "unapproved", SERVICE_URL) + store.remove(TENANT, APPLICATION, "unapproved") + store.install(TENANT, APPLICATION, "unapproved", REFRESHED_URL) + assert raw_config == before + assert config.public_destinations() == [{"key": "ops", "label": "Operations"}] + assert config.get_destination("ops").team_id == "approved" + assert set(TeamsInstallation.__table__.columns.keys()) == { + "id", + "tenant_id", + "application_id", + "team_id", + "service_url", + "active", + "created_on", + "changed_on", + } + + +def test_migration_downgrade_and_reupgrade(engine: Engine, session: Session) -> None: + TeamsInstallationStore(session).install(TENANT, APPLICATION, "team-1", SERVICE_URL) + session.commit() + with engine.begin() as connection: + connection.exec_driver_sql("CREATE TABLE teams_migration_sentinel (id INTEGER)") + with Operations.context(MigrationContext.configure(connection)): + migration.downgrade() + assert "teams_installations" not in inspect(connection).get_table_names() + assert "teams_migration_sentinel" in inspect(connection).get_table_names() + migration.upgrade() + assert "teams_installations" in inspect(connection).get_table_names() + assert connection.execute(select(TeamsInstallation.id)).all() == [] From 2232075cd3a084fb1e765f0032b7529795336c1b Mon Sep 17 00:00:00 2001 From: Kristi Dodaj Date: Mon, 19 Oct 2026 15:16:35 -0400 Subject: [PATCH 03/30] feat(reports): handle authenticated Teams installation events --- .../configuration/alerts-reports.mdx | 24 +- pyproject.toml | 2 +- superset/initialization/__init__.py | 3 + superset/integrations/teams/adapter.py | 212 +++++++ superset/integrations/teams/api.py | 100 +++ .../unit_tests/integrations/teams/api_test.py | 575 ++++++++++++++++++ 6 files changed, 912 insertions(+), 4 deletions(-) create mode 100644 superset/integrations/teams/adapter.py create mode 100644 superset/integrations/teams/api.py create mode 100644 tests/unit_tests/integrations/teams/api_test.py diff --git a/docs/admin_docs/configuration/alerts-reports.mdx b/docs/admin_docs/configuration/alerts-reports.mdx index c90766e7b428..e565722f61ed 100644 --- a/docs/admin_docs/configuration/alerts-reports.mdx +++ b/docs/admin_docs/configuration/alerts-reports.mdx @@ -894,9 +894,11 @@ Airflow, additional fields can be easily added to the configuration file above. ## Microsoft Teams configuration (development) -The optional `apache-superset[teams]` extra provides MSAL for bot authentication. -`FEATURE_FLAGS["ALERT_REPORT_TEAMS"]` defaults to `False`. This configuration -foundation does not register a recipient type or send messages yet. +The optional `apache-superset[teams]` extra installs Microsoft Teams SDK 2.1.1. +The SDK handles callback JWT authentication; SDK-specific code lives in +`superset.integrations.teams.adapter`. +`FEATURE_FLAGS["ALERT_REPORT_TEAMS"]` defaults to `False`. The integration +does not register a recipient type or send messages yet. Set `ALERT_REPORTS_TEAMS` in `superset_config.py` with `tenant_id` (UUID), `application_id` (bot application UUID), `credential` (client secret from your @@ -916,6 +918,22 @@ approve standard channels in that tenant; the parser cannot verify channel type or tenant membership without Teams metadata. Private/shared channels, DMs, scheduled reports, and attachments are outside the integration scope. +Set the bot's messaging endpoint to `POST /api/v1/teams/events/`. The callback +is enabled only by `ALERT_REPORT_TEAMS` and a valid `ALERT_REPORTS_TEAMS` mapping. +It authenticates installation `add` and `remove` events using the SDK's public +`HttpServer` with a custom `HttpServerAdapter` inside Flask. It never starts the +SDK's standalone server or creates a full SDK `App`. Each invocation closes its +event loop and database session on success or failure. + +Events must be Bot Framework connector activities with a valid token for the +configured application and a signed `serviceurl` claim matching the event URL. +The callback also requires the configured tenant, bot recipient, and an approved +team from `destinations`. Personal/group chats, explicit private/shared channel +contexts, other activity types, and attachments are rejected. Installation events +cannot add channels to configuration. Browser sessions do not authenticate the +callback. Only this independently authenticated route is exempt from CSRF checks. +Agent 365/Entra activity tokens are outside this notification-only bot's scope. + Bot installations are stored separately in `teams_installations`, uniquely keyed by tenant, application, and team. Installation events refresh the service URL and reactivate the same UUID record; removal makes it inactive. Duplicate events diff --git a/pyproject.toml b/pyproject.toml index 5aef80b56cb8..1526d1b9d075 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -268,7 +268,7 @@ pinot = ["pinotdb[sqlalchemy]>=9.2.2, <10.0.0"] playwright = ["playwright>=1.64.0, <2"] postgres = ["psycopg2-binary==2.9.13"] presto = ["pyhive[presto]>=0.7.0"] -teams = ["msal>=1.31.0, <2"] +teams = ["microsoft-teams-apps==2.1.1"] trino = ["trino>=0.340.0"] prophet = [ "prophet>=1.5.0, <2", diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index 3280ea87e826..1f57bef51d58 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -1811,6 +1811,9 @@ def configure_websocket(self) -> None: register_ws_channel_cookie(self.superset_app) def register_blueprints(self) -> None: + from superset.integrations.teams.api import register_teams_api + + register_teams_api(self.superset_app) # Register custom blueprints from config for bp in self.config["BLUEPRINTS"]: try: diff --git a/superset/integrations/teams/adapter.py b/superset/integrations/teams/adapter.py new file mode 100644 index 000000000000..d2dd51fa371f --- /dev/null +++ b/superset/integrations/teams/adapter.py @@ -0,0 +1,212 @@ +# 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. +"""Microsoft Teams SDK boundary for synchronous Flask installation callbacks.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import Any, Literal +from uuid import UUID + +from microsoft_teams.api import ( + ClientCredentials, + InstalledActivity, + InvokeResponse, + JsonWebToken, + UninstalledActivity, +) +from microsoft_teams.api.auth.cloud_environment import PUBLIC +from microsoft_teams.apps import HttpServer, HttpServerAdapter +from microsoft_teams.apps.events import ActivityEvent +from microsoft_teams.apps.http import HttpRequest, HttpResponse, HttpRouteHandler +from pydantic import ValidationError + +from superset.integrations.teams.config import TeamsConfig + +EVENTS_PATH = "/api/v1/teams/events/" + + +@dataclass(frozen=True) +class TeamsInstallationEvent: + """Authenticated, deployment-scoped installation data, with no SDK objects.""" + + team_id: str + service_url: str + active: bool + + +@dataclass(frozen=True) +class TeamsEventResult: + """Callback outcome; only an authenticated event may reach persistence.""" + + status: int + event: TeamsInstallationEvent | None = None + + +class _FlaskServerAdapter(HttpServerAdapter): + """Capture the SDK route for one invocation of Flask's existing POST route. + + Flask registers its route once at startup. Each request creates a fresh SDK + authentication server; its handler runs to completion in an operation-scoped + event loop. No SDK App, outbound clients, tasks, or separate server are created. + """ + + def __init__(self) -> None: + """Hold only the handler for this request.""" + self.handler: HttpRouteHandler | None = None + + def register_route( + self, method: Literal["POST"], path: str, handler: HttpRouteHandler + ) -> None: + """Accept only the independently authenticated Teams callback.""" + if method != "POST" or path != EVENTS_PATH or self.handler is not None: + raise ValueError("Unexpected Teams SDK route registration") + self.handler = handler + + async def start(self, port: int) -> None: + """Prevent the SDK from starting its own HTTP server.""" + raise RuntimeError("Flask manages the Teams callback server") + + async def stop(self) -> None: + """Release the operation's handler; Flask owns the HTTP server.""" + self.handler = None + + +def _same_uuid(value: object, expected: str) -> bool: + """Compare concrete UUIDs without accepting tenant aliases.""" + try: + return isinstance(value, str) and UUID(value) == UUID(expected) + except ValueError: + return False + + +def _allowed_token(config: TeamsConfig, event: ActivityEvent) -> bool: + """Restrict the SDK's broader auth support to expiring bot service tokens.""" + # Only Bot Framework service tokens bind serviceurl to a signed claim. + # Agent 365/Entra activities and bot-to-bot callers are outside this integration. + token = event.token + return ( + isinstance(token, JsonWebToken) + and token.issuer == PUBLIC.token_issuer + and token.expiration is not None + and (not token.tenant_id or _same_uuid(token.tenant_id, config.tenant_id)) + ) + + +def _installation_event( + config: TeamsConfig, event: ActivityEvent, body: dict[str, Any] +) -> TeamsEventResult: + """Apply deployment restrictions only after the SDK has authenticated the token.""" + if not _allowed_token(config, event): + return TeamsEventResult(403) + token = event.token + if body.get("type") != "installationUpdate" or body.get("action") not in ( + "add", + "remove", + ): + return TeamsEventResult(400) + if body.get("attachments"): + return TeamsEventResult(400) + # Require the wire-format fields used by SDK authentication. Do not allow + # model defaults or snake_case aliases to bypass missing channel/service data. + if body.get("channelId") != "msteams" or not isinstance( + body.get("serviceUrl"), str + ): + return TeamsEventResult(400) + service_url = body["serviceUrl"] + if not service_url or token.service_url.rstrip("/") != service_url.rstrip("/"): + return TeamsEventResult(403) + try: + model = InstalledActivity if body["action"] == "add" else UninstalledActivity + activity = model.model_validate(body) + except ValidationError: + return TeamsEventResult(400) + if activity.recipient.id not in { + config.application_id, + f"28:{config.application_id}", + }: + return TeamsEventResult(403) + data = activity.channel_data + if ( + data is None + or data.tenant is None + or not _same_uuid(data.tenant.id, config.tenant_id) + or activity.conversation.conversation_type != "channel" + or ( + activity.conversation.tenant_id is not None + and not _same_uuid(activity.conversation.tenant_id, config.tenant_id) + ) + or data.team is None + or not data.team.id + or data.team.team_type not in (None, "standard") + or (data.channel is not None and data.channel.type not in (None, "standard")) + ): + return TeamsEventResult(403) + approved_teams = {destination.team_id for destination in config.destinations} + if data.team.id not in approved_teams: + return TeamsEventResult(403) + return TeamsEventResult( + 200, TeamsInstallationEvent(data.team.id, service_url, body["action"] == "add") + ) + + +def process_teams_event( + config: TeamsConfig, body: dict[str, Any], headers: dict[str, str] +) -> TeamsEventResult: + """Authenticate through the SDK's server adapter without starting a server. + + The SDK's HttpServer uses PyJWKClient, whose network responses close within + its scoped fetch. It creates no persistent HTTP clients. Keep construction, + authentication, and adapter cleanup within the same event loop; asyncio.run + closes that loop on success and failure. SDK response bodies are never exposed. + """ + + async def process() -> TeamsEventResult: + """Run one SDK invocation, returning only sanitized application data.""" + adapter = _FlaskServerAdapter() + result = TeamsEventResult(500) + + async def on_request(event: ActivityEvent) -> InvokeResponse[None]: + """Receive activities only after SDK authentication succeeds.""" + nonlocal result + result = _installation_event(config, event, body) + return InvokeResponse(status=result.status) + + try: + server = HttpServer(adapter, messaging_endpoint=EVENTS_PATH) + server.on_request = on_request + server.initialize( + credentials=ClientCredentials( + client_id=config.application_id, + client_secret=config.credential, + tenant_id=config.tenant_id, + ), + dangerously_allow_unauthenticated_requests=False, + ) + if adapter.handler is None: + return TeamsEventResult(500) + response: HttpResponse = await adapter.handler( + HttpRequest(body=body, headers=headers) + ) + if response["status"] != 200: + return TeamsEventResult(response["status"]) + return result + finally: + await adapter.stop() + + return asyncio.run(process()) diff --git a/superset/integrations/teams/api.py b/superset/integrations/teams/api.py new file mode 100644 index 000000000000..a30af36f7600 --- /dev/null +++ b/superset/integrations/teams/api.py @@ -0,0 +1,100 @@ +# 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. +"""Independently authenticated, installation-only Teams bot callback.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from flask import Blueprint, current_app, Flask, request, Response +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session + +from superset.extensions import csrf, db, feature_flag_manager +from superset.integrations.teams.config import ( + parse_teams_config, + TeamsConfig, + TeamsConfigError, +) +from superset.integrations.teams.store import TeamsInstallationStore + +if TYPE_CHECKING: + from superset.integrations.teams.adapter import TeamsInstallationEvent + +logger = logging.getLogger(__name__) +blueprint = Blueprint("teams", __name__) + + +@blueprint.post("/api/v1/teams/events/") +@csrf.exempt +def teams_events() -> Response: + """Accept SDK-authenticated installation events for approved teams.""" + if not feature_flag_manager.is_feature_enabled("ALERT_REPORT_TEAMS"): + return Response(status=404) + try: + config = parse_teams_config(current_app.config.get("ALERT_REPORTS_TEAMS", {})) + except TeamsConfigError: + logger.error("Invalid Teams deployment configuration") + return Response(status=503) + if config is None: + return Response(status=503) + if not request.is_json: + return Response(status=415) + body = request.get_json(silent=True) + if not isinstance(body, dict): + return Response(status=400) + # Optional SDK imports stay behind the feature flag and deployment configuration. + try: + from superset.integrations.teams.adapter import process_teams_event + except ImportError: + logger.error("Teams SDK unavailable; install the teams dependency extra") + return Response(status=503) + result = process_teams_event(config, body, dict(request.headers)) + if result.status != 200 or result.event is None: + return Response(status=result.status) + event = result.event + try: + _persist_event(config, event) + except ValueError: + return Response(status=400) + except SQLAlchemyError: + logger.error("Unable to persist Teams installation event") + return Response(status=500) + return Response(status=200) + + +def _persist_event(config: TeamsConfig, event: TeamsInstallationEvent) -> None: + """Commit one installation change, rolling back and closing on failure.""" + # A separate metadata session keeps transaction state out of Flask's session. + # Persistence stays synchronous; the SDK event loop has already closed. + with Session(db.engine) as session, session.begin(): + store = TeamsInstallationStore(session) + if event.active: + store.install( + config.tenant_id, + config.application_id, + event.team_id, + event.service_url, + ) + else: + store.remove(config.tenant_id, config.application_id, event.team_id) + + +def register_teams_api(app: Flask) -> None: + """Register the callback without adding a blueprint-wide CSRF exemption.""" + app.register_blueprint(blueprint) diff --git a/tests/unit_tests/integrations/teams/api_test.py b/tests/unit_tests/integrations/teams/api_test.py new file mode 100644 index 000000000000..251248e74108 --- /dev/null +++ b/tests/unit_tests/integrations/teams/api_test.py @@ -0,0 +1,575 @@ +# 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. +"""Exercise the real Teams SDK authentication boundary and Flask persistence.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable, Iterator +from copy import deepcopy +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from typing import Any +from unittest.mock import patch + +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa +from flask import current_app, Flask, Response +from flask.testing import FlaskClient +from sqlalchemy import create_engine +from sqlalchemy.engine import Engine +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session + +from superset.integrations.teams import api as teams_api +from superset.integrations.teams.config import parse_teams_config +from superset.integrations.teams.models import TeamsInstallation +from superset.integrations.teams.store import TeamsInstallationStore + +teams_adapter = pytest.importorskip( + "superset.integrations.teams.adapter", exc_type=ImportError +) +PATH = "/api/v1/teams/events/" +TENANT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" +APPLICATION = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" +OTHER = "cccccccc-cccc-4ccc-8ccc-cccccccccccc" +SERVICE_URL = "https://smba.trafficmanager.net/amer/" + + +@pytest.fixture(scope="module") +def signing_key() -> rsa.RSAPrivateKey: + """Sign real SDK-validated tokens without contacting Microsoft's JWKS service.""" + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +@pytest.fixture(autouse=True) +def trusted_signing_key( + monkeypatch: pytest.MonkeyPatch, signing_key: rsa.RSAPrivateKey +) -> None: + """Replace only key discovery; the SDK verifies signature and claims itself.""" + monkeypatch.setattr( + jwt.PyJWKClient, + "get_signing_key_from_jwt", + lambda self, token: SimpleNamespace(key=signing_key.public_key()), + ) + + +@pytest.fixture +def auth_headers(signing_key: rsa.RSAPrivateKey) -> Callable[..., dict[str, str]]: + """Build connector JWTs with optional invalid claims or signing key.""" + + def sign( + claims: dict[str, Any] | None = None, + key: rsa.RSAPrivateKey | None = None, + ) -> dict[str, str]: + """Sign a token so failures exercise the SDK's real validator.""" + now = datetime.now(timezone.utc) + payload = { + "aud": APPLICATION, + "iss": "https://api.botframework.com", + "iat": int(now.timestamp()), + "nbf": int(now.timestamp()), + "exp": int((now + timedelta(hours=1)).timestamp()), + "serviceurl": SERVICE_URL, + } + payload.update(claims or {}) + return { + "Authorization": "Bearer " + + jwt.encode(payload, key or signing_key, algorithm="RS256") + } + + return sign + + +@pytest.fixture +def teams_config() -> dict[str, Any]: + """Configure one tenant, application and explicitly approved destination.""" + return { + "tenant_id": TENANT, + "application_id": APPLICATION, + "credential": "deployment-secret", + "destinations": [ + { + "key": "ops", + "label": "Operations", + "team_id": "team-1", + "channel_id": "channel-1", + } + ], + } + + +@pytest.fixture +def installation() -> dict[str, Any]: + """An installationUpdate event from a standard team channel.""" + return { + "type": "installationUpdate", + "action": "add", + "id": "event-1", + "serviceUrl": SERVICE_URL, + "channelId": "msteams", + "from": {"id": "29:installer"}, + "recipient": {"id": f"28:{APPLICATION}"}, + "conversation": { + "id": "channel-1", + "conversationType": "channel", + "tenantId": TENANT, + }, + "channelData": { + "tenant": {"id": TENANT}, + "team": {"id": "team-1"}, + "channel": {"id": "channel-1", "type": "standard"}, + }, + } + + +@pytest.fixture +def teams_engine() -> Iterator[Engine]: + """Create only the installation model's real database table.""" + engine = create_engine("sqlite://") + TeamsInstallation.__table__.create(engine) + yield engine + engine.dispose() + + +@pytest.fixture +def teams_client( + teams_config: dict[str, Any], + teams_engine: Engine, + monkeypatch: pytest.MonkeyPatch, +) -> FlaskClient: + """Mount the callback in Flask with real CSRF enforcement and SQLAlchemy.""" + app = Flask(__name__) + app.config.update( + TESTING=True, + SECRET_KEY="test-callback-key", # noqa: S106 + WTF_CSRF_ENABLED=True, + FEATURE_FLAGS={"ALERT_REPORT_TEAMS": True}, + ALERT_REPORTS_TEAMS=teams_config, + ) + monkeypatch.setattr(teams_api, "db", SimpleNamespace(engine=teams_engine)) + monkeypatch.setattr( + teams_api.feature_flag_manager, + "is_feature_enabled", + lambda name: current_app.config["FEATURE_FLAGS"].get(name, False), + ) + teams_api.csrf.init_app(app) + teams_api.register_teams_api(app) + + @app.post("/csrf-control") + def csrf_control() -> Response: + """An unrelated route must remain CSRF protected.""" + return Response(status=200) + + return app.test_client() + + +def test_callback_persists_only_approved_metadata( + teams_client: FlaskClient, + teams_engine: Engine, + teams_config: dict[str, Any], + installation: dict[str, Any], + auth_headers: Callable[..., dict[str, str]], +) -> None: + before = deepcopy(teams_config) + response = teams_client.post(PATH, json=installation, headers=auth_headers()) + assert response.status_code == 200 + assert response.data == b"" + with Session(teams_engine) as session: + persisted = session.query(TeamsInstallation).one() + assert persisted.service_url == SERVICE_URL + assert persisted.active + assert str(persisted.tenant_id) == TENANT + assert str(persisted.application_id) == APPLICATION + assert persisted.team_id == "team-1" + assert teams_config == before + assert b"deployment-secret" not in response.data + + +@pytest.mark.parametrize( + "authentication", + [ + "missing", + "malformed", + "bad-signature", + "wrong-audience", + "expired", + "wrong-issuer", + "missing-serviceurl", + "mismatched-serviceurl", + ], +) +def test_invalid_authentication_never_opens_database_session( + teams_client: FlaskClient, + installation: dict[str, Any], + auth_headers: Callable[..., dict[str, str]], + authentication: str, +) -> None: + if authentication == "missing": + headers = {} + elif authentication == "malformed": + headers = {"Authorization": "Bearer not-a-jwt"} + elif authentication == "bad-signature": + headers = auth_headers( + key=rsa.generate_private_key(public_exponent=65537, key_size=2048) + ) + else: + claims = { + "wrong-audience": {"aud": OTHER}, + "expired": {"exp": 1}, + "wrong-issuer": {"iss": "https://untrusted.example"}, + "missing-serviceurl": {"serviceurl": None}, + "mismatched-serviceurl": { + "serviceurl": "https://smba.trafficmanager.net/other/" + }, + } + headers = auth_headers(claims[authentication]) + with patch.object(teams_api, "Session") as session: + response = teams_client.post(PATH, json=installation, headers=headers) + assert response.status_code == 401 + assert response.data == b"" + session.assert_not_called() + + +@pytest.mark.parametrize( + "mismatch", + [ + "tenant", + "conversation-tenant", + "token-tenant", + "recipient", + "team", + "personal", + "groupChat", + "private", + "shared", + "private-team", + "shared-team", + ], +) +def test_authenticated_events_must_match_deployment_scope( + teams_client: FlaskClient, + installation: dict[str, Any], + auth_headers: Callable[..., dict[str, str]], + mismatch: str, +) -> None: + claims = {} + if mismatch == "tenant": + installation["channelData"]["tenant"]["id"] = OTHER + elif mismatch == "conversation-tenant": + installation["conversation"]["tenantId"] = OTHER + elif mismatch == "token-tenant": + claims = {"tid": OTHER} + elif mismatch == "recipient": + installation["recipient"]["id"] = f"28:{OTHER}" + elif mismatch == "team": + installation["channelData"]["team"]["id"] = "unapproved-team" + elif mismatch in {"personal", "groupChat"}: + installation["conversation"]["conversationType"] = mismatch + elif mismatch in {"private", "shared"}: + installation["channelData"]["channel"]["type"] = mismatch + else: + installation["channelData"]["team"]["teamType"] = ( + "privateChannel" if mismatch == "private-team" else "sharedChannel" + ) + with patch.object(teams_api, "Session") as session: + response = teams_client.post( + PATH, json=installation, headers=auth_headers(claims) + ) + assert response.status_code == 403 + session.assert_not_called() + + +def test_entra_tokens_cannot_bypass_signed_service_url_binding( + teams_client: FlaskClient, + installation: dict[str, Any], + auth_headers: Callable[..., dict[str, str]], +) -> None: + # The SDK also supports Agent 365 tokens; this notification bot accepts only + # connector tokens whose signed serviceurl claim binds the installation URL. + headers = auth_headers( + { + "iss": f"https://login.microsoftonline.com/{TENANT}/v2.0", + "tid": TENANT, + "azp": "5a807f24-c9de-44ee-a3a7-329e88a00ffc", + "serviceurl": None, + } + ) + with patch.object(teams_api, "Session") as session: + response = teams_client.post(PATH, json=installation, headers=headers) + assert response.status_code == 403 + session.assert_not_called() + + +def test_tokens_without_expiration_are_rejected( + teams_client: FlaskClient, + installation: dict[str, Any], + auth_headers: Callable[..., dict[str, str]], +) -> None: + with patch.object(teams_api, "Session") as session: + response = teams_client.post( + PATH, json=installation, headers=auth_headers({"exp": None}) + ) + assert response.status_code in (401, 403) + session.assert_not_called() + + +def test_repeated_events_removal_and_reinstallation( + teams_client: FlaskClient, + teams_engine: Engine, + installation: dict[str, Any], + auth_headers: Callable[..., dict[str, str]], +) -> None: + headers = auth_headers() + for _ in range(2): + assert ( + teams_client.post(PATH, json=installation, headers=headers).status_code + == 200 + ) + with Session(teams_engine) as session: + original = session.query(TeamsInstallation).one() + identity, created_on = original.id, original.created_on + installation["action"] = "remove" + for _ in range(2): + assert ( + teams_client.post(PATH, json=installation, headers=headers).status_code + == 200 + ) + with Session(teams_engine) as session: + removed = session.query(TeamsInstallation).one() + assert removed.id == identity + assert not removed.active + installation["action"] = "add" + refreshed = "https://smba.trafficmanager.net/emea/" + installation["serviceUrl"] = refreshed + for _ in range(2): + response = teams_client.post( + PATH, json=installation, headers=auth_headers({"serviceurl": refreshed}) + ) + assert response.status_code == 200 + with Session(teams_engine) as session: + reinstalled = session.query(TeamsInstallation).one() + assert reinstalled.id == identity + assert reinstalled.created_on == created_on + assert reinstalled.active + assert reinstalled.service_url == refreshed + + +def test_removal_does_not_create_unknown_installation( + teams_client: FlaskClient, + teams_engine: Engine, + installation: dict[str, Any], + auth_headers: Callable[..., dict[str, str]], +) -> None: + installation["action"] = "remove" + assert ( + teams_client.post(PATH, json=installation, headers=auth_headers()).status_code + == 200 + ) + with Session(teams_engine) as session: + assert session.query(TeamsInstallation).count() == 0 + + +@pytest.mark.parametrize( + "change", + [ + "type", + "action", + "malformed-action", + "missing-service", + "missing-channel", + "missing-recipient", + "attachment", + ], +) +def test_unsupported_or_incomplete_events_do_not_persist( + teams_client: FlaskClient, + installation: dict[str, Any], + auth_headers: Callable[..., dict[str, str]], + change: str, +) -> None: + if change == "type": + installation["type"] = "message" + elif change == "action": + installation["action"] = "unknown" + elif change == "malformed-action": + installation["action"] = [] + elif change == "missing-service": + installation.pop("serviceUrl") + elif change == "missing-channel": + installation.pop("channelId") + elif change == "missing-recipient": + installation.pop("recipient") + else: + installation["attachments"] = [{"contentType": "image/png"}] + with patch.object(teams_api, "Session") as session: + response = teams_client.post(PATH, json=installation, headers=auth_headers()) + assert response.status_code == 400 + session.assert_not_called() + + +def test_session_cookie_does_not_authenticate_callback( + teams_client: FlaskClient, installation: dict[str, Any] +) -> None: + with teams_client.session_transaction() as browser_session: + browser_session["_user_id"] = "1" + browser_session["_fresh"] = True + with patch.object(teams_api, "Session") as session: + response = teams_client.post(PATH, json=installation) + assert response.status_code == 401 + session.assert_not_called() + + +def test_csrf_exemption_is_only_for_callback(teams_client: FlaskClient) -> None: + # Without a CSRF exemption this would be 400 before SDK authentication. + assert teams_client.post(PATH, json={}).status_code == 401 + assert teams_client.post("/csrf-control").status_code == 400 + assert teams_client.get(PATH).status_code == 405 + + +@pytest.mark.parametrize( + "mode", ["disabled", "unconfigured", "invalid-config", "missing-sdk"] +) +def test_callback_fails_closed_without_sdk_or_configuration( + teams_client: FlaskClient, + installation: dict[str, Any], + auth_headers: Callable[..., dict[str, str]], + mode: str, +) -> None: + expected = 503 + if mode == "disabled": + teams_client.application.config["FEATURE_FLAGS"]["ALERT_REPORT_TEAMS"] = False + expected = 404 + elif mode == "unconfigured": + teams_client.application.config["ALERT_REPORTS_TEAMS"] = {} + elif mode == "invalid-config": + teams_client.application.config["ALERT_REPORTS_TEAMS"] = { + "credential": "deployment-secret" + } + with patch.dict("sys.modules", {"superset.integrations.teams.adapter": None}): + response = teams_client.post(PATH, json=installation, headers=auth_headers()) + assert response.status_code == expected + assert response.data == b"" + + +def test_bad_json_is_rejected(teams_client: FlaskClient) -> None: + assert teams_client.post(PATH, data="text").status_code == 415 + assert ( + teams_client.post(PATH, data="{", content_type="application/json").status_code + == 400 + ) + assert teams_client.post(PATH, json=[]).status_code == 400 + + +@pytest.mark.parametrize("fail", [False, True]) +def test_database_operation_closes_session_and_commits_or_rolls_back( + teams_client: FlaskClient, + teams_engine: Engine, + installation: dict[str, Any], + auth_headers: Callable[..., dict[str, str]], + monkeypatch: pytest.MonkeyPatch, + fail: bool, +) -> None: + original_install = TeamsInstallationStore.install + closed: list[bool] = [] + + class TrackedSession(Session): + """Observe cleanup without replacing real SQLAlchemy transactions.""" + + def close(self) -> None: + """Record resource closure after rollback.""" + super().close() + closed.append(not self.in_transaction()) + + def failing_install( + self: TeamsInstallationStore, *args: Any, **kwargs: Any + ) -> TeamsInstallation: + """Fail after writing so rollback must undo the database insert.""" + installation = original_install(self, *args, **kwargs) + if fail: + raise SQLAlchemyError("injected persistence failure") + return installation + + monkeypatch.setattr(TeamsInstallationStore, "install", failing_install) + monkeypatch.setattr(teams_api, "Session", TrackedSession) + response = teams_client.post(PATH, json=installation, headers=auth_headers()) + assert response.status_code == (500 if fail else 200) + assert response.data == b"" + assert closed == [True] + with Session(teams_engine) as session: + assert session.query(TeamsInstallation).count() == (0 if fail else 1) + + +@pytest.mark.parametrize("invalid_auth", [False, True]) +def test_sdk_adapter_and_event_loop_are_operation_scoped( + teams_config: dict[str, Any], + installation: dict[str, Any], + auth_headers: Callable[..., dict[str, str]], + monkeypatch: pytest.MonkeyPatch, + invalid_auth: bool, +) -> None: + config = parse_teams_config(teams_config) + assert config is not None + loops: list[asyncio.AbstractEventLoop] = [] + original_stop = teams_adapter._FlaskServerAdapter.stop + + async def stop(adapter: Any) -> None: + """Ensure cleanup runs within the operation's loop.""" + loops.append(asyncio.get_running_loop()) + await original_stop(adapter) + assert adapter.handler is None + + monkeypatch.setattr(teams_adapter._FlaskServerAdapter, "stop", stop) + # This callback needs authentication only; no SDK App/outbound clients are used. + with patch( + "microsoft_teams.common.http.Client.__init__", + side_effect=AssertionError("unexpected outbound client"), + ): + result = teams_adapter.process_teams_event( + config, installation, {} if invalid_auth else auth_headers() + ) + assert result.status == (401 if invalid_auth else 200) + assert len(loops) == 1 + assert loops[0].is_closed() + + +def test_sdk_initialization_failure_still_releases_adapter( + teams_config: dict[str, Any], + installation: dict[str, Any], + monkeypatch: pytest.MonkeyPatch, +) -> None: + config = parse_teams_config(teams_config) + assert config is not None + loops: list[asyncio.AbstractEventLoop] = [] + original_stop = teams_adapter._FlaskServerAdapter.stop + + async def stop(adapter: Any) -> None: + """Record cleanup even if route initialization raises.""" + loops.append(asyncio.get_running_loop()) + await original_stop(adapter) + + monkeypatch.setattr(teams_adapter._FlaskServerAdapter, "stop", stop) + with patch.object( + teams_adapter.HttpServer, + "initialize", + side_effect=RuntimeError("initialization failed"), + ): + with pytest.raises(RuntimeError, match="initialization failed"): + teams_adapter.process_teams_event(config, installation, {}) + assert len(loops) == 1 + assert loops[0].is_closed() From 0b0e3b974e11d1512118ab53963dfd9f4a25d30e Mon Sep 17 00:00:00 2001 From: Kristi Dodaj Date: Mon, 19 Oct 2026 15:35:03 -0400 Subject: [PATCH 04/30] feat(reports): deliver alert notifications to Teams channels --- .../configuration/alerts-reports.mdx | 28 +- superset/integrations/teams/adapter.py | 306 ++++++- superset/reports/models.py | 1 + superset/reports/notifications/__init__.py | 1 + superset/reports/notifications/teams.py | 169 ++++ .../integrations/teams/delivery_test.py | 751 ++++++++++++++++++ 6 files changed, 1251 insertions(+), 5 deletions(-) create mode 100644 superset/reports/notifications/teams.py create mode 100644 tests/unit_tests/integrations/teams/delivery_test.py diff --git a/docs/admin_docs/configuration/alerts-reports.mdx b/docs/admin_docs/configuration/alerts-reports.mdx index e565722f61ed..89f7869cee70 100644 --- a/docs/admin_docs/configuration/alerts-reports.mdx +++ b/docs/admin_docs/configuration/alerts-reports.mdx @@ -897,8 +897,9 @@ Airflow, additional fields can be easily added to the configuration file above. The optional `apache-superset[teams]` extra installs Microsoft Teams SDK 2.1.1. The SDK handles callback JWT authentication; SDK-specific code lives in `superset.integrations.teams.adapter`. -`FEATURE_FLAGS["ALERT_REPORT_TEAMS"]` defaults to `False`. The integration -does not register a recipient type or send messages yet. +`FEATURE_FLAGS["ALERT_REPORT_TEAMS"]` defaults to `False`. The `Teams` notification +recipient sends attachment-free alerts to configured standard channels; scheduled +reports are rejected. Set `ALERT_REPORTS_TEAMS` in `superset_config.py` with `tenant_id` (UUID), `application_id` (bot application UUID), `credential` (client secret from your @@ -918,6 +919,29 @@ approve standard channels in that tenant; the parser cannot verify channel type or tenant membership without Teams metadata. Private/shared channels, DMs, scheduled reports, and attachments are outside the integration scope. +Set the recipient type to `Teams` and `recipient_config_json` to +`{"target": "ops"}`, where `ops` is a destination key from the deployment +allowlist. The recipient's configuration must contain only `target`; channel IDs and credentials stay on the +server. Delivery requires an active installation matching the configured tenant, +application, and destination team. + +Each alert creates a new root channel post with an Adaptive Card containing its +name and optional description. The optional **Open in Superset** action follows +the existing `include_cta` setting. Cards use normal JSON serialization; execution +diagnostics are replaced by a generic failure message. File uploads and embedded +tabular results are rejected. The Adaptive Card's protocol attachment envelope +contains only the card, with no attached files. + +Transport runs in the Teams adapter with a replaceable operation-scoped interface. +It allows at most four attempts within a 60-second retry window, bounds individual +HTTP requests to 10 seconds or the remaining window, and honors `Retry-After` +(seconds or HTTP date) and `x-ms-retry-after-ms`. A delay exceeding the remaining +window ends delivery without retrying early. One 401 response can invalidate the +MSAL token cache and retry with fresh authentication; a second 401 or any 403 +fails authorization. Transient exhaustion uses the existing notification transient +exception. A failed or lost response can make delivery ambiguous, so transport +retries can produce duplicate root posts; no conversation IDs are reused. + Set the bot's messaging endpoint to `POST /api/v1/teams/events/`. The callback is enabled only by `ALERT_REPORT_TEAMS` and a valid `ALERT_REPORTS_TEAMS` mapping. It authenticates installation `add` and `remove` events using the SDK's public diff --git a/superset/integrations/teams/adapter.py b/superset/integrations/teams/adapter.py index d2dd51fa371f..92aa37f899a5 100644 --- a/superset/integrations/teams/adapter.py +++ b/superset/integrations/teams/adapter.py @@ -14,29 +14,43 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -"""Microsoft Teams SDK boundary for synchronous Flask installation callbacks.""" +"""Microsoft Teams SDK boundary for Flask callbacks and root channel delivery.""" from __future__ import annotations import asyncio +import math +from collections.abc import AsyncIterator, Callable +from contextlib import AbstractAsyncContextManager, asynccontextmanager from dataclasses import dataclass -from typing import Any, Literal +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime +from time import monotonic +from typing import Any, Literal, Protocol from uuid import UUID +import httpx +import requests from microsoft_teams.api import ( + ApiClient, + Attachment, ClientCredentials, InstalledActivity, InvokeResponse, JsonWebToken, + MessageActivityInput, UninstalledActivity, ) from microsoft_teams.api.auth.cloud_environment import PUBLIC +from microsoft_teams.api.clients.conversation import CreateConversationParams from microsoft_teams.apps import HttpServer, HttpServerAdapter from microsoft_teams.apps.events import ActivityEvent from microsoft_teams.apps.http import HttpRequest, HttpResponse, HttpRouteHandler +from microsoft_teams.common import Client, ClientOptions +from msal import ConfidentialClientApplication from pydantic import ValidationError -from superset.integrations.teams.config import TeamsConfig +from superset.integrations.teams.config import TeamsConfig, TeamsDestination EVENTS_PATH = "/api/v1/teams/events/" @@ -210,3 +224,289 @@ async def on_request(event: ActivityEvent) -> InvokeResponse[None]: await adapter.stop() return asyncio.run(process()) + + +# Count every send, including the single 401 replay, against the same retry bound. +_DELIVERY_ATTEMPTS = 4 +_DELIVERY_RETRY_WINDOW = 60.0 +_DELIVERY_REQUEST_TIMEOUT = 10.0 + + +class TeamsDeliveryError(Exception): + """Sanitized delivery outcome for mapping to existing notification exceptions.""" + + def __init__( + self, category: Literal["authorization", "transient", "parameter"] + ) -> None: + """Expose a stable message without response bodies, SQL, tokens, or URLs.""" + self.category = category + messages = { + "authorization": "Teams bot authorization failed", + "transient": "Teams alert delivery temporarily unavailable", + "parameter": "Teams alert delivery was rejected", + } + super().__init__(messages[category]) + + +class TeamsTransportError(Exception): + """An HTTP failure carrying only the status and Microsoft's retry metadata.""" + + def __init__( + self, + status: int | None = None, + headers: httpx.Headers + | requests.structures.CaseInsensitiveDict[str] + | None = None, + ) -> None: + """Retain only retry headers; never retain a response body or request token.""" + super().__init__("Teams transport request failed") + self.status = status + self.retry_after = headers.get("Retry-After") if headers is not None else None + self.retry_after_ms = ( + headers.get("x-ms-retry-after-ms") if headers is not None else None + ) + + +class TeamsTransport(Protocol): + """Replaceable operation-scoped transport; retries live outside SDK calls.""" + + async def post_root( + self, destination: TeamsDestination, card: dict[str, Any] + ) -> None: + """Create a new channel root post containing the card.""" + ... + + async def refresh_auth(self) -> None: + """Invalidate cached authentication before a single 401 replay.""" + ... + + +def _remaining(deadline: float) -> float: + """Bound each request by both its timeout and remaining retry window.""" + remaining = deadline - monotonic() + if remaining <= 0: + raise TeamsTransportError() + return min(_DELIVERY_REQUEST_TIMEOUT, remaining) + + +class _AuthHttpClient: + """MSAL HTTP interface backed by a caller-owned, timeout-bounded session.""" + + def __init__(self, session: requests.Session, deadline: float) -> None: + """Keep OAuth traffic within the same operation as the channel post.""" + self.session = session + self.deadline = deadline + + def get(self, url: str, **kwargs: Any) -> requests.Response: + """Fetch MSAL metadata without leaving a response open.""" + return self._request("GET", url, **kwargs) + + def post(self, url: str, **kwargs: Any) -> requests.Response: + """Acquire tokens without leaving a response open.""" + return self._request("POST", url, **kwargs) + + def _request(self, method: str, url: str, **kwargs: Any) -> requests.Response: + """Use normal serialization, explicit timeouts, and sanitized retry errors.""" + kwargs["timeout"] = _remaining(self.deadline) + response = self.session.request(method, url, **kwargs) + try: + # requests eagerly reads responses; MSAL can still read cached JSON + # after close. Closing here also covers retryable OAuth errors. + if response.status_code in (408, 429) or response.status_code >= 500: + raise TeamsTransportError(response.status_code, response.headers) + return response + finally: + response.close() + + +class _ChannelConversationParams(CreateConversationParams): + """Include the connector's group flag, absent from the SDK base parameter model.""" + + is_group: Literal[True] = True + + +class _SDKTeamsTransport: + """Use the SDK create-conversation API with shared, explicitly owned resources.""" + + def __init__( + self, + config: TeamsConfig, + service_url: str, + http: Client, + auth_http: _AuthHttpClient, + deadline: float, + ) -> None: + """Prepare the SDK without constructing a full App or starting a server.""" + self.config = config + self.http = http + self.auth_http = auth_http + self.deadline = deadline + self.auth: ConfidentialClientApplication | None = None + self.api = ApiClient(service_url, http) + + def _authenticate(self) -> None: + """Acquire a bot token without exposing identity-provider errors.""" + if self.http.token: + return + if self.auth is None: + self.auth = ConfidentialClientApplication( + self.config.application_id, + client_credential=self.config.credential, + authority=f"{PUBLIC.login_endpoint}/{self.config.tenant_id}", + http_client=self.auth_http, + instance_discovery=False, + ) + result = self.auth.acquire_token_for_client([PUBLIC.bot_scope]) + token = result.get("access_token") + if not isinstance(token, str) or not token: + status = ( + 503 + if result.get("error") in {"temporarily_unavailable", "server_error"} + else 401 + ) + raise TeamsTransportError(status) + self.http.token = token + + async def refresh_auth(self) -> None: + """Invalidate MSAL app tokens so the next request acquires a fresh token.""" + if self.auth is not None: + self.auth.remove_tokens_for_client() + self.http.token = None + + async def post_root( + self, destination: TeamsDestination, card: dict[str, Any] + ) -> None: + """Create a new conversation with an initial card, never reply to a thread.""" + try: + self._authenticate() + self.http.http.timeout = httpx.Timeout(_remaining(self.deadline)) + await self.api.conversations.create( + _ChannelConversationParams( + tenant_id=self.config.tenant_id, + channel_data={ + "tenant": {"id": self.config.tenant_id}, + "team": {"id": destination.team_id}, + "channel": {"id": destination.channel_id}, + }, + activity=MessageActivityInput( + attachments=[ + Attachment( + content_type="application/vnd.microsoft.card.adaptive", + content=card, + ) + ] + ), + ) + ) + except httpx.HTTPStatusError as ex: + error = TeamsTransportError(ex.response.status_code, ex.response.headers) + await ex.response.aclose() + raise error from None + except (httpx.RequestError, requests.RequestException): + raise TeamsTransportError() from None + except ValueError: + # Includes invalid SDK models/response JSON. Replaying a successful + # POST whose response could not be decoded could duplicate delivery. + raise TeamsDeliveryError("parameter") from None + + +@asynccontextmanager +async def _create_transport( + config: TeamsConfig, service_url: str, deadline: float +) -> AsyncIterator[TeamsTransport]: + """Close the SDK's shared async client and MSAL's session in the same operation.""" + http = Client(ClientOptions(timeout=_DELIVERY_REQUEST_TIMEOUT)) + try: + with requests.Session() as session: + yield _SDKTeamsTransport( + config, service_url, http, _AuthHttpClient(session, deadline), deadline + ) + finally: + await http.http.aclose() + + +def _retry_delay(error: TeamsTransportError, attempt: int) -> float: + """Honor Microsoft's retry headers, falling back to bounded exponential delays.""" + delays: list[float] = [] + for value, divisor in [(error.retry_after_ms, 1000), (error.retry_after, 1)]: + if value is None: + continue + try: + delay = float(value) / divisor + except ValueError: + if divisor != 1: + continue + try: + retry_at = parsedate_to_datetime(value) + if retry_at.tzinfo is None: + retry_at = retry_at.replace(tzinfo=timezone.utc) + delay = (retry_at - datetime.now(timezone.utc)).total_seconds() + except (ValueError, TypeError, OverflowError): + continue + if math.isfinite(delay): + delays.append(max(0.0, delay)) + return max(delays) if delays else min(2.0**attempt, 8.0) + + +async def _deliver_with_retries( + transport: TeamsTransport, + destination: TeamsDestination, + card: dict[str, Any], + deadline: float, +) -> None: + """Bound transient retries and allow exactly one authentication refresh.""" + refreshed = False + for attempt in range(_DELIVERY_ATTEMPTS): + if monotonic() >= deadline: + raise TeamsDeliveryError("transient") + try: + await transport.post_root(destination, card) + return + except TeamsTransportError as error: + if error.status in (401, 403): + if ( + error.status == 403 + or refreshed + or attempt == _DELIVERY_ATTEMPTS - 1 + ): + raise TeamsDeliveryError("authorization") from None + refreshed = True + await transport.refresh_auth() + continue + if ( + error.status is not None + and error.status not in (408, 429) + and not 500 <= error.status < 600 + ): + raise TeamsDeliveryError("parameter") from None + delay = _retry_delay(error, attempt) + if attempt == _DELIVERY_ATTEMPTS - 1 or delay >= deadline - monotonic(): + # Never shorten a server-specified delay to squeeze in a retry. + raise TeamsDeliveryError("transient") from None + await asyncio.sleep(delay) + raise TeamsDeliveryError("transient") + + +TransportFactory = Callable[ + [TeamsConfig, str, float], AbstractAsyncContextManager[TeamsTransport] +] + + +def deliver_teams_alert( + config: TeamsConfig, + destination: TeamsDestination, + service_url: str, + card: dict[str, Any], + *, + transport_factory: TransportFactory | None = None, +) -> None: + """Send one card as a root channel post using an operation-scoped transport.""" + + async def deliver() -> None: + """Construct, retry, and close the transport before closing the event loop.""" + deadline = monotonic() + _DELIVERY_RETRY_WINDOW + factory = transport_factory or _create_transport + async with factory(config, service_url, deadline) as transport: + await _deliver_with_retries(transport, destination, card, deadline) + + asyncio.run(deliver()) diff --git a/superset/reports/models.py b/superset/reports/models.py index c2be870b2fdd..917296f350ed 100644 --- a/superset/reports/models.py +++ b/superset/reports/models.py @@ -69,6 +69,7 @@ class ReportRecipientType(StrEnum): SLACK = "Slack" SLACKV2 = "SlackV2" WEBHOOK = "Webhook" + TEAMS = "Teams" class ReportState(StrEnum): diff --git a/superset/reports/notifications/__init__.py b/superset/reports/notifications/__init__.py index 8cf9ea5668e1..565b3ec625ca 100644 --- a/superset/reports/notifications/__init__.py +++ b/superset/reports/notifications/__init__.py @@ -19,6 +19,7 @@ from superset.reports.notifications.email import EmailNotification # noqa: F401 from superset.reports.notifications.slack import SlackNotification # noqa: F401 from superset.reports.notifications.slackv2 import SlackV2Notification # noqa: F401 +from superset.reports.notifications.teams import TeamsNotification # noqa: F401 from superset.reports.notifications.webhook import WebhookNotification # noqa: F401 diff --git a/superset/reports/notifications/teams.py b/superset/reports/notifications/teams.py new file mode 100644 index 000000000000..d6a64cc69ee9 --- /dev/null +++ b/superset/reports/notifications/teams.py @@ -0,0 +1,169 @@ +# 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. +"""Attachment-free alert delivery to configured Teams standard channels.""" + +from typing import Any +from urllib.parse import urlsplit + +from flask import current_app +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session + +from superset.extensions import db, feature_flag_manager +from superset.integrations.teams.config import ( + parse_teams_config, + TeamsConfig, + TeamsDestination, +) +from superset.integrations.teams.store import TeamsInstallationStore +from superset.reports.models import ReportRecipientType, ReportScheduleType +from superset.reports.notifications.base import BaseNotification, NotificationContent +from superset.reports.notifications.exceptions import ( + NotificationAuthorizationException, + NotificationError, + NotificationParamException, + NotificationTransientError, +) +from superset.utils import json + + +def build_teams_card(content: NotificationContent) -> dict[str, Any]: + """Build a card using structured data, omitting raw execution diagnostics.""" + body: list[dict[str, Any]] = [ + { + "type": "TextBlock", + "text": content.name, + "weight": "Bolder", + "size": "Medium", + "wrap": True, + }, + ] + if content.description: + body.append({"type": "TextBlock", "text": content.description, "wrap": True}) + if content.text: + body.append( + { + "type": "TextBlock", + "text": "Alert execution failed. Contact the alert owner for details.", + "wrap": True, + } + ) + card: dict[str, Any] = { + "$schema": "https://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.2", + "body": body, + } + if content.include_cta and content.url: + try: + url = urlsplit(content.url) + safe_link = url.scheme in {"http", "https"} and bool(url.hostname) + except ValueError: + safe_link = False + if safe_link: + card["actions"] = [ + { + "type": "Action.OpenUrl", + "title": "Open in Superset", + "url": content.url, + } + ] + return card + + +def _send_to_teams( + config: TeamsConfig, + destination: TeamsDestination, + service_url: str, + card: dict[str, Any], +) -> None: + """Load the optional SDK only for delivery and map sanitized transport errors.""" + try: + from superset.integrations.teams.adapter import ( + deliver_teams_alert, + TeamsDeliveryError, + ) + except ImportError: + raise NotificationParamException("Teams SDK is not installed") from None + try: + deliver_teams_alert(config, destination, service_url, card) + except TeamsDeliveryError as ex: + exception = { + "authorization": NotificationAuthorizationException, + "transient": NotificationTransientError, + "parameter": NotificationParamException, + }[ex.category] + raise exception(str(ex)) from None + + +class TeamsNotification(BaseNotification): + """Resolve the configured destination and its active installation before send.""" + + type = ReportRecipientType.TEAMS + + def send(self) -> None: + """Send an alert as a new root post, without attachments or SQL diagnostics.""" + if not feature_flag_manager.is_feature_enabled("ALERT_REPORT_TEAMS"): + raise NotificationParamException("Teams alert delivery is disabled") + if ( + self._content.header_data.get("notification_type") + != ReportScheduleType.ALERT + or self._content.is_editor_error + ): + raise NotificationParamException("Teams supports alert notifications only") + if self._content.has_attachments or self._content.embedded_data is not None: + raise NotificationParamException("Teams alerts do not support attachments") + try: + recipient = json.loads(self._recipient.recipient_config_json) + if ( + not isinstance(recipient, dict) + or set(recipient) != {"target"} + or not isinstance(recipient["target"], str) + or not recipient["target"] + ): + raise NotificationParamException( + "A configured Teams destination key is required" + ) + config = parse_teams_config( + current_app.config.get("ALERT_REPORTS_TEAMS", {}) + ) + if config is None: + raise NotificationParamException("Teams deployment is not configured") + destination = config.get_destination(recipient["target"]) + except (ValueError, TypeError): + raise NotificationParamException( + "Invalid Teams destination configuration" + ) from None + try: + with Session(db.engine) as session: + installation = TeamsInstallationStore(session).get_active( + config.tenant_id, + config.application_id, + destination.team_id, + ) + if installation is None: + raise NotificationAuthorizationException( + "No active Teams bot installation for this destination" + ) + service_url = installation.service_url + except SQLAlchemyError: + raise NotificationError( + "Unable to resolve Teams bot installation" + ) from None + _send_to_teams( + config, destination, service_url, build_teams_card(self._content) + ) diff --git a/tests/unit_tests/integrations/teams/delivery_test.py b/tests/unit_tests/integrations/teams/delivery_test.py new file mode 100644 index 000000000000..a1c82337d0ac --- /dev/null +++ b/tests/unit_tests/integrations/teams/delivery_test.py @@ -0,0 +1,751 @@ +# 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. +"""Teams notification formatting, resolution, retry, and SDK delivery tests.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Iterator +from contextlib import asynccontextmanager +from datetime import datetime, timedelta, timezone +from email.utils import format_datetime +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock + +import httpx +import pytest +import requests +from flask import current_app, Flask +from sqlalchemy import create_engine +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session + +from superset.integrations.teams.config import ( + parse_teams_config, + TeamsConfig, + TeamsDestination, +) +from superset.integrations.teams.models import TeamsInstallation +from superset.integrations.teams.store import TeamsInstallationStore +from superset.reports.models import ReportRecipients, ReportRecipientType +from superset.reports.notifications import create_notification, teams as notifications +from superset.reports.notifications.base import NotificationContent +from superset.reports.notifications.exceptions import ( + NotificationAuthorizationException, + NotificationError, + NotificationParamException, + NotificationTransientError, +) +from superset.utils import json + +adapter = pytest.importorskip( + "superset.integrations.teams.adapter", exc_type=ImportError +) +TENANT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" +APPLICATION = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" +SERVICE_URL = "https://smba.trafficmanager.net/amer/" + + +@pytest.fixture +def deployment() -> dict[str, Any]: + """Return the server-only identity and destination allowlist.""" + return { + "tenant_id": TENANT, + "application_id": APPLICATION, + "credential": "server-only-secret", + "destinations": [ + { + "key": "ops", + "label": "Operations", + "team_id": "team-1", + "channel_id": "channel-1", + } + ], + } + + +@pytest.fixture +def configured(deployment: dict[str, Any]) -> TeamsConfig: + """Parse the same deployment configuration used by real notifications.""" + config = parse_teams_config(deployment) + assert config is not None + return config + + +@pytest.fixture +def content() -> NotificationContent: + """Include difficult text to verify normal structured JSON serialization.""" + return NotificationContent( + name='Alert "Revenue"\nCafé 東京 🚨', + description='Description "quoted"\n第二行', + header_data={ + "notification_type": "Alert", + "notification_format": "NONE", + "editors": [], + "notification_source": None, + "chart_id": None, + "dashboard_id": None, + "slack_channels": None, + "execution_id": None, + }, + url="https://superset.example/chart/42/", + ) + + +@pytest.fixture +def notification_context( + deployment: dict[str, Any], + monkeypatch: pytest.MonkeyPatch, +) -> Iterator[SimpleNamespace]: + """Use the real installation table and resolve through the notification class.""" + app = Flask(__name__) + app.config.update( + ALERT_REPORTS_TEAMS=deployment, FEATURE_FLAGS={"ALERT_REPORT_TEAMS": True} + ) + engine = create_engine("sqlite://") + TeamsInstallation.__table__.create(engine) + with Session(engine) as session: + TeamsInstallationStore(session).install( + TENANT, APPLICATION, "team-1", SERVICE_URL + ) + session.commit() + monkeypatch.setattr(notifications, "db", SimpleNamespace(engine=engine)) + monkeypatch.setattr( + notifications.feature_flag_manager, + "is_feature_enabled", + lambda name: current_app.config["FEATURE_FLAGS"].get(name, False), + ) + send = MagicMock() + monkeypatch.setattr(notifications, "_send_to_teams", send) + try: + with app.app_context(): + yield SimpleNamespace(app=app, engine=engine, send=send) + finally: + engine.dispose() + + +def recipient(target: str = "ops") -> ReportRecipients: + """Recipients store only a configured destination key.""" + return ReportRecipients( + type=ReportRecipientType.TEAMS, + recipient_config_json=json.dumps({"target": target}), + ) + + +def test_factory_resolves_active_installation_and_formats_card( + notification_context: SimpleNamespace, + content: NotificationContent, +) -> None: + notification = create_notification(recipient(), content) + assert isinstance(notification, notifications.TeamsNotification) + notification.send() + config, destination, service_url, card = notification_context.send.call_args.args + assert config.credential == "server-only-secret" + assert destination.key == "ops" + assert destination.channel_id == "channel-1" + assert service_url == SERVICE_URL + assert card["body"][0]["text"] == content.name + assert card["body"][1]["text"] == content.description + assert card["actions"][0]["url"] == content.url + assert "server-only-secret" not in json.dumps(card) + + +@pytest.mark.parametrize( + "include_cta,url", + [ + (False, "https://superset.example"), + (True, None), + (True, "javascript:alert(1)"), + (True, "https://[invalid"), + ], +) +def test_optional_link_respects_existing_setting( + content: NotificationContent, include_cta: bool, url: str | None +) -> None: + content.include_cta, content.url = include_cta, url + assert "actions" not in notifications.build_teams_card(content) + + +def test_optional_description_and_diagnostics(content: NotificationContent) -> None: + content.description = None + content.text = ( + 'SQL failed: SELECT "password" FROM internal_table; database://secret' + ) + card = notifications.build_teams_card(content) + assert len(card["body"]) == 2 + assert ( + card["body"][1]["text"] + == "Alert execution failed. Contact the alert owner for details." + ) + serialized = json.dumps(card) + assert "password" not in serialized + assert "internal_table" not in serialized + assert "database://secret" not in serialized + + +def test_card_json_round_trip_preserves_quotes_newlines_and_unicode( + content: NotificationContent, +) -> None: + card = notifications.build_teams_card(content) + decoded = json.loads(json.dumps(card)) + assert decoded["body"][0]["text"] == content.name + assert decoded["body"][1]["text"] == content.description + + +@pytest.mark.parametrize( + "target", ["unapproved", "https://example.com", "", None, ["ops"]] +) +def test_recipient_target_is_a_destination_key( + notification_context: SimpleNamespace, + content: NotificationContent, + target: Any, +) -> None: + entry = recipient() + entry.recipient_config_json = json.dumps({"target": target}) + with pytest.raises(NotificationParamException): + notifications.TeamsNotification(entry, content).send() + notification_context.send.assert_not_called() + + +@pytest.mark.parametrize( + "raw", + [ + "{", + "[]", + "{}", + '{"target":"ops","credential":"secret"}', + '{"target":"ops","channel_id":"override"}', + ], +) +def test_invalid_recipient_json_is_rejected( + notification_context: SimpleNamespace, content: NotificationContent, raw: str +) -> None: + entry = recipient() + entry.recipient_config_json = raw + with pytest.raises(NotificationParamException): + notifications.TeamsNotification(entry, content).send() + notification_context.send.assert_not_called() + + +@pytest.mark.parametrize( + "mode", ["inactive", "missing", "other-tenant", "other-application"] +) +def test_installation_must_be_active_for_exact_identity( + notification_context: SimpleNamespace, + content: NotificationContent, + mode: str, +) -> None: + with Session(notification_context.engine) as session: + store = TeamsInstallationStore(session) + if mode == "inactive": + store.remove(TENANT, APPLICATION, "team-1") + else: + session.query(TeamsInstallation).delete() + other = "cccccccc-cccc-4ccc-8ccc-cccccccccccc" + if mode != "missing": + store.install( + other if mode == "other-tenant" else TENANT, + other if mode == "other-application" else APPLICATION, + "team-1", + SERVICE_URL, + ) + session.commit() + with pytest.raises(NotificationAuthorizationException): + notifications.TeamsNotification(recipient(), content).send() + notification_context.send.assert_not_called() + + +@pytest.mark.parametrize( + "mode", + [ + "disabled", + "unconfigured", + "report", + "editor-diagnostic", + "csv", + "xlsx", + "pdf", + "screenshots", + "embedded_data", + ], +) +def test_out_of_scope_delivery_is_rejected( + notification_context: SimpleNamespace, content: NotificationContent, mode: str +) -> None: + if mode == "disabled": + notification_context.app.config["FEATURE_FLAGS"]["ALERT_REPORT_TEAMS"] = False + elif mode == "unconfigured": + notification_context.app.config["ALERT_REPORTS_TEAMS"] = {} + elif mode == "report": + content.header_data["notification_type"] = "Report" + elif mode == "editor-diagnostic": + content.is_editor_error = True + else: + setattr(content, mode, [b"file"] if mode == "screenshots" else b"file") + with pytest.raises(NotificationParamException): + notifications.TeamsNotification(recipient(), content).send() + notification_context.send.assert_not_called() + + +def test_database_errors_are_sanitized( + notification_context: SimpleNamespace, + content: NotificationContent, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + TeamsInstallationStore, + "get_active", + MagicMock(side_effect=SQLAlchemyError("SELECT password FROM internal_table")), + ) + with pytest.raises(NotificationError) as exc: + notifications.TeamsNotification(recipient(), content).send() + assert "internal_table" not in str(exc.value) + assert exc.value.__cause__ is None + notification_context.send.assert_not_called() + + +class FakeTransport: + """Deterministic replaceable transport for bounded retry policy tests.""" + + def __init__(self, errors: list[Exception | None]) -> None: + """Consume one outcome for each root post attempt.""" + self.errors = iter(errors) + self.posts: list[tuple[TeamsDestination, dict[str, Any]]] = [] + self.refreshes = 0 + self.closed = False + self.loop: asyncio.AbstractEventLoop | None = None + + async def post_root( + self, destination: TeamsDestination, card: dict[str, Any] + ) -> None: + """Record every fresh root-post attempt.""" + self.posts.append((destination, card)) + error = next(self.errors) + if error: + raise error + + async def refresh_auth(self) -> None: + """Record a cache invalidation without contacting Entra.""" + self.refreshes += 1 + + @asynccontextmanager + async def factory( + self, config: TeamsConfig, service_url: str, deadline: float + ) -> AsyncIterator[FakeTransport]: + """Track operation cleanup on both successful and failed delivery.""" + self.loop = asyncio.get_running_loop() + try: + yield self + finally: + self.closed = True + + +@pytest.fixture +def retry_clock(monkeypatch: pytest.MonkeyPatch) -> list[float]: + """Advance retry time without actually sleeping.""" + elapsed = [0.0] + sleeps: list[float] = [] + monkeypatch.setattr(adapter, "monotonic", lambda: elapsed[0]) + + async def sleep(delay: float) -> None: + """Advance the same clock used by the retry budget.""" + elapsed[0] += delay + sleeps.append(delay) + + monkeypatch.setattr(adapter.asyncio, "sleep", sleep) + return sleeps + + +@pytest.mark.parametrize("status", [None, 408, 429, 500, 502, 503, 504]) +def test_transient_failures_retry_with_bounded_attempts( + configured: TeamsConfig, retry_clock: list[float], status: int | None +) -> None: + transport = FakeTransport([adapter.TeamsTransportError(status)] * 4) + with pytest.raises(adapter.TeamsDeliveryError) as exc: + adapter.deliver_teams_alert( + configured, + configured.destinations[0], + SERVICE_URL, + {}, + transport_factory=transport.factory, + ) + assert exc.value.category == "transient" + assert len(transport.posts) == 4 + assert retry_clock == [1.0, 2.0, 4.0] + assert transport.closed + assert transport.loop is not None + assert transport.loop.is_closed() + + +@pytest.mark.parametrize( + "headers,expected", + [ + ({"Retry-After": "3"}, 3.0), + ({"x-ms-retry-after-ms": "1500"}, 1.5), + ({"Retry-After": "2", "x-ms-retry-after-ms": "3000"}, 3.0), + ({"Retry-After": "invalid"}, 1.0), + ], +) +def test_microsoft_retry_delays_are_respected( + configured: TeamsConfig, + retry_clock: list[float], + headers: dict[str, str], + expected: float, +) -> None: + transport = FakeTransport( + [adapter.TeamsTransportError(429, httpx.Headers(headers)), None] + ) + adapter.deliver_teams_alert( + configured, + configured.destinations[0], + SERVICE_URL, + {}, + transport_factory=transport.factory, + ) + assert retry_clock == [expected] + assert len(transport.posts) == 2 + assert transport.closed + + +def test_retry_after_http_date() -> None: + retry_at = datetime.now(timezone.utc) + timedelta(seconds=5) + error = adapter.TeamsTransportError( + 429, httpx.Headers({"Retry-After": format_datetime(retry_at, usegmt=True)}) + ) + assert 3.0 <= adapter._retry_delay(error, 0) <= 5.0 + + +def test_excessive_server_delay_is_not_shortened( + configured: TeamsConfig, retry_clock: list[float] +) -> None: + transport = FakeTransport( + [adapter.TeamsTransportError(429, httpx.Headers({"Retry-After": "120"}))] + ) + with pytest.raises(adapter.TeamsDeliveryError) as exc: + adapter.deliver_teams_alert( + configured, + configured.destinations[0], + SERVICE_URL, + {}, + transport_factory=transport.factory, + ) + assert exc.value.category == "transient" + assert len(transport.posts) == 1 + assert retry_clock == [] + assert transport.closed + + +@pytest.mark.parametrize( + "outcomes,category,posts,refreshes", + [ + ([401, None], None, 2, 1), + ([401, 401], "authorization", 2, 1), + ([403], "authorization", 1, 0), + ([400], "parameter", 1, 0), + ([404], "parameter", 1, 0), + ([302], "parameter", 1, 0), + ([401, 503, 503, None], None, 4, 1), + ], +) +def test_one_authentication_refresh_and_permanent_errors( + configured: TeamsConfig, + retry_clock: list[float], + outcomes: list[int | None], + category: str | None, + posts: int, + refreshes: int, +) -> None: + transport = FakeTransport( + [adapter.TeamsTransportError(status) if status else None for status in outcomes] + ) + if category: + with pytest.raises(adapter.TeamsDeliveryError) as exc: + adapter.deliver_teams_alert( + configured, + configured.destinations[0], + SERVICE_URL, + {}, + transport_factory=transport.factory, + ) + assert exc.value.category == category + else: + adapter.deliver_teams_alert( + configured, + configured.destinations[0], + SERVICE_URL, + {}, + transport_factory=transport.factory, + ) + assert len(transport.posts) == posts + assert transport.refreshes == refreshes + assert transport.closed + assert transport.loop is not None + assert transport.loop.is_closed() + + +@pytest.mark.parametrize( + "category,exception", + [ + ("authorization", NotificationAuthorizationException), + ("transient", NotificationTransientError), + ("parameter", NotificationParamException), + ], +) +def test_transport_errors_map_to_existing_notification_exceptions( + configured: TeamsConfig, + monkeypatch: pytest.MonkeyPatch, + category: str, + exception: type[Exception], +) -> None: + monkeypatch.setattr( + adapter, + "deliver_teams_alert", + MagicMock(side_effect=adapter.TeamsDeliveryError(category)), + ) + with pytest.raises(exception) as exc: + notifications._send_to_teams( + configured, configured.destinations[0], SERVICE_URL, {} + ) + assert exc.value.__cause__ is None + + +def _sdk_post_response(status: int) -> httpx.Response: + """Script a successful root, malformed success, or failing connector response.""" + if status == 201: + return httpx.Response( + status, json={"id": "new-thread", "activityId": "root-message"} + ) + if status == 299: + return httpx.Response(201, json={"malformed": "success response"}) + return httpx.Response( + status, headers={"Retry-After": "2"}, json={"error": "secret SQL diagnostic"} + ) + + +@pytest.fixture +def sdk_network( + monkeypatch: pytest.MonkeyPatch, retry_clock: list[float] +) -> SimpleNamespace: + """Run real MSAL and SDK serializers against offline HTTP endpoints.""" + state = SimpleNamespace( + posts=[], + tokens=0, + oauth_responses=[], + auth_sessions=[], + clients=[], + statuses=[], + oauth_error=None, + ) + + class TrackedResponse(requests.Response): + """Track closure of metadata and token HTTP responses.""" + + closed = False + + def close(self) -> None: + """Record deterministic cleanup even on OAuth failures.""" + super().close() + self.closed = True + + class AuthSession(requests.Session): + """Provide real requests response objects to MSAL's HTTP contract.""" + + closed = False + + def __init__(self) -> None: + """Record operation-owned sessions.""" + super().__init__() + state.auth_sessions.append(self) + + def request( + self, method: Any, url: Any, *args: Any, **kwargs: Any + ) -> requests.Response: + """Answer only known Microsoft metadata and client-credential calls.""" + assert 0 < kwargs["timeout"] <= 10 + response = TrackedResponse() + response.status_code = 200 + response.url = url + if method == "GET": + assert ( + url + == f"https://login.microsoftonline.com/{TENANT}/v2.0/.well-known/openid-configuration" + ) + data: dict[str, Any] = { + "authorization_endpoint": f"https://login.microsoftonline.com/{TENANT}/oauth2/v2.0/authorize", + "token_endpoint": f"https://login.microsoftonline.com/{TENANT}/oauth2/v2.0/token", + "issuer": f"https://login.microsoftonline.com/{TENANT}/v2.0", + } + else: + assert method == "POST" + assert ( + url + == f"https://login.microsoftonline.com/{TENANT}/oauth2/v2.0/token" + ) + assert kwargs["data"]["grant_type"] == "client_credentials" + assert ( + kwargs["data"]["scope"] == "https://api.botframework.com/.default" + ) + state.tokens += 1 + data = { + "access_token": f"token-{state.tokens}", + "token_type": "Bearer", + "expires_in": 3600, + } + if state.oauth_error: + response.status_code = 400 + data = { + "error": "invalid_client", + "error_description": "secret SQL diagnostic", + } + response._content = json.dumps(data).encode() + _ = ( + response.content + ) # Match requests' eager, non-streaming response behavior. + state.oauth_responses.append(response) + return response + + def close(self) -> None: + """Close MSAL's caller-provided session.""" + super().close() + self.closed = True + + def receive(request: httpx.Request) -> httpx.Response: + """Inspect the actual SDK wire request and supply scripted statuses.""" + assert request.method == "POST" + assert str(request.url) == SERVICE_URL.rstrip("/") + "/v3/conversations" + state.posts.append((request, json.loads(request.content))) + status = state.statuses.pop(0) if state.statuses else 201 + return _sdk_post_response(status) + + class SDKHttpClient(httpx.AsyncClient): + """Use httpx's real serialization, response handling, and client lifecycle.""" + + def __init__(self, **kwargs: Any) -> None: + """Replace only external I/O with a deterministic mock transport.""" + kwargs["transport"] = httpx.MockTransport(receive) + super().__init__(**kwargs) + state.clients.append(self) + + monkeypatch.setattr(adapter.requests, "Session", AuthSession) + monkeypatch.setattr(adapter.httpx, "AsyncClient", SDKHttpClient) + return state + + +def assert_resources_closed(network: SimpleNamespace) -> None: + """Verify all resources created by real SDK/MSAL calls are closed.""" + assert network.clients + assert all(client.is_closed for client in network.clients) + assert network.auth_sessions + assert all(session.closed for session in network.auth_sessions) + assert network.oauth_responses + assert all(response.closed for response in network.oauth_responses) + + +def test_real_sdk_creates_fresh_channel_root_for_every_notification( + sdk_network: SimpleNamespace, + configured: TeamsConfig, + content: NotificationContent, +) -> None: + card = notifications.build_teams_card(content) + for _ in range(2): + adapter.deliver_teams_alert( + configured, configured.destinations[0], SERVICE_URL, card + ) + assert len(sdk_network.posts) == 2 + for request, payload in sdk_network.posts: + assert request.headers["Authorization"].startswith("Bearer token-") + assert payload["isGroup"] is True + assert payload["tenantId"] == TENANT + assert payload["channelData"]["channel"]["id"] == "channel-1" + assert payload["channelData"]["team"]["id"] == "team-1" + assert "replyToId" not in payload["activity"] + assert "conversation" not in payload["activity"] + assert payload["activity"]["attachments"] == [ + {"contentType": "application/vnd.microsoft.card.adaptive", "content": card} + ] + assert ( + payload["activity"]["attachments"][0]["content"]["body"][0]["text"] + == content.name + ) + assert "server-only-secret" not in request.content.decode() + assert_resources_closed(sdk_network) + + +def test_real_sdk_refreshes_token_once_after_401( + sdk_network: SimpleNamespace, + configured: TeamsConfig, + retry_clock: list[float], +) -> None: + sdk_network.statuses = [429, 401, 201] + adapter.deliver_teams_alert(configured, configured.destinations[0], SERVICE_URL, {}) + assert sdk_network.tokens == 2 + assert [request.headers["Authorization"] for request, _ in sdk_network.posts] == [ + "Bearer token-1", + "Bearer token-1", + "Bearer token-2", + ] + assert retry_clock == [2.0] + assert_resources_closed(sdk_network) + + +@pytest.mark.parametrize( + "statuses,oauth_error,category,posts", + [ + ([401, 401], None, "authorization", 2), + ([503, 503, 503, 503], None, "transient", 4), + ([400], None, "parameter", 1), + ([299], None, "parameter", 1), + ([], True, "authorization", 0), + ], +) +def test_real_sdk_failures_close_resources_and_sanitize_errors( + sdk_network: SimpleNamespace, + configured: TeamsConfig, + statuses: list[int], + oauth_error: bool | None, + category: str, + posts: int, +) -> None: + sdk_network.statuses = statuses + sdk_network.oauth_error = oauth_error + with pytest.raises(adapter.TeamsDeliveryError) as exc: + adapter.deliver_teams_alert( + configured, configured.destinations[0], SERVICE_URL, {} + ) + assert exc.value.category == category + assert "secret SQL diagnostic" not in str(exc.value) + assert "server-only-secret" not in str(exc.value) + assert len(sdk_network.posts) == posts + assert_resources_closed(sdk_network) + + +def test_auth_http_closes_response_and_preserves_retry_delay( + monkeypatch: pytest.MonkeyPatch, +) -> None: + response = MagicMock(spec=requests.Response) + response.status_code = 429 + response.headers = requests.structures.CaseInsensitiveDict({"Retry-After": "7"}) + session = MagicMock(spec=requests.Session) + session.request.return_value = response + monkeypatch.setattr(adapter, "monotonic", lambda: 0) + http = adapter._AuthHttpClient(session, 60) + with pytest.raises(adapter.TeamsTransportError) as exc: + http.post("https://login.microsoftonline.com/token") + assert exc.value.retry_after == "7" + response.close.assert_called_once() + assert session.request.call_args.kwargs["timeout"] == 10 From c71d86ff30923c2f573997d5f6e29c74eb4f9f69 Mon Sep 17 00:00:00 2001 From: Kristi Dodaj Date: Mon, 19 Oct 2026 17:02:44 -0400 Subject: [PATCH 05/30] feat(reports): validate Teams recipients on create, update, and delivery --- .../configuration/alerts-reports.mdx | 11 + superset/commands/report/base.py | 29 ++ superset/commands/report/create.py | 1 + superset/commands/report/update.py | 2 + superset/integrations/teams/validation.py | 95 ++++ superset/reports/notifications/teams.py | 48 +- .../integrations/teams/delivery_test.py | 16 +- .../integrations/teams/validation_test.py | 457 ++++++++++++++++++ 8 files changed, 625 insertions(+), 34 deletions(-) create mode 100644 superset/integrations/teams/validation.py create mode 100644 tests/unit_tests/integrations/teams/validation_test.py diff --git a/docs/admin_docs/configuration/alerts-reports.mdx b/docs/admin_docs/configuration/alerts-reports.mdx index 89f7869cee70..f0df432a6f58 100644 --- a/docs/admin_docs/configuration/alerts-reports.mdx +++ b/docs/admin_docs/configuration/alerts-reports.mdx @@ -925,6 +925,17 @@ allowlist. The recipient's configuration must contain only `target`; channel IDs server. Delivery requires an active installation matching the configured tenant, application, and destination team. +Schedule creation and updates share Teams recipient validation with delivery. +Teams recipients require the enabled, configured integration, an `Alert` schedule, +explicit `report_format="NONE"`, and a known destination key. The global attachment +setting does not override this requirement. PUT validates the merged saved +configuration, including existing recipients when omitted from the request; +changing only schedule type, format, or metadata cannot retain an invalid Teams +recipient. Explicitly replacing/removing Teams recipients validates the replacement +list. An active bot installation is required at delivery time, not when saving. +Attachment-free alerts use the existing scheduler and execution logs and skip +screenshot, PDF, and file generation. + Each alert creates a new root channel post with an Adaptive Card containing its name and optional description. The optional **Open in Superset** action follows the existing `include_cta` setting. Cards use normal JSON serialization; execution diff --git a/superset/commands/report/base.py b/superset/commands/report/base.py index 790f1690739b..ea0bb6091812 100644 --- a/superset/commands/report/base.py +++ b/superset/commands/report/base.py @@ -52,11 +52,16 @@ from superset.daos.dashboard import DashboardDAO from superset.daos.report import ReportConfigDAO from superset.exceptions import SupersetParseError, SupersetSecurityException +from superset.integrations.teams.validation import ( + TeamsRecipientValidationError, + validate_teams_recipient, +) from superset.models.core import Database from superset.reports.models import ( ReportConfigKey, ReportCreationMethod, ReportDataFormat, + ReportRecipientType, ReportSchedule, ReportScheduleType, ) @@ -553,6 +558,30 @@ def validate_report_format( ): exceptions.append(ReportScheduleFormatRequiredError()) + def validate_teams_recipients(self, exceptions: list[ValidationError]) -> None: + """Validate the merged saved schedule, including recipients omitted on PUT.""" + model = getattr(self, "_model", None) + recipients = self._properties.get( + "recipients", getattr(model, "recipients", []) + ) + report_type = self._properties.get("type", getattr(model, "type", None)) + report_format = self._properties.get( + "report_format", getattr(model, "report_format", ReportDataFormat.PNG) + ) + for recipient in recipients or []: + if isinstance(recipient, dict): + recipient_type = recipient.get("type") + recipient_config = recipient.get("recipient_config_json") + else: + recipient_type = recipient.type + recipient_config = recipient.recipient_config_json + if recipient_type != ReportRecipientType.TEAMS: + continue + try: + validate_teams_recipient(recipient_config, report_type, report_format) + except TeamsRecipientValidationError as ex: + exceptions.append(ValidationError(str(ex), field_name=ex.field)) + def validate_recipients_policy(self, exceptions: list[ValidationError]) -> None: """ Validate the e-mail recipients in the payload against the global diff --git a/superset/commands/report/create.py b/superset/commands/report/create.py index 4078d51ff759..59b042db16bf 100644 --- a/superset/commands/report/create.py +++ b/superset/commands/report/create.py @@ -209,6 +209,7 @@ def validate(self) -> None: self.validate_report_format( report_type, self._properties.get("report_format"), exceptions ) + self.validate_teams_recipients(exceptions) self.validate_recipients_policy(exceptions) self._validate_executors(report_type, exceptions) self.validate_executor_target_access(exceptions) diff --git a/superset/commands/report/update.py b/superset/commands/report/update.py index a68ccf29917d..9410e3def348 100644 --- a/superset/commands/report/update.py +++ b/superset/commands/report/update.py @@ -496,6 +496,8 @@ def validate(self) -> None: # noqa: C901 self._properties.get("report_format", self._model.report_format), exceptions, ) + # Always validate the effective Teams recipients, even for metadata-only PUTs. + self.validate_teams_recipients(exceptions) if "recipients" in self._properties: self.validate_recipients_policy(exceptions) self._validate_executors( diff --git a/superset/integrations/teams/validation.py b/superset/integrations/teams/validation.py new file mode 100644 index 000000000000..1519b7983767 --- /dev/null +++ b/superset/integrations/teams/validation.py @@ -0,0 +1,95 @@ +# 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. +"""Shared Teams recipient rules for schedule saves and notification delivery.""" + +from flask import current_app + +from superset.extensions import feature_flag_manager +from superset.integrations.teams.config import ( + parse_teams_config, + TeamsConfig, + TeamsDestination, +) +from superset.reports.models import ReportDataFormat, ReportScheduleType +from superset.utils import json + + +class TeamsRecipientValidationError(ValueError): + """A field-specific error that never includes credentials or supplied values.""" + + def __init__(self, message: str, field: str = "recipients") -> None: + """Identify the schedule field that must be corrected.""" + super().__init__(message) + self.field = field + + +def validate_teams_recipient( + recipient_config: object, + schedule_type: str | None, + report_format: str | None, +) -> tuple[TeamsConfig, TeamsDestination]: + """Require an enabled, configured, attachment-free alert and an approved key. + + Accept deserialized command input or persisted recipient JSON. Installation + availability is deliberately checked only at delivery, so schedules may be + saved before a bot is installed. No SDK dependency or database access is needed. + """ + if not feature_flag_manager.is_feature_enabled("ALERT_REPORT_TEAMS"): + raise TeamsRecipientValidationError("Teams alert delivery is disabled") + try: + config = parse_teams_config(current_app.config.get("ALERT_REPORTS_TEAMS", {})) + except ValueError: + raise TeamsRecipientValidationError( + "Invalid Teams deployment configuration" + ) from None + if config is None: + raise TeamsRecipientValidationError("Teams deployment is not configured") + if schedule_type != ReportScheduleType.ALERT: + raise TeamsRecipientValidationError( + "Teams recipients require an alert schedule", "type" + ) + if report_format != ReportDataFormat.NONE: + raise TeamsRecipientValidationError( + "Teams recipients require disabled attachments (NONE format)", + "report_format", + ) + try: + recipient = ( + json.loads(recipient_config) + if isinstance(recipient_config, str) + else recipient_config + ) + except (ValueError, TypeError): + raise TeamsRecipientValidationError( + "Invalid Teams recipient configuration" + ) from None + if ( + not isinstance(recipient, dict) + or set(recipient) != {"target"} + or not isinstance(recipient["target"], str) + or not recipient["target"] + ): + raise TeamsRecipientValidationError( + "A configured Teams destination key is required" + ) + try: + destination = config.get_destination(recipient["target"]) + except ValueError: + raise TeamsRecipientValidationError( + "Teams destination is not approved" + ) from None + return config, destination diff --git a/superset/reports/notifications/teams.py b/superset/reports/notifications/teams.py index d6a64cc69ee9..a9f73433e233 100644 --- a/superset/reports/notifications/teams.py +++ b/superset/reports/notifications/teams.py @@ -19,18 +19,20 @@ from typing import Any from urllib.parse import urlsplit -from flask import current_app from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session -from superset.extensions import db, feature_flag_manager +from superset.extensions import db from superset.integrations.teams.config import ( - parse_teams_config, TeamsConfig, TeamsDestination, ) from superset.integrations.teams.store import TeamsInstallationStore -from superset.reports.models import ReportRecipientType, ReportScheduleType +from superset.integrations.teams.validation import ( + TeamsRecipientValidationError, + validate_teams_recipient, +) +from superset.reports.models import ReportRecipientType from superset.reports.notifications.base import BaseNotification, NotificationContent from superset.reports.notifications.exceptions import ( NotificationAuthorizationException, @@ -38,7 +40,6 @@ NotificationParamException, NotificationTransientError, ) -from superset.utils import json def build_teams_card(content: NotificationContent) -> dict[str, Any]: @@ -117,37 +118,18 @@ class TeamsNotification(BaseNotification): def send(self) -> None: """Send an alert as a new root post, without attachments or SQL diagnostics.""" - if not feature_flag_manager.is_feature_enabled("ALERT_REPORT_TEAMS"): - raise NotificationParamException("Teams alert delivery is disabled") - if ( - self._content.header_data.get("notification_type") - != ReportScheduleType.ALERT - or self._content.is_editor_error - ): + try: + config, destination = validate_teams_recipient( + self._recipient.recipient_config_json, + self._content.header_data.get("notification_type"), + self._content.header_data.get("notification_format"), + ) + except TeamsRecipientValidationError as ex: + raise NotificationParamException(str(ex)) from None + if self._content.is_editor_error: raise NotificationParamException("Teams supports alert notifications only") if self._content.has_attachments or self._content.embedded_data is not None: raise NotificationParamException("Teams alerts do not support attachments") - try: - recipient = json.loads(self._recipient.recipient_config_json) - if ( - not isinstance(recipient, dict) - or set(recipient) != {"target"} - or not isinstance(recipient["target"], str) - or not recipient["target"] - ): - raise NotificationParamException( - "A configured Teams destination key is required" - ) - config = parse_teams_config( - current_app.config.get("ALERT_REPORTS_TEAMS", {}) - ) - if config is None: - raise NotificationParamException("Teams deployment is not configured") - destination = config.get_destination(recipient["target"]) - except (ValueError, TypeError): - raise NotificationParamException( - "Invalid Teams destination configuration" - ) from None try: with Session(db.engine) as session: installation = TeamsInstallationStore(session).get_active( diff --git a/tests/unit_tests/integrations/teams/delivery_test.py b/tests/unit_tests/integrations/teams/delivery_test.py index a1c82337d0ac..22e4d47d492b 100644 --- a/tests/unit_tests/integrations/teams/delivery_test.py +++ b/tests/unit_tests/integrations/teams/delivery_test.py @@ -35,6 +35,7 @@ from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session +from superset.integrations.teams import validation as teams_validation from superset.integrations.teams.config import ( parse_teams_config, TeamsConfig, @@ -126,7 +127,7 @@ def notification_context( session.commit() monkeypatch.setattr(notifications, "db", SimpleNamespace(engine=engine)) monkeypatch.setattr( - notifications.feature_flag_manager, + teams_validation.feature_flag_manager, "is_feature_enabled", lambda name: current_app.config["FEATURE_FLAGS"].get(name, False), ) @@ -302,6 +303,19 @@ def test_out_of_scope_delivery_is_rejected( notification_context.send.assert_not_called() +@pytest.mark.parametrize("report_format", ["PNG", "PDF", "CSV", "XLSX", "TEXT"]) +def test_delivery_revalidates_format_even_without_generated_attachments( + notification_context: SimpleNamespace, + content: NotificationContent, + report_format: str, +) -> None: + content.header_data["notification_format"] = report_format + assert not content.has_attachments + with pytest.raises(NotificationParamException, match="disabled attachments"): + notifications.TeamsNotification(recipient(), content).send() + notification_context.send.assert_not_called() + + def test_database_errors_are_sanitized( notification_context: SimpleNamespace, content: NotificationContent, diff --git a/tests/unit_tests/integrations/teams/validation_test.py b/tests/unit_tests/integrations/teams/validation_test.py new file mode 100644 index 000000000000..119b76b9f07f --- /dev/null +++ b/tests/unit_tests/integrations/teams/validation_test.py @@ -0,0 +1,457 @@ +# 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. +"""Teams save-time rules, merged updates, and existing execution-path regressions.""" + +from __future__ import annotations + +from collections.abc import Iterator +from copy import deepcopy +from types import SimpleNamespace +from typing import Any, cast +from unittest.mock import call, MagicMock +from uuid import uuid4 + +import pytest +from flask import current_app, Flask + +from superset.commands.report import create, execute, update +from superset.commands.report.base import BaseReportScheduleCommand +from superset.commands.report.exceptions import ReportScheduleInvalidError +from superset.integrations.teams import validation +from superset.integrations.teams.store import TeamsInstallationStore +from superset.reports.models import ( + ReportRecipients, + ReportRecipientType, + ReportScheduleType, + ReportState, +) +from superset.reports.notifications.exceptions import NotificationTransientError +from superset.utils import json + +TENANT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" +APPLICATION = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" + + +@pytest.fixture +def configured_app(monkeypatch: pytest.MonkeyPatch) -> Iterator[Flask]: + """Enable a single approved destination without requiring any installation.""" + app = Flask(__name__) + app.config.update( + FEATURE_FLAGS={"ALERT_REPORT_TEAMS": True}, + ALERT_REPORTS_TEAMS={ + "tenant_id": TENANT, + "application_id": APPLICATION, + "credential": "private-credential", + "destinations": [ + { + "key": "ops", + "label": "Operations", + "team_id": "team-1", + "channel_id": "channel-1", + } + ], + }, + ) + monkeypatch.setattr( + validation.feature_flag_manager, + "is_feature_enabled", + lambda name: current_app.config["FEATURE_FLAGS"].get(name, False), + ) + with app.app_context(): + yield app + + +def teams_recipient(config: object = None, *, saved: bool = False) -> Any: + """Represent either command input or an existing recipient ORM object.""" + config = {"target": "ops"} if config is None else config + if saved: + return ReportRecipients( + type=ReportRecipientType.TEAMS, recipient_config_json=json.dumps(config) + ) + return {"type": ReportRecipientType.TEAMS, "recipient_config_json": config} + + +@pytest.mark.parametrize("serialized", [False, True]) +def test_shared_validation_accepts_command_and_saved_recipient( + configured_app: Flask, serialized: bool, monkeypatch: pytest.MonkeyPatch +) -> None: + lookup = MagicMock( + side_effect=AssertionError("save-time validation must not query installations") + ) + monkeypatch.setattr(TeamsInstallationStore, "get_active", lookup) + recipient = json.dumps({"target": "ops"}) if serialized else {"target": "ops"} + before = deepcopy(configured_app.config["ALERT_REPORTS_TEAMS"]) + config, destination = validation.validate_teams_recipient( + recipient, "Alert", "NONE" + ) + assert config.tenant_id == TENANT + assert destination.key == "ops" + assert configured_app.config["ALERT_REPORTS_TEAMS"] == before + lookup.assert_not_called() + + +@pytest.mark.parametrize( + "mode,field", + [ + ("disabled", "recipients"), + ("unconfigured", "recipients"), + ("invalid-config", "recipients"), + ("report", "type"), + ("png", "report_format"), + ("pdf", "report_format"), + ("missing-format", "report_format"), + ("unknown", "recipients"), + ("malformed", "recipients"), + ("extra-fields", "recipients"), + ], +) +def test_shared_validation_rejects_invalid_combinations( + configured_app: Flask, mode: str, field: str +) -> None: + recipient: object = {"target": "ops"} + schedule_type = "Alert" + report_format = "NONE" + if mode == "disabled": + configured_app.config["FEATURE_FLAGS"]["ALERT_REPORT_TEAMS"] = False + elif mode == "unconfigured": + configured_app.config["ALERT_REPORTS_TEAMS"] = {} + elif mode == "invalid-config": + configured_app.config["ALERT_REPORTS_TEAMS"] = { + "credential": "private-credential" + } + elif mode == "report": + schedule_type = "Report" + elif mode in {"png", "pdf", "missing-format"}: + report_format = {"png": "PNG", "pdf": "PDF", "missing-format": ""}[mode] + elif mode == "unknown": + recipient = {"target": "unapproved"} + elif mode == "malformed": + recipient = "{private-credential" + else: + recipient = {"target": "ops", "credential": "private-credential"} + with pytest.raises(validation.TeamsRecipientValidationError) as exc: + validation.validate_teams_recipient(recipient, schedule_type, report_format) + assert exc.value.field == field + assert "private-credential" not in str(exc.value) + + +@pytest.fixture +def command_dependencies(monkeypatch: pytest.MonkeyPatch) -> None: + """Stub unrelated DAO, editorship, SQL, and executor rules; run real validation.""" + for module in (create, update): + monkeypatch.setattr(module, "is_feature_enabled", lambda name: False) + monkeypatch.setattr(module, "ReportScheduleDAO", MagicMock()) + module.ReportScheduleDAO.validate_update_uniqueness.return_value = True + module.ReportScheduleDAO.validate_unique_creation_method.return_value = True + monkeypatch.setattr(module, "DatabaseDAO", MagicMock()) + module.DatabaseDAO.find_by_id.return_value = SimpleNamespace(id=1) + monkeypatch.setattr(create, "populate_subjects", MagicMock()) + monkeypatch.setattr(update, "compute_subjects", MagicMock()) + monkeypatch.setattr( + update, "security_manager", SimpleNamespace(raise_for_editorship=MagicMock()) + ) + for method in ( + "validate_alert_query", + "validate_chart_dashboard", + "validate_report_frequency", + "_validate_report_extra", + "validate_recipients_policy", + ): + monkeypatch.setattr(BaseReportScheduleCommand, method, MagicMock()) + monkeypatch.setattr( + create.CreateReportScheduleCommand, "_validate_executors", MagicMock() + ) + monkeypatch.setattr( + update.UpdateReportScheduleCommand, "_validate_executors", MagicMock() + ) + monkeypatch.setattr( + update.UpdateReportScheduleCommand, "_validate_attachment_executor", MagicMock() + ) + + +def create_command(**changes: Any) -> create.CreateReportScheduleCommand: + """Build a complete attachment-free alert request.""" + return create.CreateReportScheduleCommand( + { + "type": "Alert", + "report_format": "NONE", + "name": "Operations alert", + "crontab": "0 9 * * *", + "database": 1, + "creation_method": "alerts_reports", + "recipients": [teams_recipient()], + **changes, + } + ) + + +def saved_schedule(**changes: Any) -> SimpleNamespace: + """Supply the persisted fields used when PUT omits recipients/type/format.""" + return SimpleNamespace( + **{ + "type": "Alert", + "report_format": "NONE", + "name": "Operations alert", + "crontab": "0 9 * * *", + "database_id": 1, + "database": SimpleNamespace(id=1), + "sql": "SELECT 1", + "validator_type": "not null", + "validator_config_json": "{}", + "creation_method": "alerts_reports", + "last_state": "Success", + "recipients": [teams_recipient(saved=True)], + "editors": [], + "chart_id": None, + "dashboard_id": None, + "dashboard": None, + "extra_json": None, + "run_as": None, + "run_as_type": None, + "run_alert_query_as": None, + "run_alert_query_as_type": None, + "retry_on_failure": False, + "send_failed_reports": False, + **changes, + } + ) + + +def validate_update( + model: SimpleNamespace, changes: dict[str, Any] +) -> update.UpdateReportScheduleCommand: + """Run the real update validator, including its unconditional merged-state check.""" + cast(MagicMock, update.ReportScheduleDAO.find_by_id).return_value = model + command = update.UpdateReportScheduleCommand(1, changes) + command.validate() + return command + + +def test_create_valid_teams_alert_without_installation( + configured_app: Flask, command_dependencies: None +) -> None: + command = create_command() + command.validate() + assert command._properties["recipients"] == [teams_recipient()] + + +def test_create_must_explicitly_disable_default_attachments( + configured_app: Flask, command_dependencies: None +) -> None: + command = create_command() + command._properties.pop("report_format") + with pytest.raises(ReportScheduleInvalidError) as exc: + command.validate() + assert "report_format" in exc.value.normalized_messages() + + +@pytest.mark.parametrize( + "changes,field", + [ + ({"report_format": "PNG"}, "report_format"), + ({"report_format": "PDF"}, "report_format"), + ({"type": "Report", "database": None, "report_format": "PNG"}, "type"), + ({"recipients": [teams_recipient({"target": "unknown"})]}, "recipients"), + ], +) +def test_create_command_applies_shared_recipient_rules( + configured_app: Flask, + command_dependencies: None, + changes: dict[str, Any], + field: str, +) -> None: + with pytest.raises(ReportScheduleInvalidError) as exc: + create_command(**changes).validate() + assert field in exc.value.normalized_messages() + + +@pytest.mark.parametrize( + "changes,field", + [ + ({"report_format": "PNG"}, "report_format"), + ({"report_format": "CSV"}, "report_format"), + ({"type": "Report"}, "type"), + ({"type": "Report", "report_format": "PNG", "database": None}, "type"), + ], +) +def test_partial_update_checks_saved_teams_recipients( + configured_app: Flask, + command_dependencies: None, + changes: dict[str, Any], + field: str, +) -> None: + model = saved_schedule() + before = model.recipients[0].recipient_config_json + with pytest.raises(ReportScheduleInvalidError) as exc: + validate_update(model, changes) + assert field in exc.value.normalized_messages() + assert "Teams" in str(exc.value.normalized_messages()[field]) + assert model.report_format == "NONE" + assert model.type == "Alert" + assert model.recipients[0].recipient_config_json == before + + +@pytest.mark.parametrize("mode", ["disabled", "unconfigured", "removed-destination"]) +def test_metadata_only_update_rechecks_saved_teams_configuration( + configured_app: Flask, command_dependencies: None, mode: str +) -> None: + if mode == "disabled": + configured_app.config["FEATURE_FLAGS"]["ALERT_REPORT_TEAMS"] = False + elif mode == "unconfigured": + configured_app.config["ALERT_REPORTS_TEAMS"] = {} + else: + configured_app.config["ALERT_REPORTS_TEAMS"]["destinations"][0]["key"] = ( + "renamed" + ) + with pytest.raises(ReportScheduleInvalidError) as exc: + validate_update(saved_schedule(), {"name": "Rename only"}) + assert "recipients" in exc.value.normalized_messages() + + +def test_metadata_only_update_preserves_valid_saved_recipients( + configured_app: Flask, command_dependencies: None +) -> None: + command = validate_update(saved_schedule(), {"description": "Changed description"}) + assert "recipients" not in command._properties + assert "report_format" not in command._properties + assert "type" not in command._properties + + +def test_adding_teams_uses_saved_type_and_format( + configured_app: Flask, command_dependencies: None +) -> None: + command = validate_update( + saved_schedule(recipients=[]), {"recipients": [teams_recipient()]} + ) + assert command._properties["recipients"] == [teams_recipient()] + + +@pytest.mark.parametrize( + "replacement", + [[], [{"type": "Email", "recipient_config_json": {"target": "owner@example.com"}}]], +) +def test_removing_teams_allows_format_and_type_change( + configured_app: Flask, command_dependencies: None, replacement: list[dict[str, Any]] +) -> None: + configured_app.config["FEATURE_FLAGS"]["ALERT_REPORT_TEAMS"] = False + command = validate_update( + saved_schedule(), + { + "recipients": replacement, + "type": "Report", + "report_format": "PNG", + "database": None, + }, + ) + assert command._properties["recipients"] == replacement + + +@pytest.mark.parametrize("saved_format", ["PNG", "PDF", "CSV", "XLSX", "TEXT"]) +def test_metadata_update_does_not_preserve_legacy_invalid_teams_format( + configured_app: Flask, command_dependencies: None, saved_format: str +) -> None: + with pytest.raises(ReportScheduleInvalidError) as exc: + validate_update( + saved_schedule(report_format=saved_format), {"name": "Rename only"} + ) + assert "report_format" in exc.value.normalized_messages() + + +def test_update_can_fix_invalid_saved_format_in_same_request( + configured_app: Flask, command_dependencies: None +) -> None: + command = validate_update( + saved_schedule(report_format="PNG"), {"report_format": "NONE"} + ) + assert command._properties["report_format"] == "NONE" + + +@pytest.mark.parametrize("global_attachments", [False, True]) +@pytest.mark.parametrize("fail_delivery", [False, True]) +def test_teams_none_alert_reuses_execution_and_logs_without_screenshot( + monkeypatch: pytest.MonkeyPatch, + global_attachments: bool, + fail_delivery: bool, +) -> None: + schedule = SimpleNamespace( + type=ReportScheduleType.ALERT, + report_format="NONE", + name="Operations alert", + description="Condition triggered", + chart=None, + dashboard=None, + email_subject=None, + include_cta=False, + recipients=[teams_recipient(saved=True)], + ) + state = execute.ReportSuccessState.__new__(execute.ReportSuccessState) + state._report_schedule = schedule + state._execution_id = uuid4() + policy = MagicMock(return_value=global_attachments) + monkeypatch.setattr(execute, "alerts_attach_reports_enabled", policy) + monkeypatch.setattr(state, "is_in_grace_period", lambda: False) + monkeypatch.setattr(state, "is_in_error_grace_period", lambda: True) + monkeypatch.setattr(state, "_handle_retry_or_error", lambda *args: False) + monkeypatch.setattr(state, "_reset_retry_counter", MagicMock()) + logs = MagicMock() + monkeypatch.setattr(state, "update_report_schedule_and_log", logs) + alert = MagicMock() + alert.return_value.run.return_value = (True, None) + monkeypatch.setattr(execute, "AlertCommand", alert) + monkeypatch.setattr(state, "_validate_recipients_policy", MagicMock()) + monkeypatch.setattr(state, "_get_url", lambda **kwargs: None) + monkeypatch.setattr(state, "_get_slack_retry_deadline", lambda: None) + monkeypatch.setattr( + state, + "_get_log_data", + lambda: {"notification_type": "Alert", "notification_format": "NONE"}, + ) + capture = MagicMock( + side_effect=AssertionError( + "attachment-free alerts must not render or fetch files" + ) + ) + for method in ("_get_screenshots", "_get_pdf", "_get_data", "_get_embedded_data"): + monkeypatch.setattr(state, method, capture) + dispatched = MagicMock( + side_effect=NotificationTransientError("Teams temporarily unavailable") + if fail_delivery + else None + ) + monkeypatch.setattr(state, "_send", dispatched) + if fail_delivery: + with pytest.raises(NotificationTransientError): + state.next() + assert logs.call_args_list == [ + call(ReportState.WORKING), + call(ReportState.ERROR, error_message="Teams temporarily unavailable"), + ] + else: + state.next() + assert logs.call_args_list == [ + call(ReportState.WORKING), + call(ReportState.SUCCESS, error_message=None), + ] + message, recipients = dispatched.call_args.args + assert message.name == "Operations alert" + assert message.description == "Condition triggered" + assert not message.has_attachments + assert message.embedded_data is None + assert recipients == schedule.recipients + capture.assert_not_called() + policy.assert_not_called() + alert.return_value.run.assert_called_once() From 88af203dc5f7fc44f0feb4e8c26cfd5bcedbc94a Mon Sep 17 00:00:00 2001 From: Kristi Dodaj Date: Mon, 19 Oct 2026 18:03:32 -0400 Subject: [PATCH 06/30] feat(reports): add Teams destination selection to alert configuration --- .../configuration/alerts-reports.mdx | 13 ++ .../features/alerts/AlertReportModal.test.tsx | 145 +++++++++++++++- .../src/features/alerts/AlertReportModal.tsx | 159 +++++++++--------- .../alerts/components/NotificationMethod.tsx | 41 ++++- .../alerts/components/RecipientIcon.test.tsx | 9 + .../alerts/components/RecipientIcon.tsx | 6 + .../TeamsDestinationSelect.test.tsx | 100 +++++++++++ .../components/TeamsDestinationSelect.tsx | 125 ++++++++++++++ .../alerts/notificationMethods.test.ts | 79 +++++++++ .../features/alerts/notificationMethods.ts | 51 ++++++ .../src/features/alerts/types.ts | 8 + superset/integrations/teams/destinations.py | 60 +++++++ superset/reports/api.py | 38 +++++ superset/views/base.py | 29 ++-- .../reports/teams_api_tests.py | 102 +++++++++++ .../integrations/teams/destinations_test.py | 147 ++++++++++++++++ 16 files changed, 1010 insertions(+), 102 deletions(-) create mode 100644 superset-frontend/src/features/alerts/components/TeamsDestinationSelect.test.tsx create mode 100644 superset-frontend/src/features/alerts/components/TeamsDestinationSelect.tsx create mode 100644 superset-frontend/src/features/alerts/notificationMethods.test.ts create mode 100644 superset-frontend/src/features/alerts/notificationMethods.ts create mode 100644 superset/integrations/teams/destinations.py create mode 100644 tests/integration_tests/reports/teams_api_tests.py create mode 100644 tests/unit_tests/integrations/teams/destinations_test.py diff --git a/docs/admin_docs/configuration/alerts-reports.mdx b/docs/admin_docs/configuration/alerts-reports.mdx index f0df432a6f58..a7535bd5286a 100644 --- a/docs/admin_docs/configuration/alerts-reports.mdx +++ b/docs/admin_docs/configuration/alerts-reports.mdx @@ -988,3 +988,16 @@ Do not serialize the parsed configuration object, including via `dataclasses.asd - [Tutorial: Automated Alerts and Reporting via Slack/Email in Superset](https://dev.to/ngtduc693/apache-superset-topic-5-automated-alerts-and-reporting-via-slackemail-in-superset-2gbe) - [Blog: Integrating Slack alerts and Apache Superset for better data observability](https://medium.com/affinityanswers-tech/integrating-slack-alerts-and-apache-superset-for-better-data-observability-fd2f9a12c350) ::: + +The alert editor lists Teams when the integration is enabled and configured and +attachments are disabled. Choose an approved channel by its display label. To +notify multiple channels, add another notification method and choose Teams again; +each entry stores one destination key. Scheduled reports do not offer Teams. + +The destination list uses the Reports API read permission and returns only keys, +labels, and installation availability. Channels without an active bot installation +remain selectable so alerts can be saved before installation, with a visible +installation-required notice. Install the notification bot in the approved team +before delivery. Removed configuration keys remain visible as unavailable when +editing saved alerts; restore the configuration or select another approved channel. +Credentials are managed only in deployment configuration. diff --git a/superset-frontend/src/features/alerts/AlertReportModal.test.tsx b/superset-frontend/src/features/alerts/AlertReportModal.test.tsx index cec5b278d9ff..b2426ee97d6d 100644 --- a/superset-frontend/src/features/alerts/AlertReportModal.test.tsx +++ b/superset-frontend/src/features/alerts/AlertReportModal.test.tsx @@ -57,9 +57,11 @@ jest.mock('src/utils/getBootstrapData', () => ({ })), })); +let mockNotificationMethods = ['Email', 'Slack', 'SlackV2']; + jest.mock('src/features/databases/state.ts', () => ({ useCommonConf: () => ({ - ALERT_REPORTS_NOTIFICATION_METHODS: ['Email', 'Slack', 'SlackV2'], + ALERT_REPORTS_NOTIFICATION_METHODS: mockNotificationMethods, }), })); @@ -330,6 +332,7 @@ const restoreDefaultTabsRoute = () => { }; afterEach(() => { + mockNotificationMethods = ['Email', 'Slack', 'SlackV2']; restoreDefaultTabsRoute(); // Clear call history so stale counts don't leak between tests @@ -1168,9 +1171,9 @@ test('removes notification method on clicking trash can', async () => { /add another notification method/i, ); await userEvent.click(addNotificationMethod); - const images = screen.getAllByRole('img'); - const trash = images[images.length - 1]; - await userEvent.click(trash); + await userEvent.click( + screen.getByRole('button', { name: /remove notification method/i }), + ); expect( screen.getAllByRole('combobox', { name: /delivery method/i }).length, ).toBe(1); @@ -4662,3 +4665,137 @@ test('global attachment policy hides both the alert toggle and attachment contro ); } }); + +test('Teams preserves separate destination entries and serializes only their keys', async () => { + mockNotificationMethods = ['Email', 'Teams']; + fetchMock.get('glob:*/api/v1/report/92', { + result: { + ...generateMockPayload(true), + id: 92, + report_format: 'NONE', + recipients: [ + { id: 1, type: 'Teams', recipient_config_json: '{"target":"ops"}' }, + { id: 2, type: 'Teams', recipient_config_json: '{"target":"finance"}' }, + ], + }, + }); + fetchMock.get( + 'glob:*/api/v1/report/teams_destinations/', + { + result: [ + { key: 'ops', label: 'Operations', available: true }, + { key: 'finance', label: 'Finance', available: false }, + ], + }, + { name: 'teams-destinations' }, + ); + fetchMock.put( + 'glob:*/api/v1/report/92', + { id: 92, result: {} }, + { name: 'save-teams' }, + ); + try { + render( + , + { + useRedux: true, + initialState: { user: adminUser }, + }, + ); + await waitFor(() => + expect(screen.getByRole('button', { name: /save/i })).toBeEnabled(), + ); + await userEvent.click(screen.getByTestId('notification-method-panel')); + expect( + await screen.findByText('Finance (Bot installation required)'), + ).toBeInTheDocument(); + expect( + screen.getAllByRole('combobox', { name: 'Teams destination' }), + ).toHaveLength(2); + expect( + screen.getByText(/Add another notification method/i), + ).toBeInTheDocument(); + await userEvent.click(screen.getByTestId('contents-panel')); + const attachmentToggle = screen.getByRole('switch', { + name: 'Include attachment', + }); + await userEvent.click(attachmentToggle); + await waitFor(() => + expect(screen.getByRole('button', { name: /save/i })).toBeDisabled(), + ); + await userEvent.click(attachmentToggle); + await waitFor(() => + expect(screen.getByRole('button', { name: /save/i })).toBeEnabled(), + ); + await userEvent.click(screen.getByTestId('notification-method-panel')); + expect( + screen.getByText('Finance (Bot installation required)'), + ).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: /save/i })); + await waitFor(() => + expect(fetchMock.callHistory.calls('save-teams')).toHaveLength(1), + ); + const [call] = fetchMock.callHistory.calls('save-teams'); + expect(JSON.parse(call.options.body as string)).toMatchObject({ + report_format: 'NONE', + recipients: [ + { type: 'Teams', recipient_config_json: { target: 'ops' } }, + { type: 'Teams', recipient_config_json: { target: 'finance' } }, + ], + }); + expect(JSON.parse(call.options.body as string).recipients).toEqual([ + { type: 'Teams', recipient_config_json: { target: 'ops' } }, + { type: 'Teams', recipient_config_json: { target: 'finance' } }, + ]); + } finally { + fetchMock.removeRoute('teams-destinations'); + fetchMock.removeRoute('save-teams'); + } +}); + +test('Teams saved recipients remain visible when deployment configuration is removed', async () => { + fetchMock.get('glob:*/api/v1/report/93', { + result: { + ...generateMockPayload(true), + id: 93, + report_format: 'NONE', + recipients: [ + { + id: 1, + type: 'Teams', + recipient_config_json: '{"target":"removed-key"}', + }, + ], + }, + }); + fetchMock.get( + 'glob:*/api/v1/report/teams_destinations/', + { result: [] }, + { name: 'teams-unavailable' }, + ); + try { + render( + , + { + useRedux: true, + initialState: { user: adminUser }, + }, + ); + await userEvent.click(screen.getByTestId('notification-method-panel')); + expect( + await screen.findByText('removed-key (Unavailable)'), + ).toBeInTheDocument(); + expect( + await screen.findByText(/Teams delivery is unavailable/), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /save/i })).toBeDisabled(); + } finally { + fetchMock.removeRoute('teams-unavailable'); + } +}); diff --git a/superset-frontend/src/features/alerts/AlertReportModal.tsx b/superset-frontend/src/features/alerts/AlertReportModal.tsx index 5dc406d4a6c9..96290b262871 100644 --- a/superset-frontend/src/features/alerts/AlertReportModal.tsx +++ b/superset-frontend/src/features/alerts/AlertReportModal.tsx @@ -119,6 +119,10 @@ import { isUserAdmin } from 'src/dashboard/util/permissionUtils'; import NumberInput from './components/NumberInput'; import { AlertReportCronScheduler } from './components/AlertReportCronScheduler'; import { NotificationMethod } from './components/NotificationMethod'; +import { + getNotificationMethodOptions, + supportsTeamsRecipients, +} from './notificationMethods'; import { buildErrorTooltipMessage } from './buildErrorTooltipMessage'; import { useReportConfiguration } from './hooks/useReportConfiguration'; @@ -580,7 +584,7 @@ const AlertReportModal: FunctionComponent = ({ const currentUserSubjectId = getBootstrapData()?.common?.user_subject_id; // Check config for alternate notification methods setting const conf = useCommonConf(); - const allowedNotificationMethods: NotificationMethodOption[] = + const configuredNotificationMethods: NotificationMethodOption[] = conf?.ALERT_REPORTS_NOTIFICATION_METHODS || DEFAULT_NOTIFICATION_METHODS; const [disableSave, setDisableSave] = useState(true); @@ -707,6 +711,21 @@ const AlertReportModal: FunctionComponent = ({ const formatOptionEnabled = attachmentsEnabledForAlerts || isReport; const attachmentControlsVisible = isReport || (attachmentsEnabledForAlerts && reportFormat !== 'NONE'); + const teamsIntegrationAvailable = configuredNotificationMethods.includes( + NotificationMethodOption.Teams, + ); + const teamsSupported = supportsTeamsRecipients( + isReport, + attachmentsEnabledForAlerts, + reportFormat, + ); + const allowedNotificationMethods = useMemo( + () => + configuredNotificationMethods.filter( + method => method !== NotificationMethodOption.Teams || teamsSupported, + ), + [configuredNotificationMethods, teamsSupported], + ); const [previousAttachmentFormat, setPreviousAttachmentFormat] = useState( DEFAULT_NOTIFICATION_FORMAT, ); @@ -740,53 +759,27 @@ const AlertReportModal: FunctionComponent = ({ } : undefined; - const [notificationAddState, setNotificationAddState] = - useState('active'); - const [notificationSettings, setNotificationSettings] = useState< NotificationSetting[] >([]); const [emailSubject, setEmailSubject] = useState(''); const [emailError, setEmailError] = useState(false); - const allowedNotificationMethodsCount = useMemo( - () => - allowedNotificationMethods.reduce((accum: string[], setting: string) => { - if ( - accum.some(nm => nm.includes('slack')) && - setting.toLowerCase().includes('slack') - ) { - return accum; - } - return [...accum, setting.toLowerCase()]; - }, []).length, - [allowedNotificationMethods], + const availableNotificationMethods = getNotificationMethodOptions( + allowedNotificationMethods, + notificationSettings, ); + const notificationAddState: NotificationAddStatus = notificationSettings.some( + setting => !setting.method, + ) + ? 'disabled' + : 'active'; const onNotificationAdd = () => { setNotificationSettings([ ...notificationSettings, - { - recipients: '', - // options shown in the newly added notification method - options: allowedNotificationMethods.filter( - // are filtered such that - option => - // options are not included - !notificationSettings.reduce( - // when it exists in previous notificationSettings - (accum, setting) => accum || option === setting.method, - false, - ), - ), - }, + { recipients: '', options: availableNotificationMethods }, ]); - - setNotificationAddState( - notificationSettings.length === allowedNotificationMethodsCount - ? 'hidden' - : 'disabled', - ); }; const { @@ -1040,31 +1033,12 @@ const AlertReportModal: FunctionComponent = ({ const settings: NotificationSetting[] = [...notificationSettings]; settings[index] = setting; - // if you've changed notification method -> remove trailing methods - if (notificationSettings[index].method !== setting.method) { - notificationSettings[index] = setting; - - setNotificationSettings( - notificationSettings.filter((_, idx) => idx <= index), - ); - - if (notificationSettings.length - 1 > index) { - setNotificationAddState('active'); - } - - if (setting.method !== undefined && notificationAddState !== 'hidden') { - setNotificationAddState('active'); - } - } else { - setNotificationSettings(settings); - } + setNotificationSettings(settings); }; const removeNotificationSetting = (index: number) => { - const settings = notificationSettings.slice(); - - settings.splice(index, 1); - setNotificationSettings(settings); - setNotificationAddState('active'); + setNotificationSettings( + notificationSettings.filter((_, idx) => idx !== index), + ); }; const updateAnchorState = (value: any) => { @@ -1099,7 +1073,6 @@ const AlertReportModal: FunctionComponent = ({ onHide(); setNotificationSettings([]); setCurrentAlert({ ...defaultAlert }); - setNotificationAddState('active'); }; const onSave = () => { @@ -1111,8 +1084,12 @@ const AlertReportModal: FunctionComponent = ({ recipients.push({ recipient_config_json: { target: setting.recipients, - ccTarget: setting.cc, - bccTarget: setting.bcc, + ...(setting.method === NotificationMethodOption.Teams + ? {} + : { + ccTarget: setting.cc, + bccTarget: setting.bcc, + }), }, type: setting.method, }); @@ -1906,6 +1883,20 @@ const AlertReportModal: FunctionComponent = ({ return false; } + const hasTeamsRecipients = notificationSettings.some( + setting => setting.method === NotificationMethodOption.Teams, + ); + const invalidTeamsRecipients = + hasTeamsRecipients && + (!teamsSupported || + !teamsIntegrationAvailable || + notificationSettings.some( + setting => + setting.method === NotificationMethodOption.Teams && + !setting.recipients, + )); + if (invalidTeamsRecipients) return false; + let hasInfo = false; notificationSettings.forEach(setting => { @@ -2255,7 +2246,6 @@ const AlertReportModal: FunctionComponent = ({ method: NotificationMethodOption.Email, }, ]); - setNotificationAddState('active'); } }, [alert]); @@ -2282,7 +2272,7 @@ const AlertReportModal: FunctionComponent = ({ const config = typeof setting.recipient_config_json === 'string' ? JSON.parse(setting.recipient_config_json) - : {}; + : setting.recipient_config_json; return { method: setting.type, recipients: config.target || setting.recipient_config_json, @@ -2293,11 +2283,6 @@ const AlertReportModal: FunctionComponent = ({ }); setNotificationSettings(settings); - setNotificationAddState( - settings.length === allowedNotificationMethods.length - ? 'hidden' - : 'active', - ); setContentType( resource.chart ? ContentType.Chart : ContentType.Dashboard, ); @@ -2378,6 +2363,8 @@ const AlertReportModal: FunctionComponent = ({ contentType, attachmentControlsVisible, reportFormat, + teamsSupported, + teamsIntegrationAvailable, nativeFilterData, notificationSettings, conditionNotNull, @@ -3286,11 +3273,21 @@ const AlertReportModal: FunctionComponent = ({ children: ( <> {notificationSettings.map((notificationSetting, i) => ( - + = ({ /> ))} - { - // Prohibit 'add notification method' button if only one present - allowedNotificationMethodsCount > - notificationSettings.length && ( - - ) - } + {availableNotificationMethods.length > 0 && ( + + )} ), }, diff --git a/superset-frontend/src/features/alerts/components/NotificationMethod.tsx b/superset-frontend/src/features/alerts/components/NotificationMethod.tsx index cb1e64b399fe..8d82c24d2e42 100644 --- a/superset-frontend/src/features/alerts/components/NotificationMethod.tsx +++ b/superset-frontend/src/features/alerts/components/NotificationMethod.tsx @@ -28,6 +28,7 @@ import { import rison from 'rison'; import { t } from '@apache-superset/core/translation'; +import { Alert } from '@apache-superset/core/components'; import { FeatureFlag, JsonResponse, @@ -49,6 +50,7 @@ import { SlackChannel, } from '../types'; import { StyledInputContainer } from '../AlertReportModal'; +import TeamsDestinationSelect from './TeamsDestinationSelect'; const StyledNotificationMethod = styled.div` ${({ theme }) => ` @@ -143,6 +145,8 @@ const TRANSLATIONS = { }; interface NotificationMethodProps { + teamsSupported?: boolean; + teamsIntegrationAvailable?: boolean; setting?: NotificationSetting | null; index: number; onUpdate?: (index: number, updatedSetting: NotificationSetting) => void; @@ -256,6 +260,8 @@ const fetchEmailRecipientOptions = async ( }; export const NotificationMethod: FunctionComponent = ({ + teamsSupported = true, + teamsIntegrationAvailable = true, setting = null, index, onUpdate, @@ -480,7 +486,8 @@ export const NotificationMethod: FunctionComponent = ({ method === NotificationMethodOption.Slack) || (isFeatureEnabled(FeatureFlag.AlertReportWebhook) && method === NotificationMethodOption.Webhook) || - method === NotificationMethodOption.Email, + method === NotificationMethodOption.Email || + method === NotificationMethodOption.Teams, ) .map(method => ({ label: @@ -488,8 +495,11 @@ export const NotificationMethod: FunctionComponent = ({ ? NotificationMethodOption.Slack : method, value: method, + disabled: + method === NotificationMethodOption.Teams && + (!teamsSupported || !teamsIntegrationAvailable), })), - [options, useSlackV1], + [options, useSlackV1, teamsSupported, teamsIntegrationAvailable], ); if (!setting) { @@ -626,6 +636,23 @@ export const NotificationMethod: FunctionComponent = ({ ) : null} + {method === NotificationMethodOption.Teams && !teamsSupported && ( + + )} + {method === NotificationMethodOption.Teams && + !teamsIntegrationAvailable && ( + + )} {method !== NotificationMethodOption.Webhook ? (
@@ -639,7 +666,15 @@ export const NotificationMethod: FunctionComponent = ({ *
- {method === NotificationMethodOption.Slack ? ( + {method === NotificationMethodOption.Teams ? ( + + onUpdate?.(index, { ...setting, recipients: value }) + } + /> + ) : method === NotificationMethodOption.Slack ? ( <>
{ }); }); }); + +test('renders Teams with an accessible icon', () => { + const { container } = render( + , + ); + expect( + container.querySelector('[data-icon="usergroup-add"]'), + ).toBeInTheDocument(); +}); diff --git a/superset-frontend/src/features/alerts/components/RecipientIcon.tsx b/superset-frontend/src/features/alerts/components/RecipientIcon.tsx index 8e4dd26ee6de..f50edf1789ad 100644 --- a/superset-frontend/src/features/alerts/components/RecipientIcon.tsx +++ b/superset-frontend/src/features/alerts/components/RecipientIcon.tsx @@ -52,6 +52,12 @@ export default function RecipientIcon({ type }: { type: string }) { ); recipientIconConfig.label = NotificationMethodOption.Slack; break; + case NotificationMethodOption.Teams: + recipientIconConfig.icon = ( + + ); + recipientIconConfig.label = NotificationMethodOption.Teams; + break; case NotificationMethodOption.Webhook: recipientIconConfig.icon = ( diff --git a/superset-frontend/src/features/alerts/components/TeamsDestinationSelect.test.tsx b/superset-frontend/src/features/alerts/components/TeamsDestinationSelect.test.tsx new file mode 100644 index 000000000000..589902117d84 --- /dev/null +++ b/superset-frontend/src/features/alerts/components/TeamsDestinationSelect.test.tsx @@ -0,0 +1,100 @@ +/** + * 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 { + render, + screen, + userEvent, + waitFor, +} from 'spec/helpers/testing-library'; +import TeamsDestinationSelect from './TeamsDestinationSelect'; + +const endpoint = 'glob:*/api/v1/report/teams_destinations/'; +const destinations = [ + { key: 'ops', label: 'Operations', available: true }, + { key: 'finance', label: 'Finance', available: false }, +]; + +afterEach(() => fetchMock.clearHistory().removeRoutes()); + +test('selects approved destinations by label and returns only the stable key', async () => { + fetchMock.get(endpoint, { result: destinations }); + const onChange = jest.fn(); + render(); + await userEvent.click(screen.getByRole('combobox')); + await userEvent.click(await screen.findByText('Operations')); + expect(onChange).toHaveBeenCalledWith('ops', expect.anything()); +}); + +test('explains a missing installation without clearing the saved destination', async () => { + fetchMock.get(endpoint, { result: destinations }); + const onChange = jest.fn(); + render(); + expect( + await screen.findByText( + /Install the notification bot in the approved team before delivery/, + ), + ).toBeInTheDocument(); + expect( + screen.getByText('Finance (Bot installation required)'), + ).toBeInTheDocument(); + expect(onChange).not.toHaveBeenCalled(); +}); + +test('preserves removed destination keys with a visible unavailable state', async () => { + fetchMock.get(endpoint, { result: destinations }); + const onChange = jest.fn(); + render(); + expect( + await screen.findByText(/This saved Teams destination is unavailable/), + ).toBeInTheDocument(); + expect(screen.getByText('removed-key (Unavailable)')).toBeInTheDocument(); + expect(onChange).not.toHaveBeenCalled(); +}); + +test('a loading failure keeps saved selections and displays a recoverable error', async () => { + fetchMock.get(endpoint, 500); + const onChange = jest.fn(); + render(); + expect( + await screen.findByText(/Unable to load Teams destinations/), + ).toBeInTheDocument(); + expect(screen.getByRole('combobox')).toBeDisabled(); + expect(onChange).not.toHaveBeenCalled(); +}); + +test('supports saving a configured destination before bot installation', async () => { + fetchMock.get(endpoint, { result: destinations }); + const onChange = jest.fn(); + render(); + await userEvent.click(screen.getByRole('combobox')); + await userEvent.click( + await screen.findByText('Finance (Bot installation required)'), + ); + expect(onChange).toHaveBeenCalledWith('finance', expect.anything()); +}); + +test('keeps Teams destinations disabled for incompatible schedules', async () => { + fetchMock.get(endpoint, { result: destinations }); + render(); + await waitFor(() => + expect(screen.getByText('Operations')).toBeInTheDocument(), + ); + expect(screen.getByRole('combobox')).toBeDisabled(); +}); diff --git a/superset-frontend/src/features/alerts/components/TeamsDestinationSelect.tsx b/superset-frontend/src/features/alerts/components/TeamsDestinationSelect.tsx new file mode 100644 index 000000000000..1c719ffb85e1 --- /dev/null +++ b/superset-frontend/src/features/alerts/components/TeamsDestinationSelect.tsx @@ -0,0 +1,125 @@ +/** + * 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 { useEffect, useState } from 'react'; +import { t } from '@apache-superset/core/translation'; +import { Alert } from '@apache-superset/core/components'; +import { SupersetClient } from '@superset-ui/core'; +import { Select } from '@superset-ui/core/components'; +import { TeamsDestination } from '../types'; + +interface TeamsDestinationSelectProps { + value: string; + onChange: (value: string) => void; + disabled?: boolean; +} + +export default function TeamsDestinationSelect({ + value, + onChange, + disabled = false, +}: TeamsDestinationSelectProps) { + const [destinations, setDestinations] = useState([]); + const [loading, setLoading] = useState(true); + const [loadFailed, setLoadFailed] = useState(false); + + useEffect(() => { + let cancelled = false; + SupersetClient.get({ endpoint: '/api/v1/report/teams_destinations/' }) + .then(({ json }) => { + if (!cancelled) setDestinations(json.result); + }) + .catch(() => { + if (!cancelled) setLoadFailed(true); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + const selectedDestination = destinations.find(item => item.key === value); + const savedDestinationUnavailable = + Boolean(value) && !loading && !loadFailed && !selectedDestination; + const installationRequired = + selectedDestination !== undefined && !selectedDestination.available; + const options = destinations.map(destination => ({ + value: destination.key, + label: destination.available + ? destination.label + : t('%s (Bot installation required)', destination.label), + })); + if (value && !selectedDestination) { + options.push({ + value, + label: loading || loadFailed ? value : t('%s (Unavailable)', value), + }); + } + + return ( + <> + onChange(event.target.value)} + /> + ), +})); + +test.each([ + { supported: true, available: true, warning: null }, + { + supported: false, + available: true, + warning: /Teams delivery requires an alert with attachments disabled/, + }, + { + supported: true, + available: false, + warning: /Teams delivery is unavailable/, + }, +])( + 'Teams retains its saved key when supported=$supported and available=$available', + ({ supported, available, warning }) => { + const onUpdate = jest.fn(); + render( + , + ); + const destination = screen.getByRole('textbox', { + name: 'Teams destination', + }); + expect(destination).toHaveValue('saved-key'); + if (warning) { + expect(screen.getByText(warning)).toBeInTheDocument(); + expect(destination).toBeDisabled(); + } else { + expect(destination).toBeEnabled(); + fireEvent.change(destination, { target: { value: 'ops' } }); + expect(onUpdate).toHaveBeenCalledWith( + 2, + expect.objectContaining({ + method: NotificationMethodOption.Teams, + recipients: 'ops', + }), + ); + } + }, +); diff --git a/superset-frontend/src/features/alerts/components/TeamsDestinationSelect.test.tsx b/superset-frontend/src/features/alerts/components/TeamsDestinationSelect.test.tsx index 589902117d84..9a78c5b50e8c 100644 --- a/superset-frontend/src/features/alerts/components/TeamsDestinationSelect.test.tsx +++ b/superset-frontend/src/features/alerts/components/TeamsDestinationSelect.test.tsx @@ -98,3 +98,52 @@ test('keeps Teams destinations disabled for incompatible schedules', async () => ); expect(screen.getByRole('combobox')).toBeDisabled(); }); + +test('explains an empty allowlist without inventing destinations', async () => { + fetchMock.get(endpoint, { result: [] }); + const onChange = jest.fn(); + render(); + expect( + await screen.findByText(/No approved Teams destinations are configured/), + ).toBeInTheDocument(); + expect(onChange).not.toHaveBeenCalled(); +}); + +test('allows replacing a removed saved key with an approved destination', async () => { + fetchMock.get(endpoint, { result: destinations }); + const onChange = jest.fn(); + const { rerender } = render( + , + ); + expect(await screen.findByText('removed (Unavailable)')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('combobox')); + await userEvent.click(await screen.findByText('Operations')); + expect(onChange).toHaveBeenCalledWith('ops', expect.anything()); + rerender(); + expect( + screen.queryByText(/This saved Teams destination is unavailable/), + ).not.toBeInTheDocument(); + expect(screen.getAllByText('Operations').length).toBeGreaterThan(0); +}); + +test('an availability-only refresh preserves the stable saved destination key', async () => { + fetchMock.get(endpoint, { result: destinations }); + const onChange = jest.fn(); + const { unmount } = render( + , + ); + expect( + await screen.findByText('Finance (Bot installation required)'), + ).toBeInTheDocument(); + unmount(); + fetchMock.removeRoutes(); + fetchMock.get(endpoint, { + result: destinations.map(item => ({ ...item, available: true })), + }); + render(); + expect(await screen.findByText('Finance')).toBeInTheDocument(); + expect( + screen.queryByText(/Install the notification bot/), + ).not.toBeInTheDocument(); + expect(onChange).not.toHaveBeenCalled(); +}); diff --git a/tests/unit_tests/reports/notifications/teams_tests.py b/tests/unit_tests/reports/notifications/teams_tests.py new file mode 100644 index 000000000000..cc507825390d --- /dev/null +++ b/tests/unit_tests/reports/notifications/teams_tests.py @@ -0,0 +1,232 @@ +# 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. +"""Notification contract tests that run without the optional Microsoft SDK.""" + +from collections.abc import Iterator +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from flask import Flask +from sqlalchemy.exc import SQLAlchemyError + +from superset.integrations.teams import validation +from superset.reports.models import ReportRecipients, ReportRecipientType +from superset.reports.notifications import create_notification, teams +from superset.reports.notifications.base import NotificationContent +from superset.reports.notifications.exceptions import ( + NotificationAuthorizationException, + NotificationError, + NotificationParamException, +) +from superset.utils import json + + +@pytest.fixture +def notification(monkeypatch: pytest.MonkeyPatch) -> Iterator[SimpleNamespace]: + """Keep the transport replaceable while exercising real recipient validation.""" + app = Flask(__name__) + app.config["ALERT_REPORTS_TEAMS"] = { + "tenant_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "application_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + "credential": "server-only-credential", + "destinations": [ + { + "key": "ops", + "label": "Operations", + "team_id": "team-1", + "channel_id": "channel-1", + } + ], + } + monkeypatch.setattr( + validation.feature_flag_manager, "is_feature_enabled", lambda _: True + ) + session = MagicMock() + monkeypatch.setattr(teams, "db", SimpleNamespace(engine=object())) + monkeypatch.setattr(teams, "Session", session) + store = MagicMock() + store.return_value.get_active.return_value = SimpleNamespace( + service_url="https://smba.trafficmanager.net/amer/" + ) + monkeypatch.setattr(teams, "TeamsInstallationStore", store) + send = MagicMock() + monkeypatch.setattr(teams, "_send_to_teams", send) + content = NotificationContent( + name='Revenue "quoted"\n東京 🚨', + description="Café\nsecond line", + header_data={ + "notification_type": "Alert", + "notification_format": "NONE", + "editors": [], + "notification_source": None, + "chart_id": None, + "dashboard_id": None, + "slack_channels": None, + "execution_id": None, + }, + url="https://superset.example/chart/42/", + ) + recipient = ReportRecipients( + type=ReportRecipientType.TEAMS, recipient_config_json='{"target":"ops"}' + ) + with app.app_context(): + yield SimpleNamespace( + app=app, + content=content, + recipient=recipient, + store=store, + session=session, + send=send, + ) + + +def test_factory_delivery_uses_server_config_and_closes_session( + notification: SimpleNamespace, +) -> None: + """A destination key resolves on the server and database work ends before send.""" + + def check_closed(*_: object) -> None: + notification.session.return_value.__exit__.assert_called_once() + + notification.send.side_effect = check_closed + handler = create_notification(notification.recipient, notification.content) + assert isinstance(handler, teams.TeamsNotification) + handler.send() + config, destination, service_url, card = notification.send.call_args.args + assert config.credential == "server-only-credential" + assert destination.key == "ops" + assert service_url == "https://smba.trafficmanager.net/amer/" + assert json.loads(json.dumps(card))["body"][0]["text"] == notification.content.name + assert card["body"][1]["text"] == notification.content.description + assert card["actions"][0]["url"] == notification.content.url + assert "server-only-credential" not in json.dumps(card) + assert json.loads(notification.recipient.recipient_config_json) == {"target": "ops"} + + +@pytest.mark.parametrize( + "field,value", + [ + ("csv", b"csv"), + ("xlsx", b"xlsx"), + ("pdf", b"pdf"), + ("screenshots", [b"png"]), + ("embedded_data", []), + ("is_editor_error", True), + ], +) +def test_rejects_attachments_and_editor_diagnostics( + notification: SimpleNamespace, field: str, value: object +) -> None: + """Excluded content fails before accessing installations or the transport.""" + setattr(notification.content, field, value) + with pytest.raises(NotificationParamException): + teams.TeamsNotification(notification.recipient, notification.content).send() + notification.store.assert_not_called() + notification.send.assert_not_called() + + +@pytest.mark.parametrize( + "change", ["disabled", "unconfigured", "removed", "report", "attachment"] +) +def test_saved_recipient_is_revalidated_at_delivery( + notification: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, change: str +) -> None: + """Previously saved recipients cannot bypass deployment or schedule changes.""" + handler = teams.TeamsNotification(notification.recipient, notification.content) + if change == "disabled": + monkeypatch.setattr( + validation.feature_flag_manager, "is_feature_enabled", lambda _: False + ) + elif change == "unconfigured": + notification.app.config["ALERT_REPORTS_TEAMS"] = {} + elif change == "removed": + notification.app.config["ALERT_REPORTS_TEAMS"]["destinations"][0]["key"] = ( + "replacement" + ) + elif change == "report": + notification.content.header_data["notification_type"] = "Report" + else: + notification.content.header_data["notification_format"] = "PNG" + with pytest.raises(NotificationParamException): + handler.send() + notification.store.assert_not_called() + notification.send.assert_not_called() + + +def test_missing_active_installation_prevents_transport( + notification: SimpleNamespace, +) -> None: + """Saving without installation is allowed; sending without one is not.""" + notification.store.return_value.get_active.return_value = None + with pytest.raises( + NotificationAuthorizationException, match="No active Teams bot installation" + ): + teams.TeamsNotification(notification.recipient, notification.content).send() + notification.session.return_value.__exit__.assert_called_once() + notification.send.assert_not_called() + + +def test_sql_error_is_sanitized_before_leaving_notification( + notification: SimpleNamespace, +) -> None: + """Neither channel content nor the outward exception includes SQL diagnostics.""" + notification.store.return_value.get_active.side_effect = SQLAlchemyError( + "SELECT password FROM secret_table" + ) + with pytest.raises( + NotificationError, match="^Unable to resolve Teams bot installation$" + ) as error: + teams.TeamsNotification(notification.recipient, notification.content).send() + assert error.value.__suppress_context__ + notification.send.assert_not_called() + + +@pytest.mark.parametrize( + "include_cta,url", + [ + (False, "https://superset.example"), + (True, None), + (True, "javascript:alert(1)"), + (True, "https://[invalid"), + ], +) +def test_card_omits_disabled_or_invalid_link( + notification: SimpleNamespace, include_cta: bool, url: str | None +) -> None: + """Link opt-out and malformed URLs never create card actions.""" + notification.content.include_cta = include_cta + notification.content.url = url + assert "actions" not in teams.build_teams_card(notification.content) + + +def test_card_omits_empty_description_and_hides_execution_error( + notification: SimpleNamespace, +) -> None: + """Only a generic failure notice is posted for internal execution errors.""" + notification.content.description = None + notification.content.text = ( + "SELECT password FROM secret_table; database://credential" + ) + card = teams.build_teams_card(notification.content) + assert len(card["body"]) == 2 + assert ( + card["body"][1]["text"] + == "Alert execution failed. Contact the alert owner for details." + ) + assert "secret_table" not in json.dumps(card) + assert "credential" not in json.dumps(card) From fcf44627e1fb25ac3ec8fd4f9cd608bc354df574 Mon Sep 17 00:00:00 2001 From: Kristi Dodaj Date: Mon, 19 Oct 2026 21:03:14 -0400 Subject: [PATCH 09/30] refactor(reports): simplify Teams delivery timeout handling --- .../configuration/alerts-reports.mdx | 44 +- superset/commands/report/execute.py | 7 + superset/integrations/teams/adapter.py | 199 +----- superset/reports/notifications/base.py | 2 + superset/reports/notifications/teams.py | 12 +- .../integrations/teams/delivery_test.py | 599 +++++++----------- .../integrations/teams/validation_test.py | 47 ++ .../reports/notifications/teams_tests.py | 4 +- 8 files changed, 361 insertions(+), 553 deletions(-) diff --git a/docs/admin_docs/configuration/alerts-reports.mdx b/docs/admin_docs/configuration/alerts-reports.mdx index 05a3f4647329..23eb8abedbb8 100644 --- a/docs/admin_docs/configuration/alerts-reports.mdx +++ b/docs/admin_docs/configuration/alerts-reports.mdx @@ -944,8 +944,10 @@ tabular results are rejected. The Adaptive Card's protocol attachment envelope contains only the card, with no attached files. Transport runs in the Teams adapter with a replaceable operation-scoped interface. -It allows at most four attempts within a 60-second retry window, bounds individual -HTTP requests to 10 seconds or the remaining window, and honors `Retry-After` +It allows at most four attempts within one 60-second retry budget, capped by the +remaining execution deadline after its cleanup reserve. Individual MSAL metadata, +token, and channel HTTP requests use a timeout of at most 10 seconds or the +remaining budget. The adapter honors `Retry-After` (seconds or HTTP date) and `x-ms-retry-after-ms`. A delay exceeding the remaining window ends delivery without retrying early. One 401 response can invalidate the MSAL token cache and retry with fresh authentication; a second 401 or any 403 @@ -996,19 +998,29 @@ before delivery. Removed configuration keys remain visible as unavailable when editing saved alerts; restore the configuration or select another approved channel. Credentials are managed only in deployment configuration. -Teams delivery has a single 60-second wall-clock budget covering SDK construction, -synchronous token acquisition, channel posting, resource cleanup, event-loop -shutdown, and delivery-child reaping. Up to five seconds are reserved for cleanup -and reaping. An exhausted operation raises the existing transient notification -error; retries do not reset its budget. Request-level timeouts also apply. - -Bounded delivery requires a Unix main-thread worker, such as Celery's prefork or -solo worker. Each delivery uses a short-lived child process, without starting an -SDK HTTP server. Only a sanitized outcome crosses the child's result pipe. The -parent's alarms and signal handlers remain intact. If SDK teardown stalls, the -parent terminates and reaps the child within the same operation budget. Other -execution contexts fail before opening the transport. - +Teams delivery runs inside the existing report task. Authentication, posting, +retry delays, and resource cleanup consume the same retry budget; an operation +that finishes after the deadline cannot report success. SDK and MSAL clients are +closed in the same operation, and Celery soft timeouts propagate to the existing +execution handler without transport retries. + +The retry budget is cooperative, not a separate hard 60-second wall-clock limit. +[Requests timeouts](https://requests.readthedocs.io/en/latest/user/advanced/#timeouts) +bound socket operations rather than the entire call; DNS resolution and a response +that keeps delivering small amounts of data can exceed them. The adapter does not +introduce a subprocess, signal handler, background thread, or watchdog to interrupt +such work. Cleanup runs normally on success, failure, and soft task timeouts; +a hard task kill cannot guarantee Python cleanup. + +For an outer bound on blocking work, use a Celery prefork worker on a platform +that supports [Celery time limits](https://docs.celeryq.dev/en/stable/userguide/workers.html#time-limits), +keep `ALERT_REPORTS_WORKING_TIME_OUT_KILL=True`, and set a finite schedule +`working_timeout`. For alerts, the existing defaults apply a soft limit at +`working_timeout + 1` seconds and a hard limit at `working_timeout + 10` seconds. +These limits cover the entire task, including query evaluation and notification +delivery, rather than granting Teams another independent hard timeout. Disabling +those limits or using a worker pool without time-limit support removes this +backstop; request timeouts alone do not replace it. ### Deploy the optional integration @@ -1141,7 +1153,7 @@ and a new verified installation for that identity. | Callback returns 401 or 403 | Check connector token authentication, bot application/tenant IDs, approved team, signed service URL, and that ingress preserved Authorization. Browser cookies cannot authenticate this route. | | Delivery fails authorization | Check installation status, secret value/expiry, bot registration, and channel access. A second 401 after one refresh or a 403 ends delivery. | | Save fails after a configuration change | Restore the integration/key or explicitly remove/replace Teams recipients. Partial updates validate the merged schedule, including format and schedule type. | -| Delivery times out or exhausts retries | Check Entra and Bot Framework connectivity and worker execution context. The entire synchronous call has one 60-second budget, including token acquisition and cleanup. Unix main-thread prefork/solo workers are supported. | +| Delivery times out or exhausts retries | Check Entra and Bot Framework connectivity and worker execution context. The 60-second retry budget includes token acquisition and cleanup and is capped by the execution deadline. Use a supported prefork worker with finite `working_timeout` and `ALERT_REPORTS_WORKING_TIME_OUT_KILL=True` for the outer task limit. | | A duplicate channel post appears | A response can be lost after Teams accepted a post. Bounded retries may create another root post; delivery is not exactly-once. | Use the existing alert execution logs to inspect success and failure. Do not copy diff --git a/superset/commands/report/execute.py b/superset/commands/report/execute.py index c367bb7d44d2..7d1e99d5db37 100644 --- a/superset/commands/report/execute.py +++ b/superset/commands/report/execute.py @@ -1742,6 +1742,13 @@ def _send_notification( ) -> None: """Send one notification, upgrading Slack v1 recipients when required.""" self._assert_rendered_delivery_allowed(notification_content) + if self._report_execution_context is not None: + context = self._report_execution_context + notification_content.delivery_deadline = ( + context.deadline.started_at + + context.deadline.total_seconds + - context.cleanup_reserve_seconds + ) notification = create_notification(recipient, notification_content) if app.config["ALERT_REPORTS_NOTIFICATION_DRY_RUN"]: logger.info( diff --git a/superset/integrations/teams/adapter.py b/superset/integrations/teams/adapter.py index 0b998e2d4b4a..499ec38f12b4 100644 --- a/superset/integrations/teams/adapter.py +++ b/superset/integrations/teams/adapter.py @@ -20,18 +20,13 @@ import asyncio import math -import os -import select -import signal -import threading from collections.abc import AsyncIterator, Callable from contextlib import AbstractAsyncContextManager, asynccontextmanager from dataclasses import dataclass from datetime import datetime, timezone from email.utils import parsedate_to_datetime -from time import monotonic, sleep -from types import FrameType -from typing import Any, Literal, NoReturn, Protocol +from time import monotonic +from typing import Any, Literal, Protocol from uuid import UUID import httpx @@ -234,8 +229,6 @@ async def on_request(event: ActivityEvent) -> InvokeResponse[None]: # Count every send, including the single 401 replay, against the same retry bound. _DELIVERY_ATTEMPTS = 4 _DELIVERY_TIMEOUT = 60.0 -_DELIVERY_CLEANUP_RESERVE = 5.0 -_DELIVERY_WORKERS: set[int] = set() _DELIVERY_REQUEST_TIMEOUT = 10.0 @@ -289,7 +282,7 @@ async def refresh_auth(self) -> None: def _remaining(deadline: float) -> float: - """Bound each request by its timeout and remaining delivery work time.""" + """Bound each request by its timeout and remaining delivery budget.""" remaining = deadline - monotonic() if remaining <= 0: raise TeamsTransportError() @@ -317,6 +310,7 @@ def _request(self, method: str, url: str, **kwargs: Any) -> requests.Response: kwargs["timeout"] = _remaining(self.deadline) response = self.session.request(method, url, **kwargs) try: + _remaining(self.deadline) # requests eagerly reads responses; MSAL can still read cached JSON # after close. Closing here also covers retryable OAuth errors. if response.status_code in (408, 429) or response.status_code >= 500: @@ -468,6 +462,8 @@ async def _deliver_with_retries( raise TeamsDeliveryError("transient") try: await transport.post_root(destination, card) + if monotonic() >= deadline: + raise TeamsDeliveryError("transient") return except TeamsTransportError as error: if error.status in (401, 403): @@ -499,179 +495,34 @@ async def _deliver_with_retries( ] -DeliveryOperation = Callable[[Callable[[], None]], None] - - -class _DeliveryDeadlineExpired(BaseException): - """Interrupt token acquisition without being swallowed by SDK exception handlers.""" - - -def _reap_delivery_worker(pid: int) -> bool: - """Reap only a delivery child owned by this worker, without blocking Celery.""" - try: - exited, _ = os.waitpid(pid, os.WNOHANG) - except ChildProcessError: - exited = pid - if exited: - _DELIVERY_WORKERS.discard(pid) - return bool(exited) - - -def _close_delivery_process( - reader: int, writer: int, pid: int | None, deadline: float -) -> None: - """Kill a stalled child and bound reaping by the same synchronous-call budget.""" - previous_mask = signal.pthread_sigmask( - signal.SIG_BLOCK, {signal.SIGALRM, signal.SIGUSR1} - ) - try: - os.close(reader) - if writer >= 0: - os.close(writer) - if pid is None or _reap_delivery_worker(pid): - return - try: - os.kill(pid, signal.SIGKILL) - except ProcessLookupError: - pass - reap_deadline = min(deadline, monotonic() + 1.0) - while not _reap_delivery_worker(pid): - remaining = reap_deadline - monotonic() - if remaining <= 0: - return - sleep(min(0.001, remaining)) - finally: - signal.pthread_sigmask(signal.SIG_SETMASK, previous_mask) - - -def _delivery_child( - operation: DeliveryOperation, - pipe: tuple[int, int], - work_deadline: float, - deadline: float, - previous_mask: set[int | signal.Signals], -) -> NoReturn: - """Own all SDK resources in a child; report only a sanitized one-byte outcome.""" - reader, writer = pipe - outcome = b"t" - watchdog: threading.Timer | None = None - - def interrupt(_signum: int, _frame: FrameType | None) -> None: - """Leave time for ordinary SDK teardown before the parent enforces exit.""" - raise _DeliveryDeadlineExpired() - - def begin_cleanup() -> None: - """Let the parent bound cleanup, including stalled finalizers.""" - signal.setitimer(signal.ITIMER_REAL, 0) - - try: - os.close(reader) - signal.signal(signal.SIGALRM, interrupt) - signal.signal(signal.SIGUSR1, signal.SIG_IGN) - signal.pthread_sigmask(signal.SIG_SETMASK, previous_mask - {signal.SIGALRM}) - signal.setitimer(signal.ITIMER_REAL, max(work_deadline - monotonic(), 0.001)) - # Remain bounded even if the Celery worker exits before it can reap us. - watchdog = threading.Timer(max(deadline - monotonic(), 0), os._exit, (1,)) - watchdog.daemon = True - watchdog.start() - operation(begin_cleanup) - outcome = b"0" - except TeamsDeliveryError as error: - outcome = {"authorization": b"a", "parameter": b"p", "transient": b"t"}[ - error.category - ] - finally: - try: - if watchdog is not None: - watchdog.cancel() - signal.setitimer(signal.ITIMER_REAL, 0) - os.write(writer, outcome) - finally: - # Do not run inherited Flask/Celery finalizers. Exiting also closes - # sockets if Python or SDK cleanup could not finish within budget. - os._exit(0) - - -def _run_delivery_process( - operation: DeliveryOperation, work_deadline: float, deadline: float -) -> None: - """Bound synchronous SDK work and cleanup from the existing Celery worker. - - Direct fork supports Celery's daemonic prefork workers without starting an - SDK server. No configuration, tokens or exceptions cross the outcome pipe. - Other execution contexts fail before constructing the SDK transport. - """ - if ( - threading.current_thread() is not threading.main_thread() - or not hasattr(os, "fork") - or not hasattr(signal, "pthread_sigmask") - or monotonic() >= work_deadline - ): - raise TeamsDeliveryError("transient") - for previous_pid in tuple(_DELIVERY_WORKERS): - _reap_delivery_worker(previous_pid) - try: - reader, writer = os.pipe() - except OSError: - raise TeamsDeliveryError("transient") from None - pid: int | None = None - outcome = b"t" - try: - previous_mask = signal.pthread_sigmask( - signal.SIG_BLOCK, {signal.SIGALRM, signal.SIGUSR1} - ) - try: - pid = os.fork() - if pid == 0: - _delivery_child( - operation, (reader, writer), work_deadline, deadline, previous_mask - ) - _DELIVERY_WORKERS.add(pid) - finally: - signal.pthread_sigmask(signal.SIG_SETMASK, previous_mask) - os.close(writer) - writer = -1 - # Leave time to kill/reap a child whose token acquisition or cleanup stalls. - worker_deadline = deadline - min(1.0, (deadline - work_deadline) / 2) - remaining = worker_deadline - monotonic() - if remaining > 0 and select.select([reader], [], [], remaining)[0]: - outcome = os.read(reader, 1) - except OSError: - raise TeamsDeliveryError("transient") from None - finally: - _close_delivery_process(reader, writer, pid, deadline) - if outcome != b"0": - categories: dict[bytes, Literal["authorization", "parameter", "transient"]] = { - b"a": "authorization", - b"p": "parameter", - } - raise TeamsDeliveryError(categories.get(outcome, "transient")) - - def deliver_teams_alert( config: TeamsConfig, destination: TeamsDestination, service_url: str, card: dict[str, Any], *, + retry_deadline: float | None = None, transport_factory: TransportFactory | None = None, ) -> None: - """Bound the complete synchronous call, including token acquisition and cleanup.""" + """Share one retry budget across authentication, posting and resource cleanup. + + Synchronous MSAL requests and async SDK posts have explicit socket timeouts. + The existing Celery soft/hard task limits bound blocking work that cannot be + interrupted by a cooperative deadline, including DNS and resource teardown. + """ deadline = monotonic() + _DELIVERY_TIMEOUT - cleanup_reserve = min(_DELIVERY_CLEANUP_RESERVE, _DELIVERY_TIMEOUT / 10) - work_deadline = deadline - cleanup_reserve + if retry_deadline is not None: + deadline = min(deadline, retry_deadline) + if monotonic() >= deadline: + raise TeamsDeliveryError("transient") - async def deliver(begin_cleanup: Callable[[], None]) -> None: - """Construct, retry, and close the transport before closing the event loop.""" + async def deliver() -> None: + """Construct, retry and close the transport in the same event loop.""" factory = transport_factory or _create_transport - async with factory(config, service_url, work_deadline) as transport: - try: - await _deliver_with_retries(transport, destination, card, work_deadline) - finally: - begin_cleanup() + async with factory(config, service_url, deadline) as transport: + await _deliver_with_retries(transport, destination, card, deadline) - def operation(begin_cleanup: Callable[[], None]) -> None: - """Finish SDK teardown and loop shutdown before acknowledging delivery.""" - asyncio.run(deliver(begin_cleanup)) - - _run_delivery_process(operation, work_deadline, deadline) + asyncio.run(deliver()) + # Cleanup consumes the same budget; it must finish before reporting success. + if monotonic() >= deadline: + raise TeamsDeliveryError("transient") diff --git a/superset/reports/notifications/base.py b/superset/reports/notifications/base.py index 4986fca7a4bb..98f44598a6c1 100644 --- a/superset/reports/notifications/base.py +++ b/superset/reports/notifications/base.py @@ -41,6 +41,8 @@ class NotificationContent: retry_max_attempts: Optional[int] = None include_cta: bool = True # include the call-to-action link back to Superset is_editor_error: bool = False # diagnostic email restricted to schedule editors + # Absolute monotonic deadline, excluding the execution cleanup reserve. + delivery_deadline: Optional[float] = None @property def has_attachments(self) -> bool: diff --git a/superset/reports/notifications/teams.py b/superset/reports/notifications/teams.py index a9f73433e233..693a2630e6bb 100644 --- a/superset/reports/notifications/teams.py +++ b/superset/reports/notifications/teams.py @@ -91,6 +91,8 @@ def _send_to_teams( destination: TeamsDestination, service_url: str, card: dict[str, Any], + *, + retry_deadline: float | None = None, ) -> None: """Load the optional SDK only for delivery and map sanitized transport errors.""" try: @@ -101,7 +103,9 @@ def _send_to_teams( except ImportError: raise NotificationParamException("Teams SDK is not installed") from None try: - deliver_teams_alert(config, destination, service_url, card) + deliver_teams_alert( + config, destination, service_url, card, retry_deadline=retry_deadline + ) except TeamsDeliveryError as ex: exception = { "authorization": NotificationAuthorizationException, @@ -147,5 +151,9 @@ def send(self) -> None: "Unable to resolve Teams bot installation" ) from None _send_to_teams( - config, destination, service_url, build_teams_card(self._content) + config, + destination, + service_url, + build_teams_card(self._content), + retry_deadline=self._content.delivery_deadline, ) diff --git a/tests/unit_tests/integrations/teams/delivery_test.py b/tests/unit_tests/integrations/teams/delivery_test.py index be9b7920a98f..f71dccfb5b08 100644 --- a/tests/unit_tests/integrations/teams/delivery_test.py +++ b/tests/unit_tests/integrations/teams/delivery_test.py @@ -19,15 +19,12 @@ from __future__ import annotations import asyncio -import os -import signal import threading import time from collections.abc import AsyncIterator, Iterator from contextlib import asynccontextmanager from datetime import datetime, timedelta, timezone from email.utils import format_datetime -from pathlib import Path from types import SimpleNamespace from typing import Any from unittest.mock import MagicMock @@ -35,7 +32,6 @@ import httpx import pytest import requests -from billiard import current_process from billiard.exceptions import SoftTimeLimitExceeded from flask import current_app, Flask from sqlalchemy import create_engine @@ -67,7 +63,6 @@ TENANT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" APPLICATION = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" SERVICE_URL = "https://smba.trafficmanager.net/amer/" -REAL_DELIVERY_PROCESS = adapter._run_delivery_process @pytest.fixture @@ -383,13 +378,6 @@ def retry_clock(monkeypatch: pytest.MonkeyPatch) -> list[float]: elapsed = [0.0] sleeps: list[float] = [] monkeypatch.setattr(adapter, "monotonic", lambda: elapsed[0]) - # Exercise retry policy/SDK serialization in-process; dedicated deadline - # tests below use real child processes and wall time. - monkeypatch.setattr( - adapter, - "_run_delivery_process", - lambda operation, *args: operation(lambda: None), - ) async def sleep(delay: float) -> None: """Advance the same clock used by the retry budget.""" @@ -781,144 +769,138 @@ def test_auth_http_closes_response_and_preserves_retry_delay( @pytest.mark.parametrize( - "phase", - ["construction", "post", "refresh", "cleanup", "post-and-cleanup", "loop-shutdown"], + "mode", ["removed-destination", "disabled", "report", "attachments"] ) -def test_delivery_process_bounds_the_entire_synchronous_call( - configured: TeamsConfig, +def test_saved_notification_rechecks_changes_before_touching_installations( + notification_context: SimpleNamespace, + content: NotificationContent, monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, - phase: str, + mode: str, ) -> None: - monkeypatch.setattr(adapter, "_DELIVERY_TIMEOUT", 0.3) - transport = FakeTransport( - [adapter.TeamsTransportError(401)] if phase == "refresh" else [None] - ) - pid_file = tmp_path / "child" - cleanup_file = tmp_path / "cleanup" - - async def stall() -> None: - time.sleep(4) - - if phase in {"post", "post-and-cleanup"}: - monkeypatch.setattr(transport, "post_root", lambda *args: stall()) - if phase == "refresh": - monkeypatch.setattr(transport, "refresh_auth", stall) - - async def background() -> None: - try: - await asyncio.Future() - finally: - time.sleep(4) - - @asynccontextmanager - async def factory(*args: Any) -> AsyncIterator[FakeTransport]: - pid_file.write_text(str(os.getpid())) - try: - if phase == "construction": - time.sleep(4) - if phase == "loop-shutdown": - asyncio.create_task(background()) - yield transport - finally: - cleanup_file.write_text("started") - if phase in {"cleanup", "post-and-cleanup"}: - time.sleep(4) - cleanup_file.write_text("closed") - - started = time.monotonic() - with pytest.raises( - adapter.TeamsDeliveryError, match="temporarily unavailable" - ) as exc: - adapter.deliver_teams_alert( - configured, - configured.destinations[0], - SERVICE_URL, - {}, - transport_factory=factory, - ) - assert time.monotonic() - started < 0.5 - assert exc.value.category == "transient" - assert cleanup_file.exists() - if phase not in {"cleanup", "post-and-cleanup"}: - assert cleanup_file.read_text() == "closed" - child = int(pid_file.read_text()) - assert child != os.getpid() - with pytest.raises(ChildProcessError): - os.waitpid(child, os.WNOHANG) - assert adapter._DELIVERY_WORKERS == set() + entry = recipient() + saved_json = entry.recipient_config_json + notification = notifications.TeamsNotification(entry, content) + if mode == "removed-destination": + notification_context.app.config["ALERT_REPORTS_TEAMS"]["destinations"][0][ + "key" + ] = "changed" + elif mode == "disabled": + notification_context.app.config["FEATURE_FLAGS"]["ALERT_REPORT_TEAMS"] = False + elif mode == "report": + content.header_data["notification_type"] = "Report" + else: + content.header_data["notification_format"] = "PNG" + lookup = MagicMock() + monkeypatch.setattr(TeamsInstallationStore, "get_active", lookup) + with pytest.raises(NotificationParamException): + notification.send() + lookup.assert_not_called() + notification_context.send.assert_not_called() + assert entry.recipient_config_json == saved_json @pytest.mark.parametrize("phase", ["metadata", "token"]) -def test_stalled_msal_http_is_interrupted_and_sdk_resources_close( +def test_real_msal_requests_timeout_and_close_resources( configured: TeamsConfig, sdk_network: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, phase: str, ) -> None: - monkeypatch.setattr(adapter, "monotonic", time.monotonic) - monkeypatch.setattr(adapter, "_run_delivery_process", REAL_DELIVERY_PROCESS) - monkeypatch.setattr(adapter, "_DELIVERY_TIMEOUT", 0.4) + """A stalled OAuth endpoint is bounded by requests, without an async timer.""" + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + release = threading.Event() + + class StalledEndpoint(BaseHTTPRequestHandler): + """Accept a connection but withhold the response until teardown.""" + + def do_GET(self) -> None: # noqa: N802 + """Stall either metadata discovery or token acquisition.""" + release.wait(2) + + do_POST = do_GET # noqa: N815 + + def log_message(self, format: str, *args: Any) -> None: + """Suppress test server access logs.""" + + server = ThreadingHTTPServer(("127.0.0.1", 0), StalledEndpoint) + worker = threading.Thread(target=server.serve_forever, daemon=True) + worker.start() session_type = adapter.requests.Session request = session_type.request - close_session = session_type.close - close_http = adapter.httpx.AsyncClient.aclose - auth_closed = tmp_path / "auth-closed" - http_closed = tmp_path / "http-closed" + real_request = requests.sessions.Session.request + timeouts: list[float] = [] def stalled_request( - self: requests.Session, method: str, url: str, *args: Any, **kwargs: Any + self: requests.Session, method: str, url: str, **kwargs: Any ) -> requests.Response: + """Use real socket I/O for the chosen OAuth phase only.""" if (phase == "metadata" and method == "GET") or ( phase == "token" and method == "POST" ): - time.sleep(4) - return request(self, method, url, *args, **kwargs) - - def auth_close(self: requests.Session) -> None: - close_session(self) - auth_closed.write_text("closed") - - async def http_close(self: httpx.AsyncClient) -> None: - await close_http(self) - http_closed.write_text(str(len(sdk_network.posts))) + timeouts.append(kwargs["timeout"]) + self.trust_env = False + return real_request( + self, method, f"http://127.0.0.1:{server.server_port}/", **kwargs + ) + return request(self, method, url, **kwargs) monkeypatch.setattr(session_type, "request", stalled_request) - monkeypatch.setattr(session_type, "close", auth_close) - monkeypatch.setattr(adapter.httpx.AsyncClient, "aclose", http_close) - started = time.monotonic() - with pytest.raises(NotificationTransientError, match="temporarily unavailable"): - notifications._send_to_teams( - configured, configured.destinations[0], SERVICE_URL, {} - ) - assert time.monotonic() - started < 0.6 - assert auth_closed.read_text() == "closed" - assert http_closed.read_text() == "0" - assert adapter._DELIVERY_WORKERS == set() + monkeypatch.setattr(adapter, "monotonic", time.monotonic) + monkeypatch.setattr(adapter, "_DELIVERY_TIMEOUT", 0.15) + try: + started = time.monotonic() + with pytest.raises(adapter.TeamsDeliveryError, match="temporarily unavailable"): + adapter.deliver_teams_alert( + configured, configured.destinations[0], SERVICE_URL, {} + ) + assert time.monotonic() - started < 1 + assert len(timeouts) == 1 + assert 0 < timeouts[0] <= 0.15 + assert sdk_network.posts == [] + assert all(session.closed for session in sdk_network.auth_sessions) + assert all(client.is_closed for client in sdk_network.clients) + assert all(response.closed for response in sdk_network.oauth_responses) + finally: + release.set() + server.shutdown() + server.server_close() + worker.join(timeout=1) -def test_one_budget_covers_token_post_and_cleanup( +@pytest.mark.parametrize("phase", ["construction", "post", "refresh", "cleanup"]) +def test_celery_soft_timeout_propagates_after_cleanup( configured: TeamsConfig, monkeypatch: pytest.MonkeyPatch, + phase: str, ) -> None: - monkeypatch.setattr(adapter, "_DELIVERY_TIMEOUT", 0.3) - transport = FakeTransport([None]) + """The existing task handler receives soft limits without transport retries.""" + transport = FakeTransport( + [adapter.TeamsTransportError(401)] if phase == "refresh" else [None] + ) + closed: list[bool] = [] + soft_timeout = SoftTimeLimitExceeded() - async def post(*args: Any) -> None: - time.sleep(0.14) - time.sleep(0.14) + async def interrupt(*args: Any) -> None: + raise soft_timeout + + if phase == "post": + monkeypatch.setattr(transport, "post_root", interrupt) + if phase == "refresh": + monkeypatch.setattr(transport, "refresh_auth", interrupt) @asynccontextmanager async def factory(*args: Any) -> AsyncIterator[FakeTransport]: try: + if phase == "construction": + raise soft_timeout yield transport finally: - time.sleep(0.14) + closed.append(True) + if phase == "cleanup": + raise soft_timeout - monkeypatch.setattr(transport, "post_root", post) - started = time.monotonic() - with pytest.raises(adapter.TeamsDeliveryError): + with pytest.raises(SoftTimeLimitExceeded) as error: adapter.deliver_teams_alert( configured, configured.destinations[0], @@ -926,288 +908,185 @@ async def factory(*args: Any) -> AsyncIterator[FakeTransport]: {}, transport_factory=factory, ) - assert time.monotonic() - started < 0.39 - assert adapter._DELIVERY_WORKERS == set() + assert error.value is soft_timeout + assert closed == [True] + assert len(transport.posts) <= 1 -@pytest.mark.parametrize( - "outcome", - ["success", "authorization", "parameter", "transient", "unexpected", "crash"], -) -def test_delivery_child_outcomes_are_sanitized(outcome: str) -> None: - def operation(begin_cleanup: Any) -> None: - if outcome == "crash": - os._exit(7) - if outcome == "unexpected": - raise ValueError("credential and SQL diagnostic") - if outcome != "success": - raise adapter.TeamsDeliveryError(outcome) - - started = time.monotonic() - if outcome == "success": - adapter._run_delivery_process(operation, started + 0.5, started + 0.6) - else: - with pytest.raises(adapter.TeamsDeliveryError) as exc: - adapter._run_delivery_process(operation, started + 0.5, started + 0.6) - assert exc.value.category == ( - outcome if outcome in {"authorization", "parameter"} else "transient" - ) - assert "credential" not in str(exc.value) - assert "SQL" not in str(exc.value) - assert exc.value.__cause__ is None - assert adapter._DELIVERY_WORKERS == set() +@pytest.mark.parametrize("phase", ["construction", "post", "cleanup"]) +def test_elapsed_work_and_cleanup_do_not_report_success_after_deadline( + configured: TeamsConfig, + monkeypatch: pytest.MonkeyPatch, + phase: str, +) -> None: + """Every phase shares the caller's deadline, including successful teardown.""" + clock = [0.0] + monkeypatch.setattr(adapter, "monotonic", lambda: clock[0]) + transport = FakeTransport([None]) + post = transport.post_root + async def post_root(*args: Any) -> None: + await post(*args) + if phase == "post": + clock[0] = 3.0 -def test_parent_worker_alarm_and_handler_are_preserved(configured: TeamsConfig) -> None: - previous_handler = signal.getsignal(signal.SIGALRM) - previous_usr1 = signal.getsignal(signal.SIGUSR1) - previous_mask = signal.pthread_sigmask(signal.SIG_BLOCK, set()) - received: list[int] = [] + monkeypatch.setattr(transport, "post_root", post_root) - def worker_handler(signum: int, frame: Any) -> None: - received.append(signum) + @asynccontextmanager + async def factory(*args: Any) -> AsyncIterator[FakeTransport]: + assert args[2] == 2.0 + transport.loop = asyncio.get_running_loop() + try: + if phase == "construction": + clock[0] = 3.0 + yield transport + finally: + transport.closed = True + if phase == "cleanup": + clock[0] = 3.0 - signal.signal(signal.SIGALRM, worker_handler) - signal.setitimer(signal.ITIMER_REAL, 4) - try: - transport = FakeTransport([None]) + with pytest.raises(adapter.TeamsDeliveryError, match="temporarily unavailable"): adapter.deliver_teams_alert( configured, configured.destinations[0], SERVICE_URL, {}, - transport_factory=transport.factory, + retry_deadline=2.0, + transport_factory=factory, ) - assert signal.getsignal(signal.SIGALRM) is worker_handler - assert signal.getsignal(signal.SIGUSR1) == previous_usr1 - assert signal.pthread_sigmask(signal.SIG_BLOCK, set()) == previous_mask - remaining, interval = signal.getitimer(signal.ITIMER_REAL) - assert 0 < remaining <= 4 - assert interval == 0 - assert received == [] - finally: - signal.setitimer(signal.ITIMER_REAL, 0) - signal.signal(signal.SIGALRM, previous_handler) - - -def test_thread_context_fails_before_opening_transport(configured: TeamsConfig) -> None: - transport = FakeTransport([None]) - failures: list[str] = [] - - def deliver() -> None: - try: - adapter.deliver_teams_alert( - configured, - configured.destinations[0], - SERVICE_URL, - {}, - transport_factory=transport.factory, - ) - except adapter.TeamsDeliveryError as exc: - failures.append(exc.category) - - thread = threading.Thread(target=deliver) - thread.start() - thread.join(timeout=1) - assert not thread.is_alive() - assert failures == ["transient"] - assert transport.loop is None + assert transport.closed + assert len(transport.posts) == (0 if phase == "construction" else 1) + assert transport.loop is not None + assert transport.loop.is_closed() -def test_parent_interruption_kills_and_reaps_delivery_child( - monkeypatch: pytest.MonkeyPatch, +def test_expired_execution_budget_does_not_open_transport( + configured: TeamsConfig, ) -> None: - interrupted = RuntimeError("worker soft limit") - monkeypatch.setattr( - adapter, "select", SimpleNamespace(select=MagicMock(side_effect=interrupted)) - ) - - def operation(begin_cleanup: Any) -> None: - time.sleep(4) - - started = time.monotonic() - with pytest.raises(RuntimeError) as exc: - adapter._run_delivery_process(operation, started + 0.5, started + 0.6) - assert exc.value is interrupted - assert adapter._DELIVERY_WORKERS == set() + """An exhausted report deadline cannot start a new 60-second Teams budget.""" + factory = MagicMock() + with pytest.raises(adapter.TeamsDeliveryError): + adapter.deliver_teams_alert( + configured, + configured.destinations[0], + SERVICE_URL, + {}, + retry_deadline=time.monotonic() - 1, + transport_factory=factory, + ) + factory.assert_not_called() -def test_delivery_fork_failure_is_transient_and_closes_the_pipe( - monkeypatch: pytest.MonkeyPatch, +def test_retry_after_uses_remaining_execution_budget( + configured: TeamsConfig, retry_clock: list[float] ) -> None: - pipe = os.pipe - descriptors: list[int] = [] - - def tracked_pipe() -> tuple[int, int]: - pair = pipe() - descriptors.extend(pair) - return pair - - monkeypatch.setattr(adapter.os, "pipe", tracked_pipe) - monkeypatch.setattr( - adapter.os, "fork", MagicMock(side_effect=OSError("internal diagnostic")) + """Neither a refresh nor Retry-After creates another delivery allowance.""" + transport = FakeTransport( + [ + adapter.TeamsTransportError(401), + adapter.TeamsTransportError(429, httpx.Headers({"Retry-After": "2"})), + adapter.TeamsTransportError(503, httpx.Headers({"Retry-After": "2"})), + ] ) - started = time.monotonic() - with pytest.raises(adapter.TeamsDeliveryError, match="temporarily unavailable"): - adapter._run_delivery_process( - lambda callback: None, started + 0.5, started + 0.6 + with pytest.raises(adapter.TeamsDeliveryError): + adapter.deliver_teams_alert( + configured, + configured.destinations[0], + SERVICE_URL, + {}, + retry_deadline=3.0, + transport_factory=transport.factory, ) - for descriptor in descriptors: - with pytest.raises(OSError, match="Bad file descriptor"): - os.fstat(descriptor) - assert adapter._DELIVERY_WORKERS == set() + assert transport.refreshes == 1 + assert len(transport.posts) == 3 + assert retry_clock == [2.0] + assert transport.closed -def test_successful_delivery_waits_for_cleanup( - configured: TeamsConfig, +def test_expired_oauth_response_closes_before_returning_token( monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, ) -> None: - monkeypatch.setattr(adapter, "_DELIVERY_TIMEOUT", 1.0) - transport = FakeTransport([None]) - closed = tmp_path / "closed" + """A synchronous response arriving after budget cannot proceed to a post.""" + clock = [0.0] + monkeypatch.setattr(adapter, "monotonic", lambda: clock[0]) + response = MagicMock(spec=requests.Response) + session = MagicMock(spec=requests.Session) - async def post(*args: Any) -> None: - time.sleep(0.55) + def receive(*args: Any, **kwargs: Any) -> requests.Response: + clock[0] = 3.0 + return response - @asynccontextmanager - async def factory(*args: Any) -> AsyncIterator[FakeTransport]: - try: - yield transport - finally: - time.sleep(0.36) - closed.write_text("closed") - - monkeypatch.setattr(transport, "post_root", post) - adapter.deliver_teams_alert( - configured, - configured.destinations[0], - SERVICE_URL, - {}, - transport_factory=factory, - ) - assert closed.read_text() == "closed" - assert adapter._DELIVERY_WORKERS == set() + session.request.side_effect = receive + with pytest.raises(adapter.TeamsTransportError): + adapter._AuthHttpClient(session, 2.0).post( + "https://login.microsoftonline.com/token" + ) + response.close.assert_called_once() + assert session.request.call_args.kwargs["timeout"] == 2.0 -@pytest.mark.parametrize( - "mode", ["removed-destination", "disabled", "report", "attachments"] -) -def test_saved_notification_rechecks_changes_before_touching_installations( - notification_context: SimpleNamespace, - content: NotificationContent, +@pytest.mark.parametrize("phase", ["metadata", "token"]) +def test_celery_soft_timeout_during_real_msal_acquisition_is_not_retried( + configured: TeamsConfig, + sdk_network: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, - mode: str, + phase: str, ) -> None: - entry = recipient() - saved_json = entry.recipient_config_json - notification = notifications.TeamsNotification(entry, content) - if mode == "removed-destination": - notification_context.app.config["ALERT_REPORTS_TEAMS"]["destinations"][0][ - "key" - ] = "changed" - elif mode == "disabled": - notification_context.app.config["FEATURE_FLAGS"]["ALERT_REPORT_TEAMS"] = False - elif mode == "report": - content.header_data["notification_type"] = "Report" - else: - content.header_data["notification_format"] = "PNG" - lookup = MagicMock() - monkeypatch.setattr(TeamsInstallationStore, "get_active", lookup) - with pytest.raises(NotificationParamException): - notification.send() - lookup.assert_not_called() - notification_context.send.assert_not_called() - assert entry.recipient_config_json == saved_json + """A task timeout during synchronous OAuth reaches the scheduler unchanged.""" + session_type = adapter.requests.Session + request = session_type.request + error = SoftTimeLimitExceeded() + def interrupted_request( + self: requests.Session, method: str, url: str, **kwargs: Any + ) -> requests.Response: + if (phase == "metadata" and method == "GET") or ( + phase == "token" and method == "POST" + ): + raise error + return request(self, method, url, **kwargs) -def test_daemonic_celery_worker_can_deliver(configured: TeamsConfig) -> None: - process = current_process() - previous = process.daemon - process.daemon = True - try: - transport = FakeTransport([None]) + monkeypatch.setattr(session_type, "request", interrupted_request) + with pytest.raises(SoftTimeLimitExceeded) as caught: adapter.deliver_teams_alert( - configured, - configured.destinations[0], - SERVICE_URL, - {}, - transport_factory=transport.factory, + configured, configured.destinations[0], SERVICE_URL, {} ) - assert adapter._DELIVERY_WORKERS == set() - finally: - process.daemon = previous - - -def test_celery_soft_limit_is_preserved_and_delivery_child_is_reaped() -> None: - previous_alarm = signal.getsignal(signal.SIGALRM) - previous_usr1 = signal.getsignal(signal.SIGUSR1) + assert caught.value is error + assert sdk_network.posts == [] + assert all(session.closed for session in sdk_network.auth_sessions) + assert all(client.is_closed for client in sdk_network.clients) - def soft_limit(signum: int, frame: Any) -> None: - raise SoftTimeLimitExceeded() - def fire_soft_limit(signum: int, frame: Any) -> None: - signal.raise_signal(signal.SIGUSR1) - - def operation(begin_cleanup: Any) -> None: - time.sleep(4) - - signal.signal(signal.SIGUSR1, soft_limit) - signal.signal(signal.SIGALRM, fire_soft_limit) - signal.setitimer(signal.ITIMER_REAL, 0.05) - started = time.monotonic() - try: - with pytest.raises(SoftTimeLimitExceeded): - adapter._run_delivery_process(operation, started + 0.5, started + 0.6) - assert time.monotonic() - started < 0.5 - assert adapter._DELIVERY_WORKERS == set() - assert signal.getsignal(signal.SIGUSR1) is soft_limit - assert signal.getsignal(signal.SIGALRM) is fire_soft_limit - finally: - signal.setitimer(signal.ITIMER_REAL, 0) - signal.signal(signal.SIGALRM, previous_alarm) - signal.signal(signal.SIGUSR1, previous_usr1) - - -def test_child_watchdog_bounds_cleanup_without_parent_termination( +def test_real_sdk_caps_metadata_token_and_post_to_one_deadline( configured: TeamsConfig, + sdk_network: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(adapter, "_DELIVERY_TIMEOUT", 0.3) - original_close = adapter._close_delivery_process - exits: list[int] = [] - - def reap_without_killing( - reader: int, writer: int, pid: int | None, deadline: float - ) -> None: - assert pid is not None - try: - while time.monotonic() < deadline + 0.2: - exited, status = os.waitpid(pid, os.WNOHANG) - if exited: - exits.append(os.waitstatus_to_exitcode(status)) - adapter._DELIVERY_WORKERS.discard(pid) - return - time.sleep(0.001) - pytest.fail("The delivery child survived its independent deadline") - finally: - original_close(reader, writer, pid, deadline) + """Synchronous OAuth work reduces the remaining channel request timeout.""" + clock = [0.0] + monkeypatch.setattr(adapter, "monotonic", lambda: clock[0]) + session_type = adapter.requests.Session + request = session_type.request + timeouts: list[float] = [] - @asynccontextmanager - async def factory(*args: Any) -> AsyncIterator[FakeTransport]: - try: - yield FakeTransport([None]) - finally: - time.sleep(4) + def auth_request( + self: requests.Session, method: str, url: str, **kwargs: Any + ) -> requests.Response: + timeouts.append(kwargs["timeout"]) + response = request(self, method, url, **kwargs) + clock[0] += 1.0 + return response - monkeypatch.setattr(adapter, "_close_delivery_process", reap_without_killing) - with pytest.raises(adapter.TeamsDeliveryError): - adapter.deliver_teams_alert( - configured, - configured.destinations[0], - SERVICE_URL, - {}, - transport_factory=factory, - ) - assert exits == [1] - assert adapter._DELIVERY_WORKERS == set() + monkeypatch.setattr(session_type, "request", auth_request) + adapter.deliver_teams_alert( + configured, configured.destinations[0], SERVICE_URL, {}, retry_deadline=3.0 + ) + assert timeouts == [3.0, 2.0] + request_sent, _ = sdk_network.posts[0] + assert request_sent.extensions["timeout"] == { + "connect": 1.0, + "read": 1.0, + "write": 1.0, + "pool": 1.0, + } + assert_resources_closed(sdk_network) diff --git a/tests/unit_tests/integrations/teams/validation_test.py b/tests/unit_tests/integrations/teams/validation_test.py index 119b76b9f07f..85c8cf856797 100644 --- a/tests/unit_tests/integrations/teams/validation_test.py +++ b/tests/unit_tests/integrations/teams/validation_test.py @@ -455,3 +455,50 @@ def test_teams_none_alert_reuses_execution_and_logs_without_screenshot( capture.assert_not_called() policy.assert_not_called() alert.return_value.run.assert_called_once() + + +@pytest.mark.parametrize("total_seconds", [100.0, float("inf")]) +def test_teams_delivery_reuses_execution_deadline_and_cleanup_reserve( + configured_app: Flask, + monkeypatch: pytest.MonkeyPatch, + total_seconds: float, +) -> None: + """Each recipient gets the same absolute deadline instead of a fresh allowance.""" + from superset.reports.notifications.base import NotificationContent + from superset.utils.report_execution import ( + ReportExecutionContext, + ReportExecutionDeadline, + ) + + state = execute.BaseReportState.__new__(execute.BaseReportState) + state._report_execution_context = ReportExecutionContext( + execution_id=uuid4(), + report_schedule_id=1, + deadline=ReportExecutionDeadline(total_seconds=total_seconds, started_at=10.0), + cleanup_reserve_seconds=5.0, + ) + state._report_schedule = SimpleNamespace(name="Teams alert") + monkeypatch.setattr(state, "_assert_rendered_delivery_allowed", MagicMock()) + monkeypatch.setattr(execute, "app", configured_app) + configured_app.config["ALERT_REPORTS_NOTIFICATION_DRY_RUN"] = False + notification = MagicMock() + factory = MagicMock(return_value=notification) + monkeypatch.setattr(execute, "create_notification", factory) + content = NotificationContent( + name="Teams alert", + header_data={ + "notification_type": "Alert", + "notification_format": "NONE", + "editors": [], + "notification_source": None, + "chart_id": None, + "dashboard_id": None, + "slack_channels": None, + "execution_id": None, + }, + ) + recipient = teams_recipient(saved=True) + for _ in range(2): + state._send_notification(content, recipient) + assert content.delivery_deadline == 10.0 + total_seconds - 5.0 + assert notification.send.call_count == 2 diff --git a/tests/unit_tests/reports/notifications/teams_tests.py b/tests/unit_tests/reports/notifications/teams_tests.py index cc507825390d..a4022e9b7731 100644 --- a/tests/unit_tests/reports/notifications/teams_tests.py +++ b/tests/unit_tests/reports/notifications/teams_tests.py @@ -100,14 +100,16 @@ def test_factory_delivery_uses_server_config_and_closes_session( ) -> None: """A destination key resolves on the server and database work ends before send.""" - def check_closed(*_: object) -> None: + def check_closed(*_: object, **kwargs: object) -> None: notification.session.return_value.__exit__.assert_called_once() + notification.content.delivery_deadline = 123.0 notification.send.side_effect = check_closed handler = create_notification(notification.recipient, notification.content) assert isinstance(handler, teams.TeamsNotification) handler.send() config, destination, service_url, card = notification.send.call_args.args + assert notification.send.call_args.kwargs == {"retry_deadline": 123.0} assert config.credential == "server-only-credential" assert destination.key == "ops" assert service_url == "https://smba.trafficmanager.net/amer/" From 3a90b63e6aba35e2ed231cc2949fb9faefba1711 Mon Sep 17 00:00:00 2001 From: Kristi Dodaj Date: Mon, 19 Oct 2026 21:07:29 -0400 Subject: [PATCH 10/30] fix(reports): validate Teams destination IDs during configuration --- superset/integrations/teams/config.py | 13 ++++++-- superset/integrations/teams/store.py | 7 ++-- .../reports/teams_api_tests.py | 32 +++++++++++++++++++ .../integrations/teams/config_test.py | 25 +++++++++++++++ .../integrations/teams/destinations_test.py | 15 +++++++++ .../integrations/teams/store_test.py | 18 +++++++++++ 6 files changed, 103 insertions(+), 7 deletions(-) diff --git a/superset/integrations/teams/config.py b/superset/integrations/teams/config.py index 31ba60ff9683..237ffb7b22b7 100644 --- a/superset/integrations/teams/config.py +++ b/superset/integrations/teams/config.py @@ -87,13 +87,22 @@ def _uuid(value: object, path: str) -> str: return str(parsed) +def validate_team_id(value: object) -> str: + """Match installation identity limits without rewriting operator-supplied IDs.""" + if not isinstance(value, str) or not value.strip() or len(value) > 255: + raise TeamsConfigError("Teams team ID must contain 1 to 255 characters") + if value != value.strip(): + raise TeamsConfigError("Teams team ID must not have surrounding whitespace") + return value + + def parse_teams_config(value: object) -> TeamsConfig | None: """Validate ALERT_REPORTS_TEAMS; an empty mapping leaves Teams unconfigured. This parser performs no network calls and imports no optional Teams dependency. Operators must approve standard channels in the configured tenant. Channel IDs alone cannot establish membership or distinguish standard/private/shared types. - Feature gating and Teams metadata checks belong to the future delivery layer. + Feature gating and authenticated Teams metadata checks run outside this parser. """ if isinstance(value, Mapping) and not value: return None @@ -121,7 +130,7 @@ def parse_teams_config(value: object) -> TeamsConfig | None: if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}", key): raise TeamsConfigError("Teams destination key must be a stable identifier") label = _text(destination["label"], "Teams destination label") - team_id = _text(destination["team_id"], "Teams destination team_id") + team_id = validate_team_id(destination["team_id"]) channel_id = _text(destination["channel_id"], "Teams destination channel_id") if key in keys or (team_id, channel_id) in channels: raise TeamsConfigError("Teams destination keys and channels must be unique") diff --git a/superset/integrations/teams/store.py b/superset/integrations/teams/store.py index 6d28de417dc6..f1fedc7b11bd 100644 --- a/superset/integrations/teams/store.py +++ b/superset/integrations/teams/store.py @@ -23,6 +23,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session +from superset.integrations.teams.config import validate_team_id from superset.integrations.teams.models import TeamsInstallation @@ -37,11 +38,7 @@ def _identity( raise ValueError("Teams tenant and application IDs must be UUIDs") from None if not tenant.int or not application.int: raise ValueError("Teams tenant and application IDs must be nonzero UUIDs") - if not isinstance(team_id, str) or not team_id.strip() or len(team_id) > 255: - raise ValueError("Teams team ID must contain 1 to 255 characters") - if team_id != team_id.strip(): - raise ValueError("Teams team ID must not have surrounding whitespace") - return tenant, application, team_id + return tenant, application, validate_team_id(team_id) def _service_url(value: str) -> str: diff --git a/tests/integration_tests/reports/teams_api_tests.py b/tests/integration_tests/reports/teams_api_tests.py index b584bdb157ff..9e7e02e10804 100644 --- a/tests/integration_tests/reports/teams_api_tests.py +++ b/tests/integration_tests/reports/teams_api_tests.py @@ -22,6 +22,7 @@ from superset import security_manager from tests.integration_tests.base_tests import SupersetTestCase from tests.integration_tests.conftest import with_feature_flags +from tests.integration_tests.test_app import app class TestTeamsDestinationsApi(SupersetTestCase): @@ -100,3 +101,34 @@ def test_reports_disabled_hides_destination_discovery(self) -> None: response = self.client.get("/api/v1/report/teams_destinations/") assert response.status_code == 404 discover.assert_not_called() + + @with_feature_flags(ALERT_REPORTS=True, ALERT_REPORT_TEAMS=True) + def test_invalid_team_configuration_returns_empty_destinations(self) -> None: + """The actual Reports endpoint handles parser failures without querying DB.""" + self.login("admin") + for team_id in ("t" * 256, " team-1", "team-1 ", "\tteam-1\n"): + with self.subTest(team_id=team_id): + configuration = { + "tenant_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "application_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + "credential": "test-secret", + "destinations": [ + { + "key": "ops", + "label": "Operations", + "team_id": team_id, + "channel_id": "channel-1", + } + ], + } + with ( + patch.dict(app.config, {"ALERT_REPORTS_TEAMS": configuration}), + patch( + "superset.integrations.teams.destinations.Session", + side_effect=AssertionError("must not query installations"), + ) as session, + ): + response = self.client.get("/api/v1/report/teams_destinations/") + assert response.status_code == 200 + assert response.json == {"result": []} + session.assert_not_called() diff --git a/tests/unit_tests/integrations/teams/config_test.py b/tests/unit_tests/integrations/teams/config_test.py index 68191db541ec..f235a90fac27 100644 --- a/tests/unit_tests/integrations/teams/config_test.py +++ b/tests/unit_tests/integrations/teams/config_test.py @@ -145,3 +145,28 @@ def test_unknown_fields_do_not_leak_values( parse_teams_config(deployment) assert deployment["credential"] not in str(exc.value) assert "secret-unknown-key" not in str(exc.value) + + +@pytest.mark.parametrize("team_id", ["t" * 256, " team-1", "team-1 ", "\tteam-1\n"]) +def test_rejects_team_ids_incompatible_with_installation_identity( + deployment: dict[str, Any], + team_id: str, +) -> None: + """Bad deployment identifiers fail at parsing, without leaking their values.""" + deployment["destinations"][0]["team_id"] = team_id + with pytest.raises(TeamsConfigError, match="Teams team ID") as error: + parse_teams_config(deployment) + assert team_id not in str(error.value) + assert deployment["destinations"][0]["team_id"] == team_id + + +@pytest.mark.parametrize("team_id", ["t", "t" * 255]) +def test_accepts_team_id_length_boundaries( + deployment: dict[str, Any], + team_id: str, +) -> None: + """Identifiers at both supported limits remain intact.""" + deployment["destinations"][0]["team_id"] = team_id + config = parse_teams_config(deployment) + assert config is not None + assert config.destinations[0].team_id == team_id diff --git a/tests/unit_tests/integrations/teams/destinations_test.py b/tests/unit_tests/integrations/teams/destinations_test.py index 95d2cc73f015..f24c992b96f9 100644 --- a/tests/unit_tests/integrations/teams/destinations_test.py +++ b/tests/unit_tests/integrations/teams/destinations_test.py @@ -145,3 +145,18 @@ def test_disabled_integration_does_not_query_installations( destinations, "Session", MagicMock(side_effect=AssertionError("must not query")) ) assert destinations.list_teams_destinations() == [] + + +@pytest.mark.parametrize("team_id", ["t" * 256, " team-1", "team-1 ", "\tteam-1\n"]) +def test_invalid_team_config_is_hidden_before_installation_lookup( + configured_app: Flask, + monkeypatch: pytest.MonkeyPatch, + team_id: str, +) -> None: + """Discovery handles invalid deployment IDs instead of raising store ValueError.""" + configured_app.config["ALERT_REPORTS_TEAMS"]["destinations"][0]["team_id"] = team_id + lookup = MagicMock(side_effect=AssertionError("must not query installations")) + monkeypatch.setattr(destinations, "Session", lookup) + assert destinations.configured_teams() is None + assert destinations.list_teams_destinations() == [] + lookup.assert_not_called() diff --git a/tests/unit_tests/integrations/teams/store_test.py b/tests/unit_tests/integrations/teams/store_test.py index a5b41f9c10de..07b0cbd5da59 100644 --- a/tests/unit_tests/integrations/teams/store_test.py +++ b/tests/unit_tests/integrations/teams/store_test.py @@ -281,3 +281,21 @@ def test_migration_downgrade_and_reupgrade(engine: Engine, session: Session) -> migration.upgrade() assert "teams_installations" in inspect(connection).get_table_names() assert connection.execute(select(TeamsInstallation.id)).all() == [] + + +@pytest.mark.parametrize("team_id", ["t" * 256, " team-1", "team-1 ", "\tteam-1\n"]) +@pytest.mark.parametrize("operation", ["install", "remove", "get_active"]) +def test_store_rejects_invalid_team_ids_consistently( + session: Session, + team_id: str, + operation: str, +) -> None: + """Authenticated event persistence shares the deployment identifier validator.""" + store = TeamsInstallationStore(session) + args: tuple[str, ...] = (TENANT, APPLICATION, team_id) + if operation == "install": + args = (*args, SERVICE_URL) + method = getattr(store, operation) + with pytest.raises(ValueError, match="Teams team ID"): + method(*args) + assert session.query(TeamsInstallation).count() == 0 From 742b7ef2c24cd38663b06c178f66da5c5b953ac7 Mon Sep 17 00:00:00 2001 From: Kristi Dodaj Date: Mon, 19 Oct 2026 21:16:21 -0400 Subject: [PATCH 11/30] fix(reports): hide Teams options when optional SDK is missing --- .../configuration/alerts-reports.mdx | 9 ++- .../tests/reports/teams-alerts.spec.ts | 4 +- .../features/alerts/AlertReportModal.test.tsx | 52 +++++++++++++++++ .../alerts/components/NotificationMethod.tsx | 2 +- superset/integrations/teams/dependencies.py | 28 +++++++++ superset/integrations/teams/destinations.py | 6 +- .../integrations/teams/dependencies_test.py | 51 ++++++++++++++++ .../integrations/teams/destinations_test.py | 58 +++++++++++++++++++ .../reports/notifications/teams_tests.py | 17 +++++- 9 files changed, 217 insertions(+), 10 deletions(-) create mode 100644 superset/integrations/teams/dependencies.py create mode 100644 tests/unit_tests/integrations/teams/dependencies_test.py diff --git a/docs/admin_docs/configuration/alerts-reports.mdx b/docs/admin_docs/configuration/alerts-reports.mdx index 23eb8abedbb8..cf72a8adeb9b 100644 --- a/docs/admin_docs/configuration/alerts-reports.mdx +++ b/docs/admin_docs/configuration/alerts-reports.mdx @@ -985,9 +985,12 @@ Keep the entire mapping on the server. Browser destination lists must use store only the selected destination key, never credentials or routing details. Do not serialize the parsed configuration object, including via `dataclasses.asdict`. -The alert editor lists Teams when the integration is enabled and configured and -attachments are disabled. Choose an approved channel by its display label. To -notify multiple channels, add another notification method and choose Teams again; +The alert editor lists Teams when the integration is enabled and configured, the +optional SDK is installed, and attachments are disabled. Dependency discovery reads +package metadata without importing the SDK. If the SDK is absent, the destination +API returns an empty list and saved Teams recipients remain visible as unavailable +when editing. Delivery retains a lazy import guard for missing or unloadable SDKs. +Choose an approved channel by its display label. To notify multiple channels, add another notification method and choose Teams again; each entry stores one destination key. Scheduled reports do not offer Teams. The destination list uses the Reports API read permission and returns only keys, diff --git a/superset-frontend/playwright/tests/reports/teams-alerts.spec.ts b/superset-frontend/playwright/tests/reports/teams-alerts.spec.ts index 62d9fed1473c..f6fc171f82a3 100644 --- a/superset-frontend/playwright/tests/reports/teams-alerts.spec.ts +++ b/superset-frontend/playwright/tests/reports/teams-alerts.spec.ts @@ -111,9 +111,7 @@ test('creates and edits an inactive Teams alert and preserves unavailable destin modal.getByRole('combobox', { name: 'Timezone selector' }), ).toBeVisible(); await modal.getByTestId('notification-method-panel').click(); - await modal - .getByRole('combobox', { name: 'Delivery method', exact: true }) - .click(); + await modal.getByRole('combobox', { name: /^Delivery method/ }).click(); await page.getByRole('option', { name: 'Teams', exact: true }).click(); await modal.getByRole('combobox', { name: 'Teams destination' }).click(); await page diff --git a/superset-frontend/src/features/alerts/AlertReportModal.test.tsx b/superset-frontend/src/features/alerts/AlertReportModal.test.tsx index cc7584fb08c4..c501120abaa9 100644 --- a/superset-frontend/src/features/alerts/AlertReportModal.test.tsx +++ b/superset-frontend/src/features/alerts/AlertReportModal.test.tsx @@ -4871,3 +4871,55 @@ test('Teams unavailable saved keys can be replaced without losing inactive state fetchMock.removeRoute('teams-replacement-save'); } }); + +test('Teams saved recipients stay visible and replaceable when the optional SDK is unavailable', async () => { + mockNotificationMethods = ['Email']; + fetchMock.get('glob:*/api/v1/report/95', { + result: { + ...generateMockPayload(true), + id: 95, + report_format: 'NONE', + recipients: [ + { id: 1, type: 'Teams', recipient_config_json: '{"target":"ops"}' }, + ], + }, + }); + fetchMock.get( + 'glob:*/api/v1/report/teams_destinations/', + { result: [] }, + { name: 'teams-sdk-missing' }, + ); + try { + render( + , + { + useRedux: true, + initialState: { user: adminUser }, + }, + ); + await userEvent.click(screen.getByTestId('notification-method-panel')); + expect(await screen.findByText('ops (Unavailable)')).toBeInTheDocument(); + expect( + screen.getByText( + /Teams delivery is unavailable.*optional Teams dependency/, + ), + ).toBeInTheDocument(); + expect( + screen.getByRole('combobox', { name: 'Teams destination' }), + ).toBeDisabled(); + expect(screen.getByRole('button', { name: /save/i })).toBeDisabled(); + await userEvent.click( + screen.getByRole('combobox', { name: /^Delivery method/ }), + ); + await userEvent.click(await screen.findByText('Email')); + expect( + screen.queryByRole('combobox', { name: 'Teams destination' }), + ).not.toBeInTheDocument(); + expect(screen.queryByText('ops (Unavailable)')).not.toBeInTheDocument(); + } finally { + fetchMock.removeRoute('teams-sdk-missing'); + } +}); diff --git a/superset-frontend/src/features/alerts/components/NotificationMethod.tsx b/superset-frontend/src/features/alerts/components/NotificationMethod.tsx index 8d82c24d2e42..83255626bafe 100644 --- a/superset-frontend/src/features/alerts/components/NotificationMethod.tsx +++ b/superset-frontend/src/features/alerts/components/NotificationMethod.tsx @@ -649,7 +649,7 @@ export const NotificationMethod: FunctionComponent = ({ )} diff --git a/superset/integrations/teams/dependencies.py b/superset/integrations/teams/dependencies.py new file mode 100644 index 000000000000..ae18dcc922be --- /dev/null +++ b/superset/integrations/teams/dependencies.py @@ -0,0 +1,28 @@ +# 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. +"""Inspect optional Teams dependencies without importing the SDK.""" + +from importlib.metadata import PackageNotFoundError, version + + +def is_teams_sdk_installed() -> bool: + """Check metadata only; SDK imports stay at the operation boundary.""" + try: + version("microsoft-teams-apps") + except PackageNotFoundError: + return False + return True diff --git a/superset/integrations/teams/destinations.py b/superset/integrations/teams/destinations.py index e26151252680..1e54ab9b3ce4 100644 --- a/superset/integrations/teams/destinations.py +++ b/superset/integrations/teams/destinations.py @@ -25,17 +25,19 @@ TeamsConfig, TeamsConfigError, ) +from superset.integrations.teams.dependencies import is_teams_sdk_installed from superset.integrations.teams.store import TeamsInstallationStore def configured_teams() -> TeamsConfig | None: - """Resolve an enabled integration without exposing configuration errors.""" + """Resolve an enabled, configured integration with its optional SDK installed.""" if not feature_flag_manager.is_feature_enabled("ALERT_REPORT_TEAMS"): return None try: - return parse_teams_config(current_app.config.get("ALERT_REPORTS_TEAMS", {})) + config = parse_teams_config(current_app.config.get("ALERT_REPORTS_TEAMS", {})) except TeamsConfigError: return None + return config if config is not None and is_teams_sdk_installed() else None def list_teams_destinations() -> list[dict[str, str | bool]]: diff --git a/tests/unit_tests/integrations/teams/dependencies_test.py b/tests/unit_tests/integrations/teams/dependencies_test.py new file mode 100644 index 000000000000..67f77a63c6b6 --- /dev/null +++ b/tests/unit_tests/integrations/teams/dependencies_test.py @@ -0,0 +1,51 @@ +# 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. +"""Optional dependency discovery must never import Microsoft SDK modules.""" + +import builtins +from importlib.metadata import PackageNotFoundError +from typing import Any + +import pytest + +from superset.integrations.teams import dependencies + + +@pytest.mark.parametrize("installed", [False, True]) +def test_dependency_check_uses_metadata_without_importing_sdk( + monkeypatch: pytest.MonkeyPatch, + installed: bool, +) -> None: + """A missing extra is normal; an installed one is detected without importing it.""" + imported = builtins.__import__ + lookups: list[str] = [] + + def check_import(name: str, *args: Any, **kwargs: Any) -> Any: + if name.startswith("microsoft_teams"): + raise AssertionError("must not import the optional SDK") + return imported(name, *args, **kwargs) + + def version(name: str) -> str: + lookups.append(name) + if not installed: + raise PackageNotFoundError(name) + return "2.1.1" + + monkeypatch.setattr(builtins, "__import__", check_import) + monkeypatch.setattr(dependencies, "version", version) + assert dependencies.is_teams_sdk_installed() is installed + assert lookups == ["microsoft-teams-apps"] diff --git a/tests/unit_tests/integrations/teams/destinations_test.py b/tests/unit_tests/integrations/teams/destinations_test.py index f24c992b96f9..67763144aec6 100644 --- a/tests/unit_tests/integrations/teams/destinations_test.py +++ b/tests/unit_tests/integrations/teams/destinations_test.py @@ -65,6 +65,7 @@ def configured_app(monkeypatch: pytest.MonkeyPatch) -> Iterator[Flask]: monkeypatch.setattr( destinations.feature_flag_manager, "is_feature_enabled", lambda name: True ) + monkeypatch.setattr(destinations, "is_teams_sdk_installed", lambda: True) engine = create_engine("sqlite://") TeamsInstallation.__table__.create(engine) monkeypatch.setattr(destinations, "db", MagicMock(engine=engine)) @@ -160,3 +161,60 @@ def test_invalid_team_config_is_hidden_before_installation_lookup( assert destinations.configured_teams() is None assert destinations.list_teams_destinations() == [] lookup.assert_not_called() + + +@pytest.mark.parametrize("installed", [False, True]) +def test_optional_sdk_controls_both_discovery_and_notification_methods( + configured_app: Flask, + monkeypatch: pytest.MonkeyPatch, + installed: bool, +) -> None: + """Bootstrap and destination discovery agree when the optional SDK is missing.""" + from superset.reports.models import ReportRecipientType + from superset.views import base + + monkeypatch.setattr(destinations, "is_teams_sdk_installed", lambda: installed) + monkeypatch.setattr(base, "app", configured_app) + configured_app.config["SLACK_API_TOKEN"] = None + if installed: + assert destinations.configured_teams() is not None + assert len(destinations.list_teams_destinations()) == 3 + assert ReportRecipientType.TEAMS in base._notification_methods() + else: + session = MagicMock(side_effect=AssertionError("must not query installations")) + monkeypatch.setattr(destinations, "Session", session) + assert destinations.configured_teams() is None + assert destinations.list_teams_destinations() == [] + assert ReportRecipientType.TEAMS not in base._notification_methods() + session.assert_not_called() + + +def test_disabled_integration_registers_and_bootstraps_without_sdk( + configured_app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The Teams startup paths require neither SDK imports nor a dependency lookup.""" + import sys + + from superset.integrations.teams.api import register_teams_api + from superset.reports.models import ReportRecipientType + from superset.views import base + + monkeypatch.setattr( + destinations.feature_flag_manager, "is_feature_enabled", lambda _: False + ) + missing_sdk = MagicMock(side_effect=AssertionError("must not check SDK")) + monkeypatch.setattr(destinations, "is_teams_sdk_installed", missing_sdk) + monkeypatch.setitem(sys.modules, "superset.integrations.teams.adapter", None) + monkeypatch.setitem(sys.modules, "microsoft_teams", None) + monkeypatch.setitem(sys.modules, "microsoft_teams.apps", None) + monkeypatch.setattr(base, "app", configured_app) + register_teams_api(configured_app) + assert destinations.configured_teams() is None + assert destinations.list_teams_destinations() == [] + assert ReportRecipientType.TEAMS not in base._notification_methods() + assert ( + configured_app.test_client().post("/api/v1/teams/events/", json={}).status_code + == 404 + ) + missing_sdk.assert_not_called() diff --git a/tests/unit_tests/reports/notifications/teams_tests.py b/tests/unit_tests/reports/notifications/teams_tests.py index a4022e9b7731..30102f800d39 100644 --- a/tests/unit_tests/reports/notifications/teams_tests.py +++ b/tests/unit_tests/reports/notifications/teams_tests.py @@ -18,7 +18,7 @@ from collections.abc import Iterator from types import SimpleNamespace -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest from flask import Flask @@ -33,6 +33,7 @@ NotificationError, NotificationParamException, ) +from superset.reports.notifications.teams import _send_to_teams from superset.utils import json @@ -232,3 +233,17 @@ def test_card_omits_empty_description_and_hides_execution_error( ) assert "secret_table" not in json.dumps(card) assert "credential" not in json.dumps(card) + + +def test_delivery_still_reports_missing_sdk( + notification: SimpleNamespace, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Previously saved recipients retain the lazy delivery-time dependency error.""" + monkeypatch.setattr(teams, "_send_to_teams", _send_to_teams) + with patch.dict("sys.modules", {"superset.integrations.teams.adapter": None}): + with pytest.raises( + NotificationParamException, match="^Teams SDK is not installed$" + ): + teams.TeamsNotification(notification.recipient, notification.content).send() + notification.session.return_value.__exit__.assert_called_once() From 5d13c4af16be76db17513bc1c495fa481c7d8d3c Mon Sep 17 00:00:00 2001 From: Kristi Dodaj Date: Mon, 19 Oct 2026 21:21:38 -0400 Subject: [PATCH 12/30] fix(reports): limit Teams installation callback request size --- .../configuration/alerts-reports.mdx | 6 +- superset/integrations/teams/api.py | 21 +++ .../unit_tests/integrations/teams/api_test.py | 134 ++++++++++++++++++ 3 files changed, 159 insertions(+), 2 deletions(-) diff --git a/docs/admin_docs/configuration/alerts-reports.mdx b/docs/admin_docs/configuration/alerts-reports.mdx index cf72a8adeb9b..1c16db9e92c7 100644 --- a/docs/admin_docs/configuration/alerts-reports.mdx +++ b/docs/admin_docs/configuration/alerts-reports.mdx @@ -1131,8 +1131,10 @@ routing was ready, remove and reinstall the app to deliver a fresh event. ### Route the callback Your ingress must forward `POST /api/v1/teams/events/` (including the trailing -slash) to Flask, preserving the Authorization header and JSON body. Do not send -this path through a browser login redirect or an ingress SSO challenge. Keep TLS +slash) to Flask, preserving the Authorization header and JSON body. The callback +accepts bodies up to 256 KiB, or a smaller deployment body limit, and returns 413 +for oversized requests before JSON parsing, SDK authentication, or database access. +Do not send this path through a browser login redirect or an ingress SSO challenge. Keep TLS termination and the configured public URL consistent. Other Superset routes keep their normal authentication and CSRF requirements; do not exempt the whole API. The callback validates Microsoft's connector token, application, tenant, approved diff --git a/superset/integrations/teams/api.py b/superset/integrations/teams/api.py index a30af36f7600..195027dfc036 100644 --- a/superset/integrations/teams/api.py +++ b/superset/integrations/teams/api.py @@ -24,6 +24,7 @@ from flask import Blueprint, current_app, Flask, request, Response from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session +from werkzeug.exceptions import RequestEntityTooLarge from superset.extensions import csrf, db, feature_flag_manager from superset.integrations.teams.config import ( @@ -36,10 +37,29 @@ if TYPE_CHECKING: from superset.integrations.teams.adapter import TeamsInstallationEvent +_MAX_EVENT_BYTES = 256 * 1024 + logger = logging.getLogger(__name__) blueprint = Blueprint("teams", __name__) +def _check_event_body_size() -> None: + """Bound body reading and reject oversized input before JSON parsing.""" + configured_limit = request.max_content_length + limit = ( + min(_MAX_EVENT_BYTES, configured_limit) + if configured_limit is not None + else _MAX_EVENT_BYTES + ) + if request.content_length is not None and request.content_length > limit: + raise RequestEntityTooLarge() + # Werkzeug can truncate lengthless streams at the limit without raising. + # Read one lookahead byte through Flask's bounded stream before parsing JSON. + request.max_content_length = limit + 1 + if len(request.get_data()) > limit: + raise RequestEntityTooLarge() + + @blueprint.post("/api/v1/teams/events/") @csrf.exempt def teams_events() -> Response: @@ -53,6 +73,7 @@ def teams_events() -> Response: return Response(status=503) if config is None: return Response(status=503) + _check_event_body_size() if not request.is_json: return Response(status=415) body = request.get_json(silent=True) diff --git a/tests/unit_tests/integrations/teams/api_test.py b/tests/unit_tests/integrations/teams/api_test.py index 251248e74108..0f26db862063 100644 --- a/tests/unit_tests/integrations/teams/api_test.py +++ b/tests/unit_tests/integrations/teams/api_test.py @@ -40,6 +40,7 @@ from superset.integrations.teams.config import parse_teams_config from superset.integrations.teams.models import TeamsInstallation from superset.integrations.teams.store import TeamsInstallationStore +from superset.utils import json teams_adapter = pytest.importorskip( "superset.integrations.teams.adapter", exc_type=ImportError @@ -573,3 +574,136 @@ async def stop(adapter: Any) -> None: teams_adapter.process_teams_event(config, installation, {}) assert len(loops) == 1 assert loops[0].is_closed() + + +@pytest.mark.parametrize("content_length", [True, False]) +@pytest.mark.parametrize("extra_bytes", [1, 4096]) +@pytest.mark.parametrize("content_type", ["application/json", "text/plain"]) +def test_oversized_event_is_rejected_before_json_sdk_or_database( + teams_client: FlaskClient, + installation: dict[str, Any], + content_length: bool, + extra_bytes: int, + content_type: str, +) -> None: + """Even valid JSON followed by excess whitespace must not reach the parser.""" + limit = 256 * 1024 + body = json.dumps(installation).encode() + body += b" " * (limit + extra_bytes - len(body)) + environ = ( + {} + if content_length + else { + "CONTENT_LENGTH": "", + "wsgi.input_terminated": True, + } + ) + with ( + patch.object(teams_client.application.request_class, "get_json") as parse, + patch.object(teams_adapter, "process_teams_event") as sdk, + patch.object(teams_api, "Session") as session, + ): + response = teams_client.post( + PATH, + data=body, + content_type=content_type, + environ_overrides=environ, + ) + assert response.status_code == 413 + parse.assert_not_called() + sdk.assert_not_called() + session.assert_not_called() + + +@pytest.mark.parametrize("content_length", [True, False]) +@pytest.mark.parametrize("size", [1024, 256 * 1024]) +def test_valid_event_within_body_limit_still_authenticates_and_persists( + teams_client: FlaskClient, + teams_engine: Engine, + installation: dict[str, Any], + auth_headers: Callable[..., dict[str, str]], + content_length: bool, + size: int, +) -> None: + """Accept normal and boundary-size JSON bodies, including terminated streams.""" + body = json.dumps(installation).encode() + body += b" " * (size - len(body)) + assert len(body) == size + environ = ( + {} + if content_length + else { + "CONTENT_LENGTH": "", + "wsgi.input_terminated": True, + } + ) + response = teams_client.post( + PATH, + data=body, + content_type="application/json", + headers=auth_headers(), + environ_overrides=environ, + ) + assert response.status_code == 200 + with Session(teams_engine) as session: + installation_record = session.query(TeamsInstallation).one() + assert installation_record.team_id == "team-1" + assert installation_record.active + + +@pytest.mark.parametrize("content_length", [True, False]) +def test_callback_respects_stricter_deployment_body_limit( + teams_client: FlaskClient, + installation: dict[str, Any], + content_length: bool, +) -> None: + """The endpoint cap must not relax a smaller operator-configured limit.""" + teams_client.application.config["MAX_CONTENT_LENGTH"] = 1024 + body = json.dumps(installation).encode() + body += b" " * (1025 - len(body)) + environ = ( + {} + if content_length + else { + "CONTENT_LENGTH": "", + "wsgi.input_terminated": True, + } + ) + with ( + patch.object(teams_client.application.request_class, "get_json") as parse, + patch.object(teams_adapter, "process_teams_event") as sdk, + patch.object(teams_api, "Session") as session, + ): + response = teams_client.post( + PATH, + data=body, + content_type="application/json", + environ_overrides=environ, + ) + assert response.status_code == 413 + assert teams_client.application.config["MAX_CONTENT_LENGTH"] == 1024 + parse.assert_not_called() + sdk.assert_not_called() + session.assert_not_called() + + +def test_body_limit_applies_only_to_teams_callback(teams_client: FlaskClient) -> None: + """A separate route retains the deployment's existing request-size handling.""" + app = teams_client.application + + @app.post("/other-body") + @teams_api.csrf.exempt + def other_body() -> Response: + from flask import request + + return Response(str(len(request.get_data()))) + + body = b"x" * (256 * 1024 + 1) + assert ( + teams_client.post(PATH, data=body, content_type="application/json").status_code + == 413 + ) + response = teams_client.post("/other-body", data=body) + assert response.status_code == 200 + assert response.data == str(len(body)).encode() + assert app.config["MAX_CONTENT_LENGTH"] is None From e47cac0929f7b640ecdf736eee942c60a073ab25 Mon Sep 17 00:00:00 2001 From: Kristi Dodaj Date: Mon, 19 Oct 2026 22:02:08 -0400 Subject: [PATCH 13/30] refactor(reports): simplify Teams destination discovery and consolidate tests --- .../configuration/alerts-reports.mdx | 171 +++++------------- superset/integrations/teams/config.py | 8 - superset/integrations/teams/destinations.py | 37 ++-- .../unit_tests/integrations/teams/api_test.py | 9 +- .../integrations/teams/config_test.py | 8 +- .../integrations/teams/delivery_test.py | 150 +-------------- .../integrations/teams/destinations_test.py | 25 ++- .../integrations/teams/store_test.py | 1 - .../reports/notifications/teams_tests.py | 46 +++++ 9 files changed, 150 insertions(+), 305 deletions(-) diff --git a/docs/admin_docs/configuration/alerts-reports.mdx b/docs/admin_docs/configuration/alerts-reports.mdx index 1c16db9e92c7..b277b892fc4c 100644 --- a/docs/admin_docs/configuration/alerts-reports.mdx +++ b/docs/admin_docs/configuration/alerts-reports.mdx @@ -894,136 +894,42 @@ Airflow, additional fields can be easily added to the configuration file above. ## Microsoft Teams configuration -The optional `apache-superset[teams]` extra installs Microsoft Teams SDK 2.1.1. -The SDK handles callback JWT authentication; SDK-specific code lives in -`superset.integrations.teams.adapter`. -`FEATURE_FLAGS["ALERT_REPORT_TEAMS"]` defaults to `False`. The `Teams` notification -recipient sends attachment-free alerts to configured standard channels; scheduled -reports are rejected. - -Set `ALERT_REPORTS_TEAMS` in `superset_config.py` with `tenant_id` (UUID), -`application_id` (bot application UUID), `credential` (client secret from your -secret store), and a nonempty `destinations` list. Each destination requires: - -- `key`: unique stable identifier, 1–128 letters, digits, underscores or hyphens, - starting with a letter or digit. Retain this key when changing channel details. -- `label`: display name. -- `team_id`: Teams team identifier. -- `channel_id`: Teams channel identifier. - -An empty mapping leaves Teams unconfigured. The server-side -`superset.integrations.teams.config.parse_teams_config` function validates the -mapping and rejects missing/unknown fields and duplicate keys or channels. -Only one tenant and bot identity are accepted per deployment. Operators must -approve standard channels in that tenant; the parser cannot verify channel type -or tenant membership without Teams metadata. Private/shared channels, DMs, -scheduled reports, and attachments are outside the integration scope. - -Set the recipient type to `Teams` and `recipient_config_json` to -`{"target": "ops"}`, where `ops` is a destination key from the deployment -allowlist. The recipient's configuration must contain only `target`; channel IDs and credentials stay on the -server. Delivery requires an active installation matching the configured tenant, -application, and destination team. - -Schedule creation and updates share Teams recipient validation with delivery. -Teams recipients require the enabled, configured integration, an `Alert` schedule, -explicit `report_format="NONE"`, and a known destination key. The global attachment -setting does not override this requirement. PUT validates the merged saved -configuration, including existing recipients when omitted from the request; -changing only schedule type, format, or metadata cannot retain an invalid Teams -recipient. Explicitly replacing/removing Teams recipients validates the replacement -list. An active bot installation is required at delivery time, not when saving. -Attachment-free alerts use the existing scheduler and execution logs and skip -screenshot, PDF, and file generation. - -Each alert creates a new root channel post with an Adaptive Card containing its -name and optional description. The optional **Open in Superset** action follows -the existing `include_cta` setting. Cards use normal JSON serialization; execution -diagnostics are replaced by a generic failure message. File uploads and embedded -tabular results are rejected. The Adaptive Card's protocol attachment envelope -contains only the card, with no attached files. - -Transport runs in the Teams adapter with a replaceable operation-scoped interface. -It allows at most four attempts within one 60-second retry budget, capped by the -remaining execution deadline after its cleanup reserve. Individual MSAL metadata, -token, and channel HTTP requests use a timeout of at most 10 seconds or the -remaining budget. The adapter honors `Retry-After` -(seconds or HTTP date) and `x-ms-retry-after-ms`. A delay exceeding the remaining -window ends delivery without retrying early. One 401 response can invalidate the -MSAL token cache and retry with fresh authentication; a second 401 or any 403 -fails authorization. Transient exhaustion uses the existing notification transient -exception. A failed or lost response can make delivery ambiguous, so transport -retries can produce duplicate root posts; no conversation IDs are reused. - -Set the bot's messaging endpoint to `POST /api/v1/teams/events/`. The callback -is enabled only by `ALERT_REPORT_TEAMS` and a valid `ALERT_REPORTS_TEAMS` mapping. -It authenticates installation `add` and `remove` events using the SDK's public -`HttpServer` with a custom `HttpServerAdapter` inside Flask. It never starts the -SDK's standalone server or creates a full SDK `App`. Each invocation closes its -event loop and database session on success or failure. - -Events must be Bot Framework connector activities with a valid token for the -configured application and a signed `serviceurl` claim matching the event URL. -The callback also requires the configured tenant, bot recipient, and an approved -team from `destinations`. Personal/group chats, explicit private/shared channel -contexts, other activity types, and attachments are rejected. Installation events -cannot add channels to configuration. Browser sessions do not authenticate the -callback. Only this independently authenticated route is exempt from CSRF checks. -Agent 365/Entra activity tokens are outside this notification-only bot's scope. - -Bot installations are stored separately in `teams_installations`, uniquely keyed -by tenant, application, and team. Installation events refresh the service URL -and reactivate the same UUID record; removal makes it inactive. Duplicate events -are safe and events are applied in processing order. The store leaves transaction -commit/rollback to its caller. Event authentication, deployment identity checks, -and service URL ownership verification must happen before invoking the store. -Installation records never add selectable destinations or store channels or -credentials. The additive migration can be downgraded by dropping only this table. - -Keep the entire mapping on the server. Browser destination lists must use -`TeamsConfig.public_destinations()` (keys and labels only). Recipient JSON must -store only the selected destination key, never credentials or routing details. -Do not serialize the parsed configuration object, including via `dataclasses.asdict`. - -The alert editor lists Teams when the integration is enabled and configured, the -optional SDK is installed, and attachments are disabled. Dependency discovery reads -package metadata without importing the SDK. If the SDK is absent, the destination -API returns an empty list and saved Teams recipients remain visible as unavailable -when editing. Delivery retains a lazy import guard for missing or unloadable SDKs. -Choose an approved channel by its display label. To notify multiple channels, add another notification method and choose Teams again; -each entry stores one destination key. Scheduled reports do not offer Teams. - -The destination list uses the Reports API read permission and returns only keys, -labels, and installation availability. Channels without an active bot installation -remain selectable so alerts can be saved before installation, with a visible -installation-required notice. Install the notification bot in the approved team -before delivery. Removed configuration keys remain visible as unavailable when -editing saved alerts; restore the configuration or select another approved channel. -Credentials are managed only in deployment configuration. - -Teams delivery runs inside the existing report task. Authentication, posting, -retry delays, and resource cleanup consume the same retry budget; an operation -that finishes after the deadline cannot report success. SDK and MSAL clients are -closed in the same operation, and Celery soft timeouts propagate to the existing -execution handler without transport retries. - -The retry budget is cooperative, not a separate hard 60-second wall-clock limit. -[Requests timeouts](https://requests.readthedocs.io/en/latest/user/advanced/#timeouts) -bound socket operations rather than the entire call; DNS resolution and a response -that keeps delivering small amounts of data can exceed them. The adapter does not -introduce a subprocess, signal handler, background thread, or watchdog to interrupt -such work. Cleanup runs normally on success, failure, and soft task timeouts; -a hard task kill cannot guarantee Python cleanup. - -For an outer bound on blocking work, use a Celery prefork worker on a platform -that supports [Celery time limits](https://docs.celeryq.dev/en/stable/userguide/workers.html#time-limits), +Teams is an optional, disabled-by-default notification destination for +attachment-free alerts in standard channels. Each deployment supports one +Microsoft tenant and bot identity. Scheduled reports, private/shared channels, +direct messages, and file attachments are not supported. + +Each alert creates a separate channel post containing its name, optional +description, and an **Open in Superset** link when link inclusion is enabled. +Execution errors show a generic failure message rather than SQL diagnostics. + +Administrators configure approved destinations on the server. In the alert +editor, disable attachments and select a destination by label. Add another Teams +notification method to notify another channel. Credentials are managed only in +deployment configuration. The protected destination API returns keys, labels, +and installation availability, without credentials or routing details. + +For API clients, use recipient type `Teams`, `recipient_config_json` containing +only `{"target": "ops"}`, and `report_format="NONE"`. Create, update, and delivery +validation enforce the same restrictions, including partial updates to saved +alerts. Alerts can be saved before bot installation, but delivery requires an +active installation. Unavailable saved destinations remain visible when editing. + +Authenticated installation events persist routing information for approved teams. +Removal disables delivery, and reinstallation refreshes the same record. Events +are applied in processing order and never create selectable destinations. + +Delivery allows up to four attempts within a cooperative 60-second retry budget, +limited by the remaining report execution deadline. Requests have socket timeouts +of at most 10 seconds. Throttling honors Microsoft's retry delay, and expired +authentication can refresh once. A lost response can cause duplicate posts. + +Socket timeouts do not bound DNS resolution or an entire streaming response. For +an outer time limit, use a Celery prefork worker with +[time-limit support](https://docs.celeryq.dev/en/stable/userguide/workers.html#time-limits), keep `ALERT_REPORTS_WORKING_TIME_OUT_KILL=True`, and set a finite schedule -`working_timeout`. For alerts, the existing defaults apply a soft limit at -`working_timeout + 1` seconds and a hard limit at `working_timeout + 10` seconds. -These limits cover the entire task, including query evaluation and notification -delivery, rather than granting Teams another independent hard timeout. Disabling -those limits or using a worker pool without time-limit support removes this -backstop; request timeouts alone do not replace it. +`working_timeout`. These limits cover the entire alert task, including query +execution and delivery. Hard task termination cannot guarantee resource cleanup. ### Deploy the optional integration @@ -1060,6 +966,13 @@ ALERT_REPORTS_TEAMS = { } ``` +Destination keys must be unique, stable identifiers of 1–128 letters, digits, +underscores or hyphens, starting with a letter or digit. Keep keys stable when +changing routing. Each destination requires a label, team ID, and channel ID. +Approve only standard channels in the configured tenant. Duplicate keys or +channels and unknown configuration fields are rejected. An empty mapping leaves +Teams unconfigured. + Merge these feature flags into your existing flags. Restart the web server and workers after changing configuration or rotating a secret. Apply the migration before accepting installation events. The scheduler, broker, and worker setup diff --git a/superset/integrations/teams/config.py b/superset/integrations/teams/config.py index 237ffb7b22b7..fded59ea09fa 100644 --- a/superset/integrations/teams/config.py +++ b/superset/integrations/teams/config.py @@ -35,10 +35,6 @@ class TeamsDestination: team_id: str channel_id: str - def as_public_dict(self) -> dict[str, str]: - """Return the only destination fields intended for browser responses.""" - return {"key": self.key, "label": self.label} - @dataclass(frozen=True) class TeamsConfig: @@ -56,10 +52,6 @@ def get_destination(self, key: str) -> TeamsDestination: return destination raise TeamsConfigError("Teams destination is not approved") - def public_destinations(self) -> list[dict[str, str]]: - """Project selectable destinations without identity, routing, or credentials.""" - return [destination.as_public_dict() for destination in self.destinations] - def _mapping(value: object, fields: set[str], path: str) -> Mapping[str, object]: """Require exactly the supported fields without echoing unknown keys or values.""" diff --git a/superset/integrations/teams/destinations.py b/superset/integrations/teams/destinations.py index 1e54ab9b3ce4..de526dca7479 100644 --- a/superset/integrations/teams/destinations.py +++ b/superset/integrations/teams/destinations.py @@ -16,7 +16,10 @@ # under the License. """Browser-safe discovery of operator-approved Teams destinations.""" +from uuid import UUID + from flask import current_app +from sqlalchemy import select from sqlalchemy.orm import Session from superset.extensions import db, feature_flag_manager @@ -26,7 +29,7 @@ TeamsConfigError, ) from superset.integrations.teams.dependencies import is_teams_sdk_installed -from superset.integrations.teams.store import TeamsInstallationStore +from superset.integrations.teams.models import TeamsInstallation def configured_teams() -> TeamsConfig | None: @@ -46,17 +49,23 @@ def list_teams_destinations() -> list[dict[str, str | bool]]: if config is None: return [] with Session(db.engine) as session: - store = TeamsInstallationStore(session) - return [ - { - "key": destination.key, - "label": destination.label, - "available": store.get_active( - tenant_id=config.tenant_id, - application_id=config.application_id, - team_id=destination.team_id, + active_teams = set( + session.scalars( + select(TeamsInstallation.team_id).where( + TeamsInstallation.tenant_id == UUID(config.tenant_id), + TeamsInstallation.application_id == UUID(config.application_id), + TeamsInstallation.active.is_(True), + TeamsInstallation.team_id.in_( + {destination.team_id for destination in config.destinations} + ), ) - is not None, - } - for destination in config.destinations - ] + ) + ) + return [ + { + "key": destination.key, + "label": destination.label, + "available": destination.team_id in active_teams, + } + for destination in config.destinations + ] diff --git a/tests/unit_tests/integrations/teams/api_test.py b/tests/unit_tests/integrations/teams/api_test.py index 0f26db862063..bfe66ed5beb5 100644 --- a/tests/unit_tests/integrations/teams/api_test.py +++ b/tests/unit_tests/integrations/teams/api_test.py @@ -19,6 +19,8 @@ from __future__ import annotations import asyncio +import importlib +import importlib.util from collections.abc import Callable, Iterator from copy import deepcopy from datetime import datetime, timedelta, timezone @@ -42,9 +44,10 @@ from superset.integrations.teams.store import TeamsInstallationStore from superset.utils import json -teams_adapter = pytest.importorskip( - "superset.integrations.teams.adapter", exc_type=ImportError -) +if importlib.util.find_spec("microsoft_teams") is None: + pytest.skip("Optional Teams SDK is not installed", allow_module_level=True) + +teams_adapter = importlib.import_module("superset.integrations.teams.adapter") PATH = "/api/v1/teams/events/" TENANT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" APPLICATION = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" diff --git a/tests/unit_tests/integrations/teams/config_test.py b/tests/unit_tests/integrations/teams/config_test.py index f235a90fac27..013f52a3d96b 100644 --- a/tests/unit_tests/integrations/teams/config_test.py +++ b/tests/unit_tests/integrations/teams/config_test.py @@ -46,18 +46,16 @@ def test_unconfigured() -> None: assert parse_teams_config({}) is None -def test_valid_config_and_safe_projection(deployment: dict[str, Any]) -> None: +def test_valid_config_is_immutable_and_hides_credential( + deployment: dict[str, Any], +) -> None: config = parse_teams_config(deployment) assert config is not None assert config.tenant_id == deployment["tenant_id"] assert config.application_id == deployment["application_id"] assert config.credential == deployment["credential"] assert config.get_destination("operations").channel_id == "19:channel@thread.tacv2" - assert config.public_destinations() == [ - {"key": "operations", "label": "Operations alerts"}, - ] assert deployment["credential"] not in repr(config) - assert deployment["credential"] not in str(config.public_destinations()) deployment["destinations"][0]["key"] = "changed" assert config.get_destination("operations").key == "operations" with pytest.raises(TeamsConfigError, match="not approved"): diff --git a/tests/unit_tests/integrations/teams/delivery_test.py b/tests/unit_tests/integrations/teams/delivery_test.py index f71dccfb5b08..673d13221245 100644 --- a/tests/unit_tests/integrations/teams/delivery_test.py +++ b/tests/unit_tests/integrations/teams/delivery_test.py @@ -19,6 +19,8 @@ from __future__ import annotations import asyncio +import importlib +import importlib.util import threading import time from collections.abc import AsyncIterator, Iterator @@ -35,7 +37,6 @@ from billiard.exceptions import SoftTimeLimitExceeded from flask import current_app, Flask from sqlalchemy import create_engine -from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session from superset.integrations.teams import validation as teams_validation @@ -51,15 +52,15 @@ from superset.reports.notifications.base import NotificationContent from superset.reports.notifications.exceptions import ( NotificationAuthorizationException, - NotificationError, NotificationParamException, NotificationTransientError, ) from superset.utils import json -adapter = pytest.importorskip( - "superset.integrations.teams.adapter", exc_type=ImportError -) +if importlib.util.find_spec("microsoft_teams") is None: + pytest.skip("Optional Teams SDK is not installed", allow_module_level=True) + +adapter = importlib.import_module("superset.integrations.teams.adapter") TENANT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" APPLICATION = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" SERVICE_URL = "https://smba.trafficmanager.net/amer/" @@ -169,83 +170,6 @@ def test_factory_resolves_active_installation_and_formats_card( assert "server-only-secret" not in json.dumps(card) -@pytest.mark.parametrize( - "include_cta,url", - [ - (False, "https://superset.example"), - (True, None), - (True, "javascript:alert(1)"), - (True, "https://[invalid"), - ], -) -def test_optional_link_respects_existing_setting( - content: NotificationContent, include_cta: bool, url: str | None -) -> None: - content.include_cta, content.url = include_cta, url - assert "actions" not in notifications.build_teams_card(content) - - -def test_optional_description_and_diagnostics(content: NotificationContent) -> None: - content.description = None - content.text = ( - 'SQL failed: SELECT "password" FROM internal_table; database://secret' - ) - card = notifications.build_teams_card(content) - assert len(card["body"]) == 2 - assert ( - card["body"][1]["text"] - == "Alert execution failed. Contact the alert owner for details." - ) - serialized = json.dumps(card) - assert "password" not in serialized - assert "internal_table" not in serialized - assert "database://secret" not in serialized - - -def test_card_json_round_trip_preserves_quotes_newlines_and_unicode( - content: NotificationContent, -) -> None: - card = notifications.build_teams_card(content) - decoded = json.loads(json.dumps(card)) - assert decoded["body"][0]["text"] == content.name - assert decoded["body"][1]["text"] == content.description - - -@pytest.mark.parametrize( - "target", ["unapproved", "https://example.com", "", None, ["ops"]] -) -def test_recipient_target_is_a_destination_key( - notification_context: SimpleNamespace, - content: NotificationContent, - target: Any, -) -> None: - entry = recipient() - entry.recipient_config_json = json.dumps({"target": target}) - with pytest.raises(NotificationParamException): - notifications.TeamsNotification(entry, content).send() - notification_context.send.assert_not_called() - - -@pytest.mark.parametrize( - "raw", - [ - "{", - "[]", - "{}", - '{"target":"ops","credential":"secret"}', - '{"target":"ops","channel_id":"override"}', - ], -) -def test_invalid_recipient_json_is_rejected( - notification_context: SimpleNamespace, content: NotificationContent, raw: str -) -> None: - entry = recipient() - entry.recipient_config_json = raw - with pytest.raises(NotificationParamException): - notifications.TeamsNotification(entry, content).send() - notification_context.send.assert_not_called() - - @pytest.mark.parametrize( "mode", ["inactive", "missing", "other-tenant", "other-application"] ) @@ -274,68 +198,6 @@ def test_installation_must_be_active_for_exact_identity( notification_context.send.assert_not_called() -@pytest.mark.parametrize( - "mode", - [ - "disabled", - "unconfigured", - "report", - "editor-diagnostic", - "csv", - "xlsx", - "pdf", - "screenshots", - "embedded_data", - ], -) -def test_out_of_scope_delivery_is_rejected( - notification_context: SimpleNamespace, content: NotificationContent, mode: str -) -> None: - if mode == "disabled": - notification_context.app.config["FEATURE_FLAGS"]["ALERT_REPORT_TEAMS"] = False - elif mode == "unconfigured": - notification_context.app.config["ALERT_REPORTS_TEAMS"] = {} - elif mode == "report": - content.header_data["notification_type"] = "Report" - elif mode == "editor-diagnostic": - content.is_editor_error = True - else: - setattr(content, mode, [b"file"] if mode == "screenshots" else b"file") - with pytest.raises(NotificationParamException): - notifications.TeamsNotification(recipient(), content).send() - notification_context.send.assert_not_called() - - -@pytest.mark.parametrize("report_format", ["PNG", "PDF", "CSV", "XLSX", "TEXT"]) -def test_delivery_revalidates_format_even_without_generated_attachments( - notification_context: SimpleNamespace, - content: NotificationContent, - report_format: str, -) -> None: - content.header_data["notification_format"] = report_format - assert not content.has_attachments - with pytest.raises(NotificationParamException, match="disabled attachments"): - notifications.TeamsNotification(recipient(), content).send() - notification_context.send.assert_not_called() - - -def test_database_errors_are_sanitized( - notification_context: SimpleNamespace, - content: NotificationContent, - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr( - TeamsInstallationStore, - "get_active", - MagicMock(side_effect=SQLAlchemyError("SELECT password FROM internal_table")), - ) - with pytest.raises(NotificationError) as exc: - notifications.TeamsNotification(recipient(), content).send() - assert "internal_table" not in str(exc.value) - assert exc.value.__cause__ is None - notification_context.send.assert_not_called() - - class FakeTransport: """Deterministic replaceable transport for bounded retry policy tests.""" diff --git a/tests/unit_tests/integrations/teams/destinations_test.py b/tests/unit_tests/integrations/teams/destinations_test.py index 67763144aec6..2a7c335c42ad 100644 --- a/tests/unit_tests/integrations/teams/destinations_test.py +++ b/tests/unit_tests/integrations/teams/destinations_test.py @@ -22,7 +22,7 @@ import pytest from flask import current_app, Flask -from sqlalchemy import create_engine +from sqlalchemy import create_engine, event from sqlalchemy.orm import Session from superset.integrations.teams import destinations @@ -218,3 +218,26 @@ def test_disabled_integration_registers_and_bootstraps_without_sdk( == 404 ) missing_sdk.assert_not_called() + + +def test_destination_availability_uses_one_query(configured_app: Flask) -> None: + statements: list[str] = [] + + def record_query( + connection: Any, + cursor: Any, + statement: str, + parameters: Any, + context: Any, + executemany: bool, + ) -> None: + statements.append(statement) + + engine = destinations.db.engine + event.listen(engine, "before_cursor_execute", record_query) + try: + assert len(destinations.list_teams_destinations()) == 3 + finally: + event.remove(engine, "before_cursor_execute", record_query) + assert len(statements) == 1 + assert statements[0].lstrip().upper().startswith("SELECT") diff --git a/tests/unit_tests/integrations/teams/store_test.py b/tests/unit_tests/integrations/teams/store_test.py index 07b0cbd5da59..37996b718523 100644 --- a/tests/unit_tests/integrations/teams/store_test.py +++ b/tests/unit_tests/integrations/teams/store_test.py @@ -255,7 +255,6 @@ def test_installation_does_not_authorize_destinations(session: Session) -> None: store.remove(TENANT, APPLICATION, "unapproved") store.install(TENANT, APPLICATION, "unapproved", REFRESHED_URL) assert raw_config == before - assert config.public_destinations() == [{"key": "ops", "label": "Operations"}] assert config.get_destination("ops").team_id == "approved" assert set(TeamsInstallation.__table__.columns.keys()) == { "id", diff --git a/tests/unit_tests/reports/notifications/teams_tests.py b/tests/unit_tests/reports/notifications/teams_tests.py index 30102f800d39..bab227ebe71f 100644 --- a/tests/unit_tests/reports/notifications/teams_tests.py +++ b/tests/unit_tests/reports/notifications/teams_tests.py @@ -247,3 +247,49 @@ def test_delivery_still_reports_missing_sdk( ): teams.TeamsNotification(notification.recipient, notification.content).send() notification.session.return_value.__exit__.assert_called_once() + + +@pytest.mark.parametrize( + "target", ["unapproved", "https://example.com", "", None, ["ops"]] +) +def test_recipient_target_is_a_destination_key( + notification: SimpleNamespace, + target: object, +) -> None: + entry = notification.recipient + entry.recipient_config_json = json.dumps({"target": target}) + with pytest.raises(NotificationParamException): + teams.TeamsNotification(entry, notification.content).send() + notification.send.assert_not_called() + + +@pytest.mark.parametrize( + "raw", + [ + "{", + "[]", + "{}", + '{"target":"ops","credential":"secret"}', + '{"target":"ops","channel_id":"override"}', + ], +) +def test_invalid_recipient_json_is_rejected( + notification: SimpleNamespace, raw: str +) -> None: + entry = notification.recipient + entry.recipient_config_json = raw + with pytest.raises(NotificationParamException): + teams.TeamsNotification(entry, notification.content).send() + notification.send.assert_not_called() + + +@pytest.mark.parametrize("report_format", ["PNG", "PDF", "CSV", "XLSX", "TEXT"]) +def test_delivery_revalidates_format_even_without_generated_attachments( + notification: SimpleNamespace, + report_format: str, +) -> None: + notification.content.header_data["notification_format"] = report_format + assert not notification.content.has_attachments + with pytest.raises(NotificationParamException, match="disabled attachments"): + teams.TeamsNotification(notification.recipient, notification.content).send() + notification.send.assert_not_called() From 4c6c8e2fc736469336b8f287ab9f9c228a06deef Mon Sep 17 00:00:00 2001 From: Kristi Dodaj Date: Mon, 19 Oct 2026 22:09:34 -0400 Subject: [PATCH 14/30] test(reports): consolidate Teams test fixtures and coverage --- .../unit_tests/integrations/teams/api_test.py | 131 +++++------------- .../integrations/teams/config_test.py | 22 +-- .../unit_tests/integrations/teams/conftest.py | 56 ++++++++ .../integrations/teams/delivery_test.py | 78 ++--------- .../integrations/teams/destinations_test.py | 30 ++-- .../integrations/teams/store_test.py | 19 +-- .../integrations/teams/validation_test.py | 18 +-- .../reports/notifications/teams_tests.py | 2 + 8 files changed, 134 insertions(+), 222 deletions(-) create mode 100644 tests/unit_tests/integrations/teams/conftest.py diff --git a/tests/unit_tests/integrations/teams/api_test.py b/tests/unit_tests/integrations/teams/api_test.py index bfe66ed5beb5..b5155cc40d35 100644 --- a/tests/unit_tests/integrations/teams/api_test.py +++ b/tests/unit_tests/integrations/teams/api_test.py @@ -21,7 +21,7 @@ import asyncio import importlib import importlib.util -from collections.abc import Callable, Iterator +from collections.abc import Callable from copy import deepcopy from datetime import datetime, timedelta, timezone from types import SimpleNamespace @@ -33,7 +33,6 @@ from cryptography.hazmat.primitives.asymmetric import rsa from flask import current_app, Flask, Response from flask.testing import FlaskClient -from sqlalchemy import create_engine from sqlalchemy.engine import Engine from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session @@ -100,24 +99,6 @@ def sign( return sign -@pytest.fixture -def teams_config() -> dict[str, Any]: - """Configure one tenant, application and explicitly approved destination.""" - return { - "tenant_id": TENANT, - "application_id": APPLICATION, - "credential": "deployment-secret", - "destinations": [ - { - "key": "ops", - "label": "Operations", - "team_id": "team-1", - "channel_id": "channel-1", - } - ], - } - - @pytest.fixture def installation() -> dict[str, Any]: """An installationUpdate event from a standard team channel.""" @@ -142,15 +123,6 @@ def installation() -> dict[str, Any]: } -@pytest.fixture -def teams_engine() -> Iterator[Engine]: - """Create only the installation model's real database table.""" - engine = create_engine("sqlite://") - TeamsInstallation.__table__.create(engine) - yield engine - engine.dispose() - - @pytest.fixture def teams_client( teams_config: dict[str, Any], @@ -579,7 +551,29 @@ async def stop(adapter: Any) -> None: assert loops[0].is_closed() +def post_sized_event( + client: FlaskClient, + installation: dict[str, Any], + size: int, + content_length: bool, + **kwargs: Any, +) -> Response: + """Send padded JSON through either sized or terminated-stream WSGI input.""" + body = json.dumps(installation).encode() + return client.post( + PATH, + data=body + b" " * (size - len(body)), + environ_overrides=( + {} + if content_length + else {"CONTENT_LENGTH": "", "wsgi.input_terminated": True} + ), + **kwargs, + ) + + @pytest.mark.parametrize("content_length", [True, False]) +@pytest.mark.parametrize("limit", [None, 1024]) @pytest.mark.parametrize("extra_bytes", [1, 4096]) @pytest.mark.parametrize("content_type", ["application/json", "text/plain"]) def test_oversized_event_is_rejected_before_json_sdk_or_database( @@ -588,31 +582,24 @@ def test_oversized_event_is_rejected_before_json_sdk_or_database( content_length: bool, extra_bytes: int, content_type: str, + limit: int | None, ) -> None: """Even valid JSON followed by excess whitespace must not reach the parser.""" - limit = 256 * 1024 - body = json.dumps(installation).encode() - body += b" " * (limit + extra_bytes - len(body)) - environ = ( - {} - if content_length - else { - "CONTENT_LENGTH": "", - "wsgi.input_terminated": True, - } - ) + teams_client.application.config["MAX_CONTENT_LENGTH"] = limit with ( patch.object(teams_client.application.request_class, "get_json") as parse, patch.object(teams_adapter, "process_teams_event") as sdk, patch.object(teams_api, "Session") as session, ): - response = teams_client.post( - PATH, - data=body, + response = post_sized_event( + teams_client, + installation, + (limit or 256 * 1024) + extra_bytes, + content_length, content_type=content_type, - environ_overrides=environ, ) assert response.status_code == 413 + assert teams_client.application.config["MAX_CONTENT_LENGTH"] == limit parse.assert_not_called() sdk.assert_not_called() session.assert_not_called() @@ -629,23 +616,13 @@ def test_valid_event_within_body_limit_still_authenticates_and_persists( size: int, ) -> None: """Accept normal and boundary-size JSON bodies, including terminated streams.""" - body = json.dumps(installation).encode() - body += b" " * (size - len(body)) - assert len(body) == size - environ = ( - {} - if content_length - else { - "CONTENT_LENGTH": "", - "wsgi.input_terminated": True, - } - ) - response = teams_client.post( - PATH, - data=body, + response = post_sized_event( + teams_client, + installation, + size, + content_length, content_type="application/json", headers=auth_headers(), - environ_overrides=environ, ) assert response.status_code == 200 with Session(teams_engine) as session: @@ -654,42 +631,6 @@ def test_valid_event_within_body_limit_still_authenticates_and_persists( assert installation_record.active -@pytest.mark.parametrize("content_length", [True, False]) -def test_callback_respects_stricter_deployment_body_limit( - teams_client: FlaskClient, - installation: dict[str, Any], - content_length: bool, -) -> None: - """The endpoint cap must not relax a smaller operator-configured limit.""" - teams_client.application.config["MAX_CONTENT_LENGTH"] = 1024 - body = json.dumps(installation).encode() - body += b" " * (1025 - len(body)) - environ = ( - {} - if content_length - else { - "CONTENT_LENGTH": "", - "wsgi.input_terminated": True, - } - ) - with ( - patch.object(teams_client.application.request_class, "get_json") as parse, - patch.object(teams_adapter, "process_teams_event") as sdk, - patch.object(teams_api, "Session") as session, - ): - response = teams_client.post( - PATH, - data=body, - content_type="application/json", - environ_overrides=environ, - ) - assert response.status_code == 413 - assert teams_client.application.config["MAX_CONTENT_LENGTH"] == 1024 - parse.assert_not_called() - sdk.assert_not_called() - session.assert_not_called() - - def test_body_limit_applies_only_to_teams_callback(teams_client: FlaskClient) -> None: """A separate route retains the deployment's existing request-size handling.""" app = teams_client.application diff --git a/tests/unit_tests/integrations/teams/config_test.py b/tests/unit_tests/integrations/teams/config_test.py index 013f52a3d96b..a6198c78db6e 100644 --- a/tests/unit_tests/integrations/teams/config_test.py +++ b/tests/unit_tests/integrations/teams/config_test.py @@ -25,21 +25,13 @@ @pytest.fixture -def deployment() -> dict[str, Any]: - """Return a single-tenant configuration with an approved channel.""" - return { - "tenant_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "application_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", - "credential": "test-secret-never-return", - "destinations": [ - { - "key": "operations", - "label": "Operations alerts", - "team_id": "team-1", - "channel_id": "19:channel@thread.tacv2", - }, - ], - } +def deployment(teams_config: dict[str, Any]) -> dict[str, Any]: + teams_config["destinations"][0].update( + key="operations", + label="Operations alerts", + channel_id="19:channel@thread.tacv2", + ) + return teams_config def test_unconfigured() -> None: diff --git a/tests/unit_tests/integrations/teams/conftest.py b/tests/unit_tests/integrations/teams/conftest.py new file mode 100644 index 000000000000..fd3ec2130bc0 --- /dev/null +++ b/tests/unit_tests/integrations/teams/conftest.py @@ -0,0 +1,56 @@ +# 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. +"""Shared deployment and isolated installation database for Teams tests.""" + +from collections.abc import Iterator +from typing import Any + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.engine import Engine + +from superset.integrations.teams.models import TeamsInstallation + +TENANT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" +APPLICATION = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" + + +@pytest.fixture +def teams_config() -> dict[str, Any]: + """Configure one tenant, application and explicitly approved destination.""" + return { + "tenant_id": TENANT, + "application_id": APPLICATION, + "credential": "deployment-secret", + "destinations": [ + { + "key": "ops", + "label": "Operations", + "team_id": "team-1", + "channel_id": "channel-1", + } + ], + } + + +@pytest.fixture +def teams_engine() -> Iterator[Engine]: + """Create only the installation model's real database table.""" + engine = create_engine("sqlite://") + TeamsInstallation.__table__.create(engine) + yield engine + engine.dispose() diff --git a/tests/unit_tests/integrations/teams/delivery_test.py b/tests/unit_tests/integrations/teams/delivery_test.py index 673d13221245..01582b16cfc8 100644 --- a/tests/unit_tests/integrations/teams/delivery_test.py +++ b/tests/unit_tests/integrations/teams/delivery_test.py @@ -36,7 +36,7 @@ import requests from billiard.exceptions import SoftTimeLimitExceeded from flask import current_app, Flask -from sqlalchemy import create_engine +from sqlalchemy.engine import Engine from sqlalchemy.orm import Session from superset.integrations.teams import validation as teams_validation @@ -67,27 +67,9 @@ @pytest.fixture -def deployment() -> dict[str, Any]: - """Return the server-only identity and destination allowlist.""" - return { - "tenant_id": TENANT, - "application_id": APPLICATION, - "credential": "server-only-secret", - "destinations": [ - { - "key": "ops", - "label": "Operations", - "team_id": "team-1", - "channel_id": "channel-1", - } - ], - } - - -@pytest.fixture -def configured(deployment: dict[str, Any]) -> TeamsConfig: +def configured(teams_config: dict[str, Any]) -> TeamsConfig: """Parse the same deployment configuration used by real notifications.""" - config = parse_teams_config(deployment) + config = parse_teams_config(teams_config) assert config is not None return config @@ -114,16 +96,16 @@ def content() -> NotificationContent: @pytest.fixture def notification_context( - deployment: dict[str, Any], + teams_config: dict[str, Any], + teams_engine: Engine, monkeypatch: pytest.MonkeyPatch, ) -> Iterator[SimpleNamespace]: """Use the real installation table and resolve through the notification class.""" app = Flask(__name__) app.config.update( - ALERT_REPORTS_TEAMS=deployment, FEATURE_FLAGS={"ALERT_REPORT_TEAMS": True} + ALERT_REPORTS_TEAMS=teams_config, FEATURE_FLAGS={"ALERT_REPORT_TEAMS": True} ) - engine = create_engine("sqlite://") - TeamsInstallation.__table__.create(engine) + engine = teams_engine with Session(engine) as session: TeamsInstallationStore(session).install( TENANT, APPLICATION, "team-1", SERVICE_URL @@ -137,11 +119,8 @@ def notification_context( ) send = MagicMock() monkeypatch.setattr(notifications, "_send_to_teams", send) - try: - with app.app_context(): - yield SimpleNamespace(app=app, engine=engine, send=send) - finally: - engine.dispose() + with app.app_context(): + yield SimpleNamespace(app=app, engine=engine, send=send) def recipient(target: str = "ops") -> ReportRecipients: @@ -160,14 +139,14 @@ def test_factory_resolves_active_installation_and_formats_card( assert isinstance(notification, notifications.TeamsNotification) notification.send() config, destination, service_url, card = notification_context.send.call_args.args - assert config.credential == "server-only-secret" + assert config.credential == "deployment-secret" assert destination.key == "ops" assert destination.channel_id == "channel-1" assert service_url == SERVICE_URL assert card["body"][0]["text"] == content.name assert card["body"][1]["text"] == content.description assert card["actions"][0]["url"] == content.url - assert "server-only-secret" not in json.dumps(card) + assert "deployment-secret" not in json.dumps(card) @pytest.mark.parametrize( @@ -561,7 +540,7 @@ def test_real_sdk_creates_fresh_channel_root_for_every_notification( payload["activity"]["attachments"][0]["content"]["body"][0]["text"] == content.name ) - assert "server-only-secret" not in request.content.decode() + assert "deployment-secret" not in request.content.decode() assert_resources_closed(sdk_network) @@ -608,7 +587,7 @@ def test_real_sdk_failures_close_resources_and_sanitize_errors( ) assert exc.value.category == category assert "secret SQL diagnostic" not in str(exc.value) - assert "server-only-secret" not in str(exc.value) + assert "deployment-secret" not in str(exc.value) assert len(sdk_network.posts) == posts assert_resources_closed(sdk_network) @@ -630,37 +609,6 @@ def test_auth_http_closes_response_and_preserves_retry_delay( assert session.request.call_args.kwargs["timeout"] == 10 -@pytest.mark.parametrize( - "mode", ["removed-destination", "disabled", "report", "attachments"] -) -def test_saved_notification_rechecks_changes_before_touching_installations( - notification_context: SimpleNamespace, - content: NotificationContent, - monkeypatch: pytest.MonkeyPatch, - mode: str, -) -> None: - entry = recipient() - saved_json = entry.recipient_config_json - notification = notifications.TeamsNotification(entry, content) - if mode == "removed-destination": - notification_context.app.config["ALERT_REPORTS_TEAMS"]["destinations"][0][ - "key" - ] = "changed" - elif mode == "disabled": - notification_context.app.config["FEATURE_FLAGS"]["ALERT_REPORT_TEAMS"] = False - elif mode == "report": - content.header_data["notification_type"] = "Report" - else: - content.header_data["notification_format"] = "PNG" - lookup = MagicMock() - monkeypatch.setattr(TeamsInstallationStore, "get_active", lookup) - with pytest.raises(NotificationParamException): - notification.send() - lookup.assert_not_called() - notification_context.send.assert_not_called() - assert entry.recipient_config_json == saved_json - - @pytest.mark.parametrize("phase", ["metadata", "token"]) def test_real_msal_requests_timeout_and_close_resources( configured: TeamsConfig, diff --git a/tests/unit_tests/integrations/teams/destinations_test.py b/tests/unit_tests/integrations/teams/destinations_test.py index 2a7c335c42ad..88f5d9cf5b86 100644 --- a/tests/unit_tests/integrations/teams/destinations_test.py +++ b/tests/unit_tests/integrations/teams/destinations_test.py @@ -22,11 +22,11 @@ import pytest from flask import current_app, Flask -from sqlalchemy import create_engine, event +from sqlalchemy import event +from sqlalchemy.engine import Engine from sqlalchemy.orm import Session from superset.integrations.teams import destinations -from superset.integrations.teams.models import TeamsInstallation from superset.integrations.teams.store import TeamsInstallationStore TENANT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" @@ -35,19 +35,12 @@ @pytest.fixture -def configured_app(monkeypatch: pytest.MonkeyPatch) -> Iterator[Flask]: +def configured_app( + teams_config: dict[str, Any], teams_engine: Engine, monkeypatch: pytest.MonkeyPatch +) -> Iterator[Flask]: app = Flask(__name__) - app.config["ALERT_REPORTS_TEAMS"] = { - "tenant_id": TENANT, - "application_id": APPLICATION, - "credential": "never-return-this-credential", - "destinations": [ - { - "key": "ops", - "label": "Operations", - "team_id": "team-1", - "channel_id": "channel-1", - }, + teams_config["destinations"].extend( + [ { "key": "finance", "label": "Finance", @@ -60,18 +53,17 @@ def configured_app(monkeypatch: pytest.MonkeyPatch) -> Iterator[Flask]: "team_id": "team-2", "channel_id": "channel-3", }, - ], - } + ] + ) + app.config["ALERT_REPORTS_TEAMS"] = teams_config monkeypatch.setattr( destinations.feature_flag_manager, "is_feature_enabled", lambda name: True ) monkeypatch.setattr(destinations, "is_teams_sdk_installed", lambda: True) - engine = create_engine("sqlite://") - TeamsInstallation.__table__.create(engine) + engine = teams_engine monkeypatch.setattr(destinations, "db", MagicMock(engine=engine)) with app.app_context(): yield app - engine.dispose() def test_only_approved_public_fields_and_missing_installations( diff --git a/tests/unit_tests/integrations/teams/store_test.py b/tests/unit_tests/integrations/teams/store_test.py index 37996b718523..b6ffe5132d02 100644 --- a/tests/unit_tests/integrations/teams/store_test.py +++ b/tests/unit_tests/integrations/teams/store_test.py @@ -233,20 +233,11 @@ def test_rejects_invalid_service_url(session: Session, url: str) -> None: assert session.query(TeamsInstallation).count() == 0 -def test_installation_does_not_authorize_destinations(session: Session) -> None: - raw_config = { - "tenant_id": TENANT, - "application_id": APPLICATION, - "credential": "test-secret", - "destinations": [ - { - "key": "ops", - "label": "Operations", - "team_id": "approved", - "channel_id": "channel-1", - } - ], - } +def test_installation_does_not_authorize_destinations( + session: Session, teams_config: dict[str, Any] +) -> None: + raw_config = teams_config + raw_config["destinations"][0]["team_id"] = "approved" before = deepcopy(raw_config) config = parse_teams_config(raw_config) assert config is not None diff --git a/tests/unit_tests/integrations/teams/validation_test.py b/tests/unit_tests/integrations/teams/validation_test.py index 85c8cf856797..6c1c367d7131 100644 --- a/tests/unit_tests/integrations/teams/validation_test.py +++ b/tests/unit_tests/integrations/teams/validation_test.py @@ -47,24 +47,14 @@ @pytest.fixture -def configured_app(monkeypatch: pytest.MonkeyPatch) -> Iterator[Flask]: +def configured_app( + teams_config: dict[str, Any], monkeypatch: pytest.MonkeyPatch +) -> Iterator[Flask]: """Enable a single approved destination without requiring any installation.""" app = Flask(__name__) app.config.update( FEATURE_FLAGS={"ALERT_REPORT_TEAMS": True}, - ALERT_REPORTS_TEAMS={ - "tenant_id": TENANT, - "application_id": APPLICATION, - "credential": "private-credential", - "destinations": [ - { - "key": "ops", - "label": "Operations", - "team_id": "team-1", - "channel_id": "channel-1", - } - ], - }, + ALERT_REPORTS_TEAMS=teams_config, ) monkeypatch.setattr( validation.feature_flag_manager, diff --git a/tests/unit_tests/reports/notifications/teams_tests.py b/tests/unit_tests/reports/notifications/teams_tests.py index bab227ebe71f..516b52fbd5be 100644 --- a/tests/unit_tests/reports/notifications/teams_tests.py +++ b/tests/unit_tests/reports/notifications/teams_tests.py @@ -150,6 +150,7 @@ def test_saved_recipient_is_revalidated_at_delivery( notification: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, change: str ) -> None: """Previously saved recipients cannot bypass deployment or schedule changes.""" + saved_json = notification.recipient.recipient_config_json handler = teams.TeamsNotification(notification.recipient, notification.content) if change == "disabled": monkeypatch.setattr( @@ -169,6 +170,7 @@ def test_saved_recipient_is_revalidated_at_delivery( handler.send() notification.store.assert_not_called() notification.send.assert_not_called() + assert notification.recipient.recipient_config_json == saved_json def test_missing_active_installation_prevents_transport( From c0d82bf22430f941366c3fdc5eb0898a03f21db5 Mon Sep 17 00:00:00 2001 From: Kristi Dodaj Date: Wed, 21 Oct 2026 21:24:33 -0400 Subject: [PATCH 15/30] docs(reports): fix Teams configuration guide link --- docs/developer_docs/contributing/howtos.md | 23 ++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/developer_docs/contributing/howtos.md b/docs/developer_docs/contributing/howtos.md index 65803d04375a..5022b90a148a 100644 --- a/docs/developer_docs/contributing/howtos.md +++ b/docs/developer_docs/contributing/howtos.md @@ -211,6 +211,29 @@ npm run playwright:debug tests/auth/login.spec.ts npm run playwright:report ``` +### Testing Microsoft Teams notifications + +For deployment setup, see the [Microsoft Teams configuration guide](/admin-docs/configuration/alerts-reports#microsoft-teams-configuration). + +Backend tests under `tests/unit_tests/integrations/teams/` exercise the real SDK +with fake HTTP transports. Install the `teams` extra to run authentication and +transport tests rather than skipping them. Notification contract tests at +`tests/unit_tests/reports/notifications/teams_tests.py` run without the SDK. + +The browser regression at `playwright/tests/reports/teams-alerts.spec.ts` requires a +running Superset instance, an admin test user, Reports and Teams enabled, at least +one configured destination, and a selectable test database. An unconfigured +integration skips this optional browser test. Use an isolated test deployment +with placeholder UUIDs and a dummy secret; no Microsoft app or installation is +needed for this test. It mocks destination availability, creates only inactive +alerts, blocks execution requests, verifies saved recipient keys, and deletes its +schedule afterward. It never clicks **Trigger now**. Run it from +`superset-frontend`: + +```bash +npx playwright test playwright/tests/reports/teams-alerts.spec.ts --project=chromium +``` + ### Debugging Server App For debugging the Flask backend: From b32f64cd78894b33ff06cbcbdf505033b9270d9f Mon Sep 17 00:00:00 2001 From: Kristi Dodaj Date: Fri, 23 Oct 2026 12:00:30 -0400 Subject: [PATCH 16/30] fix(reports): handle Teams alert formats and stale installation events --- .../configuration/alerts-reports.mdx | 3 + .../features/alerts/AlertReportModal.test.tsx | 87 +++++++++ .../src/features/alerts/AlertReportModal.tsx | 9 +- superset/integrations/teams/adapter.py | 16 +- superset/integrations/teams/api.py | 9 +- superset/integrations/teams/models.py | 1 + superset/integrations/teams/store.py | 78 +++++--- ...00_7b9d2f8a6c31_add_teams_installations.py | 1 + .../unit_tests/integrations/teams/api_test.py | 42 ++++- .../integrations/teams/delivery_test.py | 7 +- .../integrations/teams/destinations_test.py | 28 ++- .../integrations/teams/store_test.py | 178 ++++++++++++++---- 12 files changed, 386 insertions(+), 73 deletions(-) diff --git a/docs/admin_docs/configuration/alerts-reports.mdx b/docs/admin_docs/configuration/alerts-reports.mdx index b277b892fc4c..1476f0085b11 100644 --- a/docs/admin_docs/configuration/alerts-reports.mdx +++ b/docs/admin_docs/configuration/alerts-reports.mdx @@ -1040,6 +1040,9 @@ approve any destinations. Configure the allowlist separately. Uninstalling marks the installation inactive; reinstalling refreshes its verified service URL and reactivates the same record. If an initial installation happened before callback routing was ready, remove and reinstall the app to deliver a fresh event. +Lifecycle events require a timezone-qualified source timestamp. Older events are +acknowledged without changing installation state, and removal wins timestamp ties. +Removal records are retained even when the installation event has not arrived. ### Route the callback diff --git a/superset-frontend/src/features/alerts/AlertReportModal.test.tsx b/superset-frontend/src/features/alerts/AlertReportModal.test.tsx index c501120abaa9..dfcee11dd9a0 100644 --- a/superset-frontend/src/features/alerts/AlertReportModal.test.tsx +++ b/superset-frontend/src/features/alerts/AlertReportModal.test.tsx @@ -4923,3 +4923,90 @@ test('Teams saved recipients stay visible and replaceable when the optional SDK fetchMock.removeRoute('teams-sdk-missing'); } }); + +test.each(['PNG', 'CSV'])( + 'Teams edits normalize saved %s format when attachments are globally disabled', + async reportFormat => { + mockNotificationMethods = ['Email', 'Teams']; + fetchMock.removeRoute(configurationEndpoint); + fetchMock.get( + configurationEndpoint, + { + result: { ...mockReportConfiguration, alerts_attach_reports: false }, + }, + { name: configurationEndpoint }, + ); + fetchMock.get('glob:*/api/v1/report/96', { + result: { + ...generateMockPayload(true), + id: 96, + report_format: reportFormat, + recipients: [ + { + id: 1, + type: 'Email', + recipient_config_json: { target: 'test@example.com' }, + }, + ], + }, + }); + fetchMock.get( + 'glob:*/api/v1/report/teams_destinations/', + { + result: [{ key: 'ops', label: 'Operations', available: true }], + }, + { name: 'teams-hidden-attachments' }, + ); + fetchMock.put( + 'glob:*/api/v1/report/96', + { id: 96, result: {} }, + { name: 'save-teams-hidden' }, + ); + try { + render( + , + { + useRedux: true, + initialState: { user: adminUser }, + }, + ); + await userEvent.click(screen.getByTestId('notification-method-panel')); + await userEvent.click( + screen.getByRole('combobox', { name: /^Delivery method/ }), + ); + await userEvent.click(await screen.findByText('Teams')); + await userEvent.click( + screen.getByRole('combobox', { name: 'Teams destination' }), + ); + await userEvent.click(await screen.findByText('Operations')); + await waitFor(() => + expect(screen.getByRole('button', { name: /save/i })).toBeEnabled(), + ); + await userEvent.click(screen.getByRole('button', { name: /save/i })); + await waitFor(() => + expect(fetchMock.callHistory.calls('save-teams-hidden')).toHaveLength( + 1, + ), + ); + const [call] = fetchMock.callHistory.calls('save-teams-hidden'); + expect(JSON.parse(call.options.body as string)).toMatchObject({ + report_format: 'NONE', + recipients: [ + { type: 'Teams', recipient_config_json: { target: 'ops' } }, + ], + }); + } finally { + fetchMock.removeRoute('teams-hidden-attachments'); + fetchMock.removeRoute('save-teams-hidden'); + fetchMock.removeRoute(configurationEndpoint); + fetchMock.get( + configurationEndpoint, + { result: mockReportConfiguration }, + { name: configurationEndpoint }, + ); + } + }, +); diff --git a/superset-frontend/src/features/alerts/AlertReportModal.tsx b/superset-frontend/src/features/alerts/AlertReportModal.tsx index 96290b262871..8b4ca6d8901d 100644 --- a/superset-frontend/src/features/alerts/AlertReportModal.tsx +++ b/superset-frontend/src/features/alerts/AlertReportModal.tsx @@ -1152,7 +1152,14 @@ const AlertReportModal: FunctionComponent = ({ extra: contentType === ContentType.Dashboard ? currentAlert?.extra : {}, }; - if (!isReport && !attachmentControlsVisible && !isEditMode) { + if ( + !isReport && + !attachmentControlsVisible && + (!isEditMode || + notificationSettings.some( + setting => setting.method === NotificationMethodOption.Teams, + )) + ) { data.report_format = 'NONE'; } if (!attachmentControlsVisible && isEditMode) { diff --git a/superset/integrations/teams/adapter.py b/superset/integrations/teams/adapter.py index 499ec38f12b4..12802cba55de 100644 --- a/superset/integrations/teams/adapter.py +++ b/superset/integrations/teams/adapter.py @@ -62,6 +62,7 @@ class TeamsInstallationEvent: team_id: str service_url: str active: bool + event_at: datetime @dataclass(frozen=True) @@ -134,8 +135,6 @@ def _installation_event( "remove", ): return TeamsEventResult(400) - if body.get("attachments"): - return TeamsEventResult(400) # Require the wire-format fields used by SDK authentication. Do not allow # model defaults or snake_case aliases to bypass missing channel/service data. if body.get("channelId") != "msteams" or not isinstance( @@ -174,8 +173,19 @@ def _installation_event( approved_teams = {destination.team_id for destination in config.destinations} if data.team.id not in approved_teams: return TeamsEventResult(403) + event_at = activity.timestamp + if ( + body.get("attachments") + or event_at is None + or event_at.tzinfo is None + or event_at.utcoffset() is None + ): + return TeamsEventResult(400) return TeamsEventResult( - 200, TeamsInstallationEvent(data.team.id, service_url, body["action"] == "add") + 200, + TeamsInstallationEvent( + data.team.id, service_url, body["action"] == "add", event_at + ), ) diff --git a/superset/integrations/teams/api.py b/superset/integrations/teams/api.py index 195027dfc036..30c1ba9a50c4 100644 --- a/superset/integrations/teams/api.py +++ b/superset/integrations/teams/api.py @@ -111,9 +111,16 @@ def _persist_event(config: TeamsConfig, event: TeamsInstallationEvent) -> None: config.application_id, event.team_id, event.service_url, + event.event_at, ) else: - store.remove(config.tenant_id, config.application_id, event.team_id) + store.remove( + config.tenant_id, + config.application_id, + event.team_id, + event.service_url, + event.event_at, + ) def register_teams_api(app: Flask) -> None: diff --git a/superset/integrations/teams/models.py b/superset/integrations/teams/models.py index 3751b83b2b03..c95fafe38c54 100644 --- a/superset/integrations/teams/models.py +++ b/superset/integrations/teams/models.py @@ -49,5 +49,6 @@ class TeamsInstallation(Model): active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) # Naive UTC, matching Superset metadata timestamps. # changed_on records the last event processing time. + event_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) created_on: Mapped[datetime] = mapped_column(DateTime, nullable=False) changed_on: Mapped[datetime] = mapped_column(DateTime, nullable=False) diff --git a/superset/integrations/teams/store.py b/superset/integrations/teams/store.py index f1fedc7b11bd..e80c83a38ea5 100644 --- a/superset/integrations/teams/store.py +++ b/superset/integrations/teams/store.py @@ -72,7 +72,7 @@ class TeamsInstallationStore: Callers must authenticate events, enforce the deployment tenant/application, and verify service URLs before invoking this store. This layer stores no credentials or channels and never makes a team selectable on its own. - Events are applied in processing order; duplicate installs/removals are safe. + Events are ordered by their source timestamp, with removal winning ties. """ def __init__(self, session: Session) -> None: @@ -99,23 +99,54 @@ def install( application_id: str, team_id: str, verified_service_url: str, + event_at: datetime, ) -> TeamsInstallation: - """Create, refresh, or reactivate an installation, including insert races.""" + """Apply an installation only if it is newer than the stored event.""" + return self._apply( + tenant_id, application_id, team_id, verified_service_url, event_at, True + ) + + def remove( + self, + tenant_id: str, + application_id: str, + team_id: str, + verified_service_url: str, + event_at: datetime, + ) -> None: + """Retain a removal tombstone, including when the install has not arrived.""" + self._apply( + tenant_id, application_id, team_id, verified_service_url, event_at, False + ) + + def _apply( + self, + tenant_id: str, + application_id: str, + team_id: str, + verified_service_url: str, + event_at: datetime, + active: bool, + ) -> TeamsInstallation: + """Serialize writes per identity and ignore stale or duplicate events.""" tenant, application, team = _identity(tenant_id, application_id, team_id) service_url = _service_url(verified_service_url) + if event_at.tzinfo is None or event_at.utcoffset() is None: + raise ValueError("Teams event timestamp must include a timezone") + event_at = event_at.astimezone(timezone.utc).replace(tzinfo=None) now = datetime.now(timezone.utc).replace(tzinfo=None) table = TeamsInstallation.__table__ - update = ( + identity = ( table.update() .where(table.c.tenant_id == tenant) .where(table.c.application_id == application) .where(table.c.team_id == team) - .values(service_url=service_url, active=True, changed_on=now) ) - # Flush unrelated pending work outside the savepoint and exception handler. + # Lock through a write so repeatable-read databases also see insert races. + lock = identity.values(team_id=team) self.session.flush() connection = self.session.connection() - if not connection.execute(update).rowcount: + if not connection.execute(lock).rowcount: try: with connection.begin_nested(): connection.execute( @@ -124,36 +155,29 @@ def install( application_id=application, team_id=team, service_url=service_url, - active=True, + active=active, + event_at=event_at, created_on=now, changed_on=now, ) ) except IntegrityError: - # A concurrent event may have inserted this identity. Use a write - # to see that row even on repeatable-read databases. Do not hide - # unrelated integrity failures if no matching identity exists. - if not connection.execute(update).rowcount: + if not connection.execute(lock).rowcount: raise + newer = table.c.event_at < event_at + if not active: + newer |= (table.c.event_at == event_at) & table.c.active + connection.execute( + identity.where(newer).values( + service_url=service_url, + active=active, + event_at=event_at, + changed_on=now, + ) + ) return ( self.session.query(TeamsInstallation) .populate_existing() .filter_by(tenant_id=tenant, application_id=application, team_id=team) .one() ) - - def remove(self, tenant_id: str, application_id: str, team_id: str) -> None: - """Deactivate an existing row; repeated or unknown removals are harmless.""" - tenant, application, team = _identity(tenant_id, application_id, team_id) - self.session.flush() - ( - self.session.query(TeamsInstallation) - .filter_by(tenant_id=tenant, application_id=application, team_id=team) - .update( - { - "active": False, - "changed_on": datetime.now(timezone.utc).replace(tzinfo=None), - }, - synchronize_session="fetch", - ) - ) diff --git a/superset/migrations/versions/2026-10-19_00-00_7b9d2f8a6c31_add_teams_installations.py b/superset/migrations/versions/2026-10-19_00-00_7b9d2f8a6c31_add_teams_installations.py index 99bf955ce2e3..d12fd2fe5849 100644 --- a/superset/migrations/versions/2026-10-19_00-00_7b9d2f8a6c31_add_teams_installations.py +++ b/superset/migrations/versions/2026-10-19_00-00_7b9d2f8a6c31_add_teams_installations.py @@ -38,6 +38,7 @@ def upgrade() -> None: sa.Column("team_id", sa.String(255), nullable=False), sa.Column("service_url", sa.String(2048), nullable=False), sa.Column("active", sa.Boolean(), nullable=False), + sa.Column("event_at", sa.DateTime(), nullable=False), sa.Column("created_on", sa.DateTime(), nullable=False), sa.Column("changed_on", sa.DateTime(), nullable=False), sa.PrimaryKeyConstraint("id"), diff --git a/tests/unit_tests/integrations/teams/api_test.py b/tests/unit_tests/integrations/teams/api_test.py index b5155cc40d35..37a40ec0e36c 100644 --- a/tests/unit_tests/integrations/teams/api_test.py +++ b/tests/unit_tests/integrations/teams/api_test.py @@ -106,6 +106,7 @@ def installation() -> dict[str, Any]: "type": "installationUpdate", "action": "add", "id": "event-1", + "timestamp": "2026-10-01T12:00:00Z", "serviceUrl": SERVICE_URL, "channelId": "msteams", "from": {"id": "29:installer"}, @@ -331,6 +332,7 @@ def test_repeated_events_removal_and_reinstallation( assert removed.id == identity assert not removed.active installation["action"] = "add" + installation["timestamp"] = "2026-10-01T12:01:00Z" refreshed = "https://smba.trafficmanager.net/emea/" installation["serviceUrl"] = refreshed for _ in range(2): @@ -346,7 +348,7 @@ def test_repeated_events_removal_and_reinstallation( assert reinstalled.service_url == refreshed -def test_removal_does_not_create_unknown_installation( +def test_removal_retains_unknown_installation_tombstone( teams_client: FlaskClient, teams_engine: Engine, installation: dict[str, Any], @@ -358,7 +360,7 @@ def test_removal_does_not_create_unknown_installation( == 200 ) with Session(teams_engine) as session: - assert session.query(TeamsInstallation).count() == 0 + assert not session.query(TeamsInstallation).one().active @pytest.mark.parametrize( @@ -651,3 +653,39 @@ def other_body() -> Response: assert response.status_code == 200 assert response.data == str(len(body)).encode() assert app.config["MAX_CONTENT_LENGTH"] is None + + +@pytest.mark.parametrize( + "timestamp", [None, "missing", "invalid", "2026-10-01T12:00:00"] +) +def test_callback_requires_source_timestamp( + teams_client: FlaskClient, + teams_engine: Engine, + installation: dict[str, Any], + auth_headers: Callable[..., dict[str, str]], + timestamp: str | None, +) -> None: + if timestamp == "missing": + installation.pop("timestamp") + else: + installation["timestamp"] = timestamp + assert ( + teams_client.post(PATH, json=installation, headers=auth_headers()).status_code + == 400 + ) + with Session(teams_engine) as session: + assert session.query(TeamsInstallation).count() == 0 + + +def test_delayed_install_callback_cannot_undo_removal( + teams_client: FlaskClient, + teams_engine: Engine, + installation: dict[str, Any], + auth_headers: Callable[..., dict[str, str]], +) -> None: + headers = auth_headers() + removal = {**installation, "action": "remove", "timestamp": "2026-10-01T12:01:00Z"} + for body in (removal, installation, removal, installation): + assert teams_client.post(PATH, json=body, headers=headers).status_code == 200 + with Session(teams_engine) as session: + assert not session.query(TeamsInstallation).one().active diff --git a/tests/unit_tests/integrations/teams/delivery_test.py b/tests/unit_tests/integrations/teams/delivery_test.py index 01582b16cfc8..de730baa3bb3 100644 --- a/tests/unit_tests/integrations/teams/delivery_test.py +++ b/tests/unit_tests/integrations/teams/delivery_test.py @@ -108,7 +108,7 @@ def notification_context( engine = teams_engine with Session(engine) as session: TeamsInstallationStore(session).install( - TENANT, APPLICATION, "team-1", SERVICE_URL + TENANT, APPLICATION, "team-1", SERVICE_URL, datetime.now(timezone.utc) ) session.commit() monkeypatch.setattr(notifications, "db", SimpleNamespace(engine=engine)) @@ -160,7 +160,9 @@ def test_installation_must_be_active_for_exact_identity( with Session(notification_context.engine) as session: store = TeamsInstallationStore(session) if mode == "inactive": - store.remove(TENANT, APPLICATION, "team-1") + store.remove( + TENANT, APPLICATION, "team-1", SERVICE_URL, datetime.now(timezone.utc) + ) else: session.query(TeamsInstallation).delete() other = "cccccccc-cccc-4ccc-8ccc-cccccccccccc" @@ -170,6 +172,7 @@ def test_installation_must_be_active_for_exact_identity( other if mode == "other-application" else APPLICATION, "team-1", SERVICE_URL, + datetime.now(timezone.utc), ) session.commit() with pytest.raises(NotificationAuthorizationException): diff --git a/tests/unit_tests/integrations/teams/destinations_test.py b/tests/unit_tests/integrations/teams/destinations_test.py index 88f5d9cf5b86..c37460596f9f 100644 --- a/tests/unit_tests/integrations/teams/destinations_test.py +++ b/tests/unit_tests/integrations/teams/destinations_test.py @@ -17,6 +17,7 @@ """Browser discovery only projects approved keys and active-installation status.""" from collections.abc import Iterator +from datetime import datetime, timezone from typing import Any from unittest.mock import MagicMock @@ -80,25 +81,40 @@ def test_removal_and_reinstallation_update_availability(configured_app: Flask) - with Session(destinations.db.engine) as session: store = TeamsInstallationStore(session) store.install( - TENANT, APPLICATION, "team-1", "https://smba.trafficmanager.net/amer/" + TENANT, + APPLICATION, + "team-1", + "https://smba.trafficmanager.net/amer/", + datetime.now(timezone.utc), ) store.install( TENANT, APPLICATION, "unapproved-team", "https://smba.trafficmanager.net/amer/", + datetime.now(timezone.utc), ) session.commit() assert [ item["available"] for item in destinations.list_teams_destinations() ] == [True, True, False] - store.remove(TENANT, APPLICATION, "team-1") + store.remove( + TENANT, + APPLICATION, + "team-1", + "https://smba.trafficmanager.net/amer/", + datetime.now(timezone.utc), + ) session.commit() assert not any( item["available"] for item in destinations.list_teams_destinations() ) store.install( - TENANT, APPLICATION, "team-1", "https://smba.trafficmanager.net/emea/" + TENANT, + APPLICATION, + "team-1", + "https://smba.trafficmanager.net/emea/", + datetime.now(timezone.utc), ) session.commit() assert [ @@ -113,7 +129,11 @@ def test_other_identity_cannot_make_destination_available( ) -> None: with Session(destinations.db.engine) as session: TeamsInstallationStore(session).install( - tenant, application, "team-1", "https://smba.trafficmanager.net/amer/" + tenant, + application, + "team-1", + "https://smba.trafficmanager.net/amer/", + datetime.now(timezone.utc), ) session.commit() assert not any(item["available"] for item in destinations.list_teams_destinations()) diff --git a/tests/unit_tests/integrations/teams/store_test.py b/tests/unit_tests/integrations/teams/store_test.py index b6ffe5132d02..768d278c630e 100644 --- a/tests/unit_tests/integrations/teams/store_test.py +++ b/tests/unit_tests/integrations/teams/store_test.py @@ -18,7 +18,7 @@ from collections.abc import Iterator from copy import deepcopy -from datetime import datetime +from datetime import datetime, timezone from importlib import import_module from typing import Any from unittest.mock import MagicMock, patch @@ -66,7 +66,9 @@ def session(engine: Engine) -> Iterator[Session]: def test_installation_is_persisted(engine: Engine, session: Session) -> None: store = TeamsInstallationStore(session) - installation = store.install(TENANT, APPLICATION, "team-1", SERVICE_URL) + installation = store.install( + TENANT, APPLICATION, "team-1", SERVICE_URL, datetime.now(timezone.utc) + ) identity = installation.id assert isinstance(identity, UUID) assert installation.tenant_id == UUID(TENANT) @@ -84,10 +86,16 @@ def test_installation_is_persisted(engine: Engine, session: Session) -> None: def test_duplicate_install_refreshes_same_record(session: Session) -> None: store = TeamsInstallationStore(session) - original = store.install(TENANT, APPLICATION, "team-1", SERVICE_URL) + original = store.install( + TENANT, APPLICATION, "team-1", SERVICE_URL, datetime.now(timezone.utc) + ) identity, created_on = original.id, original.created_on - refreshed = store.install(TENANT, APPLICATION, "team-1", REFRESHED_URL) - repeated = store.install(TENANT, APPLICATION, "team-1", REFRESHED_URL) + refreshed = store.install( + TENANT, APPLICATION, "team-1", REFRESHED_URL, datetime.now(timezone.utc) + ) + repeated = store.install( + TENANT, APPLICATION, "team-1", REFRESHED_URL, datetime.now(timezone.utc) + ) assert refreshed is original assert repeated.id == identity assert repeated.created_on == created_on @@ -98,14 +106,18 @@ def test_duplicate_install_refreshes_same_record(session: Session) -> None: def test_remove_and_reinstall_preserves_identity(session: Session) -> None: store = TeamsInstallationStore(session) - original = store.install(TENANT, APPLICATION, "team-1", SERVICE_URL) + original = store.install( + TENANT, APPLICATION, "team-1", SERVICE_URL, datetime.now(timezone.utc) + ) identity, created_on = original.id, original.created_on - store.remove(TENANT, APPLICATION, "team-1") - store.remove(TENANT, APPLICATION, "team-1") + store.remove(TENANT, APPLICATION, "team-1", SERVICE_URL, datetime.now(timezone.utc)) + store.remove(TENANT, APPLICATION, "team-1", SERVICE_URL, datetime.now(timezone.utc)) assert not original.active assert store.get_active(TENANT, APPLICATION, "team-1") is None assert session.query(TeamsInstallation).count() == 1 - reinstalled = store.install(TENANT, APPLICATION, "team-1", REFRESHED_URL) + reinstalled = store.install( + TENANT, APPLICATION, "team-1", REFRESHED_URL, datetime.now(timezone.utc) + ) assert reinstalled.id == identity assert reinstalled.created_on == created_on assert reinstalled.active @@ -113,12 +125,16 @@ def test_remove_and_reinstall_preserves_identity(session: Session) -> None: assert store.get_active(TENANT, APPLICATION, "team-1") is reinstalled -def test_unknown_removal_does_not_create_installation(session: Session) -> None: +def test_unknown_removal_retains_tombstone(session: Session) -> None: store = TeamsInstallationStore(session) - store.remove(TENANT, APPLICATION, "unknown") - store.remove(TENANT, APPLICATION, "unknown") + store.remove( + TENANT, APPLICATION, "unknown", SERVICE_URL, datetime.now(timezone.utc) + ) + store.remove( + TENANT, APPLICATION, "unknown", SERVICE_URL, datetime.now(timezone.utc) + ) assert store.get_active(TENANT, APPLICATION, "unknown") is None - assert session.query(TeamsInstallation).count() == 0 + assert session.query(TeamsInstallation).count() == 1 @pytest.mark.parametrize( @@ -133,11 +149,13 @@ def test_identity_is_scoped_to_all_three_fields( session: Session, identity: tuple[str, str, str] ) -> None: store = TeamsInstallationStore(session) - first = store.install(TENANT, APPLICATION, "team-1", SERVICE_URL) + first = store.install( + TENANT, APPLICATION, "team-1", SERVICE_URL, datetime.now(timezone.utc) + ) assert store.get_active(*identity) is None - second = store.install(*identity, REFRESHED_URL) + second = store.install(*identity, REFRESHED_URL, datetime.now(timezone.utc)) assert second.id != first.id - store.remove(*identity) + store.remove(*identity, SERVICE_URL, datetime.now(timezone.utc)) assert first.active assert not second.active assert session.query(TeamsInstallation).count() == 2 @@ -145,7 +163,9 @@ def test_identity_is_scoped_to_all_three_fields( def test_database_enforces_identity_uniqueness(session: Session) -> None: store = TeamsInstallationStore(session) - original = store.install(TENANT, APPLICATION, "team-1", SERVICE_URL) + original = store.install( + TENANT, APPLICATION, "team-1", SERVICE_URL, datetime.now(timezone.utc) + ) with pytest.raises(IntegrityError), session.begin_nested(): session.execute( TeamsInstallation.__table__.insert().values( @@ -157,6 +177,7 @@ def test_database_enforces_identity_uniqueness(session: Session) -> None: active=True, created_on=original.created_on, changed_on=original.changed_on, + event_at=original.event_at, ) ) assert session.query(TeamsInstallation).count() == 1 @@ -164,7 +185,9 @@ def test_database_enforces_identity_uniqueness(session: Session) -> None: def test_insert_collision_recovers_inside_savepoint(session: Session) -> None: store = TeamsInstallationStore(session) - winner = store.install(TENANT, APPLICATION, "team-1", SERVICE_URL) + winner = store.install( + TENANT, APPLICATION, "team-1", SERVICE_URL, datetime.now(timezone.utc) + ) identity = winner.id connection = session.connection() execute = connection.execute @@ -175,12 +198,16 @@ def test_insert_collision_recovers_inside_savepoint(session: Session) -> None: if mocked.call_count == 1 else execute(statement, *args, **kwargs) ) - refreshed = store.install(TENANT, APPLICATION, "team-1", REFRESHED_URL) + refreshed = store.install( + TENANT, APPLICATION, "team-1", REFRESHED_URL, datetime.now(timezone.utc) + ) assert refreshed.id == identity assert refreshed.service_url == REFRESHED_URL assert session.query(TeamsInstallation).count() == 1 # The insert error did not invalidate the caller's transaction. - assert store.install(TENANT, APPLICATION, "team-2", SERVICE_URL).active + assert store.install( + TENANT, APPLICATION, "team-2", SERVICE_URL, datetime.now(timezone.utc) + ).active session.commit() @@ -197,19 +224,27 @@ def fail_insert(statement: Any, *args: Any, **kwargs: Any) -> Any: with patch.object(connection, "execute", side_effect=fail_insert): with pytest.raises(IntegrityError, match="unrelated constraint"): - store.install(TENANT, APPLICATION, "team-1", SERVICE_URL) + store.install( + TENANT, APPLICATION, "team-1", SERVICE_URL, datetime.now(timezone.utc) + ) assert session.query(TeamsInstallation).count() == 0 - assert store.install(TENANT, APPLICATION, "team-2", SERVICE_URL).active + assert store.install( + TENANT, APPLICATION, "team-2", SERVICE_URL, datetime.now(timezone.utc) + ).active def test_caller_can_roll_back_install_and_remove(session: Session) -> None: store = TeamsInstallationStore(session) - store.install(TENANT, APPLICATION, "team-1", SERVICE_URL) + store.install( + TENANT, APPLICATION, "team-1", SERVICE_URL, datetime.now(timezone.utc) + ) session.rollback() assert session.query(TeamsInstallation).count() == 0 - store.install(TENANT, APPLICATION, "team-1", SERVICE_URL) + store.install( + TENANT, APPLICATION, "team-1", SERVICE_URL, datetime.now(timezone.utc) + ) session.commit() - store.remove(TENANT, APPLICATION, "team-1") + store.remove(TENANT, APPLICATION, "team-1", SERVICE_URL, datetime.now(timezone.utc)) session.rollback() assert store.get_active(TENANT, APPLICATION, "team-1") is not None @@ -229,7 +264,9 @@ def test_caller_can_roll_back_install_and_remove(session: Session) -> None: ) def test_rejects_invalid_service_url(session: Session, url: str) -> None: with pytest.raises(ValueError, match="HTTPS URL"): - TeamsInstallationStore(session).install(TENANT, APPLICATION, "team-1", url) + TeamsInstallationStore(session).install( + TENANT, APPLICATION, "team-1", url, datetime.now(timezone.utc) + ) assert session.query(TeamsInstallation).count() == 0 @@ -242,9 +279,15 @@ def test_installation_does_not_authorize_destinations( config = parse_teams_config(raw_config) assert config is not None store = TeamsInstallationStore(session) - store.install(TENANT, APPLICATION, "unapproved", SERVICE_URL) - store.remove(TENANT, APPLICATION, "unapproved") - store.install(TENANT, APPLICATION, "unapproved", REFRESHED_URL) + store.install( + TENANT, APPLICATION, "unapproved", SERVICE_URL, datetime.now(timezone.utc) + ) + store.remove( + TENANT, APPLICATION, "unapproved", SERVICE_URL, datetime.now(timezone.utc) + ) + store.install( + TENANT, APPLICATION, "unapproved", REFRESHED_URL, datetime.now(timezone.utc) + ) assert raw_config == before assert config.get_destination("ops").team_id == "approved" assert set(TeamsInstallation.__table__.columns.keys()) == { @@ -256,11 +299,14 @@ def test_installation_does_not_authorize_destinations( "active", "created_on", "changed_on", + "event_at", } def test_migration_downgrade_and_reupgrade(engine: Engine, session: Session) -> None: - TeamsInstallationStore(session).install(TENANT, APPLICATION, "team-1", SERVICE_URL) + TeamsInstallationStore(session).install( + TENANT, APPLICATION, "team-1", SERVICE_URL, datetime.now(timezone.utc) + ) session.commit() with engine.begin() as connection: connection.exec_driver_sql("CREATE TABLE teams_migration_sentinel (id INTEGER)") @@ -282,10 +328,76 @@ def test_store_rejects_invalid_team_ids_consistently( ) -> None: """Authenticated event persistence shares the deployment identifier validator.""" store = TeamsInstallationStore(session) - args: tuple[str, ...] = (TENANT, APPLICATION, team_id) - if operation == "install": - args = (*args, SERVICE_URL) + args: tuple[Any, ...] = (TENANT, APPLICATION, team_id) + if operation != "get_active": + args = (*args, SERVICE_URL, datetime.now(timezone.utc)) method = getattr(store, operation) with pytest.raises(ValueError, match="Teams team ID"): method(*args) assert session.query(TeamsInstallation).count() == 0 + + +def test_migration_preserves_bootstrapped_installations( + engine: Engine, session: Session +) -> None: + """Fresh database bootstrap may create the model table before Alembic runs.""" + installation = TeamsInstallationStore(session).install( + TENANT, APPLICATION, "team-1", SERVICE_URL, datetime.now(timezone.utc) + ) + session.commit() + installation_id = installation.id + with engine.begin() as connection: + with Operations.context(MigrationContext.configure(connection)): + migration.upgrade() + assert connection.execute(select(TeamsInstallation.id)).scalars().all() == [ + installation_id + ] + + +@pytest.mark.parametrize("install_first", [False, True]) +@pytest.mark.parametrize("equal_timestamp", [False, True]) +def test_stale_events_do_not_override_removal( + engine: Engine, install_first: bool, equal_timestamp: bool +) -> None: + installed = datetime(2026, 10, 1, 12, tzinfo=timezone.utc) + removed = installed.replace(minute=0 if equal_timestamp else 1) + reinstalled = installed.replace(minute=2) + with Session(engine) as session: + store = TeamsInstallationStore(session) + if install_first: + store.install(TENANT, APPLICATION, "team-1", SERVICE_URL, installed) + store.remove(TENANT, APPLICATION, "team-1", SERVICE_URL, removed) + session.commit() + with Session(engine) as session: + store = TeamsInstallationStore(session) + original = session.query(TeamsInstallation).one() + identity, changed_on = original.id, original.changed_on + stale = store.install(TENANT, APPLICATION, "team-1", REFRESHED_URL, installed) + assert not stale.active + assert stale.service_url == SERVICE_URL + assert stale.changed_on == changed_on + fresh = store.install(TENANT, APPLICATION, "team-1", REFRESHED_URL, reinstalled) + store.remove(TENANT, APPLICATION, "team-1", SERVICE_URL, removed) + assert fresh.active + assert fresh.id == identity + assert fresh.service_url == REFRESHED_URL + assert fresh.event_at == reinstalled.replace(tzinfo=None) + + +def test_insert_race_preserves_newer_removal(session: Session) -> None: + store = TeamsInstallationStore(session) + installed = datetime(2026, 10, 1, 12, tzinfo=timezone.utc) + store.remove( + TENANT, APPLICATION, "team-1", SERVICE_URL, installed.replace(minute=1) + ) + connection = session.connection() + execute = connection.execute + with patch.object(connection, "execute") as mocked: + mocked.side_effect = lambda statement, *args, **kwargs: ( + MagicMock(rowcount=0) + if mocked.call_count == 1 + else execute(statement, *args, **kwargs) + ) + stale = store.install(TENANT, APPLICATION, "team-1", REFRESHED_URL, installed) + assert not stale.active + assert session.query(TeamsInstallation).count() == 1 From fb0d270b4b5f0a6a6dc2eecbc45129b18d46bbcb Mon Sep 17 00:00:00 2001 From: Kristi Dodaj Date: Sat, 24 Oct 2026 00:18:00 -0400 Subject: [PATCH 17/30] fix(reports): preserve Teams event precision and handle app upgrades --- superset/integrations/teams/adapter.py | 23 ++++++---- superset/integrations/teams/models.py | 8 +++- ...00_7b9d2f8a6c31_add_teams_installations.py | 9 +++- .../unit_tests/integrations/teams/api_test.py | 46 ++++++++++++++++++- .../integrations/teams/store_test.py | 30 ++++++++++-- 5 files changed, 99 insertions(+), 17 deletions(-) diff --git a/superset/integrations/teams/adapter.py b/superset/integrations/teams/adapter.py index 12802cba55de..8f9744dde457 100644 --- a/superset/integrations/teams/adapter.py +++ b/superset/integrations/teams/adapter.py @@ -130,11 +130,20 @@ def _installation_event( if not _allowed_token(config, event): return TeamsEventResult(403) token = event.token - if body.get("type") != "installationUpdate" or body.get("action") not in ( - "add", - "remove", + actions = { + "add": "add", + "add-upgrade": "add", + "remove": "remove", + "remove-upgrade": "remove", + } + action = body.get("action") + if ( + body.get("type") != "installationUpdate" + or not isinstance(action, str) + or action not in actions ): return TeamsEventResult(400) + action = actions[action] # Require the wire-format fields used by SDK authentication. Do not allow # model defaults or snake_case aliases to bypass missing channel/service data. if body.get("channelId") != "msteams" or not isinstance( @@ -145,8 +154,8 @@ def _installation_event( if not service_url or token.service_url.rstrip("/") != service_url.rstrip("/"): return TeamsEventResult(403) try: - model = InstalledActivity if body["action"] == "add" else UninstalledActivity - activity = model.model_validate(body) + model = InstalledActivity if action == "add" else UninstalledActivity + activity = model.model_validate({**body, "action": action}) except ValidationError: return TeamsEventResult(400) if activity.recipient.id not in { @@ -183,9 +192,7 @@ def _installation_event( return TeamsEventResult(400) return TeamsEventResult( 200, - TeamsInstallationEvent( - data.team.id, service_url, body["action"] == "add", event_at - ), + TeamsInstallationEvent(data.team.id, service_url, action == "add", event_at), ) diff --git a/superset/integrations/teams/models.py b/superset/integrations/teams/models.py index c95fafe38c54..8b5450a1b931 100644 --- a/superset/integrations/teams/models.py +++ b/superset/integrations/teams/models.py @@ -21,6 +21,7 @@ from flask_appbuilder import Model from sqlalchemy import Boolean, DateTime, String, UniqueConstraint +from sqlalchemy.dialects import mysql from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy_utils import UUIDType @@ -49,6 +50,11 @@ class TeamsInstallation(Model): active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) # Naive UTC, matching Superset metadata timestamps. # changed_on records the last event processing time. - event_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + event_at: Mapped[datetime] = mapped_column( + DateTime() + .with_variant(mysql.DATETIME(fsp=6), "mysql") + .with_variant(mysql.DATETIME(fsp=6), "mariadb"), + nullable=False, + ) created_on: Mapped[datetime] = mapped_column(DateTime, nullable=False) changed_on: Mapped[datetime] = mapped_column(DateTime, nullable=False) diff --git a/superset/migrations/versions/2026-10-19_00-00_7b9d2f8a6c31_add_teams_installations.py b/superset/migrations/versions/2026-10-19_00-00_7b9d2f8a6c31_add_teams_installations.py index d12fd2fe5849..9a210d1f2322 100644 --- a/superset/migrations/versions/2026-10-19_00-00_7b9d2f8a6c31_add_teams_installations.py +++ b/superset/migrations/versions/2026-10-19_00-00_7b9d2f8a6c31_add_teams_installations.py @@ -22,6 +22,7 @@ import sqlalchemy as sa from alembic import op +from sqlalchemy.dialects import mysql from sqlalchemy_utils import UUIDType revision: str = "7b9d2f8a6c31" @@ -38,7 +39,13 @@ def upgrade() -> None: sa.Column("team_id", sa.String(255), nullable=False), sa.Column("service_url", sa.String(2048), nullable=False), sa.Column("active", sa.Boolean(), nullable=False), - sa.Column("event_at", sa.DateTime(), nullable=False), + sa.Column( + "event_at", + sa.DateTime() + .with_variant(mysql.DATETIME(fsp=6), "mysql") + .with_variant(mysql.DATETIME(fsp=6), "mariadb"), + nullable=False, + ), sa.Column("created_on", sa.DateTime(), nullable=False), sa.Column("changed_on", sa.DateTime(), nullable=False), sa.PrimaryKeyConstraint("id"), diff --git a/tests/unit_tests/integrations/teams/api_test.py b/tests/unit_tests/integrations/teams/api_test.py index 37a40ec0e36c..8fbbe606ce04 100644 --- a/tests/unit_tests/integrations/teams/api_test.py +++ b/tests/unit_tests/integrations/teams/api_test.py @@ -306,12 +306,17 @@ def test_tokens_without_expiration_are_rejected( session.assert_not_called() +@pytest.mark.parametrize("add_action", ["add", "add-upgrade"]) +@pytest.mark.parametrize("remove_action", ["remove", "remove-upgrade"]) def test_repeated_events_removal_and_reinstallation( teams_client: FlaskClient, teams_engine: Engine, installation: dict[str, Any], auth_headers: Callable[..., dict[str, str]], + add_action: str, + remove_action: str, ) -> None: + installation["action"] = add_action headers = auth_headers() for _ in range(2): assert ( @@ -321,7 +326,7 @@ def test_repeated_events_removal_and_reinstallation( with Session(teams_engine) as session: original = session.query(TeamsInstallation).one() identity, created_on = original.id, original.created_on - installation["action"] = "remove" + installation["action"] = remove_action for _ in range(2): assert ( teams_client.post(PATH, json=installation, headers=headers).status_code @@ -331,7 +336,7 @@ def test_repeated_events_removal_and_reinstallation( removed = session.query(TeamsInstallation).one() assert removed.id == identity assert not removed.active - installation["action"] = "add" + installation["action"] = add_action installation["timestamp"] = "2026-10-01T12:01:00Z" refreshed = "https://smba.trafficmanager.net/emea/" installation["serviceUrl"] = refreshed @@ -689,3 +694,40 @@ def test_delayed_install_callback_cannot_undo_removal( assert teams_client.post(PATH, json=body, headers=headers).status_code == 200 with Session(teams_engine) as session: assert not session.query(TeamsInstallation).one().active + + +@pytest.mark.parametrize( + "action, active", [("add-upgrade", True), ("remove-upgrade", False)] +) +def test_upgrade_action_does_not_mutate_request( + teams_config: dict[str, Any], + installation: dict[str, Any], + auth_headers: Callable[..., dict[str, str]], + action: str, + active: bool, +) -> None: + """Authenticate the original event and normalize only the SDK validation copy.""" + config = parse_teams_config(teams_config) + assert config is not None + installation["action"] = action + original = deepcopy(installation) + result = teams_adapter.process_teams_event(config, installation, auth_headers()) + assert result.status == 200 + assert result.event is not None + assert result.event.active is active + assert installation == original + + +@pytest.mark.parametrize("action", [None, [], {}, True, 1, "", "add upgrade", "other"]) +def test_invalid_actions_are_rejected_after_authentication( + teams_client: FlaskClient, + installation: dict[str, Any], + auth_headers: Callable[..., dict[str, str]], + action: Any, +) -> None: + """Malformed actions return a client error without reaching persistence.""" + installation["action"] = action + with patch.object(teams_api, "Session") as session: + response = teams_client.post(PATH, json=installation, headers=auth_headers()) + assert response.status_code == 400 + session.assert_not_called() diff --git a/tests/unit_tests/integrations/teams/store_test.py b/tests/unit_tests/integrations/teams/store_test.py index 768d278c630e..a53d48577399 100644 --- a/tests/unit_tests/integrations/teams/store_test.py +++ b/tests/unit_tests/integrations/teams/store_test.py @@ -27,8 +27,10 @@ import pytest from alembic.migration import MigrationContext from alembic.operations import Operations -from sqlalchemy import create_engine, inspect, select -from sqlalchemy.engine import Engine +from sqlalchemy import Column, create_engine, inspect, select +from sqlalchemy.dialects import mysql +from sqlalchemy.dialects.mysql.mariadb import MariaDBDialect +from sqlalchemy.engine import Dialect, Engine from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -359,9 +361,9 @@ def test_migration_preserves_bootstrapped_installations( def test_stale_events_do_not_override_removal( engine: Engine, install_first: bool, equal_timestamp: bool ) -> None: - installed = datetime(2026, 10, 1, 12, tzinfo=timezone.utc) - removed = installed.replace(minute=0 if equal_timestamp else 1) - reinstalled = installed.replace(minute=2) + installed = datetime(2026, 10, 1, 12, microsecond=100000, tzinfo=timezone.utc) + removed = installed.replace(microsecond=100000 if equal_timestamp else 200000) + reinstalled = installed.replace(microsecond=300000) with Session(engine) as session: store = TeamsInstallationStore(session) if install_first: @@ -401,3 +403,21 @@ def test_insert_race_preserves_newer_removal(session: Session) -> None: stale = store.install(TENANT, APPLICATION, "team-1", REFRESHED_URL, installed) assert not stale.active assert session.query(TeamsInstallation).count() == 1 + + +@pytest.mark.parametrize( + "dialect", [mysql.dialect(), MariaDBDialect()], ids=["mysql", "mariadb"] +) +def test_event_timestamp_preserves_microseconds_in_model_and_migration( + dialect: Dialect, +) -> None: + """Both schema definitions must retain subsecond source event ordering.""" + assert TeamsInstallation.__table__.c.event_at.type.compile(dialect) == "DATETIME(6)" + with patch.object(migration, "create_table") as create_table: + migration.upgrade() + event_at = next( + column + for column in create_table.call_args.args[1:] + if isinstance(column, Column) and column.name == "event_at" + ) + assert event_at.type.compile(dialect) == "DATETIME(6)" From 2db391900d10865ddf3e187361d9a9941a2033ee Mon Sep 17 00:00:00 2001 From: Kristi Dodaj Date: Sat, 24 Oct 2026 16:09:58 -0400 Subject: [PATCH 18/30] fix(reports): support older Flask and allow pausing unavailable Teams alerts --- superset/commands/report/update.py | 22 ++- superset/integrations/teams/api.py | 34 +++- .../unit_tests/commands/report/run_as_test.py | 8 +- .../unit_tests/integrations/teams/api_test.py | 115 +++++++++++- .../integrations/teams/validation_test.py | 164 +++++++++++++++--- 5 files changed, 302 insertions(+), 41 deletions(-) diff --git a/superset/commands/report/update.py b/superset/commands/report/update.py index 9410e3def348..b088212f5bdd 100644 --- a/superset/commands/report/update.py +++ b/superset/commands/report/update.py @@ -131,7 +131,7 @@ def _normalize_recipients(recipients: Any) -> list[tuple[str, str]]: config = json.loads(config) except json.JSONDecodeError: pass - if isinstance(config, dict): + if recipient_type == ReportRecipientType.EMAIL and isinstance(config, dict): config = { key: value for key, value in config.items() @@ -192,6 +192,22 @@ def _changed_content_fields(self) -> set[str]: } return self._compare_fields(CONTENT_FIELDS, current, normalizers) + def _teams_delivery_changed(self) -> bool: + """Validate changed delivery settings, allowing pauses and metadata edits.""" + assert self._model is not None + model = self._model + activating = self._properties.get("active", model.active) and not model.active + changed = self._compare_fields( + frozenset({"type", "report_format", "recipients"}), + { + "type": model.type, + "report_format": model.report_format, + "recipients": _normalize_recipients(model.recipients), + }, + {"recipients": _normalize_recipients}, + ) + return bool(activating or changed) + def _changed_condition_fields(self) -> set[str]: """Return alert-condition fields that differ from the stored values.""" assert self._model is not None @@ -496,8 +512,8 @@ def validate(self) -> None: # noqa: C901 self._properties.get("report_format", self._model.report_format), exceptions, ) - # Always validate the effective Teams recipients, even for metadata-only PUTs. - self.validate_teams_recipients(exceptions) + if self._teams_delivery_changed(): + self.validate_teams_recipients(exceptions) if "recipients" in self._properties: self.validate_recipients_policy(exceptions) self._validate_executors( diff --git a/superset/integrations/teams/api.py b/superset/integrations/teams/api.py index 30c1ba9a50c4..2042c3f7e8ec 100644 --- a/superset/integrations/teams/api.py +++ b/superset/integrations/teams/api.py @@ -25,6 +25,7 @@ from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session from werkzeug.exceptions import RequestEntityTooLarge +from werkzeug.wsgi import get_input_stream from superset.extensions import csrf, db, feature_flag_manager from superset.integrations.teams.config import ( @@ -43,7 +44,7 @@ blueprint = Blueprint("teams", __name__) -def _check_event_body_size() -> None: +def _check_event_body_size() -> bytes: """Bound body reading and reject oversized input before JSON parsing.""" configured_limit = request.max_content_length limit = ( @@ -53,11 +54,30 @@ def _check_event_body_size() -> None: ) if request.content_length is not None and request.content_length > limit: raise RequestEntityTooLarge() - # Werkzeug can truncate lengthless streams at the limit without raising. - # Read one lookahead byte through Flask's bounded stream before parsing JSON. - request.max_content_length = limit + 1 - if len(request.get_data()) > limit: + # Use the WSGI length/termination safeguards without Flask's global stream + # cap, which can hide overflow by truncating a lengthless stream at the limit. + # A lookahead byte bounds actual reads without a Flask 3.1-only setter. + stream = get_input_stream(request.environ) + remaining = limit + 1 + chunks = [] + while remaining: + chunk = stream.read(remaining) + if not chunk: + break + chunks.append(chunk) + remaining -= len(chunk) + body = b"".join(chunks) + if len(body) > limit: raise RequestEntityTooLarge() + return body + + +def _parse_event_body(body: bytes) -> object: + """Decode the bounded body with Flask's JSON provider, rejecting malformed JSON.""" + try: + return current_app.json.loads(body) + except ValueError: + return None @blueprint.post("/api/v1/teams/events/") @@ -73,10 +93,10 @@ def teams_events() -> Response: return Response(status=503) if config is None: return Response(status=503) - _check_event_body_size() + raw_body = _check_event_body_size() if not request.is_json: return Response(status=415) - body = request.get_json(silent=True) + body = _parse_event_body(raw_body) if not isinstance(body, dict): return Response(status=400) # Optional SDK imports stay behind the feature flag and deployment configuration. diff --git a/tests/unit_tests/commands/report/run_as_test.py b/tests/unit_tests/commands/report/run_as_test.py index f7f85d607a63..46fca52ff7bd 100644 --- a/tests/unit_tests/commands/report/run_as_test.py +++ b/tests/unit_tests/commands/report/run_as_test.py @@ -522,7 +522,7 @@ def test_empty_email_cc_and_bcc_do_not_count_as_recipient_changes( @pytest.mark.parametrize("recipient_type", ["Slack", "SlackV2", "Webhook"]) -def test_empty_cc_and_bcc_do_not_change_non_email_recipients( +def test_empty_cc_and_bcc_change_non_email_recipients( mocker: MockerFixture, recipient_type: str ) -> None: model = _make_model(run_as=OTHER_USER) @@ -550,9 +550,11 @@ def test_empty_cc_and_bcc_do_not_change_non_email_recipients( ], }, ) - command.validate() + with pytest.raises(ReportScheduleInvalidError) as exc: + command.validate() - assert command._changed_content_fields() == set() + assert ReportScheduleRunAsContentForbiddenError in _errors(exc) + assert command._changed_content_fields() == {"recipients"} @pytest.mark.parametrize( diff --git a/tests/unit_tests/integrations/teams/api_test.py b/tests/unit_tests/integrations/teams/api_test.py index 8fbbe606ce04..464e6833fdb7 100644 --- a/tests/unit_tests/integrations/teams/api_test.py +++ b/tests/unit_tests/integrations/teams/api_test.py @@ -24,6 +24,7 @@ from collections.abc import Callable from copy import deepcopy from datetime import datetime, timedelta, timezone +from io import BytesIO from types import SimpleNamespace from typing import Any from unittest.mock import patch @@ -558,6 +559,23 @@ async def stop(adapter: Any) -> None: assert loops[0].is_closed() +@pytest.fixture(params=[False, True], ids=["writable-limit", "read-only-limit"]) +def body_size_client( + teams_client: FlaskClient, request: pytest.FixtureRequest +) -> FlaskClient: + """Exercise older Flask's read-only request limit alongside modern Flask.""" + if request.param: + + class ReadOnlyLimitRequest(teams_client.application.request_class): + @property + def max_content_length(self) -> int | None: + """Expose only the deployment limit, as Flask before 3.1 does.""" + return current_app.config["MAX_CONTENT_LENGTH"] + + teams_client.application.request_class = ReadOnlyLimitRequest + return teams_client + + def post_sized_event( client: FlaskClient, installation: dict[str, Any], @@ -584,7 +602,7 @@ def post_sized_event( @pytest.mark.parametrize("extra_bytes", [1, 4096]) @pytest.mark.parametrize("content_type", ["application/json", "text/plain"]) def test_oversized_event_is_rejected_before_json_sdk_or_database( - teams_client: FlaskClient, + body_size_client: FlaskClient, installation: dict[str, Any], content_length: bool, extra_bytes: int, @@ -592,39 +610,41 @@ def test_oversized_event_is_rejected_before_json_sdk_or_database( limit: int | None, ) -> None: """Even valid JSON followed by excess whitespace must not reach the parser.""" - teams_client.application.config["MAX_CONTENT_LENGTH"] = limit + body_size_client.application.config["MAX_CONTENT_LENGTH"] = limit with ( - patch.object(teams_client.application.request_class, "get_json") as parse, + patch.object(body_size_client.application.json, "loads") as parse, patch.object(teams_adapter, "process_teams_event") as sdk, patch.object(teams_api, "Session") as session, ): response = post_sized_event( - teams_client, + body_size_client, installation, (limit or 256 * 1024) + extra_bytes, content_length, content_type=content_type, ) assert response.status_code == 413 - assert teams_client.application.config["MAX_CONTENT_LENGTH"] == limit + assert body_size_client.application.config["MAX_CONTENT_LENGTH"] == limit parse.assert_not_called() sdk.assert_not_called() session.assert_not_called() @pytest.mark.parametrize("content_length", [True, False]) -@pytest.mark.parametrize("size", [1024, 256 * 1024]) +@pytest.mark.parametrize("limit,size", [(None, 1024), (None, 256 * 1024), (1024, 1024)]) def test_valid_event_within_body_limit_still_authenticates_and_persists( - teams_client: FlaskClient, + body_size_client: FlaskClient, teams_engine: Engine, installation: dict[str, Any], auth_headers: Callable[..., dict[str, str]], content_length: bool, size: int, + limit: int | None, ) -> None: """Accept normal and boundary-size JSON bodies, including terminated streams.""" + body_size_client.application.config["MAX_CONTENT_LENGTH"] = limit response = post_sized_event( - teams_client, + body_size_client, installation, size, content_length, @@ -731,3 +751,82 @@ def test_invalid_actions_are_rejected_after_authentication( response = teams_client.post(PATH, json=installation, headers=auth_headers()) assert response.status_code == 400 session.assert_not_called() + + +@pytest.mark.parametrize("limit", [None, 1024]) +def test_oversized_declared_length_does_not_read_body( + body_size_client: FlaskClient, limit: int | None +) -> None: + """Reject the header before consuming even one byte of WSGI input.""" + body_size_client.application.config["MAX_CONTENT_LENGTH"] = limit + stream = BytesIO(b"{}") + with ( + patch.object( + stream, "read", side_effect=AssertionError("unexpected read") + ) as read, + patch.object(body_size_client.application.json, "loads") as parse, + patch.object(teams_adapter, "process_teams_event") as sdk, + patch.object(teams_api, "Session") as session, + ): + response = body_size_client.post( + PATH, + content_type="application/json", + environ_overrides={ + "wsgi.input": stream, + "CONTENT_LENGTH": str((limit or 256 * 1024) + 1), + }, + ) + assert response.status_code == 413 + read.assert_not_called() + parse.assert_not_called() + sdk.assert_not_called() + session.assert_not_called() + + +@pytest.mark.parametrize("limit", [None, 1024]) +@pytest.mark.parametrize("declared_length", ["", "1"]) +@pytest.mark.parametrize("short_reads", [False, True]) +def test_terminated_stream_reads_are_bounded_before_parsing( + body_size_client: FlaskClient, + limit: int | None, + declared_length: str, + short_reads: bool, +) -> None: + """Bound reads even when a terminated stream omits or understates its length.""" + body_size_client.application.config["MAX_CONTENT_LENGTH"] = limit + effective_limit = limit or 256 * 1024 + stream = BytesIO(b"{}" + b" " * (effective_limit + 4096)) + read_chunk = stream.read + with ( + patch.object( + stream, + "read", + side_effect=lambda size: read_chunk( + min(size, 512) if short_reads else size + ), + ) as read, + patch.object(body_size_client.application.json, "loads") as parse, + patch.object(teams_adapter, "process_teams_event") as sdk, + patch.object(teams_api, "Session") as session, + ): + response = body_size_client.post( + PATH, + content_type="application/json", + environ_overrides={ + "wsgi.input": stream, + "CONTENT_LENGTH": declared_length, + "wsgi.input_terminated": True, + }, + ) + assert stream.tell() == effective_limit + 1 + assert response.status_code == 413 + if short_reads: + assert read.call_count > 1 + assert all( + 0 < call.args[0] <= effective_limit + 1 for call in read.call_args_list + ) + else: + read.assert_called_once_with(effective_limit + 1) + parse.assert_not_called() + sdk.assert_not_called() + session.assert_not_called() diff --git a/tests/unit_tests/integrations/teams/validation_test.py b/tests/unit_tests/integrations/teams/validation_test.py index 6c1c367d7131..dca68f333e5a 100644 --- a/tests/unit_tests/integrations/teams/validation_test.py +++ b/tests/unit_tests/integrations/teams/validation_test.py @@ -193,6 +193,7 @@ def saved_schedule(**changes: Any) -> SimpleNamespace: """Supply the persisted fields used when PUT omits recipients/type/format.""" return SimpleNamespace( **{ + "active": True, "type": "Alert", "report_format": "NONE", "name": "Operations alert", @@ -224,7 +225,7 @@ def saved_schedule(**changes: Any) -> SimpleNamespace: def validate_update( model: SimpleNamespace, changes: dict[str, Any] ) -> update.UpdateReportScheduleCommand: - """Run the real update validator, including its unconditional merged-state check.""" + """Run the real update validator with saved values and submitted changes.""" cast(MagicMock, update.ReportScheduleDAO.find_by_id).return_value = model command = update.UpdateReportScheduleCommand(1, changes) command.validate() @@ -296,20 +297,34 @@ def test_partial_update_checks_saved_teams_recipients( @pytest.mark.parametrize("mode", ["disabled", "unconfigured", "removed-destination"]) -def test_metadata_only_update_rechecks_saved_teams_configuration( - configured_app: Flask, command_dependencies: None, mode: str +@pytest.mark.parametrize( + "changes", + [ + {"active": False}, + { + "active": False, + "type": "Alert", + "report_format": "NONE", + "recipients": [teams_recipient()], + }, + {"name": "Rename only"}, + {"description": "Changed description"}, + ], +) +def test_pause_and_metadata_updates_allow_unavailable_teams( + configured_app: Flask, + command_dependencies: None, + mode: str, + changes: dict[str, Any], ) -> None: - if mode == "disabled": - configured_app.config["FEATURE_FLAGS"]["ALERT_REPORT_TEAMS"] = False - elif mode == "unconfigured": - configured_app.config["ALERT_REPORTS_TEAMS"] = {} - else: - configured_app.config["ALERT_REPORTS_TEAMS"]["destinations"][0]["key"] = ( - "renamed" - ) - with pytest.raises(ReportScheduleInvalidError) as exc: - validate_update(saved_schedule(), {"name": "Rename only"}) - assert "recipients" in exc.value.normalized_messages() + make_teams_unavailable(configured_app, mode) + model = saved_schedule() + command = validate_update(model, changes) + assert all(command._properties[key] == value for key, value in changes.items()) + assert ( + model.recipients[0].recipient_config_json + == teams_recipient(saved=True).recipient_config_json + ) def test_metadata_only_update_preserves_valid_saved_recipients( @@ -351,14 +366,13 @@ def test_removing_teams_allows_format_and_type_change( @pytest.mark.parametrize("saved_format", ["PNG", "PDF", "CSV", "XLSX", "TEXT"]) -def test_metadata_update_does_not_preserve_legacy_invalid_teams_format( +def test_metadata_update_can_preserve_legacy_invalid_teams_format( configured_app: Flask, command_dependencies: None, saved_format: str ) -> None: - with pytest.raises(ReportScheduleInvalidError) as exc: - validate_update( - saved_schedule(report_format=saved_format), {"name": "Rename only"} - ) - assert "report_format" in exc.value.normalized_messages() + command = validate_update( + saved_schedule(report_format=saved_format), {"name": "Rename only"} + ) + assert command._properties["name"] == "Rename only" def test_update_can_fix_invalid_saved_format_in_same_request( @@ -492,3 +506,113 @@ def test_teams_delivery_reuses_execution_deadline_and_cleanup_reserve( state._send_notification(content, recipient) assert content.delivery_deadline == 10.0 + total_seconds - 5.0 assert notification.send.call_count == 2 + + +@pytest.mark.parametrize("mode", ["disabled", "unconfigured", "removed-destination"]) +@pytest.mark.parametrize("active", [False, True]) +@pytest.mark.parametrize("pause", [False, True]) +def test_echoed_delivery_values_do_not_revalidate_unavailable_teams( + configured_app: Flask, + command_dependencies: None, + mode: str, + active: bool, + pause: bool, +) -> None: + """Email's empty CC/BCC echoes allow metadata edits and pausing saved Teams.""" + make_teams_unavailable(configured_app, mode) + model = saved_schedule(active=active) + model.recipients.append( + SimpleNamespace( + type="Email", recipient_config_json='{"target":"owner@example.com"}' + ) + ) + recipients = [ + { + "type": "Email", + "recipient_config_json": { + "target": "owner@example.com", + "ccTarget": "", + "bccTarget": "", + }, + }, + teams_recipient(), + ] + command = validate_update( + model, + { + "description": "Changed", + "active": False if pause else active, + "type": "Alert", + "report_format": "NONE", + "recipients": recipients, + }, + ) + assert command._properties["recipients"] == recipients + + +def make_teams_unavailable(app: Flask, mode: str) -> None: + """Simulate deployment changes without changing the saved alert.""" + if mode == "disabled": + app.config["FEATURE_FLAGS"]["ALERT_REPORT_TEAMS"] = False + elif mode == "unconfigured": + app.config["ALERT_REPORTS_TEAMS"] = {} + else: + app.config["ALERT_REPORTS_TEAMS"]["destinations"][0]["key"] = "renamed" + + +@pytest.mark.parametrize("mode", ["disabled", "unconfigured", "removed-destination"]) +@pytest.mark.parametrize( + "changes", + [ + {"active": True}, + {"recipients": [teams_recipient({"target": "unknown"})]}, + {"type": "Report", "database": None}, + {"report_format": "PNG"}, + {"active": False, "report_format": "PNG"}, + {"active": False, "type": "Report", "database": None}, + {"active": False, "recipients": [teams_recipient({"target": "unknown"})]}, + ], +) +def test_delivery_changes_revalidate_unavailable_teams_even_when_pausing( + configured_app: Flask, + command_dependencies: None, + mode: str, + changes: dict[str, Any], +) -> None: + """Omitted recipients and active:false cannot bypass delivery validation.""" + make_teams_unavailable(configured_app, mode) + with pytest.raises(ReportScheduleInvalidError) as exc: + validate_update(saved_schedule(active=changes != {"active": True}), changes) + field = "recipients" + if mode == "removed-destination": + if "type" in changes: + field = "type" + elif "report_format" in changes: + field = "report_format" + assert "Teams" in str(exc.value.normalized_messages()[field]) + + +@pytest.mark.parametrize("field", ["ccTarget", "bccTarget"]) +@pytest.mark.parametrize("serialized", [False, True]) +@pytest.mark.parametrize("pause", [False, True]) +def test_empty_email_fields_on_teams_are_delivery_changes( + configured_app: Flask, + command_dependencies: None, + field: str, + serialized: bool, + pause: bool, +) -> None: + """Unsupported Teams fields cannot compare unchanged and bypass validation.""" + config = {"target": "ops", field: ""} + recipient = teams_recipient(json.dumps(config) if serialized else config) + changes: dict[str, Any] = { + "description": "Changed description", + "recipients": [recipient], + } + if pause: + changes["active"] = False + with pytest.raises(ReportScheduleInvalidError) as exc: + validate_update(saved_schedule(), changes) + assert "A configured Teams destination key is required" in str( + exc.value.normalized_messages()["recipients"] + ) From 79241e0df3f472263807ec89e74b519b20ba2040 Mon Sep 17 00:00:00 2001 From: Kristi Dodaj Date: Sat, 24 Oct 2026 17:34:47 -0400 Subject: [PATCH 19/30] fix(reports): preserve recipient normalization for non-Teams methods --- superset/commands/report/update.py | 2 +- tests/unit_tests/commands/report/run_as_test.py | 9 +++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/superset/commands/report/update.py b/superset/commands/report/update.py index b088212f5bdd..5817f233762b 100644 --- a/superset/commands/report/update.py +++ b/superset/commands/report/update.py @@ -131,7 +131,7 @@ def _normalize_recipients(recipients: Any) -> list[tuple[str, str]]: config = json.loads(config) except json.JSONDecodeError: pass - if recipient_type == ReportRecipientType.EMAIL and isinstance(config, dict): + if recipient_type != ReportRecipientType.TEAMS and isinstance(config, dict): config = { key: value for key, value in config.items() diff --git a/tests/unit_tests/commands/report/run_as_test.py b/tests/unit_tests/commands/report/run_as_test.py index 46fca52ff7bd..73686226f395 100644 --- a/tests/unit_tests/commands/report/run_as_test.py +++ b/tests/unit_tests/commands/report/run_as_test.py @@ -522,7 +522,7 @@ def test_empty_email_cc_and_bcc_do_not_count_as_recipient_changes( @pytest.mark.parametrize("recipient_type", ["Slack", "SlackV2", "Webhook"]) -def test_empty_cc_and_bcc_change_non_email_recipients( +def test_empty_cc_and_bcc_do_not_change_non_email_recipients( mocker: MockerFixture, recipient_type: str ) -> None: model = _make_model(run_as=OTHER_USER) @@ -550,11 +550,8 @@ def test_empty_cc_and_bcc_change_non_email_recipients( ], }, ) - with pytest.raises(ReportScheduleInvalidError) as exc: - command.validate() - - assert ReportScheduleRunAsContentForbiddenError in _errors(exc) - assert command._changed_content_fields() == {"recipients"} + command.validate() + assert command._changed_content_fields() == set() @pytest.mark.parametrize( From 4fcb5914c327eff72d346ecfc9618cced19685e6 Mon Sep 17 00:00:00 2001 From: Kristi Dodaj Date: Sun, 25 Oct 2026 17:29:16 -0400 Subject: [PATCH 20/30] fix(reports): allow unchanged Teams alert edits when unavailable --- .../features/alerts/AlertReportModal.test.tsx | 78 ++++++++++++++++--- .../src/features/alerts/AlertReportModal.tsx | 75 ++++++++++++++---- 2 files changed, 129 insertions(+), 24 deletions(-) diff --git a/superset-frontend/src/features/alerts/AlertReportModal.test.tsx b/superset-frontend/src/features/alerts/AlertReportModal.test.tsx index dfcee11dd9a0..643647816a09 100644 --- a/superset-frontend/src/features/alerts/AlertReportModal.test.tsx +++ b/superset-frontend/src/features/alerts/AlertReportModal.test.tsx @@ -4756,11 +4756,18 @@ test('Teams preserves separate destination entries and serializes only their key } }); -test('Teams saved recipients remain visible when deployment configuration is removed', async () => { - fetchMock.get('glob:*/api/v1/report/93', { +test.each([ + ['an active alert', true, false], + ['an inactive alert', false, false], + ['pausing an alert', true, true], +])('Teams unavailable integration allows %s', async (_, active, pause) => { + const id = pause ? 193 : active ? 194 : 195; + mockNotificationMethods = ['Email', 'Slack']; + fetchMock.get(`glob:*/api/v1/report/${id}`, { result: { ...generateMockPayload(true), - id: 93, + id, + active, report_format: 'NONE', recipients: [ { @@ -4768,6 +4775,7 @@ test('Teams saved recipients remain visible when deployment configuration is rem type: 'Teams', recipient_config_json: '{"target":"removed-key"}', }, + { id: 2, type: 'Slack', recipient_config_json: { target: 'C123' } }, ], }, }); @@ -4776,16 +4784,20 @@ test('Teams saved recipients remain visible when deployment configuration is rem { result: [] }, { name: 'teams-unavailable' }, ); + fetchMock.put( + `glob:*/api/v1/report/${id}`, + { id, result: {} }, + { + name: 'teams-unavailable-save', + }, + ); try { render( , - { - useRedux: true, - initialState: { user: adminUser }, - }, + { useRedux: true, initialState: { user: adminUser } }, ); await userEvent.click(screen.getByTestId('notification-method-panel')); expect( @@ -4794,9 +4806,48 @@ test('Teams saved recipients remain visible when deployment configuration is rem expect( await screen.findByText(/Teams delivery is unavailable/), ).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /save/i })).toBeDisabled(); + await userEvent.click(screen.getByTestId('general-information-panel')); + const description = screen.getByPlaceholderText( + /include description to be sent with alert/i, + ); + await userEvent.clear(description); + await userEvent.type(description, 'Updated description'); + if (pause) await userEvent.click(screen.getByRole('switch')); + const save = screen.getByRole('button', { name: /save/i }); + await waitFor(() => expect(save).toBeEnabled()); + await userEvent.click(screen.getByTestId('notification-method-panel')); + const slackTarget = screen.getByTestId('recipients'); + fireEvent.change(slackTarget, { target: { value: 'C456' } }); + await waitFor(() => expect(save).toBeDisabled()); + fireEvent.change(slackTarget, { target: { value: 'C123' } }); + await waitFor(() => expect(save).toBeEnabled()); + await userEvent.click(save); + await waitFor(() => + expect( + fetchMock.callHistory.calls('teams-unavailable-save'), + ).toHaveLength(1), + ); + const [call] = fetchMock.callHistory.calls('teams-unavailable-save'); + expect(JSON.parse(call.options.body as string)).toMatchObject({ + active: pause ? false : active, + description: 'Updated description', + report_format: 'NONE', + recipients: [ + { type: 'Teams', recipient_config_json: { target: 'removed-key' } }, + { + type: 'Slack', + recipient_config_json: { + target: 'C123', + ccTarget: '', + bccTarget: '', + }, + }, + ], + }); } finally { + fetchMock.removeRoute(`glob:*/api/v1/report/${id}`); fetchMock.removeRoute('teams-unavailable'); + fetchMock.removeRoute('teams-unavailable-save'); } }); @@ -4910,7 +4961,14 @@ test('Teams saved recipients stay visible and replaceable when the optional SDK expect( screen.getByRole('combobox', { name: 'Teams destination' }), ).toBeDisabled(); - expect(screen.getByRole('button', { name: /save/i })).toBeDisabled(); + const save = screen.getByRole('button', { name: /save/i }); + await waitFor(() => expect(save).toBeEnabled()); + await userEvent.click(screen.getByTestId('general-information-panel')); + await userEvent.click(screen.getByRole('switch')); + await waitFor(() => expect(save).toBeDisabled()); + await userEvent.click(screen.getByRole('switch')); + await waitFor(() => expect(save).toBeEnabled()); + await userEvent.click(screen.getByTestId('notification-method-panel')); await userEvent.click( screen.getByRole('combobox', { name: /^Delivery method/ }), ); diff --git a/superset-frontend/src/features/alerts/AlertReportModal.tsx b/superset-frontend/src/features/alerts/AlertReportModal.tsx index 8b4ca6d8901d..11d80faa3fab 100644 --- a/superset-frontend/src/features/alerts/AlertReportModal.tsx +++ b/superset-frontend/src/features/alerts/AlertReportModal.tsx @@ -1075,7 +1075,7 @@ const AlertReportModal: FunctionComponent = ({ setCurrentAlert({ ...defaultAlert }); }; - const onSave = () => { + const getRecipients = () => { // Notification Settings const recipients: Recipient[] = []; @@ -1096,6 +1096,21 @@ const AlertReportModal: FunctionComponent = ({ } }); + return recipients; + }; + + const effectiveReportFormat = + !isReport && + !attachmentControlsVisible && + (!isEditMode || + notificationSettings.some( + setting => setting.method === NotificationMethodOption.Teams, + )) + ? 'NONE' + : reportFormat || DEFAULT_NOTIFICATION_FORMAT; + + const onSave = () => { + const recipients = getRecipients(); const shouldEnableForceScreenshot = contentType === ContentType.Chart && !isReport; @@ -1148,20 +1163,10 @@ const AlertReportModal: FunctionComponent = ({ database: currentAlert?.database?.value, editors: mapSubjectPickerValuesToIds(currentAlert?.editors || []), recipients, - report_format: reportFormat || DEFAULT_NOTIFICATION_FORMAT, + report_format: effectiveReportFormat, extra: contentType === ContentType.Dashboard ? currentAlert?.extra : {}, }; - if ( - !isReport && - !attachmentControlsVisible && - (!isEditMode || - notificationSettings.some( - setting => setting.method === NotificationMethodOption.Teams, - )) - ) { - data.report_format = 'NONE'; - } if (!attachmentControlsVisible && isEditMode) { // Hidden settings stay stored; only the format controls attachment delivery. delete data.chart; @@ -1893,10 +1898,49 @@ const AlertReportModal: FunctionComponent = ({ const hasTeamsRecipients = notificationSettings.some( setting => setting.method === NotificationMethodOption.Teams, ); + // Match the backend's delivery-change check so unavailable integrations do + // not prevent pausing or editing metadata on an existing alert. + const normalizeRecipients = ( + recipients: { + type: string; + recipient_config_json: string | Recipient['recipient_config_json']; + }[], + ) => + JSON.stringify( + recipients + .map(({ type, recipient_config_json: config }) => { + const parsed = + typeof config === 'string' ? JSON.parse(config) : config; + return JSON.stringify([ + type, + Object.entries(parsed) + .filter( + ([key, value]) => + value !== undefined && + !( + type !== NotificationMethodOption.Teams && + ['ccTarget', 'bccTarget'].includes(key) && + value === '' + ), + ) + .sort(([left], [right]) => left.localeCompare(right)), + ]); + }) + .sort(), + ); + const deliveryUnchanged = + hasTeamsRecipients && + isEditMode && + resource && + !(currentAlert?.active && !resource.active) && + resource.type === (isReport ? 'Report' : 'Alert') && + resource.report_format === effectiveReportFormat && + normalizeRecipients(resource.recipients || []) === + normalizeRecipients(getRecipients()); const invalidTeamsRecipients = hasTeamsRecipients && - (!teamsSupported || - !teamsIntegrationAvailable || + ((!deliveryUnchanged && + (!teamsSupported || !teamsIntegrationAvailable)) || notificationSettings.some( setting => setting.method === NotificationMethodOption.Teams && @@ -2355,6 +2399,9 @@ const AlertReportModal: FunctionComponent = ({ updateEmailSubject(); }, [ currentAlertSafe.name, + currentAlertSafe.active, + resource, + isReport, currentAlertSafe.editors, currentAlertSafe.run_as, currentAlertSafe.run_as_type, From 70ee150d2ff4b335bf64ec9e85ec82f761757ec2 Mon Sep 17 00:00:00 2001 From: Kristi Dodaj Date: Sun, 25 Oct 2026 18:25:11 -0400 Subject: [PATCH 21/30] fix(reports): reject duplicate Teams destinations --- .../features/alerts/AlertReportModal.test.tsx | 29 ++++++++- superset/commands/report/base.py | 10 +++- .../integrations/teams/validation_test.py | 59 +++++++++++++++++++ 3 files changed, 94 insertions(+), 4 deletions(-) diff --git a/superset-frontend/src/features/alerts/AlertReportModal.test.tsx b/superset-frontend/src/features/alerts/AlertReportModal.test.tsx index 643647816a09..11416b4a0b0c 100644 --- a/superset-frontend/src/features/alerts/AlertReportModal.test.tsx +++ b/superset-frontend/src/features/alerts/AlertReportModal.test.tsx @@ -4666,7 +4666,7 @@ test('global attachment policy hides both the alert toggle and attachment contro } }); -test('Teams preserves separate destination entries and serializes only their keys', async () => { +test('Changing the first Teams method preserves the following destination', async () => { mockNotificationMethods = ['Email', 'Teams']; fetchMock.get('glob:*/api/v1/report/92', { result: { @@ -4734,6 +4734,15 @@ test('Teams preserves separate destination entries and serializes only their key expect( screen.getByText('Finance (Bot installation required)'), ).toBeInTheDocument(); + await userEvent.click( + screen.getAllByRole('combobox', { name: /^Delivery method/ })[0], + ); + await userEvent.click(await screen.findByText('Email')); + await addAsyncSelectValue( + /email recipients/i, + 'owner@example.com', + reportUsersEndpoint, + ); await userEvent.click(screen.getByRole('button', { name: /save/i })); await waitFor(() => expect(fetchMock.callHistory.calls('save-teams')).toHaveLength(1), @@ -4742,12 +4751,26 @@ test('Teams preserves separate destination entries and serializes only their key expect(JSON.parse(call.options.body as string)).toMatchObject({ report_format: 'NONE', recipients: [ - { type: 'Teams', recipient_config_json: { target: 'ops' } }, + { + type: 'Email', + recipient_config_json: { + target: 'owner@example.com', + ccTarget: '', + bccTarget: '', + }, + }, { type: 'Teams', recipient_config_json: { target: 'finance' } }, ], }); expect(JSON.parse(call.options.body as string).recipients).toEqual([ - { type: 'Teams', recipient_config_json: { target: 'ops' } }, + { + type: 'Email', + recipient_config_json: { + target: 'owner@example.com', + ccTarget: '', + bccTarget: '', + }, + }, { type: 'Teams', recipient_config_json: { target: 'finance' } }, ]); } finally { diff --git a/superset/commands/report/base.py b/superset/commands/report/base.py index ea0bb6091812..e8839bdce5e9 100644 --- a/superset/commands/report/base.py +++ b/superset/commands/report/base.py @@ -568,6 +568,7 @@ def validate_teams_recipients(self, exceptions: list[ValidationError]) -> None: report_format = self._properties.get( "report_format", getattr(model, "report_format", ReportDataFormat.PNG) ) + destination_keys: set[str] = set() for recipient in recipients or []: if isinstance(recipient, dict): recipient_type = recipient.get("type") @@ -578,7 +579,14 @@ def validate_teams_recipients(self, exceptions: list[ValidationError]) -> None: if recipient_type != ReportRecipientType.TEAMS: continue try: - validate_teams_recipient(recipient_config, report_type, report_format) + _, destination = validate_teams_recipient( + recipient_config, report_type, report_format + ) + if destination.key in destination_keys: + raise TeamsRecipientValidationError( + "Teams destination keys must be unique within a schedule" + ) + destination_keys.add(destination.key) except TeamsRecipientValidationError as ex: exceptions.append(ValidationError(str(ex), field_name=ex.field)) diff --git a/tests/unit_tests/integrations/teams/validation_test.py b/tests/unit_tests/integrations/teams/validation_test.py index dca68f333e5a..b38f994e6540 100644 --- a/tests/unit_tests/integrations/teams/validation_test.py +++ b/tests/unit_tests/integrations/teams/validation_test.py @@ -240,6 +240,65 @@ def test_create_valid_teams_alert_without_installation( assert command._properties["recipients"] == [teams_recipient()] +@pytest.mark.parametrize("updating", [False, True]) +@pytest.mark.parametrize("duplicate", [False, True]) +def test_teams_destination_keys_must_be_unique( + configured_app: Flask, + command_dependencies: None, + updating: bool, + duplicate: bool, +) -> None: + destinations = configured_app.config["ALERT_REPORTS_TEAMS"]["destinations"] + destinations.append( + {**destinations[0], "key": "finance", "channel_id": "channel-2"} + ) + recipients = [ + teams_recipient(), + teams_recipient({"target": "ops" if duplicate else "finance"}), + ] + + def validate() -> None: + if updating: + validate_update(saved_schedule(), {"recipients": recipients}) + else: + create_command(recipients=recipients).validate() + + if duplicate: + with pytest.raises(ReportScheduleInvalidError) as exc: + validate() + assert "Teams destination keys must be unique" in str( + exc.value.normalized_messages() + ) + else: + validate() + + +@pytest.mark.parametrize("changes", [{"active": False}, {"description": "Updated"}]) +def test_existing_duplicate_teams_recipients_allow_metadata_edits( + configured_app: Flask, command_dependencies: None, changes: dict[str, Any] +) -> None: + model = saved_schedule( + recipients=[teams_recipient(saved=True), teams_recipient(saved=True)] + ) + validate_update( + model, {**changes, "recipients": [teams_recipient(), teams_recipient()]} + ) + + +def test_reactivating_saved_duplicate_teams_recipients_is_rejected( + configured_app: Flask, command_dependencies: None +) -> None: + model = saved_schedule( + active=False, + recipients=[teams_recipient(saved=True), teams_recipient(saved=True)], + ) + with pytest.raises(ReportScheduleInvalidError) as exc: + validate_update(model, {"active": True}) + assert "Teams destination keys must be unique" in str( + exc.value.normalized_messages() + ) + + def test_create_must_explicitly_disable_default_attachments( configured_app: Flask, command_dependencies: None ) -> None: From c1034e787a951792e42ba74928fea6ec10f4f469 Mon Sep 17 00:00:00 2001 From: Kristi Dodaj Date: Sun, 25 Oct 2026 23:52:56 -0400 Subject: [PATCH 22/30] fix(reports): harden Teams delivery retries and callback tests --- superset/integrations/teams/adapter.py | 23 +++++- superset/reports/notifications/teams.py | 1 + .../unit_tests/integrations/teams/api_test.py | 15 +++- .../integrations/teams/delivery_test.py | 74 +++++++++++++++++-- 4 files changed, 100 insertions(+), 13 deletions(-) diff --git a/superset/integrations/teams/adapter.py b/superset/integrations/teams/adapter.py index 8f9744dde457..d3a15600fa07 100644 --- a/superset/integrations/teams/adapter.py +++ b/superset/integrations/teams/adapter.py @@ -253,7 +253,7 @@ class TeamsDeliveryError(Exception): """Sanitized delivery outcome for mapping to existing notification exceptions.""" def __init__( - self, category: Literal["authorization", "transient", "parameter"] + self, category: Literal["authorization", "transient", "parameter", "unknown"] ) -> None: """Expose a stable message without response bodies, SQL, tokens, or URLs.""" self.category = category @@ -261,12 +261,16 @@ def __init__( "authorization": "Teams bot authorization failed", "transient": "Teams alert delivery temporarily unavailable", "parameter": "Teams alert delivery was rejected", + "unknown": ( + "Teams alert delivery could not be confirmed. " + "It may have been delivered" + ), } super().__init__(messages[category]) class TeamsTransportError(Exception): - """An HTTP failure carrying only the status and Microsoft's retry metadata.""" + """Sanitized HTTP failure with explicit retry safety for status-less errors.""" def __init__( self, @@ -274,10 +278,13 @@ def __init__( headers: httpx.Headers | requests.structures.CaseInsensitiveDict[str] | None = None, + *, + retryable: bool = False, ) -> None: """Retain only retry headers; never retain a response body or request token.""" super().__init__("Teams transport request failed") self.status = status + self.retryable = retryable self.retry_after = headers.get("Retry-After") if headers is not None else None self.retry_after_ms = ( headers.get("x-ms-retry-after-ms") if headers is not None else None @@ -302,7 +309,7 @@ def _remaining(deadline: float) -> float: """Bound each request by its timeout and remaining delivery budget.""" remaining = deadline - monotonic() if remaining <= 0: - raise TeamsTransportError() + raise TeamsTransportError(retryable=True) return min(_DELIVERY_REQUEST_TIMEOUT, remaining) @@ -420,8 +427,14 @@ async def post_root( error = TeamsTransportError(ex.response.status_code, ex.response.headers) await ex.response.aclose() raise error from None - except (httpx.RequestError, requests.RequestException): + except (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout): + raise TeamsTransportError(retryable=True) from None + except httpx.RequestError: + # The connector may have accepted the POST before the connection failed. raise TeamsTransportError() from None + except requests.RequestException: + # requests is used only for authentication, before the connector POST. + raise TeamsTransportError(retryable=True) from None except ValueError: # Includes invalid SDK models/response JSON. Replaying a successful # POST whose response could not be decoded could duplicate delivery. @@ -483,6 +496,8 @@ async def _deliver_with_retries( raise TeamsDeliveryError("transient") return except TeamsTransportError as error: + if error.status is None and not error.retryable: + raise TeamsDeliveryError("unknown") from None if error.status in (401, 403): if ( error.status == 403 diff --git a/superset/reports/notifications/teams.py b/superset/reports/notifications/teams.py index 693a2630e6bb..5466d7e67db3 100644 --- a/superset/reports/notifications/teams.py +++ b/superset/reports/notifications/teams.py @@ -111,6 +111,7 @@ def _send_to_teams( "authorization": NotificationAuthorizationException, "transient": NotificationTransientError, "parameter": NotificationParamException, + "unknown": NotificationError, }[ex.category] raise exception(str(ex)) from None diff --git a/tests/unit_tests/integrations/teams/api_test.py b/tests/unit_tests/integrations/teams/api_test.py index 464e6833fdb7..a8eadbd87c51 100644 --- a/tests/unit_tests/integrations/teams/api_test.py +++ b/tests/unit_tests/integrations/teams/api_test.py @@ -445,8 +445,19 @@ def test_callback_fails_closed_without_sdk_or_configuration( teams_client.application.config["ALERT_REPORTS_TEAMS"] = { "credential": "deployment-secret" } - with patch.dict("sys.modules", {"superset.integrations.teams.adapter": None}): - response = teams_client.post(PATH, json=installation, headers=auth_headers()) + with patch.object(teams_adapter, "process_teams_event") as process_event: + if mode == "missing-sdk": + with patch.dict( + "sys.modules", {"superset.integrations.teams.adapter": None} + ): + response = teams_client.post( + PATH, json=installation, headers=auth_headers() + ) + else: + response = teams_client.post( + PATH, json=installation, headers=auth_headers() + ) + process_event.assert_not_called() assert response.status_code == expected assert response.data == b"" diff --git a/tests/unit_tests/integrations/teams/delivery_test.py b/tests/unit_tests/integrations/teams/delivery_test.py index de730baa3bb3..916529935dc7 100644 --- a/tests/unit_tests/integrations/teams/delivery_test.py +++ b/tests/unit_tests/integrations/teams/delivery_test.py @@ -52,6 +52,7 @@ from superset.reports.notifications.base import NotificationContent from superset.reports.notifications.exceptions import ( NotificationAuthorizationException, + NotificationError, NotificationParamException, NotificationTransientError, ) @@ -232,11 +233,28 @@ async def sleep(delay: float) -> None: return sleeps -@pytest.mark.parametrize("status", [None, 408, 429, 500, 502, 503, 504]) -def test_transient_failures_retry_with_bounded_attempts( - configured: TeamsConfig, retry_clock: list[float], status: int | None +@pytest.mark.parametrize( + "status,retryable", + [ + (None, False), + (None, True), + (408, False), + (429, False), + (500, False), + (502, False), + (503, False), + (504, False), + ], +) +def test_transport_failures_respect_retry_safety_and_attempt_bound( + configured: TeamsConfig, + retry_clock: list[float], + status: int | None, + retryable: bool, ) -> None: - transport = FakeTransport([adapter.TeamsTransportError(status)] * 4) + transport = FakeTransport( + [adapter.TeamsTransportError(status, retryable=retryable)] * 4 + ) with pytest.raises(adapter.TeamsDeliveryError) as exc: adapter.deliver_teams_alert( configured, @@ -245,9 +263,10 @@ def test_transient_failures_retry_with_bounded_attempts( {}, transport_factory=transport.factory, ) - assert exc.value.category == "transient" - assert len(transport.posts) == 4 - assert retry_clock == [1.0, 2.0, 4.0] + ambiguous = status is None and not retryable + assert exc.value.category == ("unknown" if ambiguous else "transient") + assert len(transport.posts) == (1 if ambiguous else 4) + assert retry_clock == ([] if ambiguous else [1.0, 2.0, 4.0]) assert transport.closed assert transport.loop is not None assert transport.loop.is_closed() @@ -365,6 +384,7 @@ def test_one_authentication_refresh_and_permanent_errors( ("authorization", NotificationAuthorizationException), ("transient", NotificationTransientError), ("parameter", NotificationParamException), + ("unknown", NotificationError), ], ) def test_transport_errors_map_to_existing_notification_exceptions( @@ -491,6 +511,8 @@ def receive(request: httpx.Request) -> httpx.Response: assert str(request.url) == SERVICE_URL.rstrip("/") + "/v3/conversations" state.posts.append((request, json.loads(request.content))) status = state.statuses.pop(0) if state.statuses else 201 + if isinstance(status, Exception): + raise status return _sdk_post_response(status) class SDKHttpClient(httpx.AsyncClient): @@ -595,6 +617,44 @@ def test_real_sdk_failures_close_resources_and_sanitize_errors( assert_resources_closed(sdk_network) +@pytest.mark.parametrize( + "error,retryable", + [ + (httpx.ConnectError, True), + (httpx.ConnectTimeout, True), + (httpx.PoolTimeout, True), + (httpx.ReadTimeout, False), + (httpx.ReadError, False), + (httpx.WriteTimeout, False), + (httpx.WriteError, False), + (httpx.RemoteProtocolError, False), + ], +) +def test_sdk_retries_only_safe_connection_failures( + sdk_network: SimpleNamespace, + configured: TeamsConfig, + retry_clock: list[float], + error: type[httpx.RequestError], + retryable: bool, +) -> None: + sdk_network.statuses = [error("secret diagnostic"), 201] + if retryable: + adapter.deliver_teams_alert( + configured, configured.destinations[0], SERVICE_URL, {} + ) + else: + with pytest.raises(adapter.TeamsDeliveryError) as exc: + adapter.deliver_teams_alert( + configured, configured.destinations[0], SERVICE_URL, {} + ) + assert exc.value.category == "unknown" + assert "may have been delivered" in str(exc.value) + assert "secret diagnostic" not in str(exc.value) + assert len(sdk_network.posts) == (2 if retryable else 1) + assert retry_clock == ([1.0] if retryable else []) + assert_resources_closed(sdk_network) + + def test_auth_http_closes_response_and_preserves_retry_delay( monkeypatch: pytest.MonkeyPatch, ) -> None: From 7d9c27db4e8ac22299c438314edebda9c41fb41a Mon Sep 17 00:00:00 2001 From: Kristi Dodaj Date: Mon, 26 Oct 2026 09:35:40 -0400 Subject: [PATCH 23/30] test(reports): cover Teams authorization failure on final attempt --- tests/unit_tests/integrations/teams/delivery_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit_tests/integrations/teams/delivery_test.py b/tests/unit_tests/integrations/teams/delivery_test.py index 916529935dc7..714905983a00 100644 --- a/tests/unit_tests/integrations/teams/delivery_test.py +++ b/tests/unit_tests/integrations/teams/delivery_test.py @@ -340,6 +340,7 @@ def test_excessive_server_delay_is_not_shortened( ([404], "parameter", 1, 0), ([302], "parameter", 1, 0), ([401, 503, 503, None], None, 4, 1), + ([503, 503, 503, 401], "authorization", 4, 0), ], ) def test_one_authentication_refresh_and_permanent_errors( From f4066eb2ede0b15874d335c5dfacb845bc9c1a07 Mon Sep 17 00:00:00 2001 From: Kristi Dodaj Date: Mon, 26 Oct 2026 10:59:35 -0400 Subject: [PATCH 24/30] fix(ci): lock Teams SDK development dependencies --- .../workflows/superset-python-unittest.yml | 6 +-- requirements/development.in | 2 +- requirements/development.txt | 49 ++++++++++++++++--- 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/.github/workflows/superset-python-unittest.yml b/.github/workflows/superset-python-unittest.yml index af3e6e6344e3..de27026b2d61 100644 --- a/.github/workflows/superset-python-unittest.yml +++ b/.github/workflows/superset-python-unittest.yml @@ -65,16 +65,14 @@ jobs: uses: $/.github/actions/setup-backend/ with: python-version: ${{ matrix.python-version }} + - name: Verify Teams SDK is available for authentication and delivery tests + run: python -c "import microsoft_teams.apps" - name: SDK unit tests env: PYTEST_DISABLE_PLUGIN_AUTOLOAD: "1" PYTHONPATH: ${{ github.workspace }}/superset-core/src run: | python -m pytest -q -o addopts='' superset-core/tests --junit-xml=test-results/junit-sdk.xml - - name: Install optional Teams SDK for authentication and delivery tests - run: | - uv pip install --system -e '.[teams]' - python -c "import microsoft_teams.apps" # The babel_update.sh tests run the script's msgcat step and fail on CI # when gettext is missing (tests/unit_tests/scripts/translations). - name: Install gettext tools diff --git a/requirements/development.in b/requirements/development.in index d76072d3dc5a..47889f5de383 100644 --- a/requirements/development.in +++ b/requirements/development.in @@ -16,7 +16,7 @@ # specific language governing permissions and limitations # under the License. # --e .[development,bigquery,clickhouse,cockroachdb,crate,databend,druid,duckdb,elasticsearch,fastmcp,firebird,gevent,gsheets,monetdb,mongodb,mssql,mysql,oracle,postgres,presto,prophet,risingwave,starrocks,trino,thumbnails,ydb] +-e .[development,bigquery,clickhouse,cockroachdb,crate,databend,druid,duckdb,elasticsearch,fastmcp,firebird,gevent,gsheets,monetdb,mongodb,mssql,mysql,oracle,postgres,presto,prophet,risingwave,starrocks,trino,teams,thumbnails,ydb] -e ./superset-extensions-cli[test] # testcontainers-backed db_engine_specs tests (tests/testcontainers/) -- # see .github/workflows/testcontainers.yml diff --git a/requirements/development.txt b/requirements/development.txt index 4118dac0118d..3a5901e6cd7d 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -27,6 +27,8 @@ amqp==5.3.1 # via # -c requirements/base-constraint.txt # kombu +annotated-doc==0.0.5 + # via fastapi annotated-types==0.7.0 # via # -c requirements/base-constraint.txt @@ -207,6 +209,8 @@ cryptography==50.0.2 # authlib # google-auth # joserfc + # microsoft-teams-apps + # msal # oracledb # paramiko # pyjwt @@ -227,6 +231,8 @@ defusedxml==0.7.1 # via # -c requirements/base-constraint.txt # odfpy +dependency-injector==4.49.1 + # via microsoft-teams-apps deprecated==1.2.18 # via # -c requirements/base-constraint.txt @@ -277,6 +283,8 @@ exceptiongroup==1.3.0 # via fastmcp-slim execnet==2.1.2 # via pytest-xdist +fastapi==0.162.0 + # via microsoft-teams-apps fastmcp==4.1.2 # via apache-superset fastmcp-slim==4.1.2 @@ -468,6 +476,7 @@ httpcore2==2.15.1 httpx==0.28.1 # via # apache-superset + # microsoft-teams-common # testcontainers httpx2==2.15.1 # via @@ -490,9 +499,7 @@ idna==3.20 # url-normalize # yarl importlib-metadata==8.7.0 - # via - # keyring - # opentelemetry-api + # via keyring iniconfig==2.0.0 # via pytest isodate==0.7.2 @@ -629,10 +636,22 @@ mdurl==0.1.2 # via # -c requirements/base-constraint.txt # markdown-it-py +microsoft-teams-api==2.1.1 + # via microsoft-teams-apps +microsoft-teams-apps==2.1.1 + # via apache-superset +microsoft-teams-cards==2.1.1 + # via microsoft-teams-api +microsoft-teams-common==2.1.1 + # via + # microsoft-teams-api + # microsoft-teams-apps more-itertools==10.8.0 # via # jaraco-classes # jaraco-functools +msal==1.39.0 + # via microsoft-teams-apps msgpack==1.2.3 # via # -c requirements/base-constraint.txt @@ -685,10 +704,13 @@ openpyxl==3.1.5 # pandas opensearch-py==2.8.0 # via elasticsearch-dbapi -opentelemetry-api==1.39.1 +opentelemetry-api==1.45.1 # via + # fastapi # fastmcp-slim # mcp + # microsoft-teams-api + # microsoft-teams-apps oracledb==26.0.0 # via # apache-superset @@ -824,17 +846,21 @@ pydantic==2.14.1 # -c requirements/base-constraint.txt # apache-superset # apache-superset-core + # fastapi # fastmcp-slim # mcp # mcp-types + # microsoft-teams-api # openapi-pydantic # pydantic-settings pydantic-core==2.50.1 # via # -c requirements/base-constraint.txt # pydantic -pydantic-settings==2.10.1 - # via fastmcp-slim +pydantic-settings==2.16.1 + # via + # fastmcp-slim + # microsoft-teams-apps pydata-google-auth==1.9.0 # via pandas-gbq pydruid==0.6.9 @@ -860,6 +886,9 @@ pyjwt==2.15.1 # flask-appbuilder # flask-jwt-extended # mcp + # microsoft-teams-api + # microsoft-teams-apps + # msal pylint==3.3.7 # via apache-superset pymonetdb==1.9.1 @@ -943,6 +972,7 @@ python-dotenv==1.2.2 # -c requirements/base-constraint.txt # apache-superset # fastmcp-slim + # microsoft-teams-apps # pydantic-settings # testcontainers python-ldap==3.4.8 @@ -989,6 +1019,7 @@ requests==2.33.0 # google-api-core # google-cloud-bigquery # jsonschema-path + # msal # opensearch-py # pydruid # pyhive @@ -1123,6 +1154,7 @@ sshtunnel==0.5.1 # apache-superset starlette==1.3.1 # via + # fastapi # fastmcp-slim # mcp starrocks==1.3.4 @@ -1165,7 +1197,9 @@ typing-extensions==4.16.0 # apache-superset # apache-superset-core # cattrs + # dependency-injector # exceptiongroup + # fastapi # fastmcp-slim # grpcio # httpx2 @@ -1177,6 +1211,7 @@ typing-extensions==4.16.0 # py-key-value-aio # pydantic # pydantic-core + # pydantic-settings # pyopenssl # referencing # shillelagh @@ -1187,6 +1222,7 @@ typing-extensions==4.16.0 typing-inspection==0.4.4 # via # -c requirements/base-constraint.txt + # fastapi # mcp # pydantic # pydantic-settings @@ -1221,6 +1257,7 @@ uvicorn==0.37.0 # via # fastmcp-slim # mcp + # microsoft-teams-apps verlib2==0.3.2 # via # crate From 47dc737b1b680d445b068deb0043fe3f3d68fa28 Mon Sep 17 00:00:00 2001 From: Kristi Dodaj Date: Mon, 26 Oct 2026 11:48:27 -0400 Subject: [PATCH 25/30] test(reports): isolate saved Teams format fixtures --- .../features/alerts/AlertReportModal.test.tsx | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/superset-frontend/src/features/alerts/AlertReportModal.test.tsx b/superset-frontend/src/features/alerts/AlertReportModal.test.tsx index 11416b4a0b0c..c8b35157e809 100644 --- a/superset-frontend/src/features/alerts/AlertReportModal.test.tsx +++ b/superset-frontend/src/features/alerts/AlertReportModal.test.tsx @@ -5017,20 +5017,24 @@ test.each(['PNG', 'CSV'])( }, { name: configurationEndpoint }, ); - fetchMock.get('glob:*/api/v1/report/96', { - result: { - ...generateMockPayload(true), - id: 96, - report_format: reportFormat, - recipients: [ - { - id: 1, - type: 'Email', - recipient_config_json: { target: 'test@example.com' }, - }, - ], + fetchMock.get( + 'glob:*/api/v1/report/96', + { + result: { + ...generateMockPayload(true), + id: 96, + report_format: reportFormat, + recipients: [ + { + id: 1, + type: 'Email', + recipient_config_json: { target: 'test@example.com' }, + }, + ], + }, }, - }); + { name: 'teams-saved-format' }, + ); fetchMock.get( 'glob:*/api/v1/report/teams_destinations/', { @@ -5080,6 +5084,7 @@ test.each(['PNG', 'CSV'])( ], }); } finally { + fetchMock.removeRoute('teams-saved-format'); fetchMock.removeRoute('teams-hidden-attachments'); fetchMock.removeRoute('save-teams-hidden'); fetchMock.removeRoute(configurationEndpoint); From 27c27f2cba5bbdf235eb7962903d5fa7bf4861ce Mon Sep 17 00:00:00 2001 From: Kristi Dodaj Date: Mon, 26 Oct 2026 13:12:18 -0400 Subject: [PATCH 26/30] fix(reports): preserve Teams delivery success and clarify team IDs --- docs/admin_docs/configuration/alerts-reports.mdx | 11 +++++++++++ superset/integrations/teams/adapter.py | 8 ++------ .../unit_tests/integrations/teams/delivery_test.py | 13 +++++++++---- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/docs/admin_docs/configuration/alerts-reports.mdx b/docs/admin_docs/configuration/alerts-reports.mdx index 1476f0085b11..ddcf1a0a2a88 100644 --- a/docs/admin_docs/configuration/alerts-reports.mdx +++ b/docs/admin_docs/configuration/alerts-reports.mdx @@ -973,6 +973,17 @@ Approve only standard channels in the configured tenant. Duplicate keys or channels and unknown configuration fields are rejected. An empty mapping leaves Teams unconfigured. +For `team_id`, use the Teams thread ID, for example `19:example@thread.tacv2`, +not the Microsoft 365 group GUID shown as the Team ID in Graph or the Teams +admin center. In Teams, open the team's **More options (…) > Get link to team**. +Copy the value between `/l/team/` and `/conversations` and URL-decode it +(`%3A` becomes `:` and `%40` becomes `@`). Do not use the link's `groupId` parameter. +For `channel_id`, use **Get link to channel** and URL-decode the value immediately +after `/l/channel/`. See Microsoft's [Teams link formats](https://learn.microsoft.com/en-us/microsoftteams/platform/concepts/build-and-test/deep-link-teams). +The installation callback matches `team_id` exactly. A group GUID will cause +installation events to return 403 and leave the destination without an active +installation. + Merge these feature flags into your existing flags. Restart the web server and workers after changing configuration or rotating a secret. Apply the migration before accepting installation events. The scheduler, broker, and worker setup diff --git a/superset/integrations/teams/adapter.py b/superset/integrations/teams/adapter.py index d3a15600fa07..78aeae3a3b80 100644 --- a/superset/integrations/teams/adapter.py +++ b/superset/integrations/teams/adapter.py @@ -492,8 +492,7 @@ async def _deliver_with_retries( raise TeamsDeliveryError("transient") try: await transport.post_root(destination, card) - if monotonic() >= deadline: - raise TeamsDeliveryError("transient") + # Preserve confirmed delivery even if the request exceeded its budget. return except TeamsTransportError as error: if error.status is None and not error.retryable: @@ -536,7 +535,7 @@ def deliver_teams_alert( retry_deadline: float | None = None, transport_factory: TransportFactory | None = None, ) -> None: - """Share one retry budget across authentication, posting and resource cleanup. + """Bound authentication and posting without reclassifying confirmed delivery. Synchronous MSAL requests and async SDK posts have explicit socket timeouts. The existing Celery soft/hard task limits bound blocking work that cannot be @@ -555,6 +554,3 @@ async def deliver() -> None: await _deliver_with_retries(transport, destination, card, deadline) asyncio.run(deliver()) - # Cleanup consumes the same budget; it must finish before reporting success. - if monotonic() >= deadline: - raise TeamsDeliveryError("transient") diff --git a/tests/unit_tests/integrations/teams/delivery_test.py b/tests/unit_tests/integrations/teams/delivery_test.py index 714905983a00..760af5a57e55 100644 --- a/tests/unit_tests/integrations/teams/delivery_test.py +++ b/tests/unit_tests/integrations/teams/delivery_test.py @@ -24,7 +24,7 @@ import threading import time from collections.abc import AsyncIterator, Iterator -from contextlib import asynccontextmanager +from contextlib import asynccontextmanager, nullcontext from datetime import datetime, timedelta, timezone from email.utils import format_datetime from types import SimpleNamespace @@ -788,12 +788,12 @@ async def factory(*args: Any) -> AsyncIterator[FakeTransport]: @pytest.mark.parametrize("phase", ["construction", "post", "cleanup"]) -def test_elapsed_work_and_cleanup_do_not_report_success_after_deadline( +def test_deadline_expiry_preserves_confirmed_delivery( configured: TeamsConfig, monkeypatch: pytest.MonkeyPatch, phase: str, ) -> None: - """Every phase shares the caller's deadline, including successful teardown.""" + """Expiry blocks a new post but does not turn confirmed delivery into failure.""" clock = [0.0] monkeypatch.setattr(adapter, "monotonic", lambda: clock[0]) transport = FakeTransport([None]) @@ -819,7 +819,12 @@ async def factory(*args: Any) -> AsyncIterator[FakeTransport]: if phase == "cleanup": clock[0] = 3.0 - with pytest.raises(adapter.TeamsDeliveryError, match="temporarily unavailable"): + expected = ( + pytest.raises(adapter.TeamsDeliveryError, match="temporarily unavailable") + if phase == "construction" + else nullcontext() + ) + with expected: adapter.deliver_teams_alert( configured, configured.destinations[0], From b6525161a111362484bdfd8987164980a6e90dff Mon Sep 17 00:00:00 2001 From: Kristi Dodaj Date: Wed, 28 Oct 2026 13:44:43 -0400 Subject: [PATCH 27/30] test(reports): assert exact Teams notification error types --- tests/unit_tests/integrations/teams/delivery_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit_tests/integrations/teams/delivery_test.py b/tests/unit_tests/integrations/teams/delivery_test.py index 760af5a57e55..99c02600989f 100644 --- a/tests/unit_tests/integrations/teams/delivery_test.py +++ b/tests/unit_tests/integrations/teams/delivery_test.py @@ -403,6 +403,7 @@ def test_transport_errors_map_to_existing_notification_exceptions( notifications._send_to_teams( configured, configured.destinations[0], SERVICE_URL, {} ) + assert type(exc.value) is exception assert exc.value.__cause__ is None From 6834d367fa79549b134b259d27402d5ba2f6acdc Mon Sep 17 00:00:00 2001 From: Kristi Dodaj Date: Thu, 29 Oct 2026 22:41:03 -0400 Subject: [PATCH 28/30] fix(reports): localize Teams notification text --- superset/reports/notifications/teams.py | 7 +++++-- superset/translations/messages.pot | 6 ++++++ .../integrations/teams/delivery_test.py | 2 ++ .../reports/notifications/teams_tests.py | 20 +++++++++++++++++++ 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/superset/reports/notifications/teams.py b/superset/reports/notifications/teams.py index 5466d7e67db3..deee0c6ebf3e 100644 --- a/superset/reports/notifications/teams.py +++ b/superset/reports/notifications/teams.py @@ -19,6 +19,7 @@ from typing import Any from urllib.parse import urlsplit +from flask_babel import gettext as __ from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session @@ -59,7 +60,9 @@ def build_teams_card(content: NotificationContent) -> dict[str, Any]: body.append( { "type": "TextBlock", - "text": "Alert execution failed. Contact the alert owner for details.", + "text": __( + "Alert execution failed. Contact the alert owner for details." + ), "wrap": True, } ) @@ -79,7 +82,7 @@ def build_teams_card(content: NotificationContent) -> dict[str, Any]: card["actions"] = [ { "type": "Action.OpenUrl", - "title": "Open in Superset", + "title": __("Open in Superset"), "url": content.url, } ] diff --git a/superset/translations/messages.pot b/superset/translations/messages.pot index d14275155227..2da3e2a5748d 100644 --- a/superset/translations/messages.pot +++ b/superset/translations/messages.pot @@ -1603,6 +1603,9 @@ msgstr "" msgid "Alert ended grace period." msgstr "" +msgid "Alert execution failed. Contact the alert owner for details." +msgstr "" + msgid "Alert failed" msgstr "" @@ -10186,6 +10189,9 @@ msgstr "" msgid "Open in SQL Lab" msgstr "" +msgid "Open in Superset" +msgstr "" + msgid "Open in SQL lab" msgstr "" diff --git a/tests/unit_tests/integrations/teams/delivery_test.py b/tests/unit_tests/integrations/teams/delivery_test.py index 99c02600989f..d91021faa6a5 100644 --- a/tests/unit_tests/integrations/teams/delivery_test.py +++ b/tests/unit_tests/integrations/teams/delivery_test.py @@ -36,6 +36,7 @@ import requests from billiard.exceptions import SoftTimeLimitExceeded from flask import current_app, Flask +from flask_babel import Babel from sqlalchemy.engine import Engine from sqlalchemy.orm import Session @@ -103,6 +104,7 @@ def notification_context( ) -> Iterator[SimpleNamespace]: """Use the real installation table and resolve through the notification class.""" app = Flask(__name__) + Babel(app) app.config.update( ALERT_REPORTS_TEAMS=teams_config, FEATURE_FLAGS={"ALERT_REPORT_TEAMS": True} ) diff --git a/tests/unit_tests/reports/notifications/teams_tests.py b/tests/unit_tests/reports/notifications/teams_tests.py index 516b52fbd5be..8e89402922de 100644 --- a/tests/unit_tests/reports/notifications/teams_tests.py +++ b/tests/unit_tests/reports/notifications/teams_tests.py @@ -22,6 +22,7 @@ import pytest from flask import Flask +from flask_babel import Babel from sqlalchemy.exc import SQLAlchemyError from superset.integrations.teams import validation @@ -41,6 +42,7 @@ def notification(monkeypatch: pytest.MonkeyPatch) -> Iterator[SimpleNamespace]: """Keep the transport replaceable while exercising real recipient validation.""" app = Flask(__name__) + Babel(app) app.config["ALERT_REPORTS_TEAMS"] = { "tenant_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", "application_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", @@ -237,6 +239,24 @@ def test_card_omits_empty_description_and_hides_execution_error( assert "credential" not in json.dumps(card) +def test_card_translates_failure_notice_and_link_title( + notification: SimpleNamespace, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Translate static card text without translating user-provided content.""" + translations = { + "Alert execution failed. Contact the alert owner for details.": "Échec.", + "Open in Superset": "Ouvrir dans Superset", + } + monkeypatch.setattr(teams, "__", translations.__getitem__) + notification.content.text = "internal diagnostic" + card = teams.build_teams_card(notification.content) + assert card["body"][0]["text"] == notification.content.name + assert card["body"][1]["text"] == notification.content.description + assert card["body"][2]["text"] == "Échec." + assert card["actions"][0]["title"] == "Ouvrir dans Superset" + + def test_delivery_still_reports_missing_sdk( notification: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, From 056cfe9d48aa7edb783112dea16a0b23002c140c Mon Sep 17 00:00:00 2001 From: Kristi Dodaj Date: Thu, 29 Oct 2026 22:48:13 -0400 Subject: [PATCH 29/30] fix(reports): preserve Teams review fixes after rebase --- .../configuration/alerts-reports.mdx | 91 ++++++++----------- docs/static/resources/openapi.json | 56 +++++++++++- .../tests/reports/teams-alerts.spec.ts | 19 ++-- ...0_7b9d2f8a6c31_add_teams_installations.py} | 8 +- superset/translations/messages.pot | 38 ++++++++ .../integrations/teams/delivery_test.py | 11 ++- .../integrations/teams/store_test.py | 2 +- .../teams/{api_test.py => teams_api_test.py} | 0 .../integrations/teams/validation_test.py | 1 + 9 files changed, 160 insertions(+), 66 deletions(-) rename superset/migrations/versions/{2026-10-19_00-00_7b9d2f8a6c31_add_teams_installations.py => 2026-10-28_00-00_7b9d2f8a6c31_add_teams_installations.py} (94%) rename tests/unit_tests/integrations/teams/{api_test.py => teams_api_test.py} (100%) diff --git a/docs/admin_docs/configuration/alerts-reports.mdx b/docs/admin_docs/configuration/alerts-reports.mdx index ddcf1a0a2a88..ae55df53f983 100644 --- a/docs/admin_docs/configuration/alerts-reports.mdx +++ b/docs/admin_docs/configuration/alerts-reports.mdx @@ -899,39 +899,7 @@ attachment-free alerts in standard channels. Each deployment supports one Microsoft tenant and bot identity. Scheduled reports, private/shared channels, direct messages, and file attachments are not supported. -Each alert creates a separate channel post containing its name, optional -description, and an **Open in Superset** link when link inclusion is enabled. -Execution errors show a generic failure message rather than SQL diagnostics. - -Administrators configure approved destinations on the server. In the alert -editor, disable attachments and select a destination by label. Add another Teams -notification method to notify another channel. Credentials are managed only in -deployment configuration. The protected destination API returns keys, labels, -and installation availability, without credentials or routing details. - -For API clients, use recipient type `Teams`, `recipient_config_json` containing -only `{"target": "ops"}`, and `report_format="NONE"`. Create, update, and delivery -validation enforce the same restrictions, including partial updates to saved -alerts. Alerts can be saved before bot installation, but delivery requires an -active installation. Unavailable saved destinations remain visible when editing. - -Authenticated installation events persist routing information for approved teams. -Removal disables delivery, and reinstallation refreshes the same record. Events -are applied in processing order and never create selectable destinations. - -Delivery allows up to four attempts within a cooperative 60-second retry budget, -limited by the remaining report execution deadline. Requests have socket timeouts -of at most 10 seconds. Throttling honors Microsoft's retry delay, and expired -authentication can refresh once. A lost response can cause duplicate posts. - -Socket timeouts do not bound DNS resolution or an entire streaming response. For -an outer time limit, use a Celery prefork worker with -[time-limit support](https://docs.celeryq.dev/en/stable/userguide/workers.html#time-limits), -keep `ALERT_REPORTS_WORKING_TIME_OUT_KILL=True`, and set a finite schedule -`working_timeout`. These limits cover the entire alert task, including query -execution and delivery. Hard task termination cannot guarantee resource cleanup. - -### Deploy the optional integration +### Install and configure Install the extra in both the web-server and Celery worker images using the same Superset release as the deployment: @@ -1073,6 +1041,34 @@ URL. Use the public Microsoft cloud endpoints supported by this adapter. Changin the bot identity requires matching configuration on both web and worker processes and a new verified installation for that identity. +### Create a Teams alert + +1. Install the app in each approved team once the callback is reachable. +2. In **Alerts & reports**, create an alert and disable attachments. +3. Choose **Teams** as the notification method and select an approved destination + by its display label. Add another Teams notification method to notify another + channel. +4. Save the alert and check its execution logs after delivery. + +Each alert creates a separate channel post containing its name, optional +description, and an **Open in Superset** link when link inclusion is enabled. +Execution errors show a generic failure message rather than SQL diagnostics. + +You can save an alert before installing the bot, but delivery requires an active +installation. Saved destinations that are no longer configured remain visible +as unavailable. Restore the destination's stable key or select a replacement. +Credentials are managed in deployment configuration, not in the alert editor. + +### Configure a recipient through the API + +Use recipient type `Teams`, `recipient_config_json` containing only +`{"target": "ops"}`, and `report_format="NONE"`. Replace `ops` with a configured +destination key. Create, update, and delivery validation enforce the same +restrictions, including partial updates to saved alerts. + +The protected destination API returns keys, labels, and installation availability, +without credentials or routing details. + ### Troubleshooting | Symptom | Check | @@ -1093,26 +1089,19 @@ access tokens, credentials, raw SDK responses, or SQL diagnostics into channel messages or issue reports. Rotate credentials through deployment configuration; there is no browser credential-management page. -### Test without sending messages - -Backend tests under `tests/unit_tests/integrations/teams/` exercise the real SDK -with fake HTTP transports. Install the `teams` extra to run authentication and -transport tests rather than skipping them. Notification contract tests at -`tests/unit_tests/reports/notifications/teams_tests.py` run without the SDK. +### Delivery timeouts and retries -The browser regression at `playwright/tests/reports/teams-alerts.spec.ts` requires a -running Superset instance, an admin test user, Reports and Teams enabled, at least -one configured destination, and a selectable test database. An unconfigured -integration skips this optional browser test. Use an isolated test deployment -with placeholder UUIDs and a dummy secret; no Microsoft app or installation is -needed for this test. It mocks destination availability, creates only inactive -alerts, blocks execution requests, verifies saved recipient keys, and deletes its -schedule afterward. It never clicks **Trigger now**. Run it from -`superset-frontend`: +Delivery allows up to four attempts within a cooperative 60-second retry budget, +limited by the remaining report execution deadline. Requests have socket timeouts +of at most 10 seconds. Throttling honors Microsoft's retry delay, and expired +authentication can refresh once. A lost response can cause duplicate posts. -```bash -npx playwright test playwright/tests/reports/teams-alerts.spec.ts --project=chromium -``` +Socket timeouts do not bound DNS resolution or an entire streaming response. For +an outer time limit, use a Celery prefork worker with +[time-limit support](https://docs.celeryq.dev/en/stable/userguide/workers.html#time-limits), +keep `ALERT_REPORTS_WORKING_TIME_OUT_KILL=True`, and set a finite schedule +`working_timeout`. These limits cover the entire alert task, including query +execution and delivery. Hard task termination cannot guarantee resource cleanup. :::resources diff --git a/docs/static/resources/openapi.json b/docs/static/resources/openapi.json index dfdbf9fdc984..23a956e926be 100644 --- a/docs/static/resources/openapi.json +++ b/docs/static/resources/openapi.json @@ -9448,7 +9448,8 @@ "Email", "Slack", "SlackV2", - "Webhook" + "Webhook", + "Teams" ], "type": "string" } @@ -29206,6 +29207,59 @@ ] } }, + "/api/v1/report/teams_destinations/": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "result": { + "items": { + "properties": { + "available": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "label": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + } + } + }, + "description": "Approved destinations and installation availability" + }, + "401": { + "$ref": "#/components/responses/401" + }, + "403": { + "$ref": "#/components/responses/403" + } + }, + "security": [ + { + "jwt": [] + }, + { + "api_key": [] + } + ], + "summary": "List approved Teams alert destinations", + "tags": [ + "Report Schedules" + ] + } + }, "/api/v1/report/{pk}": { "delete": { "parameters": [ diff --git a/superset-frontend/playwright/tests/reports/teams-alerts.spec.ts b/superset-frontend/playwright/tests/reports/teams-alerts.spec.ts index f6fc171f82a3..8eaf487b1f19 100644 --- a/superset-frontend/playwright/tests/reports/teams-alerts.spec.ts +++ b/superset-frontend/playwright/tests/reports/teams-alerts.spec.ts @@ -84,7 +84,7 @@ test('creates and edits an inactive Teams alert and preserves unavailable destin try { await page.goto('alert/list/'); await page - .getByRole('button', { name: 'Alert', exact: true }) + .getByRole('button', { name: 'plus Alert', exact: true }) .first() .click(); const modal = page.getByTestId('alert-report-modal'); @@ -94,13 +94,16 @@ test('creates and edits an inactive Teams alert and preserves unavailable destin await modal .getByRole('combobox', { name: 'Database', exact: true }) .click(); - await page.getByRole('option').first().click(); + await page + .locator('.ant-select-dropdown:visible .ant-select-item-option') + .first() + .click(); await modal.locator('.ace_editor').click(); await page.keyboard.insertText('SELECT 1'); await modal .getByRole('combobox', { name: 'Condition', exact: true }) .click(); - await page.getByRole('option', { name: 'Not null', exact: true }).click(); + await page.getByText('Not null', { exact: true }).click(); await modal.getByTestId('contents-panel').click(); const attachments = modal.getByRole('switch', { name: 'Include attachment', @@ -112,15 +115,17 @@ test('creates and edits an inactive Teams alert and preserves unavailable destin ).toBeVisible(); await modal.getByTestId('notification-method-panel').click(); await modal.getByRole('combobox', { name: /^Delivery method/ }).click(); - await page.getByRole('option', { name: 'Teams', exact: true }).click(); + await page.getByText('Teams', { exact: true }).click(); await modal.getByRole('combobox', { name: 'Teams destination' }).click(); await page - .getByRole('option', { - name: `${destination.label} (Bot installation required)`, + .getByText(`${destination.label} (Bot installation required)`, { exact: true, }) .click(); await expect(modal.getByText(/Install the notification bot/)).toBeVisible(); + await expect( + modal.getByRole('button', { name: 'Add', exact: true }), + ).toBeEnabled(); const created = page.waitForResponse( response => response.request().method() === 'POST' && @@ -138,7 +143,7 @@ test('creates and edits an inactive Teams alert and preserves unavailable destin const row = page .getByRole('row') .filter({ has: page.getByText(name, { exact: true }) }); - await row.getByLabel('edit-action', { exact: true }).click(); + await row.getByRole('button', { name: 'Edit', exact: true }).click(); await modal.getByTestId('notification-method-panel').click(); }; await edit(); diff --git a/superset/migrations/versions/2026-10-19_00-00_7b9d2f8a6c31_add_teams_installations.py b/superset/migrations/versions/2026-10-28_00-00_7b9d2f8a6c31_add_teams_installations.py similarity index 94% rename from superset/migrations/versions/2026-10-19_00-00_7b9d2f8a6c31_add_teams_installations.py rename to superset/migrations/versions/2026-10-28_00-00_7b9d2f8a6c31_add_teams_installations.py index 9a210d1f2322..08a240d5d7b2 100644 --- a/superset/migrations/versions/2026-10-19_00-00_7b9d2f8a6c31_add_teams_installations.py +++ b/superset/migrations/versions/2026-10-28_00-00_7b9d2f8a6c31_add_teams_installations.py @@ -17,7 +17,7 @@ """Add Teams bot installation persistence. Revision ID: 7b9d2f8a6c31 -Revises: c179af013f48 +Revises: f72b6e9c104a """ import sqlalchemy as sa @@ -25,13 +25,15 @@ from sqlalchemy.dialects import mysql from sqlalchemy_utils import UUIDType +from superset.migrations.shared.utils import create_table + revision: str = "7b9d2f8a6c31" -down_revision: str = "c179af013f48" +down_revision: str = "f72b6e9c104a" def upgrade() -> None: """Add installation metadata without changing alerts or destinations.""" - op.create_table( + create_table( "teams_installations", sa.Column("id", UUIDType(binary=True), nullable=False), sa.Column("tenant_id", UUIDType(binary=True), nullable=False), diff --git a/superset/translations/messages.pot b/superset/translations/messages.pot index 2da3e2a5748d..9b8f75fe33c1 100644 --- a/superset/translations/messages.pot +++ b/superset/translations/messages.pot @@ -18753,3 +18753,41 @@ msgstr[1] "" msgid "№" msgstr "" + +#, python-format +msgid "%s (Bot installation required)" +msgstr "" + +#, python-format +msgid "%s (Unavailable)" +msgstr "" + +msgid "" +"Install the notification bot in the approved team before delivery. You " +"can save this alert, but delivery requires an active installation." +msgstr "" + +msgid "No approved Teams destinations are configured. Contact your administrator." +msgstr "" + +msgid "Select an approved Teams channel" +msgstr "" + +msgid "" +"Teams delivery is unavailable. Contact your administrator to check the " +"integration configuration and optional Teams dependency." +msgstr "" + +msgid "Teams delivery requires an alert with attachments disabled." +msgstr "" + +msgid "Teams destination" +msgstr "" + +msgid "" +"This saved Teams destination is unavailable. Ask your administrator to " +"restore it or choose another destination." +msgstr "" + +msgid "Unable to load Teams destinations. Try reopening this dialog." +msgstr "" diff --git a/tests/unit_tests/integrations/teams/delivery_test.py b/tests/unit_tests/integrations/teams/delivery_test.py index d91021faa6a5..667d36583d7c 100644 --- a/tests/unit_tests/integrations/teams/delivery_test.py +++ b/tests/unit_tests/integrations/teams/delivery_test.py @@ -494,7 +494,7 @@ def request( if state.oauth_error: response.status_code = 400 data = { - "error": "invalid_client", + "error": state.oauth_error, "error_description": "secret SQL diagnostic", } response._content = json.dumps(data).encode() @@ -597,14 +597,17 @@ def test_real_sdk_refreshes_token_once_after_401( ([503, 503, 503, 503], None, "transient", 4), ([400], None, "parameter", 1), ([299], None, "parameter", 1), - ([], True, "authorization", 0), + ([], "invalid_client", "authorization", 0), + ([], "temporarily_unavailable", "transient", 0), + ([], "server_error", "transient", 0), ], ) def test_real_sdk_failures_close_resources_and_sanitize_errors( sdk_network: SimpleNamespace, configured: TeamsConfig, statuses: list[int], - oauth_error: bool | None, + oauth_error: str | None, + retry_clock: list[float], category: str, posts: int, ) -> None: @@ -618,6 +621,8 @@ def test_real_sdk_failures_close_resources_and_sanitize_errors( assert "secret SQL diagnostic" not in str(exc.value) assert "deployment-secret" not in str(exc.value) assert len(sdk_network.posts) == posts + if oauth_error in {"temporarily_unavailable", "server_error"}: + assert len(retry_clock) == 3 assert_resources_closed(sdk_network) diff --git a/tests/unit_tests/integrations/teams/store_test.py b/tests/unit_tests/integrations/teams/store_test.py index a53d48577399..f05e2ee392e1 100644 --- a/tests/unit_tests/integrations/teams/store_test.py +++ b/tests/unit_tests/integrations/teams/store_test.py @@ -39,7 +39,7 @@ from superset.integrations.teams.store import TeamsInstallationStore migration = import_module( - "superset.migrations.versions.2026-10-19_00-00_7b9d2f8a6c31_add_teams_installations" + "superset.migrations.versions.2026-10-28_00-00_7b9d2f8a6c31_add_teams_installations" ) TENANT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" APPLICATION = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" diff --git a/tests/unit_tests/integrations/teams/api_test.py b/tests/unit_tests/integrations/teams/teams_api_test.py similarity index 100% rename from tests/unit_tests/integrations/teams/api_test.py rename to tests/unit_tests/integrations/teams/teams_api_test.py diff --git a/tests/unit_tests/integrations/teams/validation_test.py b/tests/unit_tests/integrations/teams/validation_test.py index b38f994e6540..f2217ca9aeaf 100644 --- a/tests/unit_tests/integrations/teams/validation_test.py +++ b/tests/unit_tests/integrations/teams/validation_test.py @@ -160,6 +160,7 @@ def command_dependencies(monkeypatch: pytest.MonkeyPatch) -> None: "validate_report_frequency", "_validate_report_extra", "validate_recipients_policy", + "validate_executor_target_access", ): monkeypatch.setattr(BaseReportScheduleCommand, method, MagicMock()) monkeypatch.setattr( From 4b3efbdee22eab7ead13b1c143f78f3be3dd6cb1 Mon Sep 17 00:00:00 2001 From: Kristi Dodaj Date: Fri, 30 Oct 2026 13:19:57 -0400 Subject: [PATCH 30/30] fix(reports): cache Teams callback authentication and localize validation --- superset/commands/report/base.py | 4 +- superset/integrations/teams/adapter.py | 42 +++++++--- superset/integrations/teams/validation.py | 17 ++-- superset/translations/messages.pot | 27 ++++++ .../integrations/teams/destinations_test.py | 72 ++++++++++------ .../integrations/teams/teams_api_test.py | 83 +++++++++++++++++++ .../integrations/teams/validation_test.py | 14 ++++ 7 files changed, 210 insertions(+), 49 deletions(-) diff --git a/superset/commands/report/base.py b/superset/commands/report/base.py index e8839bdce5e9..46149d0e6cc7 100644 --- a/superset/commands/report/base.py +++ b/superset/commands/report/base.py @@ -579,12 +579,12 @@ def validate_teams_recipients(self, exceptions: list[ValidationError]) -> None: if recipient_type != ReportRecipientType.TEAMS: continue try: - _, destination = validate_teams_recipient( + _config, destination = validate_teams_recipient( recipient_config, report_type, report_format ) if destination.key in destination_keys: raise TeamsRecipientValidationError( - "Teams destination keys must be unique within a schedule" + _("Teams destination keys must be unique within a schedule") ) destination_keys.add(destination.key) except TeamsRecipientValidationError as ex: diff --git a/superset/integrations/teams/adapter.py b/superset/integrations/teams/adapter.py index 78aeae3a3b80..49dfdc96263b 100644 --- a/superset/integrations/teams/adapter.py +++ b/superset/integrations/teams/adapter.py @@ -20,11 +20,14 @@ import asyncio import math +import os from collections.abc import AsyncIterator, Callable from contextlib import AbstractAsyncContextManager, asynccontextmanager from dataclasses import dataclass from datetime import datetime, timezone from email.utils import parsedate_to_datetime +from functools import lru_cache +from threading import Lock from time import monotonic from typing import Any, Literal, Protocol from uuid import UUID @@ -76,9 +79,9 @@ class TeamsEventResult: class _FlaskServerAdapter(HttpServerAdapter): """Capture the SDK route for one invocation of Flask's existing POST route. - Flask registers its route once at startup. Each request creates a fresh SDK - authentication server; its handler runs to completion in an operation-scoped - event loop. No SDK App, outbound clients, tasks, or separate server are created. + Flask owns the listening server. SDK authentication is reused, while each + callback runs to completion in an operation-scoped event loop. No SDK App, + outbound clients, tasks, or separate listening server are created. """ def __init__(self) -> None: @@ -196,20 +199,34 @@ def _installation_event( ) +@lru_cache(maxsize=8) +def _callback_server( + process_id: int, application_id: str, tenant_id: str +) -> tuple[HttpServer, Lock]: + """Retain SDK signing-key caches per process and configured identity. + + The process ID avoids inheriting a locked server when a worker forks. + The lock serializes use of the SDK's mutable on_request callback. + """ + return HttpServer(_FlaskServerAdapter(), messaging_endpoint=EVENTS_PATH), Lock() + + def process_teams_event( config: TeamsConfig, body: dict[str, Any], headers: dict[str, str] ) -> TeamsEventResult: """Authenticate through the SDK's server adapter without starting a server. - The SDK's HttpServer uses PyJWKClient, whose network responses close within - its scoped fetch. It creates no persistent HTTP clients. Keep construction, - authentication, and adapter cleanup within the same event loop; asyncio.run - closes that loop on success and failure. SDK response bodies are never exposed. + Only the SDK server and its signing-key cache survive requests. Request + handlers and event loops are released after every callback, and no outbound + SDK client is created. Credentials and event bodies are not cache keys. """ + server, lock = _callback_server( + os.getpid(), config.application_id, config.tenant_id + ) async def process() -> TeamsEventResult: """Run one SDK invocation, returning only sanitized application data.""" - adapter = _FlaskServerAdapter() + adapter = server.adapter result = TeamsEventResult(500) async def on_request(event: ActivityEvent) -> InvokeResponse[None]: @@ -219,7 +236,6 @@ async def on_request(event: ActivityEvent) -> InvokeResponse[None]: return InvokeResponse(status=result.status) try: - server = HttpServer(adapter, messaging_endpoint=EVENTS_PATH) server.on_request = on_request server.initialize( credentials=ClientCredentials( @@ -229,18 +245,18 @@ async def on_request(event: ActivityEvent) -> InvokeResponse[None]: ), dangerously_allow_unauthenticated_requests=False, ) - if adapter.handler is None: - return TeamsEventResult(500) - response: HttpResponse = await adapter.handler( + response: HttpResponse = await server.handle_request( HttpRequest(body=body, headers=headers) ) if response["status"] != 200: return TeamsEventResult(response["status"]) return result finally: + server.on_request = None await adapter.stop() - return asyncio.run(process()) + with lock: + return asyncio.run(process()) # Count every send, including the single 401 replay, against the same retry bound. diff --git a/superset/integrations/teams/validation.py b/superset/integrations/teams/validation.py index 1519b7983767..4844e0888e8f 100644 --- a/superset/integrations/teams/validation.py +++ b/superset/integrations/teams/validation.py @@ -17,6 +17,7 @@ """Shared Teams recipient rules for schedule saves and notification delivery.""" from flask import current_app +from flask_babel import gettext as _ from superset.extensions import feature_flag_manager from superset.integrations.teams.config import ( @@ -49,22 +50,22 @@ def validate_teams_recipient( saved before a bot is installed. No SDK dependency or database access is needed. """ if not feature_flag_manager.is_feature_enabled("ALERT_REPORT_TEAMS"): - raise TeamsRecipientValidationError("Teams alert delivery is disabled") + raise TeamsRecipientValidationError(_("Teams alert delivery is disabled")) try: config = parse_teams_config(current_app.config.get("ALERT_REPORTS_TEAMS", {})) except ValueError: raise TeamsRecipientValidationError( - "Invalid Teams deployment configuration" + _("Invalid Teams deployment configuration") ) from None if config is None: - raise TeamsRecipientValidationError("Teams deployment is not configured") + raise TeamsRecipientValidationError(_("Teams deployment is not configured")) if schedule_type != ReportScheduleType.ALERT: raise TeamsRecipientValidationError( - "Teams recipients require an alert schedule", "type" + _("Teams recipients require an alert schedule"), "type" ) if report_format != ReportDataFormat.NONE: raise TeamsRecipientValidationError( - "Teams recipients require disabled attachments (NONE format)", + _("Teams recipients require disabled attachments (NONE format)"), "report_format", ) try: @@ -75,7 +76,7 @@ def validate_teams_recipient( ) except (ValueError, TypeError): raise TeamsRecipientValidationError( - "Invalid Teams recipient configuration" + _("Invalid Teams recipient configuration") ) from None if ( not isinstance(recipient, dict) @@ -84,12 +85,12 @@ def validate_teams_recipient( or not recipient["target"] ): raise TeamsRecipientValidationError( - "A configured Teams destination key is required" + _("A configured Teams destination key is required") ) try: destination = config.get_destination(recipient["target"]) except ValueError: raise TeamsRecipientValidationError( - "Teams destination is not approved" + _("Teams destination is not approved") ) from None return config, destination diff --git a/superset/translations/messages.pot b/superset/translations/messages.pot index 9b8f75fe33c1..84b84ebbbb8d 100644 --- a/superset/translations/messages.pot +++ b/superset/translations/messages.pot @@ -18791,3 +18791,30 @@ msgstr "" msgid "Unable to load Teams destinations. Try reopening this dialog." msgstr "" + +msgid "A configured Teams destination key is required" +msgstr "" + +msgid "Invalid Teams deployment configuration" +msgstr "" + +msgid "Invalid Teams recipient configuration" +msgstr "" + +msgid "Teams alert delivery is disabled" +msgstr "" + +msgid "Teams deployment is not configured" +msgstr "" + +msgid "Teams destination is not approved" +msgstr "" + +msgid "Teams destination keys must be unique within a schedule" +msgstr "" + +msgid "Teams recipients require an alert schedule" +msgstr "" + +msgid "Teams recipients require disabled attachments (NONE format)" +msgstr "" diff --git a/tests/unit_tests/integrations/teams/destinations_test.py b/tests/unit_tests/integrations/teams/destinations_test.py index c37460596f9f..ed44591a99ce 100644 --- a/tests/unit_tests/integrations/teams/destinations_test.py +++ b/tests/unit_tests/integrations/teams/destinations_test.py @@ -16,8 +16,11 @@ # under the License. """Browser discovery only projects approved keys and active-installation status.""" +import subprocess +import sys from collections.abc import Iterator from datetime import datetime, timezone +from textwrap import dedent from typing import Any from unittest.mock import MagicMock @@ -201,35 +204,52 @@ def test_optional_sdk_controls_both_discovery_and_notification_methods( session.assert_not_called() -def test_disabled_integration_registers_and_bootstraps_without_sdk( - configured_app: Flask, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The Teams startup paths require neither SDK imports nor a dependency lookup.""" - import sys +def test_disabled_integration_registers_and_bootstraps_without_sdk() -> None: + """Block optional imports before loading the startup paths in a fresh process.""" + result = subprocess.run( # noqa: S603 -- fixed interpreter and inline test script + [ + sys.executable, + "-c", + dedent(""" + import importlib.abc + import sys - from superset.integrations.teams.api import register_teams_api - from superset.reports.models import ReportRecipientType - from superset.views import base + class NoTeamsSDK(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if (fullname == "microsoft_teams" + or fullname.startswith("microsoft_teams.") + or fullname == "superset.integrations.teams.adapter"): + raise AssertionError("Unexpected optional import: " + fullname) - monkeypatch.setattr( - destinations.feature_flag_manager, "is_feature_enabled", lambda _: False - ) - missing_sdk = MagicMock(side_effect=AssertionError("must not check SDK")) - monkeypatch.setattr(destinations, "is_teams_sdk_installed", missing_sdk) - monkeypatch.setitem(sys.modules, "superset.integrations.teams.adapter", None) - monkeypatch.setitem(sys.modules, "microsoft_teams", None) - monkeypatch.setitem(sys.modules, "microsoft_teams.apps", None) - monkeypatch.setattr(base, "app", configured_app) - register_teams_api(configured_app) - assert destinations.configured_teams() is None - assert destinations.list_teams_destinations() == [] - assert ReportRecipientType.TEAMS not in base._notification_methods() - assert ( - configured_app.test_client().post("/api/v1/teams/events/", json={}).status_code - == 404 + sys.meta_path.insert(0, NoTeamsSDK()) + from superset.app import create_app + app = create_app() + from superset.integrations.teams import destinations + from superset.reports.models import ReportRecipientType + from superset.views import base + + def unexpected_lookup(): + raise AssertionError("must not check SDK") + + app.config["SLACK_API_TOKEN"] = None + destinations.feature_flag_manager.is_feature_enabled = lambda _: False + destinations.is_teams_sdk_installed = unexpected_lookup + base.app = app + with app.app_context(): + assert destinations.configured_teams() is None + assert destinations.list_teams_destinations() == [] + assert ReportRecipientType.TEAMS not in base._notification_methods() + assert app.test_client().post( + "/api/v1/teams/events/", json={} + ).status_code == 404 + """), + ], + capture_output=True, + text=True, + timeout=60, + check=False, ) - missing_sdk.assert_not_called() + assert result.returncode == 0, result.stdout + result.stderr def test_destination_availability_uses_one_query(configured_app: Flask) -> None: diff --git a/tests/unit_tests/integrations/teams/teams_api_test.py b/tests/unit_tests/integrations/teams/teams_api_test.py index a8eadbd87c51..1204e8db2a8c 100644 --- a/tests/unit_tests/integrations/teams/teams_api_test.py +++ b/tests/unit_tests/integrations/teams/teams_api_test.py @@ -21,7 +21,9 @@ import asyncio import importlib import importlib.util +import urllib.request from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor from copy import deepcopy from datetime import datetime, timedelta, timezone from io import BytesIO @@ -51,6 +53,7 @@ PATH = "/api/v1/teams/events/" TENANT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" APPLICATION = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" +_REAL_KEY_LOOKUP = jwt.PyJWKClient.get_signing_key_from_jwt OTHER = "cccccccc-cccc-4ccc-8ccc-cccccccccccc" SERVICE_URL = "https://smba.trafficmanager.net/amer/" @@ -66,6 +69,7 @@ def trusted_signing_key( monkeypatch: pytest.MonkeyPatch, signing_key: rsa.RSAPrivateKey ) -> None: """Replace only key discovery; the SDK verifies signature and claims itself.""" + teams_adapter._callback_server.cache_clear() monkeypatch.setattr( jwt.PyJWKClient, "get_signing_key_from_jwt", @@ -841,3 +845,82 @@ def test_terminated_stream_reads_are_bounded_before_parsing( parse.assert_not_called() sdk.assert_not_called() session.assert_not_called() + + +def test_callback_reuses_jwks_and_unknown_key_cooldown( + teams_config: dict[str, Any], + installation: dict[str, Any], + signing_key: rsa.RSAPrivateKey, + auth_headers: Callable[..., dict[str, str]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Separate callbacks share real signature verification and the SDK key cache.""" + monkeypatch.setattr(jwt.PyJWKClient, "get_signing_key_from_jwt", _REAL_KEY_LOOKUP) + key = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(signing_key.public_key())) + key.update(kid="trusted", use="sig") + opens = [] + clock = [0.0] + monkeypatch.setattr( + jwt.jwks_client, "time", SimpleNamespace(monotonic=lambda: clock[0]) + ) + + def open_keys(*args: Any, **kwargs: Any) -> BytesIO: + opens.append(args) + return BytesIO(json.dumps({"keys": [key]}).encode()) + + monkeypatch.setattr(urllib.request.OpenerDirector, "open", open_keys) + config = parse_teams_config(teams_config) + assert config is not None + claims = jwt.decode( + auth_headers()["Authorization"][7:], options={"verify_signature": False} + ) + for kid, status in [ + ("trusted", 200), + ("unknown-1", 401), + ("unknown-2", 401), + ("trusted", 200), + ]: + token = jwt.encode(claims, signing_key, algorithm="RS256", headers={"kid": kid}) + result = teams_adapter.process_teams_event( + config, installation, {"Authorization": "Bearer " + token} + ) + assert result.status == status + assert len(opens) == 1 + + clock[0] = 31.0 + key["kid"] = "rotated" + token = jwt.encode( + claims, signing_key, algorithm="RS256", headers={"kid": "rotated"} + ) + result = teams_adapter.process_teams_event( + config, installation, {"Authorization": "Bearer " + token} + ) + assert result.status == 200 + assert len(opens) == 2 + + +def test_cached_callback_keeps_concurrent_request_state_separate( + teams_config: dict[str, Any], + installation: dict[str, Any], + auth_headers: Callable[..., dict[str, str]], +) -> None: + """A cached SDK handler cannot leak event state into another callback.""" + config = parse_teams_config(teams_config) + assert config is not None + headers = auth_headers() + bodies = [dict(installation, action=action) for action in ["add", "remove"] * 4] + with ThreadPoolExecutor(max_workers=4) as pool: + results = list( + pool.map( + lambda body: teams_adapter.process_teams_event(config, body, headers), + bodies, + ) + ) + for body, result in zip(bodies, results, strict=True): + assert result.status == 200 + assert result.event is not None + assert result.event.active == (body["action"] == "add") + server, _ = teams_adapter._callback_server( + teams_adapter.os.getpid(), config.application_id, config.tenant_id + ) + assert server.on_request is None diff --git a/tests/unit_tests/integrations/teams/validation_test.py b/tests/unit_tests/integrations/teams/validation_test.py index f2217ca9aeaf..cb7c90d6c432 100644 --- a/tests/unit_tests/integrations/teams/validation_test.py +++ b/tests/unit_tests/integrations/teams/validation_test.py @@ -27,6 +27,7 @@ import pytest from flask import current_app, Flask +from flask_babel import Babel from superset.commands.report import create, execute, update from superset.commands.report.base import BaseReportScheduleCommand @@ -52,6 +53,7 @@ def configured_app( ) -> Iterator[Flask]: """Enable a single approved destination without requiring any installation.""" app = Flask(__name__) + Babel(app) app.config.update( FEATURE_FLAGS={"ALERT_REPORT_TEAMS": True}, ALERT_REPORTS_TEAMS=teams_config, @@ -676,3 +678,15 @@ def test_empty_email_fields_on_teams_are_delivery_changes( assert "A configured Teams destination key is required" in str( exc.value.normalized_messages()["recipients"] ) + + +def test_destination_validation_uses_translated_message( + configured_app: Flask, monkeypatch: pytest.MonkeyPatch +) -> None: + """The editor receives translated validation text for a removed destination.""" + monkeypatch.setattr(validation, "_", lambda message: "translated: " + message) + with pytest.raises( + validation.TeamsRecipientValidationError, + match="translated: Teams destination is not approved", + ): + validation.validate_teams_recipient({"target": "removed"}, "Alert", "NONE")