Skip to content

Commit d518ea8

Browse files
committed
feat(n8n): add n8n workflow automation container module
Closes #901
1 parent b12ae13 commit d518ea8

File tree

4 files changed

+188
-0
lines changed

4 files changed

+188
-0
lines changed

modules/n8n/README.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.. autoclass:: testcontainers.n8n.N8nContainer
2+
.. title:: testcontainers.n8n.N8nContainer
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
#
2+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
3+
# not use this file except in compliance with the License. You may obtain
4+
# a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
10+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
11+
# License for the specific language governing permissions and limitations
12+
# under the License.
13+
14+
import json
15+
from http.client import HTTPException
16+
from typing import Optional
17+
from urllib.error import URLError
18+
from urllib.request import Request, urlopen
19+
20+
from testcontainers.core.container import DockerContainer
21+
from testcontainers.core.waiting_utils import wait_container_is_ready
22+
23+
24+
class N8nContainer(DockerContainer):
25+
"""
26+
n8n workflow automation container.
27+
28+
Starts an n8n instance, sets up the owner account, and generates an API key
29+
for programmatic access via the public API.
30+
31+
Example:
32+
33+
.. doctest::
34+
35+
>>> from testcontainers.n8n import N8nContainer
36+
37+
>>> with N8nContainer() as n8n:
38+
... api_key = n8n.get_api_key()
39+
... assert api_key is not None
40+
"""
41+
42+
def __init__(
43+
self,
44+
image: str = "docker.n8n.io/n8nio/n8n:latest",
45+
port: int = 5678,
46+
owner_email: str = "owner@test.com",
47+
owner_password: str = "Testpass1",
48+
owner_first_name: str = "Test",
49+
owner_last_name: str = "User",
50+
encryption_key: Optional[str] = None,
51+
**kwargs,
52+
) -> None:
53+
super().__init__(image, **kwargs)
54+
self.port = port
55+
self.owner_email = owner_email
56+
self.owner_password = owner_password
57+
self.owner_first_name = owner_first_name
58+
self.owner_last_name = owner_last_name
59+
self.encryption_key = encryption_key
60+
self._api_key: Optional[str] = None
61+
self.with_exposed_ports(self.port)
62+
63+
def _configure(self) -> None:
64+
self.with_env("N8N_PORT", str(self.port))
65+
self.with_env("N8N_DIAGNOSTICS_ENABLED", "false")
66+
self.with_env("N8N_SECURE_COOKIE", "false")
67+
if self.encryption_key:
68+
self.with_env("N8N_ENCRYPTION_KEY", self.encryption_key)
69+
70+
def get_url(self) -> str:
71+
host = self.get_container_host_ip()
72+
port = self.get_exposed_port(self.port)
73+
return f"http://{host}:{port}"
74+
75+
def get_webhook_url(self) -> str:
76+
return f"{self.get_url()}/webhook-test"
77+
78+
def get_api_key(self) -> str:
79+
"""Return the API key for the public API (X-N8N-API-KEY header)."""
80+
if self._api_key is None:
81+
raise RuntimeError("API key not available. Is the container started?")
82+
return self._api_key
83+
84+
@wait_container_is_ready(HTTPException, URLError, ConnectionError, json.JSONDecodeError)
85+
def _healthcheck(self) -> None:
86+
# /healthz returns 200 before the REST API is fully initialized,
87+
# so we check /rest/settings which only returns valid JSON once ready.
88+
url = f"{self.get_url()}/rest/settings"
89+
with urlopen(url, timeout=5) as res:
90+
if res.status > 299:
91+
raise HTTPException()
92+
body = res.read().decode()
93+
json.loads(body)
94+
95+
def _post_json(self, path: str, data: dict, headers: Optional[dict] = None) -> tuple:
96+
"""Make a JSON POST request, return (parsed_body, response_headers)."""
97+
payload = json.dumps(data).encode()
98+
req_headers = {"Content-Type": "application/json"}
99+
if headers:
100+
req_headers.update(headers)
101+
req = Request(
102+
f"{self.get_url()}{path}",
103+
data=payload,
104+
headers=req_headers,
105+
method="POST",
106+
)
107+
with urlopen(req, timeout=10) as res:
108+
body = json.loads(res.read().decode())
109+
return body, res.headers
110+
111+
def _setup_owner_and_api_key(self) -> str:
112+
"""Set up the owner account, create an API key, and return it."""
113+
# Step 1: Create owner — returns session cookie
114+
_, setup_headers = self._post_json(
115+
"/rest/owner/setup",
116+
{
117+
"email": self.owner_email,
118+
"firstName": self.owner_first_name,
119+
"lastName": self.owner_last_name,
120+
"password": self.owner_password,
121+
},
122+
)
123+
cookie = setup_headers.get("Set-Cookie", "").split(";")[0]
124+
125+
# Step 2: Get available API key scopes for the owner role
126+
req = Request(
127+
f"{self.get_url()}/rest/api-keys/scopes",
128+
headers={"Cookie": cookie},
129+
)
130+
with urlopen(req, timeout=10) as res:
131+
scopes = json.loads(res.read().decode())["data"]
132+
133+
# Step 3: Create API key with all available scopes
134+
body, _ = self._post_json(
135+
"/rest/api-keys",
136+
{
137+
"label": "testcontainer",
138+
"scopes": scopes,
139+
"expiresAt": 2089401600000,
140+
},
141+
headers={"Cookie": cookie},
142+
)
143+
144+
return body["data"]["rawApiKey"]
145+
146+
def start(self) -> "N8nContainer":
147+
super().start()
148+
self._healthcheck()
149+
self._api_key = self._setup_owner_and_api_key()
150+
return self

