From c56aa19f01c363b00b1ab762ff10974f94cc2389 Mon Sep 17 00:00:00 2001 From: Jeremy Bedient Date: Tue, 27 Oct 2026 10:20:18 -0400 Subject: [PATCH] Keep a webhook trigger's auth when an update spec leaves it out Since Kizen's 2026-10-26 release a webhook trigger carries auth_type, headers and secret, and a PUT without auth_type resets the trigger to Kizen API auth, which also changes its URL under external callers. Every spec written before the release omits the key, so an update now carries the live trigger's auth across, matched by trigger id and then by webhook name. Setting auth_type still changes it. Specs can now set all four auth types. The model refuses headers or secret on a type Kizen silently drops them for, and the retired auth_header_* keys. Headers get the same html_value the UI writes, labelling a secret reference with the secret's description, and automations get shows each webhook's auth type. --- CHANGELOG.md | 10 + CONTRIBUTING.md | 1 + src/kizen_builder/api/integration_secrets.py | 14 + src/kizen_builder/cli/automations.py | 7 +- src/kizen_builder/docs/specs/automation.md | 40 ++- src/kizen_builder/models/spec/__init__.py | 4 + .../models/spec/automations_triggers.py | 59 ++++ src/kizen_builder/tools/automations.py | 10 +- src/kizen_builder/tools/merge_fields.py | 5 + .../tools/planners/automations.py | 89 ++++- .../drift/test_active_preserved_on_update.py | 86 +++++ .../webhook_auth_triggers.raw.json | 175 ++++++++++ tests/test_automation_payloads.py | 303 ++++++++++++++++++ tests/test_cli.py | 20 ++ 14 files changed, 814 insertions(+), 9 deletions(-) create mode 100644 src/kizen_builder/api/integration_secrets.py create mode 100644 tests/fixtures/automations/webhook_auth_triggers.raw.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b9bb40..728c6c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -253,6 +253,10 @@ called out explicitly under **Changed** or **Removed**. ### 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`. + - **`activities duplicate `** copies an activity type, and **`dashboards duplicate `** copies a dashboard with its dashlets. `--name` names the copy, and `dashboards duplicate` also takes @@ -495,6 +499,12 @@ called out explicitly under **Changed** or **Removed**. ### 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. + - **`forms`/`surveys update` and `set-ui` no longer delete fields.** Kizen deletes every field that isn't placed on a page whenever a form is saved, so updating a form with no layout yet emptied it, and a partial `set-ui` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dab20d8..dd7fc5f 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/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)