From d2852e4fad0f331dd8896e4b4dda8e5ac042328d Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Fri, 13 Mar 2026 13:55:08 -0300 Subject: [PATCH 01/17] Base saas_config_version model --- src/fides/api/db/base.py | 1 + src/fides/api/models/saas_config_version.py | 72 +++++++++++++++++++ .../api/schemas/saas/saas_config_version.py | 20 ++++++ 3 files changed, 93 insertions(+) create mode 100644 src/fides/api/models/saas_config_version.py create mode 100644 src/fides/api/schemas/saas/saas_config_version.py diff --git a/src/fides/api/db/base.py b/src/fides/api/db/base.py index c204a0062eb..85ce2706b7a 100644 --- a/src/fides/api/db/base.py +++ b/src/fides/api/db/base.py @@ -92,6 +92,7 @@ ) from fides.api.models.questionnaire import ChatMessage, Questionnaire from fides.api.models.registration import UserRegistration +from fides.api.models.saas_config_version import SaaSConfigVersion from fides.api.models.saas_template_dataset import SaasTemplateDataset from fides.api.models.storage import StorageConfig from fides.api.models.system_compass_sync import SystemCompassSync diff --git a/src/fides/api/models/saas_config_version.py b/src/fides/api/models/saas_config_version.py new file mode 100644 index 00000000000..b55e704e468 --- /dev/null +++ b/src/fides/api/models/saas_config_version.py @@ -0,0 +1,72 @@ +from typing import Any, Dict, Optional + +from sqlalchemy import Boolean, Column, String, UniqueConstraint +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.ext.declarative import declared_attr +from sqlalchemy.orm import Session + +from fides.api.db.base_class import Base + + +class SaaSConfigVersion(Base): + """ + Stores a snapshot of each SaaS integration config and dataset per version. + + A new row is upserted whenever a SaaS integration version is seen for the + first time — on startup (for bundled OOB connectors), on custom template + upload/update, or on direct SaaS config PATCH. Rows are never deleted so + that execution logs can always resolve the config/dataset that was active + when a DSR ran. + """ + + @declared_attr + def __tablename__(self) -> str: + return "saas_config_version" + + __table_args__ = ( + UniqueConstraint("connector_type", "version", name="uq_saas_config_version"), + ) + + connector_type = Column(String, nullable=False, index=True) + version = Column(String, nullable=False) + config = Column(JSONB, nullable=False) + dataset = Column(JSONB, nullable=True) + is_custom = Column(Boolean, nullable=False, default=False) + + def __repr__(self) -> str: + return f"" + + @classmethod + def upsert( + cls, + db: Session, + connector_type: str, + version: str, + config: Dict[str, Any], + dataset: Optional[Dict[str, Any]] = None, + is_custom: bool = False, + ) -> "SaaSConfigVersion": + """ + Insert a new version snapshot or return the existing one if already stored. + + If the row already exists the stored config/dataset are left unchanged — + a version string is treated as immutable once written. + """ + existing = ( + db.query(cls) + .filter(cls.connector_type == connector_type, cls.version == version) + .first() + ) + if existing: + return existing + + return cls.create( + db=db, + data={ + "connector_type": connector_type, + "version": version, + "config": config, + "dataset": dataset, + "is_custom": is_custom, + }, + ) diff --git a/src/fides/api/schemas/saas/saas_config_version.py b/src/fides/api/schemas/saas/saas_config_version.py new file mode 100644 index 00000000000..8ca832a0081 --- /dev/null +++ b/src/fides/api/schemas/saas/saas_config_version.py @@ -0,0 +1,20 @@ +from datetime import datetime +from typing import Optional + +from fides.api.schemas.base_class import FidesSchema + + +class SaaSConfigVersionResponse(FidesSchema): + """Summary of a stored SaaS integration version, used for list responses.""" + + connector_type: str + version: str + is_custom: bool + created_at: datetime + + +class SaaSConfigVersionDetailResponse(SaaSConfigVersionResponse): + """Full detail for a single version, including config and dataset as raw dicts.""" + + config: dict + dataset: Optional[dict] = None From a2e4db0436e842b6d398711cb9d2810ec74d767d Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Mon, 16 Mar 2026 11:27:38 -0300 Subject: [PATCH 02/17] Adding migration for the config version tracker --- ...5f7a9b1d2_add_saas_config_version_table.py | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/fides/api/alembic/migrations/versions/xx_2026_03_13_0000_c3e5f7a9b1d2_add_saas_config_version_table.py diff --git a/src/fides/api/alembic/migrations/versions/xx_2026_03_13_0000_c3e5f7a9b1d2_add_saas_config_version_table.py b/src/fides/api/alembic/migrations/versions/xx_2026_03_13_0000_c3e5f7a9b1d2_add_saas_config_version_table.py new file mode 100644 index 00000000000..e8d3cb35bda --- /dev/null +++ b/src/fides/api/alembic/migrations/versions/xx_2026_03_13_0000_c3e5f7a9b1d2_add_saas_config_version_table.py @@ -0,0 +1,57 @@ +"""add saas_config_version table + +Stores a snapshot of each SaaS integration config and dataset per +(connector_type, version) pair. Rows are written on startup for bundled +OOB connectors, on custom template upload/update, and on direct SaaS config +PATCH. Rows are never deleted so that execution logs can always resolve the +config/dataset that was active when a DSR ran. + +Revision ID: c3e5f7a9b1d2 +Revises: a1ca9ddf3c3c +Create Date: 2026-03-13 + +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "c3e5f7a9b1d2" +down_revision = "a1ca9ddf3c3c" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "saas_config_version", + sa.Column("id", sa.String(length=255), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True), + sa.Column("connector_type", sa.String(), nullable=False), + sa.Column("version", sa.String(), nullable=False), + sa.Column("config", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column("dataset", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("is_custom", sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("connector_type", "version", "is_custom", name="uq_saas_config_version"), + ) + op.create_index( + op.f("ix_saas_config_version_connector_type"), + "saas_config_version", + ["connector_type"], + unique=False, + ) + op.create_index( + op.f("ix_saas_config_version_id"), + "saas_config_version", + ["id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_saas_config_version_id"), table_name="saas_config_version") + op.drop_index(op.f("ix_saas_config_version_connector_type"), table_name="saas_config_version") + op.drop_table("saas_config_version") From 49882a7b68102cc8ac8f5fbeac1a43a9df705c50 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Mon, 16 Mar 2026 11:40:56 -0300 Subject: [PATCH 03/17] Base saas config versionn model --- src/fides/api/models/saas_config_version.py | 30 ++++++++++++++++----- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/src/fides/api/models/saas_config_version.py b/src/fides/api/models/saas_config_version.py index b55e704e468..37a47352f4d 100644 --- a/src/fides/api/models/saas_config_version.py +++ b/src/fides/api/models/saas_config_version.py @@ -24,7 +24,11 @@ def __tablename__(self) -> str: return "saas_config_version" __table_args__ = ( - UniqueConstraint("connector_type", "version", name="uq_saas_config_version"), + # is_custom is part of the key: the same version string can exist once + # as an OOB template and once as a custom override without conflict. + UniqueConstraint( + "connector_type", "version", "is_custom", name="uq_saas_config_version" + ), ) connector_type = Column(String, nullable=False, index=True) @@ -34,7 +38,7 @@ def __tablename__(self) -> str: is_custom = Column(Boolean, nullable=False, default=False) def __repr__(self) -> str: - return f"" + return f"" @classmethod def upsert( @@ -47,17 +51,31 @@ def upsert( is_custom: bool = False, ) -> "SaaSConfigVersion": """ - Insert a new version snapshot or return the existing one if already stored. + Insert or update a version snapshot. - If the row already exists the stored config/dataset are left unchanged — - a version string is treated as immutable once written. + - OOB rows (is_custom=False): treated as immutable once written — the + version string is controlled by Ethyca and the content never changes + for a given version. + - Custom rows (is_custom=True): config/dataset are updated in place so + that users can iterate on a custom template without bumping the version. """ existing = ( db.query(cls) - .filter(cls.connector_type == connector_type, cls.version == version) + .filter( + cls.connector_type == connector_type, + cls.version == version, + cls.is_custom == is_custom, + ) .first() ) + if existing: + if is_custom: + existing.config = config + existing.dataset = dataset + db.add(existing) + db.commit() + db.refresh(existing) return existing return cls.create( From 8459a2fbe2644242bfe36125eedaf8f272064594 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Mon, 16 Mar 2026 11:47:12 -0300 Subject: [PATCH 04/17] setting up out of the box sas config version on seed --- src/fides/api/db/seed.py | 41 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/fides/api/db/seed.py b/src/fides/api/db/seed.py index 85277fab09a..014c303514a 100644 --- a/src/fides/api/db/seed.py +++ b/src/fides/api/db/seed.py @@ -12,6 +12,7 @@ from fides.api.common_exceptions import KeyOrNameAlreadyExists from fides.api.db.base_class import FidesBase +from fides.api.models.saas_config_version import SaaSConfigVersion from fides.api.db.ctl_session import sync_session from fides.api.db.system import upsert_system from fides.api.models.application_config import ApplicationConfig @@ -322,6 +323,45 @@ def load_default_dsr_policies(session: Session) -> None: log.info("All default policies & rules created") +def sync_oob_saas_config_versions(session: Session) -> None: + """ + Upserts a SaaSConfigVersion row for every bundled (OOB) SaaS connector + template currently loaded in memory. + + Called on startup so the table is bootstrapped on first deploy and + picks up new template versions automatically on each upgrade. + Rows are immutable once written, so this is safe to call repeatedly. + """ + # Import here to avoid circular imports at module load time + from fides.api.service.connectors.saas.connector_registry_service import ( # pylint: disable=import-outside-toplevel + FileConnectorTemplateLoader, + ) + from fides.api.util.saas_util import ( # pylint: disable=import-outside-toplevel + load_config_from_string, + load_dataset_from_string, + ) + from fides.api.schemas.saas.saas_config import SaaSConfig # pylint: disable=import-outside-toplevel + + templates = FileConnectorTemplateLoader.get_connector_templates() + for connector_type, template in templates.items(): + try: + saas_config = SaaSConfig(**load_config_from_string(template.config)) + dataset = load_dataset_from_string(template.dataset) + SaaSConfigVersion.upsert( + db=session, + connector_type=connector_type, + version=saas_config.version, + config=saas_config.model_dump(mode="json"), + dataset=dataset, + is_custom=False, + ) + except Exception: # pylint: disable=broad-except + log.exception( + "Failed to sync SaaSConfigVersion for OOB connector '{}'", + connector_type, + ) + + def load_default_resources(session: Session) -> None: """ Seed the database with default resources that the application @@ -330,6 +370,7 @@ def load_default_resources(session: Session) -> None: load_default_organization(session) load_default_taxonomy(session) load_default_dsr_policies(session) + sync_oob_saas_config_versions(session) async def load_samples(async_session: AsyncSession) -> None: From 87c7168e79533a408894d98f0453d23d4117f996 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Mon, 16 Mar 2026 11:47:38 -0300 Subject: [PATCH 05/17] Updating saas template --- .../connectors/saas/connector_registry_service.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/fides/api/service/connectors/saas/connector_registry_service.py b/src/fides/api/service/connectors/saas/connector_registry_service.py index 251893f2404..1c611775667 100644 --- a/src/fides/api/service/connectors/saas/connector_registry_service.py +++ b/src/fides/api/service/connectors/saas/connector_registry_service.py @@ -12,6 +12,7 @@ from fides.api.cryptography.cryptographic_util import str_to_b64_str from fides.api.models.connectionconfig import ConnectionConfig from fides.api.models.custom_connector_template import CustomConnectorTemplate +from fides.api.models.saas_config_version import SaaSConfigVersion from fides.api.models.saas_template_dataset import SaasTemplateDataset from fides.api.schemas.saas.connector_template import ( ConnectorTemplate, @@ -330,6 +331,15 @@ def save_template(cls, db: Session, zip_file: ZipFile) -> str: dataset_json=template_dataset_json, ) + SaaSConfigVersion.upsert( + db=db, + connector_type=connector_type, + version=saas_config.version, + config=saas_config.model_dump(mode="json"), + dataset=template_dataset_json, + is_custom=True, + ) + # Bump the Redis version counter and clear the local cache so # every server detects the change on its next read. cls.get_connector_templates.bump_version() # type: ignore[attr-defined] From 2be3221dc17805fe97e0edec081ba06e321448cd Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Mon, 16 Mar 2026 11:48:00 -0300 Subject: [PATCH 06/17] creating new endpoints for saas_config_versions --- .../endpoints/connector_template_endpoints.py | 111 ++++++++++++++++++ .../api/v1/endpoints/saas_config_endpoints.py | 11 ++ src/fides/common/urn_registry.py | 3 + 3 files changed, 125 insertions(+) diff --git a/src/fides/api/v1/endpoints/connector_template_endpoints.py b/src/fides/api/v1/endpoints/connector_template_endpoints.py index e2c689fc02e..ef1ae78788c 100644 --- a/src/fides/api/v1/endpoints/connector_template_endpoints.py +++ b/src/fides/api/v1/endpoints/connector_template_endpoints.py @@ -14,16 +14,19 @@ ) from fides.api import deps +from fides.api.models.saas_config_version import SaaSConfigVersion from fides.api.oauth.utils import verify_oauth_client from fides.api.schemas.saas.connector_template import ( ConnectorTemplate, ConnectorTemplateListResponse, ) +from fides.api.schemas.saas.saas_config_version import SaaSConfigVersionResponse from fides.api.service.connectors.saas.connector_registry_service import ( ConnectorRegistry, CustomConnectorTemplateLoader, ) from fides.api.util.api_router import APIRouter +from fides.api.util.saas_util import load_config_from_string, load_dataset_from_string from fides.common.scope_registry import ( CONNECTOR_TEMPLATE_READ, CONNECTOR_TEMPLATE_REGISTER, @@ -34,6 +37,9 @@ CONNECTOR_TEMPLATES_CONFIG, CONNECTOR_TEMPLATES_DATASET, CONNECTOR_TEMPLATES_REGISTER, + CONNECTOR_TEMPLATES_VERSION_CONFIG, + CONNECTOR_TEMPLATES_VERSION_DATASET, + CONNECTOR_TEMPLATES_VERSIONS, DELETE_CUSTOM_TEMPLATE, REGISTER_CONNECTOR_TEMPLATE, V1_URL_PREFIX, @@ -252,3 +258,108 @@ def delete_custom_connector_template( return JSONResponse( content={"message": "Custom connector template successfully deleted."} ) + + +@router.get( + CONNECTOR_TEMPLATES_VERSIONS, + dependencies=[Security(verify_oauth_client, scopes=[CONNECTOR_TEMPLATE_READ])], + response_model=List[SaaSConfigVersionResponse], +) +def list_connector_template_versions( + connector_template_type: str, + db: Session = Depends(deps.get_db), +) -> List[SaaSConfigVersionResponse]: + """ + Returns all stored versions for a connector template type, newest first. + + Each entry includes the version string, whether it is a custom template, + and when it was first recorded. Use the version string with the + `/versions/{version}/config` and `/versions/{version}/dataset` endpoints + to inspect the full config or dataset for that version. + """ + rows = ( + db.query(SaaSConfigVersion) + .filter(SaaSConfigVersion.connector_type == connector_template_type) + .order_by(SaaSConfigVersion.created_at.desc()) + .all() + ) + return rows + + +@router.get( + CONNECTOR_TEMPLATES_VERSION_CONFIG, + dependencies=[Security(verify_oauth_client, scopes=[CONNECTOR_TEMPLATE_READ])], +) +def get_connector_template_version_config( + connector_template_type: str, + version: str, + db: Session = Depends(deps.get_db), +) -> Response: + """ + Retrieves the SaaS config for a specific version of a connector template. + + Returns the config as raw YAML, in the same format as + `GET /connector-templates/{type}/config`. + """ + import yaml # pylint: disable=import-outside-toplevel + + row = ( + db.query(SaaSConfigVersion) + .filter( + SaaSConfigVersion.connector_type == connector_template_type, + SaaSConfigVersion.version == version, + ) + .order_by(SaaSConfigVersion.created_at.desc()) + .first() + ) + if not row: + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, + detail=f"No stored version '{version}' found for connector type '{connector_template_type}'", + ) + return Response( + content=yaml.dump({"saas_config": row.config}, default_flow_style=False), + media_type="text/yaml", + ) + + +@router.get( + CONNECTOR_TEMPLATES_VERSION_DATASET, + dependencies=[Security(verify_oauth_client, scopes=[CONNECTOR_TEMPLATE_READ])], +) +def get_connector_template_version_dataset( + connector_template_type: str, + version: str, + db: Session = Depends(deps.get_db), +) -> Response: + """ + Retrieves the dataset for a specific version of a connector template. + + Returns the dataset as raw YAML, in the same format as + `GET /connector-templates/{type}/dataset`. + """ + import yaml # pylint: disable=import-outside-toplevel + + row = ( + db.query(SaaSConfigVersion) + .filter( + SaaSConfigVersion.connector_type == connector_template_type, + SaaSConfigVersion.version == version, + ) + .order_by(SaaSConfigVersion.created_at.desc()) + .first() + ) + if not row: + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, + detail=f"No stored version '{version}' found for connector type '{connector_template_type}'", + ) + if not row.dataset: + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, + detail=f"No dataset stored for version '{version}' of connector type '{connector_template_type}'", + ) + return Response( + content=yaml.dump({"dataset": [row.dataset]}, default_flow_style=False), + media_type="text/yaml", + ) diff --git a/src/fides/api/v1/endpoints/saas_config_endpoints.py b/src/fides/api/v1/endpoints/saas_config_endpoints.py index 359d1439449..ae6667f5969 100644 --- a/src/fides/api/v1/endpoints/saas_config_endpoints.py +++ b/src/fides/api/v1/endpoints/saas_config_endpoints.py @@ -25,6 +25,7 @@ from fides.api.common_exceptions import ValidationError as FidesValidationError from fides.api.models.connectionconfig import ConnectionConfig, ConnectionType from fides.api.models.datasetconfig import DatasetConfig +from fides.api.models.saas_config_version import SaaSConfigVersion from fides.api.models.event_audit import EventAuditStatus, EventAuditType from fides.api.models.sql_models import System # type: ignore from fides.api.oauth.utils import verify_oauth_client @@ -210,6 +211,16 @@ def patch_saas_config( connection_config.update_saas_config(db, saas_config=saas_config) + patched_template = ConnectorRegistry.get_connector_template(saas_config.type) + SaaSConfigVersion.upsert( + db=db, + connector_type=saas_config.type, + version=saas_config.version, + config=saas_config.model_dump(mode="json"), + dataset=None, # PATCH only updates the config; dataset is managed separately + is_custom=patched_template.custom if patched_template else True, + ) + # Create audit event for SaaS config update event_audit_service = EventAuditService(db) event_details, description = generate_connection_audit_event_details( diff --git a/src/fides/common/urn_registry.py b/src/fides/common/urn_registry.py index 7854eea469a..a3ba1c47cfb 100644 --- a/src/fides/common/urn_registry.py +++ b/src/fides/common/urn_registry.py @@ -199,6 +199,9 @@ CONNECTOR_TEMPLATES_REGISTER = "/connector-templates/register" CONNECTOR_TEMPLATES_CONFIG = "/connector-templates/{connector_template_type}/config" CONNECTOR_TEMPLATES_DATASET = "/connector-templates/{connector_template_type}/dataset" +CONNECTOR_TEMPLATES_VERSIONS = "/connector-templates/{connector_template_type}/versions" +CONNECTOR_TEMPLATES_VERSION_CONFIG = "/connector-templates/{connector_template_type}/versions/{version}/config" +CONNECTOR_TEMPLATES_VERSION_DATASET = "/connector-templates/{connector_template_type}/versions/{version}/dataset" DELETE_CUSTOM_TEMPLATE = "/connector-templates/{connector_template_type}" # Deprecated: Old connector template register URL From fbcafd053a4854d075a38332d5dc0895e3708133 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Tue, 17 Mar 2026 13:12:47 -0300 Subject: [PATCH 07/17] Adding model and schemas for connection_config_saas_history This will save a snapshot of the connection config history th check during runtime --- ...dd_connection_config_saas_history_table.py | 91 +++++++++++++++++++ .../models/connection_config_saas_history.py | 71 +++++++++++++++ src/fides/api/models/connectionconfig.py | 22 +++++ .../saas/connection_config_saas_history.py | 19 ++++ 4 files changed, 203 insertions(+) create mode 100644 src/fides/api/alembic/migrations/versions/xx_2026_03_17_0000_d4e6f8a0b2c3_add_connection_config_saas_history_table.py create mode 100644 src/fides/api/models/connection_config_saas_history.py create mode 100644 src/fides/api/schemas/saas/connection_config_saas_history.py diff --git a/src/fides/api/alembic/migrations/versions/xx_2026_03_17_0000_d4e6f8a0b2c3_add_connection_config_saas_history_table.py b/src/fides/api/alembic/migrations/versions/xx_2026_03_17_0000_d4e6f8a0b2c3_add_connection_config_saas_history_table.py new file mode 100644 index 00000000000..a99bb86f673 --- /dev/null +++ b/src/fides/api/alembic/migrations/versions/xx_2026_03_17_0000_d4e6f8a0b2c3_add_connection_config_saas_history_table.py @@ -0,0 +1,91 @@ +"""add connection_config_saas_history table + +Stores a per-connection snapshot of a SaaS config (and associated datasets) +each time ConnectionConfig.update_saas_config() is called. Unlike the +template-level saas_config_version table, this table is append-only and +scoped to an individual connection instance, so divergent configs are +preserved correctly. + +Revision ID: d4e6f8a0b2c3 +Revises: c3e5f7a9b1d2 +Create Date: 2026-03-17 + +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "d4e6f8a0b2c3" +down_revision = "c3e5f7a9b1d2" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "connection_config_saas_history", + sa.Column("id", sa.String(length=255), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column("connection_config_id", sa.String(length=255), nullable=True), + sa.Column("connection_key", sa.String(), nullable=False), + sa.Column("version", sa.String(), nullable=False), + sa.Column( + "config", postgresql.JSONB(astext_type=sa.Text()), nullable=False + ), + sa.Column( + "dataset", postgresql.JSONB(astext_type=sa.Text()), nullable=True + ), + sa.ForeignKeyConstraint( + ["connection_config_id"], + ["connectionconfig.id"], + ondelete="SET NULL", + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_connection_config_saas_history_id"), + "connection_config_saas_history", + ["id"], + unique=False, + ) + op.create_index( + "ix_connection_config_saas_history_config_id_created_at", + "connection_config_saas_history", + ["connection_config_id", "created_at"], + unique=False, + ) + op.create_index( + "ix_connection_config_saas_history_key_version", + "connection_config_saas_history", + ["connection_key", "version"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + "ix_connection_config_saas_history_key_version", + table_name="connection_config_saas_history", + ) + op.drop_index( + "ix_connection_config_saas_history_config_id_created_at", + table_name="connection_config_saas_history", + ) + op.drop_index( + op.f("ix_connection_config_saas_history_id"), + table_name="connection_config_saas_history", + ) + op.drop_table("connection_config_saas_history") diff --git a/src/fides/api/models/connection_config_saas_history.py b/src/fides/api/models/connection_config_saas_history.py new file mode 100644 index 00000000000..97a45d58123 --- /dev/null +++ b/src/fides/api/models/connection_config_saas_history.py @@ -0,0 +1,71 @@ +from typing import Any, Dict, List, Optional + +from sqlalchemy import Column, ForeignKey, String +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.ext.declarative import declared_attr +from sqlalchemy.orm import Session + +from fides.api.db.base_class import Base + + +class ConnectionConfigSaaSHistory(Base): + """ + Append-only snapshot of a connection's SaaS config taken each time + ConnectionConfig.update_saas_config() is called. + + Unlike SaaSConfigVersion (which stores one row per connector_type/version), + this table is scoped to an individual ConnectionConfig instance. It captures + divergent configs — e.g. when a connection is individually PATCHed to a + version that differs from the shared template. + + connection_key is denormalized so that history is still queryable if the + parent ConnectionConfig row is deleted (FK uses ON DELETE SET NULL). + """ + + @declared_attr + def __tablename__(self) -> str: + return "connection_config_saas_history" + + connection_config_id = Column( + String, + ForeignKey("connectionconfig.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + connection_key = Column(String, nullable=False) + version = Column(String, nullable=False) + config = Column(JSONB, nullable=False) + dataset = Column(JSONB, nullable=True) + + def __repr__(self) -> str: + return ( + f"" + ) + + @classmethod + def create_snapshot( + cls, + db: Session, + connection_config_id: str, + connection_key: str, + version: str, + config: Dict[str, Any], + datasets: Optional[List[Dict[str, Any]]] = None, + ) -> "ConnectionConfigSaaSHistory": + """ + Appends a new history row. Always creates a new row — no upsert logic — + so every write is preserved as a distinct audit entry. + """ + return cls.create( + db=db, + data={ + "connection_config_id": connection_config_id, + "connection_key": connection_key, + "version": version, + "config": config, + "dataset": datasets or None, + }, + ) diff --git a/src/fides/api/models/connectionconfig.py b/src/fides/api/models/connectionconfig.py index 7e2e32646f2..84eaa8c648a 100644 --- a/src/fides/api/models/connectionconfig.py +++ b/src/fides/api/models/connectionconfig.py @@ -348,6 +348,11 @@ def update_saas_config( Updates the SaaS config and initializes any empty secrets with connector param default values if available (will not override any existing secrets) """ + from fides.api.models.connection_config_saas_history import ( + ConnectionConfigSaaSHistory, + ) + from fides.api.models.datasetconfig import DatasetConfig + default_secrets = { connector_param.name: connector_param.default_value for connector_param in saas_config.connector_params @@ -356,6 +361,23 @@ def update_saas_config( updated_secrets = {**default_secrets, **(self.secrets or {})} self.secrets = updated_secrets self.saas_config = saas_config.model_dump(mode="json") + + datasets = [ + dc.ctl_dataset.dict() + for dc in db.query(DatasetConfig) + .filter(DatasetConfig.connection_config_id == self.id) + .all() + if dc.ctl_dataset + ] + ConnectionConfigSaaSHistory.create_snapshot( + db=db, + connection_config_id=self.id, + connection_key=self.key, + version=saas_config.version, + config=self.saas_config, + datasets=datasets or None, + ) + self.save(db) def update_test_status( diff --git a/src/fides/api/schemas/saas/connection_config_saas_history.py b/src/fides/api/schemas/saas/connection_config_saas_history.py new file mode 100644 index 00000000000..e0512bc03dd --- /dev/null +++ b/src/fides/api/schemas/saas/connection_config_saas_history.py @@ -0,0 +1,19 @@ +from datetime import datetime +from typing import Any, Dict, List, Optional + +from fides.api.schemas.base_class import FidesSchema + + +class ConnectionConfigSaaSHistoryResponse(FidesSchema): + """Summary of a per-connection SaaS config snapshot, used for list responses.""" + + id: str + version: str + created_at: datetime + + +class ConnectionConfigSaaSHistoryDetailResponse(ConnectionConfigSaaSHistoryResponse): + """Full detail for a single snapshot, including config and dataset.""" + + config: Dict[str, Any] + dataset: Optional[List[Dict[str, Any]]] = None From b3ebb010f3b9c814343e1e4a29d796454620dac5 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Tue, 17 Mar 2026 13:13:30 -0300 Subject: [PATCH 08/17] Adding saas_version_history endpoints --- .../api/v1/endpoints/saas_config_endpoints.py | 71 +++++- src/fides/common/urn_registry.py | 2 + .../endpoints/test_saas_config_endpoints.py | 237 ++++++++++++++++++ 3 files changed, 309 insertions(+), 1 deletion(-) diff --git a/src/fides/api/v1/endpoints/saas_config_endpoints.py b/src/fides/api/v1/endpoints/saas_config_endpoints.py index ae6667f5969..7aaa5a84765 100644 --- a/src/fides/api/v1/endpoints/saas_config_endpoints.py +++ b/src/fides/api/v1/endpoints/saas_config_endpoints.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import List, Optional from fastapi import Depends, HTTPException, Request from fastapi.encoders import jsonable_encoder @@ -23,10 +23,12 @@ SaaSConfigNotFoundException, ) from fides.api.common_exceptions import ValidationError as FidesValidationError +from fides.api.models.connection_config_saas_history import ConnectionConfigSaaSHistory from fides.api.models.connectionconfig import ConnectionConfig, ConnectionType from fides.api.models.datasetconfig import DatasetConfig from fides.api.models.saas_config_version import SaaSConfigVersion from fides.api.models.event_audit import EventAuditStatus, EventAuditType +from fides.api.models.saas_config_version import SaaSConfigVersion from fides.api.models.sql_models import System # type: ignore from fides.api.oauth.utils import verify_oauth_client from fides.api.schemas.connection_configuration.connection_config import ( @@ -35,6 +37,10 @@ from fides.api.schemas.connection_configuration.saas_config_template_values import ( SaasConnectionTemplateValues, ) +from fides.api.schemas.saas.connection_config_saas_history import ( + ConnectionConfigSaaSHistoryDetailResponse, + ConnectionConfigSaaSHistoryResponse, +) from fides.api.schemas.saas.saas_config import ( SaaSConfig, SaaSConfigValidationDetails, @@ -66,6 +72,8 @@ from fides.common.urn_registry import ( AUTHORIZE, SAAS_CONFIG, + SAAS_CONFIG_HISTORY, + SAAS_CONFIG_HISTORY_BY_VERSION, SAAS_CONFIG_VALIDATE, SAAS_CONNECTOR_FROM_TEMPLATE, V1_URL_PREFIX, @@ -313,6 +321,67 @@ def delete_saas_config( connection_config.update(db, data={"saas_config": None}) +@router.get( + SAAS_CONFIG_HISTORY, + dependencies=[Security(verify_oauth_client, scopes=[SAAS_CONFIG_READ])], + response_model=List[ConnectionConfigSaaSHistoryResponse], +) +def list_saas_config_history( + db: Session = Depends(deps.get_db), + connection_config: ConnectionConfig = Depends(_get_saas_connection_config), +) -> List[ConnectionConfigSaaSHistory]: + """ + Returns all per-connection SaaS config snapshots for the given connection, + ordered newest first. + """ + logger.info( + "Listing SaaS config history for connection '{}'", connection_config.key + ) + return ( + db.query(ConnectionConfigSaaSHistory) + .filter( + ConnectionConfigSaaSHistory.connection_config_id == connection_config.id + ) + .order_by(ConnectionConfigSaaSHistory.created_at.desc()) + .all() + ) + + +@router.get( + SAAS_CONFIG_HISTORY_BY_VERSION, + dependencies=[Security(verify_oauth_client, scopes=[SAAS_CONFIG_READ])], + response_model=ConnectionConfigSaaSHistoryDetailResponse, +) +def get_saas_config_history_by_version( + version: str, + db: Session = Depends(deps.get_db), + connection_config: ConnectionConfig = Depends(_get_saas_connection_config), +) -> ConnectionConfigSaaSHistory: + """ + Returns the most recent snapshot for the given connection and version string. + """ + logger.info( + "Fetching SaaS config history for connection '{}' version '{}'", + connection_config.key, + version, + ) + snapshot = ( + db.query(ConnectionConfigSaaSHistory) + .filter( + ConnectionConfigSaaSHistory.connection_config_id == connection_config.id, + ConnectionConfigSaaSHistory.version == version, + ) + .order_by(ConnectionConfigSaaSHistory.created_at.desc()) + .first() + ) + if not snapshot: + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, + detail=f"No SaaS config history found for connection '{connection_config.key}' version '{version}'", + ) + return snapshot + + @router.get( AUTHORIZE, dependencies=[Security(verify_oauth_client, scopes=[CONNECTION_AUTHORIZE])], diff --git a/src/fides/common/urn_registry.py b/src/fides/common/urn_registry.py index a3ba1c47cfb..f6367e05d02 100644 --- a/src/fides/common/urn_registry.py +++ b/src/fides/common/urn_registry.py @@ -192,6 +192,8 @@ # SaaS Config URLs SAAS_CONFIG_VALIDATE = CONNECTION_BY_KEY + "/validate_saas_config" SAAS_CONFIG = CONNECTION_BY_KEY + "/saas_config" +SAAS_CONFIG_HISTORY = CONNECTION_BY_KEY + "/saas-history" +SAAS_CONFIG_HISTORY_BY_VERSION = CONNECTION_BY_KEY + "/saas-history/{version}" SAAS_CONNECTOR_FROM_TEMPLATE = "/connection/instantiate/{connector_template_type}" # Connector Template URLs diff --git a/tests/ops/api/v1/endpoints/test_saas_config_endpoints.py b/tests/ops/api/v1/endpoints/test_saas_config_endpoints.py index a6e4d1dcdf0..c8aed37cf37 100644 --- a/tests/ops/api/v1/endpoints/test_saas_config_endpoints.py +++ b/tests/ops/api/v1/endpoints/test_saas_config_endpoints.py @@ -24,6 +24,8 @@ from fides.common.urn_registry import ( AUTHORIZE, SAAS_CONFIG, + SAAS_CONFIG_HISTORY, + SAAS_CONFIG_HISTORY_BY_VERSION, SAAS_CONFIG_VALIDATE, V1_URL_PREFIX, ) @@ -584,3 +586,238 @@ def test_get_authorize_url( response = api_client.get(authorize_url, headers=auth_header) response.raise_for_status() assert response.text == f'"{authorization_url}"' + + +@pytest.mark.unit_saas +class TestListSaaSConfigHistory: + @pytest.fixture + def history_url(self, saas_example_connection_config) -> str: + path = V1_URL_PREFIX + SAAS_CONFIG_HISTORY + return path.format(connection_key=saas_example_connection_config.key) + + def test_list_saas_config_history_unauthenticated( + self, history_url, api_client: TestClient + ) -> None: + response = api_client.get(history_url, headers={}) + assert response.status_code == 401 + + def test_list_saas_config_history_wrong_scope( + self, + history_url, + api_client: TestClient, + generate_auth_header, + ) -> None: + auth_header = generate_auth_header(scopes=[SAAS_CONFIG_CREATE_OR_UPDATE]) + response = api_client.get(history_url, headers=auth_header) + assert response.status_code == 403 + + def test_list_saas_config_history_connection_not_found( + self, + api_client: TestClient, + generate_auth_header, + ) -> None: + url = (V1_URL_PREFIX + SAAS_CONFIG_HISTORY).format( + connection_key="nonexistent_key" + ) + auth_header = generate_auth_header(scopes=[SAAS_CONFIG_READ]) + response = api_client.get(url, headers=auth_header) + assert response.status_code == 404 + + def test_list_saas_config_history_empty( + self, + history_url, + api_client: TestClient, + generate_auth_header, + ) -> None: + """Connection exists but update_saas_config has never been called — no snapshots.""" + auth_header = generate_auth_header(scopes=[SAAS_CONFIG_READ]) + response = api_client.get(history_url, headers=auth_header) + assert response.status_code == 200 + assert response.json() == [] + + def test_list_saas_config_history_after_patch( + self, + saas_example_config, + saas_example_connection_config, + api_client: TestClient, + db: Session, + generate_auth_header, + ) -> None: + """PATCH the saas config, then verify a history snapshot was created.""" + patch_url = (V1_URL_PREFIX + SAAS_CONFIG).format( + connection_key=saas_example_connection_config.key + ) + patch_auth = generate_auth_header(scopes=[SAAS_CONFIG_CREATE_OR_UPDATE]) + patch_resp = api_client.patch( + patch_url, headers=patch_auth, json=saas_example_config + ) + assert patch_resp.status_code == 200 + + history_url = (V1_URL_PREFIX + SAAS_CONFIG_HISTORY).format( + connection_key=saas_example_connection_config.key + ) + auth_header = generate_auth_header(scopes=[SAAS_CONFIG_READ]) + response = api_client.get(history_url, headers=auth_header) + assert response.status_code == 200 + items = response.json() + assert len(items) == 1 + item = items[0] + assert item["version"] == saas_example_config["version"] + assert "id" in item + assert "created_at" in item + # list response must not include the full config blob + assert "config" not in item + + def test_list_saas_config_history_multiple_patches_newest_first( + self, + saas_example_config, + saas_example_connection_config, + api_client: TestClient, + db: Session, + generate_auth_header, + ) -> None: + """Two PATCHes produce two snapshots ordered newest first.""" + patch_url = (V1_URL_PREFIX + SAAS_CONFIG).format( + connection_key=saas_example_connection_config.key + ) + patch_auth = generate_auth_header(scopes=[SAAS_CONFIG_CREATE_OR_UPDATE]) + + # first patch + api_client.patch(patch_url, headers=patch_auth, json=saas_example_config) + + # second patch — bump version so it's distinguishable + config_v2 = dict(saas_example_config) + config_v2["version"] = "0.0.2" + api_client.patch(patch_url, headers=patch_auth, json=config_v2) + + history_url = (V1_URL_PREFIX + SAAS_CONFIG_HISTORY).format( + connection_key=saas_example_connection_config.key + ) + auth_header = generate_auth_header(scopes=[SAAS_CONFIG_READ]) + response = api_client.get(history_url, headers=auth_header) + assert response.status_code == 200 + items = response.json() + assert len(items) == 2 + # newest first + assert items[0]["version"] == "0.0.2" + assert items[1]["version"] == saas_example_config["version"] + + +@pytest.mark.unit_saas +class TestGetSaaSConfigHistoryByVersion: + @pytest.fixture + def patched_connection_config( + self, + saas_example_config, + saas_example_connection_config, + api_client: TestClient, + generate_auth_header, + ) -> ConnectionConfig: + """Connection config that has had update_saas_config called once.""" + patch_url = (V1_URL_PREFIX + SAAS_CONFIG).format( + connection_key=saas_example_connection_config.key + ) + auth_header = generate_auth_header(scopes=[SAAS_CONFIG_CREATE_OR_UPDATE]) + api_client.patch(patch_url, headers=auth_header, json=saas_example_config) + return saas_example_connection_config + + def test_get_saas_config_history_by_version_unauthenticated( + self, + patched_connection_config, + api_client: TestClient, + ) -> None: + url = (V1_URL_PREFIX + SAAS_CONFIG_HISTORY_BY_VERSION).format( + connection_key=patched_connection_config.key, version="0.0.1" + ) + response = api_client.get(url, headers={}) + assert response.status_code == 401 + + def test_get_saas_config_history_by_version_wrong_scope( + self, + patched_connection_config, + api_client: TestClient, + generate_auth_header, + ) -> None: + url = (V1_URL_PREFIX + SAAS_CONFIG_HISTORY_BY_VERSION).format( + connection_key=patched_connection_config.key, version="0.0.1" + ) + auth_header = generate_auth_header(scopes=[SAAS_CONFIG_CREATE_OR_UPDATE]) + response = api_client.get(url, headers=auth_header) + assert response.status_code == 403 + + def test_get_saas_config_history_by_version_connection_not_found( + self, + api_client: TestClient, + generate_auth_header, + ) -> None: + url = (V1_URL_PREFIX + SAAS_CONFIG_HISTORY_BY_VERSION).format( + connection_key="nonexistent_key", version="0.0.1" + ) + auth_header = generate_auth_header(scopes=[SAAS_CONFIG_READ]) + response = api_client.get(url, headers=auth_header) + assert response.status_code == 404 + + def test_get_saas_config_history_by_version_not_found( + self, + patched_connection_config, + api_client: TestClient, + generate_auth_header, + ) -> None: + url = (V1_URL_PREFIX + SAAS_CONFIG_HISTORY_BY_VERSION).format( + connection_key=patched_connection_config.key, version="9.9.9" + ) + auth_header = generate_auth_header(scopes=[SAAS_CONFIG_READ]) + response = api_client.get(url, headers=auth_header) + assert response.status_code == 404 + + def test_get_saas_config_history_by_version_found( + self, + saas_example_config, + patched_connection_config, + api_client: TestClient, + generate_auth_header, + ) -> None: + version = saas_example_config["version"] + url = (V1_URL_PREFIX + SAAS_CONFIG_HISTORY_BY_VERSION).format( + connection_key=patched_connection_config.key, version=version + ) + auth_header = generate_auth_header(scopes=[SAAS_CONFIG_READ]) + response = api_client.get(url, headers=auth_header) + assert response.status_code == 200 + data = response.json() + assert data["version"] == version + assert "id" in data + assert "created_at" in data + assert "config" in data + assert data["config"]["fides_key"] == saas_example_config["fides_key"] + # no datasets associated in this fixture + assert data["dataset"] is None + + def test_get_saas_config_history_by_version_returns_most_recent( + self, + saas_example_config, + saas_example_connection_config, + api_client: TestClient, + generate_auth_header, + ) -> None: + """When the same version is patched twice, the most recent snapshot is returned.""" + patch_url = (V1_URL_PREFIX + SAAS_CONFIG).format( + connection_key=saas_example_connection_config.key + ) + patch_auth = generate_auth_header(scopes=[SAAS_CONFIG_CREATE_OR_UPDATE]) + + # patch twice with the same version + api_client.patch(patch_url, headers=patch_auth, json=saas_example_config) + modified = dict(saas_example_config) + modified["description"] = "second patch" + api_client.patch(patch_url, headers=patch_auth, json=modified) + + version = saas_example_config["version"] + url = (V1_URL_PREFIX + SAAS_CONFIG_HISTORY_BY_VERSION).format( + connection_key=saas_example_connection_config.key, version=version + ) + auth_header = generate_auth_header(scopes=[SAAS_CONFIG_READ]) + response = api_client.get(url, headers=auth_header) + assert response.status_code == 200 + data = response.json() + assert data["config"].get("description") == "second patch" From c22b276369a99bac3fac0e36b1bc40580c1d89c7 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Wed, 18 Mar 2026 11:39:14 -0300 Subject: [PATCH 09/17] Adding SaaSVersionModal and connector-template slice endpoints --- .../connector-templates/SaaSVersionModal.tsx | 186 ++++++++++++++++++ .../connector-template.slice.ts | 35 ++++ 2 files changed, 221 insertions(+) create mode 100644 clients/admin-ui/src/features/connector-templates/SaaSVersionModal.tsx diff --git a/clients/admin-ui/src/features/connector-templates/SaaSVersionModal.tsx b/clients/admin-ui/src/features/connector-templates/SaaSVersionModal.tsx new file mode 100644 index 00000000000..273c52c1cd5 --- /dev/null +++ b/clients/admin-ui/src/features/connector-templates/SaaSVersionModal.tsx @@ -0,0 +1,186 @@ +import React, { useState } from "react"; + +import { + Button, + ChakraBox as Box, + ChakraCode as Code, + ChakraSpinner as Spinner, + ChakraText as Text, + Tabs, +} from "fidesui"; + +import FormModal from "~/features/common/modals/FormModal"; +import { useGetDatastoreConnectionByKeyQuery } from "~/features/datastore-connections"; + +import { + useGetConnectorTemplateVersionConfigQuery, + useGetConnectorTemplateVersionDatasetQuery, +} from "./connector-template.slice"; + +interface SaaSVersionContentProps { + connectorType: string; + version: string; +} + +const SaaSVersionContent = ({ + connectorType, + version, +}: SaaSVersionContentProps) => { + const { + data: configYaml, + isLoading: configLoading, + isError: configError, + } = useGetConnectorTemplateVersionConfigQuery({ connectorType, version }); + + const { + data: datasetYaml, + isLoading: datasetLoading, + isError: datasetError, + } = useGetConnectorTemplateVersionDatasetQuery({ connectorType, version }); + + if (configLoading) { + return ( + + + + ); + } + + if (configError) { + return ( + + Could not load version config. + + ); + } + + const tabItems = [ + { + key: "config", + label: "Config", + children: ( + + {configYaml} + + ), + }, + { + key: "dataset", + label: "Dataset", + children: datasetLoading ? ( + + + + ) : datasetError ? ( + + No dataset available for this version. + + ) : ( + + {datasetYaml} + + ), + }, + ]; + + return ; +}; + +interface SaaSVersionModalProps { + isOpen: boolean; + onClose: () => void; + connectorType: string; + version: string; +} + +const SaaSVersionModal = ({ + isOpen, + onClose, + connectorType, + version, +}: SaaSVersionModalProps) => ( + + Close + + } + > + + +); + +interface VersionModalState { + connectionKey: string; + version: string; +} + +/** + * Hook providing a version detail modal keyed by connection key + version string. + * Resolves connector_type via the connection config before opening the modal. + */ +export const useSaaSVersionModal = () => { + const [pending, setPending] = useState(null); + const [active, setActive] = useState(null); + + const { data: connection } = useGetDatastoreConnectionByKeyQuery( + pending?.connectionKey ?? "", + { skip: !pending?.connectionKey }, + ); + + // Once the connection resolves, promote pending to active so the modal opens + React.useEffect(() => { + if (pending && connection?.saas_config?.type) { + setActive(pending); + setPending(null); + } + }, [pending, connection]); + + const openVersionModal = (connectionKey: string, version: string) => { + setPending({ connectionKey, version }); + }; + + const handleClose = () => setActive(null); + + const connectorType = connection?.saas_config?.type ?? null; + + const modal = + active && connectorType ? ( + + ) : null; + + return { openVersionModal, modal }; +}; + +export default SaaSVersionModal; diff --git a/clients/admin-ui/src/features/connector-templates/connector-template.slice.ts b/clients/admin-ui/src/features/connector-templates/connector-template.slice.ts index 4bd935dae92..4a46e411560 100644 --- a/clients/admin-ui/src/features/connector-templates/connector-template.slice.ts +++ b/clients/admin-ui/src/features/connector-templates/connector-template.slice.ts @@ -6,6 +6,13 @@ import { baseApi } from "~/features/common/api.slice"; export interface State {} const initialState: State = {}; +export interface SaaSConfigVersionResponse { + connector_type: string; + version: string; + is_custom: boolean; + created_at: string; +} + export const connectorTemplateSlice = createSlice({ name: "connectorTemplate", initialState, @@ -36,10 +43,38 @@ export const connectorTemplateApi = baseApi.injectEndpoints({ }), invalidatesTags: () => ["Connection Type"], }), + getConnectorTemplateVersions: build.query< + SaaSConfigVersionResponse[], + string + >({ + query: (connectorType) => + `${CONNECTOR_TEMPLATE}/${connectorType}/versions`, + }), + getConnectorTemplateVersionConfig: build.query< + string, + { connectorType: string; version: string } + >({ + query: ({ connectorType, version }) => ({ + url: `${CONNECTOR_TEMPLATE}/${connectorType}/versions/${version}/config`, + responseHandler: "text", + }), + }), + getConnectorTemplateVersionDataset: build.query< + string, + { connectorType: string; version: string } + >({ + query: ({ connectorType, version }) => ({ + url: `${CONNECTOR_TEMPLATE}/${connectorType}/versions/${version}/dataset`, + responseHandler: "text", + }), + }), }), }); export const { useRegisterConnectorTemplateMutation, useDeleteConnectorTemplateMutation, + useGetConnectorTemplateVersionsQuery, + useGetConnectorTemplateVersionConfigQuery, + useGetConnectorTemplateVersionDatasetQuery, } = connectorTemplateApi; From bf2caf2c862ce43958e1b73fe5963b5f5d21a803 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Wed, 18 Mar 2026 11:47:37 -0300 Subject: [PATCH 10/17] Adding saas version modal tests --- .../__tests__/SaaSVersionModal.test.tsx | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 clients/admin-ui/src/features/connector-templates/__tests__/SaaSVersionModal.test.tsx diff --git a/clients/admin-ui/src/features/connector-templates/__tests__/SaaSVersionModal.test.tsx b/clients/admin-ui/src/features/connector-templates/__tests__/SaaSVersionModal.test.tsx new file mode 100644 index 00000000000..2ae83d78be8 --- /dev/null +++ b/clients/admin-ui/src/features/connector-templates/__tests__/SaaSVersionModal.test.tsx @@ -0,0 +1,201 @@ +// Mock ESM-only packages — must be before imports (Jest hoists these) +jest.mock("query-string", () => ({ + __esModule: true, + default: { stringify: jest.fn(), parse: jest.fn() }, +})); +jest.mock("react-dnd", () => ({ + useDrag: jest.fn(() => [{}, jest.fn()]), + useDrop: jest.fn(() => [{}, jest.fn()]), + DndProvider: ({ children }: { children: React.ReactNode }) => children, +})); +// eslint-disable-next-line global-require +jest.mock("nuqs", () => require("../../../../__tests__/utils/nuqs-mock").nuqsMock); + +// RTK Query hook mocks +jest.mock("~/features/connector-templates/connector-template.slice", () => ({ + useGetConnectorTemplateVersionConfigQuery: jest.fn(), + useGetConnectorTemplateVersionDatasetQuery: jest.fn(), +})); + +jest.mock("~/features/datastore-connections", () => ({ + useGetDatastoreConnectionByKeyQuery: jest.fn(), + // Store imports datastoreConnectionSlice from this module — provide a minimal stub + datastoreConnectionSlice: { + name: "datastoreConnection", + reducer: (state = {}) => state, + }, +})); + +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import React from "react"; + +import { render } from "~/../__tests__/utils/test-utils"; +import SaaSVersionModal, { + useSaaSVersionModal, +} from "~/features/connector-templates/SaaSVersionModal"; +import { + useGetConnectorTemplateVersionConfigQuery, + useGetConnectorTemplateVersionDatasetQuery, +} from "~/features/connector-templates/connector-template.slice"; +import { useGetDatastoreConnectionByKeyQuery } from "~/features/datastore-connections"; + +// ── Typed mocks ──────────────────────────────────────────────────────────────── + +const mockUseConfig = useGetConnectorTemplateVersionConfigQuery as jest.Mock; +const mockUseDataset = useGetConnectorTemplateVersionDatasetQuery as jest.Mock; +const mockUseConnection = useGetDatastoreConnectionByKeyQuery as jest.Mock; + +// ── Fixtures ─────────────────────────────────────────────────────────────────── + +const STRIPE_CONFIG_YAML = `connector_type: stripe\nversion: "0.0.11"\n`; +const STRIPE_DATASET_YAML = `dataset:\n - name: stripe_dataset\n`; + +function setupDefaultMocks() { + mockUseConfig.mockReturnValue({ + data: STRIPE_CONFIG_YAML, + isLoading: false, + isError: false, + }); + mockUseDataset.mockReturnValue({ + data: STRIPE_DATASET_YAML, + isLoading: false, + isError: false, + }); + mockUseConnection.mockReturnValue({ data: null }); +} + +// ── SaaSVersionModal (direct usage) ─────────────────────────────────────────── + +describe("SaaSVersionModal", () => { + beforeEach(setupDefaultMocks); + + it("shows a loading spinner while config is fetching", () => { + mockUseConfig.mockReturnValue({ data: undefined, isLoading: true, isError: false }); + + render( + , + ); + + // Chakra Spinner renders as a div with class chakra-spinner (no ARIA role in JSDOM) + expect(document.querySelector(".chakra-spinner")).toBeInTheDocument(); + }); + + it("renders the modal title with connector type and version", () => { + render( + , + ); + + expect(screen.getByText("stripe — v0.0.11")).toBeInTheDocument(); + }); + + it("calls the config query with the correct connector type and version", () => { + render( + , + ); + + expect(mockUseConfig).toHaveBeenCalledWith({ connectorType: "stripe", version: "0.0.11" }); + }); + + it("calls the dataset query with the correct connector type and version", () => { + render( + , + ); + + expect(mockUseDataset).toHaveBeenCalledWith({ connectorType: "stripe", version: "0.0.11" }); + }); + + it("shows 'No dataset available' in the Dataset tab when the dataset endpoint errors", () => { + mockUseDataset.mockReturnValue({ data: undefined, isLoading: false, isError: true }); + + render( + , + ); + + // Activate the Dataset tab, then assert the fallback message + fireEvent.click(screen.getByText("Dataset")); + expect(screen.getByText("No dataset available for this version.")).toBeInTheDocument(); + }); + + it("shows an error message when config fails to load", () => { + mockUseConfig.mockReturnValue({ data: undefined, isLoading: false, isError: true }); + + render( + , + ); + + expect(screen.getByText("Could not load version config.")).toBeInTheDocument(); + }); + + it("calls onClose when the Close button is clicked", () => { + const onClose = jest.fn(); + + render( + , + ); + + fireEvent.click(screen.getByTestId("version-modal-close-btn")); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("does not render when isOpen is false", () => { + render( + , + ); + + expect(screen.queryByText("stripe — v0.0.11")).not.toBeInTheDocument(); + }); +}); + +// ── useSaaSVersionModal hook ─────────────────────────────────────────────────── + +const HookConsumer = ({ + connectionKey, + version, +}: { + connectionKey: string; + version: string; +}) => { + const { openVersionModal, modal } = useSaaSVersionModal(); + return ( + <> + {modal} + + + ); +}; + +describe("useSaaSVersionModal", () => { + beforeEach(setupDefaultMocks); + + it("opens the modal once the connection resolves a connector type", async () => { + mockUseConnection.mockReturnValue({ + data: { saas_config: { type: "stripe" } }, + }); + + render(); + + fireEvent.click(screen.getByTestId("trigger")); + + await waitFor(() => { + expect(screen.getByText("stripe — v0.0.11")).toBeInTheDocument(); + }); + }); + + it("does not open if the connection has no saas_config type", async () => { + mockUseConnection.mockReturnValue({ data: { saas_config: null } }); + + render(); + + fireEvent.click(screen.getByTestId("trigger")); + + await waitFor(() => { + expect(screen.queryByText(/— v0\.0\.11/)).not.toBeInTheDocument(); + }); + }); +}); From e9c5c43c2869bd548dceb4bfcd25148e4a5351c2 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Wed, 18 Mar 2026 11:56:35 -0300 Subject: [PATCH 11/17] Adding Version History Tab component --- .../integrations/VersionHistoryTab.tsx | 113 +++++++++++++++ .../__tests__/VersionHistoryTab.test.tsx | 134 ++++++++++++++++++ 2 files changed, 247 insertions(+) create mode 100644 clients/admin-ui/src/features/integrations/VersionHistoryTab.tsx create mode 100644 clients/admin-ui/src/features/integrations/__tests__/VersionHistoryTab.test.tsx diff --git a/clients/admin-ui/src/features/integrations/VersionHistoryTab.tsx b/clients/admin-ui/src/features/integrations/VersionHistoryTab.tsx new file mode 100644 index 00000000000..9577ba969e3 --- /dev/null +++ b/clients/admin-ui/src/features/integrations/VersionHistoryTab.tsx @@ -0,0 +1,113 @@ +import { formatDate } from "common/utils"; +import { + Button, + ChakraSpinner as Spinner, + ChakraText as Text, + CUSTOM_TAG_COLOR, + Table, + Tag, + Typography, +} from "fidesui"; +import React, { useState } from "react"; + +import SaaSVersionModal from "~/features/connector-templates/SaaSVersionModal"; +import { + SaaSConfigVersionResponse, + useGetConnectorTemplateVersionsQuery, +} from "~/features/connector-templates/connector-template.slice"; + +interface VersionHistoryTabProps { + connectorType: string; +} + +const VersionHistoryTab = ({ connectorType }: VersionHistoryTabProps) => { + const { data: versions, isLoading } = + useGetConnectorTemplateVersionsQuery(connectorType); + + const [selected, setSelected] = useState( + null, + ); + + const columns = [ + { + title: "Version", + dataIndex: "version", + key: "version", + render: (v: string) => ( + + v{v} + + ), + }, + { + title: "Type", + dataIndex: "is_custom", + key: "is_custom", + render: (isCustom: boolean) => ( + + {isCustom ? "Custom" : "OOB"} + + ), + }, + { + title: "Captured at", + dataIndex: "created_at", + key: "created_at", + render: (ts: string) => ( + + {formatDate(ts)} + + ), + }, + { + title: "", + key: "actions", + render: (_: unknown, row: SaaSConfigVersionResponse) => ( + + ), + }, + ]; + + if (isLoading) { + return ; + } + + if (!versions || versions.length === 0) { + return ( + + No version history captured yet. + + ); + } + + return ( + <> + + All captured versions of this connector's configuration. Each + entry reflects the config and dataset snapshot at the time it was + recorded. + + + `${row.version}-${row.is_custom}` + } + size="small" + pagination={false} + /> + {selected && ( + setSelected(null)} + connectorType={selected.connector_type} + version={selected.version} + /> + )} + + ); +}; + +export default VersionHistoryTab; diff --git a/clients/admin-ui/src/features/integrations/__tests__/VersionHistoryTab.test.tsx b/clients/admin-ui/src/features/integrations/__tests__/VersionHistoryTab.test.tsx new file mode 100644 index 00000000000..eafd4b2ae3a --- /dev/null +++ b/clients/admin-ui/src/features/integrations/__tests__/VersionHistoryTab.test.tsx @@ -0,0 +1,134 @@ +// Mock ESM-only packages — must be before imports (Jest hoists these) +jest.mock("query-string", () => ({ + __esModule: true, + default: { stringify: jest.fn(), parse: jest.fn() }, +})); +jest.mock("react-dnd", () => ({ + useDrag: jest.fn(() => [{}, jest.fn()]), + useDrop: jest.fn(() => [{}, jest.fn()]), + DndProvider: ({ children }: { children: React.ReactNode }) => children, +})); +// eslint-disable-next-line global-require +jest.mock("nuqs", () => require("../../../../__tests__/utils/nuqs-mock").nuqsMock); + +jest.mock("~/features/connector-templates/connector-template.slice", () => ({ + useGetConnectorTemplateVersionsQuery: jest.fn(), +})); + +// Stub SaaSVersionModal so its own RTK deps don't need wiring up +jest.mock("~/features/connector-templates/SaaSVersionModal", () => ({ + __esModule: true, + default: ({ + isOpen, + connectorType, + version, + }: { + isOpen: boolean; + connectorType: string; + version: string; + onClose: () => void; + }) => + isOpen ? ( +
+ {connectorType} v{version} +
+ ) : null, +})); + +import { fireEvent, screen } from "@testing-library/react"; +import React from "react"; + +import { render } from "~/../__tests__/utils/test-utils"; +import { useGetConnectorTemplateVersionsQuery } from "~/features/connector-templates/connector-template.slice"; +import VersionHistoryTab from "~/features/integrations/VersionHistoryTab"; + +// ── Typed mock ───────────────────────────────────────────────────────────────── + +const mockUseVersions = useGetConnectorTemplateVersionsQuery as jest.Mock; + +// ── Fixtures ─────────────────────────────────────────────────────────────────── + +const VERSIONS = [ + { + connector_type: "stripe", + version: "0.0.12", + is_custom: false, + created_at: "2026-03-01T10:00:00Z", + }, + { + connector_type: "stripe", + version: "0.0.11", + is_custom: true, + created_at: "2026-02-15T08:00:00Z", + }, +]; + +// ── Tests ────────────────────────────────────────────────────────────────────── + +describe("VersionHistoryTab", () => { + it("shows a spinner while loading", () => { + mockUseVersions.mockReturnValue({ data: undefined, isLoading: true }); + + render(); + + // Chakra Spinner renders as a div with class chakra-spinner (no ARIA role in JSDOM) + expect(document.querySelector(".chakra-spinner")).toBeInTheDocument(); + }); + + it("shows empty-state message when no versions are available", () => { + mockUseVersions.mockReturnValue({ data: [], isLoading: false }); + + render(); + + expect(screen.getByText("No version history captured yet.")).toBeInTheDocument(); + }); + + it("renders a row for each captured version", () => { + mockUseVersions.mockReturnValue({ data: VERSIONS, isLoading: false }); + + render(); + + expect(screen.getByText("v0.0.12")).toBeInTheDocument(); + expect(screen.getByText("v0.0.11")).toBeInTheDocument(); + }); + + it("shows OOB badge for non-custom and Custom badge for custom versions", () => { + mockUseVersions.mockReturnValue({ data: VERSIONS, isLoading: false }); + + render(); + + expect(screen.getByText("OOB")).toBeInTheDocument(); + expect(screen.getByText("Custom")).toBeInTheDocument(); + }); + + it("opens the version modal when a View button is clicked", () => { + mockUseVersions.mockReturnValue({ data: VERSIONS, isLoading: false }); + + render(); + + const viewButtons = screen.getAllByRole("button", { name: /view/i }); + fireEvent.click(viewButtons[0]); + + expect(screen.getByTestId("version-modal")).toBeInTheDocument(); + expect(screen.getByText("stripe v0.0.12")).toBeInTheDocument(); + }); + + it("shows the second version's details when its View button is clicked", () => { + mockUseVersions.mockReturnValue({ data: VERSIONS, isLoading: false }); + + render(); + + const viewButtons = screen.getAllByRole("button", { name: /view/i }); + fireEvent.click(viewButtons[1]); + + expect(screen.getByText("stripe v0.0.11")).toBeInTheDocument(); + }); + + it("passes the connector type to the query", () => { + mockUseVersions.mockReturnValue({ data: [], isLoading: false }); + + render(); + + expect(mockUseVersions).toHaveBeenCalledWith("hubspot"); + }); +}); From 648b323949a5ed02a84701644649a741d36598a5 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Tue, 17 Mar 2026 13:15:04 -0300 Subject: [PATCH 12/17] Adding the connection config timeline --- .../events-and-logs/ActivityTimeline.tsx | 1 + .../events-and-logs/EventLog.tsx | 24 ++++++++++++++++++- .../events-and-logs/LogDrawer.tsx | 3 +++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/clients/admin-ui/src/features/privacy-requests/events-and-logs/ActivityTimeline.tsx b/clients/admin-ui/src/features/privacy-requests/events-and-logs/ActivityTimeline.tsx index f4ab8c172a7..0c4def2dca2 100644 --- a/clients/admin-ui/src/features/privacy-requests/events-and-logs/ActivityTimeline.tsx +++ b/clients/admin-ui/src/features/privacy-requests/events-and-logs/ActivityTimeline.tsx @@ -187,6 +187,7 @@ const ActivityTimeline = ({ subjectRequest }: ActivityTimelineProps) => { onOpenErrorPanel={openErrorPanel} onCloseErrorPanel={closeErrorPanel} privacyRequest={subjectRequest} + connectionKey={currentKey || undefined} /> ); diff --git a/clients/admin-ui/src/features/privacy-requests/events-and-logs/EventLog.tsx b/clients/admin-ui/src/features/privacy-requests/events-and-logs/EventLog.tsx index 5c4b013173b..aff8dfe0bc6 100644 --- a/clients/admin-ui/src/features/privacy-requests/events-and-logs/EventLog.tsx +++ b/clients/admin-ui/src/features/privacy-requests/events-and-logs/EventLog.tsx @@ -22,6 +22,7 @@ import { } from "privacy-requests/types"; import React from "react"; +import { useSaaSVersionModal } from "~/features/connector-templates/SaaSVersionModal"; import { ActionType } from "~/types/api"; type EventDetailsProps = { @@ -29,6 +30,7 @@ type EventDetailsProps = { allEventLogs?: ExecutionLog[]; // All event logs from all groups for total calculation onDetailPanel: (message: string, status?: ExecutionLogStatus) => void; privacyRequest?: PrivacyRequestEntity; + connectionKey?: string; }; const actionTypeToLabel = (actionType: string) => { @@ -153,7 +155,9 @@ const EventLog = ({ allEventLogs, onDetailPanel, privacyRequest, + connectionKey, }: EventDetailsProps) => { + const { openVersionModal, modal: versionModal } = useSaaSVersionModal(); // Check if any logs have collection_name OR if there's a finished entry to determine if we should show Records and Collection columns const hasDatasetEntries = eventLogs?.some((log) => log.collection_name) || @@ -269,7 +273,24 @@ const EventLog = ({ {hasDatasetEntries && !isRequestFinishedView && (
{detail.saas_version ? ( - v{detail.saas_version} + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions + { + e.stopPropagation(); + openVersionModal(connectionKey, detail.saas_version!); + } + : undefined + } + title={connectionKey ? "View version config" : undefined} + data-testid="version-badge-wrapper" + > + + v{detail.saas_version} + + ) : ( + {versionModal} void; onOpenErrorPanel: (message: string, status?: ExecutionLogStatus) => void; privacyRequest?: PrivacyRequestEntity; + connectionKey?: string; }; const LogDrawer = ({ @@ -44,6 +45,7 @@ const LogDrawer = ({ onCloseErrorPanel, onOpenErrorPanel, privacyRequest, + connectionKey, }: LogDrawerProps) => { const headerText = isViewingError ? "Event detail" : "Event log"; @@ -99,6 +101,7 @@ const LogDrawer = ({ allEventLogs={allEventLogs} onDetailPanel={onOpenErrorPanel} privacyRequest={privacyRequest} + connectionKey={connectionKey} /> ) : null} {isViewingError ? ( From 262eef5c4a30ec1adb5f2a19c5d173ecee430c0b Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Tue, 17 Mar 2026 17:16:07 -0300 Subject: [PATCH 13/17] Recovering missing tab --- .../integrations/hooks/useFeatureBasedTabs.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/clients/admin-ui/src/features/integrations/hooks/useFeatureBasedTabs.tsx b/clients/admin-ui/src/features/integrations/hooks/useFeatureBasedTabs.tsx index ed93d985669..460a9b90c8d 100644 --- a/clients/admin-ui/src/features/integrations/hooks/useFeatureBasedTabs.tsx +++ b/clients/admin-ui/src/features/integrations/hooks/useFeatureBasedTabs.tsx @@ -18,6 +18,7 @@ import ConnectionStatusNotice, { ConnectionStatusData, } from "~/features/integrations/ConnectionStatusNotice"; import IntegrationLinkedSystems from "~/features/integrations/IntegrationLinkedSystems"; +import VersionHistoryTab from "~/features/integrations/VersionHistoryTab"; import { ConnectionSystemTypeMap, IntegrationFeature } from "~/types/api"; interface UseFeatureBasedTabsProps { @@ -181,6 +182,15 @@ export const useFeatureBasedTabs = ({ }); } + const connectorType = connection?.saas_config?.type; + if (connectorType) { + tabItems.push({ + label: "Version history", + key: "version-history", + children: , + }); + } + return tabItems; }, [ enabledFeatures, From 4363b2c1d25b3f64a0ce61d7b36bb072d345ae97 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Wed, 18 Mar 2026 11:57:27 -0300 Subject: [PATCH 14/17] Add event log versions tests --- .../__tests__/EventLog.test.tsx | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 clients/admin-ui/src/features/privacy-requests/events-and-logs/__tests__/EventLog.test.tsx diff --git a/clients/admin-ui/src/features/privacy-requests/events-and-logs/__tests__/EventLog.test.tsx b/clients/admin-ui/src/features/privacy-requests/events-and-logs/__tests__/EventLog.test.tsx new file mode 100644 index 00000000000..c5ddffc69f5 --- /dev/null +++ b/clients/admin-ui/src/features/privacy-requests/events-and-logs/__tests__/EventLog.test.tsx @@ -0,0 +1,140 @@ +// Mock ESM-only packages — must be before imports (Jest hoists these) +jest.mock("query-string", () => ({ + __esModule: true, + default: { stringify: jest.fn(), parse: jest.fn() }, +})); +jest.mock("react-dnd", () => ({ + useDrag: jest.fn(() => [{}, jest.fn()]), + useDrop: jest.fn(() => [{}, jest.fn()]), + DndProvider: ({ children }: { children: React.ReactNode }) => children, +})); +// eslint-disable-next-line global-require +jest.mock("nuqs", () => require("../../../../../__tests__/utils/nuqs-mock").nuqsMock); + +// Capture openVersionModal so tests can assert on calls +const mockOpenVersionModal = jest.fn(); +jest.mock("~/features/connector-templates/SaaSVersionModal", () => ({ + useSaaSVersionModal: () => ({ + openVersionModal: mockOpenVersionModal, + modal: null, + }), +})); + +import { fireEvent, screen } from "@testing-library/react"; +import React from "react"; + +import { render } from "~/../__tests__/utils/test-utils"; +import EventLog from "~/features/privacy-requests/events-and-logs/EventLog"; +import { + ExecutionLog, + ExecutionLogStatus, +} from "~/features/privacy-requests/types"; +import { ActionType } from "~/types/api"; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +const makeLog = (overrides: Partial = {}): ExecutionLog => ({ + collection_name: "stripe_customer", + fields_affected: [], + message: "success - retrieved 3 records", + action_type: ActionType.ACCESS, + status: ExecutionLogStatus.COMPLETE, + updated_at: "2026-03-01T10:00:00Z", + saas_version: null, + ...overrides, +}); + +const noop = () => {}; + +// ── Tests ────────────────────────────────────────────────────────────────────── + +describe("EventLog — version badge", () => { + beforeEach(() => { + mockOpenVersionModal.mockClear(); + }); + + it("renders the version badge when saas_version is present", () => { + render( + , + ); + + expect(screen.getByText("v0.0.11")).toBeInTheDocument(); + }); + + it("shows a dash in the Version column when saas_version is null", () => { + render( + , + ); + + // Records column also shows "-" for completed rows with no parseable count, + // so just confirm the Version column header is present (dataset entries exist) + expect(screen.getByText("Version")).toBeInTheDocument(); + }); + + it("does not make the badge clickable when connectionKey is absent", () => { + render( + , + ); + + const wrapper = screen.getByTestId("version-badge-wrapper"); + + expect(wrapper).not.toHaveAttribute("title"); + expect(wrapper.style.cursor).toBeFalsy(); + + fireEvent.click(wrapper); + expect(mockOpenVersionModal).not.toHaveBeenCalled(); + }); + + it("makes the badge clickable and triggers openVersionModal when connectionKey is given", () => { + render( + , + ); + + const wrapper = screen.getByTestId("version-badge-wrapper"); + + expect(wrapper).toHaveAttribute("title", "View version config"); + + fireEvent.click(wrapper); + expect(mockOpenVersionModal).toHaveBeenCalledTimes(1); + expect(mockOpenVersionModal).toHaveBeenCalledWith("stripe_conn", "0.0.11"); + }); + + it("passes the correct version for each row when multiple versioned logs are shown", () => { + const logs = [ + makeLog({ saas_version: "0.0.11", updated_at: "2026-03-01T10:00:00Z" }), + makeLog({ saas_version: "0.0.12", updated_at: "2026-03-02T10:00:00Z" }), + ]; + + render( + , + ); + + const wrappers = screen.getAllByTestId("version-badge-wrapper"); + expect(wrappers).toHaveLength(2); + + fireEvent.click(wrappers[0]); + expect(mockOpenVersionModal).toHaveBeenLastCalledWith("stripe_conn", "0.0.11"); + + fireEvent.click(wrappers[1]); + expect(mockOpenVersionModal).toHaveBeenLastCalledWith("stripe_conn", "0.0.12"); + }); + + it("does not show the Version column when no logs have a collection_name", () => { + render( + , + ); + + expect(screen.queryByText("Version")).not.toBeInTheDocument(); + }); +}); From 90585a8d61d8b8f96724208283b993b39bbc9dc6 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Wed, 18 Mar 2026 15:37:01 -0300 Subject: [PATCH 15/17] Fix useSaaSVersionModal: capture connectorType in active state connectorType was derived from the RTK Query result, which returns undefined once pending is cleared (skip: true). The modal condition active && connectorType would always be falsy, so the modal never opened in production. Store connectorType directly in active state so the modal renders independently of the skipped query. --- .../connector-templates/SaaSVersionModal.tsx | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/clients/admin-ui/src/features/connector-templates/SaaSVersionModal.tsx b/clients/admin-ui/src/features/connector-templates/SaaSVersionModal.tsx index 273c52c1cd5..e497c26e3c0 100644 --- a/clients/admin-ui/src/features/connector-templates/SaaSVersionModal.tsx +++ b/clients/admin-ui/src/features/connector-templates/SaaSVersionModal.tsx @@ -136,28 +136,35 @@ const SaaSVersionModal = ({ ); -interface VersionModalState { +interface PendingModalState { connectionKey: string; version: string; } +interface ActiveModalState { + connectorType: string; + version: string; +} + /** * Hook providing a version detail modal keyed by connection key + version string. * Resolves connector_type via the connection config before opening the modal. */ export const useSaaSVersionModal = () => { - const [pending, setPending] = useState(null); - const [active, setActive] = useState(null); + const [pending, setPending] = useState(null); + const [active, setActive] = useState(null); const { data: connection } = useGetDatastoreConnectionByKeyQuery( pending?.connectionKey ?? "", { skip: !pending?.connectionKey }, ); - // Once the connection resolves, promote pending to active so the modal opens + // Once the connection resolves, promote pending to active so the modal opens. + // connectorType is captured into active so the modal doesn't depend on the + // query after pending is cleared (skip: true returns undefined data). React.useEffect(() => { if (pending && connection?.saas_config?.type) { - setActive(pending); + setActive({ connectorType: connection.saas_config.type, version: pending.version }); setPending(null); } }, [pending, connection]); @@ -168,17 +175,14 @@ export const useSaaSVersionModal = () => { const handleClose = () => setActive(null); - const connectorType = connection?.saas_config?.type ?? null; - - const modal = - active && connectorType ? ( - - ) : null; + const modal = active ? ( + + ) : null; return { openVersionModal, modal }; }; From 53aa35d2a8539d3911a7eeee035f99eae8a61169 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Wed, 18 Mar 2026 15:37:39 -0300 Subject: [PATCH 16/17] Clear pending in useSaaSVersionModal when connection has no saas_config type If openVersionModal was called with a non-SaaS connection key, the effect condition never fired and pending stayed set indefinitely, keeping the hook subscribed to that connection key. Now pending is always cleared once connection resolves, regardless of whether a connectorType was found. --- .../src/features/connector-templates/SaaSVersionModal.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/clients/admin-ui/src/features/connector-templates/SaaSVersionModal.tsx b/clients/admin-ui/src/features/connector-templates/SaaSVersionModal.tsx index e497c26e3c0..d44b87d5edd 100644 --- a/clients/admin-ui/src/features/connector-templates/SaaSVersionModal.tsx +++ b/clients/admin-ui/src/features/connector-templates/SaaSVersionModal.tsx @@ -162,11 +162,14 @@ export const useSaaSVersionModal = () => { // Once the connection resolves, promote pending to active so the modal opens. // connectorType is captured into active so the modal doesn't depend on the // query after pending is cleared (skip: true returns undefined data). + // If the connection has no saas_config.type (non-SaaS), bail out silently + // so pending doesn't stay set indefinitely. React.useEffect(() => { - if (pending && connection?.saas_config?.type) { + if (!pending || !connection) return; + if (connection.saas_config?.type) { setActive({ connectorType: connection.saas_config.type, version: pending.version }); - setPending(null); } + setPending(null); }, [pending, connection]); const openVersionModal = (connectionKey: string, version: string) => { From d53ebe4fd94bda888bf1b45f4d9022205b333720 Mon Sep 17 00:00:00 2001 From: Vagoasdf Date: Wed, 18 Mar 2026 15:38:57 -0300 Subject: [PATCH 17/17] Replace non-interactive span with button for version badge The clickable version badge was using a with onClick, requiring two jsx-a11y eslint-disable comments. Replace with a {detail.saas_version ? ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions - { - e.stopPropagation(); - openVersionModal(connectionKey, detail.saas_version!); - } - : undefined - } - title={connectionKey ? "View version config" : undefined} - data-testid="version-badge-wrapper" - > - - v{detail.saas_version} - - + connectionKey ? ( + + ) : ( + + v{detail.saas_version} + + ) ) : (