diff --git a/README_PYPI.md b/README_PYPI.md index 95d5d9b..0d64f20 100644 --- a/README_PYPI.md +++ b/README_PYPI.md @@ -29,7 +29,7 @@ class FetchData(ActionHandler): async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: response = await context.fetch( "https://api.example.com/data", - headers={"Authorization": f"Bearer {context.auth['api_key']}"} + headers={"Authorization": f"Bearer {context.auth['credentials']['api_key']}"} ) return ActionResult(data=response.data) ``` diff --git a/docs/manual/building_your_first_integration.md b/docs/manual/building_your_first_integration.md index 5f46d3b..1e79a34 100644 --- a/docs/manual/building_your_first_integration.md +++ b/docs/manual/building_your_first_integration.md @@ -437,17 +437,18 @@ if __name__ == "__main__": asyncio.run(test_get_items()) ``` -For integrations that require authentication, pass credentials matching your `auth.fields` schema: +For integrations that require authentication, pass the wrapped auth envelope. The `credentials` object must match your `auth.fields` schema, and `auth_type` must be present: ```python auth = { - "api_token": "your-test-token" + "auth_type": "Custom", + "credentials": {"api_token": "your-test-token"} } async with ExecutionContext(auth=auth) as context: result = await my_integration.execute_action("get_items", inputs, context) ``` -> **Note:** In production, the platform wraps credentials as `{"auth_type": "...", "credentials": {"api_token": "..."}}`. That's why production action handlers access `context.auth.get("credentials", {}).get("api_token")`. When testing locally with `execute_action`, the SDK validates `context.auth` directly against your `auth.fields` schema, so pass credentials flat. +> **Note:** The platform always passes `context.auth` as the wrapped envelope `{"auth_type": "...", "credentials": {"api_token": "..."}}`, and the SDK validates it as such — flat auth is rejected with a `source="auth"` validation error. Handlers should read individual credentials directly from the envelope via `context.auth["credentials"]` (for example `context.auth["credentials"].get("api_token")`). Local tests must use the same wrapped shape. For **platform OAuth** integrations (where `context.fetch()` auto-injects the Bearer token), pass the full wrapped structure in tests: diff --git a/docs/manual/integration_structure.md b/docs/manual/integration_structure.md index 7a55cb3..11fcec3 100644 --- a/docs/manual/integration_structure.md +++ b/docs/manual/integration_structure.md @@ -144,7 +144,10 @@ For integrations where users provide their own credentials: Required fields: - `type`: must be `"custom"` -- `fields`: JSON Schema object with `properties` +- `fields`: a JSON Schema describing the **credentials object only** (the inner + `credentials` the platform passes at runtime), with `properties` and an + optional `required` list. `required` is allowed and recommended — it is + enforced against the credentials object. The `title` field labels the auth section in the UI. @@ -158,7 +161,10 @@ Each property in `fields.properties` supports: | `help_text` | string | Help text shown below the field | | `default` | any | Default value | -At runtime, credentials are nested under `context.auth["credentials"]`: `context.auth.get("credentials", {}).get("api_token")`. +At runtime `context.auth` is the wrapped platform envelope +(`{"auth_type": ..., "credentials": {...}}`). Handlers should read individual +credentials directly from the envelope via +`context.auth["credentials"]`: `context.auth["credentials"].get("api_token")`. #### Platform Auth (OAuth2) diff --git a/docs/manual/patterns.md b/docs/manual/patterns.md index 7794a73..5d99cb5 100644 --- a/docs/manual/patterns.md +++ b/docs/manual/patterns.md @@ -218,7 +218,11 @@ Define multiple properties in the `auth.fields` schema: ### Accessing Credentials -Credentials are nested under `context.auth["credentials"]`: +At runtime `context.auth` is the wrapped platform envelope +(`{"auth_type": ..., "credentials": {...}}`). The `auth.fields` schema in +`config.json` describes the inner credentials object only, and `auth.fields.required` +is allowed and recommended. Read credentials directly from the envelope via +`context.auth["credentials"]`: ```python def get_base_url(context: ExecutionContext) -> str: diff --git a/samples/api-fetch/api_fetch.py b/samples/api-fetch/api_fetch.py index 8991686..06ae718 100644 --- a/samples/api-fetch/api_fetch.py +++ b/samples/api-fetch/api_fetch.py @@ -34,8 +34,8 @@ class APIFetchActionBasicAuth(ActionHandler): async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: url = inputs["url"] - username = context.auth.get("user_name", "") - password = context.auth.get("password", "") + username = context.auth["credentials"].get("user_name", "") + password = context.auth["credentials"].get("password", "") # Inject credentials into the URL for Basic Auth if url.startswith("https://"): @@ -57,7 +57,7 @@ class APIFetchActionHeader(ActionHandler): async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: url = inputs["url"] - api_key = context.auth.get("api_key", "") + api_key = context.auth["credentials"].get("api_key", "") response = await context.fetch( url, diff --git a/samples/api-fetch/tests/test_api_fetch.py b/samples/api-fetch/tests/test_api_fetch.py index 27fef63..cfe16c8 100644 --- a/samples/api-fetch/tests/test_api_fetch.py +++ b/samples/api-fetch/tests/test_api_fetch.py @@ -7,9 +7,12 @@ async def test_call_api(): """Test simple API call.""" auth = { - "user_name": "test_user", - "password": "test_password", - "api_key": "test_api_key" + "auth_type": "Custom", + "credentials": { + "user_name": "test_user", + "password": "test_password", + "api_key": "test_api_key" + } } inputs = {"url": "https://httpbin.org/get"} @@ -21,9 +24,12 @@ async def test_call_api(): async def test_call_api_un_pw(): """Test API call with Basic Auth.""" auth = { - "user_name": "test_user", - "password": "test_password", - "api_key": "test_api_key" + "auth_type": "Custom", + "credentials": { + "user_name": "test_user", + "password": "test_password", + "api_key": "test_api_key" + } } inputs = {"url": "https://httpbin.org/basic-auth/test_user/test_password"} @@ -35,9 +41,12 @@ async def test_call_api_un_pw(): async def test_call_api_header(): """Test API call with Bearer token header.""" auth = { - "user_name": "test_user", - "password": "test_password", - "api_key": "test_api_key" + "auth_type": "Custom", + "credentials": { + "user_name": "test_user", + "password": "test_password", + "api_key": "test_api_key" + } } inputs = {"url": "https://httpbin.org/bearer"} diff --git a/samples/template/my_integration.py b/samples/template/my_integration.py index 69a80c2..9913a9c 100644 --- a/samples/template/my_integration.py +++ b/samples/template/my_integration.py @@ -18,7 +18,7 @@ class MyAction(ActionHandler): async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: example_input = inputs["example_input"] - api_key = context.auth.get("api_key", "") + api_key = context.auth["credentials"].get("api_key", "") response = await context.fetch( "https://api.example.com/endpoint", diff --git a/samples/template/tests/test_my_integration.py b/samples/template/tests/test_my_integration.py index dfaa0f9..953bff0 100644 --- a/samples/template/tests/test_my_integration.py +++ b/samples/template/tests/test_my_integration.py @@ -6,10 +6,14 @@ async def test_my_action(): """Test the my_action action.""" - # Auth fields are passed flat (matching config.json fields schema). - # In production, the platform wraps these under {"auth_type": "...", "credentials": {...}}. + # Auth is the platform envelope. The credentials object matches the + # config.json fields schema. This is the same shape the platform sends + # in production. auth = { - "api_key": "test_api_key" + "auth_type": "Custom", + "credentials": { + "api_key": "test_api_key" + } } inputs = { diff --git a/skills/writing-integration-tests/SKILL.md b/skills/writing-integration-tests/SKILL.md index f1f66e0..b98875a 100644 --- a/skills/writing-integration-tests/SKILL.md +++ b/skills/writing-integration-tests/SKILL.md @@ -101,7 +101,7 @@ def live_context(env_credentials, make_context): api_key = env_credentials("MYINTEGRATION_API_KEY") if not api_key: pytest.skip("MYINTEGRATION_API_KEY not set — skipping integration tests") - return make_context(auth={"credentials": {"api_key": api_key}}) + return make_context(auth={"auth_type": "Custom", "credentials": {"api_key": api_key}}) ``` This is the recommended pattern for any test that needs API credentials: it skips automatically when the env var is missing, integrates with the project `.env`, and avoids per-test boilerplate. @@ -264,7 +264,7 @@ def live_context(env_credentials, make_context): api_key = env_credentials("MYINTEGRATION_API_KEY") if not api_key: pytest.skip("MYINTEGRATION_API_KEY not set — skipping integration tests") - return make_context(auth={"credentials": {"api_key": api_key}}) + return make_context(auth={"auth_type": "Custom", "credentials": {"api_key": api_key}}) ``` This variant is the simplest of the four — no `real_fetch` definition needed. Use it whenever the integration's source imports a vendor SDK and calls it directly rather than calling `context.fetch`. diff --git a/skills/writing-unit-tests/SKILL.md b/skills/writing-unit-tests/SKILL.md index 214ab64..1ed89e3 100644 --- a/skills/writing-unit-tests/SKILL.md +++ b/skills/writing-unit-tests/SKILL.md @@ -65,7 +65,7 @@ def mock_context(): """Mock ExecutionContext pre-loaded with this integration's credentials.""" ctx = MagicMock(name="ExecutionContext") ctx.fetch = AsyncMock(name="fetch") - ctx.auth = {"credentials": {"api_key": "test_api_key"}} # nosec B105 + ctx.auth = {"auth_type": "Custom", "credentials": {"api_key": "test_api_key"}} # nosec B105 return ctx ``` @@ -132,7 +132,7 @@ For one-off credential shapes inside a single test, use `make_context`: ```python async def test_with_custom_auth(make_context): - ctx = make_context(auth={"credentials": {"api_key": "different_key"}}) # nosec B105 + ctx = make_context(auth={"auth_type": "Custom", "credentials": {"api_key": "different_key"}}) # nosec B105 ... ``` @@ -145,24 +145,32 @@ ctx.auth = { } ``` -**Custom auth** (`config.auth.type == "custom"`): The SDK validates `context.auth` directly against `config.auth.fields`. Use a flat object matching the field schema: +**Custom auth** (`config.auth.type == "custom"`): The platform always passes `context.auth` as the wrapped envelope `{"auth_type": ..., "credentials": {...}}`, and the SDK validates the inner `credentials` object against `config.auth.fields`. Use the wrapped envelope with a non-empty `auth_type` and put the field values under `credentials`: ```python # Example: Freshdesk (api_key + domain) ctx.auth = { - "api_key": "test_api_key", # nosec B105 - "domain": "testcompany", + "auth_type": "Custom", + "credentials": { + "api_key": "test_api_key", # nosec B105 + "domain": "testcompany", + }, } # Example: Ghost (api_url + content_api_key + admin_api_key) ctx.auth = { - "api_url": "https://test.ghost.io", - "content_api_key": "test_content_key", # nosec B105 - "admin_api_key": "test_admin_key", # nosec B105 + "auth_type": "Custom", + "credentials": { + "api_url": "https://test.ghost.io", + "content_api_key": "test_content_key", # nosec B105 + "admin_api_key": "test_admin_key", # nosec B105 + }, } ``` -Using the wrong shape will cause `ResultType.VALIDATION_ERROR` before the action handler runs, making tests false negatives. +Handlers read individual credentials directly from the envelope via `context.auth["credentials"]` (for example `context.auth["credentials"].get("api_key")`). + +Using the wrong shape — flat auth, or an envelope missing `auth_type` — will cause `ResultType.VALIDATION_ERROR` (with `source="auth"`) before the action handler runs, making tests false negatives. ## Test Categories diff --git a/src/autohive_integrations_sdk/integration.py b/src/autohive_integrations_sdk/integration.py index 2c008bf..98906fe 100644 --- a/src/autohive_integrations_sdk/integration.py +++ b/src/autohive_integrations_sdk/integration.py @@ -117,7 +117,7 @@ def __init__(self, message: str, schema: str = None, inputs: str = None, source: self.message = message """The error message""" self.source = source - """Where the validation failed: 'input', 'output', or 'legacy' (pre-versioning default)""" + """Where the validation failed: 'input', 'output', 'auth', or 'legacy' (pre-versioning default)""" super().__init__(message) class ConfigurationError(Exception): @@ -373,10 +373,15 @@ class ExecutionContext: result = await integration.execute_action("my_action", inputs, context) Args: - auth: Authentication data. In **local tests** this is a flat dict - matching the ``auth.fields`` schema in ``config.json`` - (e.g. ``{"api_key": "..."}``). In **production** the platform - wraps credentials as ``{"auth_type": "...", "credentials": {...}}``. + auth: Authentication data. This is always the platform auth envelope + ``{"auth_type": "...", "credentials": {...}}``. The ``credentials`` + object matches the ``auth.fields`` schema in ``config.json``. Local + tests must use the same wrapped shape (e.g. + ``{"auth_type": "Custom", "credentials": {"api_key": "..."}}``); flat + auth is not supported. Handlers should read + individual credentials via ``context.auth["credentials"]`` (for + example ``context.auth["credentials"].get("api_key", "")``); strict + validation guarantees this shape exists before a handler runs. request_config: Override default ``max_retries`` (3) and ``timeout`` (30 s). metadata: Arbitrary metadata forwarded to handlers. logger: Custom logger; falls back to ``logging.getLogger(__name__)``. @@ -788,6 +793,58 @@ def decorator(handler_class: Type[ConnectedAccountHandler]): return handler_class return decorator + def _validate_auth(self, context: ExecutionContext) -> None: + """Validate the auth envelope's credentials against ``auth.fields``. + + The platform always passes ``context.auth`` as the + wrapped envelope ``{"auth_type": ..., "credentials": {...}}``. The + ``auth.fields`` schema in ``config.json`` describes only the inner + ``credentials`` object, so validation runs against + ``context.auth["credentials"]`` — not the whole envelope. + + Integrations with no auth or no ``fields`` key skip validation entirely. + + Raises: + ValidationError: (source ``"auth"``) if ``context.auth`` is not a + wrapped envelope (a dict with a non-empty string ``auth_type`` + and a dict ``credentials``), or if the credentials fail the + schema. + """ + if "fields" not in self.config.auth: + return + + auth = context.auth + has_valid_credentials = isinstance(auth, dict) and isinstance(auth.get("credentials"), dict) + has_valid_auth_type = ( + isinstance(auth, dict) + and isinstance(auth.get("auth_type"), str) + and auth.get("auth_type") != "" + ) + if not has_valid_credentials or not has_valid_auth_type: + raise ValidationError( + 'context.auth must be the platform auth envelope ' + '{"auth_type": ..., "credentials": {...}} with a non-empty ' + 'auth_type; flat auth is not supported.', + source="auth", + ) + + valid_auth_types = {member.value for member in AuthType} + if auth["auth_type"] not in valid_auth_types: + raise ValidationError( + f'Unknown auth_type "{auth["auth_type"]}" in context.auth; ' + f'expected one of: {", ".join(sorted(valid_auth_types))}.', + source="auth", + ) + + auth_config = self.config.auth["fields"] + validator = Draft7Validator(auth_config) + errors = sorted(validator.iter_errors(context.auth["credentials"]), key=lambda e: e.path) + if errors: + message = "" + for error in errors: + message += f"{list(error.schema_path)}, {error.message},\n " + raise ValidationError(message, auth_config, context.auth["credentials"], source="auth") + async def execute_action(self, name: str, inputs: Dict[str, Any], @@ -818,15 +875,7 @@ async def execute_action(self, message += f"{list(error.schema_path)}, {error.message},\n " raise ValidationError(message, action_config.input_schema, inputs, source="input") - if "fields" in self.config.auth: - auth_config = self.config.auth["fields"] - validator = Draft7Validator(auth_config) - errors = sorted(validator.iter_errors(context.auth), key=lambda e: e.path) - if errors: - message = "" - for error in errors: - message += f"{list(error.schema_path)}, {error.message},\n " - raise ValidationError(message, auth_config, context.auth, source="input") + self._validate_auth(context) # Create handler instance and execute handler = self._action_handlers[name]() @@ -908,12 +957,8 @@ async def execute_polling_trigger(self, except Exception as e: raise ValidationError(e.message, e.schema, e.instance) - try: - auth_config = self.config.auth["fields"] - validate(context.auth, auth_config) - except Exception as e: - raise ValidationError(e.message, e.schema, e.instance) - + self._validate_auth(context) + # Create handler instance and execute handler = self._polling_handlers[name]() previous_identity = (context._integration_name, context._integration_version) @@ -954,15 +999,7 @@ async def get_connected_account(self, context: ExecutionContext) -> IntegrationR if not self._connected_account_handler: raise ValidationError("No connected account handler registered") - if "fields" in self.config.auth: - auth_config = self.config.auth["fields"] - validator = Draft7Validator(auth_config) - errors = sorted(validator.iter_errors(context.auth), key=lambda e: e.path) - if errors: - message = "" - for error in errors: - message += f"{list(error.schema_path)}, {error.message},\n " - raise ValidationError(message, auth_config, context.auth) + self._validate_auth(context) handler = self._connected_account_handler() previous_identity = (context._integration_name, context._integration_version) diff --git a/tests/conftest.py b/tests/conftest.py index d1cffba..990d1ed 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -82,8 +82,8 @@ def integration(tmp_config): @pytest.fixture def execution_context(): - """A simple ExecutionContext with a Custom API key.""" + """A simple ExecutionContext with a Custom API key in the platform envelope.""" return ExecutionContext( - auth={"api_key": "test-key-123"}, + auth={"auth_type": "Custom", "credentials": {"api_key": "test-key-123"}}, request_config={"max_retries": 1, "timeout": 1}, ) diff --git a/tests/test_integration.py b/tests/test_integration.py index 3addfce..59b104d 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -79,7 +79,7 @@ class Handler(ActionHandler): async def execute(self, inputs, context): return ActionResult(data={"greeting": f"Hello {inputs['name']}"}) - ctx = ExecutionContext(auth={"api_key": "k"}) + ctx = ExecutionContext(auth={"auth_type": "Custom", "credentials": {"api_key": "k"}}) result = await integration.execute_action("test_action", {"name": "World"}, ctx) assert result.type == ResultType.ACTION @@ -93,7 +93,7 @@ class Handler(ActionHandler): async def execute(self, inputs, context): return ActionResult(data={"greeting": "hi"}, cost_usd=0.05) - ctx = ExecutionContext(auth={"api_key": "k"}) + ctx = ExecutionContext(auth={"auth_type": "Custom", "credentials": {"api_key": "k"}}) result = await integration.execute_action("test_action", {"name": "x"}, ctx) assert result.type == ResultType.ACTION @@ -112,7 +112,7 @@ async def execute(self, inputs, context): with aioresponses() as mock_aio: mock_aio.get(url, payload={"ok": True}) - ctx = ExecutionContext(auth={"api_key": "k"}) + ctx = ExecutionContext(auth={"auth_type": "Custom", "credentials": {"api_key": "k"}}) result = await integration.execute_action("test_action", {"name": "x"}, ctx) request = mock_aio.requests[("GET", URL(url))][0] @@ -129,7 +129,7 @@ class Handler(ActionHandler): async def execute(self, inputs, context): return ActionResult(data={"greeting": "hi"}) - ctx = ExecutionContext(auth={"api_key": "k"}) + ctx = ExecutionContext(auth={"auth_type": "Custom", "credentials": {"api_key": "k"}}) ctx._set_integration_identity("previous-integration", "9.9.9") result = await integration.execute_action("test_action", {"name": "x"}, ctx) @@ -145,7 +145,7 @@ class Handler(ActionHandler): async def execute(self, inputs, context): return ActionResult(data={"greeting": "hi"}) - ctx = ExecutionContext(auth={"api_key": "k"}) + ctx = ExecutionContext(auth={"auth_type": "Custom", "credentials": {"api_key": "k"}}) result = await integration.execute_action("test_action", {}, ctx) assert result.type == ResultType.VALIDATION_ERROR @@ -157,7 +157,7 @@ class Handler(ActionHandler): async def execute(self, inputs, context): return ActionResult(data={"wrong_key": 123}) - ctx = ExecutionContext(auth={"api_key": "k"}) + ctx = ExecutionContext(auth={"auth_type": "Custom", "credentials": {"api_key": "k"}}) result = await integration.execute_action("test_action", {"name": "x"}, ctx) assert result.type == ResultType.VALIDATION_ERROR @@ -169,14 +169,14 @@ class Handler(ActionHandler): async def execute(self, inputs, context): return {"greeting": "plain dict"} - ctx = ExecutionContext(auth={"api_key": "k"}) + ctx = ExecutionContext(auth={"auth_type": "Custom", "credentials": {"api_key": "k"}}) result = await integration.execute_action("test_action", {"name": "x"}, ctx) assert result.type == ResultType.VALIDATION_ERROR async def test_execute_action_not_registered(integration): - ctx = ExecutionContext(auth={"api_key": "k"}) + ctx = ExecutionContext(auth={"auth_type": "Custom", "credentials": {"api_key": "k"}}) result = await integration.execute_action("test_action", {"name": "x"}, ctx) assert result.type == ResultType.VALIDATION_ERROR @@ -188,7 +188,7 @@ class Handler(ActionHandler): async def execute(self, inputs, context): return ActionError(message="something went wrong", cost_usd=0.01) - ctx = ExecutionContext(auth={"api_key": "k"}) + ctx = ExecutionContext(auth={"auth_type": "Custom", "credentials": {"api_key": "k"}}) result = await integration.execute_action("test_action", {"name": "x"}, ctx) assert result.type == ResultType.ACTION_ERROR @@ -214,7 +214,7 @@ class Handler(PollingTriggerHandler): async def poll(self, inputs, last_poll_ts, context): return [{"id": "1", "data": {"message": "hello"}}] - ctx = ExecutionContext(auth={"api_key": "k"}) + ctx = ExecutionContext(auth={"auth_type": "Custom", "credentials": {"api_key": "k"}}) records = await integration.execute_polling_trigger( "test_trigger", {"channel": "general"}, None, ctx ) @@ -230,7 +230,7 @@ class Handler(PollingTriggerHandler): async def poll(self, inputs, last_poll_ts, context): return [{"data": {"message": "no id"}}] - ctx = ExecutionContext(auth={"api_key": "k"}) + ctx = ExecutionContext(auth={"auth_type": "Custom", "credentials": {"api_key": "k"}}) with pytest.raises(ValidationError, match="id"): await integration.execute_polling_trigger( "test_trigger", {"channel": "general"}, None, ctx @@ -255,7 +255,7 @@ class Handler(ConnectedAccountHandler): async def get_account_info(self, context): return ConnectedAccountInfo(email="a@b.com", first_name="Alice") - ctx = ExecutionContext(auth={"api_key": "k"}) + ctx = ExecutionContext(auth={"auth_type": "Custom", "credentials": {"api_key": "k"}}) result = await integration.get_connected_account(ctx) assert result.type == ResultType.CONNECTED_ACCOUNT @@ -269,7 +269,7 @@ class Handler(ConnectedAccountHandler): async def get_account_info(self, context): return {"email": "raw dict"} - ctx = ExecutionContext(auth={"api_key": "k"}) + ctx = ExecutionContext(auth={"auth_type": "Custom", "credentials": {"api_key": "k"}}) with pytest.raises(ValidationError, match="ConnectedAccountInfo"): await integration.get_connected_account(ctx) @@ -293,17 +293,19 @@ def test_parse_interval_invalid(): async def test_execute_action_auth_validation_failure(integration): - """Invalid auth credentials (missing required api_key) → VALIDATION_ERROR.""" + """Wrapped auth missing a required credential → VALIDATION_ERROR (source 'auth').""" @integration.action("test_action") class Handler(ActionHandler): async def execute(self, inputs, context): return ActionResult(data={"greeting": "hi"}) - ctx = ExecutionContext(auth={}) # missing required api_key + # Wrapped envelope, but credentials are missing the required api_key. + ctx = ExecutionContext(auth={"auth_type": "Custom", "credentials": {}}) result = await integration.execute_action("test_action", {"name": "x"}, ctx) assert result.type == ResultType.VALIDATION_ERROR + assert result.result["source"] == "auth" async def test_execute_action_no_auth_fields_in_config(tmp_path): @@ -348,7 +350,7 @@ async def execute(self, inputs, context): async def test_execute_polling_trigger_not_registered(integration): - ctx = ExecutionContext(auth={"api_key": "k"}) + ctx = ExecutionContext(auth={"auth_type": "Custom", "credentials": {"api_key": "k"}}) with pytest.raises(ValidationError, match="not registered"): await integration.execute_polling_trigger( "test_trigger", {"channel": "general"}, None, ctx @@ -363,7 +365,7 @@ class Handler(PollingTriggerHandler): async def poll(self, inputs, last_poll_ts, context): return [{"id": "1"}] # missing 'data' - ctx = ExecutionContext(auth={"api_key": "k"}) + ctx = ExecutionContext(auth={"auth_type": "Custom", "credentials": {"api_key": "k"}}) with pytest.raises(ValidationError, match="data"): await integration.execute_polling_trigger( "test_trigger", {"channel": "general"}, None, ctx @@ -378,7 +380,7 @@ class Handler(PollingTriggerHandler): async def poll(self, inputs, last_poll_ts, context): return [{"id": "1", "data": {"wrong_field": 123}}] - ctx = ExecutionContext(auth={"api_key": "k"}) + ctx = ExecutionContext(auth={"auth_type": "Custom", "credentials": {"api_key": "k"}}) with pytest.raises(ValidationError): await integration.execute_polling_trigger( "test_trigger", {"channel": "general"}, None, ctx @@ -395,7 +397,7 @@ async def poll(self, inputs, last_poll_ts, context): {"id": "3", "data": {"message": "third"}}, ] - ctx = ExecutionContext(auth={"api_key": "k"}) + ctx = ExecutionContext(auth={"auth_type": "Custom", "credentials": {"api_key": "k"}}) records = await integration.execute_polling_trigger( "test_trigger", {"channel": "general"}, None, ctx ) @@ -408,7 +410,7 @@ async def poll(self, inputs, last_poll_ts, context): async def test_get_connected_account_no_handler(integration): - ctx = ExecutionContext(auth={"api_key": "k"}) + ctx = ExecutionContext(auth={"auth_type": "Custom", "credentials": {"api_key": "k"}}) with pytest.raises(ValidationError, match="No connected account handler"): await integration.get_connected_account(ctx) @@ -419,9 +421,11 @@ class Handler(ConnectedAccountHandler): async def get_account_info(self, context): return ConnectedAccountInfo(email="a@b.com") - ctx = ExecutionContext(auth={}) # missing required api_key - with pytest.raises(ValidationError): + # Wrapped envelope, but credentials are missing the required api_key. + ctx = ExecutionContext(auth={"auth_type": "Custom", "credentials": {}}) + with pytest.raises(ValidationError) as exc_info: await integration.get_connected_account(ctx) + assert exc_info.value.source == "auth" async def test_get_connected_account_no_auth_fields(tmp_path): @@ -556,7 +560,7 @@ class Handler(PollingTriggerHandler): async def poll(self, inputs, last_poll_ts, context): return [] - ctx = ExecutionContext(auth={"api_key": "k"}) + ctx = ExecutionContext(auth={"auth_type": "Custom", "credentials": {"api_key": "k"}}) with pytest.raises(ValidationError): await integration.execute_polling_trigger( "test_trigger", {}, None, ctx # missing required 'channel' @@ -564,18 +568,20 @@ async def poll(self, inputs, last_poll_ts, context): async def test_execute_polling_trigger_auth_failure(integration): - """Invalid auth credentials for polling trigger → ValidationError.""" + """Wrapped auth missing a required credential for polling trigger → ValidationError.""" @integration.polling_trigger("test_trigger") class Handler(PollingTriggerHandler): async def poll(self, inputs, last_poll_ts, context): return [] - ctx = ExecutionContext(auth={}) # missing required api_key - with pytest.raises(ValidationError): + # Wrapped envelope, but credentials are missing the required api_key. + ctx = ExecutionContext(auth={"auth_type": "Custom", "credentials": {}}) + with pytest.raises(ValidationError) as exc_info: await integration.execute_polling_trigger( "test_trigger", {"channel": "general"}, None, ctx ) + assert exc_info.value.source == "auth" def test_re_register_handler_overwrites(integration): @@ -628,3 +634,288 @@ async def get_account_info(self, context): result = await Concrete().get_account_info(None) assert result is None + + +# ── Strict wrapped-auth validation ────────────────────────────────────── + + +def _no_required_auth_config(): + """Config whose auth.fields has properties but no required list.""" + return { + "name": "no-required-auth", + "version": "1.0.0", + "description": "Auth fields without a required list", + "auth": { + "auth_type": "Custom", + "fields": { + "type": "object", + "properties": {"api_key": {"type": "string"}}, + }, + }, + "actions": { + "test_action": { + "description": "A simple test action", + "input_schema": { + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + }, + "output_schema": { + "type": "object", + "properties": {"greeting": {"type": "string"}}, + "required": ["greeting"], + }, + } + }, + "polling_triggers": { + "test_trigger": { + "description": "A simple test trigger", + "polling_interval": "5m", + "input_schema": { + "type": "object", + "properties": {"channel": {"type": "string"}}, + "required": ["channel"], + }, + "output_schema": { + "type": "object", + "properties": {"message": {"type": "string"}}, + "required": ["message"], + }, + } + }, + } + + +async def test_execute_action_wrapped_auth_success(integration): + """Wrapped auth with a non-empty required credential passes and runs the action.""" + + @integration.action("test_action") + class Handler(ActionHandler): + async def execute(self, inputs, context): + return ActionResult(data={"greeting": f"Hello {inputs['name']}"}) + + ctx = ExecutionContext( + auth={"auth_type": "Custom", "credentials": {"api_key": "k"}} + ) + result = await integration.execute_action("test_action", {"name": "World"}, ctx) + + assert result.type == ResultType.ACTION + assert result.result.data == {"greeting": "Hello World"} + + +async def test_execute_action_flat_auth_rejected(integration): + """Flat auth (no credentials key) fails with the explicit envelope error.""" + + @integration.action("test_action") + class Handler(ActionHandler): + async def execute(self, inputs, context): + return ActionResult(data={"greeting": "hi"}) + + ctx = ExecutionContext(auth={"api_key": "k"}) # flat, no envelope + result = await integration.execute_action("test_action", {"name": "x"}, ctx) + + assert result.type == ResultType.VALIDATION_ERROR + assert result.result["source"] == "auth" + assert "envelope" in result.result["message"] + assert "flat auth is not supported" in result.result["message"] + + +async def test_execute_action_missing_auth_type_rejected(integration): + """Envelope without auth_type is rejected with the explicit envelope error.""" + + @integration.action("test_action") + class Handler(ActionHandler): + async def execute(self, inputs, context): + return ActionResult(data={"greeting": "hi"}) + + # Has credentials but no auth_type — the undocumented third shape. + ctx = ExecutionContext(auth={"credentials": {"api_key": "k"}}) + result = await integration.execute_action("test_action", {"name": "x"}, ctx) + + assert result.type == ResultType.VALIDATION_ERROR + assert result.result["source"] == "auth" + assert "auth_type" in result.result["message"] + + +async def test_execute_action_unknown_auth_type_rejected(integration): + """Envelope with an auth_type outside the AuthType enum is rejected.""" + + @integration.action("test_action") + class Handler(ActionHandler): + async def execute(self, inputs, context): + return ActionResult(data={"greeting": "hi"}) + + ctx = ExecutionContext(auth={"auth_type": "Bogus", "credentials": {"api_key": "k"}}) + result = await integration.execute_action("test_action", {"name": "x"}, ctx) + + assert result.type == ResultType.VALIDATION_ERROR + assert result.result["source"] == "auth" + assert 'Unknown auth_type "Bogus"' in result.result["message"] + + +async def test_execute_action_empty_auth_type_rejected(integration): + """Envelope with an empty-string auth_type is rejected.""" + + @integration.action("test_action") + class Handler(ActionHandler): + async def execute(self, inputs, context): + return ActionResult(data={"greeting": "hi"}) + + ctx = ExecutionContext(auth={"auth_type": "", "credentials": {"api_key": "k"}}) + result = await integration.execute_action("test_action", {"name": "x"}, ctx) + + assert result.type == ResultType.VALIDATION_ERROR + assert result.result["source"] == "auth" + + +async def test_execute_polling_trigger_missing_auth_type_rejected(integration): + """Envelope without auth_type is rejected for polling triggers via the shared helper.""" + + @integration.polling_trigger("test_trigger") + class Handler(PollingTriggerHandler): + async def poll(self, inputs, last_poll_ts, context): + return [] + + ctx = ExecutionContext(auth={"credentials": {"api_key": "k"}}) + with pytest.raises(ValidationError) as exc_info: + await integration.execute_polling_trigger( + "test_trigger", {"channel": "general"}, None, ctx + ) + assert exc_info.value.source == "auth" + assert "auth_type" in exc_info.value.message + + +async def test_get_connected_account_missing_auth_type_rejected(integration): + """Envelope without auth_type is rejected for connected accounts via the shared helper.""" + + @integration.connected_account() + class Handler(ConnectedAccountHandler): + async def get_account_info(self, context): + return ConnectedAccountInfo(email="a@b.com") + + ctx = ExecutionContext(auth={"credentials": {"api_key": "k"}}) + with pytest.raises(ValidationError) as exc_info: + await integration.get_connected_account(ctx) + assert exc_info.value.source == "auth" + assert "auth_type" in exc_info.value.message + + +async def test_execute_action_empty_credentials_no_required_passes(tmp_path): + """Empty credentials pass when auth.fields has no required list.""" + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps(_no_required_auth_config())) + intg = Integration.load(config_file) + + @intg.action("test_action") + class Handler(ActionHandler): + async def execute(self, inputs, context): + return ActionResult(data={"greeting": "hi"}) + + ctx = ExecutionContext(auth={"auth_type": "Custom", "credentials": {}}) + result = await intg.execute_action("test_action", {"name": "x"}, ctx) + + assert result.type == ResultType.ACTION + + +async def test_execute_polling_trigger_wrapped_auth_success(integration): + """Wrapped auth with a required credential passes for polling triggers.""" + + @integration.polling_trigger("test_trigger") + class Handler(PollingTriggerHandler): + async def poll(self, inputs, last_poll_ts, context): + return [{"id": "1", "data": {"message": "hello"}}] + + ctx = ExecutionContext( + auth={"auth_type": "Custom", "credentials": {"api_key": "k"}} + ) + records = await integration.execute_polling_trigger( + "test_trigger", {"channel": "general"}, None, ctx + ) + + assert len(records) == 1 + assert records[0]["id"] == "1" + + +async def test_execute_polling_trigger_flat_auth_rejected(integration): + """Flat auth is rejected for polling triggers with the explicit envelope error.""" + + @integration.polling_trigger("test_trigger") + class Handler(PollingTriggerHandler): + async def poll(self, inputs, last_poll_ts, context): + return [] + + ctx = ExecutionContext(auth={"api_key": "k"}) # flat, no envelope + with pytest.raises(ValidationError) as exc_info: + await integration.execute_polling_trigger( + "test_trigger", {"channel": "general"}, None, ctx + ) + assert exc_info.value.source == "auth" + assert "envelope" in exc_info.value.message + + +async def test_execute_polling_trigger_empty_credentials_no_required_passes(tmp_path): + """Empty credentials pass for polling triggers when auth.fields has no required.""" + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps(_no_required_auth_config())) + intg = Integration.load(config_file) + + @intg.polling_trigger("test_trigger") + class Handler(PollingTriggerHandler): + async def poll(self, inputs, last_poll_ts, context): + return [{"id": "1", "data": {"message": "hello"}}] + + ctx = ExecutionContext(auth={"auth_type": "Custom", "credentials": {}}) + records = await intg.execute_polling_trigger( + "test_trigger", {"channel": "general"}, None, ctx + ) + + assert len(records) == 1 + + +async def test_get_connected_account_wrapped_auth_success(integration): + """Wrapped auth with a required credential passes for connected accounts.""" + + @integration.connected_account() + class Handler(ConnectedAccountHandler): + async def get_account_info(self, context): + return ConnectedAccountInfo(email="a@b.com") + + ctx = ExecutionContext( + auth={"auth_type": "Custom", "credentials": {"api_key": "k"}} + ) + result = await integration.get_connected_account(ctx) + + assert result.type == ResultType.CONNECTED_ACCOUNT + assert result.result.email == "a@b.com" + + +async def test_get_connected_account_flat_auth_rejected(integration): + """Flat auth is rejected for connected accounts with the explicit envelope error.""" + + @integration.connected_account() + class Handler(ConnectedAccountHandler): + async def get_account_info(self, context): + return ConnectedAccountInfo(email="a@b.com") + + ctx = ExecutionContext(auth={"api_key": "k"}) # flat, no envelope + with pytest.raises(ValidationError) as exc_info: + await integration.get_connected_account(ctx) + assert exc_info.value.source == "auth" + assert "envelope" in exc_info.value.message + + +async def test_get_connected_account_empty_credentials_no_required_passes(tmp_path): + """Empty credentials pass for connected accounts when auth.fields has no required.""" + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps(_no_required_auth_config())) + intg = Integration.load(config_file) + + @intg.connected_account() + class Handler(ConnectedAccountHandler): + async def get_account_info(self, context): + return ConnectedAccountInfo(email="a@b.com") + + ctx = ExecutionContext(auth={"auth_type": "Custom", "credentials": {}}) + result = await intg.get_connected_account(ctx) + + assert result.type == ResultType.CONNECTED_ACCOUNT