diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f85019..e853505 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,20 @@ called out explicitly under **Changed** or **Removed**. ## [Unreleased] +### Added + +- **Webhook trigger specs accept `auth_type`, `headers` and `secret`** (all + four auth types), and **`automations get` shows each webhook's auth + type**. See `kizen docs show automation`. + +### Fixed + +- **`automations update` no longer resets a webhook trigger's + authentication to Kizen API auth when the spec leaves it out**, and + `automations diff` no longer shows that reset. The trigger keeps its live + auth, so its URL no longer changes under external callers. Set + `auth_type` to change it. + ## [0.3.0] — 2026-10-21 ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 08ef939..1c2a3c5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -106,6 +106,7 @@ src/kizen_builder/ external_refs.py # name→UUID lookups for by-name spec references files.py # raw file bytes: the S3 upload/download legs (not JSON) forms.py # form/survey CRUD (base_path-shared with surveys) + integration_secrets.py # integration-secret listing (read-only; values never kept) layouts.py # record-layout CRUD (field/block arrangement) messages.py # automation-scoped message resources (notify_* step content) permissions.py # roles, permission groups, permissions catalog diff --git a/scripts/cli-tree-baseline.txt b/scripts/cli-tree-baseline.txt index 2b1c4a6..8e6140e 100644 --- a/scripts/cli-tree-baseline.txt +++ b/scripts/cli-tree-baseline.txt @@ -4346,7 +4346,8 @@ `--check` is the session-start form: it reports whether a newer version exists, caches the answer for a day, and always exits 0 — offline, behind a - proxy, or with no remote configured, it just says so quietly. + proxy, or with no remote configured, it just says so quietly. Under + app-builder it says the installation is managed there, and still exits 0. ╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ │ --check Only report whether a newer version exists; change nothing. │ diff --git a/src/kizen_builder/api/integration_secrets.py b/src/kizen_builder/api/integration_secrets.py new file mode 100644 index 0000000..07ee2dc --- /dev/null +++ b/src/kizen_builder/api/integration_secrets.py @@ -0,0 +1,14 @@ +"""Integration secret reads against the Kizen API.""" + +from __future__ import annotations + +from typing import Any + +from kizen_builder.api.client import KizenClient + + +def list_integration_secrets(client: KizenClient) -> list[dict[str, Any]]: + """GET /api/integration-secrets — every secret's ``api_name``, + ``description`` and ``obfuscated_value``. Callers keep what they need and + drop ``obfuscated_value``.""" + return client.paginate("/api/integration-secrets") diff --git a/src/kizen_builder/cli/automations.py b/src/kizen_builder/cli/automations.py index 14858fc..834dedb 100644 --- a/src/kizen_builder/cli/automations.py +++ b/src/kizen_builder/cli/automations.py @@ -129,8 +129,13 @@ def table() -> None: trig_table = Table(title="Triggers") trig_table.add_column("type") trig_table.add_column("description") + trig_table.add_column("auth") for t in result["triggers"]: - trig_table.add_row(t["trigger_type"] or "", t["description"] or "") + trig_table.add_row( + t["trigger_type"] or "", + t["description"] or "", + t.get("auth_type") or "", + ) console.print(trig_table) step_table = Table(title="Steps") diff --git a/src/kizen_builder/docs/specs/automation.md b/src/kizen_builder/docs/specs/automation.md index 0ac497c..7afbac0 100644 --- a/src/kizen_builder/docs/specs/automation.md +++ b/src/kizen_builder/docs/specs/automation.md @@ -158,6 +158,42 @@ Kizen (e.g. `email_interaction`, `email_link_clicked`, `contact_tag_added_removed`) but aren't wired here and raise `PlanError` if you try to spec them. +### Webhook trigger auth (confirmed live 2026-10-27) + +```json +"trigger_webhook": { + "name": "partner_feed", + "auth_type": "http_header", + "headers": [{"name": "Authorization", "value": "Bearer {{ integration_secret.partner_token }}"}] +} +``` + +- `auth_type` is `kizen_api` (the default), `http_header`, `basic_auth` or + `none`. Every type but `kizen_api` adds `?business_id=` to + `webhook_url`. +- `http_header` takes `headers: [{name, value}]`: at least one, names 2-100 + characters, no name twice. A caller must send every header, each matching + exactly. Put `{{ integration_secret. }}` or + `{{ business_variable. }}` in the value. Text around the reference is + sent as written, so `Bearer {{ integration_secret.token }}` expects + `Bearer `. A literal value is accepted, but it's stored in plain + text and `show --json` prints it, so use a reference. The CLI fills each + header's `html_value` the way the UI does. +- `basic_auth` takes `secret: {api_name}`, an integration secret whose value + is `user:pass`. Callers send `Authorization: Basic `. +- `none` accepts any request. Kizen API headers don't get past `http_header` + or `basic_auth`; a failed check answers 401 `Invalid webhook credentials.` +- Kizen silently drops `headers` on any type but `http_header`, and `secret` + on any type but `basic_auth`, so the spec refuses both. The old + `auth_header_name`/`auth_header_value` keys are no longer read and are + refused too. +- On `update`, a webhook trigger that leaves out `auth_type` keeps the live + automation's auth. Set `auth_type` to change it. The live trigger is matched + by trigger `id`, else by webhook `name`; a renamed trigger without its `id` + is a new trigger and gets `kizen_api` (`diff` shows it). +- `automations duplicate` keeps each webhook's auth and name. The copy's URL + uses the copy's api_name. + A `goal` step's `step_goal.triggers` are wait-until triggers with the same `trigger_type` names and config blocks as the top level, e.g. `email_received` to wait for a reply. Its `wait_type` is `never`, `delay` or @@ -661,7 +697,9 @@ Other reserved namespaces (not real object fields, no API-queryable catalog, never carry `data-merge-field-objectname`): `team_member.` (the notified team member's own fields), `business.` (tenant settings), `contact.`, `automation_variable.`, -`automation_history.`, and `entity_record` pseudo-fields like +`automation_history.`, `integration_secret.` and +`business_variable.` (webhook header values; a secret's label is its +description), and `entity_record` pseudo-fields like `link_url`/`created`/`estimated_close_date`. Any OTHER namespace is treated as a real custom object's own api_name, and its span gets `data-merge-field-objectname` holding that object's display name. diff --git a/src/kizen_builder/models/spec/__init__.py b/src/kizen_builder/models/spec/__init__.py index 3762f40..9c15a2f 100644 --- a/src/kizen_builder/models/spec/__init__.py +++ b/src/kizen_builder/models/spec/__init__.py @@ -75,6 +75,8 @@ TriggerWebhookConfig, TriggerManualConfig, AutomationTriggerDef, + WebhookAuthHeader, + WebhookAuthSecret, ) from kizen_builder.models.spec.automations_actions_control import ( StepConditionConfig, @@ -263,6 +265,8 @@ def build_payload_meta(model: BaseModel) -> dict[str, Any]: "TriggerScheduledActivityOverdueConfig", "TriggerNewEntityCreatedConfig", "TriggerWebhookConfig", + "WebhookAuthHeader", + "WebhookAuthSecret", "TriggerManualConfig", "AutomationTriggerDef", "StepConditionConfig", diff --git a/src/kizen_builder/models/spec/automations_triggers.py b/src/kizen_builder/models/spec/automations_triggers.py index 1c183ad..0a8162d 100644 --- a/src/kizen_builder/models/spec/automations_triggers.py +++ b/src/kizen_builder/models/spec/automations_triggers.py @@ -277,6 +277,35 @@ class TriggerNewEntityCreatedConfig(BaseModel): ) +class WebhookAuthHeader(BaseModel): + """One header an `http_header` webhook caller must send.""" + + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=2, max_length=100) + value: str = Field( + min_length=1, + description="Use a `{{ integration_secret. }}` reference.", + ) + html_value: str | None = None + + +class WebhookAuthSecret(BaseModel): + """The integration secret a `basic_auth` webhook checks against. A read + also carries `description` and `deleted`, which are ignored.""" + + model_config = ConfigDict(extra="ignore") + + api_name: str = Field(min_length=1) + + +RETIRED_WEBHOOK_AUTH_KEYS = ( + "auth_header_name", + "auth_header_value", + "html_auth_header_value", +) + + class TriggerWebhookConfig(BaseModel): """Config for `trigger_type: webhook`.""" @@ -289,6 +318,36 @@ class TriggerWebhookConfig(BaseModel): extract_raw_body_content: bool = False extract_url_query_string: bool = True extractors: list[dict[str, Any]] = Field(default_factory=list) + auth_type: Literal["kizen_api", "http_header", "basic_auth", "none"] | None = Field( + default=None, + description="Omitted on update: the live webhook keeps its auth.", + ) + headers: list[WebhookAuthHeader] | None = None + secret: WebhookAuthSecret | None = None + + # Only the combinations Kizen accepts with a 200 and then drops are + # refused here (confirmed live 2026-10-27); the rest it rejects with a + # precise 400 of its own. + @model_validator(mode="after") + def _validate_auth(self) -> Self: + if any(k in (self.model_extra or {}) for k in RETIRED_WEBHOOK_AUTH_KEYS): + raise ValueError( + "trigger_webhook.auth_header_name, auth_header_value and " + "html_auth_header_value are no longer read by Kizen. Use " + "auth_type 'http_header' with headers: " + '[{"name": ..., "value": ...}].' + ) + if self.headers and self.auth_type != "http_header": + raise ValueError( + "trigger_webhook.headers only applies when auth_type is " + "'http_header'; Kizen drops it for any other auth type." + ) + if self.secret is not None and self.auth_type != "basic_auth": + raise ValueError( + "trigger_webhook.secret only applies when auth_type is " + "'basic_auth'; Kizen drops it for any other auth type." + ) + return self class TriggerManualConfig(BaseModel): diff --git a/src/kizen_builder/tools/automations.py b/src/kizen_builder/tools/automations.py index 2695851..50a2309 100644 --- a/src/kizen_builder/tools/automations.py +++ b/src/kizen_builder/tools/automations.py @@ -132,14 +132,16 @@ def get_automation(api_name: str) -> dict[str, Any]: raise LookupError(f"no automation with api_name '{api_name}'") detail = auto_api.get_automation(client, match["id"]) - triggers = [ - { + triggers = [] + for t in detail.get("triggers") or []: + row = { "id": t.get("id"), "trigger_type": t.get("trigger_type"), "description": t.get("description"), } - for t in (detail.get("triggers") or []) - ] + if t.get("trigger_type") == "webhook": + row["auth_type"] = (t.get("trigger_webhook") or {}).get("auth_type") + triggers.append(row) steps = [] for s in detail.get("steps") or []: diff --git a/src/kizen_builder/tools/merge_fields.py b/src/kizen_builder/tools/merge_fields.py index cc8210b..037549c 100644 --- a/src/kizen_builder/tools/merge_fields.py +++ b/src/kizen_builder/tools/merge_fields.py @@ -52,6 +52,9 @@ # `automation_history`, whose labels vary by containing automation # (`merge-field-markup-captured-live.md`) — it is reserved because it has no # queryable live metadata source, not because its labels are stable. +# `integration_secret` and `business_variable` are the webhook-header +# namespaces: the UI's 2026-10-27 capture of an `integration_secret` header +# carries no `objectname`, and its label is the secret's description. # # This must be an explicit allowlist, not "did a live object lookup for this # token succeed" — `contact` is itself a real, live-queryable object api_name @@ -66,6 +69,8 @@ "contact", "automation_variable", "automation_history", + "integration_secret", + "business_variable", } ) diff --git a/src/kizen_builder/tools/planners/automations.py b/src/kizen_builder/tools/planners/automations.py index 713e85d..354664d 100644 --- a/src/kizen_builder/tools/planners/automations.py +++ b/src/kizen_builder/tools/planners/automations.py @@ -55,7 +55,10 @@ AutomationStepDef, AutomationTriggerDef, ) -from kizen_builder.models.spec.automations_triggers import trigger_config_field +from kizen_builder.models.spec.automations_triggers import ( + RETIRED_WEBHOOK_AUTH_KEYS, + trigger_config_field, +) from kizen_builder.tools import merge_fields from kizen_builder.tools.automations import get_automation, list_automations from kizen_builder.tools.objects import get_object @@ -83,6 +86,7 @@ def __init__(self) -> None: # rather than a single value shape. self._objects_by_api: dict[str, Any] = {} self._automations_by_api: dict[str, dict[str, Any]] | None = None + self._secret_descriptions: dict[str, str] | None = None def object_uuid(self, api_name: str) -> str: return self.object_data(api_name)["id"] @@ -153,6 +157,20 @@ def automation_uuid(self, api_name: str) -> str | None: rec = self._automations_by_api.get(api_name) return rec["id"] if rec else None + def integration_secret_description(self, api_name: str) -> str | None: + """The secret's description, the label the UI gives its merge field. + An unknown name returns None; the server's 400 names it.""" + if self._secret_descriptions is None: + from kizen_builder.api.client import KizenClient + from kizen_builder.api.integration_secrets import list_integration_secrets + + with KizenClient(load_env_config()) as client: + self._secret_descriptions = { + s["api_name"]: s.get("description") or s["api_name"] + for s in list_integration_secrets(client) + } + return self._secret_descriptions.get(api_name) + def filter_group_uuid(self, object_api_name: str, identifier: str) -> str: """UUID of a saved filter group on ``object_api_name`` by name or UUID.""" from kizen_builder.api.saved_views import FILTER_GROUPS_BASE @@ -397,6 +415,8 @@ def _merge_server_state( if current.get(field) is not None: server_fields[field] = current[field] merged = {**server_fields, **payload} + if "triggers" in merged: + merged["triggers"] = _carry_webhook_auth(merged["triggers"], current) merged["last_revision"] = current.get("revision") return merged @@ -968,9 +988,72 @@ def _trigger_on_or_around_date( @honours_all -def _trigger_webhook(block: dict[str, Any], _ctx: LiveContext) -> dict[str, Any]: +def _trigger_webhook(block: dict[str, Any], ctx: LiveContext) -> dict[str, Any]: + def resolve_label(namespace: str, field_path: str) -> str | None: + if namespace == "integration_secret": + return ctx.integration_secret_description(field_path) + return None + # Drop server-assigned id; pass through the rest as-is. - return _strip(block, drop={"id"}) + return _webhook_auth(_strip(block, drop={"id"}), resolve_label) + + +_WEBHOOK_AUTH_KEYS = ("auth_type", "headers", "secret") + + +def _webhook_auth( + block: dict[str, Any], resolve_label: merge_fields.ResolveLabel +) -> dict[str, Any]: + """Reduce a webhook block's auth to the shape the UI writes (confirmed + live 2026-10-27). Reads carry `headers: []`, `secret: null`, an expanded + `secret` and, on older captures, the retired `auth_header_*` keys. A + header without an `html_value` key gets the UI's rich-text mirror, whose + merge-field label for a secret is the secret's description.""" + out = {k: v for k, v in block.items() if k not in RETIRED_WEBHOOK_AUTH_KEYS} + if out.get("secret") is None: + out.pop("secret", None) + else: + out["secret"] = {"api_name": out["secret"].get("api_name")} + if not out.get("headers"): + out.pop("headers", None) + else: + out["headers"] = [ + h + if "html_value" in h + else { + **h, + "html_value": "

