feat(apim-apps): first commit

This commit is contained in:
2026-06-29 14:22:37 +02:00
commit 7738a51d16
28 changed files with 1973 additions and 0 deletions
View File
+40
View File
@@ -0,0 +1,40 @@
import pytest
from unittest.mock import patch, MagicMock
from apim_apps import AxwayClient, _AppendOnlySession, EnvConfig
@pytest.fixture
def client():
env = EnvConfig(name="TEST", url="https://test.example.com:8075", username="u", password="p")
return AxwayClient(env)
def test_session_is_append_only(client):
assert isinstance(client.session, _AppendOnlySession)
def test_delete_raises_runtime_error(client):
with pytest.raises(RuntimeError, match="DELETE is not allowed"):
client.session.delete("https://test.example.com:8075/api/portal/v1.4/applications/123")
def test_get_is_allowed(client):
with patch("requests.Session.request") as mock_req:
mock_req.return_value = MagicMock(status_code=200)
client.session.get("https://test.example.com")
mock_req.assert_called_once()
assert mock_req.call_args[0][0].upper() == "GET"
def test_post_is_allowed(client):
with patch("requests.Session.request") as mock_req:
mock_req.return_value = MagicMock(status_code=201)
client.session.post("https://test.example.com", json={})
mock_req.assert_called_once()
assert mock_req.call_args[0][0].upper() == "POST"
def test_no_delete_method_on_client():
"""AxwayClient must not expose any delete_* method."""
delete_methods = [m for m in dir(AxwayClient) if m.startswith("delete")]
assert delete_methods == [], f"Unexpected delete methods found: {delete_methods}"
+116
View File
@@ -0,0 +1,116 @@
import json
import os
import textwrap
import pytest
from unittest.mock import patch
from apim_apps.cli import main
SAMPLE_CONFIG = json.dumps({
"environments": [{"name": "DEV_LAN", "url": "https://dev.example.com:8075"}]
})
SAMPLE_MANIFEST = textwrap.dedent("""\
organizations: []
applications: []
""")
@pytest.fixture
def project_files(tmp_path):
(tmp_path / "config.json").write_text(SAMPLE_CONFIG)
(tmp_path / "apps.yaml").write_text(SAMPLE_MANIFEST)
return tmp_path
# ---------------------------------------------------------------------------
# Credential validation
# ---------------------------------------------------------------------------
def test_exits_when_apim_user_missing(project_files):
argv = ["apim_apps.py", "--config", str(project_files / "config.json"),
"--manifest", str(project_files / "apps.yaml"), "--dry-run", "--env", "DEV_LAN"]
env = {"APIM_PASSWORD": "secret"}
with patch("sys.argv", argv), patch.dict(os.environ, env, clear=True):
with pytest.raises(SystemExit) as exc:
main()
assert exc.value.code == 1
def test_exits_when_apim_password_missing(project_files):
argv = ["apim_apps.py", "--config", str(project_files / "config.json"),
"--manifest", str(project_files / "apps.yaml"), "--dry-run", "--env", "DEV_LAN"]
env = {"APIM_USER": "admin"}
with patch("sys.argv", argv), patch.dict(os.environ, env, clear=True):
with pytest.raises(SystemExit) as exc:
main()
assert exc.value.code == 1
def test_exits_when_both_credentials_missing(project_files):
argv = ["apim_apps.py", "--config", str(project_files / "config.json"),
"--manifest", str(project_files / "apps.yaml"), "--dry-run", "--env", "DEV_LAN"]
with patch("sys.argv", argv), patch.dict(os.environ, {}, clear=True):
with pytest.raises(SystemExit) as exc:
main()
assert exc.value.code == 1
# ---------------------------------------------------------------------------
# --env filtering
# ---------------------------------------------------------------------------
def test_exits_when_env_not_found(project_files):
argv = ["apim_apps.py", "--config", str(project_files / "config.json"),
"--manifest", str(project_files / "apps.yaml"), "--env", "UNKNOWN"]
env = {"APIM_USER": "admin", "APIM_PASSWORD": "secret"}
with patch("sys.argv", argv), patch.dict(os.environ, env, clear=True):
with pytest.raises(SystemExit) as exc:
main()
assert exc.value.code == 1
def test_dry_run_succeeds_with_valid_env(project_files):
argv = ["apim_apps.py", "--config", str(project_files / "config.json"),
"--manifest", str(project_files / "apps.yaml"), "--dry-run", "--env", "DEV_LAN"]
env = {"APIM_USER": "admin", "APIM_PASSWORD": "secret"}
with patch("sys.argv", argv), patch.dict(os.environ, env, clear=True):
with patch("apim_apps.engine.AxwayClient") as MockClient:
instance = MockClient.return_value
instance.list_orgs.return_value = {}
instance.list_apps.return_value = {}
with pytest.raises(SystemExit) as exc:
main()
assert exc.value.code == 0
# ---------------------------------------------------------------------------
# --init-* helpers
# ---------------------------------------------------------------------------
def test_init_manifest_creates_file(tmp_path):
out = tmp_path / "apps.yaml"
with patch("sys.argv", ["apim_apps.py", "--init-manifest", "--manifest", str(out)]):
main()
assert out.exists()
assert out.stat().st_size > 0
def test_init_config_creates_file(tmp_path):
out = tmp_path / "config.json"
with patch("sys.argv", ["apim_apps.py", "--init-config", "--config", str(out)]):
main()
assert out.exists()
data = json.loads(out.read_text())
assert "environments" in data
def test_init_config_no_credentials_in_template(tmp_path):
"""Generated config.json must not contain username or password."""
out = tmp_path / "config.json"
with patch("sys.argv", ["apim_apps.py", "--init-config", "--config", str(out)]):
main()
data = json.loads(out.read_text())
for entry in data["environments"]:
assert "username" not in entry
assert "password" not in entry
+43
View File
@@ -0,0 +1,43 @@
import json
import pytest
from apim_apps import load_config
SAMPLE = {
"environments": [
{"name": "DEV_LAN", "url": "https://dev.example.com:8075", "verify_ssl": False},
{"name": "PROD", "url": "https://prod.example.com:8075", "verify_ssl": True},
]
}
def test_load_config_names_and_urls(tmp_path):
f = tmp_path / "config.json"
f.write_text(json.dumps(SAMPLE))
envs = load_config(str(f))
assert len(envs) == 2
assert envs[0].name == "DEV_LAN"
assert envs[0].url == "https://dev.example.com:8075"
assert envs[0].verify_ssl is False
assert envs[1].name == "PROD"
assert envs[1].verify_ssl is True
def test_credentials_not_loaded_from_config(tmp_path):
"""Credentials come from env vars, not from config.json."""
f = tmp_path / "config.json"
f.write_text(json.dumps(SAMPLE))
envs = load_config(str(f))
for env in envs:
assert env.username == ""
assert env.password == ""
def test_verify_ssl_defaults_to_false(tmp_path):
config = {"environments": [{"name": "DEV", "url": "https://dev.example.com:8075"}]}
f = tmp_path / "config.json"
f.write_text(json.dumps(config))
envs = load_config(str(f))
assert envs[0].verify_ssl is False
+96
View File
@@ -0,0 +1,96 @@
import textwrap
import pytest
from apim_apps import load_manifest, ApiAccess
MINIMAL = textwrap.dedent("""\
organizations: []
applications: []
""")
FULL = textwrap.dedent("""\
organizations:
- name: "MyOrg"
description: "Test org"
email: "org@example.com"
enabled: true
development: false
environments:
- DEV_LAN
applications:
- name: "MyApp"
organization: "MyOrg"
description: "Test app"
email: "app@example.com"
enabled: true
environments:
- DEV_LAN
apis:
- name: "MyAPI"
version: "v1"
- "OtherAPI"
""")
NO_ENVS = textwrap.dedent("""\
organizations:
- name: "GlobalOrg"
applications:
- name: "GlobalApp"
organization: "GlobalOrg"
""")
def test_minimal(tmp_path):
f = tmp_path / "apps.yaml"
f.write_text(MINIMAL)
m = load_manifest(str(f))
assert m.organizations == []
assert m.applications == []
def test_full_org_fields(tmp_path):
f = tmp_path / "apps.yaml"
f.write_text(FULL)
m = load_manifest(str(f))
assert len(m.organizations) == 1
org = m.organizations[0]
assert org.name == "MyOrg"
assert org.description == "Test org"
assert org.email == "org@example.com"
assert org.enabled is True
assert org.development is False
assert org.environments == ["DEV_LAN"]
def test_full_app_fields(tmp_path):
f = tmp_path / "apps.yaml"
f.write_text(FULL)
m = load_manifest(str(f))
assert len(m.applications) == 1
app = m.applications[0]
assert app.name == "MyApp"
assert app.org_name == "MyOrg"
assert app.environments == ["DEV_LAN"]
def test_api_access_named(tmp_path):
f = tmp_path / "apps.yaml"
f.write_text(FULL)
m = load_manifest(str(f))
apis = m.applications[0].apis
assert len(apis) == 2
assert apis[0] == ApiAccess(api_name="MyAPI", api_version="v1")
assert apis[1] == ApiAccess(api_name="OtherAPI", api_version="")
def test_defaults_when_envs_omitted(tmp_path):
f = tmp_path / "apps.yaml"
f.write_text(NO_ENVS)
m = load_manifest(str(f))
assert m.organizations[0].environments == []
assert m.applications[0].environments == []
assert m.applications[0].apis == []
+198
View File
@@ -0,0 +1,198 @@
import pytest
from unittest.mock import MagicMock, patch
from apim_apps import normalize_env, EnvConfig, Manifest, OrgSpec, AppSpec, ApiAccess
@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 = {}
instance.list_apps.return_value = {}
instance.list_app_api_access.return_value = []
yield instance
def _manifest(orgs=None, apps=None):
return Manifest(organizations=orgs or [], applications=apps or [])
# ---------------------------------------------------------------------------
# Dry-run
# ---------------------------------------------------------------------------
def test_dry_run_makes_no_api_calls(env, mock_client):
manifest = _manifest(
orgs=[OrgSpec(name="MyOrg")],
apps=[AppSpec(name="MyApp", org_name="MyOrg", apis=[ApiAccess(api_name="MyAPI")])],
)
mock_client.list_orgs.return_value = {}
mock_client.list_apps.return_value = {}
result = normalize_env(env, manifest, dry_run=True)
mock_client.create_org.assert_not_called()
mock_client.create_app.assert_not_called()
mock_client.grant_api_access.assert_not_called()
assert len(result.errors) == 0
def test_dry_run_reports_planned_actions(env, mock_client):
manifest = _manifest(
orgs=[OrgSpec(name="MyOrg")],
apps=[AppSpec(name="MyApp", org_name="MyOrg")],
)
result = normalize_env(env, manifest, dry_run=True)
kinds = {a.kind for a in result.actions_done}
assert "create_org" in kinds
assert "create_app" in kinds
# ---------------------------------------------------------------------------
# Apply mode
# ---------------------------------------------------------------------------
def test_apply_creates_org_and_app(env, mock_client):
manifest = _manifest(
orgs=[OrgSpec(name="MyOrg")],
apps=[AppSpec(name="MyApp", org_name="MyOrg")],
)
mock_client.create_org.return_value = {"id": "org-1", "name": "MyOrg"}
mock_client.create_app.return_value = {"id": "app-1", "name": "MyApp"}
result = normalize_env(env, manifest, dry_run=False)
mock_client.create_org.assert_called_once()
mock_client.create_app.assert_called_once()
assert len(result.errors) == 0
def test_apply_grants_api_subscription(env, mock_client):
manifest = _manifest(
orgs=[OrgSpec(name="MyOrg")],
apps=[AppSpec(name="MyApp", org_name="MyOrg", apis=[ApiAccess(api_name="MyAPI")])],
)
mock_client.create_org.return_value = {"id": "org-1", "name": "MyOrg"}
mock_client.create_app.return_value = {"id": "app-1", "name": "MyApp"}
mock_client.find_frontend_api.return_value = {"id": "api-1", "name": "MyAPI"}
normalize_env(env, manifest, dry_run=False)
mock_client.grant_api_access.assert_called_once_with("app-1", "api-1")
def test_apply_skips_missing_frontend_api(env, mock_client):
manifest = _manifest(
orgs=[OrgSpec(name="MyOrg")],
apps=[AppSpec(name="MyApp", org_name="MyOrg", apis=[ApiAccess(api_name="Ghost")])],
)
mock_client.create_org.return_value = {"id": "org-1", "name": "MyOrg"}
mock_client.create_app.return_value = {"id": "app-1", "name": "MyApp"}
mock_client.find_frontend_api.return_value = None
result = normalize_env(env, manifest, dry_run=False)
mock_client.grant_api_access.assert_not_called()
assert any("Ghost" in e for e in result.errors)
# ---------------------------------------------------------------------------
# Skip when already present
# ---------------------------------------------------------------------------
def test_skips_existing_org(env, mock_client):
manifest = _manifest(orgs=[OrgSpec(name="MyOrg")])
mock_client.list_orgs.return_value = {"MyOrg": {"id": "org-1", "name": "MyOrg"}}
result = normalize_env(env, manifest, dry_run=False)
mock_client.create_org.assert_not_called()
assert any(a.kind == "create_org" for a in result.actions_skipped)
def test_skips_existing_app(env, mock_client):
manifest = _manifest(
orgs=[OrgSpec(name="MyOrg")],
apps=[AppSpec(name="MyApp", org_name="MyOrg")],
)
mock_client.list_orgs.return_value = {"MyOrg": {"id": "org-1", "name": "MyOrg"}}
mock_client.list_apps.return_value = {("MyOrg", "MyApp"): {"id": "app-1", "name": "MyApp"}}
result = normalize_env(env, manifest, dry_run=False)
mock_client.create_app.assert_not_called()
assert any(a.kind == "create_app" for a in result.actions_skipped)
def test_skips_already_granted_api(env, mock_client):
manifest = _manifest(
orgs=[OrgSpec(name="MyOrg")],
apps=[AppSpec(name="MyApp", org_name="MyOrg", apis=[ApiAccess(api_name="MyAPI")])],
)
mock_client.list_orgs.return_value = {"MyOrg": {"id": "org-1", "name": "MyOrg"}}
mock_client.list_apps.return_value = {("MyOrg", "MyApp"): {"id": "app-1", "name": "MyApp"}}
mock_client.list_app_api_access.return_value = [{"apiName": "MyAPI", "id": "api-1"}]
result = normalize_env(env, manifest, dry_run=False)
mock_client.grant_api_access.assert_not_called()
assert any(a.kind == "grant_api" for a in result.actions_skipped)
# ---------------------------------------------------------------------------
# Environment filtering
# ---------------------------------------------------------------------------
def test_env_filter_excludes_other_env(env, mock_client):
manifest = _manifest(
orgs=[
OrgSpec(name="GlobalOrg"),
OrgSpec(name="ProdOnly", environments=["PROD"]),
]
)
result = normalize_env(env, manifest, dry_run=True)
org_names = [a.detail["org"] for a in result.actions_done + result.actions_skipped]
assert "GlobalOrg" in org_names
assert "ProdOnly" not in org_names
def test_env_filter_matches_current_env(env, mock_client):
manifest = _manifest(
orgs=[OrgSpec(name="TestOnly", environments=["TEST"])]
)
result = normalize_env(env, manifest, dry_run=True)
org_names = [a.detail["org"] for a in result.actions_done + result.actions_skipped]
assert "TestOnly" in org_names
# ---------------------------------------------------------------------------
# Error handling
# ---------------------------------------------------------------------------
def test_error_when_org_missing_for_app(env, mock_client):
manifest = _manifest(
apps=[AppSpec(name="Orphan", org_name="MissingOrg")]
)
mock_client.list_orgs.return_value = {}
result = normalize_env(env, manifest, dry_run=False)
assert any("MissingOrg" in e for e in result.errors)
def test_list_orgs_failure_returns_error(env, mock_client):
import requests
mock_client.list_orgs.side_effect = requests.ConnectionError("timeout")
manifest = _manifest(orgs=[OrgSpec(name="MyOrg")])
result = normalize_env(env, manifest, dry_run=False)
assert len(result.errors) == 1