diff --git a/roles/web-app-keycloak/meta/services.yml b/roles/web-app-keycloak/meta/services.yml index 2071d0fa64..379394b130 100644 --- a/roles/web-app-keycloak/meta/services.yml +++ b/roles/web-app-keycloak/meta/services.yml @@ -51,7 +51,7 @@ keycloak: shared: true provides: sso image: quay.io/keycloak/keycloak - version: "26.8.0" + version: "26.8.1" name: keycloak min_storage: 468MB cpus: "2.0" diff --git a/roles/web-app-mailu/docs/Testing.md b/roles/web-app-mailu/docs/Testing.md index f5e69f4230..db0f20a84f 100644 --- a/roles/web-app-mailu/docs/Testing.md +++ b/roles/web-app-mailu/docs/Testing.md @@ -2,5 +2,5 @@ Use the following tools to test your server instance: -- [SSL-Tools Mailserver Test](https://de.ssl-tools.net/mailservers/) +- [Internet.nl Email Test](https://internet.nl/test-mail/) - [TestEmail.de](http://testemail.de/) diff --git a/roles/web-app-matrix/meta/services.yml b/roles/web-app-matrix/meta/services.yml index 5a59dbcf13..e8e59ab894 100644 --- a/roles/web-app-matrix/meta/services.yml +++ b/roles/web-app-matrix/meta/services.yml @@ -66,7 +66,7 @@ synapse: element: healthcheck: flavor: wget - version: v1.12.30 + version: v1.12.31 image: vectorim/element-web name: matrix-element ports: 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 bd056f4d29..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" @@ -67,36 +69,6 @@ TOOL_SCHEMA = json.dumps( SESSION = {"cookie": ""} -def records(body, what): - """The records a list endpoint returned, as a list of dicts. - - A 200 whose body is not a list of records used to reach the callers' - comprehensions, which iterate a string or a dict key by key and die on - ``'str' object has no attribute 'get'`` with nothing naming the endpoint. - The shape is reported instead; the body is not, because the credentials - listing carries secrets. - - Args: - body: decoded response body. - what: what was being listed, for the failure message. - """ - data = body.get("data") if isinstance(body, dict) else body - if data is None: - return [] - if not isinstance(data, list): - sys.exit( - f"FAILED listing {what}: expected a list of records, " - f"got {type(data).__name__}" - ) - offenders = {type(row).__name__ for row in data if not isinstance(row, dict)} - if offenders: - sys.exit( - f"FAILED listing {what}: expected records, " - f"got {', '.join(sorted(offenders))} among {len(data)} entries" - ) - return data - - def call(path, method="GET", payload=None, api_key=None): """Return ``(status, body)`` of one n8n API call. @@ -125,6 +97,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", @@ -141,14 +210,11 @@ 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 + 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}") @@ -165,13 +231,14 @@ def api_key(): "workflow:read", "workflow:update", "workflow:list", + "workflow:activate", ], "expiresAt": None, }, ) 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"]) @@ -214,7 +281,7 @@ def bearer_credential(): status, body = call(f"{REST}/credentials", method="POST", payload=payload) if status not in (200, 201): sys.exit(f"FAILED creating {CREDENTIAL_NAME}: {status} {body}") - created = (body or {}).get("data") if isinstance(body, dict) else body + created = record(body, CREDENTIAL_NAME) return {"id": str(created["id"]), "name": CREDENTIAL_NAME}, True status, body = call( @@ -298,13 +365,9 @@ 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") + for flow in workflows_named(key, WORKFLOW_NAME) if flow.get("name") == WORKFLOW_NAME ] if len(matches) > 1: diff --git a/roles/web-app-n8n/meta/services.yml b/roles/web-app-n8n/meta/services.yml index 64aa57e29f..d4e7d5e36b 100644 --- a/roles/web-app-n8n/meta/services.yml +++ b/roles/web-app-n8n/meta/services.yml @@ -67,7 +67,7 @@ n8n: backup: no_stop_required: false image: docker.n8n.io/n8nio/n8n - version: "2.43.4" + version: "2.44.0" 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 c10a7eb936..f8688c8b03 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() @@ -346,11 +498,12 @@ class TestRecordsRejectsAShapeItCannotRead(unittest.TestCase): self.assertIn("api keys", str(exit_.exception)) self.assertIn("str", str(exit_.exception)) - def test_a_nested_payload_names_its_type(self) -> None: + def test_a_mapping_that_is_not_the_paged_shape_names_its_keys(self) -> None: + """n8n 2 pages as {"data": {"items": [...]}}; anything else is named.""" with self.assertRaises(SystemExit) as exit_: self.module.records({"data": {"data": [{"id": "a"}]}}, "workflows") self.assertIn("workflows", str(exit_.exception)) - self.assertIn("dict", str(exit_.exception)) + self.assertIn("data", str(exit_.exception)) def test_a_list_of_strings_names_the_offending_entries(self) -> None: with self.assertRaises(SystemExit) as exit_: