Skip to content

Commit 3e8edc2

Browse files
author
sidey79
committed
test: add isolated e2e auth endpoint checks
1 parent d44a96d commit 3e8edc2

6 files changed

Lines changed: 348 additions & 0 deletions

File tree

.github/workflows/lint.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,13 @@ jobs:
5656
- name: Verify auth-protected endpoint policy
5757
run: |
5858
bash scripts/test-auth-protected-endpoints.sh caddy/Caddyfile
59+
60+
endpoint-authz-e2e:
61+
name: runner / endpoint-authz-e2e
62+
runs-on: ubuntu-latest
63+
steps:
64+
- name: Check out code
65+
uses: actions/checkout@v6
66+
- name: Run E2E auth endpoint checks
67+
run: |
68+
bash scripts/test-auth-endpoints-e2e.sh

scripts/test-auth-endpoints-e2e.sh

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5+
COMPOSE_FILE="$ROOT_DIR/tests/e2e/docker-compose.e2e.yml"
6+
BASE_URL="http://127.0.0.1:18080"
7+
8+
cleanup() {
9+
docker compose -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
10+
}
11+
trap cleanup EXIT
12+
13+
req_code() {
14+
local method="$1" host="$2" path="$3" auth="$4" extra_header="${5:-}"
15+
local -a args=( -sS -o /dev/null -w "%{http_code}" -X "$method" -H "Host: $host" )
16+
if [[ "$auth" == "auth" ]]; then
17+
args+=( -H "X-Test-Auth: allow" )
18+
fi
19+
if [[ -n "$extra_header" ]]; then
20+
args+=( -H "$extra_header" )
21+
fi
22+
curl "${args[@]}" "$BASE_URL$path"
23+
}
24+
25+
req_header() {
26+
local method="$1" host="$2" path="$3" auth="$4" header_name="$5" extra_header="${6:-}"
27+
local -a args=( -sS -D - -o /dev/null -X "$method" -H "Host: $host" )
28+
if [[ "$auth" == "auth" ]]; then
29+
args+=( -H "X-Test-Auth: allow" )
30+
fi
31+
if [[ -n "$extra_header" ]]; then
32+
args+=( -H "$extra_header" )
33+
fi
34+
curl "${args[@]}" "$BASE_URL$path" | awk -v h="$header_name" 'BEGIN{IGNORECASE=1} $0 ~ "^"h":" {print; found=1} END{if(!found) exit 1}'
35+
}
36+
37+
assert_eq() {
38+
local got="$1" want="$2" msg="$3"
39+
if [[ "$got" != "$want" ]]; then
40+
echo "FAIL: $msg (got=$got want=$want)" >&2
41+
exit 1
42+
fi
43+
echo "OK: $msg -> $got"
44+
}
45+
46+
assert_no_upstream_header() {
47+
local method="$1" host="$2" path="$3" auth="$4"
48+
if req_header "$method" "$host" "$path" "$auth" "X-Upstream" >/dev/null 2>&1; then
49+
echo "FAIL: unexpected upstream proxy for $host$path" >&2
50+
exit 1
51+
fi
52+
echo "OK: no upstream proxy header for $host$path"
53+
}
54+
55+
echo "Starting isolated E2E stack..."
56+
docker compose -f "$COMPOSE_FILE" up -d --wait
57+
58+
# landing.* protected endpoints
59+
assert_eq "$(req_code GET landing.test /backup/status none)" "401" "landing /backup/status requires auth"
60+
assert_eq "$(req_code GET landing.test /backup/status auth)" "200" "landing /backup/status works with auth"
61+
assert_eq "$(req_code GET landing.test /backup/names none)" "401" "landing /backup/names requires auth"
62+
assert_eq "$(req_code GET landing.test /backup/names auth)" "200" "landing /backup/names works with auth"
63+
assert_eq "$(req_code GET landing.test /api/me none)" "401" "landing /api/me requires auth"
64+
assert_eq "$(req_code GET landing.test /api/me auth)" "200" "landing /api/me works with auth"
65+
66+
# Ensure no broad /backup/* passthrough remains
67+
assert_eq "$(req_code GET landing.test /backup/rest/workflows auth)" "404" "landing /backup/rest/workflows is not proxied"
68+
assert_no_upstream_header GET landing.test /backup/rest/workflows auth
69+
70+
# workflow.* protected webhook path
71+
assert_eq "$(req_code POST workflow.test /webhook/protected none)" "401" "workflow protected webhook requires auth without mTLS/exception"
72+
assert_eq "$(req_code POST workflow.test /webhook/protected auth)" "200" "workflow protected webhook works with auth"
73+
74+
# Explicit Telegram exception (no auth but header secret)
75+
assert_eq "$(req_code POST workflow.test /webhook/scanservjs/telegram/reissue none "X-Telegram-Bot-Api-Secret-Token: test-telegram-secret")" "200" "telegram exception works without auth when secret header matches"
76+
assert_eq "$(req_code POST workflow.test /webhook/scanservjs/telegram/reissue none)" "401" "telegram path without secret remains protected"
77+
78+
echo "All E2E auth endpoint checks passed."

