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
25 changes: 24 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,30 @@ jobs:
# No fix release as of 2026-04-25; only triggered by malicious package
# archives during install — we pin our own deps and don't install
# arbitrary user input, so the path isn't reachable in our runtime.
run: pip-audit --desc --skip-editable --ignore-vuln CVE-2026-4539 --ignore-vuln CVE-2026-3219 # pygments regex DoS + pip tar/zip confusion
#
# Starlette PYSEC-2026-161/248/249/2280/2281: all fixed in Starlette
# 1.x, but the codebase is pinned to starlette<0.51.0 because fastapi's
# TemplateResponse call sites use the pre-1.0 signature (tracked for
# the 1.x migration in issue #181). Reachability audited — none apply
# to the running app:
# - 161 (Host header -> request.url.path auth bypass): the only
# request.url.path security check lives in OAuth2BearerMiddleware,
# which is not mounted; no active middleware/route reads request.url
# for authorization.
# - 248 (path -> request.url.hostname): no request.url.hostname/netloc
# usage anywhere in the codebase.
# - 249 (form limits ignored for urlencoded): we never set
# max_fields/max_part_size, so no configured limit is bypassed;
# generic large-body DoS is bounded by the fronting proxy.
# - 2280 (HTTPEndpoint method confusion): no Starlette HTTPEndpoint
# subclasses — routes are FastAPI APIRouter handlers.
# - 2281 (StaticFiles SSRF on Windows): Linux-only deployment.
run: >-
pip-audit --desc --skip-editable
--ignore-vuln CVE-2026-4539 --ignore-vuln CVE-2026-3219
--ignore-vuln PYSEC-2026-161 --ignore-vuln GHSA-86qp-5c8j-p5mr
--ignore-vuln PYSEC-2026-248 --ignore-vuln PYSEC-2026-249
--ignore-vuln PYSEC-2026-2280 --ignore-vuln PYSEC-2026-2281

- name: Secret scan
run: |
Expand Down
3 changes: 3 additions & 0 deletions core/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ class User(Model):

username = fields.Text(unique=True)
email = fields.Text()
# Uniqueness enforced by partial index idx_users_email_lower
# (migration 013 in ui/auth/database.py), not the ORM

password_hash = fields.Text() # nullable = bot-only user
display_name = fields.Text()
avatar_url = fields.Text()
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ dependencies = [
"filelock>=3.13.0",
# Web / Admin UI
"fastapi>=0.131.0,<0.139.1",
"starlette>=0.38.0,<2.0.0",
"starlette>=0.40.0,<0.51.0",
"uvicorn>=0.27.0",
"jinja2>=3.1.0",
"itsdangerous>=2.1.0",
Expand Down
127 changes: 127 additions & 0 deletions tests/integration/test_token_invalidation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import os
from unittest.mock import patch

import pytest

DATABASE_URL = os.environ.get("TEST_DATABASE_URL", "")
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(not DATABASE_URL, reason="TEST_DATABASE_URL not set"),
]


@pytest.fixture(scope="module")
def pg_db():
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool

from core.config_models import PasswordToken
from core.database import DatabaseManager
from core.models.company import Company
from core.models.user import User
from core.orm.migrate import migrate_all
from core.orm.model import set_database as orm_set_database

dm = DatabaseManager(DATABASE_URL)
dm._sync_pool = ConnectionPool(
DATABASE_URL,
min_size=1,
max_size=3,
open=False,
kwargs={"row_factory": dict_row},
)
dm._sync_pool.open()

orm_set_database(dm)

with dm.acquire_sync() as conn:
conn.execute("CREATE SCHEMA IF NOT EXISTS admin")
conn.execute("CREATE SCHEMA IF NOT EXISTS app")
conn.execute(
"CREATE TABLE IF NOT EXISTS public._migrations ("
"id SERIAL PRIMARY KEY, name TEXT UNIQUE NOT NULL, "
"applied_at TIMESTAMPTZ DEFAULT NOW())"
)
# Stale environment fixup: this test DB may contain a leftover
# app.users table predating the current User model (PK on
# `username`, no `id` column). migrate_all() only ever ADDs
# columns to existing tables, it never fixes a wrong PK, so a
# stale table would break the PasswordToken.user_id FK to
# app.users(id). Drop and let migrate_all recreate it correctly.
conn.execute("DROP TABLE IF EXISTS app.password_tokens CASCADE")
conn.execute("DROP TABLE IF EXISTS app.users CASCADE")
# Dropping app.users also destroys the idx_users_email_lower
# index applied by ui.auth.database migration 013. Clear its
# marker so init_auth_db() (used by other integration tests
# sharing this DB) re-applies it against the recreated table.
conn.execute(
"DELETE FROM public._migrations WHERE name = %s",
("013_users_email_unique",),
)
conn.commit()