" + + merge_fields.render(h["value"], resolve_label=resolve_label) + + "

", + } + for h in out["headers"] + ] + return out + + +def _carry_webhook_auth( + triggers: list[dict[str, Any]], current: dict[str, Any] +) -> list[dict[str, Any]]: + """A spec webhook block without `auth_type` keeps the live trigger's auth: + a PUT without it resets the trigger to Kizen API auth and changes its + URL. Matched by trigger id, else by webhook name.""" + live = [t for t in current.get("triggers") or [] if t.get("trigger_webhook")] + by_id = {t["id"]: t["trigger_webhook"] for t in live if t.get("id")} + by_name = {t["trigger_webhook"].get("name"): t["trigger_webhook"] for t in live} + out = [] + for t in triggers: + block = t.get("trigger_webhook") + if t.get("type") != "webhook" or block is None or "auth_type" in block: + out.append(t) + continue + match = by_id.get(t.get("id")) or by_name.get(block.get("name")) + if match is None: + out.append(t) + continue + auth = _webhook_auth(match, lambda _ns, _path: None) + carried = {k: auth[k] for k in _WEBHOOK_AUTH_KEYS if k in auth} + out.append({**t, "trigger_webhook": {**block, **carried}}) + return out @honours_all diff --git a/tests/drift/test_active_preserved_on_update.py b/tests/drift/test_active_preserved_on_update.py index e54c60e..5c9092c 100644 --- a/tests/drift/test_active_preserved_on_update.py +++ b/tests/drift/test_active_preserved_on_update.py @@ -74,3 +74,89 @@ def test_update_omitting_active_preserves_live_active_automation(drift_client, s "an update spec that omitted `active` deactivated a live automation " "— this is the regression First-Use Feedback §7/§9 row #12 reported" ) + + +def test_update_omitting_webhook_auth_preserves_live_auth(drift_client, scratch): + """Same guard for webhook trigger auth: a PUT without `auth_type` resets a + trigger to Kizen API auth and changes its URL. References an existing + integration secret by name rather than creating one, so it never writes + a secret.""" + from kizen_builder.api import automations as auto_api + from kizen_builder.api.integration_secrets import list_integration_secrets + from kizen_builder.models.spec import AutomationDef + from kizen_builder.tools.planners.automations import ( + LiveContext, + _build_automation_payload, + ) + + secrets = list_integration_secrets(drift_client) + if not secrets: + pytest.skip("the drift environment has no integration secret to reference") + secret = secrets[0]["api_name"] + + api_name = debris_api_name("webhook_auth") + name = debris_name("webhook auth") + hooks = { + f"{api_name}_header": { + "auth_type": "http_header", + "headers": [ + { + "name": "X-Drift-Token", + "value": f"{{{{ integration_secret.{secret} }}}}", + } + ], + }, + f"{api_name}_basic": { + "auth_type": "basic_auth", + "secret": {"api_name": secret}, + }, + } + + def spec(with_auth: bool) -> dict: + return { + "api_name": api_name, + "name": name, + "type": "global", + "active": False, + "triggers": [ + { + "trigger_type": "webhook", + "order": i + 1, + "trigger_webhook": {"name": hook, **(auth if with_auth else {})}, + } + for i, (hook, auth) in enumerate(hooks.items()) + ], + "steps": [], + } + + payload = _build_automation_payload( + AutomationDef.model_validate(spec(True)), LiveContext() + ) + created = auto_api.create_automation(drift_client, payload) + scratch.track( + "automation", + created["id"], + lambda: auto_api.delete_automation(drift_client, created["id"]), + ) + + def auth_of(raw: dict) -> dict: + return { + t["trigger_webhook"]["name"]: ( + t["trigger_webhook"]["auth_type"], + [(h["name"], h["value"]) for h in t["trigger_webhook"]["headers"]], + (t["trigger_webhook"]["secret"] or {}).get("api_name"), + t["trigger_webhook"]["webhook_url"], + ) + for t in raw["triggers"] + if t["trigger_type"] == "webhook" + } + + before = auth_of(get_automation(api_name)["raw"]) + assert before[f"{api_name}_header"][0] == "http_header" + assert before[f"{api_name}_basic"][2] == secret + + result = apply_plan(plan_update_automation(spec(False))) + assert result.all_ok, [r.message for r in result.results if r.status != "ok"] + assert auth_of(get_automation(api_name)["raw"]) == before, ( + "an update spec that omitted webhook auth reset it on the live automation" + ) diff --git a/tests/fixtures/automations/webhook_auth_triggers.raw.json b/tests/fixtures/automations/webhook_auth_triggers.raw.json new file mode 100644 index 0000000..988ad48 --- /dev/null +++ b/tests/fixtures/automations/webhook_auth_triggers.raw.json @@ -0,0 +1,175 @@ +{ + "id": "6d0c3f0e-5b8a-4c6e-9a51-0b5f0a1e2d93", + "name": "Webhook Auth Triggers", + "api_name": "webhook_auth_triggers", + "ai_description": null, + "active": false, + "type": "global", + "custom_object": null, + "user_description": "", + "folder": { + "id": "44b300c0-ce2c-4c49-a427-44a25f85bfad", + "name": "" + }, + "triggers": [ + { + "id": "0812ee6e-7fb3-4a32-9e72-2d9fc1b18a50", + "description": "Manual", + "user_description": "", + "trigger_type": "manual", + "stats": { + "number_completed": 0 + } + }, + { + "id": "229d6694-f315-4fad-a0f6-40c06bb894f3", + "order": 1, + "description": "‘webhook_auth_triggers’ Received", + "user_description": "Standard webhook auth", + "trigger_type": "webhook", + "trigger_webhook": { + "id": "405006e7-ef0b-4066-8192-313c4d04d244", + "name": "webhook_auth_triggers", + "http_method": "POST", + "content_type": "application/json", + "sample_post_body": "", + "extract_raw_body_content": false, + "extract_url_query_string": true, + "auth_type": "kizen_api", + "webhook_url": "https://app.example.com/api/automations/webhook_auth_triggers/webhook/webhook_auth_triggers", + "extractors": [], + "headers": [], + "secret": null + }, + "stats": { + "number_completed": 0 + } + }, + { + "id": "c6c51a0a-0007-4713-a56c-7ec742378dc2", + "order": 2, + "description": "‘webhook_auth_triggers_1’ Received", + "user_description": "http header", + "trigger_type": "webhook", + "trigger_webhook": { + "id": "0b6e0b7f-e367-464d-a63e-d2120d518de7", + "name": "webhook_auth_triggers_1", + "http_method": "GET", + "content_type": null, + "sample_post_body": "", + "extract_raw_body_content": false, + "extract_url_query_string": true, + "auth_type": "http_header", + "webhook_url": "https://app.example.com/api/automations/webhook_auth_triggers/webhook/webhook_auth_triggers_1?business_id=00000000-0000-4000-8000-000000000000", + "extractors": [], + "headers": [ + { + "name": "token-name", + "value": "{{ integration_secret.partner_token }} -merge field", + "html_value": "