tests/e2e/Caddyfile

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
{
2+
auto_https off
3+
admin off
4+
}
5+
6+
(authelia_forward_auth) {
7+
forward_auth authelia:9091 {
8+
uri /api/authz/forward-auth
9+
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
10+
}
11+
}
12+
13+
(n8n_upstream) {
14+
reverse_proxy http://n8n-mock:5678
15+
}
16+
17+
http://landing.test {
18+
encode gzip
19+
20+
@landingApiMe {
21+
method GET HEAD
22+
path /api/me
23+
}
24+
25+
@landingBackupStatus {
26+
method GET HEAD
27+
path /backup/status
28+
}
29+
30+
@landingBackupNames {
31+
method GET HEAD
32+
path /backup/names
33+
}
34+
35+
route {
36+
handle @landingApiMe {
37+
import authelia_forward_auth
38+
rewrite * /webhook/landing/api/me
39+
import n8n_upstream
40+
}
41+
42+
handle @landingBackupStatus {
43+
import authelia_forward_auth
44+
rewrite * /webhook/backup/status-public
45+
import n8n_upstream
46+
}
47+
48+
handle @landingBackupNames {
49+
import authelia_forward_auth
50+
rewrite * /webhook/backup/names
51+
import n8n_upstream
52+
}
53+
54+
handle {
55+
import authelia_forward_auth
56+
respond "landing static" 404
57+
}
58+
}
59+
}
60+
61+
http://workflow.test {
62+
@apiMe {
63+
method GET HEAD
64+
path /api/me
65+
}
66+
67+
@oauth2Credential {
68+
method GET HEAD OPTIONS
69+
path /rest/oauth2-credential*
70+
}
71+
72+
@telegramReissueWebhook {
73+
method POST
74+
header X-Telegram-Bot-Api-Secret-Token {$TELEGRAM_WEBHOOK_SECRET}
75+
path /webhook/scanservjs/telegram/reissue /webhook-test/scanservjs/telegram/reissue
76+
}
77+
78+
@alexaGeminiWebhook {
79+
method POST
80+
path /webhook/antannah/alexa-gemini /webhook-test/antannah/alexa-gemini /webhook/sven/alexa-gemini /webhook-test/sven/alexa-gemini
81+
}
82+
83+
# In this HTTP-only e2e setup, mTLS vars are empty, so all webhooks go via Authelia unless explicit exceptions above.
84+
@webhooksAuthelia {
85+
path /webhook/* /webhook-test/*
86+
}
87+
88+
route {
89+
handle @apiMe {
90+
import authelia_forward_auth
91+
rewrite * /webhook/landing/api/me
92+
import n8n_upstream
93+
}
94+
95+
handle @oauth2Credential {
96+
import n8n_upstream
97+
}
98+
99+
handle @telegramReissueWebhook {
100+
import n8n_upstream
101+
}
102+
103+
handle @alexaGeminiWebhook {
104+
import n8n_upstream
105+
}
106+
107+
handle @webhooksAuthelia {
108+
import authelia_forward_auth
109+
import n8n_upstream
110+
}
111+
112+
handle {
113+
respond "workflow fallback" 404
114+
}
115+
}
116+
}

tests/e2e/docker-compose.e2e.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: caddy-rproxy-e2e
2+
3+
services:
4+
caddy-e2e:
5+
image: caddy:2.11.3@sha256:ec18ee54aab3315c22e25f3b2babda73ff8007d39b13b3bd1bfffa2f0444c7d9
6+
environment:
7+
TELEGRAM_WEBHOOK_SECRET: test-telegram-secret
8+
volumes:
9+
- ./Caddyfile:/etc/caddy/Caddyfile:ro
10+
ports:
11+
- "18080:80"
12+
depends_on:
13+
- authelia
14+
- n8n-mock
15+
16+
authelia:
17+
image: python:3.12-alpine
18+
working_dir: /app
19+
volumes:
20+
- ./mock_auth.py:/app/mock_auth.py:ro
21+
command: ["python3", "/app/mock_auth.py"]
22+
23+
n8n-mock:
24+
image: python:3.12-alpine
25+
working_dir: /app
26+
volumes:
27+
- ./mock_n8n.py:/app/mock_n8n.py:ro
28+
command: ["python3", "/app/mock_n8n.py"]

tests/e2e/mock_auth.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
#!/usr/bin/env python3
2+
from http.server import BaseHTTPRequestHandler, HTTPServer
3+
4+
class Handler(BaseHTTPRequestHandler):
5+
def do_GET(self):
6+
self._handle()
7+
8+
def do_POST(self):
9+
self._handle()
10+
11+
def _handle(self):
12+
if self.path != "/api/authz/forward-auth":
13+
self.send_response(404)
14+
self.end_headers()
15+
self.wfile.write(b"not found")
16+
return
17+
18+
allowed = self.headers.get("X-Test-Auth", "") == "allow"
19+
if not allowed:
20+
self.send_response(401)
21+
self.send_header("Content-Type", "text/plain")
22+
self.end_headers()
23+
self.wfile.write(b"unauthorized")
24+
return
25+
26+
self.send_response(200)
27+
self.send_header("Content-Type", "text/plain")
28+
self.send_header("Remote-User", "test-user")
29+
self.send_header("Remote-Groups", "admins")
30+
self.send_header("Remote-Email", "test@example.com")
31+
self.send_header("Remote-Name", "Test User")
32+
self.end_headers()
33+
self.wfile.write(b"ok")
34+
35+
def log_message(self, fmt, *args):
36+
return
37+
38+
if __name__ == "__main__":
39+
HTTPServer(("0.0.0.0", 9091), Handler).serve_forever()

tests/e2e/mock_n8n.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
#!/usr/bin/env python3
2+
import json
3+
from http.server import BaseHTTPRequestHandler, HTTPServer
4+
5+
class Handler(BaseHTTPRequestHandler):
6+
def _send_json(self, code, payload):
7+
data = json.dumps(payload).encode("utf-8")
8+
self.send_response(code)
9+
self.send_header("Content-Type", "application/json")
10+
self.send_header("X-Upstream", "n8n-mock")
11+
self.send_header("Content-Length", str(len(data)))
12+
self.end_headers()
13+
self.wfile.write(data)
14+
15+
def do_GET(self):
16+
self._handle()
17+
18+
def do_HEAD(self):
19+
self._handle(head=True)
20+
21+
def do_POST(self):
22+
self._handle()
23+
24+
def _handle(self, head=False):
25+
path = self.path
26+
if path == "/webhook/backup/status-public":
27+
payload = {"status": "ok", "backupName": "paperless", "checkedAt": "2026-05-23T00:00:00Z"}
28+
if head:
29+
self.send_response(200)
30+
self.send_header("Content-Type", "application/json")
31+
self.send_header("X-Upstream", "n8n-mock")
32+
self.end_headers()
33+
else:
34+
self._send_json(200, payload)
35+
return
36+
37+
if path == "/webhook/backup/names":
38+
payload = {"count": 1, "backupNames": ["paperless"], "backups": [{"backupName": "paperless", "status": "ok"}]}
39+
if head:
40+
self.send_response(200)
41+
self.send_header("Content-Type", "application/json")
42+
self.send_header("X-Upstream", "n8n-mock")
43+
self.end_headers()
44+
else:
45+
self._send_json(200, payload)
46+
return
47+
48+
if path == "/webhook/landing/api/me":
49+
payload = {"user": "test-user", "groups": ["admins"], "role": "admin"}
50+
if head:
51+
self.send_response(200)
52+
self.send_header("Content-Type", "application/json")
53+
self.send_header("X-Upstream", "n8n-mock")
54+
self.end_headers()
55+
else:
56+
self._send_json(200, payload)
57+
return
58+
59+
# Generic webhook sink for protected workflow endpoints.
60+
if path.startswith("/webhook/") or path.startswith("/webhook-test/") or path.startswith("/rest/oauth2-credential"):
61+
payload = {"ok": True, "path": path, "method": self.command}
62+
if head:
63+
self.send_response(200)
64+
self.send_header("Content-Type", "application/json")
65+
self.send_header("X-Upstream", "n8n-mock")
66+
self.end_headers()
67+
else:
68+
self._send_json(200, payload)
69+
return
70+
71+
self._send_json(404, {"error": "not found", "path": path})
72+
73+
def log_message(self, fmt, *args):
74+
return
75+
76+
if __name__ == "__main__":
77+
HTTPServer(("0.0.0.0", 5678), Handler).serve_forever()

0 commit comments

Comments
 (0)