Skip to content

Commit b42e276

Browse files
Merge remote-tracking branch 'origin/main' into hetzner
2 parents 3ae5263 + 4659d3b commit b42e276

30 files changed

Lines changed: 2052 additions & 132 deletions

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
<!-- version list -->
99

10+
## v1.1.6 (2026-07-02)
11+
12+
### Bug Fixes
13+
14+
- Add Postgres-backed rate limiter for multi-worker deployments
15+
([#222](https://github.com/Promptly-Technologies-LLC/fastapi-jinja2-postgres-webapp/pull/222),
16+
[`d46bddf`](https://github.com/Promptly-Technologies-LLC/fastapi-jinja2-postgres-webapp/commit/d46bddf8f6dbf33e7b573d1175ab0a1d16ae52ad))
17+
18+
- Remove duplicate get_client_ip definition
19+
([#222](https://github.com/Promptly-Technologies-LLC/fastapi-jinja2-postgres-webapp/pull/222),
20+
[`d46bddf`](https://github.com/Promptly-Technologies-LLC/fastapi-jinja2-postgres-webapp/commit/d46bddf8f6dbf33e7b573d1175ab0a1d16ae52ad))
21+
22+
1023
## v1.1.5 (2026-07-02)
1124

1225
### Bug Fixes

pyproject.toml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "fastapi-jinja2-postgres-webapp"
3-
version = "1.1.5"
3+
version = "1.1.6"
44
description = "A template webapp with a pure-Python FastAPI backend, frontend templating with Jinja2, and a Postgres database to power user auth"
55
readme = "README.md"
66
package-mode = false
@@ -39,6 +39,10 @@ dev = [
3939
"great-docs>=0.14.1",
4040
]
4141

42+
[tool.ruff.lint.per-file-ignores]
43+
# pytest_plugins must be declared before plugin imports in conftest.
44+
"tests/conftest.py" = ["E402"]
45+
4246
[tool.ty.rules]
4347
# SQLModel/SQLAlchemy metaclass attributes (__table__) and constructor
4448
# patterns are not understood by ty, nor is pytest's @_with_exception
@@ -121,3 +125,10 @@ insecure = false
121125
[tool.semantic_release.publish]
122126
dist_glob_patterns = []
123127
upload_to_vcs_release = false
128+
129+
[tool.pytest-jinja-check]
130+
app = "main:app"
131+
python_dir = "."
132+
template_dir = "templates"
133+
route_file_patterns = ["main.py", "routers/**/*.py"]
134+
auto_check = false

templates/users/macros/organizations.html

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,7 @@
1111
<!-- New Organization Form -->
1212
<div class="collapse mb-3" id="newOrgForm">
1313
<form action="{{ url_for('create_organization') }}" method="post" class="border rounded p-3 bg-light">
14-
{% include 'base/partials/csrf_field.html' %}
15-
{% include 'base/partials/csrf_field.html' %}
14+
{% include 'base/partials/csrf_field.html' %}
1615
<div class="mb-3">
1716
<label for="name" class="form-label">Organization Name</label>
1817
<input type="text"

templates/users/profile.html

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,7 @@ <h1 class="mb-4">User Profile</h1>
9696
<div class="card-body">
9797
{% if show_form %}
9898
<form action="{{ url_for('forgot_password') }}" method="post">
99-
{% include 'base/partials/csrf_field.html' %}
100-
{% include 'base/partials/csrf_field.html' %}
99+
{% include 'base/partials/csrf_field.html' %}
101100
<input type="hidden" name="email" value="{{ user.account.email }}">
102101
<p>To change your password, please confirm your email. A password reset link will be sent to your email address.</p>
103102
<button type="submit" class="btn btn-primary">Send Password Reset Email</button>
@@ -118,8 +117,7 @@ <h1 class="mb-4">User Profile</h1>
118117
</div>
119118
<div class="card-body">
120119
<form action="{{ url_for('delete_account') }}" method="post">
121-
{% include 'base/partials/csrf_field.html' %}
122-
{% include 'base/partials/csrf_field.html' %}
120+
{% include 'base/partials/csrf_field.html' %}
123121
<p class="text-danger">This action cannot be undone. Please confirm your email and password to delete your account.</p>
124122
<div class="mb-3">
125123
<label for="delete_email" class="form-label">Email</label>

tests/browser/conftest.py

Lines changed: 133 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
1-
import pytest
2-
import subprocess
3-
import time
41
import os
52
import socket
6-
from dotenv import load_dotenv
3+
import subprocess
4+
import time
5+
from contextlib import contextmanager
6+
7+
import pytest
8+
from playwright.sync_api import Browser, Page
9+
from tests.browser.db_helpers import browser_db_env
710
from utils.core.db import (
11+
ensure_database_exists,
812
get_connection_url,
913
set_up_db,
1014
tear_down_db,
11-
ensure_database_exists,
1215
)
1316

1417

@@ -17,49 +20,145 @@ def _port_free(port: int) -> bool:
1720
return s.connect_ex(("127.0.0.1", port)) != 0
1821

1922

20-
@pytest.fixture(scope="session")
21-
def browser_env():
22-
"""Build an environment dict for the live server subprocess."""
23-
load_dotenv()
24-
env = os.environ.copy()
25-
env["DB_NAME"] = "webapp-browser-test-db"
26-
env["SECRET_KEY"] = "testsecretkey-that-is-at-least-32-bytes-long"
27-
env["HOST_NAME"] = "Test Organization"
28-
env["RESEND_API_KEY"] = "test"
29-
env["EMAIL_FROM"] = "test@example.com"
30-
env["BASE_URL"] = "http://127.0.0.1:8113"
31-
return env
23+
@contextmanager
24+
def _temporary_env(env: dict[str, str]):
25+
"""Apply env vars for DB helpers without leaking into the pytest process."""
26+
saved = {key: os.environ.get(key) for key in env}
27+
os.environ.update(env)
28+
try:
29+
yield
30+
finally:
31+
for key, value in saved.items():
32+
if value is None:
33+
os.environ.pop(key, None)
34+
else:
35+
os.environ[key] = value
3236

3337

34-
@pytest.fixture(scope="session")
35-
def live_server(browser_env):
36-
"""Start a uvicorn server for Playwright tests and return the base URL."""
37-
# Apply env so our DB helpers use the right database
38-
os.environ.update(browser_env)
38+
def register_user(
39+
browser: Browser,
40+
live_server: str,
41+
*,
42+
name: str,
43+
email: str,
44+
password: str,
45+
) -> None:
46+
"""Register a user through the live server UI (session-scoped helper)."""
47+
context = browser.new_context(viewport={"width": 1280, "height": 720})
48+
page = context.new_page()
49+
page.goto(f"{live_server}/account/register")
50+
page.fill("#name", name)
51+
page.fill("#email", email)
52+
page.fill("#password", password)
53+
page.fill("#confirm_password", password)
54+
page.click('button[type="submit"]')
55+
page.wait_for_function(
56+
"window.location.pathname.startsWith('/dashboard')", timeout=10_000
57+
)
58+
context.close()
59+
3960

40-
ensure_database_exists(get_connection_url())
41-
set_up_db(drop=True)
61+
def login_user(
62+
browser: Browser,
63+
live_server: str,
64+
*,
65+
email: str,
66+
password: str,
67+
) -> Page:
68+
"""Log in via the live server and return a page on the dashboard."""
69+
context = browser.new_context(viewport={"width": 1280, "height": 720})
70+
page = context.new_page()
71+
page.goto(f"{live_server}/account/login")
72+
page.fill("#email", email)
73+
page.fill("#password", password)
74+
page.click('button[type="submit"]')
75+
page.wait_for_function(
76+
"window.location.pathname.startsWith('/dashboard')", timeout=10_000
77+
)
78+
return page
4279

43-
assert _port_free(8113), "Port 8113 already in use"
80+
81+
def _apply_rate_limit_env(env: dict[str, str]) -> dict[str, str]:
82+
env["LOGIN_IP_LIMIT"] = "500"
83+
env["LOGIN_EMAIL_LIMIT"] = "500"
84+
env["REGISTER_IP_LIMIT"] = "500"
85+
env["FORGOT_PASSWORD_IP_LIMIT"] = "500"
86+
env["FORGOT_PASSWORD_EMAIL_LIMIT"] = "500"
87+
return env
88+
89+
90+
def _start_live_server(env: dict[str, str], port: int) -> subprocess.Popen:
91+
assert _port_free(port), f"Port {port} already in use"
92+
with _temporary_env(env):
93+
ensure_database_exists(get_connection_url())
94+
set_up_db(drop=True)
4495

4596
proc = subprocess.Popen(
46-
["uv", "run", "uvicorn", "main:app", "--host", "127.0.0.1", "--port", "8113"],
47-
env=browser_env,
97+
[
98+
"uv",
99+
"run",
100+
"uvicorn",
101+
"main:app",
102+
"--host",
103+
"127.0.0.1",
104+
"--port",
105+
str(port),
106+
],
107+
env=env,
48108
stdout=subprocess.PIPE,
49109
stderr=subprocess.PIPE,
50110
)
51111

52-
# Wait for server to be ready
53112
for _ in range(30):
54-
if not _port_free(8113):
113+
if not _port_free(port):
55114
break
56115
time.sleep(0.5)
57116
else:
58117
proc.terminate()
59-
raise RuntimeError("Server did not start within 15 seconds")
118+
raise RuntimeError(f"Server did not start on port {port} within 15 seconds")
119+
return proc
60120

61-
yield "http://127.0.0.1:8113"
62121

63-
proc.terminate()
64-
proc.wait(timeout=5)
65-
tear_down_db()
122+
@pytest.fixture(scope="session")
123+
def browser_env():
124+
"""Build an environment dict for the live server subprocess."""
125+
env = _apply_rate_limit_env(browser_db_env())
126+
env["BASE_URL"] = "http://127.0.0.1:8113"
127+
env["CSRF_ENABLED"] = "0"
128+
return env
129+
130+
131+
@pytest.fixture(scope="session")
132+
def browser_csrf_env():
133+
"""Live-server env with CSRF protection enabled (separate port/DB)."""
134+
env = _apply_rate_limit_env(browser_db_env())
135+
env["DB_NAME"] = "webapp-browser-csrf-test-db"
136+
env["BASE_URL"] = "http://127.0.0.1:8114"
137+
env["CSRF_ENABLED"] = "1"
138+
return env
139+
140+
141+
@pytest.fixture(scope="session")
142+
def live_server(browser_env):
143+
"""Start a uvicorn server for Playwright tests and return the base URL."""
144+
proc = _start_live_server(browser_env, 8113)
145+
try:
146+
yield "http://127.0.0.1:8113"
147+
finally:
148+
proc.terminate()
149+
proc.wait(timeout=5)
150+
with _temporary_env(browser_env):
151+
tear_down_db()
152+
153+
154+
@pytest.fixture(scope="session")
155+
def live_server_csrf(browser_csrf_env):
156+
"""Live server with CSRF_ENABLED=1 for CSRF browser tests."""
157+
proc = _start_live_server(browser_csrf_env, 8114)
158+
try:
159+
yield "http://127.0.0.1:8114"
160+
finally:
161+
proc.terminate()
162+
proc.wait(timeout=5)
163+
with _temporary_env(browser_csrf_env):
164+
tear_down_db()

0 commit comments

Comments
 (0)