diff --git a/roles/web-app-n8n/files/python/provision/client.py b/roles/web-app-n8n/files/python/provision/client.py index e1c6241d3d..e81b3598cb 100644 --- a/roles/web-app-n8n/files/python/provision/client.py +++ b/roles/web-app-n8n/files/python/provision/client.py @@ -25,6 +25,7 @@ import os import re import sys import urllib.error +import urllib.parse import urllib.request BASE = os.environ.get("N8N_BASE", "").rstrip("/") @@ -38,6 +39,7 @@ PROVIDERS = json.loads(os.environ.get("N8N_MCP_PROVIDERS", "[]")) REST = "/rest" PUBLIC = "/api/v1" +PAGE = 250 CREDENTIAL_TYPE = "httpBearerAuth" BEARER_AUTH = "bearerAuth" CLIENT_PREFIX = "infinito:mcp-client:" @@ -90,6 +92,103 @@ def call(path, method="GET", payload=None, api_key=None): return error.code, error.read().decode(errors="replace") +def shape(value): + """Describe a value by its type and, for a mapping, its keys, never its content. + + Args: + value: decoded response fragment. + """ + if isinstance(value, dict): + return f"a mapping with keys {sorted(map(str, value))}" + if isinstance(value, list): + return f"a list of {sorted({type(item).__name__ for item in value})}" + return f"a {type(value).__name__}" + + +def records(body, what): + """Return the records of a list response. + + Args: + body: decoded body: ``{"data": [...]}``, a bare list, or the paged + ``{"data": {"items": [...], ...}}`` of n8n 2. + what: noun naming the collection, for the failure message. + """ + listed = body.get("data", body) if isinstance(body, dict) else body + if isinstance(listed, dict) and "items" in listed: + listed = listed["items"] + if listed is None: + return [] + if not isinstance(listed, list) or not all( + isinstance(item, dict) for item in listed + ): + sys.exit(f"FAILED listing {what}: expected records, got {shape(listed)}") + return listed + + +def record(body, what): + """Return the single record of a create response. + + Args: + body: decoded body, either ``{"data": {...}}`` or a bare mapping. + what: noun naming the record, for the failure message. + """ + created = body.get("data", body) if isinstance(body, dict) else body + if not isinstance(created, dict): + sys.exit(f"FAILED creating {what}: expected a record, got {shape(created)}") + return created + + +def api_keys_labelled(label): + """Return every API key whose label n8n matches against ``label``, all pages. + + Args: + label: text n8n looks for inside each label. + """ + found = [] + seen = set() + while True: + query = urllib.parse.urlencode( + {"label": label, "take": PAGE, "skip": len(found)} + ) + status, body = call(f"{REST}/api-keys?{query}") + if status != 200: + sys.exit(f"FAILED listing api keys: {status} {body}") + page = records(body, "api keys") + ids = {str(key.get("id")) for key in page} + if page and ids <= seen: + sys.exit(f"FAILED listing api keys: page at skip {len(found)} repeats") + seen |= ids + found.extend(page) + if len(page) < PAGE: + return found + + +def workflows_named(key, name): + """Return every workflow n8n matches against ``name``, following ``nextCursor``. + + Args: + key: the public-API key. + name: workflow name n8n filters on. + """ + found = [] + cursors = set() + query = {"name": name, "limit": PAGE} + while True: + status, body = call( + f"{PUBLIC}/workflows?{urllib.parse.urlencode(query)}", api_key=key + ) + if status != 200: + sys.exit(f"FAILED listing workflows: {status} {body}") + found.extend(records(body, "workflows")) + cursor = body.get("nextCursor") if isinstance(body, dict) else None + if not cursor: + return found + if cursor in cursors: + sys.exit("FAILED listing workflows: nextCursor repeats") + cursors.add(cursor) + query = {"name": name, "cursor": cursor} + + def login(): status, body = call( f"{REST}/login", @@ -106,14 +205,12 @@ def api_key(): n8n returns the usable secret only once, as ``rawApiKey``, and a listed key is redacted, so the key cannot be carried between runs. It is minted here and revoked before the run ends; an entry left behind under the managed name - belongs to a run that died and is deleted rather than guessed at. + belongs to a run that died and is deleted rather than guessed at. n8n matches + ``label`` as a substring, so the exact match happens here. """ - status, body = call(f"{REST}/api-keys") - if status != 200: - sys.exit(f"FAILED listing api keys: {status} {body}") - - existing = (body or {}).get("data") if isinstance(body, dict) else body - matches = [key for key in existing or [] if key.get("label") == KEY_NAME] + matches = [ + key for key in api_keys_labelled(KEY_NAME) if key.get("label") == KEY_NAME + ] if len(matches) > 1: sys.exit(f"FAILED: {len(matches)} api keys named {KEY_NAME}") for key in matches: @@ -136,7 +233,7 @@ def api_key(): ) if status not in (200, 201): sys.exit(f"FAILED creating api key {KEY_NAME}: {status} {body}") - created = (body or {}).get("data") if isinstance(body, dict) else body + created = record(body, f"api key {KEY_NAME}") return str(created["rawApiKey"]), str(created["id"]) @@ -174,8 +271,7 @@ def listed_credentials(): status, body = call(f"{REST}/credentials") if status != 200: sys.exit(f"FAILED listing credentials: {status} {body}") - existing = (body or {}).get("data") if isinstance(body, dict) else body - return list(existing or []) + return records(body, "credentials") def credential(provider): @@ -198,7 +294,7 @@ def credential(provider): status, body = call(f"{REST}/credentials", method="POST", payload=payload) if status not in (200, 201): sys.exit(f"FAILED creating {name}: {status} {body}") - created = (body or {}).get("data") if isinstance(body, dict) else body + created = record(body, name) return {"id": str(created["id"]), "name": name}, True status, body = call( @@ -314,11 +410,11 @@ def managed_workflow(key): Args: key: the public-API key. """ - status, body = call(f"{PUBLIC}/workflows", api_key=key) - if status != 200: - sys.exit(f"FAILED listing workflows: {status} {body}") - existing = (body or {}).get("data") if isinstance(body, dict) else body - matches = [flow for flow in existing or [] if flow.get("name") == WORKFLOW_NAME] + matches = [ + flow + for flow in workflows_named(key, WORKFLOW_NAME) + if flow.get("name") == WORKFLOW_NAME + ] if len(matches) > 1: sys.exit(f"FAILED: {len(matches)} workflows named {WORKFLOW_NAME}") return matches[0] if matches else None diff --git a/roles/web-app-n8n/files/python/provision/mcp.py b/roles/web-app-n8n/files/python/provision/mcp.py index 643e1aabc2..81d485c19c 100644 --- a/roles/web-app-n8n/files/python/provision/mcp.py +++ b/roles/web-app-n8n/files/python/provision/mcp.py @@ -19,6 +19,7 @@ import json import os import sys import urllib.error +import urllib.parse import urllib.request BASE = os.environ.get("N8N_BASE", "").rstrip("/") @@ -32,6 +33,7 @@ CREDENTIAL_NAME = os.environ.get("N8N_MCP_CREDENTIAL", "") REST = "/rest" PUBLIC = "/api/v1" +PAGE = 250 TRIGGER_TYPE = "@n8n/n8n-nodes-langchain.mcpTrigger" TRIGGER_VERSION = 2 TRIGGER_NAME = "Infinito MCP Trigger" @@ -96,14 +98,10 @@ def call(path, method="GET", payload=None, api_key=None): def shape(value): - """Describe a value for a failure message, without quoting its content. + """Describe a value by its type and, for a mapping, its keys, never its content. Args: - value: Decoded response fragment. - - Returns: - The type, and for a mapping its keys, which name fields rather than - carrying the credentials a value would. + value: decoded response fragment. """ if isinstance(value, dict): return f"a mapping with keys {sorted(map(str, value))}" @@ -116,13 +114,13 @@ def records(body, what): """Return the records of a list response. Args: - body: Decoded body, either ``{"data": [...]}`` or a bare list. - what: Noun naming the collection, for the failure message. - - Returns: - The records, empty when the response carries none. + body: decoded body: ``{"data": [...]}``, a bare list, or the paged + ``{"data": {"items": [...], ...}}`` of n8n 2. + what: noun naming the collection, for the failure message. """ listed = body.get("data", body) if isinstance(body, dict) else body + if isinstance(listed, dict) and "items" in listed: + listed = listed["items"] if listed is None: return [] if not isinstance(listed, list) or not all( @@ -136,11 +134,8 @@ def record(body, what): """Return the single record of a create response. Args: - body: Decoded body, either ``{"data": {...}}`` or a bare mapping. - what: Noun naming the record, for the failure message. - - Returns: - The record mapping. + body: decoded body, either ``{"data": {...}}`` or a bare mapping. + what: noun naming the record, for the failure message. """ created = body.get("data", body) if isinstance(body, dict) else body if not isinstance(created, dict): @@ -148,6 +143,57 @@ def record(body, what): return created +def api_keys_labelled(label): + """Return every API key whose label n8n matches against ``label``, all pages. + + Args: + label: text n8n looks for inside each label. + """ + found = [] + seen = set() + while True: + query = urllib.parse.urlencode( + {"label": label, "take": PAGE, "skip": len(found)} + ) + status, body = call(f"{REST}/api-keys?{query}") + if status != 200: + sys.exit(f"FAILED listing api keys: {status} {body}") + page = records(body, "api keys") + ids = {str(key.get("id")) for key in page} + if page and ids <= seen: + sys.exit(f"FAILED listing api keys: page at skip {len(found)} repeats") + seen |= ids + found.extend(page) + if len(page) < PAGE: + return found + + +def workflows_named(key, name): + """Return every workflow n8n matches against ``name``, following ``nextCursor``. + + Args: + key: the public-API key. + name: workflow name n8n filters on. + """ + found = [] + cursors = set() + query = {"name": name, "limit": PAGE} + while True: + status, body = call( + f"{PUBLIC}/workflows?{urllib.parse.urlencode(query)}", api_key=key + ) + if status != 200: + sys.exit(f"FAILED listing workflows: {status} {body}") + found.extend(records(body, "workflows")) + cursor = body.get("nextCursor") if isinstance(body, dict) else None + if not cursor: + return found + if cursor in cursors: + sys.exit("FAILED listing workflows: nextCursor repeats") + cursors.add(cursor) + query = {"name": name, "cursor": cursor} + + def login(): status, body = call( f"{REST}/login", @@ -164,13 +210,12 @@ def api_key(): n8n returns the usable secret only once, as ``rawApiKey``, and a listed key is redacted, so the key cannot be carried between runs. It is minted here and revoked before the run ends; an entry left behind under the managed name - belongs to a run that died and is deleted rather than guessed at. + belongs to a run that died and is deleted rather than guessed at. n8n matches + ``label`` as a substring, so the exact match happens here. """ - status, body = call(f"{REST}/api-keys") - if status != 200: - sys.exit(f"FAILED listing api keys: {status} {body}") - - matches = [key for key in records(body, "api keys") if key.get("label") == KEY_NAME] + matches = [ + key for key in api_keys_labelled(KEY_NAME) if key.get("label") == KEY_NAME + ] if len(matches) > 1: sys.exit(f"FAILED: {len(matches)} api keys named {KEY_NAME}") for key in matches: @@ -186,6 +231,7 @@ def api_key(): "workflow:read", "workflow:update", "workflow:list", + "workflow:activate", ], "expiresAt": None, }, @@ -319,12 +365,10 @@ def upsert_workflow(key, credential): key: the public-API key. credential: ``{"id", "name"}`` reference to the managed bearer credential. """ - status, body = call(f"{PUBLIC}/workflows", api_key=key) - if status != 200: - sys.exit(f"FAILED listing workflows: {status} {body}") - matches = [ - flow for flow in records(body, "workflows") if flow.get("name") == WORKFLOW_NAME + flow + for flow in workflows_named(key, WORKFLOW_NAME) + if flow.get("name") == WORKFLOW_NAME ] if len(matches) > 1: sys.exit(f"FAILED: {len(matches)} workflows named {WORKFLOW_NAME}") diff --git a/roles/web-app-n8n/meta/services.yml b/roles/web-app-n8n/meta/services.yml index b6c4b98888..f1801170af 100644 --- a/roles/web-app-n8n/meta/services.yml +++ b/roles/web-app-n8n/meta/services.yml @@ -73,7 +73,7 @@ n8n: backup: no_stop_required: false image: docker.n8n.io/n8nio/n8n - version: "2.42.3" + version: "2.43.2" update: type: registry_tags image: docker.n8n.io/n8nio/n8n diff --git a/tests/unit/python/roles/web-app-n8n/files/provision/test_client.py b/tests/unit/python/roles/web-app-n8n/files/provision/test_client.py index d698b892dc..914ab8b83f 100644 --- a/tests/unit/python/roles/web-app-n8n/files/provision/test_client.py +++ b/tests/unit/python/roles/web-app-n8n/files/provision/test_client.py @@ -8,10 +8,26 @@ import io import json import unittest from unittest.mock import patch +from urllib.parse import parse_qs, urlsplit from . import PROJECT_ROOT SCRIPT_PATH = PROJECT_ROOT / "roles/web-app-n8n/files/python/provision/client.py" +PAGE_SIZE = 10 +WORKFLOW_PAGE_SIZE = 100 +MAX_PAGE_SIZE = 250 + + +def paged(items, offset, limit): + """Return one page the way n8n slices a listing. + + Args: + items: every record the listing holds. + offset: index of the first record on the page. + limit: requested page size, capped like n8n caps it. + """ + return items[offset : offset + min(limit, MAX_PAGE_SIZE)] + PROVIDER = { "id": "web-app-gitea", @@ -78,16 +94,34 @@ class FakeApi: self.deleted_credentials.append(dead) self.credentials = [c for c in self.credentials if c["id"] != dead] return 200, {"success": True} - if path == "/rest/api-keys" and method == "GET": - return 200, {"data": [dict(key) for key in self.keys]} + if path.startswith("/rest/api-keys?") and method == "GET": + query = parse_qs(urlsplit(path).query) + label = query["label"][0].lower() + items = [dict(key) for key in self.keys if label in key["label"].lower()] + skip = int(query.get("skip", ["0"])[0]) + take = int(query.get("take", [str(PAGE_SIZE)])[0]) + return 200, {"data": {"items": paged(items, skip, take)}} if path == "/rest/api-keys" and method == "POST": self.keys = [{"id": "k1", "label": payload["label"]}] return 200, {"data": {"id": "k1", "rawApiKey": "n8n-raw-key"}} if path.startswith("/rest/api-keys/") and method == "DELETE": self.deleted_keys.append(path.rsplit("/", 1)[-1]) return 200, {"success": True} - if path == "/api/v1/workflows" and method == "GET": - return 200, {"data": [dict(flow) for flow in self.workflows]} + if path.split("?")[0] == "/api/v1/workflows" and method == "GET": + query = parse_qs(urlsplit(path).query) + name = query.get("name", [""])[0] + hits = [dict(flow) for flow in self.workflows if name in flow["name"]] + cursor = query.get("cursor", [f"0:{WORKFLOW_PAGE_SIZE}"])[0].split(":") + offset, limit = (int(cursor[0]), int(cursor[1])) + if "limit" in query: + limit = int(query["limit"][0]) + page = paged(hits, offset, limit) + following = offset + len(page) + more = following < len(hits) + return 200, { + "data": page, + "nextCursor": f"{following}:{limit}" if more else None, + } if path == "/api/v1/workflows" and method == "POST": self.created_workflows.append(payload) return 200, dict(payload, id="w1", active=False) @@ -179,6 +213,100 @@ class TestProvisionClient(unittest.TestCase): module.main() self.assertEqual(["k1"], api.deleted_keys) + def test_a_stale_managed_key_behind_a_full_page_is_still_replaced(self) -> None: + module = load_script() + humans = [{"id": f"h{n}", "label": f"human {n}"} for n in range(PAGE_SIZE)] + api = FakeApi(keys=[*humans, {"id": "old", "label": "infinito:mcp"}]) + with patch.object(module, "call", api): + module.main() + self.assertEqual(["old", "k1"], api.deleted_keys) + + def test_a_stale_managed_key_behind_a_full_filtered_page_is_still_replaced( + self, + ) -> None: + module = load_script() + near = [ + {"id": f"n{n}", "label": f"infinito:mcp-copy-{n}"} + for n in range(MAX_PAGE_SIZE) + ] + api = FakeApi(keys=[*near, {"id": "old", "label": "infinito:mcp"}]) + with patch.object(module, "call", api): + module.main() + self.assertEqual(["old", "k1"], api.deleted_keys) + + def test_a_managed_workflow_on_a_later_page_is_updated_not_duplicated( + self, + ) -> None: + module = load_script() + near = [ + {"id": f"n{n}", "name": f"infinito:mcp-agent copy {n}"} + for n in range(MAX_PAGE_SIZE) + ] + managed = {"id": "w9", "name": "infinito:mcp-agent"} + api = FakeApi(workflows=[*near, managed], credentials=[dict(REFERENCE)]) + with patch.object(module, "call", api): + module.main() + self.assertEqual([], api.created_workflows) + self.assertEqual(1, len(api.updated_workflows)) + + def test_a_repeated_workflow_cursor_aborts_rather_than_loops(self) -> None: + module = load_script() + api = FakeApi() + listings = [] + + def stuck(path, method="GET", payload=None, api_key=None): + if path.startswith("/api/v1/workflows?"): + listings.append(path) + if len(listings) > 3: + raise AssertionError("the workflow listing never ends") + return 200, {"data": [], "nextCursor": "same"} + return api(path, method, payload, api_key) + + with ( + patch.object(module, "call", stuck), + self.assertRaises(SystemExit) as exit_, + ): + module.main() + self.assertIn("nextCursor repeats", str(exit_.exception)) + + def test_a_key_listing_that_ignores_skip_aborts_rather_than_loops(self) -> None: + module = load_script() + api = FakeApi() + full = [ + {"id": f"n{n}", "label": f"infinito:mcp-copy-{n}"} + for n in range(MAX_PAGE_SIZE) + ] + listings = [] + + def frozen(path, method="GET", payload=None, api_key=None): + if path.startswith("/rest/api-keys?"): + listings.append(path) + if len(listings) > 3: + raise AssertionError("the key listing never ends") + return 200, {"data": {"items": full}} + return api(path, method, payload, api_key) + + with ( + patch.object(module, "call", frozen), + self.assertRaises(SystemExit) as exit_, + ): + module.main() + self.assertIn("repeats", str(exit_.exception)) + + def test_an_unknown_listing_shape_names_its_keys_but_never_a_value(self) -> None: + module = load_script() + api = FakeApi() + + def odd(path, method="GET", payload=None, api_key=None): + if path == "/rest/credentials" and method == "GET": + return 200, {"data": {"token": "t0ps3cret"}} + return api(path, method, payload, api_key) + + with patch.object(module, "call", odd), self.assertRaises(SystemExit) as exit_: + module.main() + self.assertIn("['token']", str(exit_.exception)) + self.assertNotIn("t0ps3cret", str(exit_.exception)) + def test_a_rotated_provider_token_replaces_the_stored_one(self) -> None: module = load_script() api = FakeApi(credentials=[dict(REFERENCE)]) diff --git a/tests/unit/python/roles/web-app-n8n/files/provision/test_mcp.py b/tests/unit/python/roles/web-app-n8n/files/provision/test_mcp.py index 81dcecf03a..a664866a33 100644 --- a/tests/unit/python/roles/web-app-n8n/files/provision/test_mcp.py +++ b/tests/unit/python/roles/web-app-n8n/files/provision/test_mcp.py @@ -8,10 +8,26 @@ import io import json import unittest from unittest.mock import patch +from urllib.parse import parse_qs, urlsplit from . import PROJECT_ROOT SCRIPT_PATH = PROJECT_ROOT / "roles/web-app-n8n/files/python/provision/mcp.py" +PAGE_SIZE = 10 +WORKFLOW_PAGE_SIZE = 100 +MAX_PAGE_SIZE = 250 + + +def paged(items, offset, limit): + """Return one page the way n8n slices a listing. + + Args: + items: every record the listing holds. + offset: index of the first record on the page. + limit: requested page size, capped like n8n caps it. + """ + return items[offset : offset + min(limit, MAX_PAGE_SIZE)] + ENV = { "N8N_BASE": "http://n8n:5678", @@ -51,6 +67,7 @@ class FakeApi: self.deleted_keys = [] self.logins = 0 self.minted = 0 + self.scopes = set() def __call__(self, path, method="GET", payload=None, api_key=None): if path == "/rest/login": @@ -72,16 +89,35 @@ class FakeApi: self.deleted_credentials.append(dead) self.credentials = [c for c in self.credentials if c["id"] != dead] return 200, {"success": True} - if path == "/rest/api-keys" and method == "GET": - return 200, {"data": [dict(key) for key in self.keys]} + if path.startswith("/rest/api-keys?") and method == "GET": + query = parse_qs(urlsplit(path).query) + label = query["label"][0].lower() + items = [dict(key) for key in self.keys if label in key["label"].lower()] + skip = int(query.get("skip", ["0"])[0]) + take = int(query.get("take", [str(PAGE_SIZE)])[0]) + return 200, {"data": {"items": paged(items, skip, take)}} if path == "/rest/api-keys" and method == "POST": self.keys = [{"id": "k1", "label": payload["label"]}] + self.scopes = set(payload["scopes"]) return 200, {"data": {"id": "k1", "rawApiKey": "n8n-raw-key"}} if path.startswith("/rest/api-keys/") and method == "DELETE": self.deleted_keys.append(path.rsplit("/", 1)[-1]) return 200, {"success": True} - if path == "/api/v1/workflows" and method == "GET": - return 200, {"data": [dict(flow) for flow in self.workflows]} + if path.split("?")[0] == "/api/v1/workflows" and method == "GET": + query = parse_qs(urlsplit(path).query) + name = query.get("name", [""])[0] + hits = [dict(flow) for flow in self.workflows if name in flow["name"]] + cursor = query.get("cursor", [f"0:{WORKFLOW_PAGE_SIZE}"])[0].split(":") + offset, limit = (int(cursor[0]), int(cursor[1])) + if "limit" in query: + limit = int(query["limit"][0]) + page = paged(hits, offset, limit) + following = offset + len(page) + more = following < len(hits) + return 200, { + "data": page, + "nextCursor": f"{following}:{limit}" if more else None, + } if path == "/api/v1/workflows" and method == "POST": self.created_workflows.append(payload) return 200, dict(payload, id="w1", active=False) @@ -89,6 +125,8 @@ class FakeApi: self.updated_workflows.append(payload) return 200, payload if path.endswith("/activate") and method == "POST": + if "workflow:activate" not in self.scopes: + return 403, '{"message":"Forbidden"}' self.activated_workflows.append(path.split("/")[-2]) return 200, {"active": True} if path.startswith("/api/v1/workflows/") and method == "DELETE": @@ -184,6 +222,120 @@ class TestProvisionMcp(unittest.TestCase): module.main() self.assertEqual(["old", "k1"], api.deleted_keys) + def test_a_stale_managed_key_behind_a_full_page_is_still_replaced(self) -> None: + module = load_script() + humans = [{"id": f"h{n}", "label": f"human {n}"} for n in range(PAGE_SIZE)] + api = FakeApi(keys=[*humans, {"id": "old", "label": "infinito:mcp"}]) + with patch.object(module, "call", api): + module.main() + self.assertEqual(["old", "k1"], api.deleted_keys) + + def test_an_unknown_listing_shape_names_its_keys_but_never_a_value(self) -> None: + module = load_script() + api = FakeApi() + + def odd(path, method="GET", payload=None, api_key=None): + if path.startswith("/rest/api-keys?"): + return 200, {"data": {"secret": "t0ps3cret"}} + return api(path, method, payload, api_key) + + with patch.object(module, "call", odd), self.assertRaises(SystemExit) as exit_: + module.main() + self.assertIn("['secret']", str(exit_.exception)) + self.assertNotIn("t0ps3cret", str(exit_.exception)) + + def test_an_unpaged_listing_of_n8n_1_still_finds_the_stale_key(self) -> None: + module = load_script() + api = FakeApi() + + def unpaged(path, method="GET", payload=None, api_key=None): + if path.startswith("/rest/api-keys?"): + return 200, {"data": [{"id": "old", "label": "infinito:mcp"}]} + return api(path, method, payload, api_key) + + with patch.object(module, "call", unpaged): + module.main() + self.assertEqual(["old", "k1"], api.deleted_keys) + + def test_a_stale_managed_key_behind_a_full_filtered_page_is_still_replaced( + self, + ) -> None: + module = load_script() + near = [ + {"id": f"n{n}", "label": f"infinito:mcp-copy-{n}"} + for n in range(MAX_PAGE_SIZE) + ] + api = FakeApi(keys=[*near, {"id": "old", "label": "infinito:mcp"}]) + with patch.object(module, "call", api): + module.main() + self.assertEqual(["old", "k1"], api.deleted_keys) + + def test_a_managed_workflow_on_a_later_page_is_updated_not_duplicated( + self, + ) -> None: + module = load_script() + near = [ + {"id": f"n{n}", "name": f"infinito:mcp-server copy {n}"} + for n in range(MAX_PAGE_SIZE) + ] + managed = {"id": "w9", "name": "infinito:mcp-server", "active": True} + api = FakeApi(workflows=[*near, managed]) + with patch.object(module, "call", api): + module.main() + self.assertEqual([], api.created_workflows) + self.assertEqual(1, len(api.updated_workflows)) + + def test_a_repeated_workflow_cursor_aborts_rather_than_loops(self) -> None: + module = load_script() + api = FakeApi() + listings = [] + + def stuck(path, method="GET", payload=None, api_key=None): + if path.startswith("/api/v1/workflows?"): + listings.append(path) + if len(listings) > 3: + raise AssertionError("the workflow listing never ends") + return 200, {"data": [], "nextCursor": "same"} + return api(path, method, payload, api_key) + + with ( + patch.object(module, "call", stuck), + self.assertRaises(SystemExit) as exit_, + ): + module.main() + self.assertIn("nextCursor repeats", str(exit_.exception)) + + def test_a_key_listing_that_ignores_skip_aborts_rather_than_loops(self) -> None: + module = load_script() + api = FakeApi() + full = [ + {"id": f"n{n}", "label": f"infinito:mcp-copy-{n}"} + for n in range(MAX_PAGE_SIZE) + ] + listings = [] + + def frozen(path, method="GET", payload=None, api_key=None): + if path.startswith("/rest/api-keys?"): + listings.append(path) + if len(listings) > 3: + raise AssertionError("the key listing never ends") + return 200, {"data": {"items": full}} + return api(path, method, payload, api_key) + + with ( + patch.object(module, "call", frozen), + self.assertRaises(SystemExit) as exit_, + ): + module.main() + self.assertIn("repeats", str(exit_.exception)) + + def test_a_key_that_only_contains_the_managed_name_is_left_alone(self) -> None: + module = load_script() + api = FakeApi(keys=[{"id": "near", "label": "infinito:mcp-legacy"}]) + with patch.object(module, "call", api): + module.main() + self.assertEqual(["k1"], api.deleted_keys) + def test_the_run_leaves_no_api_key_behind(self) -> None: module = load_script() api = FakeApi()