migrate_all([Company, User, PasswordToken], dm)

yield dm
dm._sync_pool.close()


def test_generate_token_invalidates_prior_unused_token(pg_db):
"""generate_token must invalidate prior unused reset tokens for the
same user+purpose. Regression for `("used_at", "=", None)` which is
SQL `used_at = NULL` (always false) instead of `used_at IS NULL`.
"""
from core.models.user import User

with patch("core.registry.get_database", return_value=pg_db):
user = User.create_sync(
username="token_invalidation_test_user",
password_hash="x",
is_active=True,
)
user_id = user["id"] if isinstance(user, dict) else user

try:
from ui.auth.invite import generate_token

t1 = generate_token(user_id, "reset")
t2 = generate_token(user_id, "reset")

assert t1 != t2

with pg_db.acquire_sync() as conn:
rows = conn.execute(
"SELECT used_at FROM app.password_tokens "
"WHERE user_id = %s AND purpose = 'reset' "
"ORDER BY created_at",
(user_id,),
).fetchall()

assert len(rows) == 2
assert rows[0]["used_at"] is not None, (
"first token should have been invalidated by the second "
"generate_token() call"
)
assert rows[1]["used_at"] is None

unused_count_row = None
with pg_db.acquire_sync() as conn:
unused_count_row = conn.execute(
"SELECT count(*) AS c FROM app.password_tokens "
"WHERE user_id = %s AND purpose = 'reset' "
"AND used_at IS NULL",
(user_id,),
).fetchone()

assert unused_count_row["c"] == 1
finally:
with pg_db.acquire_sync() as conn:
conn.execute(
"DELETE FROM app.password_tokens WHERE user_id = %s",
(user_id,),
)
conn.execute(
"DELETE FROM app.users WHERE id = %s",
(user_id,),
)
conn.commit()
95 changes: 95 additions & 0 deletions tests/integration/test_users_email_unique.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import os

import pytest

DATABASE_URL = os.environ.get("TEST_DATABASE_URL", "")
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(not DATABASE_URL, reason="TEST_DATABASE_URL not set"),
]


@pytest.fixture(scope="module")
def pg_db():
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool

from core.database import DatabaseManager
from core.models.company import Company
from core.models.user import User
from core.orm.migrate import migrate_all
from core.orm.model import set_database as orm_set_database

dm = DatabaseManager(DATABASE_URL)
dm._sync_pool = ConnectionPool(
DATABASE_URL,
min_size=1,
max_size=3,
open=False,
kwargs={"row_factory": dict_row},
)
dm._sync_pool.open()

orm_set_database(dm)

with dm.acquire_sync() as conn:
conn.execute("CREATE SCHEMA IF NOT EXISTS admin")
conn.execute("CREATE SCHEMA IF NOT EXISTS app")
conn.execute(
"CREATE TABLE IF NOT EXISTS public._migrations ("
"id SERIAL PRIMARY KEY, name TEXT UNIQUE NOT NULL, "
"applied_at TIMESTAMPTZ DEFAULT NOW())"
)
conn.commit()

# Ensure app.users exists (ORM-managed) before the migration under test
# creates a unique index against it. Company must be migrated first —
# User.company_id is a FK to app.companies.
migrate_all([Company, User], dm)

yield dm
dm._sync_pool.close()


def test_migration_creates_partial_unique_index_and_is_idempotent(pg_db):
from unittest.mock import patch

# init_auth_db() does `from core.registry import get_database` INSIDE the
# function body, so patching core.registry.get_database is sufficient —
# the local import resolves the attribute on core.registry at call time.
# ui.auth.database has no module-level get_database name to patch.
with patch("core.registry.get_database", return_value=pg_db):
from ui.auth.database import init_auth_db

init_auth_db()
init_auth_db() # second call must not raise

with pg_db.acquire_sync() as conn:
row = conn.execute(
"SELECT 1 FROM pg_indexes WHERE schemaname='app' "
"AND indexname='idx_users_email_lower'"
).fetchone()
assert row is not None


def test_create_user_duplicate_email_rejected(pg_db):
"""Two users sharing an email (case-insensitive) must be rejected at DB level."""
import psycopg

with pg_db.acquire_sync() as conn:
conn.execute(
"INSERT INTO app.users (username, email, is_active) "
"VALUES ('dup_a', 'dup@example.com', true) "
"ON CONFLICT (username) DO NOTHING"
)
conn.commit()
with pg_db.acquire_sync() as conn:
with pytest.raises(psycopg.errors.UniqueViolation):
conn.execute(
"INSERT INTO app.users (username, email, is_active) "
"VALUES ('dup_b', 'DUP@example.com', true)"
)
conn.commit()
with pg_db.acquire_sync() as conn:
conn.execute("DELETE FROM app.users WHERE username IN ('dup_a','dup_b')")
conn.commit()
105 changes: 105 additions & 0 deletions tests/unit/test_forgot_password_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import pytest


@pytest.fixture(autouse=True)
def _reset_rate_limiter():
# rate_limiter is a module-level singleton; clear it so per-IP/per-account
# counts don't accumulate across tests in the same process.
from ui.rate_limit import rate_limiter

rate_limiter._requests.clear()
yield
rate_limiter._requests.clear()


def test_password_reset_category_exists():
from ui.rate_limit import RATE_LIMITS

cfg = RATE_LIMITS["password_reset"]
assert cfg.requests == 3
assert cfg.window == 300


def test_check_rate_limit_key_trips_after_threshold():
from ui.rate_limit import RateLimiter

limiter = RateLimiter()
key = "acct:someone@example.com"
allowed = [limiter.is_allowed_key(key, "password_reset")[0] for _ in range(4)]
assert allowed == [True, True, True, False]


def _client():
# Route-logic test only: no CSRFMiddleware here. The POST is sessionless,
# which the CSRF middleware skips by design (ui/csrf.py). In production the
# GET creates a session + token and the template's csrf_field submits it,
# same as /auth/setup-password (also not CSRF-exempt).
from fastapi import FastAPI
from fastapi.testclient import TestClient
from starlette.middleware.sessions import SessionMiddleware

from ui.routes.auth import router

app = FastAPI()
app.add_middleware(SessionMiddleware, secret_key="test-key")
app.include_router(router, prefix="/auth")
return TestClient(app)


def test_get_forgot_password_renders_form():
client = _client()
resp = client.get("/auth/forgot-password")
assert resp.status_code == 200
assert 'name="email"' in resp.text
assert "csrf" in resp.text.lower()


def test_post_known_and_unknown_email_identical_response():
from unittest.mock import patch

client = _client()
with patch("ui.routes.auth.request_password_reset") as mock_req:
mock_req.return_value = None
r_known = client.post(
"/auth/forgot-password", data={"email": "davide.corio@dubhe.it"}
)
r_unknown = client.post(
"/auth/forgot-password", data={"email": "nobody@example.com"}
)
assert r_known.status_code == r_unknown.status_code == 200
assert r_known.text == r_unknown.text


def test_post_uses_configured_base_url(monkeypatch):
from unittest.mock import patch

monkeypatch.setenv("GRIDBEAR_BASE_URL", "https://gridbear.example.com")
client = _client()
with patch("ui.routes.auth.request_password_reset") as mock_req:
mock_req.return_value = None
client.post("/auth/forgot-password", data={"email": "x@example.com"})
# The BackgroundTask runs during response teardown under TestClient.
args, _ = mock_req.call_args
assert args[1] == "https://gridbear.example.com"


def test_post_falls_back_to_request_base_url(monkeypatch):
from unittest.mock import patch

monkeypatch.delenv("GRIDBEAR_BASE_URL", raising=False)
client = _client()
with patch("ui.routes.auth.request_password_reset") as mock_req:
mock_req.return_value = None
client.post("/auth/forgot-password", data={"email": "x@example.com"})
args, _ = mock_req.call_args
assert args[1].startswith("http://testserver")


def test_setup_password_page_reflects_min_length():
client = _client()
resp = client.get(
"/auth/setup-password"
) # no token → renders form with an error banner
assert resp.status_code == 200
assert 'minlength="12"' in resp.text
assert 'minlength="8"' not in resp.text
Loading