{{ integration_secret.partner_token }} -merge field

" + } + ], + "secret": null + }, + "stats": { + "number_completed": 0 + } + }, + { + "id": "fec8df6f-1018-45e8-81ce-4d1dcdfdb49b", + "order": 3, + "description": "‘webhook_auth_triggers_2’ Received", + "user_description": "basic auth", + "trigger_type": "webhook", + "trigger_webhook": { + "id": "2f561cf3-d85b-47c6-940f-d92c94a25196", + "name": "webhook_auth_triggers_2", + "http_method": "POST", + "content_type": "application/xml", + "sample_post_body": "", + "extract_raw_body_content": true, + "extract_url_query_string": false, + "auth_type": "basic_auth", + "webhook_url": "https://app.example.com/api/automations/webhook_auth_triggers/webhook/webhook_auth_triggers_2?business_id=00000000-0000-4000-8000-000000000000", + "extractors": [], + "headers": [], + "secret": { + "api_name": "partner_basic", + "description": "partner basic (api)", + "deleted": false + } + }, + "stats": { + "number_completed": 0 + } + }, + { + "id": "fbcd43b4-293b-4476-b310-e4d9d0b801ff", + "order": 4, + "description": "‘webhook_auth_triggers_3’ Received", + "user_description": "no auth", + "trigger_type": "webhook", + "trigger_webhook": { + "id": "08b26eec-7a5d-4569-8792-0dc3ebaaea14", + "name": "webhook_auth_triggers_3", + "http_method": "POST", + "content_type": "application/json", + "sample_post_body": "{\n \"key\": \"value\"\n}", + "extract_raw_body_content": true, + "extract_url_query_string": true, + "auth_type": "none", + "webhook_url": "https://app.example.com/api/automations/webhook_auth_triggers/webhook/webhook_auth_triggers_3?business_id=00000000-0000-4000-8000-000000000000", + "extractors": [ + { + "id": "07e955eb-02a3-4a1f-8421-a61e7fd2200e", + "value_name": "key", + "value_json_path": "$['key']", + "order": 0 + } + ], + "headers": [], + "secret": null + }, + "stats": { + "number_completed": 0 + } + } + ], + "steps": [ + { + "id": "a3cd61b8-a788-4e17-86fe-877553f0378b", + "step_type": "stop_execution", + "order": 0, + "description": "Stop and mark as Successfully Completed and Do Not Notify", + "user_description": "", + "stats": { + "number_completed": 0, + "number_failed": 0, + "number_pending": 0 + }, + "parent_step_id": null, + "parent_condition": "", + "action_stop_execution": { + "action": "stop_and_complete", + "notify": false + }, + "action_on_failure": "notify_pause", + "should_skip_execution": false, + "error_notification_severity_level": "inherit", + "is_branch_group_initiator": false + } + ], + "variables": [], + "revision": 3, + "error_notification_email": null, + "skip_non_working_days": false, + "number_active": 0, + "number_paused": 0, + "number_completed": 0, + "priority_rank": 10, + "trigger_throttle_seconds": 0.5, + "created": "2026-10-27T07:56:22.047574-05:00", + "updated": "2026-10-27T08:09:36.060150-05:00", + "debug_next_n_executions": 0, + "error_notification_severity_level": "error" +} diff --git a/tests/test_automation_payloads.py b/tests/test_automation_payloads.py index 1a5f032..21250de 100644 --- a/tests/test_automation_payloads.py +++ b/tests/test_automation_payloads.py @@ -1825,6 +1825,309 @@ def test_plan_update_strips_variable_ids(patch_live_lookups): assert all("id" not in v and "created" not in v for v in sent) +# --------------------------------------------------------------------------- +# Webhook trigger auth: kept on update, typed, mirrored as the UI writes it +# (confirmed live 2026-10-27) +# --------------------------------------------------------------------------- + +_WEBHOOK_RAW = "automations/webhook_auth_triggers.raw.json" +_SECRET_DESCRIPTIONS = { + "partner_token": "partner token (api)", + "partner_basic": "partner basic (api)", +} + + +@pytest.fixture +def webhook_live(patch_live_lookups, monkeypatch): + """Serve the four-auth-type fixture as a live automation, with the + integration secret descriptions the header label resolves against.""" + import kizen_builder.tools.planners.automations as ma + + raw = load_fixture(_WEBHOOK_RAW) + listing = [*ma.list_automations(), {"id": raw["id"], "api_name": raw["api_name"]}] + monkeypatch.setattr(ma, "list_automations", lambda: listing) + monkeypatch.setattr( + ma.LiveContext, + "integration_secret_description", + lambda self, api_name: _SECRET_DESCRIPTIONS.get(api_name), + ) + return raw + + +def _webhook_spec(raw: dict, *, auth: bool = False, ids: bool = True) -> dict: + """A spec reproducing the fixture, with or without the webhook auth keys. + With auth, headers carry no `html_value` and `secret` is the read shape.""" + unspec = {"id", "webhook_url"} | ( + set() if auth else {"auth_type", "headers", "secret"} + ) + triggers = [] + for t in raw["triggers"]: + spec_t = {"trigger_type": t["trigger_type"], "order": t.get("order", 0)} + if ids: + spec_t["id"] = t["id"] + block = t.get("trigger_webhook") + if block is not None: + block = {k: v for k, v in block.items() if k not in unspec} + if auth and block["headers"]: + block["headers"] = [ + {k: v for k, v in h.items() if k != "html_value"} + for h in block["headers"] + ] + spec_t["trigger_webhook"] = block + triggers.append(spec_t) + stop = raw["steps"][0] + return { + "api_name": raw["api_name"], + "name": raw["name"], + "type": "global", + "triggers": triggers, + "steps": [ + { + "key": "stop", + "id": stop["id"], + "step_type": "stop_execution", + "order": 0, + "parent_key": None, + "description": stop["description"], + "action_on_failure": stop["action_on_failure"], + "action_stop_execution": stop["action_stop_execution"], + } + ], + } + + +def _webhook_blocks(payload: dict) -> dict[str, dict]: + return { + t["trigger_webhook"]["name"]: t["trigger_webhook"] + for t in payload["triggers"] + if t["type"] == "webhook" + } + + +def _auth(block: dict) -> dict: + return {k: block[k] for k in ("auth_type", "headers", "secret") if k in block} + + +_LIVE_HTTP_HEADER = { + "auth_type": "http_header", + "headers": [ + { + "name": "token-name", + "value": "{{ integration_secret.partner_token }} -merge field", + "html_value": '