modules/n8n/tests/test_n8n.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import json
2+
from urllib.request import Request, urlopen
3+
4+
from testcontainers.n8n import N8nContainer
5+
6+
7+
def test_docker_run_n8n():
8+
with N8nContainer("docker.n8n.io/n8nio/n8n:latest") as n8n:
9+
url = n8n.get_url()
10+
with urlopen(f"{url}/healthz") as response:
11+
assert response.status == 200
12+
data = json.loads(response.read().decode())
13+
assert data["status"] == "ok"
14+
15+
16+
def test_n8n_api_key():
17+
with N8nContainer() as n8n:
18+
api_key = n8n.get_api_key()
19+
assert api_key is not None
20+
req = Request(
21+
f"{n8n.get_url()}/api/v1/workflows",
22+
headers={"X-N8N-API-KEY": api_key},
23+
)
24+
with urlopen(req, timeout=10) as response:
25+
assert response.status == 200
26+
data = json.loads(response.read().decode())
27+
assert "data" in data
28+
29+
30+
def test_n8n_get_webhook_url():
31+
with N8nContainer() as n8n:
32+
webhook_url = n8n.get_webhook_url()
33+
assert "/webhook-test" in webhook_url

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ mongodb = ["pymongo>=4"]
8484
mqtt = []
8585
mssql = ["sqlalchemy>=2", "pymssql>=2"]
8686
mysql = ["sqlalchemy>=2", "pymysql[rsa]>=1"]
87+
n8n = []
8788
nats = ["nats-py>=2"]
8889
neo4j = ["neo4j>=6"]
8990
nginx = []
@@ -201,6 +202,7 @@ packages = [
201202
"modules/mqtt/testcontainers",
202203
"modules/mssql/testcontainers",
203204
"modules/mysql/testcontainers",
205+
"modules/n8n/testcontainers",
204206
"modules/nats/testcontainers",
205207
"modules/neo4j/testcontainers",
206208
"modules/nginx/testcontainers",
@@ -250,6 +252,7 @@ dev-mode-dirs = [
250252
"modules/mqtt",
251253
"modules/mssql",
252254
"modules/mysql",
255+
"modules/n8n",
253256
"modules/nats",
254257
"modules/neo4j",
255258
"modules/nginx",

0 commit comments

Comments
 (0)