diff --git a/roles/web-app-n8n/files/python/provision/client.py b/roles/web-app-n8n/files/python/provision/client.py index b3c673b289..e81b3598cb 100644 --- a/roles/web-app-n8n/files/python/provision/client.py +++ b/roles/web-app-n8n/files/python/provision/client.py @@ -145,6 +145,7 @@ def api_keys_labelled(label): label: text n8n looks for inside each label. """ found = [] + seen = set() while True: query = urllib.parse.urlencode( {"label": label, "take": PAGE, "skip": len(found)} @@ -153,6 +154,10 @@ def api_keys_labelled(label): 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 @@ -166,6 +171,7 @@ def workflows_named(key, name): name: workflow name n8n filters on. """ found = [] + cursors = set() query = {"name": name, "limit": PAGE} while True: status, body = call( @@ -177,6 +183,9 @@ def workflows_named(key, name): 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} diff --git a/roles/web-app-n8n/files/python/provision/mcp.py b/roles/web-app-n8n/files/python/provision/mcp.py index 8e9745b144..81d485c19c 100644 --- a/roles/web-app-n8n/files/python/provision/mcp.py +++ b/roles/web-app-n8n/files/python/provision/mcp.py @@ -150,6 +150,7 @@ def api_keys_labelled(label): label: text n8n looks for inside each label. """ found = [] + seen = set() while True: query = urllib.parse.urlencode( {"label": label, "take": PAGE, "skip": len(found)} @@ -158,6 +159,10 @@ def api_keys_labelled(label): 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 @@ -171,6 +176,7 @@ def workflows_named(key, name): name: workflow name n8n filters on. """ found = [] + cursors = set() query = {"name": name, "limit": PAGE} while True: status, body = call( @@ -182,6 +188,9 @@ def workflows_named(key, name): 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} 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 875a4abe4c..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 @@ -249,6 +249,50 @@ class TestProvisionClient(unittest.TestCase): 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() 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 2cd90c0d7c..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 @@ -285,6 +285,50 @@ class TestProvisionMcp(unittest.TestCase): 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"}])