From aed63963cdcce403eadb0f9de34de80edd1a5cc6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 18:35:46 +0000 Subject: [PATCH 1/2] Add Vercel shared environment variables via scope.kind Require destination teamId and explicit scope.kind (environment | shared-environment). Project env keeps /v10 project APIs; shared env uses /v1/env with optional scope.projects and exact projectId matching for list/prune. Offline validate catches missing teamId/project/kind. Co-authored-by: Abhishek Chadha --- README.md | 33 + examples/secretsync.yaml | 2 + src/secretsync/application/validate.py | 53 +- src/secretsync/destinations/vercel.py | 641 ++++++++++++++++-- src/secretsync/init_templates.py | 14 +- tests/fixtures/valid_full.yaml | 1 + tests/fixtures/variables_mixed.yaml | 2 + .../vercel_environment_missing_project.yaml | 24 + tests/fixtures/vercel_missing_team.yaml | 24 + .../fixtures/vercel_sensitive_deprecated.yaml | 2 + tests/integration/test_vercel.py | 424 ++++++++++-- tests/smoke/test_providers.py | 7 +- tests/unit/test_validate_plan.py | 14 + 13 files changed, 1093 insertions(+), 148 deletions(-) create mode 100644 tests/fixtures/vercel_environment_missing_project.yaml create mode 100644 tests/fixtures/vercel_missing_team.yaml diff --git a/README.md b/README.md index 497c87a..5c187ed 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,39 @@ deployments: Kind is declared once under `secrets` or `variables`. Connectors map kind to the provider primitive (GitHub secrets vs variables APIs; Vercel `type: sensitive` vs `encrypted`). SST supports secrets only — non-secret SST config belongs in code as [Linkables](https://sst.dev/docs/component/linkable/). > **Breaking:** Vercel `scope.sensitive` is removed. Put sensitive values under `deployment.secrets` and plaintext under `deployment.variables`. +> +> **Breaking:** Vercel destinations require `teamId`. Project env deployments need `scope.kind: environment` (and destination `project`). Team shared env uses `scope.kind: shared-environment` with optional `scope.projects`. + +Vercel destination modes (selected by `scope.kind`): + +```yaml +destinations: + vercel: + connector: vercel + teamId: team_xyz # required + project: prj_abc # optional; required for kind: environment + auth: + tokenEnv: VERCEL_TOKEN + +deployments: + - name: vercel-production + set: production + destination: vercel + scope: + kind: environment + targets: [production] + secrets: + apiKey: API_KEY + - name: vercel-shared + set: production + destination: vercel + scope: + kind: shared-environment + targets: [production] + projects: [prj_abc, prj_def] # optional link set + secrets: + sharedSecret: SHARED_SECRET +``` ## Deploy secrets diff --git a/examples/secretsync.yaml b/examples/secretsync.yaml index 1af2f7a..2df612f 100644 --- a/examples/secretsync.yaml +++ b/examples/secretsync.yaml @@ -70,6 +70,7 @@ deployments: set: production destination: vercel scope: + kind: environment targets: [production] secrets: secretOneProd: SECRET_ONE @@ -80,6 +81,7 @@ deployments: set: staging destination: vercel scope: + kind: environment targets: [preview] secrets: secretOneStaging: SECRET_ONE diff --git a/src/secretsync/application/validate.py b/src/secretsync/application/validate.py index f23b262..a86cfa2 100644 --- a/src/secretsync/application/validate.py +++ b/src/secretsync/application/validate.py @@ -132,15 +132,8 @@ def _validate_deployments( f"'{destination.connector}'" ) - if destination.connector == "vercel" and "sensitive" in deployment.scope: - raise ConfigInvalidError( - f"Deployment '{deployment.name}' uses deprecated scope.sensitive on Vercel", - hint=( - "Remove scope.sensitive. Put sensitive values under deployment.secrets " - "and plaintext under deployment.variables; the vercel connector sets " - "type from kind." - ), - ) + if destination.connector == "vercel": + _validate_vercel_deployment(deployment, destination) available = composed[deployment.set] kinds_used: set[ValueKind] = set() @@ -199,6 +192,48 @@ def _validate_deployments( ) +def _validate_vercel_deployment(deployment: DeploymentDefinition, destination: object) -> None: + from secretsync.destinations.vercel import _project, _team_id, _validate_scope + + dest_cfg = destination.model_dump(by_alias=True) # type: ignore[attr-defined] + if _team_id(dest_cfg) is None: + raise ConfigInvalidError( + f"Destination '{deployment.destination}' (vercel) requires teamId", + hint="Set destinations..teamId to your Vercel team id (team_…)", + ) + if "sensitive" in deployment.scope: + raise ConfigInvalidError( + f"Deployment '{deployment.name}' uses deprecated scope.sensitive on Vercel", + hint=( + "Remove scope.sensitive. Put sensitive values under deployment.secrets " + "and plaintext under deployment.variables; the vercel connector sets " + "type from kind." + ), + ) + kinds: list[ValueKind] = [] + if deployment.secrets: + kinds.append(ValueKind.SECRET) + if deployment.variables: + kinds.append(ValueKind.VARIABLE) + if not kinds: + kinds.append(ValueKind.SECRET) + project = _project(dest_cfg) + for kind in kinds: + reason = _validate_scope( + deployment.scope, # type: ignore[arg-type] + kind=kind, + destination_project=project, + ) + if reason: + raise ConfigInvalidError( + f"Deployment '{deployment.name}' has invalid Vercel scope: {reason}", + hint=( + "Use scope.kind: environment (requires destination.project) or " + "shared-environment (optional scope.projects)." + ), + ) + + def _record_target( seen_targets: set[tuple[str, str, str, str]], deployment: DeploymentDefinition, diff --git a/src/secretsync/destinations/vercel.py b/src/secretsync/destinations/vercel.py index 97bc69f..e345f0d 100644 --- a/src/secretsync/destinations/vercel.py +++ b/src/secretsync/destinations/vercel.py @@ -1,4 +1,4 @@ -"""Vercel project environment variables destination (bulk upsert).""" +"""Vercel project and shared environment variables destination.""" from __future__ import annotations @@ -27,9 +27,13 @@ VERCEL_API = "https://api.vercel.com" API_PATH = "/v10/projects/{project}/env" -EDIT_PATH = "/v9/projects/{project}/env/{env_id}" +SHARED_ENV_PATH = "/v1/env" DEFAULT_MAX_ITEMS = 100 +SHARED_MAX_ITEMS = 50 SENSITIVE_TARGETS = frozenset({"production", "preview"}) +SCOPE_KIND_ENVIRONMENT = "environment" +SCOPE_KIND_SHARED = "shared-environment" +VALID_SCOPE_KINDS = frozenset({SCOPE_KIND_ENVIRONMENT, SCOPE_KIND_SHARED}) def _capabilities() -> DestinationCapabilities: @@ -68,11 +72,38 @@ def _team_id(config: Mapping[str, JsonValue]) -> str | None: return team if isinstance(team, str) and team else None +def _scope_kind(scope: Mapping[str, JsonValue]) -> str | None: + kind = scope.get("kind") + return kind if isinstance(kind, str) and kind else None + + +def _scope_projects(scope: Mapping[str, JsonValue]) -> frozenset[str]: + projects = scope.get("projects") + if not isinstance(projects, list): + return frozenset() + return frozenset(str(p) for p in projects if isinstance(p, str) and p) + + +def _remote_projects(item: Mapping[str, Any]) -> frozenset[str]: + raw = item.get("projectId") + if not isinstance(raw, list): + return frozenset() + return frozenset(str(p) for p in raw if p) + + def _validate_scope( scope: Mapping[str, JsonValue], *, kind: ValueKind = ValueKind.SECRET, + destination_project: str | None = None, ) -> str | None: + scope_kind = _scope_kind(scope) + if scope_kind not in VALID_SCOPE_KINDS: + return ( + "scope.kind must be 'environment' or 'shared-environment' " + "(add kind: environment for project env deployments)" + ) + targets = scope.get("targets") if not isinstance(targets, list) or not targets or not all(isinstance(t, str) for t in targets): return "scope.targets must be a non-empty string array" @@ -85,12 +116,31 @@ def _validate_scope( illegal = [t for t in targets if t not in SENSITIVE_TARGETS] if illegal: return "sensitive (secret) variables are limited to production and preview targets" + git_branch = scope.get("gitBranch") + projects = scope.get("projects") + + if scope_kind == SCOPE_KIND_ENVIRONMENT: + if not destination_project: + return "destination.project is required for scope.kind=environment" + if projects is not None: + return "scope.projects is only valid for scope.kind=shared-environment" + if git_branch is not None: + if not isinstance(git_branch, str): + return "scope.gitBranch must be a string" + if "preview" not in targets: + return "scope.gitBranch is only valid with preview target" + return None + + # shared-environment if git_branch is not None: - if not isinstance(git_branch, str): - return "scope.gitBranch must be a string" - if "preview" not in targets: - return "scope.gitBranch is only valid with preview target" + return "scope.gitBranch is not supported for scope.kind=shared-environment" + if projects is not None and ( + not isinstance(projects, list) + or not projects + or not all(isinstance(p, str) and p for p in projects) + ): + return "scope.projects must be a non-empty array of non-empty strings" return None @@ -100,13 +150,12 @@ def _env_type(kind: ValueKind) -> str: return "encrypted" -def _env_matches_scope( +def _targets_and_type_match( item: Mapping[str, Any], scope: Mapping[str, JsonValue], *, - kind: ValueKind = ValueKind.SECRET, + kind: ValueKind, ) -> bool: - """True when a remote env entry belongs to the deployment inventory unit.""" targets_raw = scope.get("targets") if not isinstance(targets_raw, list): return False @@ -117,6 +166,26 @@ def _env_matches_scope( remote = {str(t) for t in remote_targets} if not wanted.intersection(remote): return False + remote_type = str(item.get("type", "")) + if kind is ValueKind.SECRET: + return remote_type == "sensitive" + return remote_type != "sensitive" + + +def _env_matches_scope( + item: Mapping[str, Any], + scope: Mapping[str, JsonValue], + *, + kind: ValueKind = ValueKind.SECRET, +) -> bool: + """True when a remote env entry belongs to the deployment inventory unit.""" + if not _targets_and_type_match(item, scope, kind=kind): + return False + + scope_kind = _scope_kind(scope) + if scope_kind == SCOPE_KIND_SHARED: + return _remote_projects(item) == _scope_projects(scope) + scope_branch = scope.get("gitBranch") item_branch = item.get("gitBranch") if scope_branch is None: @@ -124,10 +193,7 @@ def _env_matches_scope( return False elif item_branch != scope_branch: return False - remote_type = str(item.get("type", "")) - if kind is ValueKind.SECRET: - return remote_type == "sensitive" - return remote_type != "sensitive" + return True def _parse_env_list(payload: Any) -> list[dict[str, Any]]: @@ -140,6 +206,18 @@ def _parse_env_list(payload: Any) -> list[dict[str, Any]]: return [] +def _parse_shared_env_page(payload: Any) -> tuple[list[dict[str, Any]], Any]: + if not isinstance(payload, dict): + return [], None + data = payload.get("data", []) + items = [item for item in data if isinstance(item, dict)] if isinstance(data, list) else [] + pagination = payload.get("pagination") + next_ts = None + if isinstance(pagination, dict): + next_ts = pagination.get("next") + return items, next_ts + + @dataclass class VercelDestination: manifest: DestinationManifest @@ -148,8 +226,14 @@ class VercelDestination: async def validate(self, config: Mapping[str, JsonValue]) -> list[Issue]: issues: list[Issue] = [] - if _project(config) is None: - issues.append(Issue(code="DESTINATION_INVALID", message="vercel requires project")) + if _team_id(config) is None: + issues.append( + Issue( + code="DESTINATION_INVALID", + message="vercel requires teamId", + hint="Set destinations..teamId to your Vercel team id (team_…)", + ) + ) if _token_env(config) is None: issues.append(Issue(code="AUTH_MISSING", message="vercel requires auth.tokenEnv")) return issues @@ -166,9 +250,9 @@ async def list_names( *, kind: ValueKind = ValueKind.SECRET, ) -> frozenset[str]: - project = _project(config) + team_id = _team_id(config) token_env = _token_env(config) - if project is None or token_env is None: + if team_id is None or token_env is None: raise ListNamesError( SafeConnectorError( code="DESTINATION_INVALID", @@ -176,7 +260,8 @@ async def list_names( correlation_id=context.correlation_id, ) ) - reason = _validate_scope(dict(scope), kind=kind) + project = _project(config) + reason = _validate_scope(dict(scope), kind=kind, destination_project=project) if reason: raise ListNamesError( SafeConnectorError( @@ -194,14 +279,23 @@ async def list_names( correlation_id=context.correlation_id, ) ) - team_id = _team_id(config) headers = {"Authorization": f"Bearer {token}"} client = self.http_client_factory.create(headers=headers) + scope_kind = _scope_kind(scope) try: async with client: - envs, _ = await self._list_envs( - client, project=project, team_id=team_id, correlation_id=context.correlation_id - ) + if scope_kind == SCOPE_KIND_SHARED: + envs, _ = await self._list_shared_envs( + client, team_id=team_id, correlation_id=context.correlation_id + ) + else: + assert project is not None + envs, _ = await self._list_envs( + client, + project=project, + team_id=team_id, + correlation_id=context.correlation_id, + ) except HttpRequestError as exc: raise ListNamesError(exc.safe) from exc except ListNamesError: @@ -219,10 +313,11 @@ async def apply( context: OperationContext, ) -> ApplyDestinationResult: config = request.destination_config - project = _project(config) + team_id = _team_id(config) token_env = _token_env(config) + project = _project(config) all_ops: list[PutMutation | DeleteMutation] = [*request.mutations, *request.deletes] - if project is None or token_env is None: + if team_id is None or token_env is None: error = SafeConnectorError( code="DESTINATION_INVALID", message="Invalid vercel destination configuration", @@ -239,7 +334,6 @@ async def apply( ) return _all_failed_ops(all_ops, error) - # Validate scopes up front. for mutation in request.mutations: if not mutation.scopes: error = SafeConnectorError( @@ -249,7 +343,11 @@ async def apply( correlation_id=context.correlation_id, ) return _all_failed_ops(all_ops, error) - reason = _validate_scope(dict(mutation.scopes[0]), kind=mutation.kind) + reason = _validate_scope( + dict(mutation.scopes[0]), + kind=mutation.kind, + destination_project=project, + ) if reason: error = SafeConnectorError( code="DESTINATION_INVALID", @@ -267,7 +365,11 @@ async def apply( correlation_id=context.correlation_id, ) return _all_failed_ops(all_ops, error) - reason = _validate_scope(dict(deletion.scopes[0]), kind=deletion.kind) + reason = _validate_scope( + dict(deletion.scopes[0]), + kind=deletion.kind, + destination_project=project, + ) if reason: error = SafeConnectorError( code="DESTINATION_INVALID", @@ -277,7 +379,19 @@ async def apply( ) return _all_failed_ops(all_ops, error) - team_id = _team_id(config) + env_puts = [ + m for m in request.mutations if _scope_kind(m.scopes[0]) == SCOPE_KIND_ENVIRONMENT + ] + shared_puts = [ + m for m in request.mutations if _scope_kind(m.scopes[0]) == SCOPE_KIND_SHARED + ] + env_deletes = [ + d for d in request.deletes if _scope_kind(d.scopes[0]) == SCOPE_KIND_ENVIRONMENT + ] + shared_deletes = [ + d for d in request.deletes if _scope_kind(d.scopes[0]) == SCOPE_KIND_SHARED + ] + headers = {"Authorization": f"Bearer {token}"} client = self.http_client_factory.create(headers=headers) max_items = self.manifest.capabilities.put_batch.max_items or DEFAULT_MAX_ITEMS @@ -285,25 +399,45 @@ async def apply( results: dict[str, MutationResult] = {} async with client: - for chunk in _chunks(request.mutations, max_items): - if not chunk: - continue - chunk_results, n = await self._upsert_chunk( + if env_puts or env_deletes: + assert project is not None + for chunk in _chunks(env_puts, max_items): + if not chunk: + continue + chunk_results, n = await self._upsert_chunk( + client, + project=project, + team_id=team_id, + mutations=chunk, + correlation_id=context.correlation_id, + ) + requests_made += n + results.update(chunk_results) + if env_deletes: + delete_results, n = await self._delete_many( + client, + project=project, + team_id=team_id, + deletes=env_deletes, + correlation_id=context.correlation_id, + ) + requests_made += n + results.update(delete_results) + + if shared_puts: + put_results, n = await self._upsert_shared( client, - project=project, team_id=team_id, - mutations=chunk, + mutations=shared_puts, correlation_id=context.correlation_id, ) requests_made += n - results.update(chunk_results) - - if request.deletes: - delete_results, n = await self._delete_many( + results.update(put_results) + if shared_deletes: + delete_results, n = await self._delete_shared( client, - project=project, team_id=team_id, - deletes=request.deletes, + deletes=shared_deletes, correlation_id=context.correlation_id, ) requests_made += n @@ -317,7 +451,7 @@ async def _upsert_chunk( client: Any, *, project: str, - team_id: str | None, + team_id: str, mutations: Sequence[PutMutation], correlation_id: str, ) -> tuple[dict[str, MutationResult], int]: @@ -336,9 +470,7 @@ async def _upsert_chunk( entry["gitBranch"] = scope["gitBranch"] payload.append(entry) - params: dict[str, str] = {"upsert": "true"} - if team_id: - params["teamId"] = team_id + params: dict[str, str] = {"upsert": "true", "teamId": team_id} url = f"{VERCEL_API}{API_PATH.format(project=quote(project, safe=''))}" try: @@ -382,7 +514,6 @@ async def _upsert_chunk( 1, ) - # Conflict without upsert success → edit fallback for affected keys. if response.status_code in {400, 409}: edited, n = await self._edit_fallback( client, @@ -417,13 +548,11 @@ async def _list_envs( client: Any, *, project: str, - team_id: str | None, + team_id: str, correlation_id: str, ) -> tuple[list[dict[str, Any]], int]: list_url = f"{VERCEL_API}/v9/projects/{quote(project, safe='')}/env" - params: dict[str, str] = {} - if team_id: - params["teamId"] = team_id + params: dict[str, str] = {"teamId": team_id} listed = await request_with_retries( client, "GET", list_url, params=params, correlation_id=correlation_id ) @@ -433,12 +562,403 @@ async def _list_envs( ) return _parse_env_list(listed.json()), 1 + async def _list_shared_envs( + self, + client: Any, + *, + team_id: str, + correlation_id: str, + ) -> tuple[list[dict[str, Any]], int]: + url = f"{VERCEL_API}{SHARED_ENV_PATH}" + items: list[dict[str, Any]] = [] + requests = 0 + until: Any = None + for _ in range(100): + params: dict[str, str] = {"teamId": team_id} + if until is not None: + params["until"] = str(until) + listed = await request_with_retries( + client, "GET", url, params=params, correlation_id=correlation_id + ) + requests += 1 + if listed.status_code != 200: + raise ListNamesError( + error_for_status(listed.status_code, correlation_id=correlation_id) + ) + page, next_ts = _parse_shared_env_page(listed.json()) + items.extend(page) + if next_ts is None: + break + until = next_ts + return items, requests + + async def _upsert_shared( + self, + client: Any, + *, + team_id: str, + mutations: Sequence[PutMutation], + correlation_id: str, + ) -> tuple[dict[str, MutationResult], int]: + try: + envs, list_requests = await self._list_shared_envs( + client, team_id=team_id, correlation_id=correlation_id + ) + except ListNamesError as exc: + return ( + { + m.mutation_id: MutationResult( + mutation_id=m.mutation_id, + status="failed", + error=exc.safe, + ) + for m in mutations + }, + 1, + ) + except HttpRequestError as exc: + return ( + { + m.mutation_id: MutationResult( + mutation_id=m.mutation_id, + status="failed", + error=exc.safe, + ) + for m in mutations + }, + 1, + ) + + to_update: list[tuple[PutMutation, str]] = [] + to_create: list[PutMutation] = [] + for mutation in mutations: + scope = dict(mutation.scopes[0]) + env_id: str | None = None + for item in envs: + if item.get("key") == mutation.name and _env_matches_scope( + item, scope, kind=mutation.kind + ): + env_id = str(item.get("id", "")) or None + break + if env_id: + to_update.append((mutation, env_id)) + else: + to_create.append(mutation) + + results: dict[str, MutationResult] = {} + requests = list_requests + + for chunk in _chunks_pairs(to_update, SHARED_MAX_ITEMS): + chunk_results, n = await self._patch_shared( + client, + team_id=team_id, + updates=chunk, + correlation_id=correlation_id, + ) + requests += n + results.update(chunk_results) + + # Create batches share type + target + projectId at the request level. + groups: dict[tuple[str, tuple[str, ...], tuple[str, ...]], list[PutMutation]] = {} + for mutation in to_create: + scope = dict(mutation.scopes[0]) + targets_raw = scope["targets"] + assert isinstance(targets_raw, list) + targets = tuple(sorted(str(t) for t in targets_raw)) + projects = tuple(sorted(_scope_projects(scope))) + key = (_env_type(mutation.kind), targets, projects) + groups.setdefault(key, []).append(mutation) + + for (env_type, targets, projects), group in groups.items(): + for chunk in _chunks(group, SHARED_MAX_ITEMS): + chunk_results, n = await self._create_shared( + client, + team_id=team_id, + mutations=chunk, + env_type=env_type, + targets=list(targets), + projects=list(projects), + correlation_id=correlation_id, + ) + requests += n + results.update(chunk_results) + + return results, requests + + async def _create_shared( + self, + client: Any, + *, + team_id: str, + mutations: Sequence[PutMutation], + env_type: str, + targets: list[str], + projects: list[str], + correlation_id: str, + ) -> tuple[dict[str, MutationResult], int]: + body: dict[str, Any] = { + "evs": [ + { + "key": m.name, + "value": bytes(m.value).decode("utf-8"), + } + for m in mutations + ], + "type": env_type, + "target": targets, + } + if projects: + body["projectId"] = projects + url = f"{VERCEL_API}{SHARED_ENV_PATH}" + params = {"teamId": team_id} + try: + response = await request_with_retries( + client, + "POST", + url, + params=params, + json=body, + correlation_id=correlation_id, + ) + except HttpRequestError as exc: + return ( + { + m.mutation_id: MutationResult( + mutation_id=m.mutation_id, + status="failed", + error=SafeConnectorError( + code=exc.safe.code, + message=exc.safe.message, + mutation_id=m.mutation_id, + correlation_id=correlation_id, + retryable=exc.safe.retryable, + ), + ) + for m in mutations + }, + 1, + ) + if response.status_code in {200, 201}: + return ( + { + m.mutation_id: MutationResult( + mutation_id=m.mutation_id, + status="applied", + effect="upserted", + ) + for m in mutations + }, + 1, + ) + err = error_for_status(response.status_code, correlation_id=correlation_id) + return ( + { + m.mutation_id: MutationResult( + mutation_id=m.mutation_id, + status="failed", + error=SafeConnectorError( + code=err.code, + message=err.message, + mutation_id=m.mutation_id, + correlation_id=correlation_id, + retryable=err.retryable, + ), + ) + for m in mutations + }, + 1, + ) + + async def _patch_shared( + self, + client: Any, + *, + team_id: str, + updates: Sequence[tuple[PutMutation, str]], + correlation_id: str, + ) -> tuple[dict[str, MutationResult], int]: + payload_updates: dict[str, Any] = {} + for mutation, env_id in updates: + scope = dict(mutation.scopes[0]) + targets_raw = scope["targets"] + assert isinstance(targets_raw, list) + entry: dict[str, Any] = { + "value": bytes(mutation.value).decode("utf-8"), + "type": _env_type(mutation.kind), + "target": [str(t) for t in targets_raw], + "projectId": sorted(_scope_projects(scope)), + } + payload_updates[env_id] = entry + url = f"{VERCEL_API}{SHARED_ENV_PATH}" + params = {"teamId": team_id} + try: + response = await request_with_retries( + client, + "PATCH", + url, + params=params, + json={"updates": payload_updates}, + correlation_id=correlation_id, + ) + except HttpRequestError as exc: + return ( + { + m.mutation_id: MutationResult( + mutation_id=m.mutation_id, + status="failed", + error=SafeConnectorError( + code=exc.safe.code, + message=exc.safe.message, + mutation_id=m.mutation_id, + correlation_id=correlation_id, + retryable=exc.safe.retryable, + ), + ) + for m, _ in updates + }, + 1, + ) + if response.status_code in {200, 201}: + return ( + { + m.mutation_id: MutationResult( + mutation_id=m.mutation_id, + status="applied", + effect="updated", + ) + for m, _ in updates + }, + 1, + ) + err = error_for_status(response.status_code, correlation_id=correlation_id) + return ( + { + m.mutation_id: MutationResult( + mutation_id=m.mutation_id, + status="failed", + error=SafeConnectorError( + code=err.code, + message=err.message, + mutation_id=m.mutation_id, + correlation_id=correlation_id, + retryable=err.retryable, + ), + ) + for m, _ in updates + }, + 1, + ) + + async def _delete_shared( + self, + client: Any, + *, + team_id: str, + deletes: Sequence[DeleteMutation], + correlation_id: str, + ) -> tuple[dict[str, MutationResult], int]: + try: + envs, list_requests = await self._list_shared_envs( + client, team_id=team_id, correlation_id=correlation_id + ) + except ListNamesError as exc: + return ( + { + d.mutation_id: MutationResult( + mutation_id=d.mutation_id, + status="failed", + error=exc.safe, + ) + for d in deletes + }, + 1, + ) + except HttpRequestError as exc: + return ( + { + d.mutation_id: MutationResult( + mutation_id=d.mutation_id, + status="failed", + error=exc.safe, + ) + for d in deletes + }, + 1, + ) + + results: dict[str, MutationResult] = {} + pending: list[tuple[DeleteMutation, str]] = [] + for deletion in deletes: + scope = dict(deletion.scopes[0]) + env_id: str | None = None + for item in envs: + if item.get("key") == deletion.name and _env_matches_scope( + item, scope, kind=deletion.kind + ): + env_id = str(item.get("id", "")) or None + break + if env_id is None: + results[deletion.mutation_id] = MutationResult( + mutation_id=deletion.mutation_id, + status="applied", + effect="deleted", + ) + else: + pending.append((deletion, env_id)) + + requests = list_requests + url = f"{VERCEL_API}{SHARED_ENV_PATH}" + params = {"teamId": team_id} + for chunk in _chunks_pairs(pending, SHARED_MAX_ITEMS): + ids = [env_id for _, env_id in chunk] + try: + response = await request_with_retries( + client, + "DELETE", + url, + params=params, + json={"ids": ids}, + correlation_id=correlation_id, + ) + requests += 1 + except HttpRequestError as exc: + requests += 1 + for deletion, _ in chunk: + results[deletion.mutation_id] = MutationResult( + mutation_id=deletion.mutation_id, + status="failed", + error=exc.safe, + ) + continue + if response.status_code in {200, 204}: + for deletion, _ in chunk: + results[deletion.mutation_id] = MutationResult( + mutation_id=deletion.mutation_id, + status="applied", + effect="deleted", + ) + else: + err = error_for_status(response.status_code, correlation_id=correlation_id) + for deletion, _ in chunk: + results[deletion.mutation_id] = MutationResult( + mutation_id=deletion.mutation_id, + status="failed", + error=SafeConnectorError( + code=err.code, + message=err.message, + mutation_id=deletion.mutation_id, + correlation_id=correlation_id, + retryable=err.retryable, + ), + ) + return results, requests + async def _delete_many( self, client: Any, *, project: str, - team_id: str | None, + team_id: str, deletes: Sequence[DeleteMutation], correlation_id: str, ) -> tuple[dict[str, MutationResult], int]: @@ -471,7 +991,6 @@ async def _delete_many( 1, ) - # name+scope → env id (first matching entry) results: dict[str, MutationResult] = {} requests = list_requests for deletion in deletes: @@ -484,7 +1003,6 @@ async def _delete_many( env_id = str(item.get("id", "")) or None break if env_id is None: - # Already absent — treat as successful delete for reconcile. results[deletion.mutation_id] = MutationResult( mutation_id=deletion.mutation_id, status="applied", @@ -494,9 +1012,7 @@ async def _delete_many( delete_url = ( f"{VERCEL_API}/v9/projects/{quote(project, safe='')}/env/{quote(env_id, safe='')}" ) - params: dict[str, str] = {} - if team_id: - params["teamId"] = team_id + params: dict[str, str] = {"teamId": team_id} try: response = await request_with_retries( client, @@ -538,7 +1054,7 @@ async def _edit_fallback( client: Any, *, project: str, - team_id: str | None, + team_id: str, mutations: Sequence[PutMutation], correlation_id: str, ) -> tuple[dict[str, MutationResult], int]: @@ -576,7 +1092,6 @@ async def _edit_fallback( for item in envs: if "key" not in item or "id" not in item: continue - # Prefer matching inventory kind when the same key exists twice. by_key[str(item["key"])] = str(item["id"]) results: dict[str, MutationResult] = {} @@ -614,9 +1129,7 @@ async def _edit_fallback( "type": _env_type(mutation.kind), "target": [str(t) for t in targets_raw], } - edit_params: dict[str, str] = {} - if team_id: - edit_params["teamId"] = team_id + edit_params: dict[str, str] = {"teamId": team_id} try: edited = await request_with_retries( client, @@ -661,6 +1174,12 @@ def _chunks(items: Sequence[PutMutation], size: int) -> list[Sequence[PutMutatio return [items[i : i + size] for i in range(0, len(items), size)] +def _chunks_pairs[T](items: Sequence[T], size: int) -> list[Sequence[T]]: + if not items: + return [] + return [items[i : i + size] for i in range(0, len(items), size)] + + def _all_failed_ops( ops: Sequence[PutMutation | DeleteMutation], error: SafeConnectorError ) -> ApplyDestinationResult: @@ -677,7 +1196,7 @@ class VercelFactory: manifest: DestinationManifest = field( default_factory=lambda: DestinationManifest( id="vercel", - version="0.1.0+v10-env-upsert", + version="0.2.0+shared-env", capabilities=_capabilities(), ) ) diff --git a/src/secretsync/init_templates.py b/src/secretsync/init_templates.py index 48e7582..be0dbda 100644 --- a/src/secretsync/init_templates.py +++ b/src/secretsync/init_templates.py @@ -32,7 +32,8 @@ tokenEnv: GITHUB_TOKEN vercel: connector: vercel - project: my-project + teamId: team_xxx + project: my-project # required for scope.kind=environment auth: tokenEnv: VERCEL_TOKEN sst: @@ -63,10 +64,21 @@ set: production destination: vercel scope: + kind: environment targets: [production] secrets: secretOneProd: SECRET_ONE secretTwoCommon: SECRET_TWO + # Team shared env (optional projects link set): + # - name: vercel-shared + # set: production + # destination: vercel + # scope: + # kind: shared-environment + # targets: [production] + # projects: [prj_abc, prj_def] + # secrets: + # secretTwoCommon: SHARED_SECRET - name: sst-staging set: staging destination: sst diff --git a/tests/fixtures/valid_full.yaml b/tests/fixtures/valid_full.yaml index 155936f..7013e61 100644 --- a/tests/fixtures/valid_full.yaml +++ b/tests/fixtures/valid_full.yaml @@ -49,6 +49,7 @@ deployments: set: production destination: vercel scope: + kind: environment targets: [production] secrets: databaseUrl: POSTGRES_URL diff --git a/tests/fixtures/variables_mixed.yaml b/tests/fixtures/variables_mixed.yaml index 2d9c734..8854200 100644 --- a/tests/fixtures/variables_mixed.yaml +++ b/tests/fixtures/variables_mixed.yaml @@ -18,6 +18,7 @@ destinations: vercel: connector: vercel project: web + teamId: team_abc auth: tokenEnv: VERCEL_TOKEN deployments: @@ -35,6 +36,7 @@ deployments: set: production destination: vercel scope: + kind: environment targets: [production] secrets: apiKey: API_KEY diff --git a/tests/fixtures/vercel_environment_missing_project.yaml b/tests/fixtures/vercel_environment_missing_project.yaml new file mode 100644 index 0000000..8bf3a98 --- /dev/null +++ b/tests/fixtures/vercel_environment_missing_project.yaml @@ -0,0 +1,24 @@ +version: 1 +changeDetection: always-write +secrets: + apiKey: + env: API_KEY +sets: + production: + include: [apiKey] +destinations: + vercel: + connector: vercel + teamId: team_abc + # project intentionally missing + auth: + tokenEnv: VERCEL_TOKEN +deployments: + - name: vercel-production + set: production + destination: vercel + scope: + kind: environment + targets: [production] + secrets: + apiKey: API_KEY diff --git a/tests/fixtures/vercel_missing_team.yaml b/tests/fixtures/vercel_missing_team.yaml new file mode 100644 index 0000000..92f6d51 --- /dev/null +++ b/tests/fixtures/vercel_missing_team.yaml @@ -0,0 +1,24 @@ +version: 1 +changeDetection: always-write +secrets: + apiKey: + env: API_KEY +sets: + production: + include: [apiKey] +destinations: + vercel: + connector: vercel + # teamId intentionally missing + project: web + auth: + tokenEnv: VERCEL_TOKEN +deployments: + - name: vercel-production + set: production + destination: vercel + scope: + kind: environment + targets: [production] + secrets: + apiKey: API_KEY diff --git a/tests/fixtures/vercel_sensitive_deprecated.yaml b/tests/fixtures/vercel_sensitive_deprecated.yaml index 6a95a48..ab5d2c2 100644 --- a/tests/fixtures/vercel_sensitive_deprecated.yaml +++ b/tests/fixtures/vercel_sensitive_deprecated.yaml @@ -10,6 +10,7 @@ destinations: vercel: connector: vercel project: web + teamId: team_abc auth: tokenEnv: VERCEL_TOKEN deployments: @@ -17,6 +18,7 @@ deployments: set: production destination: vercel scope: + kind: environment targets: [production] sensitive: true secrets: diff --git a/tests/integration/test_vercel.py b/tests/integration/test_vercel.py index 19f1ef2..c4a3de5 100644 --- a/tests/integration/test_vercel.py +++ b/tests/integration/test_vercel.py @@ -1,11 +1,18 @@ from __future__ import annotations +import json + import httpx import pytest import respx from secretsync.application.services import create_services -from secretsync.destinations.base import ApplyDestinationRequest, OperationContext, PutMutation +from secretsync.destinations.base import ( + ApplyDestinationRequest, + DeleteMutation, + OperationContext, + PutMutation, +) from secretsync.destinations.vercel import VercelFactory from secretsync.domain.models import ValueKind @@ -14,18 +21,35 @@ def _services() -> object: return create_services({"VERCEL_TOKEN": "vercel_test_token"}) +def _dest_config(**extra: object) -> dict[str, object]: + config: dict[str, object] = { + "connector": "vercel", + "teamId": "team_abc", + "auth": {"tokenEnv": "VERCEL_TOKEN"}, + } + config.update(extra) + return config + + def _mutation( name: str, *, targets: list[str] | None = None, kind: ValueKind = ValueKind.SECRET, + scope_kind: str = "environment", git_branch: str | None = None, + projects: list[str] | None = None, value: bytes = b"SECRET_CANARY_vc", extra_scope: dict[str, object] | None = None, ) -> PutMutation: - scope: dict[str, object] = {"targets": targets or ["production"]} + scope: dict[str, object] = { + "kind": scope_kind, + "targets": targets or ["production"], + } if git_branch is not None: scope["gitBranch"] = git_branch + if projects is not None: + scope["projects"] = projects if extra_scope: scope.update(extra_scope) return PutMutation( @@ -38,10 +62,24 @@ def _mutation( @pytest.mark.asyncio -async def test_validate_requires_project_and_auth() -> None: +async def test_validate_requires_team_id_and_auth() -> None: dest = VercelFactory().create(_services()) issues = await dest.validate({"connector": "vercel"}) - assert any("project" in i.message for i in issues) + assert any("teamId" in i.message for i in issues) + assert any(i.code == "AUTH_MISSING" for i in issues) + + +@pytest.mark.asyncio +async def test_validate_project_optional() -> None: + dest = VercelFactory().create(_services()) + issues = await dest.validate( + { + "connector": "vercel", + "teamId": "team_abc", + "auth": {"tokenEnv": "VERCEL_TOKEN"}, + } + ) + assert issues == [] @pytest.mark.asyncio @@ -50,11 +88,7 @@ async def test_secret_rejects_development_target() -> None: result = await dest.apply( ApplyDestinationRequest( deployment_id="dep", - destination_config={ - "connector": "vercel", - "project": "web", - "auth": {"tokenEnv": "VERCEL_TOKEN"}, - }, + destination_config=_dest_config(project="web"), # type: ignore[arg-type] mutations=[_mutation("A", targets=["development"], kind=ValueKind.SECRET)], ), OperationContext(correlation_id="c1"), @@ -70,11 +104,7 @@ async def test_scope_sensitive_rejected() -> None: result = await dest.apply( ApplyDestinationRequest( deployment_id="dep", - destination_config={ - "connector": "vercel", - "project": "web", - "auth": {"tokenEnv": "VERCEL_TOKEN"}, - }, + destination_config=_dest_config(project="web"), # type: ignore[arg-type] mutations=[_mutation("A", extra_scope={"sensitive": True})], ), OperationContext(correlation_id="c1"), @@ -84,6 +114,85 @@ async def test_scope_sensitive_rejected() -> None: assert "scope.sensitive" in result.results[0].error.message +@pytest.mark.asyncio +async def test_environment_requires_project() -> None: + dest = VercelFactory().create(_services()) + result = await dest.apply( + ApplyDestinationRequest( + deployment_id="dep", + destination_config=_dest_config(), # type: ignore[arg-type] + mutations=[_mutation("A")], + ), + OperationContext(correlation_id="c1"), + ) + assert result.results[0].status == "failed" + assert result.results[0].error is not None + assert "project" in result.results[0].error.message + + +@pytest.mark.asyncio +async def test_environment_rejects_projects() -> None: + dest = VercelFactory().create(_services()) + result = await dest.apply( + ApplyDestinationRequest( + deployment_id="dep", + destination_config=_dest_config(project="web"), # type: ignore[arg-type] + mutations=[_mutation("A", projects=["prj_a"])], + ), + OperationContext(correlation_id="c1"), + ) + assert result.results[0].status == "failed" + assert result.results[0].error is not None + assert "projects" in result.results[0].error.message + + +@pytest.mark.asyncio +async def test_shared_rejects_git_branch() -> None: + dest = VercelFactory().create(_services()) + result = await dest.apply( + ApplyDestinationRequest( + deployment_id="dep", + destination_config=_dest_config(), # type: ignore[arg-type] + mutations=[ + _mutation( + "A", + scope_kind="shared-environment", + git_branch="feat", + targets=["preview"], + ) + ], + ), + OperationContext(correlation_id="c1"), + ) + assert result.results[0].status == "failed" + assert result.results[0].error is not None + assert "gitBranch" in result.results[0].error.message + + +@pytest.mark.asyncio +async def test_missing_scope_kind_rejected() -> None: + dest = VercelFactory().create(_services()) + result = await dest.apply( + ApplyDestinationRequest( + deployment_id="dep", + destination_config=_dest_config(project="web"), # type: ignore[arg-type] + mutations=[ + PutMutation( + mutation_id="dep:A", + name="A", + value=bytearray(b"x"), + scopes=({"targets": ["production"]},), + kind=ValueKind.SECRET, + ) + ], + ), + OperationContext(correlation_id="c1"), + ) + assert result.results[0].status == "failed" + assert result.results[0].error is not None + assert "scope.kind" in result.results[0].error.message + + @pytest.mark.asyncio @respx.mock async def test_bulk_upsert_secret_type_sensitive() -> None: @@ -94,12 +203,7 @@ async def test_bulk_upsert_secret_type_sensitive() -> None: result = await dest.apply( ApplyDestinationRequest( deployment_id="dep", - destination_config={ - "connector": "vercel", - "project": "web", - "teamId": "team_abc", - "auth": {"tokenEnv": "VERCEL_TOKEN"}, - }, + destination_config=_dest_config(project="web"), # type: ignore[arg-type] mutations=[ _mutation("DATABASE_URL", kind=ValueKind.SECRET), _mutation("API_TOKEN", kind=ValueKind.SECRET), @@ -127,11 +231,7 @@ async def test_bulk_upsert_variable_type_encrypted() -> None: result = await dest.apply( ApplyDestinationRequest( deployment_id="dep", - destination_config={ - "connector": "vercel", - "project": "web", - "auth": {"tokenEnv": "VERCEL_TOKEN"}, - }, + destination_config=_dest_config(project="web"), # type: ignore[arg-type] mutations=[ _mutation("PUBLIC_APP_URL", kind=ValueKind.VARIABLE, value=b"https://app.example"), _mutation("LOG_LEVEL", kind=ValueKind.VARIABLE, value=b"info"), @@ -156,11 +256,7 @@ async def test_mixed_secret_and_variable_types() -> None: result = await dest.apply( ApplyDestinationRequest( deployment_id="dep", - destination_config={ - "connector": "vercel", - "project": "web", - "auth": {"tokenEnv": "VERCEL_TOKEN"}, - }, + destination_config=_dest_config(project="web"), # type: ignore[arg-type] mutations=[ _mutation("API_KEY", kind=ValueKind.SECRET), _mutation("LOG_LEVEL", kind=ValueKind.VARIABLE, value=b"debug"), @@ -170,8 +266,6 @@ async def test_mixed_secret_and_variable_types() -> None: ) assert result.requests_made == 1 assert all(r.status == "applied" for r in result.results) - import json - payload = json.loads(route.calls[0].request.read()) by_key = {item["key"]: item["type"] for item in payload} assert by_key["API_KEY"] == "sensitive" @@ -208,11 +302,7 @@ async def test_chunking_requests_made() -> None: result = await dest.apply( ApplyDestinationRequest( deployment_id="dep", - destination_config={ - "connector": "vercel", - "project": "web", - "auth": {"tokenEnv": "VERCEL_TOKEN"}, - }, + destination_config=_dest_config(project="web"), # type: ignore[arg-type] mutations=[_mutation(f"K{i}") for i in range(5)], ), OperationContext(correlation_id="c1"), @@ -237,15 +327,16 @@ async def test_list_names_filters_by_kind_type() -> None: ) ) dest = VercelFactory().create(_services()) + scope = {"kind": "environment", "targets": ["production"]} secrets = await dest.list_names( - {"project": "web", "auth": {"tokenEnv": "VERCEL_TOKEN"}}, - {"targets": ["production"]}, + _dest_config(project="web"), # type: ignore[arg-type] + scope, # type: ignore[arg-type] OperationContext(correlation_id="c1"), kind=ValueKind.SECRET, ) variables = await dest.list_names( - {"project": "web", "auth": {"tokenEnv": "VERCEL_TOKEN"}}, - {"targets": ["production"]}, + _dest_config(project="web"), # type: ignore[arg-type] + scope, # type: ignore[arg-type] OperationContext(correlation_id="c1"), kind=ValueKind.VARIABLE, ) @@ -260,11 +351,7 @@ async def test_git_branch_validation() -> None: result = await dest.apply( ApplyDestinationRequest( deployment_id="dep", - destination_config={ - "connector": "vercel", - "project": "web", - "auth": {"tokenEnv": "VERCEL_TOKEN"}, - }, + destination_config=_dest_config(project="web"), # type: ignore[arg-type] mutations=[_mutation("A", targets=["production"], git_branch="feat")], ), OperationContext(correlation_id="c1"), @@ -284,11 +371,7 @@ async def test_variable_allows_development_target() -> None: result = await dest.apply( ApplyDestinationRequest( deployment_id="dep", - destination_config={ - "connector": "vercel", - "project": "web", - "auth": {"tokenEnv": "VERCEL_TOKEN"}, - }, + destination_config=_dest_config(project="web"), # type: ignore[arg-type] mutations=[ _mutation("LOG_LEVEL", targets=["development"], kind=ValueKind.VARIABLE, value=b"x") ], @@ -307,11 +390,7 @@ async def test_batch_failure_marks_all() -> None: result = await dest.apply( ApplyDestinationRequest( deployment_id="dep", - destination_config={ - "connector": "vercel", - "project": "web", - "auth": {"tokenEnv": "VERCEL_TOKEN"}, - }, + destination_config=_dest_config(project="web"), # type: ignore[arg-type] mutations=[_mutation("A"), _mutation("B")], ), OperationContext(correlation_id="c1"), @@ -350,11 +429,7 @@ async def test_conflict_edit_fallback() -> None: result = await dest.apply( ApplyDestinationRequest( deployment_id="dep", - destination_config={ - "connector": "vercel", - "project": "web", - "auth": {"tokenEnv": "VERCEL_TOKEN"}, - }, + destination_config=_dest_config(project="web"), # type: ignore[arg-type] mutations=[_mutation("DATABASE_URL")], ), OperationContext(correlation_id="c1"), @@ -387,12 +462,8 @@ async def test_list_names_filters_by_targets() -> None: ) dest = VercelFactory().create(_services()) names = await dest.list_names( - { - "connector": "vercel", - "project": "web", - "auth": {"tokenEnv": "VERCEL_TOKEN"}, - }, - {"targets": ["production"]}, + _dest_config(project="web"), # type: ignore[arg-type] + {"kind": "environment", "targets": ["production"]}, # type: ignore[arg-type] OperationContext(correlation_id="c1"), kind=ValueKind.VARIABLE, ) @@ -402,8 +473,6 @@ async def test_list_names_filters_by_targets() -> None: @pytest.mark.asyncio @respx.mock async def test_delete_env_by_id() -> None: - from secretsync.destinations.base import DeleteMutation - respx.get("https://api.vercel.com/v9/projects/web/env").mock( return_value=httpx.Response( 200, @@ -426,17 +495,222 @@ async def test_delete_env_by_id() -> None: result = await dest.apply( ApplyDestinationRequest( deployment_id="dep", - destination_config={ - "connector": "vercel", - "project": "web", - "auth": {"tokenEnv": "VERCEL_TOKEN"}, + destination_config=_dest_config(project="web"), # type: ignore[arg-type] + mutations=[], + deletes=[ + DeleteMutation( + mutation_id="dep:delete:ORPHAN", + name="ORPHAN", + scopes=({"kind": "environment", "targets": ["production"]},), + kind=ValueKind.VARIABLE, + ) + ], + ), + OperationContext(correlation_id="c1"), + ) + assert delete.called + assert result.results[0].status == "applied" + assert result.results[0].effect == "deleted" + + +def _empty_shared_list() -> httpx.Response: + return httpx.Response( + 200, + json={"data": [], "pagination": {"count": 0, "next": None, "prev": None}}, + ) + + +@pytest.mark.asyncio +@respx.mock +async def test_shared_create() -> None: + respx.get("https://api.vercel.com/v1/env").mock(return_value=_empty_shared_list()) + create = respx.post("https://api.vercel.com/v1/env").mock( + return_value=httpx.Response(201, json={"created": [], "failed": []}) + ) + dest = VercelFactory().create(_services()) + result = await dest.apply( + ApplyDestinationRequest( + deployment_id="dep", + destination_config=_dest_config(), # type: ignore[arg-type] + mutations=[ + _mutation( + "SHARED_SECRET", + scope_kind="shared-environment", + projects=["prj_a", "prj_b"], + ) + ], + ), + OperationContext(correlation_id="c1"), + ) + assert result.results[0].status == "applied" + assert result.results[0].effect == "upserted" + assert create.called + assert create.calls[0].request.url.params["teamId"] == "team_abc" + body = json.loads(create.calls[0].request.read()) + assert body["type"] == "sensitive" + assert body["target"] == ["production"] + assert body["projectId"] == ["prj_a", "prj_b"] + assert body["evs"][0]["key"] == "SHARED_SECRET" + + +@pytest.mark.asyncio +@respx.mock +async def test_shared_create_omits_empty_projects() -> None: + respx.get("https://api.vercel.com/v1/env").mock(return_value=_empty_shared_list()) + create = respx.post("https://api.vercel.com/v1/env").mock( + return_value=httpx.Response(201, json={"created": [], "failed": []}) + ) + dest = VercelFactory().create(_services()) + result = await dest.apply( + ApplyDestinationRequest( + deployment_id="dep", + destination_config=_dest_config(), # type: ignore[arg-type] + mutations=[_mutation("UNLINKED", scope_kind="shared-environment")], + ), + OperationContext(correlation_id="c1"), + ) + assert result.results[0].status == "applied" + body = json.loads(create.calls[0].request.read()) + assert "projectId" not in body + + +@pytest.mark.asyncio +@respx.mock +async def test_shared_patch_existing() -> None: + respx.get("https://api.vercel.com/v1/env").mock( + return_value=httpx.Response( + 200, + json={ + "data": [ + { + "id": "env_shared_1", + "key": "SHARED_SECRET", + "type": "sensitive", + "target": ["production"], + "projectId": ["prj_a", "prj_b"], + } + ], + "pagination": {"count": 1, "next": None, "prev": None}, + }, + ) + ) + patch = respx.patch("https://api.vercel.com/v1/env").mock( + return_value=httpx.Response(200, json={"updated": [], "failed": []}) + ) + dest = VercelFactory().create(_services()) + result = await dest.apply( + ApplyDestinationRequest( + deployment_id="dep", + destination_config=_dest_config(), # type: ignore[arg-type] + mutations=[ + _mutation( + "SHARED_SECRET", + scope_kind="shared-environment", + projects=["prj_a", "prj_b"], + value=b"rotated", + ) + ], + ), + OperationContext(correlation_id="c1"), + ) + assert result.results[0].status == "applied" + assert result.results[0].effect == "updated" + assert patch.called + body = json.loads(patch.calls[0].request.read()) + assert "env_shared_1" in body["updates"] + assert body["updates"]["env_shared_1"]["value"] == "rotated" + assert body["updates"]["env_shared_1"]["projectId"] == ["prj_a", "prj_b"] + + +@pytest.mark.asyncio +@respx.mock +async def test_shared_list_exact_projects_match() -> None: + respx.get("https://api.vercel.com/v1/env").mock( + return_value=httpx.Response( + 200, + json={ + "data": [ + { + "id": "1", + "key": "LINKED", + "type": "sensitive", + "target": ["production"], + "projectId": ["prj_a", "prj_b"], + }, + { + "id": "2", + "key": "OTHER_LINK", + "type": "sensitive", + "target": ["production"], + "projectId": ["prj_a"], + }, + { + "id": "3", + "key": "UNLINKED", + "type": "sensitive", + "target": ["production"], + "projectId": [], + }, + ], + "pagination": {"count": 3, "next": None, "prev": None}, + }, + ) + ) + dest = VercelFactory().create(_services()) + linked = await dest.list_names( + _dest_config(), # type: ignore[arg-type] + { + "kind": "shared-environment", + "targets": ["production"], + "projects": ["prj_a", "prj_b"], + }, # type: ignore[arg-type] + OperationContext(correlation_id="c1"), + kind=ValueKind.SECRET, + ) + unlinked = await dest.list_names( + _dest_config(), # type: ignore[arg-type] + {"kind": "shared-environment", "targets": ["production"]}, # type: ignore[arg-type] + OperationContext(correlation_id="c1"), + kind=ValueKind.SECRET, + ) + assert linked == frozenset({"LINKED"}) + assert unlinked == frozenset({"UNLINKED"}) + + +@pytest.mark.asyncio +@respx.mock +async def test_shared_delete() -> None: + respx.get("https://api.vercel.com/v1/env").mock( + return_value=httpx.Response( + 200, + json={ + "data": [ + { + "id": "env_orphan", + "key": "ORPHAN", + "type": "encrypted", + "target": ["production"], + "projectId": [], + } + ], + "pagination": {"count": 1, "next": None, "prev": None}, }, + ) + ) + delete = respx.delete("https://api.vercel.com/v1/env").mock( + return_value=httpx.Response(200, json={"deleted": ["env_orphan"], "failed": []}) + ) + dest = VercelFactory().create(_services()) + result = await dest.apply( + ApplyDestinationRequest( + deployment_id="dep", + destination_config=_dest_config(), # type: ignore[arg-type] mutations=[], deletes=[ DeleteMutation( mutation_id="dep:delete:ORPHAN", name="ORPHAN", - scopes=({"targets": ["production"]},), + scopes=({"kind": "shared-environment", "targets": ["production"]},), kind=ValueKind.VARIABLE, ) ], @@ -444,5 +718,7 @@ async def test_delete_env_by_id() -> None: OperationContext(correlation_id="c1"), ) assert delete.called + body = json.loads(delete.calls[0].request.read()) + assert body == {"ids": ["env_orphan"]} assert result.results[0].status == "applied" assert result.results[0].effect == "deleted" diff --git a/tests/smoke/test_providers.py b/tests/smoke/test_providers.py index e979772..8eeed6a 100644 --- a/tests/smoke/test_providers.py +++ b/tests/smoke/test_providers.py @@ -67,13 +67,14 @@ async def test_vercel_smoke_upsert() -> None: services = create_services({"VERCEL_TOKEN": token}) dest = VercelFactory().create(services) + if not team: + pytest.skip("SECRETSYNC_SMOKE_VERCEL_TEAM_ID required") config: dict[str, object] = { "connector": "vercel", "project": project, + "teamId": team, "auth": {"tokenEnv": "VERCEL_TOKEN"}, } - if team: - config["teamId"] = team result = await dest.apply( ApplyDestinationRequest( deployment_id="smoke", @@ -83,7 +84,7 @@ async def test_vercel_smoke_upsert() -> None: mutation_id="smoke:SECRETSYNC_SMOKE", name="SECRETSYNC_SMOKE", value=bytearray(b"smoke-ok"), - scopes=({"targets": ["preview"]},), + scopes=({"kind": "environment", "targets": ["preview"]},), kind=ValueKind.SECRET, ) ], diff --git a/tests/unit/test_validate_plan.py b/tests/unit/test_validate_plan.py index d0e91d4..d76e928 100644 --- a/tests/unit/test_validate_plan.py +++ b/tests/unit/test_validate_plan.py @@ -135,6 +135,20 @@ def test_vercel_sensitive_deprecated() -> None: assert "deployment.variables" in result.issues[0].hint +def test_vercel_requires_team_id_offline() -> None: + services = create_services({"API_KEY": "x", "VERCEL_TOKEN": "t"}) + result = validate_config(services, fixture_path("vercel_missing_team.yaml")) + assert not result.ok + assert "teamId" in result.issues[0].message + + +def test_vercel_environment_requires_project_offline() -> None: + services = create_services({"API_KEY": "x", "VERCEL_TOKEN": "t"}) + result = validate_config(services, fixture_path("vercel_environment_missing_project.yaml")) + assert not result.ok + assert "project" in result.issues[0].message.lower() or "project" in result.issues[0].message + + def test_mixed_variables_plan_emits_kinds() -> None: from secretsync.domain.models import ValueKind From 3b1aae16429cabf842e43a5d2ab174956d022283 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 18:38:32 +0000 Subject: [PATCH 2/2] Fix mypy errors in vercel shared env helpers Use a generic _chunks helper and drop an unused type ignore so CI typecheck passes. Co-authored-by: Abhishek Chadha --- src/secretsync/application/validate.py | 2 +- src/secretsync/destinations/vercel.py | 18 ++++++------------ 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/src/secretsync/application/validate.py b/src/secretsync/application/validate.py index a86cfa2..7d60b98 100644 --- a/src/secretsync/application/validate.py +++ b/src/secretsync/application/validate.py @@ -220,7 +220,7 @@ def _validate_vercel_deployment(deployment: DeploymentDefinition, destination: o project = _project(dest_cfg) for kind in kinds: reason = _validate_scope( - deployment.scope, # type: ignore[arg-type] + deployment.scope, kind=kind, destination_project=project, ) diff --git a/src/secretsync/destinations/vercel.py b/src/secretsync/destinations/vercel.py index e345f0d..80319a4 100644 --- a/src/secretsync/destinations/vercel.py +++ b/src/secretsync/destinations/vercel.py @@ -648,11 +648,11 @@ async def _upsert_shared( results: dict[str, MutationResult] = {} requests = list_requests - for chunk in _chunks_pairs(to_update, SHARED_MAX_ITEMS): + for update_chunk in _chunks(to_update, SHARED_MAX_ITEMS): chunk_results, n = await self._patch_shared( client, team_id=team_id, - updates=chunk, + updates=update_chunk, correlation_id=correlation_id, ) requests += n @@ -670,11 +670,11 @@ async def _upsert_shared( groups.setdefault(key, []).append(mutation) for (env_type, targets, projects), group in groups.items(): - for chunk in _chunks(group, SHARED_MAX_ITEMS): + for create_chunk in _chunks(group, SHARED_MAX_ITEMS): chunk_results, n = await self._create_shared( client, team_id=team_id, - mutations=chunk, + mutations=create_chunk, env_type=env_type, targets=list(targets), projects=list(projects), @@ -909,7 +909,7 @@ async def _delete_shared( requests = list_requests url = f"{VERCEL_API}{SHARED_ENV_PATH}" params = {"teamId": team_id} - for chunk in _chunks_pairs(pending, SHARED_MAX_ITEMS): + for chunk in _chunks(pending, SHARED_MAX_ITEMS): ids = [env_id for _, env_id in chunk] try: response = await request_with_retries( @@ -1168,13 +1168,7 @@ async def _edit_fallback( return results, requests -def _chunks(items: Sequence[PutMutation], size: int) -> list[Sequence[PutMutation]]: - if not items: - return [] - return [items[i : i + size] for i in range(0, len(items), size)] - - -def _chunks_pairs[T](items: Sequence[T], size: int) -> list[Sequence[T]]: +def _chunks[T](items: Sequence[T], size: int) -> list[Sequence[T]]: if not items: return [] return [items[i : i + size] for i in range(0, len(items), size)]