Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/dev-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,11 @@ jobs:
exit 1
fi &&

if ! python3 scripts/check_dev_routes.py https://dev.procollab.ru; then
echo "Frontend/API routing smoke check failed" >&2
exit 1
fi &&

docker compose -f docker-compose.dev-ci.yml ps

celery_status=""
Expand Down
27 changes: 27 additions & 0 deletions .github/workflows/dev-nginx-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: DEV Nginx routing

on:
pull_request:
branches: [dev]
paths:
- deploy/nginx/host/dev/**
- deploy/nginx/host/includes/**
- scripts/check_dev_routes.py
- scripts/tests/test_dev_nginx_routes.py
- .github/workflows/dev-nginx-ci.yml
workflow_dispatch:

permissions:
contents: read

jobs:
routing:
name: SPA and API routes / Nginx
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Install Nginx
run: sudo apt-get update -qq && sudo apt-get install -y nginx
- name: Test actual DEV Nginx config with disposable SPA and API
run: python3 -m unittest discover -s scripts/tests -p test_dev_nginx_routes.py -v
10 changes: 10 additions & 0 deletions deploy/nginx/host/dev/dev.procollab.ru
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ server {

client_max_body_size 100M;

# Angular auth pages share /auth with Django API endpoints. Only page reads
# go to the SPA; POST password reset requests must still reach Django.
# Keep this allowlist before the broader API regex (first regex wins).
location ~ ^/auth(/(login|register|verification(/email)?|reset_password(/(send_email|confirm))?))?/?$ {
if ($request_method ~ ^(GET|HEAD)$) {
rewrite ^ /index.html last;
}
include /etc/nginx/procollab/includes/proxy_app.inc;
}

location ~ ^/(admin|api-auth|files|industries|news|projects|vacancies|core|invites|auth|chats|events|programs|courses|rate-project|evaluation|feed|api|anymail|ws)(/|$) {
include /etc/nginx/procollab/includes/proxy_app.inc;
}
Expand Down
63 changes: 63 additions & 0 deletions scripts/check_dev_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Read-only SPA/API routing smoke check (empty reset payloads send no mail)."""

import argparse
import json
import sys
from urllib.error import HTTPError
from urllib.request import Request, urlopen


AUTH_PAGES = (
"/auth",
"/auth/login",
"/auth/register",
"/auth/verification",
"/auth/verification/email",
"/auth/reset_password",
"/auth/reset_password/send_email",
"/auth/reset_password/confirm",
)


def request(base_url, path, method="GET", context=None):
data = b"{}" if method == "POST" else None
req = Request(base_url + path, data=data, method=method, headers={"Content-Type": "application/json"})
try:
response = urlopen(req, timeout=15, context=context)
except HTTPError as error:
response = error
with response:
return response.status, response.headers.get_content_type(), response.read()


def check_routes(base_url, context=None):
status, content_type, index = request(base_url, "/", context=context)
assert status == 200 and content_type == "text/html" and b"<app-root" in index, "SPA index unavailable"
for path in (*AUTH_PAGES, "/office/program/all", "/expert-password-reset/"):
for suffix in ("", "/") if not path.endswith("/") else ("",):
page = path + suffix + "?routing_check=1"
for method in ("GET", "HEAD"):
status, content_type, body = request(base_url, page, method, context)
assert status == 200 and content_type == "text/html", (method, page, status, content_type)
assert body == (index if method == "GET" else b""), (method, page, "not the SPA index")
sys.stdout.write(f"SPA GET/HEAD OK: {path}\n")

# These invalid requests cannot send reset email or change a password.
for path, method, expected in (
("/auth/users/current/", "GET", 401),
("/auth/reset_password/", "POST", 400),
("/auth/reset_password/confirm/", "POST", 400),
("/auth/reset_password/validate_token/", "POST", 400),
("/api/token/", "POST", 400),
):
status, content_type, body = request(base_url, path, method, context)
assert status == expected and content_type == "application/json", (method, path, status, content_type)
json.loads(body)
sys.stdout.write(f"API {method} OK: {path} ({status})\n")


if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("base_url")
args = parser.parse_args()
check_routes(args.base_url.rstrip("/"))
119 changes: 119 additions & 0 deletions scripts/tests/test_dev_nginx_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""Exercise the checked-in server config with real Nginx, without Django/DB."""

from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
from pathlib import Path
import shutil
import socket
import ssl
import subprocess
import sys
from tempfile import TemporaryDirectory
from threading import Thread
import time
import unittest

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from check_dev_routes import check_routes, request # noqa: E402


class Backend(BaseHTTPRequestHandler):
def respond(self, status):
body = json.dumps({"backend_method": self.command, "backend_path": self.path}).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)

def do_GET(self):
self.respond(401 if self.path == "/auth/users/current/" else 404)

def do_POST(self):
self.rfile.read(int(self.headers.get("Content-Length", 0)))
self.respond(400)

def log_message(self, *args):
pass


def free_port():
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]


@unittest.skipUnless(shutil.which("nginx"), "Nginx is required; exercised in DEV Nginx routing CI")
class DevRoutingTests(unittest.TestCase):
def test_spa_reads_and_api_requests(self):
repo = Path(__file__).resolve().parents[2]
with TemporaryDirectory(prefix="dev-nginx-routing-") as directory:
root = Path(directory)
(root / "logs").mkdir()
temp_paths = "".join(
f"{name}_temp_path {root}/{name};\n"
for name in ("client_body", "proxy", "fastcgi", "uwsgi", "scgi")
)
(root / "index.html").write_text("<!doctype html><app-root></app-root>")
(root / "asset.js").write_text("window.fixture = true;")
subprocess.run([
"openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1",
"-subj", "/CN=localhost", "-keyout", str(root / "key.pem"), "-out", str(root / "cert.pem"),
], check=True, capture_output=True)
backend = ThreadingHTTPServer(("127.0.0.1", 0), Backend)
Thread(target=backend.serve_forever, daemon=True).start()
self.addCleanup(backend.server_close)
self.addCleanup(backend.shutdown)
http_port, https_port = free_port(), free_port()
proxy = (repo / "deploy/nginx/host/includes/proxy_app.inc").read_text()
proxy = proxy.replace("127.0.0.1:8000", f"127.0.0.1:{backend.server_port}")
(root / "proxy_app.inc").write_text(proxy)
config = (repo / "deploy/nginx/host/dev/dev.procollab.ru").read_text()
# Change only test environment paths/ports, retaining all routing rules.
replacements = {
"listen 80;": f"listen 127.0.0.1:{http_port};",
"listen 443 ssl;": f"listen 127.0.0.1:{https_port} ssl;",
"/home/front/app": root.as_posix(),
"/etc/nginx/procollab/includes/proxy_app.inc": (root / "proxy_app.inc").as_posix(),
"/etc/letsencrypt/live/dev.procollab.ru-0001/fullchain.pem": (root / "cert.pem").as_posix(),
"/etc/letsencrypt/live/dev.procollab.ru-0001/privkey.pem": (root / "key.pem").as_posix(),
}
for source, target in replacements.items():
config = config.replace(source, target)
config = (
f"pid {root}/nginx.pid;\nerror_log stderr;\nevents {{}}\n"
f"http {{\naccess_log off;\n{temp_paths}{config}\n}}\n"
)
config_path = root / "nginx.conf"
config_path.write_text(config)
command = ["nginx", "-p", root.as_posix() + "/", "-c", str(config_path)]
subprocess.run([*command, "-t"], check=True)
with (root / "nginx.log").open("w+") as log:
nginx = subprocess.Popen([*command, "-g", "daemon off;"], stdout=log, stderr=log)
try:
context = ssl._create_unverified_context() # Disposable self-signed certificate only.
base_url = f"https://127.0.0.1:{https_port}"
for _ in range(50):
try:
request(base_url, "/", context=context)
break
except OSError:
if nginx.poll() is not None:
log.seek(0)
self.fail(log.read())
time.sleep(0.1)
check_routes(base_url, context)
# Unknown auth URLs and protected APIs never fall through to SPA.
for path in ("/auth/unknown/", "/auth/login/unrecognized/", "/programs/6/"):
status, content_type, body = request(base_url, path, context=context)
self.assertEqual((status, content_type), (404, "application/json"))
self.assertEqual(json.loads(body)["backend_path"], path)
status, _, body = request(base_url, "/asset.js", context=context)
self.assertEqual((status, body), (200, b"window.fixture = true;"))
finally:
nginx.terminate()
nginx.wait(timeout=10)


if __name__ == "__main__":
unittest.main()
Loading