' + "{{ integration_secret.partner_token }} -merge field

", + } + ], +} + + +def test_plan_update_omitted_webhook_auth_keeps_live_auth(webhook_live): + """Before 2026-10-27 the PUT went out without auth and Kizen reset every + webhook to Kizen API auth, changing its URL. The live auth now rides + along, with the read-shape secret reduced to its api_name.""" + (op,) = plan_update_automation(_webhook_spec(webhook_live)).operations + blocks = _webhook_blocks(op.payload) + assert _auth(blocks["webhook_auth_triggers"]) == {"auth_type": "kizen_api"} + assert _auth(blocks["webhook_auth_triggers_1"]) == _LIVE_HTTP_HEADER + assert _auth(blocks["webhook_auth_triggers_2"]) == { + "auth_type": "basic_auth", + "secret": {"api_name": "partner_basic"}, + } + assert _auth(blocks["webhook_auth_triggers_3"]) == {"auth_type": "none"} + + +def test_plan_update_matches_live_webhook_by_name_without_id(webhook_live): + (op,) = plan_update_automation(_webhook_spec(webhook_live, ids=False)).operations + blocks = _webhook_blocks(op.payload) + assert _auth(blocks["webhook_auth_triggers_1"]) == _LIVE_HTTP_HEADER + assert blocks["webhook_auth_triggers_2"]["auth_type"] == "basic_auth" + assert blocks["webhook_auth_triggers_3"]["auth_type"] == "none" + + +def test_plan_update_matches_renamed_webhook_by_id(webhook_live): + """A renamed webhook that keeps its trigger id still keeps the live auth; + no live trigger carries the new name, so only the id can match.""" + spec = _webhook_spec(webhook_live) + for t in spec["triggers"]: + block = t.get("trigger_webhook") + if block: + block["name"] = f"renamed_{block['name']}" + (op,) = plan_update_automation(spec).operations + blocks = _webhook_blocks(op.payload) + assert _auth(blocks["renamed_webhook_auth_triggers_1"]) == _LIVE_HTTP_HEADER + assert blocks["renamed_webhook_auth_triggers_2"]["auth_type"] == "basic_auth" + assert blocks["renamed_webhook_auth_triggers_3"]["auth_type"] == "none" + + +def test_plan_update_explicit_webhook_auth_is_sent_as_written(webhook_live): + spec = _webhook_spec(webhook_live) + for t in spec["triggers"]: + if "trigger_webhook" in t: + t["trigger_webhook"]["auth_type"] = "kizen_api" + (op,) = plan_update_automation(spec).operations + for block in _webhook_blocks(op.payload).values(): + assert _auth(block) == {"auth_type": "kizen_api"} + + +def test_plan_update_new_webhook_trigger_copies_no_auth(webhook_live): + spec = _webhook_spec(webhook_live) + spec["triggers"].append( + { + "trigger_type": "webhook", + "order": 5, + "trigger_webhook": {"name": "brand_new"}, + } + ) + (op,) = plan_update_automation(spec).operations + assert _auth(_webhook_blocks(op.payload)["brand_new"]) == {} + + +@pytest.mark.parametrize("auth", [False, True], ids=["omitted", "restated"]) +def test_diff_automation_shows_no_webhook_auth_lines(webhook_live, auth): + """A spec that leaves auth out, or restates live's auth (headers without + `html_value`, `secret` in its read shape), diffs clean on auth.""" + diff = diff_automation(_webhook_spec(webhook_live, auth=auth))["diff"] + auth_lines = [ + d + for d in diff + if any(f".{k}" in d["path"] for k in ("auth_type", "headers", "secret")) + ] + assert auth_lines == [] + + +@pytest.mark.parametrize( + "block, message", + [ + ( + {"auth_type": "basic_auth", "headers": [{"name": "X-A", "value": "v"}]}, + "trigger_webhook.headers only applies when auth_type is 'http_header'; " + "Kizen drops it for any other auth type.", + ), + ( + {"headers": [{"name": "X-A", "value": "v"}]}, + "trigger_webhook.headers only applies when auth_type is 'http_header'; " + "Kizen drops it for any other auth type.", + ), + ( + {"auth_type": "http_header", "secret": {"api_name": "s"}}, + "trigger_webhook.secret only applies when auth_type is 'basic_auth'; " + "Kizen drops it for any other auth type.", + ), + ( + {"auth_header_name": "X-A", "auth_header_value": "v"}, + "trigger_webhook.auth_header_name, auth_header_value and " + "html_auth_header_value are no longer read by Kizen. Use auth_type " + """'http_header' with headers: [{"name": ..., "value": ...}].""", + ), + ], + ids=["headers", "headers-no-auth-type", "secret", "retired-keys"], +) +def test_webhook_auth_validator_messages(block, message): + import re + + from pydantic import ValidationError + + from kizen_builder.models.spec import TriggerWebhookConfig + + with pytest.raises(ValidationError, match=re.escape(message)): + TriggerWebhookConfig.model_validate({"name": "hook", **block}) + + +def test_webhook_auth_models_accept_the_read_shape(): + from kizen_builder.models.spec import TriggerWebhookConfig + + raw = load_fixture(_WEBHOOK_RAW) + for t in raw["triggers"]: + if "trigger_webhook" in t: + TriggerWebhookConfig.model_validate(t["trigger_webhook"]) + + +def test_webhook_header_html_value_matches_the_ui_byte_for_byte(): + from kizen_builder.tools.planners.automations import _webhook_auth + + live = _LIVE_HTTP_HEADER["headers"][0] + block = { + "auth_type": "http_header", + "headers": [{"name": live["name"], "value": live["value"]}], + } + out = _webhook_auth( + block, + lambda ns, path: ( + _SECRET_DESCRIPTIONS[path] if ns == "integration_secret" else None + ), + ) + assert out["headers"][0]["html_value"] == live["html_value"] + + +@pytest.mark.parametrize("html_value", ["

