feat(apim-apps): add external credentials support for new applications

Applications can now declare a `credentials:` list in the YAML manifest.
Each entry holds a static `client_id` (and optional `secret`) that is
provisioned via POST /applications/{id}/extcredentials when the application
is first created. Existing applications are never modified.

Also adds CHANGELOG.md and ROADMAP.md for the v0.1.0 initial release.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 22:16:48 +02:00
parent 7738a51d16
commit 0f1a6d865d
10 changed files with 251 additions and 3 deletions
+171
View File
@@ -0,0 +1,171 @@
import textwrap
import pytest
from unittest.mock import patch
from apim_apps import load_manifest, AppCredential
from apim_apps import normalize_env, EnvConfig, Manifest, OrgSpec, AppSpec
# ---------------------------------------------------------------------------
# Loader — parsing credentials from YAML
# ---------------------------------------------------------------------------
WITH_CREDENTIAL = textwrap.dedent("""\
organizations: []
applications:
- name: "MyApp"
organization: "MyOrg"
credentials:
- client_id: "ext-client-id"
""")
WITH_SECRET = textwrap.dedent("""\
organizations: []
applications:
- name: "MyApp"
organization: "MyOrg"
credentials:
- client_id: "ext-client-id"
secret: "s3cr3t"
""")
MULTIPLE_CREDENTIALS = textwrap.dedent("""\
organizations: []
applications:
- name: "MyApp"
organization: "MyOrg"
credentials:
- client_id: "client-one"
- client_id: "client-two"
""")
WITHOUT_CREDENTIAL = textwrap.dedent("""\
organizations: []
applications:
- name: "MyApp"
organization: "MyOrg"
""")
def test_credential_is_parsed(tmp_path):
f = tmp_path / "apps.yaml"
f.write_text(WITH_CREDENTIAL)
m = load_manifest(str(f))
assert m.applications[0].credentials == [AppCredential(client_id="ext-client-id")]
def test_credential_with_secret_parsed(tmp_path):
f = tmp_path / "apps.yaml"
f.write_text(WITH_SECRET)
m = load_manifest(str(f))
assert m.applications[0].credentials == [AppCredential(client_id="ext-client-id", secret="s3cr3t")]
def test_multiple_credentials_parsed(tmp_path):
f = tmp_path / "apps.yaml"
f.write_text(MULTIPLE_CREDENTIALS)
m = load_manifest(str(f))
assert m.applications[0].credentials == [
AppCredential(client_id="client-one"),
AppCredential(client_id="client-two"),
]
def test_credentials_default_to_empty(tmp_path):
f = tmp_path / "apps.yaml"
f.write_text(WITHOUT_CREDENTIAL)
m = load_manifest(str(f))
assert m.applications[0].credentials == []
# ---------------------------------------------------------------------------
# Engine — credentials set on new apps only, never on existing or dry-run
# ---------------------------------------------------------------------------
@pytest.fixture
def env():
return EnvConfig(name="TEST", url="https://test.example.com:8075", username="u", password="p")
@pytest.fixture
def mock_client():
with patch("apim_apps.engine.AxwayClient") as MockClient:
instance = MockClient.return_value
instance.list_orgs.return_value = {"MyOrg": {"id": "org-1", "name": "MyOrg"}}
instance.list_apps.return_value = {}
instance.list_app_api_access.return_value = []
instance.create_app.return_value = {"id": "app-1", "name": "MyApp"}
yield instance
def _app(**kwargs):
return AppSpec(name="MyApp", org_name="MyOrg", **kwargs)
def test_ext_credential_created_for_new_app(env, mock_client):
manifest = Manifest(
applications=[_app(credentials=[AppCredential(client_id="ext-id")])]
)
result = normalize_env(env, manifest, dry_run=False)
mock_client.create_app_ext_credential.assert_called_once_with("app-1", "ext-id", "")
assert len(result.errors) == 0
def test_ext_credential_with_secret(env, mock_client):
manifest = Manifest(
applications=[_app(credentials=[AppCredential(client_id="ext-id", secret="s3cr3t")])]
)
normalize_env(env, manifest, dry_run=False)
mock_client.create_app_ext_credential.assert_called_once_with("app-1", "ext-id", "s3cr3t")
def test_multiple_ext_credentials_created(env, mock_client):
manifest = Manifest(
applications=[_app(credentials=[
AppCredential(client_id="client-one"),
AppCredential(client_id="client-two"),
])]
)
normalize_env(env, manifest, dry_run=False)
assert mock_client.create_app_ext_credential.call_count == 2
mock_client.create_app_ext_credential.assert_any_call("app-1", "client-one", "")
mock_client.create_app_ext_credential.assert_any_call("app-1", "client-two", "")
def test_no_ext_credential_when_credentials_absent(env, mock_client):
manifest = Manifest(applications=[_app()])
normalize_env(env, manifest, dry_run=False)
mock_client.create_app_ext_credential.assert_not_called()
def test_ext_credential_not_set_on_existing_app(env, mock_client):
mock_client.list_apps.return_value = {("MyOrg", "MyApp"): {"id": "app-1", "name": "MyApp"}}
manifest = Manifest(
applications=[_app(credentials=[AppCredential(client_id="ext-id")])]
)
normalize_env(env, manifest, dry_run=False)
mock_client.create_app.assert_not_called()
mock_client.create_app_ext_credential.assert_not_called()
def test_ext_credential_not_set_in_dry_run(env, mock_client):
manifest = Manifest(
applications=[_app(credentials=[AppCredential(client_id="ext-id")])]
)
normalize_env(env, manifest, dry_run=True)
mock_client.create_app_ext_credential.assert_not_called()
def test_credential_error_recorded_in_result(env, mock_client):
mock_client.create_app_ext_credential.side_effect = RuntimeError("API failure")
manifest = Manifest(
applications=[_app(credentials=[AppCredential(client_id="bad-id")])]
)
result = normalize_env(env, manifest, dry_run=False)
assert any("MyApp" in e for e in result.errors)