kept

", "", None]) +def test_webhook_header_present_html_value_is_kept(html_value): + from kizen_builder.tools.planners.automations import _webhook_auth + + header = { + "name": "X-A", + "value": "{{ integration_secret.x }}", + "html_value": html_value, + } + + def no_lookup(ns, path): + raise AssertionError("a present html_value must not be derived") + + out = _webhook_auth({"auth_type": "http_header", "headers": [header]}, no_lookup) + assert out["headers"] == [header] + + +def test_live_to_payload_reduces_webhook_auth_without_lookups(): + """The read path (`translate`, and every `steps`/`activate`/`move` PUT) + runs under a context that raises on any lookup; live headers always carry + `html_value`, so nothing is derived.""" + from kizen_builder.translate import live_to_payload + + blocks = _webhook_blocks(live_to_payload(load_fixture(_WEBHOOK_RAW))) + assert _auth(blocks["webhook_auth_triggers_1"]) == _LIVE_HTTP_HEADER + assert blocks["webhook_auth_triggers_2"]["secret"] == {"api_name": "partner_basic"} + assert "headers" not in blocks["webhook_auth_triggers_2"] + assert "secret" not in blocks["webhook_auth_triggers_3"] + + +def test_live_to_payload_drops_retired_webhook_auth_keys(): + from kizen_builder.translate import live_to_payload + + payload = live_to_payload( + load_fixture("automations/condition_variable_rhs_pipeline.raw.json") + ) + for block in _webhook_blocks(payload).values(): + assert not [k for k in block if k.startswith(("auth_header", "html_auth"))] + + +def test_integration_secret_description_is_fetched_once(env_config): + import respx + + from kizen_builder.tools.planners.automations import LiveContext + + with respx.mock(base_url=env_config.base_url) as router: + route = router.get("/api/integration-secrets").respond( + json={ + "results": [ + { + "api_name": "partner_token", + "description": "partner token (api)", + "obfuscated_value": "****", + } + ], + "next": None, + } + ) + ctx = LiveContext() + assert ( + ctx.integration_secret_description("partner_token") == "partner token (api)" + ) + assert ctx.integration_secret_description("missing") is None + assert route.call_count == 1 + assert "****" not in repr(vars(ctx)) + + # --------------------------------------------------------------------------- # `active`: preserve-on-omission (the regression the reporter hit) # --------------------------------------------------------------------------- diff --git a/tests/test_cli.py b/tests/test_cli.py index 1bdd38a..3a309e8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -33,6 +33,7 @@ FAKE_BUSINESS_ID, OTHER_BUSINESS_ID, load_fixture, + mock_automation_lookup, plain, ) @@ -364,6 +365,25 @@ def test_automations_get_raw_emits_full_response(monkeypatch): assert json.loads(result.stdout) == raw +def test_automations_get_shows_webhook_auth_type(): + """Webhook rows carry their auth type; other trigger rows don't get the key.""" + raw = load_fixture("automations/webhook_auth_triggers.raw.json") + with respx.mock: + mock_automation_lookup(raw) + rows = auto_tools.get_automation(raw["api_name"])["triggers"] + result = runner.invoke(cli.app, ["automations", "get", raw["api_name"]]) + assert [r.get("auth_type") for r in rows] == [ + None, + "kizen_api", + "http_header", + "basic_auth", + "none", + ] + assert "auth_type" not in rows[0] + assert result.exit_code == 0, result.output + assert "auth" in result.stdout and "basic_auth" in result.stdout + + def test_records_list_csv_flattens_fields(monkeypatch): records = load_fixture("records/list_tax_lot.json") monkeypatch.setattr(record_tools, "search_records", lambda *a, **k: records)